vLLM · MoE · 推理性能

MoE 的隐藏加速开关:那些按显卡命名的 config 到底是什么

起 vLLM 时冒出来一句 Using default MoE config. Performance might be sub-optimal! —— 这句 warning 背后,是一套决定 MoE 模型跑多快、却和模型权重无关的 JSON 文件。它们是什么、为什么影响速度、从哪来、缺了怎么办?

基于 vLLM 官方源码整理 · 源:vllm/.../fused_moe/configs ↗

TL;DR · 30 秒抓住核心

01从一句 warning 说起

如果你在某张不太主流的卡上(比如国内常见的 H20)起一个 MoE 模型,vLLM 启动日志里很可能闪过这么一行:

# vllm 起 Qwen 系 MoE 时的真实日志
WARNING [fused_moe.py] Using default MoE config. Performance might be sub-optimal!
        Config file not found at .../fused_moe/configs/E=256,N=...,device_name=NVIDIA_H20,dtype=fp8_w8a8.json

模型能正常跑、结果也对,很多人就忽略了。但这句话的真实含义是:「我没找到为你这张卡 + 这个 MoE 形状调优过的 kernel 配置,只能用一套通用的保底参数,速度可能明显偏慢。」

20~40%
吞吐损失(社区经验)
vLLM 性能调优文档对「default MoE config」的常见量级估计;新 SKU / 未覆盖组合更容易踩中
+58%
prefill 吞吐(个案)
vllm-tune 在 Qwen3.6-35B-A3B-FP8、TP=2 上测得 pp2048: 4677→7406 tok/s
~10%
decode 吞吐(个案)
同组实验 tg128: 74.4→81.5 tok/s;不同卡 / batch 会有波动
这些数字从哪来、怎么理解不是 vLLM 官方 SLA,而是社区 benchmark 与调优文档的量级参考
  • 20%~40% 吞吐损失:社区 vLLM 性能调优 skill 对 default MoE config 的概括 —— 出现 sub-optimal warning 时,端到端吞吐常见这个量级回落(视模型、卡、并发而定)。
  • +58% prefill / ~10% decode:vllm-tuneQwen3.6-35B-A3B-FP8(TP=2, GB10×2, vLLM 0.19.2)上的对比:prefill 2048 token 从约 4677 提到 7406 tok/s,decode 128 token 从 74.4 提到 81.5 tok/s;同时方差大幅下降。
  • 「几十 tok/s」:MissionSquad/vllm-moe-configs 在问题 prompt 上报告稳定并发与「tens of tokens/s」量级的改善 —— 更偏个案,但说明不只是纸面 FLOPs。

要点:缺配置不等于算错,而是 MoE 层没吃满硬件。prefill 往往比 decode 更敏感(大 M 时矩阵形状变化大);具体数字一定要在你自己的卡 + 模型 + TP + 并发下复测。

要理解这句话到底损失了什么,得先搞清楚三件事:这个 MoE kernel 在算什么、那个 config 文件是什么、以及它俩为什么会决定速度。这篇文章就顺着这条线走一遍,最后落到一个具体问题:Qwen3.5-35B-A3B 在 H20 上到底有没有现成配置。

02先搞清楚:fused MoE kernel 在算什么

MoE(Mixture-of-Experts)层的计算逻辑其实很朴素:每个 token 经过一个 router,从一大堆专家里选中 top-k 个(Qwen3.5-35B-A3B 是 256 选 8),每个被选中的专家就是一个小 FFN(gate / up / down 三个矩阵乘 + SwiGLU)。

Tokens
一个 batch 的 hidden
Router
每个 token 选 top-k 专家
Grouped GEMM
按专家分组做大矩阵乘
(fused kernel)
加权合并
top-k 输出按门控权重求和

问题在于「怎么算这 256 个专家的矩阵乘」有天壤之别:

