架构拆解 · Session 4–6 配套

Qwen2.5-7B 结构拆解

本文把 7,615,616,512 个参数逐层摊开,讲清:每个张量的形状、每个矩阵怎么乘、参数集中在哪里; 注意力核内部五步各算什么;SwiGLU 的门控制什么; GQA 怎样把 KV Cache 压到原来的 1/7。KV Cache 这块内存决定并发上限。 最后一节用同代的 Qwen2-57B-A14B 说明 MoE 改了这套结构的哪一部分、哪一部分没改。

hidden
3584
layers
28
heads
28 Q / 4 KV
head_dim
128
ffn
18944
vocab
152064
静态权重 —— 加载时固定,不随请求变化 运行时内存 —— 随 token 数增长的 KV Cache

01先看全景:token 进去,logits 出来↑ 目录

整个模型分三段:查表变成向量 → 经过 28 个相同的 Decoder Layer → 投影回词表大小。 B 是 batch,S 是序列长度;数据流中只有最后一维会变。

token_ids [B, S] embed_tokens 152064 × 3584 = 545.00 M 查表,不是矩阵乘 [B, S, 3584] Decoder Layer × 28 Self-Attention (GQA) 29.36 M MLP (SwiGLU) 203.69 M 6,525,618,176 = 全模型 85.7% 形状进出不变 [B, S, 3584] norm · RMSNorm · 3,584 lm_head 3584 × 152064 = 545.00 M 与 embedding 不共享权重 [B, S, 152064] logits
图 1 · 整模型数据流。 输入 embedding 和输出 lm_head 是两份独立的 545 M 权重(tie_word_embeddings: false), 合计 1.09 B,占全模型 14.3%。 很多小模型让这两份共享(省一半),Qwen2.5-7B 不共享。

02一层的骨架:两个分支挂在残差流上↑ 目录

28 层结构完全相同。中间的竖线是 残差流(residual stream), 维度始终是 [B, S, 3584];两个分支各自算完,再加回残差流。 "Pre-Norm" 指归一化放在分支入口,不在出口。

x_in [B, S, 3584] x_out [B, S, 3584] input_layernorm RMSNorm · 3,584 Self-Attention GQA · 28 Q : 4 KV 29,364,736(含 bias) 残差相加 [B, S, 3584] post_attn_norm RMSNorm · 3,584 MLP (SwiGLU) 3584 → 18944 → 3584 203,685,888 每层合计 233,057,792 × 28 层 6,525,618,176 注意力 : MLP ≈ 1 : 6.9
图 2 · Decoder Layer 骨架。 残差流的宽度(3584)决定整个模型的"腰围":所有分支的入口和出口都必须回到这个宽度。 两个 RMSNorm 各只有 3,584 个参数(每维一个缩放系数),合计 7,168。 这个数在每层 2.33 亿 参数里可以忽略,但去掉 RMSNorm,训练会发散。

03每一步到底在乘什么↑ 目录

每层只有 7 次矩阵乘 + 1 次注意力核,其余都是逐元素运算。 上一节方框图里的每个框都是"一次矩阵乘"。这一节把它们逐个摊开: 输入形状、权重形状、输出形状和 FLOPs。

03.1先说清一个约定:权重存的是 [out, in]↑ 目录

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     ← 两种写法数量相同

# 本文表格一律用「输入维 × 输出维」写,因为那才是数据流的方向

03.2三维张量怎么和二维权重相乘↑ 目录

[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 的物理基础。

03.3一层里的全部矩阵乘↑ 目录

一个 Decoder Layer · d=3584 · FLOPs 按每 token 计(乘加各算 1)
#算子输入权重(输入维 × 输出维)输出FLOPs/token
1q_proj[B,S,3584]3584 × 3584[B,S,3584]25.7 M
2k_proj[B,S,3584]3584 × 512[B,S,512]3.7 M
3v_proj[B,S,3584]3584 × 512[B,S,512]3.7 M
—RoPEQ,K无参数同形状逐元素
—注意力核Q,K,V无参数[B,S,3584]见下节
4o_proj[B,S,3584]3584 × 3584[B,S,3584]25.7 M
5gate_proj[B,S,3584]3584 × 18944[B,S,18944]135.8 M
6up_proj[B,S,3584]3584 × 18944[B,S,18944]135.8 M
—SiLU × 逐元素乘[B,S,18944]无参数[B,S,18944]逐元素
7down_proj[B,S,18944]18944 × 3584[B,S,3584]135.8 M
一层合计466.1 M

03.4「FLOPs = 2 × 参数量」是怎么来的↑ 目录

算 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%)。

