14 · router 两个方向判否,与 decode 的真瓶颈
一句话 :把 MoE router 量化(fp16)与并行化两条路都走了一遍,双双判否 ------router 的 7ms 是「位级一致硬约束下的延迟地板」;而细粒度 stub 拆解出一个被长期忽略的真相:decode 最大的单项其实是 attention(8ms,42%),不是 FFN 。 前置 :建议先读 第 13 篇 · Tiling 与 Tensor Core 判否、第 09 篇 · full-GPU decode。 环境:x86-64 + NVIDIA RTX 5090(sm_120)· 阶段三 30B-A3B MoE(Qwen3-30B-A3B q4:128 专家 / top-8 / dim=2048 / moe_ffn=768 / 48 层)。
一、问题与结论
| 方向 | 判定 | 卡点 | 标注 |
|---|---|---|---|
| router 量化 fp16 | ❌ 判否 | 减字节不减延迟(延迟受限非带宽受限) | 实测 |
| router 并行化 | ❌ 判否 | 拆 i 维破坏保序 → 破坏位级一致(greedy t=27 发散) | 实测 |
| decode 真瓶颈 | --- | attention 8ms(42%)> FFN(router+gate/up/down 9ms) | 实测 |
二、背景:router 为什么是 7ms
router 是 [128 专家][2048] f32 的矩阵向量乘,每 token 读 ~50MB f32(48 层 × 1MB)。
现实现是 128 线程 (1 线程 1 专家,各做 2048 步串行 点积)= 仅 4 warps 。 在 170 SM 的 RTX 5090 上用了 0.6% 的算力。50MB 串行流式读 + 4 warps = 延迟受限 (有效带宽 ~7 GB/s,峰值的 0.4%)。
「1 线程 1 专家、2048 步串行链」的现实现长这样(这也是位级档的形态):
c
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(k_moe_router_gemm_serial,节选) */
__global__ void k_moe_router_gemm_serial(const float *__restrict router,
const float *__restrict x,
float *__restrict logits,
int ne, int d) {
const int e = blockIdx.x * blockDim.x + threadIdx.x;
if (e >= ne) return;
const float *rw = router + (size_t)e * d;
float s = 0.0f;
for (int i = 0; i < d; i++) s += rw[i] * x[i]; /* d 步串行链 */
logits[e] = s;
}
三、方向 1:router 量化 fp16 ------ 判否
按项目纪律「指令预算推算不能替代实测」,不能只靠「字节减半 → 快一倍」的推理,落地实测:
| 臂 | decode | greedy | 标注 |
|---|---|---|---|
| f32 router(基线) | 19 ms/tok | 16/16 | 实测 |
| fp16 router | 19 ms/tok(51.51 tok/s) | 64/64 一致 | 实测 |
- 正确性:fp16 的 ~2⁻¹¹ 误差没有翻转任何 top-k,greedy 64/64 逐 token 一致。
- 但吞吐一点没变 ------因为 router 是延迟受限(128 线程 × 2048 次串行加载), 字节减半不减「串行加载的次数」,延迟地板不变。
首次尝试用了自定义
vc_half2float(分支重的位操作),decode 反而 19→29ms 变慢; 换成原生__half2float(单指令)后回到 19ms。这又是一次「实现细节决定实测」的实例。
判否:量化减字节不减延迟,对延迟受限的 router 无效。
四、方向 2:router 并行化 ------ 判否
按项目纪律「s 分块之间续接原累加器 (非部分和再合并)」:
router 点积
s = Σᵢ rw[i]·x[i]是 2048 步串行 FMA 链。把 i 拆成两半分别累加再相加, 就是「部分和再合并」,违反保序公理。
并行的实现就是把 d 维切成若干 chunk、各自归约再树合并:
c
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(k_moe_router_gemm,节选) */
__global__ void k_moe_router_gemm(const float *__restrict router, /* [ne][d] */
const float *__restrict x, /* [d] */
float *__restrict logits, /* [ne] */
int ne, int d) {
const int e = blockIdx.x * 4 + (threadIdx.x >> 6); /* 0..127 */
const int c = threadIdx.x & 63; /* 0..63 */
const int chunk = d >> 6; /* 32 */
const float *rw = router + (size_t)e * d + (size_t)c * chunk;
const float *xc = x + (size_t)c * chunk;
float s = 0.0f;
for (int i = 0; i < chunk; i++) s += rw[i] * xc[i];
__shared__ float lg[4][64];
lg[threadIdx.x >> 6][c] = s;
__syncthreads();
for (int off = 32; off > 0; off >>= 1) {
if (c < off) lg[threadIdx.x >> 6][c] += lg[threadIdx.x >> 6][c + off];
__syncthreads();
}
if (c == 0) logits[e] = lg[threadIdx.x >> 6][0];
}
实测落地:
- 拆两半(256 线程):greedy 在 t=27 发散(1773 → 3837),near-tie 的 top-k 被翻转。
判否:并行化必须拆 i 维,而拆 i 维破坏保序 → 破坏位级一致。router 的并行度被硬锁在 128(专家数)。
五、完整 decode 拆解(细粒度 stub)
用 VLLM_CUDA_STUB_{ROUTER,GU,ATTN,LMHEAD} 逐段 stub,把 19ms 拆到底:
| 组件 | 成本 | 占比 | 标注 |
|---|---|---|---|
| attention(QKV 投影 + RoPE + KV 量化 + attention 核 + O 投影) | 8 ms | 42% | 实测 |
| router | 7 ms | 37% | 实测 |
| gate/up/down | 2 ms | 10% | 实测 |
| lm_head + norm + launch | 2 ms | 11% | 实测 |
这推翻了「MoE FFN 是 decode 瓶颈」的直觉------FFN(router+gate/up/down)合计 9ms, 而 attention 单一项就 8ms,且之前一直被当成「小头」忽略。
六、attention 为什么慢,是下一个目标
k_attn_decode_f32q 的 pass 3 对每个输出元素 i < hd=128 各做一次 butterfly 归约:
css
for i in 0..128:
acc 各线程累加 → __syncthreads → butterfly(8 步 __syncthreads) → 写 outp[i]
即 128 次串行归约 × 每次 8 步 __syncthreads ≈ 1408 次 barrier / 层 × 48 层 ≈ 6.7 万次 barrier / token。 这是典型的「按输出元素逐个归约」低效结构。同一套结构在同族的 q8 变体里保留至今:
c
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(k_attn_decode_q8 的 Pass 3,节选) */
/* Pass 3: weighted V sum (reduce over the token partition per output dim). */
for (int i = 0; i < hd; i++) {
float acc = 0.0f;
for (int t = threadIdx.x; t < seq_len; t += blockDim.x) {
const int8_t *vt = vb + (size_t)t * kv_dim;
const float vss = vs[(size_t)t * nkv + kh] * (1.0f / 127.0f);
acc += (float)vt[i] * scores[t] * vss;
}
red[threadIdx.x] = acc;
__syncthreads();
for (int off = blockDim.x >> 1; off > 0; off >>= 1) {
if (threadIdx.x < off) red[threadIdx.x] += red[threadIdx.x + off];
__syncthreads();
}
if (threadIdx.x == 0) outp[i] = red[0] * invS;
__syncthreads();
}
优化方向:把 128 次串行归约改成一次并行归约 + 转置(或按 (i,t) 二维映射), 把 barrier 次数从 O(hd·log T) 降到 O(log T)。代价是「数学等价但非位级等价」,须重新位级对拍 greedy。
七、边界与已知限制
- 口径:router 的 7ms / 0.6% 算力 / ~7 GB/s 有效带宽是 64-token decode 下的量;fp16 与并行化的 A/B 也在此口径下。
- 位级一致是硬约束 :本文的两次判否都以「greedy 位级一致」为前提;第 15 篇 会说明这条约束被放宽后,router 并行化反而落地并带来收益。
- 并行化发散点:t=27 是实测的 near-tie 翻转点;换 prompt 未必同点,但机制(拆 i 维破坏保序)不变。
- attention 的 8ms 拆解 只是定位,不是优化结论;真正的修改在 第 15 篇。
- fp16 的 2⁻¹¹ 误差未翻转 top-k 只在本 prompt 的 64 token 上验证。
CPU 对照(迁移前基线)
- CPU 参考 :
kestrel-llm/src/model/vllm_safetensors.c(函数st_moe_ffn_sparse_q8/st_moe_ffn_sparse_q4内的 router 串行点积s += rw[i]*x_ffn[i])------ CPU 侧逐专家沿 i 维串行点积出 router logits。 - 迁移要点:CPU 侧串行点积保序 → CUDA 侧 router 量化 fp16 判否(延迟受限而非带宽);并行化拆 i 维破坏保序 → 位级判否(greedy t=27 发散);细粒度 stub 拆出 attention 42% 才是最大项。
- 真机验证 :未独立复现:需 30B + 细粒度 stub 探针(
VLLM_CUDA_STUB_*)与 near-tie 翻转点观察。
八、小结(可复用结论)
| 认知 | 内容 |
|---|---|
| 先探针再排序 | 细粒度 stub 才拆出「attention 42% > FFN」的反直觉真相 |
| 量化/并行化对延迟受限无效 | router 是延迟受限(4 warps),减字节、加线程都动不了它 |
| 位级一致是硬约束 | 拆 i 维(并行化)违反保序公理,实测 greedy 发散 |
| 实现细节决定实测 | 手写 fp16 转换 19→29ms,原生指令才回到 19ms |
- 延迟受限 ≠ 带宽受限:router 的瓶颈是「128 线程 × 2048 次串行加载」的次数,不是字节数;量化减字节、并行化加线程,在「位级一致」约束下都不解决它。
- 别让直觉替探针排序:一直以为 MoE FFN 是瓶颈,stub 一拆才发现 attention 单一项就占 42%。
- 保序约束会把并行度钉死:router 的并行度上限是专家数(128),因为拆 i 维就变成「部分和再合并」。
- 实现选择能反转结论:同样的 fp16 量化,分支重的位操作实现反而更慢,原生指令才回到基线。
- 拆解先于优化:先把 19ms 拆成四块并定位最大项,再决定动哪个内核。
一句话(收束):router 两条路都判否,因为它的 7ms 是位级一致约束下的延迟地板; 真正的下一个大头是 attention 核的「128 次串行归约」低效结构。
相关篇目:第 13 篇 · Tiling 与 Tensor Core 判否、第 15 篇 · decode 优化四连击 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...