本文说的「配置」,调的就是这个融合 grouped-GEMM kernel 的分块与调度方式。它不改权重、不改计算公式,只决定「这堆矩阵乘怎么在 GPU 上排活」。

03那个 config 文件到底是什么

直接看一份真实文件的内容(H20 上、E=256、N=256、FP8 块量化):

// E=256,N=256,device_name=NVIDIA_H20,dtype=fp8_w8a8,block_shape=[128,128].json
{
  "1":    {"BLOCK_SIZE_M":64, "BLOCK_SIZE_N":128, "BLOCK_SIZE_K":128, "GROUP_SIZE_M":1, "num_warps":4, "num_stages":3},
  "64":   {"BLOCK_SIZE_M":64, "BLOCK_SIZE_N":128, "BLOCK_SIZE_K":256, ...},
  "4096": {"BLOCK_SIZE_M":64, "BLOCK_SIZE_N":128, "BLOCK_SIZE_K":256, ...}
  // ... 共 18 个 M 档位:1,2,4,8,16,24,32,48,64,96,128,256,512,1024,1536,2048,3072,4096
}

它就是一张 M(本次 forward 里要一起算的 token 数)→ 一组 Triton kernel 分块参数 的映射表。配置之所以按 M 分档,是因为这一步里要处理的 token 数可以从 1 到几千,矩阵形状差很多,最优分块也就差很多。

M 和 decode / prefill 是什么关系 表里用的是数字 M,不是阶段名。逐 token 生成(decode)M 通常很小:单请求常见 M=1,多请求并发时 M 等于 batch 大小。整段 prompt 一次算完(prefill)M 等于 prompt 长度,几千很正常。文章后面提到 decode / prefill,是在用这两种常见场景帮你理解「为什么 M 会差这么多」;真正选配置时,看的是当前这一步的 M 落在哪个档位。你能看到上面 M=64BLOCK_SIZE_K 就从 128 涨到了 256。
那几个参数分别是什么?BLOCK_SIZE_M/N/K、GROUP_SIZE_M、num_warps、num_stages —— GEMM 分块与流水线的旋钮

GPU 上算矩阵乘 C[M,N] = A[M,K] × B[K,N],是把输出切成一个个 BLOCK_SIZE_M × BLOCK_SIZE_N 的小块(tile),每个线程块负责一块,沿 K 维以 BLOCK_SIZE_K 为步长循环,把分片搬进 shared memory 再喂给 tensor core 累加。

参数含义影响
BLOCK_SIZE_M/N/K每个块负责的 tile 尺寸块越大 → 数据复用率越高(每 FLOP 访存越少),但越吃 shared memory / 寄存器 → 同时能跑的块变少(occupancy 下降)
num_warps每块线程数(warp×32)块内并行度 + 寄存器压力
num_stages软件流水线级数提前预取几轮 K 的数据,让访存和计算重叠(藏延迟);级数越多藏得越好但更吃 SRAM
GROUP_SIZE_M块调度重排(swizzle)让复用同一批权重的块挨着跑 → 提升 L2 cache 命中

一句话:这些旋钮共同决定了「在塞满硬件(算力/带宽/缓存)和不溢出(shared memory/寄存器)之间怎么平衡」。

04为什么会有速度差别

既然计算公式完全一样,为什么分块方案不同会快慢差几倍?因为最优分块由三个东西共同决定,选错就会出现两类典型浪费:算力单元闲着等数据,或者同时能跑的线程块太少、藏不住读内存的等待时间。

两个术语先扫盲tensor core 和 occupancy 分别是什么,和「分块选错」有什么关系

Tensor Core(张量核心):GPU 里专门干矩阵乘的硬件单元,比通用 CUDA 核心快一个数量级。MoE 的 grouped GEMM 最终就是要喂饱它。所谓「tensor core 饿着」,意思是:矩阵乘单元已经就绪,但数据还没从显存搬过来,算力空转 —— 常见于 M 很小、矩阵又瘦又长时,瓶颈在访存而不是算力。

