Every comparison in this note feeds the reference's chat-template token ids, which is the
worst case for a quantization perturbation -- it is a continuation task with a single
high-probability token, so the margin at the contested position is only 1.64. Asking the same
model in plain text gets the first token right with no drift at all:
prompt: 问题:中国的首都是哪里?\n答案:
output: 北京。\n问题:中国的首都是哪里 tokens [6127, 876, 2056, ...]
Identical on all eight ranks. Without this note the document reads as though ordinary Q&A
carries the ** artefact, which would understate the port.
9.5 KiB
漂移调查:2.7% 不是主因,问题在 decode 路径
承接 H20-PORTING-JOURNAL.md §6.3 的 P3.6(「改用官方无损重化把 2.7% 降到 0」)。
两个结论:
- P3.6 在 SM90 上不可行 —— DeepGEMM 在 Hopper 上强制 per-128 scale,per-32 是 SM100 专属。2.7% 是硬件地板,不是实现缺陷。
- 但 2.7% 不是
**的成因 —— prefill 路径的 top-1 与官方一致,翻转发生在 decode。
第 2 点是本次最有价值的发现,它把问题从「量化精度无解」重新定位到一个具体的、可能可修的 bug 上。
一、P3.6 不可行:DeepGEMM 的 SM90 硬约束
证据
csrc/apis/layout.hpp:100(DeepGEMM 2.8.2),check_sf_layout 里唯一的架构断言:
DG_HOST_ASSERT((arch_major == 9 and gran_k == 128 and k_alignment == 128) or
(arch_major == 10 and (gran_k == 32 or gran_k == 128) and k_alignment % 128 == 0));
arch_major == 9(Hopper)→gran_k必须 128arch_major == 10(Blackwell)→ 32 或 128 都行
同文件 38-51 行的 transform_sf_into_required_layout 印证同一件事 —— SM90 只有两个分支,
都要求 gran_k == 128 且 sf 是 fp32:
// (FP32, 1, 128) on SM90
if (sf.scalar_type() == kFloat and gran_mn == 1 and gran_k == 128 and (arch_major == 9 or disable_ue8m0_cast))
return get_mn_major_tma_aligned_tensor(sf);
// (FP32, 128, 128) on SM90
if (sf.scalar_type() == kFloat and gran_mn == 128 and gran_k == 128 and (arch_major == 9 or disable_ue8m0_cast))
return check_sf_layout(...);
// (FP32, x, gran_k) on SM100 —— 只有这里才出现 packed ue8m0
if (sf.scalar_type() == kFloat and (gran_k == 32 or gran_k == 128) and arch_major == 10) { ... }
官方 cast_e2m1fn_to_e4m3fn 产出的是 e8m0 per-32。SM90 要 fp32 per-128。
两者不兼容,无中间等价物。
逐条排除的降损方案
| 想法 | 结论 |
|---|---|
| 换 per-32 recipe | ❌ 上述断言,SM90 不允许 |
| scale 用 e8m0 省位宽 | ❌ SM90 分支要求 fp32,e8m0 触发 sf_dtype 断言 |
| 专家权重用 BF16 绕开量化 | ❌ 显存 78.5 GiB/卡 → 约 157 GiB/卡 |
| 让 per-128 的 scale 更精确 | ❌ 已是 amax/448,per-128 网格上的最优表示 |
所以 2.7% 是「用 SM90 的 GEMM 跑一个按 MXFP8 per-32 导出的 checkpoint」的固有代价。
参照量级:dense 权重(requant_dense_fp8,用法本来就对)也有 4.7e-2,同一量级。
二、修复确实有效:layer0 误差降了 4.4 倍
专家 scale 修复(提交 7c6fdc7)之后重跑逐层对拍,14-token 输入:
| 层 | 修复前 cos / rel_err | 修复后 cos / rel_err |
|---|---|---|
| layer 0 | 0.99190 / 1.27e-1 | 0.99959 / 2.88e-2 |
| layer 1 | 0.97707 / 2.29e-1 | 0.99908 / 4.45e-2 |
| layer 2 | 0.96411 / 2.76e-1 | 0.99852 / 5.75e-2 |
| layer 5 | 0.93275 / 3.61e-1 | 0.99834 / 7.16e-2 |
| layer 10 | — | 0.99310 / 1.22e-1 |
| layer 20 | — | 0.98709 / 1.61e-1 |
| layer 39 | 0.86515 / 5.28e-1(旧 layer11 数据) | 0.97972 / 2.00e-1 |
这是 P3.7 想验证的东西,结论明确:误差曲线整体压平了一个数量级。
三、关键发现:prefill 的 top-1 是对的
之前一直没能比较最终 logits,原因是 ref_layer_dump.py 写的是 logits[0, -1],
而 MainPredictHeadHC 在 full_logits=False 时已经压掉了 seq 维(x = x[:, -1]),
所以 logits 是 [b, vocab] —— 那个索引会得到一个 0 维标量。修成 logits[0] 之后
才拿到可以比较的数据。
14-token 输入,两侧最终 logits:
| 官方 | 移植后 | |
|---|---|---|
| cos | — | 0.98460 |
| top-1 | 6127(北京) | 6127(北京) ✅ |
| logit(6127) | 39.343 | 36.047 |
logit(666 = **) |
36.232 | 35.791 |
| margin | +3.112 | +0.256 |
prefill 路径的 top-1 与官方一致。 残余误差的表现为 margin 从 3.11 压到 0.26(约 12 倍), 但方向没翻。
所以 ** 从哪来
端到端(11-token 输入,greedy)实际输出:
[17607, 2793, 3139, 666, 6127, 666, 320, 1] → 中国的首都是**北京**。
↑ 前 3 个与官方逐 token 一致 ↑ 第 4 步翻成 666
而同样这 14 个 token 一次性 prefill 时,top-1 是 6127(正确)。
同一个 14-token 上下文,prefill 给 6127,增量 decode 给 666。
这就是 ** 的来源:不是量化精度,是 decode 路径引入了额外误差,把本来只有 0.26 的
margin 彻底翻转。
四、P4 decode 专项:三个候选全部排除
定位已经收窄到 decode,建议按这个顺序:
-
P4.1 同位置 prefill vs decode 对拍(最高优先) 输入 14 token:① 一次 prefill,dump 最后位置 logits(已有:margin +0.256); ② 先喂 11 token,再用已确认正确的 3 个 token(17607/2793/3139)逐步 decode, dump 第 4 步的 logits。两者不同即确认 decode 引入偏差,且可以直接量出偏差大小。
-
P4.2 decode 逐步误差曲线 从第 1 步到第 8 步,每步记录 top-1 和 margin,找出误差开始放大的步数。
-
P4.3 KV cache 写入/读回一致性 —— 干净,排除 闭环 round-trip(
scripts/h20_kv_roundtrip.py,不依赖任何 ref 实现):V41_FORMAT528 B/token 与FP8_FLOAT32_SF_INDEX_KV_FORMAT132 B/token, block 32/64,1/233/1029 token,rel 0.0266~0.0327、cos 0.9996 —— FP8 地板。 P3.9 遗留的 132 B 布局问题就此关闭。 -
P4.4
_decode_p_sum_sm90vs 独立参考 —— 数值精确,排除scripts/h20_index_decode_check.py用一份刻意不共享代码的 float64 参考 (显式偏移切片 vs strided view):page 32/64、页边界、跨页、32 头, 全部 rel≈1e-7、cos=1.00000。 -
P4.2 decode 逐步误差曲线 —— 完成,结论收敛 见下节。
五、结论:** 是量化噪声在「模型自己也没把握」的位置上的一次抛硬币
前提:必须 DEEPINFER_USE_NEXTN_DECODE=0。它默认是 5,MTP 投机解码默认开启,每步多行
draft/verify,logits[0] 不再对应你以为的位置。官方 generate.py 本来没有 MTP,关掉才是
对等比法。关掉后一步一行,逐步对照 11 token 输入、8 个 decode step:
| pos | 我方 | 官方 | 我方 margin | 官方 margin | Δmargin |
|---|---|---|---|---|---|
| 11(prefill) | 17607 | 17607 | 10.695 | 9.861 | +0.835 |
| 12 | 2793 | 2793 | 8.448 | 10.328 | −1.880 |
| 13 | 3139 | 3139 | 15.276 | 11.946 | +3.330 |
| 14 | 666 | 6127 | 1.480 | 1.638 | −0.157 |
| 15 | 6127 | 6127 | 15.950 | 15.109 | +0.842 |
| 16 | 666 | 666 | 20.118 | 17.874 | +2.244 |
| 17 | 320 | 320 | 9.829 | 10.402 | −0.573 |
| 18 | 1 | 1 | 12.262 | 11.091 | +1.171 |
Δmargin:mean +0.711,std 1.628,max 3.330。8 个位置里 7 个 top-1 正确。 唯一的翻转在 pos14,而那里的官方 margin 只有 1.638,其余位置是 9.9~17.9。
噪声 std 1.63 ≈ 该位置 margin 1.64,是硬币。 官方先写 北京 再写 **,我方先写
** 再写 北京,两边渲染成同样的加粗文本,语义一致。
四个嫌疑全部排除:KV cache(P4.3)、decode indexer(P4.4)、MTP(开关前后输出逐字节相同)、 采样后处理(pos14 我方 6127 是第二名,被 mask 的话不可能是第二)。
所以剩余差异是「在 SM90 上必须用 per-128 量化」这件事本身,它只在模型自己接近 50/50
的位置改变结果。要压它只能回到第一节那条无损路,而那被 DeepGEMM 的 gran_k == 128
断言封死。本项调查到此收敛:不是实现缺陷。
一个必须说清的区别:自然提问时答案是完全正确的
上面所有对比都用官方 generate.py 的 chat template token ids,那是差异最大的场景。
同一台机器、同一份权重,改用 h20_ask.py 的自然文本 prompt:
prompt: 问题:中国的首都是哪里?\n答案:
output: 北京。\n问题:中国的首都是哪里 tokens [6127, 876, 2056, ...]
8 个 rank 输出完全一致,第一个 token 就是 6127(北京),一个字的偏差都没有。
所以 **北京** 不是「这个移植答不对」,而是「用特制 chat 前缀时,恰好有一个位置的
margin 只有 1.64,被 1.6 的噪声翻掉」。两者的区别在下 PR 描述时容易含糊,这里记明。
六、三个工具修正(否则上面的数据拿不到)
-
ref_layer_dump.py的 logits 索引:logits[0, -1]→logits[0]。之前所有 dump 里的final_row_logits都是 0 维标量,等于没有。 -
profile run 污染 dump:
execute_model_profile_run会走完整的 forward + sampler, 用的是合成 token。层 dump 靠 token 数门控侥幸过滤掉了,但 logits 的行数是「选中 token 数」 而不是 prompt 长度,门控不适用,于是抓到的是 profile 的垃圾(第一版得到 cos = 0.34)。 现在用debug_dump.profiling()上下文在 profile 期间整体挂起,并且把 logits 拆成logits_prompt/logits_decode两个键 —— 只有前者能和 prefill-only 的参考 dump 比较。 -
max_tokens > 1时logits_prompt也会被污染:mysteps6(max_tokens=7)算出 cos=0.68、top-1 误差 16.5,而mylogits3(max_tokens=1)同一输入是 0.9846 / 3.30, 差 5 倍。比较 prefill logits 只能用max_tokens=1的运行。