03.5一个能手算的例子↑ 目录

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。

03.6为什么一个 token 就是「2 × 权重数」↑ 目录

一个 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,比值不变;只改一边就错了一倍。

03.7哪些运算【没有】被这条公式算进去↑ 目录

Qwen2.5-7B 一个 token 的非矩阵乘开销 · 与 13,051 MFLOP 的主干对比
运算为什么不在 2N 里约 MFLOP/token占比
embedding 查表gather 操作,一次乘加都没有00%
RMSNorm ×2×28 层逐元素,约 4·d 次运算0.800.006%
SiLU + 逐元素乘 ×28逐元素,约 6·ffn 次3.180.024%
RoPE ×28逐元素旋转,每对 6 次0.340.003%
bias 加法 ×28每个元素 1 次加0.130.001%
softmax ×28按行归一,与上下文长度成正比随 T 变小
逐元素合计≈ 4.50.03%
注意力核 QKᵀ / PV没有权重,必须单独算见 05.4 节S² 项

0.03% 的 FLOPs,却可能吃掉百分之几的时间

逐元素运算的算力占比可以忽略,但访存占比不能忽略。 每个元素都要读一次、写一次,却只做几次运算,算术强度极低。 decode 本来就受带宽限制,这些"不算 FLOPs"的操作会占掉可观的墙钟时间。 所以各家推理引擎都做 kernel fusion:把 RMSNorm、RoPE、SiLU 融进相邻 kernel。

03.8整模型:14.14 GFLOP / token,以及它的两个前提↑ 目录

总参数                    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。
#         这一条在下面的实测里会被验证出来。

03.9用实测的 FLOPs 计数器验一遍↑ 目录

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
预测 67.63 vs 服务端计数器实测 · Round 2 · 七个并发档
并发实测 TFLOP/s输出 tok/s实测 GFLOP/输出token偏差
14.1661.967.21−0.6%
27.94118.766.89−1.1%
415.00225.166.64−1.5%
827.27411.466.29−2.0%
1646.14681.567.70+0.1%
3270.431026.768.60+1.4%
6494.611387.868.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)的数据就来自这里。

04注意力分支:四个矩阵,两种尺寸↑ 目录

四个矩阵不对称:q_proj 和 o_proj 是 3584 → 3584 的方阵, k_proj / v_proj 只输出 512, 因为 K/V 只有 4 个头:4 × 128 = 512。GQA 的原理全在这一步。

hidden [B, S, 3584] q_proj 3584 → 3584 12.85 M · bias 3,584 k_proj 3584 → 512 1.84 M · bias 512 v_proj 3584 → 512 1.84 M · bias 512 [B, S, 28, 128] [B, S, 4, 128] [B, S, 4, 128] 写入 KV Cache 每 token 56 KiB · 唯一随请求增长的东西 Q K V Scaled-Dot-Product Attention softmax(QKᵀ / √128) · V · 0 参数 [B, S, 28, 128] → reshape → [B, S, 3584] o_proj 3584 → 3584 12.85 M · 无 bias attn_out [B, S, 3584] 分支合计 29,360,128 + 4,608 bias
图 3 · 注意力分支的真实尺寸。 Attention 本身一个参数都没有,只做 softmax 和两次矩阵乘;参数全在四个投影矩阵里。 Qwen2.5 给 q/k/v 保留了 bias(共 4,608 个), o_proj 没有 bias。这个设计继承自 Qwen2。

05注意力核内部:五步,两次矩阵乘↑ 目录

Attention 没有参数,切头(05.1)只是一次 reshape;之后它做五步:QKᵀ 打分 → 缩放 → 掩码 → softmax 归一 → 乘 V 加权求和。 只有第 1 步和第 5 步是矩阵乘,其余是逐元素或按行操作。