Occupancy(占用率):一块 GPU 上,同时能跑起来的线程块(warp 组)占比。占比高 → 一块数据在等显存时,别的线程块还能继续算,把等待时间「盖住」。分块太大 → 每个线程块吃太多 shared memory / 寄存器 → 同时能放的块变少 → occupancy 下降 → 等数据时更容易整体停摆。所谓「藏不住访存延迟」,就是这个意思。

调优 config 的目标,就是在给定矩阵形状和显卡上,让 tensor core 有活干、occupancy 够高,把算力和带宽都吃满。

光说不够直观。下面是同一个形状(E=256, N=256, dtype=fp8_w8a8)在 vLLM 仓库里、7 张不同卡上被自动调优出来的真实分块 —— 看它们分歧有多大:

显卡M=1M=64M=4096
NVIDIA H2064·128·128 / w4 s364·128·256 / w4 s364·128·256 / w4 s3
NVIDIA H20-3e16·128·128 / w4 s316·128·256 / w4 s364·128·128 / w4 s3
NVIDIA H2008·32·128 / w4 s416·128·128 / w4 s3 G6464·128·128 / w4 s3 G16
NVIDIA B20016·64·128 / w4 s4 G3216·256·128 / w4 s3 G3232·256·128 / w4 s3 G16
NVIDIA L2016·128·128 / w8 s316·128·256 / w8 s2 G6464·128·128 / w4 s2 G16
AMD MI300X16·128·256 / w8 s216·128·128 / w2 s2128·256·128 / w4 s2 G4

格式:BLOCK_M·BLOCK_N·BLOCK_K / num_warps num_stages [GROUP_M]。加粗为与众不同处。数据取自 vLLM 仓库对应 JSON。M=1 对应小 batch(典型逐 token 生成),M=4096 对应大 batch(典型 prefill)。

几个直观的结论:

关键澄清 这些差异只影响速度,不影响结果。换分块 = 换 GPU 上的排活方式,算出来的数值完全一致。所以它是纯性能旋钮,不是精度或正确性问题。

05配置怎么被索引:文件名解剖

vLLM 用一个函数拼出要找的文件名(源码 fused_moe.py::get_config_file_name),逻辑就一行:

E=256专家数,N=256中间维度 ÷ TP,device_name=NVIDIA_H20显卡,dtype=fp8_w8a8精度,block_shape=[128,128]量化块.json

由此得出两个关键性质:

一个小坑:H200 会被归一,H20 不会device_name 的匹配规则里藏着一个特例

源码里有一行特判:检测到 H200 家族的卡时,device_name 一律归一成 NVIDIA_H200,即各种 H200 变体共用一份配置。

H20 不在此列 —— NVIDIA_H20NVIDIA_H20-3e(141GB 版)是两个独立的 device_name,配置不通用torch.cuda.get_device_name() 报哪个,就必须命中哪个文件。

06落到实处:Qwen3.5-35B-A3B 在 H20 上有没有

Qwen3.5-35B-A3B 是 E=256、专家中间维度 512 的 MoE。所以要找的 N = 512 ÷ TP:TP1→512、TP2→256、TP4→128、TP8→64。去 vLLM 仓库 configs/ 里逐一核对 H20 的现有文件:

TP需要的 NNVIDIA_H20NVIDIA_H20-3e
TP1512✗ 无✗ 无
TP2256✓ fp8 + int8✓ fp8
TP4128✓ fp8✗ 无
TP864✗ 无✗ 无

核对结论:

TP2
H20 / H20-3e 都覆盖(FP8),最推荐的部署档
FP8
唯一有现成 H20 配置的精度;bf16 一律 fallback
0
个 bf16 配置
H20 上 E=256 的 bf16 调优文件数

07这些配置从哪来:贡献者,不是 CI

