跳到正文
vLLM Blog· vLLM Team·· 27 天前AI 评分34

vLLM 在 GB300 NVL72 上 PD 部署 Qwen3.8-2.4T 的性能结果

PD Serving of Qwen3.8-2.4T

AI 导读

vLLM 在 GB300 NVL72 集群上对 Qwen3.8-2.4T 做 PD 分离部署,8K/1K 负载下高吞吐场景达到每 GPU 5000 总 token 吞吐,低延迟场景每用户生成 180 token,并给出完整 pareto 前沿。

正文 · AI 翻译

TL;DR

在这篇博客文章中,我们展示了在 GB300 NVL72 集群上使用 vLLM 对 Qwen3.8-2.4T 模型进行 PD 服务的最新性能结果,工作负载为 8K/1K。在高吞吐场景下,vLLM 实现了每 GPU 5000 的总 token 吞吐量,而在低延迟场景下,每个用户生成 180 个 token——两者均展示在下面的 帕累托前沿 上。在这篇博客文章中,我们详细解释了如何取得这些结果,并给出了精确的 srt-slurm 配方,以便任何人都能使用本地服务来验证和复现它们。但更重要的是——我们描述了用于创建这些配方的决策过程。这甚至比实际性能结果更重要,因为它让你能够为你想要的任何模型从 vLLM 上的 PD 服务中榨取性能。

引言

最近,我们展示了 Qwen3.5 PD 服务的 结果,并表明 vLLM 可以达到 25K Total TPS/GPU。紧接着,Qwen 家族的新前沿模型发布了:Qwen3.8-2.4T。在这篇文章中,我们转向 Qwen3.8-2.4T 的 PD 服务,并展示完整的帕累托前沿。

在我们之前对 Qwen3.5 的研究中,我们主要关注帕累托曲线的左侧部分,目标是最大化 Total TPS/GPU。这一次,我们更进一步,构建完整的帕累托前沿,涵盖其左侧(高吞吐)和右侧(低延迟)部分。换句话说,除了面向吞吐的配置外,我们还确定了用于最大化交互性(即每用户 Gen TPS)的最优 PD 配方。

同样重要的是,这篇博客文章不仅仅关乎最终数字。我们还逐步拆解了调优过程:我们如何选择要测量的内容、如何识别瓶颈,以及如何迭代以获得更好的 PD 配置。我们的目标是让这一工作流程易于理解,并可供任何尝试为其他模型调优服务性能的人复用。

最大化吞吐量

我们要最大化的第一个指标是每 GPU 的总 token 吞吐量。该指标取决于并发度:一个解码引擎同时服务的请求越多,我们获得的吞吐量就越高。 限制并发度的是 KV 缓存容量,因为每个活跃请求都占用其中一部分。因此,KV 缓存大小是任何拓扑首先要回答的问题,它分为两部分:单个请求消耗多少缓存,以及在加载模型权重并为 vLLM 服务器分配自身工作空间后,GPU 上还剩多少。本章的其余部分将逐一分析这两部分,并得出 Qwen3.8-2.4T 在单个 GB300 上理论上可达到的最大并发度。

单请求的 KV 缓存估算

Qwen3.8-2.4T 有 92 层:69 个 GDN 层和 23 个 Full-Attn 层,每层都有一个 MoE 块(92 × 512 个专家)。GDN 和 Full-Attn 为 BF16,MoE 为 NVFP4。权重分类如下:

  • 91 GiB——模型的非专家权重(GDN 权重 60 GiB,Full-Attn 权重 19 GiB)
  • 1242 GiB——模型的专家权重

由于 Qwen3.8-2.4T 是混合模型,其状态分为两部分,且计算方式截然不同:Full-Attn 状态随每个 token 增长,而 GDN 状态按请求存储。

Full-Attn 状态按 token 存储。一个 token 每层需要 2 KiB:

Full-Attn state per token: num_key_value_heads 4 × head_dim 256 × 1 byte for float8_e4m3fn, times 2 for K and V, giving 2048 B = 2 KiB.
每 token 的 Full-Attn 状态:num_key_value_heads 4 × head_dim 256 × 1 字节(float8_e4m3fn),再乘以 2(K 和 V),得到 2048 B = 2 KiB。

GDN 状态按请求存储,而不是按 token 存储,这一点很重要。它由两部分组成。

  • Conv state — 因果卷积的最后 kernel-1 个输入,每个通道一个滤波器,其中通道是 q、k 和 v 拼接而成:
GDN conv state per request: for q and k, linear_key_head_dim 128 × linear_num_key_heads 16 × 2 for both q and k, plus for v, linear_value_head_dim 128 × linear_num_value_heads 128; all times linear_conv_kernel_dim minus 1, which is 3, times 2 bytes for bfloat16, giving 122,880 B = 120 KiB.
每个请求的 GDN conv state:对于 q 和 k,linear_key_head_dim 128 × linear_num_key_heads 16 × 2(q 和 k 各一份),再加上 v 的 linear_value_head_dim 128 × linear_num_value_heads 128;全部乘以 linear_conv_kernel_dim 减 1,即 3,再乘以 bfloat16 的 2 字节,得到 122,880 B = 120 KiB。
  • SSM state — 保存整个上下文的循环矩阵:
GDN SSM state per request: linear_num_value_heads 128 × linear_value_head_dim 128 × linear_key_head_dim 128, times 2 bytes for bfloat16, giving 4,194,304 B = 4 MiB.
每个请求的 GDN SSM state:linear_num_value_heads 128 × linear_value_head_dim 128 × linear_key_head_dim 128,乘以 bfloat16 的 2 字节,得到 4,194,304 B = 4 MiB。

总结起来,GDN state 需要 4 MiB + 120 KiB = 4216 KiB。

vLLM 以块为单位分配 KV cache,块必须足够大,能够容纳单个 GDN state 或一定数量的 Full-Attn state。对于 Qwen3.8-2.4T,GDN state 比 Full-Attn state 大 4216 KiB / 2 KiB = 2108 倍。块大小随后由 GDN state 决定,并对齐到 16 字节,因此 align(2108, 16) = 132 * 16 = 2112 个 token 放入一个块,使块大小为 2112 × 2 KiB = 4.125 MiB。

对于分离式服务需要记住一点:prefill 和 decode 独立计算各自的块大小,但 KV cache 传输要求两者匹配。如果不匹配,请手动将 --block-size 设置为足够大的值,以便在两侧都能容纳 GDN state,代价是浪费一些 KV cache 空间。

既然我们已经计算了单个块的大小,就可以算出一次完整请求占用多少 KV cache,并据此估算测量中可预期的最大并发数。这一计算需要工作负载估算。我们固定 ISL=8192 和 OSL=1024——即所谓的长 ISL、decode 受限测试场景。

一次请求跨越 ISL、OSL,以及为 OpenAI API 请求使用的聊天模板预留的一小部分。每块 2112 个 token、23 个 Full-Attn 层和 69 个 GDN 层,每请求开销可以这样计算:

Full-Attn state 随 token 数量增长,因此 23 层中每层每 2112 个 token 需要一个块。GDN state 每个请求固定,因此 69 层中每层恰好需要一个块。将总量在两者之间拆分:

KV cache 使用模型加载和所有其他预留之后剩余的 GPU 内存,因此第一步是确定每个分配内存的内存消耗者。

KV cache 容量

GB300 一开始给我们 279 GB。最初驱动开销占用 2.28 GiB。因此,vLLM 启动时总共有 276.62 GiB。在 vLLM 服务器启动之前,CUDA 上下文已经占用了约 3.13 GiB。这是我们完全无法控制、也无法调整的内存。

除此之外,默认情况下 vLLM 会从 276.62 GiB 基线中预留 8%,用于无法提前预测的情况——激活内存峰值及类似效应。这就是 gpu_memory_utilization 设置所控制的内容。这样我们就只剩下约 254 GiB。

另外 2.90 GiB 用于 NCCL 缓冲区、分配器和其他非 PyTorch 相关事项。因此,在加载权重之前我们有 276.62 × 0.92 − 3.13 − 2.90 = 248 GiB。

峰值激活

加载权重后,vLLM 会测量模型在 eager 模式下(即不使用 CUDA graphs)单步消耗多少内存。这是必要的,因为 CUDA graphs 本身有最大尺寸上限,而在推理过程中 batch size 可能超过该上限——我们仍然必须以某种方式运行这一 pass,这时就需要使用 eager 模式。由于性能下降,这类情况当然应尽可能避免,但在实践中无法保证它们在运行时永远不会发生。Eager 模式 pass 使用 max_num_batched_tokens 个 token 运行,因此激活峰值是在最坏情况下测量的。

估算内存消耗最简单的方法是,使用 aggregated 模式对你感兴趣的 decode 拓扑进行几次测试运行。对于本博客文章中涵盖的拓扑,我们得到每个引擎的峰值激活如下:

运行拓扑MTPmax-num-seqsmax-num-batched-tokens峰值激活(GiB)
1TP8+20960.57
2TP8+321440.58
3TP8+803360.56
4TP8+27211041.91
5TP4DP4+803361.72
6TP4DP4+27211042.24
7TP4DP4−5285441.83
8TP4DP4−6566722.15

CUDA graphs

之后,vLLM 会捕获 CUDA graphs。graph 所需的内存必须预先分配到静态地址,这意味着 graph 所需的内存在捕获 graph 之前就必须估算出来。