单个头内部 · S = 序列长度 · hd = 128 Q S×hd Kᵀ hd×S × ① scores S×S ← 唯一的 O(S²) ②③④ 可见 −∞ P = softmax 每行和 = 1 ⑤ V S×hd = out S×hd 28 个头各算一遍 拼回 S×3584 再进 o_proj ① scores = Q @ Kᵀ [S,128] @ [128,S] → [S,S] 每个元素 = 一对 token 的 128 维点积 ② ÷ √128 = ÷ 11.31 点积随维度增长而变大,不缩放会把 softmax 推进饱和区,梯度消失 ③ + causal mask 上三角(未来位置)加 −∞,softmax 后变 0 —— 这就是"不能看未来" ④ softmax(按行) 每一行独立归一化成概率分布,和为 1 ⑤ out = P @ V [S,S] @ [S,128] → [S,128] 按注意力权重把 V 的行加权求和 decode 阶段 S=1:Q 只有一行,scores 退化成 [1,T] 的一个向量 —— O(S²) 消失,变成 O(T)。 这就是为什么 prefill 是算力密集、decode 是访存密集。
图 4 · 注意力核的五步。 中间的 S×S 分数矩阵是整个 Transformer 里唯一随序列长度平方增长的东西。 FlashAttention 不把它物化到显存,而是分块计算、边算边归一。 显存占用因此从 O(S²) 降到 O(S),数学结果完全一致。

05.1切头:一次 reshape,不是 28 次矩阵乘↑ 目录

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……

05.2GQA 的广播:K/V 只有 4 组,怎么配 28 个 Q 头↑ 目录

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 个头读的是同一段显存。

05.3RoPE:位置信息是"转"进去的,不是"加"进去的↑ 目录

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 的值不同。

05.4常数 200,704 是怎么算出来的↑ 目录

注意力核的五步里,只有第 ① 步和第 ⑤ 步是矩阵乘,其余(缩放、掩码、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 省的是显存,不是算力。

05.5decode 的常数是 401,408,正好是 2 倍↑ 目录

# 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:决定并发上限的那块内存 一节。

05.6算一笔账:注意力核在 prefill 里占多少↑ 目录

# 分母口径: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
prefill 时两部分算力的此消彼长 · 最后一列是单个头的分数矩阵体积(fp16)
S权重矩阵乘 TFLOP注意力核 TFLOP注意力核占比单头 S×S 矩阵
1,02413.360.211.6%2 MiB
2,04826.730.843.1%8 MiB
4,09653.453.375.9%32 MiB
8,192106.9113.4711.2%128 MiB
16,384213.8153.8820.1%512 MiB
32,768427.62215.5033.5%2.0 GiB
65,024848.56848.6050.0%7.9 GiB
131,0721710.493448.0766.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% 的运算量,决定了整个算法能不能成立。

06GQA:28 个 Query 头,只配 4 组 K/V↑ 目录

GQA 让 7 个 Q 头共用同一组 K/V;Multi-Head Attention 里每个 Q 头配一个自己的 K/V 头。 两者相比,计算量几乎不变(K/V 广播复制到 28 份参与运算),但GQA 需要缓存的 K/V 只有 MHA 的 1/7。

28 个 Q head kv 0 kv 1 kv 2 kv 3 4 组 KV head 每 7 个 Q head 共享 1 组 K / V · 7 : 1 每 TOKEN 需要缓存的 K/V 体积 MHA · 28 组 KV head 392 KiB GQA · 4 组 KV head 56 KiB 同样 24 GB 显存,能装下 7 倍的并发上下文
图 5 · GQA 的头分组与代价。 条形长度按真实比例绘制。 GQA 省的是显存,不是算力:注意力计算时 K/V 仍会广播成 28 份,FLOPs 基本不变。 但 decode 阶段受访存限制,KV 读取量降到 MHA 的 1/7,速度也因此受益。

07MLP 分支:参数的大头在这里↑ 目录

一层 MLP 的参数抵得上 7 层注意力。SwiGLU 用三个矩阵,传统 FFN 只用两个; 中间维度 18944 是 hidden 的 5.29 倍, 每个矩阵都有 6790 万参数。

[B, S, 3584] gate_proj 3584 → 18944 67,895,296 up_proj 3584 → 18944 67,895,296 SiLU 逐元素相乘 [B, S, 18944] down_proj 18944 → 3584 67,895,296 [B, S, 3584] 分支合计 203,685,888 · 占全模型 74.9%
图 6 · SwiGLU 的三矩阵结构。 中段粗线的宽度表示维度涨到了 18944。 gate 过 SiLU 后当"开关",up 是被门控的内容;两者相乘后,down 把维度压回 3584。 三个矩阵一样大,所以参数量从传统 FFN 的 2·d·ffn 变成 3·d·ffn。

07.1门控在"门"什么↑ 目录

传统 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)
SiLU(z) = z · sigmoid(z) z SiLU −2 −4 2 4 1 3 最低 −0.28 z≪0 → 趋近 0 z≫0 → 趋近 z 18944 个通道,每个独立开关 gate SiLU(gate) up h = ⊙ −5.0 0.03 2.10 0.06 关闭 0.0 0.00 −1.40 0.00 切断 3.0 2.86 0.90 2.57 放大 −1.3 −0.28 1.50 −0.42 反相 … … … 倍率由【同一个 token 的输入】算出来 —— 所以这是一个随数据变化的动态开关, 不是训练后就固定的权重。同一个通道对不同 token 可以开、可以关、可以反相。
图 7 · 门控机理。 左图是 SiLU 的形状:负半轴几乎归零(软关闭),正半轴接近恒等(直通)。 z≈−1.28 处有一个 −0.28 的小凹陷,允许少量负值通过。 右图是它的用法:SiLU(gate) 的每个分量当作 up 对应分量的倍率,18944 个通道各开各的。