核对完覆盖情况,一个自然的疑问是:这些文件是谁生成、怎么进仓的?答案是 —— 主要靠人手工跑扫描、再通过 PR 上传,vLLM 官方并没有一套 CI 帮你自动生成。

谁在贡献:

这带来三个后果 ① 覆盖极不均匀 —— 全看有没有人愿意传(这正是 Qwen3.5 在 H20 上只有 FP8/TP2/TP4 的原因)。② 无最优保证 —— 上游基本是信任制,合并者一般不复跑验证。③ 会过时 —— Triton / kernel 升级后老配置可能不再最快,除非有人重扫(代码里还特意 pop("triton_version") 忽略版本标记)。

08扫一次要多少成本

既然要自己补,那扫一次的门槛高吗?拆成环境和时间两块看。

环境成本:中等,但有一个硬门槛

时间成本:一次性离线开销

NVIDIA 上精简后的搜索空间(每个 M 档位):

# get_configs_compute_bound 的搜索网格(NVIDIA)
BLOCK_M(5) × BLOCK_N(4) × BLOCK_K(3) × GROUP_M(4) × num_warps(2) × num_stages(4) = 1920 组 / 每个 M
1920
组 / M 档
精简搜索空间(编译失败/不对齐的会被跳过)
18
个 M 档位
默认从 1 扫到 4096,合计约 3.5 万次计时
÷N
GPU 并行
ray 自动铺到节点全部 GPU,墙钟 ÷ 卡数

现实量级:单卡约几十分钟到 1~3 小时;8 卡节点通常 ~20–60 分钟(专家数 256 会偏慢)。主要耗时是 Triton JIT 编译,不是那 20 次实测迭代本身。关键是:这是一次性成本,一个 (形状, TP, dtype, 卡) 组合扫完存成 JSON 就永久复用。

想更快?只扫你实际用的档位定死 TP / dtype / 卡,再用 --batch-size 收窄 M 网格

不用扫全排列。为你实际部署的档位扫即可:定死 --tp-size(决定 N)、--dtype(和 block_shape)、以及卡型号。

再进一步:默认 18 个 M 档位可以用 --batch-size 只指定你关心的几个(例如小 M 的逐 token 生成档 + 少数几个大 M 的 prefill 档),时间能再砍几倍。

09缺了怎么办:自己扫,本地生效

你完全不必等社区上游。缺配置时自己扫一份,用环境变量指过去就行 —— 而且它的优先级高于仓库内置配置(源码里 user-defined 目录排在查找列表最前)。

# 1. 针对你的卡 + 模型形状 + TP + 精度 调优(--tp-size 必须和部署时一致!)
python benchmarks/kernels/benchmark_moe.py \
    --model Qwen/Qwen3.5-35B-A3B \
    --tp-size 2 \
    --dtype fp8_w8a8 \
    --tune \
    --save-dir /path/to/moe_tuning

# 2. 部署时指向该目录,vLLM 自动加载(启动日志会打 Using configuration from ...)
export VLLM_TUNED_CONFIG_FOLDER=/path/to/moe_tuning
vllm serve Qwen/Qwen3.5-35B-A3B --tensor-parallel-size 2 ...
两个必对齐的点 TP 要一致 —— 调优的 TP 决定 N,TP2 和 TP4 的配置不通用(多机 PP 也一样)。dtype 要一致 —— bf16 和 fp8 是两份完全不同的文件,对不上照样命中不了。

一句话心智模型

MoE fused config 不是模型配置、不是精度设置,而是 MoE 那个大矩阵乘的 Triton kernel 分块/流水线超参数表;它的粒度是 (专家数 E, 分片中间维度 N, 显卡, dtype),与模型无关、TP 藏在 N 里;速度差别来自「分块方案与具体 GPU + 矩阵形状 + batch 大小的匹配程度」;这些文件靠社区和厂商手工扫描 + PR 上传,覆盖零散 —— 缺了就自己 --tune 扫一份,本地生效即可,一劳永逸。