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.
6.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 写入/读回一致性 写入已知向量后读回,与 bf16 原值比。这是 P3.9 遗留项,也是最可能的嫌疑人: decode 用
_decode_p_sum_sm90走 torch 兜底算 indexer 分数,而 prefill 走 TileLang kernel —— 两条路径从未对拍过。 -
P4.4
_decode_p_sum_sm90vs prefill 的 MQA logits 同一个 KV cache、同一个位置,两条 indexer 路径的分数差。
判据:如果 P4.1 的差距 < 1e-3,说明 decode 无误,** 另有来源(回到采样器/logits
processor 查);如果差距明显,就从 P4.3/P4.4 里找。
五、两个工具修正(否则上面的数据拿不到)
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 比较。