07.2为什么值得多花一个矩阵↑ 目录

代价:参数从 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 改的也正是这一块。

0876 亿参数,都堆在哪儿↑ 目录

参数分布 · 按比例 MLP 74.9% Embed 14.3% Attn 10.8% 5,703,204,864 1,089,994,752 822,212,608 RMSNorm 共 204,288 个参数(0.003%),在这条上宽度不足 0.03 像素
图 8 · 参数去向。 三块颜色深浅不同,但都属于"静态权重"。 工程含义:量化 MLP 的收益最大;embedding / lm_head 对精度敏感,常被排除在量化之外, 所以 INT4 模型的实际体积往往大于"参数量 ÷ 4"。
逐项核算 · 单位为参数个数
组件形状 单个数量小计
embed_tokens152064 × 3584544,997,3761544,997,376
lm_head3584 × 152064544,997,3761544,997,376
q_proj3584 × 358412,845,05628359,661,568
k_proj3584 × 5121,835,0082851,380,224
v_proj3584 × 5121,835,0082851,380,224
o_proj3584 × 358412,845,05628359,661,568
qkv bias3584 + 512 + 5124,60828129,024
gate_proj3584 × 1894467,895,296281,901,068,288
up_proj3584 × 1894467,895,296281,901,068,288
down_proj18944 × 358467,895,296281,901,068,288
层内 RMSNorm ×23584 × 27,16828200,704
final norm35843,58413,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。

09KV Cache:决定并发上限的那块内存↑ 目录

权重是死的,KV Cache 是活的。每来一个 token,28 层各存一份 K 和一份 V。 存下来的东西下一步还要用,所以生成第 1000 个 token 不需要重算前 999 个。

prefill · 一次填 S 个 decode · 每步 +1 空闲 每个槽位 = 1 个 token 的 K / V · vLLM 里按 block(默认 16 token)成组分配 一个槽位里装了什么 28 层 每一层都要单独存一份 K [4 × 128] + V [4 × 128] = 1,024 个 fp16 = 2,048 B / 层 × 28 层 = 57,344 B = 56 KiB 这里的 4 是 num_key_value_heads。 若是 MHA(28),这个数会变成 392 KiB。
图 9 · 一个 token 的 KV Cache 构成。 公式是 2 × L × kv_heads × head_dim × dtype_bytes, 代入得 2 × 28 × 4 × 128 × 2 = 57,344。 它与 batch 无关,也与 hidden_size 无关,只跟层数和 KV 头数有关。

09.1decode 时,KV Cache 里发生了什么↑ 目录

上面那句"存下来的东西下一步还要用"是结论。下面展开讲机制, 它回答三个问题:cache 里存什么、每步怎么变、为什么旧的永远不用重算。

第 t 步走一遍↑ 目录

# 已经生成到第 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 的行数。

⚠️ 上面出现了两个"K":一个是权重,一个是缓存↑ 目录

第 ① 步的 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 内容的来源、增长方式和读取者都讲清楚了。

