DeepInfer/H20-PORTING-JOURNAL.md
loopyt dcf8d11570 docs: record the two-branch split and the upstream PR link
The working branch stays as-is so local experiments are not churn; the PR branch carries the
ruff/annotation commit that CONTRIBUTING.md requires.
2026-10-21 17:16:46 +08:00

23 KiB
Raw Blame History

H20 / SM90 移植日志

DeepInfer 原本是 Blackwell-only 的推理框架。这份文档记录把它跑到 8×H20(SM90)上、 以及把数值从「乱码」修到「答对」的全过程:时间线、每个坑的根因和修法、完整变更清单, 最后是仍然存在的数值漂移 TODO。

配套文档:

  • H20-SM90-FINDINGS.md —— 硬件/环境事实、SM90 与 SM100 的能力差异分析
  • H20-DEBUG-PLAN.md —— 分阶段排查计划(P0~P5),含每一步的判据和结果

代码位于 git@github.com:tonyh168/DeepInfer.git,两个分支:

分支 用途
feat/h20-sm90-support 自用。能跑、验证过,但未做 ruff 规范化
h20-sm90-support 提 PR 用。在自用分支基础上补了格式与类型注解,两项 ruff 检查均通过
上游 PR:https://github.com/deepseek-ai/DeepInfer/pull/180

改动逻辑时请改 feat/,然后 cherry-pick 到 h20-sm90-support 并重跑 ruff check --no-cache 与 ruff format --check --no-cache(CONTRIBUTING.md 的硬性要求)。


0. 结论摘要

项 状态
8×H20 上加载 V4.1-Flash 全量权重(78.5 GiB/卡) 完成
prefill / decode / sampling 全链路 完成
稀疏 MLA attention kernel(TileLang 重写) 完成,104 TFLOP/s @ bf16 精度
Engram 表查询(NVLink all_to_all 替代 RDMA) 完成,与全表参考逐元素对齐
DeepSelect top-k 完成(torch.topk 复刻契约)
语义正确性 首 token 起逐 token 对得上,答案正确
残余数值漂移 见 §6,未收敛

端到端对比(11-token chat prompt,greedy):

官方参考:  [17607, 2793, 3139, 6127, 320]              -> 中国的首都是北京。
移植后:    [17607, 2793, 3139, 666, 6127, 666, 320, 1]   -> 中国的首都是**北京**。

前 3 个 token 逐 token 一致,答案正确;差异是多余的 markdown 加粗 **。


1. 起点:为什么这件事不是「改个 flag」

原框架对 Blackwell 的依赖不是一处配置,而是四层:

层 依赖 SM90 上的后果
算子库 vendor 的 FlashMLA fork 只有 sm_100a/sm_103a,每个入口都 TORCH_CHECK(arch.is_sm100f()) import 就失败,或 launch 报 "no kernel image"
内核实现 DeepGEMM 的 fp8_fp4_paged_mqa_logits / fp8_fp4_sparse_mqa_logits 无 SM90 版本 稀疏 attention 的 indexer 无法调用
量化配方 FP4 tensor core、packed UE8M0(4 个指数塞进 int32)、per-32 block 三者 SM90 全没有
通信 Engram 用 NCCL GIN(GPU-initiated RDMA) 单机 8 卡场景无 RDMA fabric
融合 kernel mega_mhc / mega_moe / FlashAttention-4 CuTe DSL / fused norm+rope+cast 全部 SM100-only

SM90 的量化口径(DeepGEMM 在 Hopper 上的唯一选择):FP8 e4m3 + fp32 scale + per-128 block, recipe (1, 128, 128),disable_ue8m0_cast=True。

策略:不去改调用点,而是复用框架已有的 patch_imports 机制(Ascend 后端在用), 把 Blackwell-only 的 from X import Y 整体替换成 SM90 实现。新增 deepinfer/h20_ops/ 包。


2. 时间线

阶段 A(本次会话之前):让它能加载、能算

已完成并记录在 H20-SM90-FINDINGS.md:

  • 平台识别:新增 PlatformH20,is_sm90=True;把原来混在一起的 fp8_sf_type 拆成 sf_pack_factor(GEMM scale 类型 vs 每元素打包几个 scale)。
  • 权重量化:FP4 experts + ue8m0 dense 在加载时重量化成 FP8 e4m3 / fp32 per-128。
  • 两个 FlashMLA kernel 用 TileLang 开源实现替换(sparse_mla_sm90.py)。
  • vendor 编译通过,_C 可导入,权重 8 卡 78.5 GiB/卡。
  • Engram 表查询改成本地 all_to_all gather。

