61 层文本骨干,隐藏维 7168,每层一条 512 维的 MQA latent。
注意力按 compress_ratios 分成两种并严格交替:31 层 HCA(128 个 token 压一行,全看)
与 30 层 CSA(4 个 token 压一行,indexer 选 top-1024)。
残差是 4 路宽的 mHC,前 3 层的专家不由门控选而是查哈希表,末尾挂 1 层 MTP。
阅读顺序
主结构里每个模块都标了进出形状。复杂的注意力画成一块,只写它吃什么、吐什么; 紧接着的一节把这块按模块拆开。主图里同样收成一块的 mHC、MoE,以及挂在骨干外面的模块,各自再有一张结构图。
先分清对象
架构名是 DeepseekV4ForCausalLM,model_type 为 deepseek_v4,纯文本、无视觉塔。
真正决定这个模型长什么样的是 compress_ratios 和 num_hash_layers 两个字段:
前者逐层给出「多少个连续 token 压成一行 KV」,后者说前几层的专家不按门控选。
下面的形状与字节来自 64 个 safetensors 分片的头部(共 145 116 个张量):
逻辑参数按权重元素计,FP4 的一个 I8 字节算 2 个参数,.scale 不进参数量。
compress_ratios:0 / 4 / 128
index_topk = 1024 行。其余三件事
hc_*_fn [24, 28672] 预测 pre / post / 4×4 混合,Sinkhorn 20 步。tid2eid[input_ids],而不是门控 logits。和 V4.1-Flash 是同一套字段,不同取值
V4.1-Flash 的 compress_ratios 只允许 0/1/2,并引入了 KV owner:
40 层里只有 4 层写压缩 KV,其余层借用。V4-Pro 没有 owner 这一层间接 ——
每个 HCA/CSA 层都自己压、自己写、自己读,CSA 层还各自跑一次 top-1024。
两代共有的是:MQA 单 KV 头、512 维 latent、尾 64 维 RoPE、分组输出 LoRA、attn_sink、4 路 mHC、sqrtsoftplus 路由。
V4-Pro 与 V4-Pro-0813
两份 checkpoint 的骨干完全相同。差别只在尾巴:DeepSeek-V4-Pro 是 1 层 MTP
(num_nextn_predict_layers = 1),-0813 把它换成 3 层 DSpark
(dspark_block_size = 5,dspark_markov_rank = 512,输入取层 58/59/60),
compress_ratios 也相应多出两个 0。
主结构 · 层调度
compress_ratios 一共 62 项:前 61 项给骨干,最后一项给 MTP 层。
骨干的排布是 [128, 128, 4, 128, 4, …, 128, 4] —— 层 0 和层 1 连着两个 HCA,
从层 2 起 CSA 与 HCA 严格交替,层 60 以 CSA 收尾。
数下来是 31 个 HCA 和 30 个 CSA,一个 Dense 都没有。
| 层 | ratio | 类型 | 压缩行 | 这一层读什么 | 路由 |
|---|---|---|---|---|---|
| 0, 1 | 128 | HCA | 128 token → 1 行 | 128 滑窗 + 全部 (pos+1)/128 行 | hash |
| 2 | 4 | CSA | 4 token → 1 行(重叠) | 128 滑窗 + top-1024 | hash |
| 3, 5, 7 … 59 | 128 | HCA | 128 token → 1 行 | 128 滑窗 + 全部 (pos+1)/128 行 | sqrtsoftplus + bias |
| 4, 6, 8 … 60 | 4 | CSA | 4 token → 1 行(重叠) | 128 滑窗 + top-1024 | sqrtsoftplus + bias |
| mtp.0 | 0 | Dense | 不压缩 | 只有 128 滑窗 | sqrtsoftplus + bias |
1 M 上下文下,两种层看到的行数天差地别
同样是 1 048 576 个 token 的历史:HCA 层把它压成 8192 行,然后一行不落全读; CSA 层把它压成 262 144 行,然后只读 indexer 选中的 1024 行。 两者读的行数是 8192 对 1024,但前者的每一行代表 128 个 token、后者代表 4 个。 粗看全局和细看局部,这就是交替排布要买的东西。
滑窗是第三份,和上面两份都不一样
不管哪种层,都另有一条 sliding_window = 128 的环,存的是没压过的原始 latent。
所以一个 CSA 层的 query 实际看到的是「最近 128 个原始 token」加「从历史里挑出来的 1024 行压缩块」,
两份 KV 物理上在同一个池子里,但一个按 slot 寻址、一个按 block 寻址。
主结构
61 层里每个子层的进出形状都标在这张图上。注意力子层是一块:进去是
[T, 7168],出来还是 [T, 7168]。
HCA、CSA、Dense 三种内部结构在下一节拆开;mHC、compressor、indexer、哈希路由和 MTP 各自单独成图。
图 1 · 文本前向的形状
Attention 块
主结构里的注意力子层拆开之后是下面这条链。HCA、CSA、Dense 的前后段完全相同, 差别只在中间:有没有 compressor、有没有 indexer、读哪些行。 那三件东西在下一组图里按层类型展开,compressor 与 indexer 再各自单独拆开。
Attention 块 · 共用通路
Attention 块 · 三种内部结构
上一张图是主结构里那块 Attention 的内部。下面三张是它的三种实例,前后段相同:wqkv_a 一次 GEMM 出 q_lora [T, 1536] 与 kv [T, 512],
一个融合 kernel 同时做逐头无权重的 Q RMSNorm、带权重的 KV RMSNorm
和尾 64 维的 GPT-J 交错 RoPE(decode 时连滑窗写入也折进这一次发射);
输出端一律是 wo_a 16 组分组 LoRA 加 wo_b。
不同的只有三件事:有没有 compressor、有没有 indexer、读哪些行。
图 2a · HCA · 31 层 · ratio = 128 · 压得狠,但一行不落
图 2b · CSA · 30 层 · ratio = 4 · 压得细,但只挑 1024 行
图 2c · Dense · ratio = 0 · 骨干里一层都没有,只有 MTP / 草稿层
| 类型 | 层数 | 额外权重 / 层 | 压缩行 / token | 打分 | 1 M 上下文下一个 query 读的行数 |
|---|---|---|---|---|---|
| HCA | 31 | 7.41 M | 1 / 128 | 无 | ≤128 + 8192 |
| CSA | 30 | 31.40 M | 1 / 4 ×2 个池子 | top-1024 | ≤128 + 1024 |
| Dense | 0(骨干) | 0 | 0 | 无 | ≤128 |
Attention 块 · 合并
滑窗行和压缩行被拼进同一份 CSR 索引表,交给同一个 kernel。
没有门控,没有加权合并,两类行在 softmax 里平等竞争。
能这样做的前提是它们在池子里结构同构:都是一条 [512] 的 latent,
而且 K 和 V 是同一行 —— kernel 只收一个 KV 张量。
图 2d · 一个 decode token 的完整形状(CSA 层,pos = 20000)
三种层只有行数不同
Q 和 KV 的维度三种层完全一样:[1, 128, 512] 对 [N, 512]。
变的只是 N —— CSA 是 ≤1024+128,HCA 是「全部 (pos+1)/128 行 + ≤128」(1 M 上下文 = 8192+128),
Dense 只剩 ≤128。同一个 kernel、同一条输出通路。
prefill 是两源,仍是一次 softmax
prefill 走 sparse_attn_v4_paged_prefill,收两套 indptr:prefix(池子里前面 chunk 的滑窗 + 压缩行)
和 extend(本 chunk 还没进池的 K)。kernel 内部按 flash 的方式分段算再用 log-sum-exp 归并,
数学上仍是对 prefix ∪ extend 这一个集合的一次 softmax。
特殊模块 · compressor
compressor 接在 Attention 块的 hidden 侧面,不经过 wqkv_a。
HCA 把 128 个 token 压成一行,组与组不重叠。CSA 的主 compressor 打开 overlap:
每行池化的窗口是 2 × ratio = 8 个 token,相邻两行重叠一半,所以投影从 512 宽变成 1024。
indexer 自己还有一个更窄的 compressor,它的输出只用来打分。
compressor · HCA、CSA 主通路、CSA 打分通路
图 3b · 一行压缩 KV 是怎么算出来的(CSA,边界 pos = 7,写出 row 1)
[256, 7168]、ape [4, 256]、
RoPE 转的是 128 维里的 64 维(一半,主通路只有八分之一),最后多一步逐行量化。
HCA 把 col_off 恒设为 0、窗口换成 128 个不重叠的 token,其余完全一致。
特殊模块 · indexer
indexer 不在主 KV 上打分。它有自己的 compressor(head_dim = 128),
把同样 4 个 token 一组压成一行 128 维的 index key,写进另一个池子;
query 侧也单独投影成 64 头 × 128 维。打分完只输出行号,真正的 KV 仍从主池取。
图 3 · 从 hidden 到 top-1024 行号
indexer 的 Q 不切 TP
indexer.wq_b 是 ReplicatedLinear:64 个头在每个 rank 上都要有。
理由很直接 —— 打分要对 64 个头求和才得到一个 logit,切了就得为 index_score 再加一次 all-reduce。
这张表只有 12.58 M,复制一份比多一次通信便宜。
index KV 的精度是单独的开关
原生单节点默认 FP4(gfx942 例外,仍 FP8),插件路径保持 FP8。
--index-cache-dtype fp8 可以强制回退。
gfx950 与 gfx1250 的 FP4 布局故意不兼容:前者是 pa_mqa_logits_fp4 的预洗牌布局,后者是 OPUS 自然行 ——
所以 indexer_layout 要和 quant_mode 分开记,一个说怎么量化,一个说怎么摆。
rotate = True 是个还没接上的标志
Compressor.__init__ 收一个 rotate 参数,indexer 那份传的是 True。
但 rotate_activation(atom/model_ops/quant_v4.py 的 Walsh-Hadamard)
在整个 V4 路径里没有任何调用者 —— 全仓只有 GLM-5.3 的 k-pool 和 V4.1 的融合 RoPE 用到 Hadamard,
后者还显式传 do_rotate_act=False。
源码注释说明了它是给后续 PR 留的判别位:参考实现训练时做的是
rotate_activation(kv) 再 FP4 量化的 QAT 往返,这一步目前没有移植。
看到这个标志不要当成「跑了 Hadamard」。
稀疏要到 4K 之后才真的生效
CSA 的候选行数是 (pos+1)/4,要超过 index_topk = 1024 才会丢东西 ——
也就是 pos ≥ 4095。在那之前 top-1024 选中的是全部候选行,
CSA 层等价于「滑窗 + 全部压缩历史」。
4K 以内的 benchmark 测不到这套机制的稀疏行为,只测到它的压缩损失。
打分读的字节和它要剪枝的那次注意力差不多
同样在 pos = 20000:打分要扫 5000 行 index K,FP8 下 5000 × 132 B ≈ 660 KB;
而被它剪到 1024 行之后,注意力读主 KV 是 1024 × 640 B ≈ 655 KB。
换成 FP4 index 平面(68 B/行)降到 340 KB,约为注意力的一半 ——
这大概就是原生单节点默认选 FP4 的原因(recipe 写了 gfx942 除外)。
只是字节数对比,不是时延对比;两者访存模式不同,没有实测。
压缩器状态环比滑窗还大
每请求、每层、FP32、kv_state 与 score_state 各一份:
CSA 主环 (8, 1024) = 64 KiB、CSA index 环 (8, 256) = 16 KiB、
HCA 主环 (128, 512) = 512 KiB。
合计约 17.8 MiB / 请求,其中 HCA 占 15.5 MiB ——
比 FP8 滑窗的 4.77 MiB 还大 3.7 倍。
HCA 环必须 128 深,因为它的池化窗口就是 128,跨 forward 边界时最多要补 127 个历史位置。
特殊模块 · mHC
embedding 进第一层之前被展成 [T, 4, 7168]。每个子层(注意力、FFN 各算一个)做两件事:
hc_pre 把 4 路残差按预测出来的权重收成一路 [T, 7168] 喂给子层,
hc_post 把子层输出按另一组权重摊回 4 路,同时用一个 4×4 组合矩阵重新混合旧残差。
这两组权重和那个矩阵都是同一个投影出来的,再经 20 步 Sinkhorn 投影到双随机流形上。
图 5 · 一个子层的 mHC 缝
pre_delayed,V4 这里没有。
特殊模块 · MoE 与 MTP
图 6 · 两种路由,以及挂在尾巴上的 MTP
-0813 把 MTP 换成 3 层 DSpark,骨干一个字节都不改。
规模
参数几乎全在一个地方:61 层 × 384 个 FP4 路由专家,占 96.78%。 单 token 激活按「这一步真正参与乘加的权重」计:6 个路由专家加 1 个共享专家、全部注意力(含 compressor 与 indexer)、embed 与 lm_head。 MTP 是另一个前向,不计入。
49.78 B 是怎么凑出来的
落盘 805.32 GiB 怎么分
tid2eid 是 [129280, 6] 的 int64,3 层各一张 —— 它是查表不是乘加,不进激活量。| 张量 | 形状 | dtype | 出现位置 |
|---|---|---|---|
| embed.weight / head.weight | [129280, 7168] | BF16 | 各一份,不共享 |
| attn.wq_a / attn.wkv | [1536, 7168] / [512, 7168] | FP8 128×128 | 运行时合成一次 GEMM,输出 [T, 2048] |
| attn.wq_b | [65536, 1536] | FP8 128×128 | 128 头 × 512,列并行 |
| attn.wo_a / attn.wo_b | [16384, 4096] / [7168, 16384] | FP8 128×128 | 16 组,每组 [1024, 4096] |
| attn.attn_sink | [128] | FP32 | 每头一个 logit,只进 softmax 分母 |
| compressor.wkv / wgate | [512, 7168] | BF16 | HCA 层(ratio 128,不重叠);ape [128, 512] |
| compressor.wkv / wgate | [1024, 7168] | BF16 | CSA 层(ratio 4,重叠窗口,宽度翻倍);ape [4, 1024] |
| indexer.wq_b | [8192, 1536] | FP8 128×128 | 只有 30 个 CSA 层。64 头 × 128,不切 TP |
| indexer.weights_proj | [64, 7168] | BF16 | 每头一个打分权重 |
| indexer.compressor.wkv / wgate | [256, 7168] | BF16 | indexer 自己的 压缩器,head_dim 128,输出只给打分 |
| ffn.gate.weight | [384, 7168] | BF16 | 层 3–60 另有 gate.bias [384] FP32 |
| ffn.gate.tid2eid | [129280, 6] | INT64 | 只有层 0 / 1 / 2,与 gate.bias 互斥 |
| ffn.experts.e.w1 / w3 | [3072, 3584] | I8 打包 FP4 | 逻辑列数 7168。scale [3072, 224] E8M0,块 1×32 |
| ffn.experts.e.w2 | [7168, 1536] | I8 打包 FP4 | 逻辑列数 3072 |
| ffn.shared_experts.w1 / w3 | [3072, 7168] | FP8 128×128 | 与路由专家同形、不同 dtype |
| hc_attn_fn / hc_ffn_fn | [24, 28672] | FP32 | 每层各一份;base [24]、scale [3] |
| hc_head_fn | [4, 28672] | FP32 | 只有一份,收尾时把 4 路残差收成 1 路 |
| mtp.0.e_proj / h_proj | [7168, 7168] | FP8 128×128 | MTP 的两路输入投影,各配 enorm / hnorm |
图 4 · KV 池
V4 把两种 KV 放进同一个行空间:压缩历史按 block 寻址,从 0 往上长; 每请求的滑窗和压缩器状态按 slot 寻址,从高位往下长。 「一行」在所有平面上指同一个意思,但一行的字节数各平面不同 —— 这是硬要求, 因为稀疏 kernel 拿的是一份共享的 index 缓冲区,NoPE 和 RoPE 两个平面必须用同一个行号。
图 4 · 一个 token 要多少字节,一个请求要多少
为什么 NoPE 和 RoPE 不能是同一个信封的两段
假设一个信封 E 字节,那么块 b 的第 r 行在一个平面上是 b·E/512 + r,
在另一个平面上是 b·E/128 + r —— 两者只有在 E = 0 时才相等。
而 mla_decode_fwd_v4_nm 这类 kernel 只收一份 index 缓冲区,
所以两个平面必须各自是一张密排的平面,行号才能通用。
MQA 单 KV 头 = KV 不随 TP 分摊
num_key_value_heads = 1,所以 TP8 不会把 KV 切成 8 份:
上面那 5465 B/token 是每个 rank 都要存一份的量。
省显存的开关因此是 --kv_cache_dtype fp8(1024 → 640 B/行)和 FP4 index 平面,而不是加 TP。
量化
| 权重大类 | 存储 | 激活 | 备注 |
|---|---|---|---|
| wq_a、wq_b、wkv、wo_a、wo_b、indexer.wq_b、e_proj、h_proj | FP8 e4m3,块 128×128,scale E8M0 | 动态 | V4.1-Flash 用的是 32×32,这是两代之间最容易看错的一处 |
| 路由专家 w1 / w2 / w3 | FP4 e2m1,块 1×32,I8 打包 | MXFP8 | 磁盘 K 维减半;AITER_BF16_FP8_MOE_BOUND=0 + ATOM_MOE_GU_ITLV=1 走对内核 |
| 共享专家 w1 / w2 / w3 | FP8 e4m3,128×128 | 动态 | 与路由专家同形不同 dtype,因此是独立模块 |
| compressor(两个)、weights_proj、gate、embed、head | BF16 | BF16 | 压缩器的组内累加指定在 FP32 |
| hc_*_fn / base / scale、attn_sink、gate.bias | FP32 | FP32 | mHC 系数不允许降精度;ATOM_MHC_USE_BF16 只改打包布局 |
| gate.tid2eid | INT64 | — | 查表,不参与乘加 |
| 主 KV 池 | BF16 1024 B/行 或 FP8 512+128 B/行 | — | --kv_cache_dtype fp8;RoPE 永不量化 |
| index KV 池 | FP4 68 B/行(默认)或 FP8 132 B/行 | — | gfx942 回退 FP8;gfx950 与 gfx1250 的 FP4 布局互不兼容 |
在 ATOM 里的位置
| # | 文件 | 看什么 |
|---|---|---|
| 1 | atom/models/deepseek_v4.py · DeepseekV4Args | compress_ratios 的三种取值注释:0 = Dense、4 = CSA、128 = HCA |
| 2 | atom/models/deepseek_v4.py · DeepseekV4Attention.__init__ | 按 ratio 决定建不建 compressor / indexer;YaRN 还是普通 RoPE |
| 3 | atom/models/deepseek_v4.py · Compressor | overlap = (ratio == 4),以及它为什么让投影宽度翻倍 |
| 4 | atom/models/deepseek_v4.py · Indexer | 自带的 compressor、复制而非切分的 wq_b、FP4/FP8 两种布局 |
| 5 | atom/models/deepseek_v4.py · _sparse_attention | 三套 kv_indices_{swa,csa,hca} 怎么按 ratio 选 |
| 6 | atom/models/deepseek_v4.py · MoE._hash_topk | tid2eid[input_ids] 定选择、sqrtsoftplus 定权重 |
| 7 | atom/models/deepseek_v4.py · Block | hc_pre / hc_post,以及层 0 为什么不能用融合版 |
| 8 | atom/model_ops/attentions/pool_layout/v4_pool_geometry.py | 行空间、信封与槽、visible_csa / visible_hca |
| 9 | atom/model_ops/attentions/pool_layout/v4_pool_fields.py | 一行有什么:NoPE / RoPE 两个平面,indexer 块的数据与 scale 分区 |
| 10 | atom/model_ops/v4_kernels/fused_compress.py · csa_translate_pack.py | 压缩与写入融合成一个 kernel;seq-local 行号翻成物理偏移 |
| 11 | recipes/DeepSeek-V4.md | TP8 + FP8 KV 的启动命令、MoE 环境变量、测得的 GSM8K |
怎么跑起来
recipe 给的是 8 × MI355X、TP8、FP8 KV:
AITER_BF16_FP8_MOE_BOUND=0 ATOM_MOE_GU_ITLV=1 加 --kv_cache_dtype fp8 -tp 8。
不需要 --trust-remote-code,DeepseekV4ForCausalLM 是 ATOM 内置架构。
这个配置在 MI355X / gfx950 上测得 GSM8K(1319 题,3-shot flexible-extract)0.9522。
可选的 EP / DP 路线
--enable-expert-parallel --enable-dp-attention --all2all-backend rccl 走原生 RCCL 的专家派发:
均匀 decode 用图安全的预路由 AllGather/ReduceScatter 快路径,prefill 与混合批走变长版本。
它只消费既有路由产出的物理专家 ID,不改 EPLB 策略;TBO 在这个后端上不支持。