vLLM 在 GB300 NVL72 上 PD 部署 Qwen3.8-2.4T 的性能结果
PD Serving of Qwen3.8-2.4T
vLLM 在 GB300 NVL72 集群上对 Qwen3.8-2.4T 做 PD 分离部署,8K/1K 负载下高吞吐场景达到每 GPU 5000 总 token 吞吐,低延迟场景每用户生成 180 token,并给出完整 pareto 前沿。
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:
GDN 状态按请求存储,而不是按 token 存储,这一点很重要。它由两部分组成。
- Conv state — 因果卷积的最后 kernel-1 个输入,每个通道一个滤波器,其中通道是 q、k 和 v 拼接而成:
- SSM state — 保存整个上下文的循环矩阵:
总结起来,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 拓扑进行几次测试运行。对于本博客文章中涵盖的拓扑,我们得到每个引擎的峰值激活如下:
| 运行 | 拓扑 | MTP | max-num-seqs | max-num-batched-tokens | 峰值激活(GiB) |
|---|---|---|---|---|---|
| 1 | TP8 | + | 20 | 96 | 0.57 |
| 2 | TP8 | + | 32 | 144 | 0.58 |
| 3 | TP8 | + | 80 | 336 | 0.56 |
| 4 | TP8 | + | 272 | 1104 | 1.91 |
| 5 | TP4DP4 | + | 80 | 336 | 1.72 |
| 6 | TP4DP4 | + | 272 | 1104 | 2.24 |
| 7 | TP4DP4 | − | 528 | 544 | 1.83 |
| 8 | TP4DP4 | − | 656 | 672 | 2.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) | % |
|---|---|---|---|---|---|---|---|
| 1 | TP8 | + | 11 | 0.91 | 0.59 | 0.32 | 54% |
| 2 | TP8 | + | 17 | 1.28 | 0.82 | 0.46 | 56% |
| 3 | TP8 | + | 37 | 1.87 | 1.88 | −0.01 | −1% |
| 4 | TP8 | + | 85 | 7.93 | 6.04 | 1.89 | 31% |
| 5 | TP4DP4 | + | 37 | 8.35 | 6.52 | 1.83 | 28% |
| 6 | TP4DP4 | + | 85 | 7.56 | 9.90 | −2.34 | −24% |
| 7 | TP4DP4 | − | 52 | 8.29 | 7.51 | 0.78 | 10% |
| 8 | TP4DP4 | − | 60 | 8.95 | 8.07 | 0.88 | 11% |
该表也清楚地说明了为什么最初会有 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 行,应将其视为上限,而非实际可达的并发数。
| 运行 | 拓扑 | MTP | CUDA 上下文 (GiB) | 非 torch (GiB) | 权重 (GiB) | 峰值激活 (GiB) | 图估算 (GiB) | KV 缓存 (GiB) | 请求数/引擎 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | TP8 | + | 2.54 | 0.72 | 169.28 | 0.57 | 0.91 | 83.00 | 112.0 |
| 2 | TP8 | + | 2.54 | 0.72 | 169.28 | 0.58 | 1.28 | 82.63 | 111.5 |
| 3 | TP8 | + | 2.54 | 1.19 | 169.28 | 0.56 | 1.87 | 81.60 | 110.1 |
| 4 | TP8 | + | 2.54 | 2.40 | 169.28 | 1.91 | 7.93 | 72.97 | 98.4 |
| 5 | TP4DP4 | + | 3.13 | 1.98 | 108.71 | 1.72 | 8.35 | 133.74 | 180.4 |
| 6 | TP4DP4 | + | 3.13 | 2.22 | 108.71 | 2.24 | 7.56 | 133.77 | 180.5 |
| 7 | TP4DP4 | − | 3.13 | 2.90 | 107.51 | 1.83 | 8.29 | 133.96 | 180.7 |
| 8 | TP4DP4 | − | 3.13 | 3.03 | 107.51 | 2.15 | 8.95 | 132.85 | 179.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
以下是我们的测量结果:

如我们所见,在低并发下,TP4DP2+EP 拓扑表现最佳。在更高并发下,TP2DP4+EP 开始占据主导。
测量解码性能
我们对解码做同样的事情。我们在仅解码的工作负载上运行聚合服务,ISL/OSL=1/1000。我们选择了以下拓扑:
- TP2DP4+EP
- TP4DP8+EP
- TEP8
- TEP16
此外,我们尝试启用带有 3 个推测令牌的 MTP,因为它应有助于获得更好的性能结果。
以下是我们的测量结果:

如我们所见,首先,只要 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%。现在我们可以继续进行性能测量。
性能结果
各个配置的帕累托曲线如下所示。每条曲线都是单个部署在并发数上扫描得到的,因此每个点只能通过其所在曲线对应的配置达到。

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

结论
在这篇博文中,我们展示了如何将构建前沿 Qwen 系列模型帕累托曲线这样复杂的任务分解为几个较小的独立步骤。最终我们达到了每 GPU 5K 总 token 吞吐量,以及 180 gen tok/s/user 的交互性。
致谢
Artem Perevedentsev (NVIDIA)、Vadim Gimpelson (NVIDIA)、Xin Li (NVIDIA)
我们还要感谢 vLLM 社区成员对本博文中提到的一些优化工作的贡献和审阅。
来源:vLLM Blog · vllm.ai