阶段 B:13:45 —— 澄清「prefill 跑通」的含义

用户问:「prefill 跑通是说第一个 token 是个语义正常的 token 吗?」

核查日志后发现:之前三个 run 里 OUTPUT / INPUT 计数都是 0,一句话都没生成过。 当时说的「跑通」只是「前向不崩、没有 NaN」。这个澄清很重要,它把目标从 「不报错」改成「答对」,后面所有工作都围绕后者。

定下的第一个硬判据:官方参考在这条 prompt 上输出什么,我们就得输出什么。

阶段 C:14:00-14:20 —— 打通到「能出 token」

按报错顺序修了 7 处(详见 §3.1~§3.3):

  1. DeepSelect topk 在 SM90 launch 失败 → torch.topk 复刻契约
  2. SM90 的 MQA logits 输出是 fp32(SM100 是 bf16),candidate_block_max 只吃 bf16 → 加窄化
  3. 共享层候选块优化(fp8_fp4_sparse_mqa_logits)SM100-only → 关掉,走稠密路径
  4. 稀疏 MLA 的 0/0:padding 行 sumexp == 0,acc_o /= 0 让整个 hidden state 变 NaN
  5. paged MQA logits 的 varlen:SM90 DG_HOST_ASSERT(not is_varlen) → indices=None
  6. paged kernel 硬要求 64-token KV page,而 cache 是 128 → decode indexer 走 torch 兜底
  7. ViT 兜底的 .tolist() 被 forbid_cuda_sync 拦 → allow_cuda_sync() 局部放行

结果:能出 token,但内容是 EVAL - 暂时不会 - (应为「北京」)。

顺带发现:DeepInfer 没有 --enforce-eager 命令行开关,只有环境变量 DEEPINFER_ENFORCE_EAGER=1 + DEEPINFER_DFLASH_ENFORCE_EAGER=1。打开后跳过 CUDA graph capture,绕过了 capture 期的 not is_varlen 断言。

阶段 D:14:20 —— 固化基准,发现「对比方法本身是错的」

建立官方基准:/data/models/DeepSeek-V4.1-Flash-1017/inference/generate.py + TP8 权重

  • config_fp8.json,其 generate() 直接取 argmax,天然 greedy、可复现。

第一个方法论坑:官方 generate.py 走 encode_messages() 套 chat 模板, 所以「两侧喂同样的原始文本」并不等于同样的输入。之前的文本对比全部无效。

修法:写 scripts/ref_next_token.py(吃任意 token ids → greedy next token + top5), 和 scripts/h20_ask.py --ids 对称。拿到官方真实输入:

[0, 128803, 2056, 768, 17607, 2793, 3139, 12108, 1148, 128804, 128822]

阶段 E:14:35 —— 环境事故:磁盘满

/tmp(= docker 根)100% 满,nvcc JIT 报 fatal error: when writing out to /tmp/tmpxft_....ii: No such file or directory, 实验根本起不来。

清理:删 4 个已退出容器 + docker image prune -a → 0 → 75 GB 可用。

注意:这台机器是共享的。flagos / seed-oss-36b-flagos / meta-llama-3-8b-instruct-nvidia-flagos1 会被同事反复重启抢走 GPU。跑 8 卡实验前 必须先 nvidia-smi + docker ps 确认;容器 stop 后 vLLM 的 EngineCore 子进程可能 残留占显存,需要手动 kill -9。

阶段 F:14:50 —— P1:prefill 因果掩码(第一个真 bug)

现象:修好 NaN 之后首 token 是 17607(中国的)——对的,但第 4 个 token 起分叉。

定性实验:把 14 个 token(官方前缀 + 17607,2793,3139)直接当 prefill 输入, 我方仍预测 666(**),官方在同一前缀下给 6127(北京)。 => 分歧在 prefill 内,不是 decode 累积。

根因:prefill_attn_varlen_sparse 用 idx <= 行号 当因果界。但 DeepInfer 传的 kv_index 是装配后 buffer [swa cache | 当前 swa | compressed] 的绝对位置 (kv_index_cat 整体加了 total_k_swa_cached + total_k_swa_cur),行号跟它没有对应关系 => 几乎所有 index 被 mask => sumexp=0 => acc_o/0 => 整层 NaN。

