起 vLLM 时冒出来一句 Using default MoE config. Performance might be sub-optimal! —— 这句 warning 背后,是一套决定 MoE 模型跑多快、却和模型权重无关的 JSON 文件。它们是什么、为什么影响速度、从哪来、缺了怎么办?
E=256,N=256,device_name=NVIDIA_H20,dtype=fp8_w8a8...json 的文件。它不是模型权重,不改计算结果或精度 —— 它是 MoE 那个大矩阵乘的 Triton GPU kernel 分块超参数表。(专家数 E, 分片中间维度 N, 显卡, 精度 dtype),与具体是哪个模型无关;TP 通过 N 间接生效。benchmark_moe.py --tune 扫一份(单卡几十分钟到几小时,一次性),VLLM_TUNED_CONFIG_FOLDER 指过去即可,优先级高于内置。如果你在某张不太主流的卡上(比如国内常见的 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 配置,只能用一套通用的保底参数,速度可能明显偏慢。」
sub-optimal warning 时,端到端吞吐常见这个量级回落(视模型、卡、并发而定)。要点:缺配置不等于算错,而是 MoE 层没吃满硬件。prefill 往往比 decode 更敏感(大 M 时矩阵形状变化大);具体数字一定要在你自己的卡 + 模型 + TP + 并发下复测。
要理解这句话到底损失了什么,得先搞清楚三件事:这个 MoE kernel 在算什么、那个 config 文件是什么、以及它俩为什么会决定速度。这篇文章就顺着这条线走一遍,最后落到一个具体问题:Qwen3.5-35B-A3B 在 H20 上到底有没有现成配置。
MoE(Mixture-of-Experts)层的计算逻辑其实很朴素:每个 token 经过一个 router,从一大堆专家里选中 top-k 个(Qwen3.5-35B-A3B 是 256 选 8),每个被选中的专家就是一个小 FFN(gate / up / down 三个矩阵乘 + SwiGLU)。
问题在于「怎么算这 256 个专家的矩阵乘」有天壤之别:
本文说的「配置」,调的就是这个融合 grouped-GEMM kernel 的分块与调度方式。它不改权重、不改计算公式,只决定「这堆矩阵乘怎么在 GPU 上排活」。
直接看一份真实文件的内容(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,不是阶段名。逐 token 生成(decode) 时 M 通常很小:单请求常见 M=1,多请求并发时 M 等于 batch 大小。整段 prompt 一次算完(prefill) 时 M 等于 prompt 长度,几千很正常。文章后面提到 decode / prefill,是在用这两种常见场景帮你理解「为什么 M 会差这么多」;真正选配置时,看的是当前这一步的 M 落在哪个档位。你能看到上面 M=64 起 BLOCK_SIZE_K 就从 128 涨到了 256。
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/寄存器)之间怎么平衡」。
既然计算公式完全一样,为什么分块方案不同会快慢差几倍?因为最优分块由三个东西共同决定,选错就会出现两类典型浪费:算力单元闲着等数据,或者同时能跑的线程块太少、藏不住读内存的等待时间。
Tensor Core(张量核心):GPU 里专门干矩阵乘的硬件单元,比通用 CUDA 核心快一个数量级。MoE 的 grouped GEMM 最终就是要喂饱它。所谓「tensor core 饿着」,意思是:矩阵乘单元已经就绪,但数据还没从显存搬过来,算力空转 —— 常见于 M 很小、矩阵又瘦又长时,瓶颈在访存而不是算力。
Occupancy(占用率):一块 GPU 上,同时能跑起来的线程块(warp 组)占比。占比高 → 一块数据在等显存时,别的线程块还能继续算,把等待时间「盖住」。分块太大 → 每个线程块吃太多 shared memory / 寄存器 → 同时能放的块变少 → occupancy 下降 → 等数据时更容易整体停摆。所谓「藏不住访存延迟」,就是这个意思。
调优 config 的目标,就是在给定矩阵形状和显卡上,让 tensor core 有活干、occupancy 够高,把算力和带宽都吃满。
M 小时(典型是逐 token 生成)矩阵又瘦又长,瓶颈在访存;M 大时(典型是 prefill)矩阵更方正,瓶颈在算力 —— 两种形状要的分块完全不同。光说不够直观。下面是同一个形状(E=256, N=256, dtype=fp8_w8a8)在 vLLM 仓库里、7 张不同卡上被自动调优出来的真实分块 —— 看它们分歧有多大:
| 显卡 | M=1 | M=64 | M=4096 |
|---|---|---|---|
| NVIDIA H20 | 64·128·128 / w4 s3 | 64·128·256 / w4 s3 | 64·128·256 / w4 s3 |
| NVIDIA H20-3e | 16·128·128 / w4 s3 | 16·128·256 / w4 s3 | 64·128·128 / w4 s3 |
| NVIDIA H200 | 8·32·128 / w4 s4 | 16·128·128 / w4 s3 G64 | 64·128·128 / w4 s3 G16 |
| NVIDIA B200 | 16·64·128 / w4 s4 G32 | 16·256·128 / w4 s3 G32 | 32·256·128 / w4 s3 G16 |
| NVIDIA L20 | 16·128·128 / w8 s3 | 16·128·256 / w8 s2 G64 | 64·128·128 / w4 s2 G16 |
| AMD MI300X | 16·128·256 / w8 s2 | 16·128·128 / w2 s2 | 128·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)。
几个直观的结论:
M=1 用极小的 8×32 tile,H20 却用 64×128,B200 又偏爱大 GROUP_M。这就是为什么配置必须按卡各存一份。M=1 的 16×128 一路涨到 M=4096 的 128×256。sub-optimal warning 想避免的事。vLLM 用一个函数拼出要找的文件名(源码 fused_moe.py::get_config_file_name),逻辑就一行:
由此得出两个关键性质:
E、分片后 N、dtype 相同,又跑在同一张卡上,就共用同一份配置。N = 专家中间维度 ÷ TP。所以 TP 不是独立的 key,改 TP → N 变 → 换文件。这也是为什么「换卡、换 TP、换量化」都要换配置,而「换个同架构同规模的模型」却能复用。源码里有一行特判:检测到 H200 家族的卡时,device_name 一律归一成 NVIDIA_H200,即各种 H200 变体共用一份配置。
但 H20 不在此列 —— NVIDIA_H20 和 NVIDIA_H20-3e(141GB 版)是两个独立的 device_name,配置不通用。torch.cuda.get_device_name() 报哪个,就必须命中哪个文件。
Qwen3.5-35B-A3B 是 E=256、专家中间维度 512 的 MoE。所以要找的 N = 512 ÷ TP:TP1→512、TP2→256、TP4→128、TP8→64。去 vLLM 仓库 configs/ 里逐一核对 H20 的现有文件:
| TP | 需要的 N | NVIDIA_H20 | NVIDIA_H20-3e |
|---|---|---|---|
| TP1 | 512 | ✗ 无 | ✗ 无 |
| TP2 | 256 | ✓ fp8 + int8 | ✓ fp8 |
| TP4 | 128 | ✓ fp8 | ✗ 无 |
| TP8 | 64 | ✗ 无 | ✗ 无 |
核对结论:
fp8_w8a8 或 int8_w8a8 + block_shape=[128,128](块级量化)。核对完覆盖情况,一个自然的疑问是:这些文件是谁生成、怎么进仓的?答案是 —— 主要靠人手工跑扫描、再通过 PR 上传,vLLM 官方并没有一套 CI 帮你自动生成。
README 明说:「See benchmark_moe.py on how to generate these config files.」 官方只提供生成工具,文件本身谁生成谁提交。谁在贡献:
AMD_Instinct_MI300X/MI325X、NVIDIA_H20/H200/B200/L20。pop("triton_version") 忽略版本标记)。
既然要自己补,那扫一次的门槛高吗?拆成环境和时间两块看。
--model 读 config 里的 E / intermediate_size / hidden_size / topk。指到 config 就行,不必拉 35B checkpoint。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
现实量级:单卡约几十分钟到 1~3 小时;8 卡节点通常 ~20–60 分钟(专家数 256 会偏慢)。主要耗时是 Triton JIT 编译,不是那 20 次实测迭代本身。关键是:这是一次性成本,一个 (形状, TP, dtype, 卡) 组合扫完存成 JSON 就永久复用。
不用扫全排列。为你实际部署的档位扫即可:定死 --tp-size(决定 N)、--dtype(和 block_shape)、以及卡型号。
再进一步:默认 18 个 M 档位可以用 --batch-size 只指定你关心的几个(例如小 M 的逐 token 生成档 + 少数几个大 M 的 prefill 档),时间能再砍几倍。
你完全不必等社区上游。缺配置时自己扫一份,用环境变量指过去就行 —— 而且它的优先级高于仓库内置配置(源码里 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 ...
MoE fused config 不是模型配置、不是精度设置,而是 MoE 那个大矩阵乘的 Triton kernel 分块/流水线超参数表;它的粒度是 (专家数 E, 分片中间维度 N, 显卡, dtype),与模型无关、TP 藏在 N 里;速度差别来自「分块方案与具体 GPU + 矩阵形状 + batch 大小的匹配程度」;这些文件靠社区和厂商手工扫描 + PR 上传,覆盖零散 —— 缺了就自己 --tune 扫一份,本地生效即可,一劳永逸。