但进 cache 的只有 K 和 V 两样↑ 目录

一步 decode 算出很多东西,但绝大部分算完就丢:

一个 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 只能复用前缀、不能复用中间片段的原因。

K/V cache 随 decode 步数的增长(柱高为示意,非等比) step t = 1024 q [1,128] · 不缓存 K / V cache 1,024 行 56.0 MiB out [1,3584] · 不缓存 +1 行 +56 KiB step t = 1025 q [1,128] · 不缓存 K / V cache 1,024 行 第 1,025 行 ← 本步新增 out [1,3584] · 不缓存 +1 行 +56 KiB step t = 1026 q [1,128] · 不缓存 K / V cache 1,024 行 第 1,025 行 · 原样不动 第 1,026 行 ← 本步新增 out [1,3584] · 不缓存 每步新增 56 KiB(恒定,与 T 无关) · 每步读取 T × 56 KiB(随 T 线性增长) · 已写入的行永远不再改动
图 10 · 三个连续 decode 步里 cache 的变化。 虚线框的 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 池 ÷ 单请求峰值占用 = 能同时跑的请求数。

09.2换算成 4090 上的并发上限↑ 目录

手算和服务端实测差得比预想大。下面先手算,再对照实测。

# 手算(乐观估计)
显存总量                     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 计数不为零,说明负载已经压到这条线。

10MoE:把这一块 MLP 换成 64 块,每次只用 8 块↑ 目录

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 的大小,和上一节的容量公式无关。

Qwen2-57B-A14B · 一层骨架 · 与图 2 逐块对照 x_in [B, S, 3584] x_out [B, S, 3584] input_layernorm RMSNorm · 3,584 Self-Attention GQA · 28 Q : 4 KV 29,364,736(含 bias) 和 Qwen2.5-7B 一字不差 KV 也还是 56 KiB 残差相加 [B, S, 3584] post_attn_norm RMSNorm · 3,584 MoE 块 64 专家 × 2560,每次选 8 + 共享专家 20480(常开) 全量 1,982,041,600 每 token 实走 440,634,880 唯一被换掉的 就是这一块 图 2 里这里是 203.7 M 的 MLP 每层合计(全量) 2,011,413,504 × 28 层 → 56,319,578,112 每层激活(每 token) 470,006,784 × 28 层 → 13,160,189,952 注意力 : FFN 分支 = 1 : 67.5(全量) · 1 : 15.0(激活) —— 图 2 里这个比只有 1 : 6.9 整层比图 2 胖 8.63 倍,但每个 token 只多走 2.02 倍 —— 差的那 4.3 倍就是稀疏度
图 11 · MoE 版本的一层骨架。 被换掉的只有右下角那一个方块。请和图 2 逐块对照:残差流、两个 RMSNorm、整条注意力分支 一个数都没动,KV Cache 单价仍是 56 KiB/token。 实线框是存在显存里的全部权重,虚线框是一个 token 真正走过的部分。 这两个数在稠密模型里相同,在 MoE 里第一次分开。下一张图拆开这个方块的内部。
Qwen2-57B-A14B · 一个 MoE 块 · 处理【一个 token】 x [3584] router (gate) 3584 × 64 = 229 K softmax → [64] 取 top-8 得到 8 个 (id, 权重) 64 个专家 · 每个都是完整的 SwiGLU(3 个矩阵) 单个专家 = 3 × 3584 × 2560 = 27.53 M ← ffn 从 18944 缩到 2560 选 ■ 激活(8/64) □ 权重在显存里,但这个 token 不用 Σ wᵢ · Eᵢ(x) 按路由权重加权求和 shared expert 3 × 3584 × 20480 = 220 M 所有 token 都过,不参与路由 out [3584] 一层的参数账 router 0.23 M + 64 专家 1761.6 M + 共享 220.2 M = 1,982.0 M 一层的激活账 router 0.23 M + 8 专家 220.2 M + 共享 220.2 M = 440.6 M 对照 Qwen2.5-7B 稠密 MLP 203.7 M —— MoE 块参数是它的 9.7 倍,激活是它的 2.2 倍 注意:激活量【大于】稠密 7B。MoE 换来的不是"更省",而是"用 2.2 倍算力买到 9.7 倍容量"。
图 12 · MoE 块的结构与两笔账。 路由器是一个 3584 × 64 的小矩阵,给 64 个专家打分。 softmax 后取分数最高的 8 个,只有这 8 个专家的三个矩阵参与计算。 Qwen2 的 MoE 还多挂了一个共享专家,所有 token 都经过它,用来兜住通用能力; Qwen3 的 MoE 去掉了它。 图中共享专家的 20480 不是推导值,是 config.json 里单独的一个字段,见下方拆解。