修法:is_causal=False。FlashMLA 的稀疏 API 本来就没有 causal 参数——因果关系 由 indexer(top-k 只取因果范围内)和 SWA window index 保证。

单测为什么没抓到:h20_attn_check.py 的 make_indices 按「第 t 行取 [0, t] 内」 造索引,正好满足 idx <= q_pos,把这个 bug 遮住了。已补「绝对位置」用例 (索引最大 3078,行数只有 16)。

结果:首 token 与官方一致,前 3 个 token 拼起来都是「中国的首都是」,第 4 个 token 仍分叉。

阶段 G:15:20 —— P2:分层对拍

工具:官方侧 scripts/ref_layer_dump.py(forward hook),我方侧 DEEPINFER_DUMP_LAYERS=<dir> → h20_ops/debug_dump.py, scripts/compare_layers.py 算 cos / rel_err / 范数比。

坑 2:我方的 decoder_layer 返回的 hidden_states 只是子层输出(2D), 完整残差流在 residual(HCLazyPostData,hc_mult=4)里。不经过 hc_post 合并就比, 每层 cos 都是 0.02 —— 假结论。

坑 3:引擎在分配 KV cache 前会跑一次 dummy profile forward,token 数与真实 prompt 不同。必须用 DEEPINFER_DUMP_TOKENS=<n> 门控,否则 dump 到的是 dummy 输入。

结果(14-token 输入):

位置 cos rel_err
layer0 attention 子层 0.99897 4.7e-2
layer0 整层 0.99190 1.27e-1
layer1 整层 0.97707 2.29e-1
layer11 0.86515 5.28e-1

结论:attention 是对的(4.7% 在 FP8 口径下属合理),误差主要来自第 0 层的 FFN/MoE, 并单调累积到把 top-1 翻掉。

阶段 H:16:40 —— P3:layer 0 的 FFN 内细分

在 mlp.py 的 MoE 内挂点,dump 路由 id / gate 权重 / shared 输出 / combined 输出。

路由(最后一个 token,top-6):

层 重合度
L0 5/6(差一个:ref 370 vs mine 312,边界翻转)
L1 6/6 完全一致
L2 6/6 完全一致

分支:

分支 cos rel_err
layer0_shared_out(shared expert) 0.99871 5.2e-2
layer0_combined(routed + shared) 0.93949 3.6e-1

=> 路由基本无罪,shared 对,routed experts 错。

阶段 I:16:50 —— 找到根因并修复

找到了权威参考:官方 inference/convert.py::cast_e2m1fn_to_e4m3fn 就是把 FP4 转 FP8 的实现(生成 TP8 权重用的)。写 scripts/h20_expert_weight_check.py 逐元素对拍, 第一次结果:cos≈0.894、rel_err≈0.53 —— 系统性错误,不是精度损失。

根因:h20_ops/quant.py::requant_expert_fp4 用 _expand_blocks(sf, n_out, k, 32) 展开 UE8M0 scale,而这个函数对两个维度都做 repeat_interleave(32):

scale 形状 该展开的维度
dense 权重 (n_out/32, k/32) 两个维度
专家权重 (n_out, k/32) 只有 k 维度

于是 (2304, 160) 被当成 (72, 5) 展开 —— 全部 2304 个输出通道都拿到了前 72 行的 scale,每个专家的权重被打乱。这精确解释了 P2/P3 观察到的 「shared 对、routed 错」非对称:shared 走 requant_dense_fp8(_expand_blocks 用法正确), routed 走这条错路径。

修法:

assert tuple(sf.shape) == (n_out, k // FP4_BLOCK), f"unexpected expert scale layout: {tuple(sf.shape)}"
x = _unpack_fp4(packed) * sf.to(torch.float32).repeat_interleave(FP4_BLOCK, dim=1)

验证:

权重 修复前 cos / rel_err 修复后 cos / rel_err
w1 0.8936 / 5.35e-1 1.0000 / 2.84e-2
w3 0.8960 / 5.26e-1 0.9997 / 2.69e-2
w2 0.8964 / 5.20e-1 0.9996 / 2.65e-2

端到端:中国的首都是**北京**。 —— 答案正确。


3. 踩过的坑(速查表)

按「当时现象 → 根因 → 修法」索引。前 3 个是方法论坑,如果不先排掉,后面所有 数字都是错的。

# 现象 根因 修法 位置
1 文本对比看起来差很远 官方套 chat 模板,两侧输入不同 用 token ids 对齐,ref_next_token.py + --ids 工具
2 每层 cos 都是 0.02 比的是子层输出,不是残差流 hc_post(hidden_states, residual) 后再比 decoder_layer.py
3 dump 出来完全不相关 引擎先跑 dummy profile forward DEEPINFER_DUMP_TOKENS=<n> 门控 debug_dump.py
4 layer0 全 NaN 稀疏 MLA sumexp=0 时 acc_o/0 eps 下限截断,空窗口返回 0 sparse_mla_sm90.py
5 首 token 之后全乱 prefill 用行号当因果界,但索引是绝对位置 is_causal=False attn_sm90.py
6 DeepSelect launch 失败,无 kernel image kernel 只编了 SM100 torch.topk 复刻契约;vendor setup.py 打开 sm_90a deep_select_sm90.py
7 candidate_block_max 断言失败 SM90 的 MQA logits 是 fp32,kernel 只吃 bf16 _as_bf16_block_logits 窄化 index_attn.py
8 共享层候选块优化报错 fp8_fp4_sparse_mqa_logits SM100-only 关掉 is_candidate_topk_source / use_candidate_topk index_attn.py
9 decode indexer 断言 block_kv == 64 SM90 paged kernel 硬要求 64-token page,cache 是 128 _decode_p_sum_sm90,torch 直接算 index_attn.py
10 not is_varlen 断言 SM90 的 paged MQA logits 不支持 varlen 两处 indices=None index_attn.py
11 called a synchronizing CUDA operation forward 内做 host 读取 allow_cuda_sync() 局部放行 多处
12 Engram 查表失败 EngramBuffer.fetch 需要 NCCL GIN / RDMA 两次 all_to_all 的本地 gather engram_local.py
13 nvcc JIT 失败 No such file or directory /tmp 100% 满 删已退出容器 + image prune -a 运维
14 OOM 时显存已被别人占 共享机器被别人 vLLM 抢卡 先 nvidia-smi;容器 stop 后 kill -9 残留 EngineCore 运维
15 routed experts 全错,shared 全对 _expand_blocks 对两个维度都展开,但专家 scale 是 per-channel 只沿 contraction 轴 repeat_interleave quant.py

关于 torch.float4_e2m1fn_x2:它的 cast kernel 在 SM90 和 CPU 上都没有实现 (GPU 报 device-side assert,CPU 报 copy_kernel not implemented)。 验证 FP4 逻辑只能走整数位运算 + 查表,见 h20_quant_check.py。


4. 变更清单

41 files changed, 3113 insertions(+), 59 deletions(-)

4.1 新增包 deepinfer/h20_ops/(1028 行)

文件 行数 职责
__init__.py 41 注册 patch_imports 的 SM90 替换项
sparse_mla_sm90.py 224 TileLang 稀疏 MLA kernel(prefill/decode)
attn_sm90.py 196 prefill_attn_varlen_sparse / decode_mla_mqa_sparse,签名与 attention_backend.py 一致
engram_local.py 192 NVLink all_to_all 替代 RDMA 查表
deep_select_sm90.py 97 torch.topk 复刻 DeepSelect 契约
quant.py 93 FP4→FP8、ue8m0→fp32 重量化
vit_attn.py 86 ViT attention 的 SDPA 兜底
debug_dump.py 99 逐层 dump(opt-in)

4.2 修改的框架文件(18 个)

文件 改动
deepinfer/platform.py 新增 PlatformH20;sf_pack_factor 拆维度
deep_cluster/hardware/{__init__,device}.py 识别 H20
deepinfer/hugging_face/config.py 配置映射(hc_mult 等)
deepinfer/hugging_face/weights.py _load_expert_params_sm90:加载时重量化专家
deepinfer/model_executor/layers/linear.py SM90 GEMM recipe (1,128,128)
.../tilelang_ops/cache_common.py 新增 FP8_FLOAT32_SF_INDEX_KV_FORMAT
.../tilelang_ops/fused_transform.py 融合 kernel 的 SM90 分支
deepinfer/model_executor/models/decoder_layer.py NaN 诊断 + dump 挂点
deepinfer/model_executor/models/deepseek.py embed / final_norm dump 挂点
.../v4_modules/attn.py 平台分支
.../v4_modules/containers.py 权重容器
.../v4_modules/engram.py 走本地 gather
.../v4_modules/hc.py Hyper-Connections 分支
.../v4_modules/index_attn.py bf16 窄化 + decode 兜底 + 关候选块优化
.../v4_modules/mlp.py MoE 兜底 + dump 挂点
.../parallel_utils/userbuffer.py SM90 分支
deepinfer/npu_ops/patch_imports.py 扩展 patch 机制
deepinfer/worker/cache_engine.py KV cache 布局

4.3 新增脚本(11 个)

自检类(8 卡 H20 上跑):h20_attn_check.py、h20_engram_check.py、 h20_topk_check.py、h20_smoke_check.py、h20_real_requant_check.py、 h20_quant_check.py、h20_expert_weight_check.py

对拍类:h20_ask.py(单 prompt 快速验证)、ref_next_token.py(官方 next token)、 ref_layer_dump.py(官方逐层)、compare_layers.py(逐层 diff)

4.4 文档

H20-SM90-FINDINGS.md、H20-DEBUG-PLAN.md、本文。

4.5 未纳入版本管理(重要)

vendor/* 是 submodule 且本地未初始化(gitlink 未改),所以:

  • vendor/DeepSelect/setup.py 里 arch=compute_90a,code=sm_90a 原本被注释掉, 需要手动打开,否则 top-k 报 "no kernel image"。
  • 该修改只存在于本机 /data/deepinfer-h20/DeepInfer-git,克隆本分支后必须手动处理。

5. 复现指南

环境变量(缺一不可)

export PYTHONPATH=<repo>:<repo>/examples
export DEEPINFER_ACCELERATOR=nvidia
export DEEPINFER_HF_MODEL_DIR=/data/models/DeepSeek-V4.1-Flash-1017
export DEEPINFER_ENABLE_MEGA_MOE=0      # mega_moe 是 SM100-only
export DEEPINFER_BATCH_INVARIANT=0
export EP_DISABLE_GIN=1                  # 没有 RDMA
export DEEPINFER_ENFORCE_EAGER=1         # 跳过 CUDA graph capture
export DEEPINFER_DFLASH_ENFORCE_EAGER=1

冒烟测试

python scripts/h20_ask.py --ep 8 --max_tokens 8 \
  --ids 0,128803,2056,768,17607,2793,3139,12108,1148,128804,128822

期望(当前):中国的首都是**北京**。

分层对拍

# 官方侧
cd /data/models/DeepSeek-V4.1-Flash-1017/inference
PYTHONPATH=$PWD torchrun --nproc-per-node 8 \
  <repo>/scripts/ref_layer_dump.py \
  --ckpt-path /data/models/DeepSeek-V4.1-Flash-1017-TP8 \
  --config config_fp8.json --out /data/deepinfer-h20/ref \
  --ids 0,128803,2056,768,17607,2793,3139,12108,1148,128804,128822,17607,2793,3139 --moe_layers 3

# 我方侧(同一条 ids,注意 14 token 门控)
DEEPINFER_DUMP_LAYERS=/data/deepinfer-h20/ref/mine8 \
DEEPINFER_DUMP_TOKENS=14 DEEPINFER_DUMP_MOE_LAYERS=3 \
python scripts/h20_ask.py --ep 8 --max_tokens 1 --ids <同上>

# 对比
python scripts/compare_layers.py \
  --ref /data/deepinfer-h20/ref/ref_layers.npz \
  --mine /data/deepinfer-h20/ref/mine8/deepinfer_layers.npz

权重正确性(不占 GPU)

python scripts/h20_expert_weight_check.py --layer 0 --expert 370
python scripts/h20_quant_check.py

6. 当前漂移:TODO

6.1 现象

答案已正确,但输出多了 markdown 加粗:

官方: 中国的首都是北京。
我方: 中国的首都是**北京**。

多出的 token 是 666(**)。说明还有残余数值差异,在个别位置把 top-1 推偏。

6.2 已量化的残余误差

环节 rel_err 是否异常
专家权重(vs 官方 converter) 2.7e-2 否,见下
layer0 attention 子层 4.7e-2 否,FP8 合理量级
layer0 整层 1.27e-1 待 P3.7 复测
最终 top-1 个别位置翻转 待定位

那 2.7% 的性质:per-32 e8m0 → per-128 fp32 的重量化损失。FP8 e4m3 只有 3 位 尾数,理论量级就是 ~3%,所以它不是 bug。

但注意:官方 cast_e2m1fn_to_e4m3fn 是无损的 —— 它把 per-32 的相对 offset 折进 FP8 数值里,scale 只保留每 32×32 块一个(6.0 * 2^6 = 384 < 448,刚好放得下)。 也就是说这 2.7% 是我们主动引入的,不是必然的。

6.3 建议的下一步(按收益排序)

  • P3.6 改用官方无损重化 照 cast_e2m1fn_to_e4m3fn 的做法:per-32 e8m0 scale 保持不变(只留块内最大 offset), 相对偏移折进 FP8 值。理论上把 2.7% 降到 0。 前置确认:SM90 DeepGEMM 是否接受 e8m0 per-32 的 expert scale —— 当前配方是 fp32 per-128,(1,128,128) recipe 可能不接受别的 grid。若不接受,则此路不通, 只能接受 2.7%。

  • P3.7 重跑 P2 分层对拍 确认 layer0 整层 rel_err 从 1.27e-1 降到 ~3e-2 量级,看误差累积曲线是否变平。 这是验证 §6.2 判断的直接手段。

  • P3.8 定位 ** 的具体来源 逐位置跑 greedy,找出从第几个 token 起 top-1 与官方不同,再对那个位置做单层对拍。 如果所有位置的 top-1 都对、只是官方提前 EOS,那是采样/EOS 逻辑问题,不是数值问题。

  • P3.9 复核 index KV 字节布局 SM90 用 FP8_FLOAT32_SF_INDEX_KV_FORMAT(128 fp8 + 1 f32 = 132 B/token), store kernel 是否按「整页 values 后接整页 scales」写、_decode_p_sum_sm90 的读法 是否一致,从未做过 round-trip 验证。目前 attention 子层 4.7% 的误差里可能含此项。 验证方法:写入已知向量 → 读出 → 比 bf16 原值。

  • P4 decode 专项(仅当 prefill 已完全对、decode 仍偏时)

    • KV cache 写入后立刻读回,比 bf16
    • 同一位置 decode 路径 vs prefill 路径重算,比 logits
    • _decode_p_sum_sm90 的数值 vs prefill 的 fp8_fp4_mqa_logits
  • P5 logprob 异常 采样结果里 token_logprobs 全是 0.0(概率=1),不正常。 先确认是采样器 bug 还是 logits 极端(打印 logits 的 max/median)。

6.4 收尾时要复核的绕过项

这些都能跑,但都会影响数值或性能,正式发布前应逐个review:

绕过 原因 数值风险
DEEPINFER_ENFORCE_EAGER=1 graph capture 期断言失败 无(仅性能)
DeepSelect → torch.topk SM90 launch 失败 无(契约等价,已单测)
decode indexer → _decode_p_sum_sm90 page size 不符 有,未经对拍
关闭共享层候选块优化 kernel SM100-only 无(稠密等价)
mega_mhc / mega_moe 关闭 SM100-only 无(有单测)
EP_DISABLE_GIN=1 无 RDMA 无(改走 NVLink all_to_all,已单测)
ViT → SDPA FA-4 是 SM100-only 无(多模态路径)

性能尚未系统测评。当前只测了两个点:稀疏 MLA prefill 104 TFLOP/s、decode 0.23 ms/step(batch=8, topk=1024)。端到端吞吐、vs vLLM/SGLang 的对比都还没做。


7. 方法论:下次遇到同类问题怎么少走弯路

  1. 先固化基准,再谈对错。 官方 generate.py 是 greedy,是同卡确定性参考。 没有基准的话,「看起来像中文」会被误判成「对了」——本次一开始就吃过这个亏。

  2. 对齐输入比对齐输出更重要。 chat 模板这个坑让前面几小时的文本对比全部作废。

  3. 离散量先查。 路由 id、top-k 索引这类离散量,一次比较就能给出确定判决; 连续量(hidden state)只能告诉你「有偏差」。这也是 P3 先查路由的原因。

  4. 比之前先确认「比的是同一个东西」。 子层输出 vs 残差流、真实 prompt vs dummy profile —— 这两个坑都让对比结果看起来像「第 0 层就全错」,白白惊慌半天。

  5. 单测的输入分布要覆盖真实调用。 make_indices 按 [0, t] 造索引,正好掩盖了 绝对位置这个 bug。写单测时要问:真实调用会传什么形状/量级?

  6. 优先找权威参考实现。 官方 inference/convert.py 里就有 cast_e2m1fn_to_e4m3fn, 对拍它的成本远低于自己推导。找到它之后,那个 bug 10 分钟就定位了。

  7. 精度损失和逻辑错误要分开判断。 FP8 有 ~3% 的理论损失,rel_err=0.53 显然不是 精度问题;区分开之后搜索空间立刻缩小一个数量级。