DeepInfer/H20-DRIFT-ANALYSIS.md
loopyt 5004c7777d docs: record that plain-text prompting answers correctly
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.
2026-10-21 22:50:57 +08:00

9.5 KiB
Raw Blame History

漂移调查:2.7% 不是主因,问题在 decode 路径

承接 H20-PORTING-JOURNAL.md §6.3 的 P3.6(「改用官方无损重化把 2.7% 降到 0」)。

两个结论:

  1. P3.6 在 SM90 上不可行 —— DeepGEMM 在 Hopper 上强制 per-128 scale,per-32 是 SM100 专属。2.7% 是硬件地板,不是实现缺陷。
  2. 但 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 必须 128
  • arch_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_FORMAT 528 B/token 与 FP8_FLOAT32_SF_INDEX_KV_FORMAT 132 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_sm90 vs 独立参考 —— 数值精确,排除 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 描述时容易含糊,这里记明。


六、三个工具修正(否则上面的数据拿不到)

  1. ref_layer_dump.py 的 logits 索引:logits[0, -1] → logits[0]。之前所有 dump 里的 final_row_logits 都是 0 维标量,等于没有。

  2. 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 比较。

  3. 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 的运行。