DeepInfer/H20-DRIFT-ANALYSIS.md
loopyt 283ff893d5 debug: capture final logits, and keep the profile forward out of the dumps
Comparing final logits was impossible before: `ref_layer_dump.py` read `logits[0, -1]`, but the
head already collapsed the sequence axis (`x = x[:, -1]`), so logits are `[b, vocab]` and that
index silently stored a 0-dimensional scalar. Every earlier dump had an unusable
`final_row_logits`.

With that fixed, the comparison reframes the remaining drift. On the same 14 tokens:

    prefill   top-1 = 6127 (北京)   margin(6127 - 666) = +0.256
    reference top-1 = 6127 (北京)   margin             = +3.112

Our prefill picks the right token; the error only compresses the margin, by ~12x. But the
end-to-end run, which reaches that context via decode steps, picks 666 (`**`) instead. Same
context, different path, different answer -- so the remaining drift is in **decode**, not in the
per-128 quantization. The quantization floor is still 2.7e-2 and still irreducible
(`H20-DRIFT-ANALYSIS.md` has the DeepGEMM assertion that forces `gran_k == 128` on SM90), but it
is not what flips the token.

Two traps had to be handled to get numbers that mean anything:
- `execute_model_profile_run` runs a full forward + sampler on synthetic tokens. The layer dumps
  were filtered by their prompt-length gate; logits are not, because their row count is the
  number of *selected* tokens (1). The first attempt captured that step and produced cos = 0.34.
  `debug_dump.profiling()` now suspends capture for the whole profile step.
- With `max_tokens=1` both a prompt and a decode step run, and only the prompt one is comparable
  with a prefill-only reference. They are dumped as `logits_prompt` / `logits_decode`.

Also re-runs the layer comparison now that the expert-scale fix has landed: layer 0 went from
1.27e-1 to 2.88e-2 and layer 39 sits at 2.00e-1, so the error curve flattened by an order of
magnitude.
2026-10-21 17:44:21 +08:00

6.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 写入/读回一致性 写入已知向量后读回,与 bf16 原值比。这是 P3.9 遗留项,也是最可能的嫌疑人: decode 用 _decode_p_sum_sm90 走 torch 兜底算 indexer 分数,而 prefill 走 TileLang kernel —— 两条路径从未对拍过。

  • P4.4 _decode_p_sum_sm90 vs prefill 的 MQA logits 同一个 KV cache、同一个位置,两条 indexer 路径的分数差。

判据:如果 P4.1 的差距 < 1e-3,说明 decode 无误,** 另有来源(回到采样器/logits processor 查);如果差距明显,就从 P4.3/P4.4 里找。


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

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