截至目前,vLLM 通过以下方式实现这一点。首先,我们枚举服务器配置可用的所有 graph 尺寸。同一时间只能重放一个 graph,而 graph 越大,其峰值内存消耗就越多——因此至少我们必须为最大的那个留出空间。下一个问题是其余 graph 需要多少额外内存,而这里的情况与 eager 运行不同:一旦某个 graph 被捕获,重放它会复用完全相同的地址。再捕获第二大的 graph,你会发现它在第一个 graph 已使用的内存之上又额外占用了一些内存。

为了节省预热时间,vLLM 随后做出如下假设:每个后续 graph 都会增加同样多的 。CUDA graphs 所需内存按 计算,其中 是最大 graph 的内存, 是 graph 的总数。而且由于 graph 使用静态地址,又由于我们无法在启动时捕获所有 graph,我们被迫使用该估算值来预先占用全部内存量。

在实践中,估算值往往大幅超过 graph 实际所需的内存。要自行测量,请使用 compilation-config: '{"cudagraph_mode": "FULL_DECODE_ONLY"}' 和 max-cudagraph-capture-size 在 aggregated 模式下对 decode 拓扑进行测试运行。

对于本博客文章中涵盖的拓扑,我们得到 FULL 模式下每个引擎的内存消耗如下:

运行拓扑MTP# Graphs预留(GiB)实际使用(GiB)从未使用(GiB)%
1TP8+110.910.590.3254%
2TP8+171.280.820.4656%
3TP8+371.871.88−0.01−1%
4TP8+857.936.041.8931%
5TP4DP4+378.356.521.8328%
6TP4DP4+857.569.90−2.34−24%
7TP4DP4−528.297.510.7810%
8TP4DP4−608.958.070.8811%

该表也清楚地说明了为什么最初会有 gpu_memory_utilization 预留。在其中一些运行中,graph 使用的内存超过了初始估算,而正是这部分额外预留防止了这种情况变成 OOM。

需要注意的是,留给 KV cache 的内存空间由 CUDA graph 的 估算值决定,而不是由 graph 在运行时实际使用的内存决定,因此 KV cache 大小直接取决于该估算的准确程度。在我们的案例中这是一个很好的估算,但如果你想优化最终百分比,可能应该手动估算 graph 大小。vLLM 中的 graph 估算机制绝对值得进一步研究和改进,因为随着 Qwen3.8、Kimi-K3 和 DSV4 等超万亿参数前沿模型的出现,这个问题变得愈发紧迫。

每个引擎的最大并发数

现在是时候把之前所有结果汇总起来,计算 vLLM 针对我们的模型每个引擎能处理多少请求了。如前所述,GB300 为我们提供了 276.62 GiB。其中一部分内存被 gpu_memory_utilization 保留。在我们的场景中,我们使用了默认值 gpu_memory_utilization=0.92。因此,每个引擎初始可用内存为 276.62 GiB × 0.92 = 254.5 GiB。

下表中除最后一列外,每一列都是实测值:这些是 vLLM 服务器在启动时报告的数字。最后一列由它们推导而来——我们将 KV 缓存大小除以单个请求所需的 759 MiB,如上方 单个请求的 KV 缓存估算 中所计算:

这是一个简单的除法,它有意忽略了 MTP 为推测令牌保留的额外 KV 槽位,因此对于 MTP 行,应将其视为上限,而非实际可达的并发数。

运行拓扑MTPCUDA 上下文 (GiB)非 torch (GiB)权重 (GiB)峰值激活 (GiB)图估算 (GiB)KV 缓存 (GiB)请求数/引擎
1TP8+2.540.72169.280.570.9183.00112.0
2TP8+2.540.72169.280.581.2882.63111.5
3TP8+2.541.19169.280.561.8781.60110.1
4TP8+2.542.40169.281.917.9372.9798.4
5TP4DP4+3.131.98108.711.728.35133.74180.4
6TP4DP4+3.132.22108.712.247.56133.77180.5
7TP4DP4−3.132.90107.511.838.29133.96180.7
8TP4DP4−3.133.03107.512.158.95132.85179.2

从这张表中我们可以得出几点观察。首先,在我们测量的每种情况下,既非权重也非 KV 缓存的所有其他内存开销总计约 20 GiB,约占可用总内存的 7%。其次,分配给模型权重的空间随拓扑结构变化很大。最大化每 GPU 的总令牌吞吐量需要高并发,而并发最终受限于 KV 缓存可用的内存。释放更多内存的唯一方法是缩小模型本身所需的内存——这正是追求最高并发时在解码阶段使用 TP4DP4 拓扑的原因。

测量预填充性能