10.1共享专家的 20480 从哪里来↑ 目录

20480 不是从 18944 或 2560 推算出来的,而是 config.json 里一个独立的字段。 Qwen2-57B-A14B 的 MoE 层一共写了三个不同的中间维,很容易看混:

一个 MoE 层里共存的三个中间维
config.json 字段值用在哪里
intermediate_size18,944 继承自稠密架构的字段。MoE 层里实际用不上,只是没删掉
moe_intermediate_size2,560 单个路由专家的宽度 × 64 个
shared_expert_intermediate_size20,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 就是"几乎只听路由专家的"。

⚠️ 一个 MoE 层里有三样东西都叫"门",别混↑ 目录

三个"门"的职责完全不同
名字形状每 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 去掉共享专家、改成"专家更多更小"的一个现实动机。

10.2路由这一步在算什么↑ 目录

# 输入:一个 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]

那个 Σ 和 + 号:9 路结果就是简单按位相加↑ 目录

式子里的求和就是逐元素加法:不拼接,不再过融合矩阵,也不重新归一化。 展开写:

  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), 把路由分布推向均匀。这也是推理时可以近似假设"均匀路由"的原因, 下面那张表就基于这个假设。

10.3参数账:57.41 B 总量,14.25 B 激活↑ 目录

Qwen2-57B-A14B 逐项核算 · 与 Qwen2.5-7B 逐行对照
组件Qwen2.5-7B(稠密)Qwen2-57B-A14B(MoE)倍数
hidden / 层数 / 头数3584 / 28 / 28Q·4KV3584 / 28 / 28Q·4KV1.00×
attn / 层29,364,73629,364,7361.00×
KV Cache / token56 KiB56 KiB1.00×
FFN 中间维18,9442,560 × 64—
router / 层—229,376—
专家 / 层—1,761,607,680—
共享专家 / 层—220,204,544—
FFN 块合计 / 层203,685,8881,982,041,6009.73×
FFN 激活 / 层203,685,888440,634,8802.16×
总参数7,615,616,51257,408,658,9447.54×
激活参数 / token7,615,616,51214,249,270,7841.87×

两个总数精确对上了模型名里的 57B 和 A14B(A 指 activated)。 稀疏率 14.25 / 57.41 = 24.8%, 即每个 token 只用到全模型四分之一的参数。

10.4⚠️ 工程上最容易踩的坑:激活量只在 batch=1 时成立↑ 目录

"只激活 14B 参数"对单个 token成立。 但服务端按批处理:batch 里不同的 token 会路由到不同的专家, 并集越大,要读进来的专家权重就越多。

均匀路由假设下,一个 decode 步要触及多少专家 · 64 选 8
batch期望触及专家数占比要读的权重相对稠密 57B 的访存优势
18.012.5%28.5 GB4.03×
215.023%39.3 GB2.92×
426.541%57.0 GB2.01×
842.066%80.9 GB1.42×
1656.488%103.2 GB1.11×
3263.199%113.4 GB1.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 省算力(那时正好卡在算力上)。

10.5另外两件必须知道的事↑ 目录

10.6Qwen3 那一代把设计改了什么↑ 目录

两代 Qwen MoE 的对照 · 数据取自各自的 config.json
项Qwen2-57B-A14BQwen3-30B-A3BQwen3-235B-A22B
hidden / 层数3584 / 282048 / 484096 / 94
专家数64128128
每 token 选888
单专家 ffn25607681536
共享专家有(ffn 20480)无无
top-k 归一化否是是
总参数57.41 B30.53 B235 B
激活参数14.25 B3.35 B22 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 的取向更激进: 专家更多更小、稀疏率更高、不留共享专家。

11换个模型怎么套↑ 目录

拿到 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 是怎么数出来的"。

11.1如果 config 里有 num_experts,再套这三条↑ 目录

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 两栏。