架构拆解 · Session 4–6 配套
本文把 7,615,616,512 个参数逐层摊开,讲清:每个张量的形状、每个矩阵怎么乘、参数集中在哪里;
注意力核内部五步各算什么;SwiGLU 的门控制什么;
GQA 怎样把 KV Cache 压到原来的 1/7。KV Cache 这块内存决定并发上限。
最后一节用同代的 Qwen2-57B-A14B 说明 MoE 改了这套结构的哪一部分、哪一部分没改。
整个模型分三段:查表变成向量 → 经过 28 个相同的 Decoder Layer → 投影回词表大小。
B 是 batch,S 是序列长度;数据流中只有最后一维会变。
tie_word_embeddings: false),
合计 1.09 B,占全模型 14.3%。
很多小模型让这两份共享(省一半),Qwen2.5-7B 不共享。
28 层结构完全相同。中间的竖线是 残差流(residual stream),
维度始终是 [B, S, 3584];两个分支各自算完,再加回残差流。
"Pre-Norm" 指归一化放在分支入口,不在出口。
每层只有 7 次矩阵乘 + 1 次注意力核,其余都是逐元素运算。 上一节方框图里的每个框都是"一次矩阵乘"。这一节把它们逐个摊开: 输入形状、权重形状、输出形状和 FLOPs。
safetensors 里 k_proj.weight 的形状是 [512, 3584],
和直觉的"3584 进、512 出"相反。原因是 PyTorch nn.Linear 的约定:
它算 y = x @ Wᵀ,所以权重按 [out_features, in_features] 存。
存储形状 [512, 3584] ← safetensors 里看到的
数学上的乘法 [B,S,3584] @ [3584,512] → [B,S,512]
参数个数 512 × 3584 = 1,835,008 ← 两种写法数量相同
# 本文表格一律用「输入维 × 输出维」写,因为那才是数据流的方向
[B, S, 3584] 是三维的,权重是二维的。实际执行时,batch 和序列两维先被压平:
[B, S, 3584] --reshape--> [B·S, 3584] ← 把每个 token 当成独立的一行
│
一次 GEMM: [B·S, 3584] @ [3584, 3584]
│
[B·S, 3584] --reshape--> [B, S, 3584]
# 关键推论:token 之间在投影这一步【互不影响】。
# 唯一让 token 互相"看见"的地方,是注意力核里的那两次矩阵乘。
所以 batch "几乎免费":B·S 只让 GEMM 的一边变长, 权重那一边一个字节也没有多读。这是 continuous batching 的物理基础。
| # | 算子 | 输入 | 权重(输入维 × 输出维) | 输出 | FLOPs/token |
|---|---|---|---|---|---|
| 1 | q_proj | [B,S,3584] | 3584 × 3584 | [B,S,3584] | 25.7 M |
| 2 | k_proj | [B,S,3584] | 3584 × 512 | [B,S,512] | 3.7 M |
| 3 | v_proj | [B,S,3584] | 3584 × 512 | [B,S,512] | 3.7 M |
| — | RoPE | Q,K | 无参数 | 同形状 | 逐元素 |
| — | 注意力核 | Q,K,V | 无参数 | [B,S,3584] | 见下节 |
| 4 | o_proj | [B,S,3584] | 3584 × 3584 | [B,S,3584] | 25.7 M |
| 5 | gate_proj | [B,S,3584] | 3584 × 18944 | [B,S,18944] | 135.8 M |
| 6 | up_proj | [B,S,3584] | 3584 × 18944 | [B,S,18944] | 135.8 M |
| — | SiLU × 逐元素乘 | [B,S,18944] | 无参数 | [B,S,18944] | 逐元素 |
| 7 | down_proj | [B,S,18944] | 18944 × 3584 | [B,S,3584] | 135.8 M |
| 一层合计 | 466.1 M |
算 MFU 时会反复用到这条经验公式。下面从矩阵乘的定义推一遍:
# 矩阵乘的定义:C = A @ B,其中 A 是 [m,k],B 是 [k,n],C 是 [m,n]
C[i,j] = A[i,0]·B[0,j] + A[i,1]·B[1,j] + … + A[i,k−1]·B[k−1,j]
└──────────────────── k 项相加 ────────────────────┘
# 也就是说:【每一个输出元素,都是一个长度为 k 的点积】
"每个输出元素乘一次加一次"的准确说法是: 「点积里每前进一项,就是一次乘 + 一次累加」:
算 C[i,j] 要做:
k 次乘法 A[i,p] · B[p,j] p = 0 … k−1
k−1 次加法 把 k 个乘积加起来
─────────────────────────────────────
= 2k − 1 次浮点运算
# 惯例上写成 2k —— 因为实现时累加器从 0 开始,第一项也做一次加法,
# 而且 k 一大,那个 −1 就无关紧要了(k=3584 时误差 0.014%)。
A = [1 2 3] B = [ 7 8] m=2, k=3, n=2
[4 5 6] [ 9 10]
(2×3) [11 12]
(3×2)
C[0,0] = 1·7 + 2·9 + 3·11 = 7 + 18 + 33 = 58 ← 3 乘 + 2 加 = 5 次运算
C[0,1] = 1·8 + 2·10 + 3·12 = 8 + 20 + 36 = 64 ← 5 次
C[1,0] = 4·7 + 5·9 + 6·11 = 28 + 45 + 66 = 139 ← 5 次
C[1,1] = 4·8 + 5·10 + 6·12 = 32 + 50 + 72 = 154 ← 5 次
────
真实次数 = 20
按公式 2·m·k·n = 2·2·3·2 = 24 ← 高估 4 次(就是那 4 个 −1)
精确式 m·n·(2k−1) = 4·5 = 20
# k=3 时差 17%;k=3584 时差 0.014%。大模型里直接用 2·m·k·n。
一个 token 的隐藏向量是【一行】: x 形状 [1, 3584] ← m = 1
q_proj 的权重: W 形状 [3584, 3584] ← k=3584, n=3584
FLOPs = 2 · m · k · n
= 2 · 1 · 3584 · 3584
= 2 · (3584 × 3584)
= 2 · 权重矩阵里的元素个数
= 2 × 12,845,056 = 25.69 MFLOP
# m=1 把 m 从式子里消掉了,于是 k·n 恰好就是权重的参数量。
# 这就是「FLOPs = 2 × 参数量」的全部来历 —— 它只对【每个 token】成立,
# batch 或 prefill 有 S 个 token 时,要再乘 S。
FMA:硬件一条指令,但仍然记 2 FLOPs
GPU 上 a·b + c 是一条融合乘加指令(FMA), 一个时钟周期出结果。它只占一条指令,FLOPs 却仍然记 2 次运算。
原因有两个。第一,FLOPs 数的是算术运算的个数,不是指令条数。 第二,NVIDIA 标称的峰值算力也按 FMA=2 算: 4090 的 165 TFLOPS(Tensor Core 的 BF16 稠密峰值) 也是按 FMA = 2 次运算标的。 分子和分母用同一套口径,MFU 才有意义。两边都改成 1,比值不变;只改一边就错了一倍。
| 运算 | 为什么不在 2N 里 | 约 MFLOP/token | 占比 |
|---|---|---|---|
| embedding 查表 | gather 操作,一次乘加都没有 | 0 | 0% |
| RMSNorm ×2×28 层 | 逐元素,约 4·d 次运算 | 0.80 | 0.006% |
| SiLU + 逐元素乘 ×28 | 逐元素,约 6·ffn 次 | 3.18 | 0.024% |
| RoPE ×28 | 逐元素旋转,每对 6 次 | 0.34 | 0.003% |
| bias 加法 ×28 | 每个元素 1 次加 | 0.13 | 0.001% |
| softmax ×28 | 按行归一,与上下文长度成正比 | 随 T 变 | 小 |
| 逐元素合计 | ≈ 4.5 | 0.03% | |
| 注意力核 QKᵀ / PV | 没有权重,必须单独算 | 见 05.4 节 | S² 项 |
0.03% 的 FLOPs,却可能吃掉百分之几的时间
逐元素运算的算力占比可以忽略,但访存占比不能忽略。 每个元素都要读一次、写一次,却只做几次运算,算术强度极低。 decode 本来就受带宽限制,这些"不算 FLOPs"的操作会占掉可观的墙钟时间。 所以各家推理引擎都做 kernel fusion:把 RMSNorm、RoPE、SiLU 融进相邻 kernel。
总参数 7,615,616,512
− embed_tokens 544,997,376 查表,不是矩阵乘
─────────────────
= 参与矩阵乘的参数 7,070,619,136 × 2 = 14.14 GFLOP / token
其中拆开看:
主干(28 层 + 末位 norm) 6,525,621,760 × 2 = 13.05 GFLOP / 每个 token 都要过
lm_head 544,997,376 × 2 = 1.09 GFLOP / 只有【需要 logits】的位置要过
# 前提一:norm / bias 那 33 万个参数(层内 norm 200,704 + qkv bias 129,024 + 末位 norm 3,584)
# 其实不是矩阵乘,混在里面误差 0.005%,可以不管。
# 前提二:prefill 时 lm_head 只在【最后一个位置】做 —— 中间 1023 个 token 不需要 logits。
# 这一条在下面的实测里会被验证出来。
Round 2 的压测给 vLLM 开了 --enable-mfu-metrics,
服务端把每次迭代实际执行的 FLOPs 累加进 /metrics。
下面用这个计数器核对上面的模型,负载是 1024 输入 / 256 输出:
预测每条请求:
主干矩阵乘 1280 tok × 13.05 = 16,705.6 GFLOP prefill 1024 + decode 256
lm_head 257 次 × 1.09 = 280.1 GFLOP 256 个输出 + prefill 末位 1 个
注意力核 prefill(因果) = 210.5 GFLOP 200,704 × 1024²
注意力核 decode = 118.3 GFLOP 401,408 × Σ(1024…1279)
──────────────────────────────────────────────
合计 17,314.5 GFLOP ÷ 256 个输出 token = 67.63 GFLOP / 输出token
| 并发 | 实测 TFLOP/s | 输出 tok/s | 实测 GFLOP/输出token | 偏差 |
|---|---|---|---|---|
| 1 | 4.16 | 61.9 | 67.21 | −0.6% |
| 2 | 7.94 | 118.7 | 66.89 | −1.1% |
| 4 | 15.00 | 225.1 | 66.64 | −1.5% |
| 8 | 27.27 | 411.4 | 66.29 | −2.0% |
| 16 | 46.14 | 681.5 | 67.70 | +0.1% |
| 32 | 70.43 | 1026.7 | 68.60 | +1.4% |
| 64 | 94.61 | 1387.8 | 68.17 | +0.8% |
±2% 以内,而且吞吐跨了 22 倍
这条公式可以直接用于容量规划,不用每换一个配置就重测一次。 吞吐从 61.9 tok/s 到 1387.8 tok/s,实测值都在 66.3 ~ 68.6 之间。
数据还反推出:vLLM 只在 prefill 的最后一个位置计算 logits,即上面的"前提二"成立。 如果按每个 token 都做 lm_head 算, 预测是 71.99 GFLOP,和实测差 6.4%,对不上。
拿去算 MFU
MFU = 实测 FLOP/s ÷ 峰值 FLOP/s。 如果服务端没有 FLOPs 计数器,就用 吞吐(输出 tok/s) × 每输出token的 GFLOP 估算。
同一颗芯片,本轮 C=64 的 MFU 是 C=1 的 23 倍: C=64 时 94.61 ÷ 165.2 = 57.3%; C=1 时 4.16 ÷ 165.2 = 2.5%。 原因是 batch 越大,14.14 GB 权重的读取被摊得越薄,算力才能被喂满。 配套压测报告《Qwen2.5-7B 并发曲线》中"两堵墙"一节(Figure 4)的数据就来自这里。
四个矩阵不对称:q_proj 和 o_proj 是 3584 → 3584 的方阵,
k_proj / v_proj 只输出 512,
因为 K/V 只有 4 个头:4 × 128 = 512。GQA 的原理全在这一步。
Attention 没有参数,切头(05.1)只是一次 reshape;之后它做五步:QKᵀ 打分 → 缩放 → 掩码 → softmax 归一 → 乘 V 加权求和。 只有第 1 步和第 5 步是矩阵乘,其余是逐元素或按行操作。
q_proj 出来 [B, S, 3584]
│ view(B, S, 28, 128) ← 3584 = 28 × 128
[B, S, 28, 128]
│ transpose(1, 2)
[B, 28, S, 128] ← 头维提到前面当 batch
│
28 个 [S,128] 的矩阵并行做上面五步
# "多头"不是 28 组不同的权重,而是【一个大矩阵的输出被切成 28 段】。
# q_proj 的 3584 个输出通道,前 128 个属于头 0,接下来 128 个属于头 1……
Q [B, 28, S, 128] 28 个头,各不相同
K [B, 4, S, 128] 只有 4 组
V [B, 4, S, 128]
↓ repeat_interleave(7, dim=1) ← 每组复制 7 份
K' [B, 28, S, 128] 头 0-6 用同一份 K,头 7-13 用下一份……
V' [B, 28, S, 128]
# 关键:复制发生在【寄存器/共享内存里】,不是在 KV Cache 里。
# 缓存的永远是那 4 组,所以显存省 7 倍;
# 参与矩阵乘的是 28 组,所以 FLOPs 一分不省。
GQA 除了省显存,还有第二重收益:decode 受访存限制时,
同一份 K/V 留在 L2 里,7 个头只付一次带宽。
原因是 FlashAttention 这类 kernel 不做物理 repeat,
而是让 7 个 Q 头直接索引同一块 K/V,7 个头读的是同一段显存。
RoPE 没有参数。它把 Q 和 K 的 128 维两两配对成 64 个复数, 第 i 对按角度 θᵢ · pos 旋转:
对第 i 对分量 (x₂ᵢ, x₂ᵢ₊₁),位置 pos:
x'₂ᵢ = x₂ᵢ·cos(θᵢ·pos) − x₂ᵢ₊₁·sin(θᵢ·pos)
x'₂ᵢ₊₁ = x₂ᵢ·sin(θᵢ·pos) + x₂ᵢ₊₁·cos(θᵢ·pos)
θᵢ = base^(−2i/128) Qwen2.5-7B 的 base = 1,000,000
# 为什么这样设计:两个向量旋转后做点积,结果只依赖【相对位置差】,
# 与绝对位置无关 —— 这就是"相对位置编码"的由来。
RoPE 只作用于 Q 和 K,不碰 V
原因:位置信息决定"谁该关注谁"(打分,即 QKᵀ), 不改变"关注到什么内容"(取值,即 V)。 工程上的后果是:KV Cache 里存的 K 已经旋转过, 所以 KV Cache 不能跨位置复用。同一段文本出现在不同位置,K 的值不同。
注意力核的五步里,只有第 ① 步和第 ⑤ 步是矩阵乘,其余(缩放、掩码、softmax)是逐元素或按行操作, 所以只数这两步。先算单个头、单层、序列长度 S:
# ① scores = Q @ Kᵀ
[S, hd] @ [hd, S] → [S, S]
按 2·m·k·n: 2 · S · hd · S = 2·hd·S²
# ⑤ out = P @ V
[S, S] @ [S, hd] → [S, hd]
按 2·m·k·n: 2 · S · S · hd = 2·hd·S²
# 两步相加
单头单层(完整) = 4·hd·S² = 4 × 128 × S² = 512·S²
再分三步放大或缩小:
① × h = 28 个 Query 头 512·S² × 28 = 14,336·S²
② 因果掩码,只算下三角 ÷ 2 14,336·S² ÷ 2 = 7,168·S²
上三角的 scores 反正会被 −∞ 掩掉,kernel 直接跳过不算;
PV 那一步 P 是下三角矩阵,第 i 行只需要累加 j ≤ i 的项,同样省一半。
③ × L = 28 层 7,168·S² × 28 = 200,704·S²
↑ 这就是那个常数
⚠️ 这里用的是 h = 28,不是 kv = 4
注意力核的 FLOPs 按 Query 头数算。 GQA 把 K/V 压到 4 组,但计算时它们会广播成 28 份(见上文), 28 个 Query 头每一个都要完整地算一遍 QKᵀ 和 PV。 如果错用 kv=4,常数会变成 28,672,只有正确值的 1/7。
这是本文反复强调的那句话的量化版本:GQA 省的是显存,不是算力。
# decode:只有 1 个 query,但要看 T 个历史位置
① q[1, hd] @ Kᵀ[hd, T] → [1, T] 2 · 1 · hd · T = 2·hd·T
⑤ p[1, T] @ V[T, hd] → [1, hd] 2 · 1 · T · hd = 2·hd·T
─────────────────────────
单头单层 = 4·hd·T × h=28 × L=28 = 401,408 · T
# 为什么恰好是 prefill 常数(200,704)的 2 倍?
# 因为 decode 【没有因果折半】—— 那唯一的 query 要看【全部】T 个历史位置,
# 一个都不能跳。少了 ÷2,常数就翻倍。
一致性检查:两条路算出来是同一个数
逐 token 生成长度为 S 的序列,注意力总开销应该等于一次性 prefill 这 S 个 token:
逐 token 累加 Σ 401,408 · t (t = 0 … S−1)
= 401,408 · S(S−1)/2
≈ 200,704 · S² ← 和 prefill 公式一模一样
S = 8,192 实算: 1.3467e13 vs 1.3469e13 比值 0.9999
两条独立的推导得到同一个式子,说明常数正确。
式子里的 T 是 KV Cache 里已有的行数, 每生成一个 token 就 +1。这些行怎样积累、为什么不用重算, 见下文 KV Cache:决定并发上限的那块内存 一节。
# 分母口径:prefill 时 lm_head 只在【最后一个位置】跑一次,不是每个 token 都跑
权重矩阵乘 = 13.05·S + 1.09 GFLOP 主干 13.05/token + lm_head 一次
注意力核 = 200,704 · S² FLOPs 因果
交叉点: 13.05e9 · S = 200,704 · S² → S = 65,021
| S | 权重矩阵乘 TFLOP | 注意力核 TFLOP | 注意力核占比 | 单头 S×S 矩阵 |
|---|---|---|---|---|
| 1,024 | 13.36 | 0.21 | 1.6% | 2 MiB |
| 2,048 | 26.73 | 0.84 | 3.1% | 8 MiB |
| 4,096 | 53.45 | 3.37 | 5.9% | 32 MiB |
| 8,192 | 106.91 | 13.47 | 11.2% | 128 MiB |
| 16,384 | 213.81 | 53.88 | 20.1% | 512 MiB |
| 32,768 | 427.62 | 215.50 | 33.5% | 2.0 GiB |
| 65,024 | 848.56 | 848.60 | 50.0% | 7.9 GiB |
| 131,072 | 1710.49 | 3448.07 | 66.8% | 32.0 GiB |
结论:6.5 万 token 以内,Transformer 的算力大头始终是那 7 个权重矩阵乘,
不是常说的"注意力平方复杂度"。
Qwen2.5-7B 的 max_model_len 上限是 32768,
所以在这个模型的整个可用范围内,注意力核最多只占三分之一。
在短上下文场景优化注意力核,收益很小。
但真正的墙不是 FLOPs,是显存 —— 这才是 FlashAttention 存在的理由
表格最后一列是 S×S 分数矩阵的体积。单个头在 S=8192 时是 128 MiB; 28 个头同时物化,一层就要 3.5 GiB。 到 S=32768,单头 2 GiB,28 头 56 GiB, 任何一张卡都放不下。
长上下文最先撞上的是显存墙,不是算力墙。 这时注意力核的 FLOPs 占比只有 33.5%,远没到"算不动"的程度。 FlashAttention 的贡献是不把这个矩阵物化: 分块进 SRAM 计算,用 online softmax 边算边归一。 显存因此从 O(S²) 降到 O(S), FLOPs 没有减少,数学结果完全一致。
被忽略的零头:softmax 和缩放
这两项都作用在S×S 的分数矩阵上,也是 S² 量级, 但常数小得多:
# softmax:对每一行做数值稳定版归一,逐元素数是 5 次
① 找本行最大值 max 1 次/元素
② 减去最大值 x − max 1 次/元素 防 exp 溢出
③ 取指数 exp(·) 1 次/元素
④ 累加求和 Σ 1 次/元素
⑤ 除以和 ÷ Σ 1 次/元素
──────────
5 次/元素
因果只有下三角 → 元素数 = S²/2
5 × (S²/2) × h=28 × L=28 = 5 × 784 ÷ 2 · S² = 1,960 · S²
# 缩放 ÷√128:每个元素就 1 次乘法
1 × (S²/2) × 28 × 28 = 784 ÷ 2 · S² = 392 · S²
# 和注意力核的两次矩阵乘比
softmax 1,960 ÷ 200,704 = 0.98%
缩放 392 ÷ 200,704 = 0.20% 两者都可以忽略
有两点要说明。第一,exp 的实际代价不止 1 次运算: 它在 GPU 上走特殊函数单元(SFU),吞吐比 FMA 低好几倍。 所以"5 次"是偏乐观的粗算。即使按实际代价乘 4~5 倍,也只有几个百分点, 仍然可以忽略。
第二,缩放在实际实现里几乎不花钱。把 1/√128 提前乘进 Q(只有 S×128 个元素), 不乘进 scores(S²/2 个元素), 开销就从 392·S² 降到 100,352·S, 从平方降到线性。原开销在 S=8192 时是新开销的 32 倍,S=32768 时是 128 倍。
softmax 是 FlashAttention 唯一真正棘手的地方: 它需要全行的最大值和总和才能归一,而分块计算时拿不到全行。 解法是 online softmax:每处理一块,就更新一次 running max 和 running sum, 并按比例修正之前的部分结果。这 1% 的运算量,决定了整个算法能不能成立。
GQA 让 7 个 Q 头共用同一组 K/V;Multi-Head Attention 里每个 Q 头配一个自己的 K/V 头。 两者相比,计算量几乎不变(K/V 广播复制到 28 份参与运算),但GQA 需要缓存的 K/V 只有 MHA 的 1/7。
一层 MLP 的参数抵得上 7 层注意力。SwiGLU 用三个矩阵,传统 FFN 只用两个; 中间维度 18944 是 hidden 的 5.29 倍, 每个矩阵都有 6790 万参数。
gate 过 SiLU 后当"开关",up 是被门控的内容;两者相乘后,down 把维度压回 3584。
三个矩阵一样大,所以参数量从传统 FFN 的 2·d·ffn 变成 3·d·ffn。
传统 FFN 是 out = W₂ · σ(W₁·x) —— 一个线性层、一个激活函数、再一个线性层。 SwiGLU 把中间那步拆成两条并行的投影:一条当"内容",一条当"闸门",两条逐元素相乘:
gate = x @ W_gate [B,S,3584] @ [3584,18944] → [B,S,18944]
up = x @ W_up [B,S,3584] @ [3584,18944] → [B,S,18944]
h = SiLU(gate) ⊙ up ← ⊙ 是逐元素乘,不是矩阵乘
out = h @ W_down [B,S,18944] @ [18944,3584] → [B,S,3584]
SiLU(z) = z · sigmoid(z) = z / (1 + e^−z)
代价:参数从 2·d·f 变成 3·d·f
Qwen2.5-7B 没有按惯例缩小 f。 业界惯例是把 f 缩到 8d/3,抵消多出来的 50% (d=3584 时 f 为 9557,参数量正好等于 f=4d 的传统 FFN)。 Qwen2.5-7B 取 f = 18944 = 5.29 d, MLP 参数为 203.7 M/层,是"等参数惯例"的 1.98 倍。
这是明确的架构取舍:把预算压在 MLP 而不是注意力上。 工程上的后果是:MLP 占全模型 74.9% 的参数, 量化和稀疏化的收益几乎全在这里。第 10 节的 MoE 改的也正是这一块。
| 组件 | 形状 | 单个 | 数量 | 小计 |
|---|---|---|---|---|
| embed_tokens | 152064 × 3584 | 544,997,376 | 1 | 544,997,376 |
| lm_head | 3584 × 152064 | 544,997,376 | 1 | 544,997,376 |
| q_proj | 3584 × 3584 | 12,845,056 | 28 | 359,661,568 |
| k_proj | 3584 × 512 | 1,835,008 | 28 | 51,380,224 |
| v_proj | 3584 × 512 | 1,835,008 | 28 | 51,380,224 |
| o_proj | 3584 × 3584 | 12,845,056 | 28 | 359,661,568 |
| qkv bias | 3584 + 512 + 512 | 4,608 | 28 | 129,024 |
| gate_proj | 3584 × 18944 | 67,895,296 | 28 | 1,901,068,288 |
| up_proj | 3584 × 18944 | 67,895,296 | 28 | 1,901,068,288 |
| down_proj | 18944 × 3584 | 67,895,296 | 28 | 1,901,068,288 |
| 层内 RMSNorm ×2 | 3584 × 2 | 7,168 | 28 | 200,704 |
| final norm | 3584 | 3,584 | 1 | 3,584 |
| 总计 | 7,615,616,512 |
× 2 字节 = 权重体积
7,615,616,512 × 2 = 15,231,233,024 B ——
即 15.23 GB(÷10⁹)或 14.18 GiB(÷2³⁰)。
两个数是同一批字节,只是单位不同。nvidia-smi 和 vLLM 日志报的是 GiB。
权重是死的,KV Cache 是活的。每来一个 token,28 层各存一份 K 和一份 V。 存下来的东西下一步还要用,所以生成第 1000 个 token 不需要重算前 999 个。
上面那句"存下来的东西下一步还要用"是结论。下面展开讲机制, 它回答三个问题:cache 里存什么、每步怎么变、为什么旧的永远不用重算。
# 已经生成到第 t 个 token,cache 里躺着 t 行 K 和 t 行 V(每层各一份)
① 新 token 的隐藏向量 h [1, 3584]
│
├─▶ q = h @ W_q → [1, 128] × 28 头 ← 只有 1 行,这就是"query 只有一个 token"
├─▶ k = h @ W_k → [1, 128] × 4 头 ← 追加进 cache,变成第 t+1 行
└─▶ v = h @ W_v → [1, 128] × 4 头 ← 同上
② 注意力:q 和 cache 里【全部 t+1 行】算
scores = q[1,128] @ Kᵀ[128, t+1] → [1, t+1]
out = P[1,t+1] @ V[t+1,128] → [1, 128]
③ 拼头 → o_proj → MLP → lm_head → 采样 → 第 t+2 个 token
# 下一步重复 ①~③,cache 再长一行。
这个流程的理解是对的
"每步 query 只有一个 token,它和之前所有 token 算注意力,然后预测下一个" —— 这个理解正确。 401,408 · T 式子里的 T, 就是第 ② 步里 cache 的行数。
第 ① 步的 W_k 和第 ② 步的 K 名字相近,却是完全不同的两个东西,
只是都存放在显存里。读这段流程时最容易把两者混淆,所以单独拆开讲。
━━━ 静态权重 · 训练出来的 · 加载时定死 · 所有请求共用同一份 ━━━━━━━━
h [1,3584] ──@ W_q [3584,3584] ──▶ q [1,3584] ──▶ 用完即弃
──@ W_k [3584, 512] ──▶ k [1, 512] ──┐
──@ W_v [3584, 512] ──▶ v [1, 512] ──┤ 追加一行
▼
━━━ 动态缓存 · 运行时算出来 · 每请求私有 · 只增不改 ━━━━━━━━━━━━━━
K cache [t+1, 512] V cache [t+1, 512]
│
q 和整个 cache做注意力(第 ② 步)
# W_k 是"怎么把 h 变成 k"的规则;K cache 是"历史上所有 h 变出来的 k 攒在一起"。
# 前者是函数,后者是这个函数的输出记录。
| 维度 | W_k / W_v(参数矩阵) | K / V(cache) |
|---|---|---|
| 是什么 | 训练出来的投影矩阵 | 历史 token 过完投影后的结果 |
| 形状 | [3584, 512] · 固定 | [T, 512] · 随 T 增长 |
| 什么时候有的 | 加载模型时,一次性 | 运行时,prefill 批量写 + decode 逐行追加 |
| 会不会变 | 永不变 | 只增不改 —— 已写入的行不动 |
| 每层体积 | 1,835,008 参数 × 2 B = 3.5 MiB(各) | T × 512 × 2 B = T KiB(各) |
| 住在哪块显存 | 权重区(启动日志的 15.49 GiB) | KV 池(启动日志的 4.64 GiB) |
| 谁分配 | 加载时一次性分配 | PagedAttention 按 block 动态分配 |
| 请求之间 | 所有请求共享同一份 | 每个请求私有一份 |
| 随并发变化 | 不变 —— 固定成本 | 正比于 并发 × 上下文 —— 可变成本 |
算一下就知道谁是并发的瓶颈
全部 28 层的 W_k 和 W_v 共
102,760,448 个参数,即 196 MiB。不管并发多少,都是这么多。
一条 1024+256 的请求,cache 峰值是 70 MiB。
所以 并发到 3 时,cache 就超过了生成它的那套投影矩阵;
并发 64 时,cache 是 4.38 GiB,是 W_k/W_v 的 23 倍。
所以并发上限由 KV 池决定,而不是由权重决定。 权重是一次性付清的固定成本;cache 是随业务规模线性增长的可变成本。
prefill 和 decode 用的是同一套 W,只是批量不同
cache 里的行,是 prefill 阶段用同一个 W_k 一次算完整个 prompt 后写进去的:
prefill H [1024,3584] @ W_k → [1024,512] 一次写入 1024 行
decode h [ 1,3584] @ W_k → [ 1,512] 每步追加 1 行
↑ 同一个 W_k,同一块 cache,只是一次喂几行不同
完整的时间线是:prefill 把 prompt 的 K/V 批量灌进 cache → decode 每步补一行 → 每步都拿新 q 去问整个 cache。 至此,cache 内容的来源、增长方式和读取者都讲清楚了。
一步 decode 算出很多东西,但绝大部分算完就丢:
| 产物 | 形状 | 进 cache | 为什么 |
|---|---|---|---|
| Q | [1,128] × 28 头 | ✗ | "谁在问"。问完即弃 —— 下一步的 query 是新 token 的,旧 q 再也用不到 |
| K | [1,128] × 4 头 | ✓ | "被谁问"。后面每一步都要拿新的 q 来问它一遍 |
| V | [1,128] × 4 头 | ✓ | "问到什么"。同上 |
| scores / P | [1, T] | ✗ | 依赖当前 q,换个 query 整行全变 |
| 注意力输出 | [1, 3584] | ✗ | 同上 |
| MLP 中间激活 | [1, 18944] | ✗ | 纯中间量,出了这一层就没用 |
| logits | [152064] | ✗ | 采样完就扔 |
严格说,缓存的不是"中间结果",而是"后续每一步都要反复读取的那两个张量"。 Q 和 K/V 的这种不对称,是 KV Cache 设计的起点。
存进去的 K 是 RoPE 之后的
cache 和绝对位置绑定。位置编码在投影之后、写入 cache 之前就已转好(见上文 RoPE:位置信息是"转"进去的)。 同一段文本出现在不同位置,K 的数值不同,所以缓存不能平移复用。 这也是 prefix caching 只能复用前缀、不能复用中间片段的原因。
q 和 out 每步都重新算、算完就扔;
实线柱是 cache,每步只在末尾追加一行,已有的行一个字节都不改。
第三列里"第 1,025 行"已从高亮变成普通行:
它上一步刚写入,这一步就成了不可变的历史。
核心:为什么 K/V 一经算出就永不改变
K/V 不变是 KV Cache 成立的唯一前提,它只有一个来源: 因果掩码。
因果掩码 ⇒ 位置 i 的隐藏状态 h_i 只依赖位置 ≤ i 的输入
⇒ 在末尾追加 token t+1,【不会改变】任何 h_i (i ≤ t)
⇒ K_i = W_k · h_i 和 V_i = W_v · h_i 恒定
⇒ 算过一次,就可以永久复用
反例:BERT 那种双向注意力里,每个位置都能看到全序列, 加一个 token 会改写所有位置的表示,所以 KV Cache 在那里不成立。 "缓存能存在"不是工程技巧,是因果掩码这个建模选择白送的。
# T = 1152 时,生成下一个 token 要花多少 FLOP
有 cache 13.05e9(主干矩阵乘) + 401,408 × 1152(注意力) = 1.35e10
无 cache 1152 × 13.05e9 + 200,704 × 1152² = 1.53e13
↑ 每生成一个 token 都要把前面 1152 个重新 prefill 一遍
─────────────
省 1,132 倍
# 代价是显存:每请求 T × 56 KiB,并且【不能丢】——丢了就得重算。
# 这就是为什么 KV 池是并发上限的硬约束,而不是权重。
为什么缓存 K/V,而不是缓存每层的输入 h
理论上也可以只存 h_i,用到时再投影出 K/V。两条路对比:
存 K,V(GQA kv=4) 2 × 4 × 128 × 2 = 2 KiB / 层 / token → 28 层 56 KiB
存 h(hidden 3584) 3584 × 2 = 7 KiB / 层 / token → 28 层 196 KiB
GQA 下存 K/V 省 3.5 倍
若换成 MHA(kv=28) 2 × 28 × 128 × 2 = 14 KiB / 层 → 反而比存 h 大 2 倍
算力上的代价更大:存 h 的话,每一步都要把全部 T 个历史位置重新投影成 K 和 V,多出 T × 2 × d × kv·hd 的矩阵乘,缓存就失去了意义。
所以两方面的比较都指向存 K/V。GQA 让这个选择在显存上也占优, 这是 GQA 的第三重收益(前两重:KV 体积只有 MHA 的 1/7、7 个头共享一份 L2)。
每步新增 56 KiB 恒定 —— 与 T 无关、与 batch 无关
每步读取 T × 56 KiB 随上下文线性增长
T = 1,152 → 0.066 GB 占权重 14.14 GB 的 0.47%
T = 8,192 → 0.470 GB 占 3.32%
T = 32,768 → 1.879 GB 占 13.29%
# 短上下文时 KV 读取可以忽略(这就是单流分析只看权重的依据);
# 到 32k 就占了一成三 —— 长上下文场景 decode 会明显变慢,慢在这里。
本轮压测形状(1024 输入 / 256 输出)一条请求的 cache:
开始解码时 T=1,024 → 56.0 MiB
解码到一半 T=1,152 → 63.0 MiB
解码结束时 T=1,280 → 70.0 MiB
下一小节要用最后那个 70 MiB: KV 池 ÷ 单请求峰值占用 = 能同时跑的请求数。
手算和服务端实测差得比预想大。下面先手算,再对照实测。
# 手算(乐观估计)
显存总量 24 GiB
--gpu-memory-utilization 0.9 → 21.6 GiB 可用
− 权重 14.18 GiB
− CUDA context / 激活 / 碎片 ≈ 0.5 GiB
────────────────────────────────────────
KV Cache 池 ≈ 6.9 GiB → 约 129,000 token
# 服务端启动日志实测(同一张卡、同样 0.9)
Available KV cache memory: 4.64 GiB
GPU KV cache size: 86,896 tokens
Maximum concurrency for 8,192 tokens per request: 10.61x
Actual usage: 15.49 GiB 权重+non-torch,1.04 GiB 激活峰值,0.34 GiB CUDA graph
# 手算高估了 49% —— 杂项那一项实际是 ~2.8 GiB 而不是 0.5
# (21.6 − 权重 14.18 − KV 4.64 = 2.78;上面日志三项 15.49+1.04+0.34 也对得上)
86,896 token ÷ 平均上下文长度 = 能开的并发
平均 1,280 token / 请求 → 约 67 并发 (1024 输入 + 256 输出)
平均 2,048 token / 请求 → 约 42 并发
平均 4,096 token / 请求 → 约 21 并发
这个数每台机器都不一样,必须从日志读
配套压测第二轮的 KV 池比第一轮大 38%。 第二轮换了一台实例(驱动 580.159.03 / CUDA 13.0,第一轮是 590.48.01 / CUDA 13.1), 同样的启动参数量到 6.41 GiB / 120,064 token / 14.66x。 差异来自权重占用和激活峰值的 profiling 结果,它们随驱动版本波动。
手算只用来估数量级,真实值一律以启动日志的
GPU KV cache size 一行为准。
这就是抢占的来源
并发升高、上下文变长后,一旦这 86,896 个槽位不够分,vLLM 就会抢占部分请求:把它们的 KV 换出(swap),或直接丢弃再重算(recompute)。
被抢占的请求恢复时会出现一次几百毫秒的空档,这就是 P99 ITL 上的那根长尾。
日志里 Preemption 计数不为零,说明负载已经压到这条线。
MoE(Mixture of Experts)只改 MLP 这一块:注意力分支、残差流、RMSNorm、KV Cache 全部原样不动, 只把每层 203.7 M 的 SwiGLU 换成"一堆小 SwiGLU + 一个路由器"。 第 07 节算过,MLP 占全模型 74.9% 的参数。
为什么用 Qwen2-57B-A14B 当例子
它和 Qwen2.5-7B 的注意力几何完全相同:
hidden 3584 · 28 层 · 28 Q 头 · 4 KV 头 · head_dim 128,
连 intermediate_size 18944 都一样。
主要差别是 MLP 换成了 MoE 块(另外词表略小:151,936 vs 152,064),
所以这是一次干净的单变量对照。
因此两个模型的 KV Cache 单价相同,都是 56 KiB/token。 MoE 不改变 KV 的大小,和上一节的容量公式无关。
config.json 里单独的一个字段,见下方拆解。
20480 不是从 18944 或 2560 推算出来的,而是
config.json 里一个独立的字段。
Qwen2-57B-A14B 的 MoE 层一共写了三个不同的中间维,很容易看混:
config.json 字段 | 值 | 用在哪里 |
|---|---|---|
intermediate_size | 18,944 | 继承自稠密架构的字段。MoE 层里实际用不上,只是没删掉 |
moe_intermediate_size | 2,560 | 单个路由专家的宽度 × 64 个 |
shared_expert_intermediate_size | 20,480 | 就是图 12 里那个 20480 —— 共享专家一个人的宽度 |
不过这几个数不是随便定的。20480 和 2560 之间有一个简洁的关系:
moe_intermediate_size × num_experts_per_tok
2560 × 8 = 20480
↑ 正好是 shared_expert_intermediate_size
# 换句话说:共享专家一个人,就有那 8 个被选中的路由专家加起来那么宽。
所以一个 token 走完一层 MoE,经过的 FFN 宽度是 路由部分 20480 + 共享部分 20480 = 40960,两部分各占一半。 图 12 底部那行"8 专家 220.2 M + 共享 220.2 M"两个数相同,原因就在这里: 不是巧合,是 2560 × 8 = 20480 直接导致的。
# 共享专家内部就是一个普通的 SwiGLU,只是宽度用 20480
gate = x @ Wg[3584, 20480] → 73,400,320
up = x @ Wu[3584, 20480] → 73,400,320
out = h @ Wd[20480, 3584] → 73,400,320
──────────
3 × 3584 × 20480 = 220,200,960
gate = sigmoid(x @ W_sg[3584, 1]) → 3,584 共享专家自己的开关
──────────
220,204,544 ← 表里"共享专家/层"那一行
# 对照被激活的 8 个路由专家(它们没有各自的开关,权重由 router 给)
8 × 3 × 3584 × 2560 = 220,200,960
# 再加上 router 本身(3584 × 64,不管选谁都要跑一遍)
3584 × 64 = 229,376
──────────
每 token 的 FFN 激活 = 440,634,880 ← 表里"FFN 激活/层"那一行
它是一个独立的、只有一列的小线性层 W_sg,形状
[3584, 1],无 bias,所以参数量正好是 3,584 个。
它给每个 token 算出一个标量,经 sigmoid 压到 (0, 1),
再用这个标量整体缩放共享专家的输出:
y_shared = SharedExpert(x) [3584] 共享专家算出来的向量
g = sigmoid(x @ W_sg) 标量 范围 (0, 1)
y_shared = g · y_shared [3584] 整条向量同乘一个数
out = Σ wᵢ · Expert_idx[i](x) + y_shared
└── 路由部分,权重来自 router ──┘ └ 共享部分,幅度由 g 控制
它是共享专家的"音量旋钮": 模型逐 token 决定,这个 token 多依赖通用能力,还是多依赖被选中的那 8 个专家。 g 靠近 1 就是"多听共享专家的",靠近 0 就是"几乎只听路由专家的"。
| 名字 | 形状 | 每 token 输出 | 管什么 |
|---|---|---|---|
gate_proj (SwiGLU 内部) |
[3584, 20480] | 20480 个数 | 逐通道开关,每个中间通道各开各的(就是"MLP 分支"一节讲的那个门控) |
W_router |
[3584, 64] | 64 个数 → top-8 | softmax 竞争,决定选谁以及各自权重 wᵢ |
W_sg (shared_expert_gate) |
[3584, 1] | 1 个数 | 共享专家整体的幅度,不竞争、不选择 |
softmax 会互相抢,sigmoid 不会
三者的关键区别在激活函数。router 用 softmax,64 个分数互相抢,总和受约束。
W_sg 用 sigmoid,只有一个数,不和任何人比较。
所以共享专家的系数和那 8 个 wᵢ 不在同一个归一化体系里:
它是独立叠加的一项,这也是"共享"的含义。
这个门省不了任何计算
router 的 top-8 是结构性稀疏:没被选中的 56 个专家,三个矩阵都不读、不算。
W_sg 则是算完之后再乘:即使 g 是 0.01,
共享专家的 220 M 参数也已经全部读过、全部乘过。
所以它只影响输出的幅度,不影响 FLOPs 和带宽, 也不改变上面 440,634,880 那笔账。 下面的"稠密尾巴"说的就是这件事:门可以关小,但活已经干完了。
它的成本可以忽略:全部 28 层的 W_sg 共
28 × 3584 = 100,352 个参数,
只占共享专家的 0.0016%。
但 220,204,544 结尾的 544 来自它,
速查公式 3·d·sf + d 末尾的 d 也是它。
拿 20480 去除 18944,表里那两个倍数就对上了
10.3 节那张表的最后两个倍数不是另算的,就是中间维的比值。 因为 SwiGLU 的参数量恒为 3 · d · f,而两个模型的 d 相同:
激活宽度 20480 + 20480 = 40960 ÷ 18944 = 2.16× ← FFN 激活/层
全量宽度 2560×64 + 20480 = 184320 ÷ 18944 = 9.73× ← FFN 块合计/层
router 那 229,376 个参数没有"中间维"可言,但它只占 0.05%, 不影响这两个倍数的两位小数。
算上共享专家,一个 token 在 MoE 里走的 FFN 宽度是稠密 7B 的 2.16 倍。 共享专家一个就比整个 Qwen2.5-7B 的 MLP 宽 (20480 / 18944 = 1.08×)。 所以 MoE 的激活量大于稠密 7B,不是小于。
共享专家在工程上是个"稠密尾巴"
路由专家对每个 token 只激活 8/64,可以做专家并行(EP),也可以按需搬参数。 共享专家的 220 M 参数每个 token 都要读, 行为和稠密层一样:不能跳过,也不能靠稀疏省掉。
它是 decode 带宽账上一块无法稀疏化的固定开销。 全部 28 层的共享专家共 6.17 B 参数,即 12.3 GB(BF16), 占 14.25 B 激活量的 43%。 这也是 Qwen3 去掉共享专家、改成"专家更多更小"的一个现实动机。
# 输入:一个 token 的隐藏向量 x,形状 [3584]
logits = x @ W_router [3584] @ [3584, 64] → [64]
probs = softmax(logits) → [64] 和为 1
idx, w = topk(probs, k=8) → 8 个专家 id + 8 个权重
# Qwen2 的 norm_topk_prob = false → 直接用原始的 8 个概率当权重(和 < 1)
# Qwen3 的 norm_topk_prob = true → 把这 8 个重新归一化成和为 1
y = Σ wᵢ · Expert_idx[i](x) i = 1..8
+ SharedExpert(x) · sigmoid(x @ W_sg) ← Qwen2 特有
# 每个 Expert 内部就是上一节那个 SwiGLU,只是 ffn 从 18944 缩到 2560:
# gate = x @ Wg[3584,2560] ; up = x @ Wu[3584,2560]
# h = SiLU(gate) ⊙ up
# out = h @ Wd[2560,3584]
式子里的求和就是逐元素加法:不拼接,不再过融合矩阵,也不重新归一化。 展开写:
E₁(x) · w₁ [3584]
E₂(x) · w₂ [3584]
...
E₈(x) · w₈ [3584]
━━━━━━━━━━━━━━━━━━━━━━━━━ 逐位相加(8 路)
routed [3584]
shared · g [3584]
━━━━━━━━━━━━━━━━━━━━━━━━━ 再逐位相加一次
y [3584] ← MoE 块的输出
# 也就是 9 个 [3584] 向量的一次线性组合,系数是 w₁..w₈ 和 g。
为什么"能"相加:所有专家都吐回同一个 3584 维空间
这由结构决定。每个专家的最后一步 down_proj 都是
[·, 3584]:不管中间维是路由专家的
2560 还是共享专家的 20480,
出口一律映射回 3584。
所以这 9 个向量在同一个坐标系里,逐位相加才有意义。 这也是 MoE 能"只换 FFN、不动其它任何东西"的原因: 对残差流来说,MoE 块和普通 MLP 的接口完全一样,进 3584,出 3584。
⚠️ 加完之后不做归一化,幅度是"自由"的
Qwen2 的 norm_topk_prob = false,
那 8 个 wᵢ 是 softmax 的原始概率,
加起来通常小于 1(top-8 只占 64 个概率里的一部分)。
共享专家那一项的系数 g 则是完全独立的 sigmoid。
两边的幅度都不再重新缩放:最终 y 的模长靠训练自己学平衡, 没有哪一步把它拉回某个量级。 Qwen3 把 norm_topk_prob 改成 true, 先把路由那半边归一化到和为 1,让幅度更稳定; 代价是丢掉了"router 有多确信"这个信息。
别忘了残差:一层其实是 10 项相加
上面的 y 只是 MoE 块的输出,回到残差流时还要再加一次:
x = x + Attention(RMSNorm(x))
x = x + MoE(RMSNorm(x))
└─ 这里面已经是 8 路由 + 1 共享 的和了
从残差流看,一层 MoE 实际是 10 项逐位相加: 1 残差 + 8 路由 + 1 共享。
这些加法的开销可以直接忽略
每层每 token 约 17 × 3584 ≈ 6.1 万 次运算 (8 次缩放 + 7 次加 + 共享的 1 次缩放和 1 次加), 只占这一层 2 × 440.6 M = 881 MFLOP 矩阵乘的 0.007%。
这和前面"非矩阵乘开销"那张表的结论相同: 凡是逐元素的操作,在 FLOPs 账上都不用记。 真正贵的永远是矩阵乘和它要读的权重。
router 是"硬"选择,但梯度是"软"传的
topk 本身不可导。训练时,梯度通过权重 wᵢ 回传到路由器:
被选中的专家如果表现好,它的 wᵢ 会被推高,下次更容易被选中。
没被选中的专家在这一步拿不到梯度。
这带来一个经典问题:富者愈富。少数专家被反复选中、越练越好, 其余专家饿死。所以训练时必须加负载均衡损失(load balancing loss), 把路由分布推向均匀。这也是推理时可以近似假设"均匀路由"的原因, 下面那张表就基于这个假设。
| 组件 | Qwen2.5-7B(稠密) | Qwen2-57B-A14B(MoE) | 倍数 |
|---|---|---|---|
| hidden / 层数 / 头数 | 3584 / 28 / 28Q·4KV | 3584 / 28 / 28Q·4KV | 1.00× |
| attn / 层 | 29,364,736 | 29,364,736 | 1.00× |
| KV Cache / token | 56 KiB | 56 KiB | 1.00× |
| FFN 中间维 | 18,944 | 2,560 × 64 | — |
| router / 层 | — | 229,376 | — |
| 专家 / 层 | — | 1,761,607,680 | — |
| 共享专家 / 层 | — | 220,204,544 | — |
| FFN 块合计 / 层 | 203,685,888 | 1,982,041,600 | 9.73× |
| FFN 激活 / 层 | 203,685,888 | 440,634,880 | 2.16× |
| 总参数 | 7,615,616,512 | 57,408,658,944 | 7.54× |
| 激活参数 / token | 7,615,616,512 | 14,249,270,784 | 1.87× |
两个总数精确对上了模型名里的 57B 和 A14B(A 指 activated)。 稀疏率 14.25 / 57.41 = 24.8%, 即每个 token 只用到全模型四分之一的参数。
"只激活 14B 参数"对单个 token成立。 但服务端按批处理:batch 里不同的 token 会路由到不同的专家, 并集越大,要读进来的专家权重就越多。
| batch | 期望触及专家数 | 占比 | 要读的权重 | 相对稠密 57B 的访存优势 |
|---|---|---|---|---|
| 1 | 8.0 | 12.5% | 28.5 GB | 4.03× |
| 2 | 15.0 | 23% | 39.3 GB | 2.92× |
| 4 | 26.5 | 41% | 57.0 GB | 2.01× |
| 8 | 42.0 | 66% | 80.9 GB | 1.42× |
| 16 | 56.4 | 88% | 103.2 GB | 1.11× |
| 32 | 63.1 | 99% | 113.4 GB | 1.01× |
算力优势恒定,访存优势会蒸发
FLOPs 这一侧:无论 batch 多大,每个 token 都只过 8 个专家,即 2 × 14.25e9 = 28.5 GFLOP/token,是同尺寸稠密模型的 1/4,且恒定不变。
访存这一侧:batch 到 32 时,64 个专家全部会被触及, 要读的权重从 28.5 GB 涨到 113.4 GB,和稠密 57B(114.8 GB)几乎相同,优势归零。
MoE 两头都省在瓶颈上,这才是它真正划算的地方。 本系列压测的结论是:decode 在小 batch 时是访存瓶颈,大 batch 时转向算力瓶颈。 MoE 在小 batch 省访存(那时本来就不缺算力),在大 batch 省算力(那时正好卡在算力上)。
| 项 | Qwen2-57B-A14B | Qwen3-30B-A3B | Qwen3-235B-A22B |
|---|---|---|---|
| hidden / 层数 | 3584 / 28 | 2048 / 48 | 4096 / 94 |
| 专家数 | 64 | 128 | 128 |
| 每 token 选 | 8 | 8 | 8 |
| 单专家 ffn | 2560 | 768 | 1536 |
| 共享专家 | 有(ffn 20480) | 无 | 无 |
| top-k 归一化 | 否 | 是 | 是 |
| 总参数 | 57.41 B | 30.53 B | 235 B |
| 激活参数 | 14.25 B | 3.35 B | 22 B |
| 稀疏率 | 24.8% | 11.0% | 9.4% |
Qwen3 的一个精巧设计:激活宽度恰好等于稠密对照组
Qwen3-30B-A3B 的 moe_intermediate_size = 768,
每 token 选 8 个,768 × 8 = 6144;
它自己 config.json 里的 intermediate_size 正是
6144。
它相当于把一个稠密 FFN 沿中间维切成 128 片,每次拿 8 片拼回原来的宽度。 激活的 FLOPs 与同架构稠密模型完全相同, 参数容量是它的 16 倍。 Qwen2 那一代的激活量是稠密的 2.16 倍;相比之下,Qwen3 的取向更激进: 专家更多更小、稀疏率更高、不留共享专家。
拿到 config.json,用下面两个公式就能算出权重体积和 KV Cache 单价:
d = hidden_size L = num_hidden_layers
h = num_attention_heads kv = num_key_value_heads
f = intermediate_size V = vocab_size
hd = d / h ← head_dim
参数量
attn/层 = d·d + 2·d·(kv·hd) + d·d Qwen2.5-7B → 29,360,128
qkv bias = d + 2·(kv·hd) → 4,608 Qwen2/2.5 有,多数模型没有
mlp /层 = 3·d·f (SwiGLU;传统 FFN 是 2·d·f) → 203,685,888
norm/层 = 2·d → 7,168
──────────────────────────────────────────────────────────────────────
总 = L·(attn + bias + mlp + norm) + (1 或 2)·V·d + d → 7,615,616,512
↑ tie_word_embeddings 决定 1 还是 2
权重体积 = 参数量 × dtype_bytes fp16=2 fp8=1 int4≈0.5
KV Cache
每 token = 2 · L · kv · hd · dtype_bytes → 57,344 B = 56 KiB
可容纳 = KV池字节数 ÷ 每token字节数 → 本机实测 86,896 token
↑ KV 池大小从启动日志读,不要手算(手算会高估约 50%)
压测前先算出这两个数当基准。实测吞吐接近
带宽 ÷ 每步读取字节,说明硬件已打满;
实测并发接近 KV 池 ÷ 每 token,说明该调 --max-num-seqs 或换量化了。
⚠️ 分母是 14.14 GB,不是 15.23 GB
算单流吞吐上限时,分母要用每步真正从显存读出的字节,
也就是参与矩阵乘的参数:全模型 7,615,616,512 减去
embed_tokens 的 544,997,376。embed_tokens 是查表,一个 token 只取 1 行(7 KB)。
单流上限 = 1008 GB/s ÷ 14.14 GB = 71.3 tok/s
而不是 1008 ÷ 15.23 = 66.2
实测反证:C=1 实测 61.9 tok/s
按 15.23 GB 反推 → 943 GB/s 超过实测的 887 GB/s,不可能
按 14.14 GB 反推 → 875 GB/s 与实测差 1.3% ✓
注意:lm_head 要算进去,它是真矩阵乘,每个 decode 步都要跑。
详细推导见上文"FLOPs 是怎么数出来的"。
e = num_experts k = num_experts_per_tok
mf = moe_intermediate_size sf = shared_expert_intermediate_size(没有就当 0)
MoE 块/层
router = d·e
专家全量 = e·3·d·mf
共享 = 3·d·sf + d (有共享专家才算,末尾的 d 是它的门)
────────────────────────────────────────────────
参数 = d·e + e·3·d·mf + 3·d·sf + d
激活 = d·e + k·3·d·mf + 3·d·sf + d
FLOPs/token = 2 × 激活参数量 ← 与 batch 无关,恒定
一个解码步真正要读的权重(batch = B,均匀路由假设)
触及专家数 ≈ e · (1 − (1 − k/e)^B)
权重字节 ≈ (常驻部分 + 触及专家) × dtype_bytes
B 大到 e·(1−(1−k/e)^B) → e 时,访存优势归零
KV Cache 与 MoE 【完全无关】,仍是 2·L·kv·hd·dtype_bytes
三个不变量
换成 MoE 后,注意力的形状、KV Cache 的单价、残差流的宽度都不变。 所以本文第 09 节算出的 KV 容量公式、并发上限、抢占阈值 对 MoE 模型原样适用,变的只有权重体积和 FLOPs 两栏。