朝着目标迈进的下一步重要工作是,将预填充性能与解码分开测量。这意味着在仅预填充的工作负载上运行聚合服务,ISL/OSL=8192/2。这将向我们展示在分离式服务中应为预填充端点选择的最佳拓扑。这种分别测量仅预填充和仅解码工作负载的方法,使我们能够选择最佳拓扑,并显著减少测量 PD 服务性能所需的工作量。在我们的案例中,我们为预填充选择了以下拓扑:

  • TP4DP2+EP
  • TP2DP4+EP
  • TP4DP4+EP
  • TP2DP8+EP
  • DP16+EP
  • TP1PP6+EP
  • TP1PP8+EP
  • TP8+EP
  • TP16+EP

以下是我们的测量结果:

Figure 1: Prefill total token throughput per GPU vs concurrency, ISL/OSL 8192/2 on GB300.
图 1:GB300 上 ISL/OSL 8192/2 时,每 GPU 预填充总令牌吞吐量与并发数的关系。

如我们所见,在低并发下,TP4DP2+EP 拓扑表现最佳。在更高并发下,TP2DP4+EP 开始占据主导。

测量解码性能

我们对解码做同样的事情。我们在仅解码的工作负载上运行聚合服务,ISL/OSL=1/1000。我们选择了以下拓扑:

  • TP2DP4+EP
  • TP4DP8+EP
  • TEP8
  • TEP16

此外,我们尝试启用带有 3 个推测令牌的 MTP,因为它应有助于获得更好的性能结果。

以下是我们的测量结果:

Figure 2: Decode total token throughput per GPU vs concurrency, ISL/OSL 1/1000 on GB300.
图 2:GB300 上 ISL/OSL 1/1000 时,每 GPU 解码总令牌吞吐量与并发数的关系。

如我们所见,首先,只要 KV 缓存有足够可用空间,MTP 就会大幅提升性能。此外,在低并发下,最佳拓扑是 带 MTP 的 TEP8,然后在并发数 512 和 1024 时,TEP8 拓扑占据主导,而在高并发下,TP4DP4+EP 优于其他所有拓扑,因为与 TEP8 拓扑相比,它有更多空间用于 KV 缓存。

现在我们拥有了进行分离式帕累托测量所需的一切。

分离式性能结果

环境设置

测量在通过 NVLink72 连接的 GB300 集群上进行。我们使用 ISL/OSL = 8192/1024,并发范围从 1 到 2560。性能在固定的 decode 拓扑和恒定的 decode 端点数下测量。在此设置中,decode 侧使用一个端点,配备 8 或 16 个 GPU:

  • 1×TEP8
  • 1×TP4DP4+EP

在 prefill 侧,我们评估了从 1 到 4 个端点的配置,每个端点使用 8 到 32 个 GPU:

  • 1×TEP8
  • 1×TP4DP2+EP
  • {1,2,3,4}×TP2DP4+EP

要复现结果,请使用 Qwen3.8-2.4T NVFP4 模型、最新的 vLLM vllm/vllm-openai:nightly-a9a17 Docker 镜像(vLLM 修订版 v0.26.1rc1.dev1177+ga9a17e709)、Dynamo 1.2.0.dev20260526、srt-slurm v1.0.98 和 AIPerf 工具。本博文中使用的所有配方均可在 srt-slurm-recipes 仓库 中找到。

精度结果

我们做的第一件事是对所有选定的 PD 服务配置进行精度验证。你可以使用 srt-slurm 自行运行的 精度配方 可在同一仓库中找到。我们使用了标准的 GSM8K(小学数学 8K)基准。每个 PD 配置的最终精度结果为 95%。现在我们可以继续进行性能测量。

性能结果

各个配置的帕累托曲线如下所示。每条曲线都是单个部署在并发数上扫描得到的,因此每个点只能通过其所在曲线对应的配置达到。

Figure 3: pareto curves for the individual disaggregated configurations, ISL/OSL 8192/1024 on GB300.
图 3:各个分离式配置的帕累托曲线,ISL/OSL 8192/1024,在 GB300 上。

将它们全部组合起来得到最终的帕累托前沿:

Figure 4: Final pareto frontier for disaggregated serving, ISL/OSL 8192/1024 on GB300.
图 4:分离式服务的最终帕累托前沿,ISL/OSL 8192/1024,在 GB300 上。

结论

在这篇博文中,我们展示了如何将构建前沿 Qwen 系列模型帕累托曲线这样复杂的任务分解为几个较小的独立步骤。最终我们达到了每 GPU 5K 总 token 吞吐量,以及 180 gen tok/s/user 的交互性。

致谢

Artem Perevedentsev (NVIDIA)、Vadim Gimpelson (NVIDIA)、Xin Li (NVIDIA)

我们还要感谢 vLLM 社区成员对本博文中提到的一些优化工作的贡献和审阅。

来源:vLLM Blog · vllm.ai