DeepInfer/H20-SM90-FINDINGS.md
loopyt 1e04aea7ea feat: add SM90 (Hopper) support for DeepSeek-V4.1 inference
DeepInfer targets Blackwell: the vendored FlashMLA fork only ships SM100 kernels
(`CUDA_ARCHS = ["sm_100a", "sm_103a"]`, every entry point asserts `arch.is_sm100f()`),
and several DeepGEMM attention APIs plus the whole quantization recipe assume
FP4 tensor cores, packed UE8M0 scales and per-32 blocks. None of that exists on
Hopper, so this branch adds an SM90 backend and gets an 8x H20 node to produce
tokens end to end.

Scope and status: the pipeline runs (prefill, decode, sampling) and matches the
reference on the first token, but numerics still diverge from position ~12 onward,
so the output is not yet correct. H20-DEBUG-PLAN.md tracks the remaining work with
the exact reproduction case; see H20-SM90-FINDINGS.md for the analysis.

Quantization
- `Platform.sf_pack_factor` separates two meanings that `fp8_sf_type` conflated:
  the GEMM scale type (FP32 on SM90) and the number of scales packed per element
  (1, instead of 4 UE8M0 exponents per int32). KV caches keep the ue8m0 byte layout.
- Requantize FP4 experts and ue8m0 dense weights to FP8 E4M3 with FP32 per-128
  scales while loading (`hugging_face/weights.py`, `h20_ops/quant.py`), so the
  runtime sees a plain FP8 checkpoint: `mxfp4` is disabled at runtime, expert
  weights are repacked as 2D, and `use_fp4_sparse_kv` is off since the FP4 cache
  kernels do not exist on Hopper.
- GEMM recipes switch to `(1, 128, 128)` with `disable_ue8m0_cast=True`, which is
  the only shape SM90 DeepGEMM accepts.

Attention
- `h20_ops/sparse_mla_sm90.py` adapts TileLang's `examples/deepseek_v32` sparse MLA
  kernel (which is Hopper-targeted) to the V4.1 layout: RoPE lives inside the
  512-wide latent, so the V3.2 `dim`/`tail` split is removed and QK/PV are both 512.
  Measured on H20: 104 TFLOP/s, bf16-noise accuracy. It takes no causal mask,
  because the indices are already causal -- deriving causality from the row number
  drops every index once the KV buffer is offset, which produced an all-NaN layer 0.
- `h20_ops/attn_sm90.py` re-implements `prefill_attn_varlen_sparse` and
  `decode_mla_mqa_sparse` with the same signatures as `attention_backend.py`, and
  registers them through `patch_imports`, so call sites are untouched and the
  FlashMLA-only module is never imported.
- Softmax normalisation clamps `sumexp`, so a padded row with no valid index yields
  zeros instead of 0/0 and cannot poison the whole hidden state.

Engram
- `h20_ops/engram_local.py` replaces `deep_ep.EngramBuffer.fetch`, which needs
  NCCL GIN (RDMA) and is unavailable on a single node, with a distributed gather
  over NVLink built from two `all_to_all`s. Row ownership follows the C++ layout
  (`owner = idx // num_local`). Verified against a full reference table on 8 ranks.

Other Blackwell assumptions removed
- DeepSelect's top-k kernel faults at launch on SM90, so `h20_ops/deep_select_sm90.py`
  reproduces its contract with `torch.topk` (end truncation, OOB fill, in-place
  output views). The shared-layer candidate-block optimisation is disabled because
  `fp8_fp4_sparse_mqa_logits` has no SM90 implementation, and the dense MQA-logits
  path is used instead; its fp32 output is narrowed for the block-max kernel.
- Decode-time index scoring uses `_decode_p_sum_sm90`: Hopper's paged kernel
  hard-requires 64-token pages while the cache uses 128, and the pages are strided
  views that cannot be split without a copy.
- `mega_mhc` / `mega_moe`, the FlashAttention-4 CuTe DSL kernels and the fused
  norm+rope+cast kernel are all SM100-only and take a Hopper fallback.
- `allow_cuda_sync()` is used for the few places that genuinely need a host read
  (all-to-all sizing, the ViT fallback, the NaN diagnostic) instead of disabling
  the sync guard globally.

Vendored submodules (not part of this commit; submodule gitlinks are unchanged)
- `vendor/DeepSelect/setup.py`: re-enable the `sm_90a` gencode that is commented
  out, otherwise top-k fails with "no kernel image".

Scripts: `h20_attn_check.py`, `h20_engram_check.py`, `h20_topk_check.py`,
`h20_smoke_check.py`, `h20_real_requant_check.py` are unit checks that run on 8x
H20; `h20_ask.py` is a one-prompt harness and `ref_next_token.py` produces the
official reference next-token for arbitrary ids, so both sides can be compared on
identical input.
2026-10-21 15:18:56 +08:00

17 KiB
Raw Blame History

DeepInfer on H20 (SM90) —— 实测结论与交接

日期:2026-10-21 机器:ssh nv-h20(8×H20-3e,SM90 / compute 9.0,驱动 580.95.05,184 核 / 1.8TB) 容器:di-h20(基于 vllm/vllm-openai:v0.32.0,torch 2.13.0+cu130 / CUDA 13.0 / gcc 13.3) 代码:/data/deepinfer-h20/DeepInfer-git(upstream 774ce24,与 handoff 的 8b16149 仅差文档) 补丁:/data/deepinfer-h20/patches/h20-sm90.diff(479 行,10 个文件改动 + 新增 deepinfer/h20_ops/) 日志:/data/deepinfer-h20/logs/


0. 当前进度(2026-10-21 傍晚)

阶段 状态
量化口径 / 平台识别 / 两个 FlashMLA kernel 换 TileLang ✅
全部 vendor 编译 + _C ✅
8 卡真权重加载 ✅ 78.5 GiB/卡
Engram 表查询(本地跨卡 gather) ✅
端到端跑完(prefill + decode + sample) ✅ 能出 token
语义正确性 ❌ 输出是乱码,见下

现在能跑到哪一步

DEEPINFER_ENFORCE_EAGER=1 + DEEPINFER_DFLASH_ENFORCE_EAGER=1(跳过 CUDA graph capture,DeepInfer 没有 --enforce-eager 命令行开关,只有这两个环境变量)之后,完整链路贯通:

python scripts/h20_ask.py --max_tokens 8
=== prompt: '问题:中国的首都是哪里?\n答案:'
=== token ids (8): [33795, 2570, 565, 223, 22807, 4555, 565, 223]
=== continuation: 'EVAL - 暂时不会 - '

所以「跑通」≠「答对」:现在是能出 token,但内容是错的(应为「北京」), 8 个 token 的 logprob 全为 0.0 也很可疑。数值正确性还没打通。

这一轮修掉的问题(按出现顺序)

  1. DeepSelect topk 在 SM90 launch 失败 → h20_ops/deep_select_sm90.py 用 torch.topk 复刻同一套契约(end 截断 / OOB 填充 / output_idx_offset / 原地写 view), scripts/h20_topk_check.py 全部通过。
  2. SM90 的 MQA logits 输出是 fp32(arch_major == 10 ? kBFloat16 : kFloat32), 而 candidate_block_max 只吃 bf16 → 加 _as_bf16_block_logits 转换。
  3. 共享层候选块优化在 SM90 不可用(fp8_fp4_sparse_mqa_logits 是 SM100-only), 关掉 is_candidate_topk_source / use_candidate_topk,走稠密路径。
  4. 稀疏 MLA 的 0/0:padding 行没有有效索引时 sumexp == 0,acc_o /= 0 让整个 hidden state 变 NaN → 加 eps 下限截断,空窗口返回 0。
  5. paged MQA logits 的 varlen:SM90 DG_HOST_ASSERT(not is_varlen) → 两处 indices=None。
  6. SM90 paged MQA logits 要求 64-token KV page,而 resolved_tokens_per_block 固定 128 (ratio=1 的层 page 就是 128)→ 写 _decode_p_sum_sm90,decode indexer 用 torch 直接算 sum_h relu(q·k) * w(page 是 strided view,拆不成两个 64 页)。
  7. ViT 兜底里的 host 读取被 forbid_cuda_sync 拦 → allow_cuda_sync() 局部放行。

下一步(数值正确性定位)

乱码说明 hidden state 有偏差,候选源(按怀疑度排序):

  1. index KV 的字节布局:SM90 改成 FP8_FLOAT32_SF_INDEX_KV_FORMAT(128 fp8 + 1 f32 = 132B/token),但 store kernel 是否按「整页 values 后接整页 scales」写,未经对拍验证; 若是别的排布,indexer 选出的 KV 就是错的。
  2. 主 KV cache 的 ue8m0 解量化:SM90 用 fp32 scale,cache 仍存 ue8m0 字节, load_paged_kv_cache / store 两侧是否一致没验证。
  3. indexer 的 torch 兜底只保证「能算」,与 DeepGEMM 的数值没有对拍过。
  4. 每层的 attention / MoE 输出没有与 BF16 参考逐层比对过。

建议先做 单层对拍:用同一份权重、同一份输入,比较 (a) indexer 选出的 top-k 索引、 (b) 稀疏 MLA 输出、(c) 首层 MoE 输出 与官方 inference/ 的中间结果,把首个偏离点找出来。

1. 环境(可直接复用)

镜像 vllm/vllm-openai:v0.32.0 的 torch 是 2.13.0+cu130,正好对上 DeepInfer 要求的 torch==2.13.0(官方写 +cu132,cu130 实测可用)。容器内需要补的东西:

docker run -d --name di-h20 --gpus all --ipc=host --network host \
  --ulimit memlock=-1 --ulimit stack=67108864 \
  -v /data:/data -v /mnt/data:/mnt/data -w /data/deepinfer-h20 \
  --entrypoint sleep vllm/vllm-openai:v0.32.0 infinity

# 容器内(镜像里没有 git / pciutils / 部分 cuda-dev 头)
apt-get update && apt-get install -y git pciutils libdw-dev libelf-dev libzstd-dev
ln -sf /usr/bin/python3 /usr/local/bin/python

# torch 的 cuda 头缺 cusparse.h(镜像只装了运行时),从 cuda-toolkit wheel 里补
pip install nvidia-cusparse-cu13
cp -n /usr/local/lib/python3.12/dist-packages/nvidia/cu13/include/*.h /usr/local/cuda/include/

# DeepEP 需要 NCCL GIN(2.32.3 才有 ginMinStride / ncclDevCommRequirements)
pip install --no-deps --force-reinstall nvidia-nccl-cu13==2.32.3

# 依赖(用 aliyun 源,官方 pypi 只有 ~70kB/s)
pip install -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
# requirements-nvidia.txt 里的 torch==2.13.0+cu132 和 flash-attn-4 都不要装

构建:

export DEEPINFER_ACCELERATOR=nvidia CONDA_PREFIX=/usr/local MAX_JOBS=48
# vendor:DeepSelect / DeepEP / DeepGEMM 都能过(跳过 FlashMLA)
cd vendor/DeepSelect && python setup.py bdist_wheel --dist-dir ../../build/nvidia-x86_64/vendor-wheels
cd vendor/DeepGEMM  && DG_FORCE_BUILD=1 python setup.py bdist_wheel --dist-dir ../../build/nvidia-x86_64/vendor-wheels
cd vendor/DeepEP    && python setup.py bdist_wheel --dist-dir ../../build/nvidia-x86_64/vendor-wheels
for p in deep_select deep_gemm deep_ep; do
  python -m pip install --no-deps --no-index --find-links build/nvidia-x86_64/vendor-wheels $p
done
bash ./scripts/build.sh build      # -> deepinfer/_so/nvidia-x86_64/_C.cpython-312-*.so

2. 已验证:SM90 上能跑的部分

组件 结果 证据
DeepGEMM fp8/fp8 GEMM,fp32 sf,recipe (1,128,128) ✅ scripts/h20_smoke_check.py:max_abs_err 5.375 / rel 3.8%(= fp8 e4m3 量化噪声)
DeepGEMM bf16 GEMM ✅ err 0.0(逐位一致)
tile_kernels.quant.per_token_cast(SM90 参数:per-128 + 非 packed ue8m0) ✅ 返回 (fp8[256,1024], fp32 sf[256,8])
tile_kernels(moe / quant / engram)import ✅ tile_kernels imports OK
DeepEP 编译 ✅ deep_ep-2.5.0+93eb6eb wheel;import deep_ep 成功
deepinfer _C 扩展 ✅ _C.cpython-312-x86_64-linux-gnu.so
deepinfer.PLATFORM → PlatformH20,fp8_sf_type=float32,granularity=128 ✅ 实测打印

注意 DeepGEMM 的一个坑(已写进补丁):

  • SM90 只支持 recipe (1,128,128)(get_default_recipe() 里 arch 9 硬编码,且断言两个 sf 都是 fp32)。
  • DeepInfer 原来传的是 SM100 的 1D1D recipe=(1,1,32),在 SM90 上会直接 assert sf.size(-2) == ceil_div(mn, gran_mn) 失败。所有 GEMM 调用点都必须换 recipe + disable_ue8m0_cast=True。

2.5 已解决:两个 FlashMLA kernel 用 TileLang 开源实现替换(2026-10-21 下午)

结论:不需要自己写 SM90 稀疏 MLA,TileLang 官方 examples/deepseek_v32 就是同一个东西,而且原生面向 Hopper。

  • tile-ai/tilelang → examples/deepseek_v32/sparse_mla_fwd.py(另有 _fp8 / _pipelined / _seesaw 变体)
  • 官方测试入口带 @tilelang.testing.requires_cuda_compute_version_eq(9, 0),README 的 pipelined 版本性能数据是 H800 SXM 上约 600 TFLOPs —— 它本来就是给 SM90 写的
  • 它的语义与 flash_mla_sparse_fwd 几乎 1:1:Q[b,s,h,dim+tail]、KV[b,s_kv,1,dim+tail]、Indices[b,s,1,topk]、输出 [b,s,h,dim] + lse,causal 用 idx <= q_pos 实现

本机实测(8×H20,本轮)

项目 结果
官方例子原样跑 ✅ 编译通过、assert_tensors_similar passed、113 TFLOP/s(H20 bf16 峰值 148)
适配 V4.1 形状(head_dim 512、rope 内嵌、64 头)后 prefill ✅ max_abs_err 0.0147(bf16 噪声级),104.3 TFLOP/s
decode(paged fp8 cache + gather + 同一 kernel,非因果) ✅ max_abs_err 0.0019,corr 0.999997,batch 8 / topk 1024 → 0.27 ms/step

需要改的地方(已做,deepinfer/h20_ops/)

  1. sparse_mla_sm90.py:从官方例子拷来,去掉 V3.2 的 dim/tail 分裂(V4.1 的 rope 在 512 维内部, QK 和 PV 都是 512),加 is_causal 开关(decode 用非因果),索引读取 clamp 防越界。
  2. attn_sm90.py:实现 prefill_attn_varlen_sparse / decode_mla_mqa_sparse 两个入口(与 attention_backend.py 同签名),注册到 patch_imports,上游调用点零改动。 顺带补齐 FlashMLASchedMeta 占位类与 fused_norm_rope_attn_rope_cast stub。
  3. decode 的 paged cache 解量化:用 DeepInfer 已有的 tilelang_ops.gather(字节级行 gather、越界补零) 取 528B/token 行,再按 ue8m0 还原 scale(实测与参考逐位一致)。SWA + CA 双 cache 拼起来一起算。

3. 硬阻塞:FlashMLA fork 只有 SM100

证据(vendor/FlashMLA):

setup.py:28   CUDA_ARCHS = ["sm_100a", "sm_103a"]
setup.py:108  assert major > 13 or (major == 13 and minor >= 1), \
              "Compiling the kernels requires NVCC 13.1 or higher (cvt with .scaled::n2::ue8m0)."

csrc/api/sparse_prefill.cpp:104  TORCH_CHECK(arch.is_sm100f(), "Sparse Attention Prefill Kernel ... only supported on SM100f")
csrc/api/sparse_decode.cpp:190   TORCH_CHECK(arch.is_sm100f(), "Sparse Attention Decode Kernel ... only supported on SM100f")
csrc/api/fused_norm_rope_attn_rope_cast_fwd.cpp:56/216  TORCH_CHECK(is_sm100f, ...)

csrc/cuda_kernels/ 下只有 sm100/(全部 kernel)和 smxx/(只有 get_decoding_sched_meta / combine 两个调度辅助),没有任何 SM90 的 MLA kernel。 所以 handoff 里「FlashMLA 原生就是 SM90 实现、不用改」这句是错的——上游 FlashMLA 确实有 SM90 实现,但 DeepInfer 用的是 DeepSeek 自己 fork 的一版,那版只保留 Blackwell。

后果:import deepinfer.model_executor.models.deepseek 会停在 input_metadata.py:9 from flash_mla.flash_mla_interface import FlashMLASchedMeta (ModuleNotFoundError: No module named 'flash_mla',因为我们根本编不出 SM90 版本)。

3.1 第二个缺口:DeepGEMM 的 sparse MQA logits 也是 SM100-only

indexer 的 top-k 打分走 DeepGEMM,四条路径里两条没有 SM90 实现:

API SM90
fp8_fp4_mqa_logits(prefill 稠密) ✅ sm90_fp8_mqa_logits
fp8_fp4_paged_mqa_logits(decode 稠密) ✅ sm90_fp8_paged_mqa_logits
fp8_fp4_sparse_mqa_logits(candidate_blocks 版) ❌ DG_HOST_ASSERT(arch_major == 10 ...),attention.hpp:380
fp8_fp4_paged_sparse_mqa_logits ❌ 同上,attention.hpp:414

好消息:index_attn.py 里这两条只在 candidate_blocks is not None 时走(那是 "共享层候选块"优化)。把该优化关掉(shared_topology 不给 candidate source,或直接让 use_candidate_topk=False)就能只走 SM90 可用的稠密路径 —— 精度等价,只是 top-k 选块慢一点。 所以它是可绕过的,不是硬阻塞。

另外 sm90_* 缺失的还有 mega_moe / mega_gate / mega_mhc / locality_domain(我们已在 h20-platform 补丁里全部关掉)。


4. 已经完成的改造(补丁内容,10 文件 + 1 新包)

h20-platform

  1. deep_cluster/hardware/device.py:新增 is_sm_major_9();get_bf16_tflops_limit() 返回 148(只影响 MFU 统计)。
  2. deepinfer/platform.py:新增 PlatformH20(PlatformGPU),is_sm90=True、fp8_sf_type=torch.float32; get_platform() 在 is_sm_major_9() 时返回它。基类加 is_sm90=False。
  3. deepinfer/npu_ops/patch_imports.py:新增 extend_import_patches(..., override=True), 让第二个后端能覆盖已注册的 import patch(patch_imports 只能调用一次,直接调会抛异常)。
  4. 新增 deepinfer/h20_ops/:
    • __init__.py:把 flash_attn.cute.interface._flash_attn_fwd(FA-4,SM100 only)覆盖成 SDPA 实现。
    • vit_attn.py:ViT 的 varlen 非 causal attention,纯 F.scaled_dot_product_attention。
    • quant.py:ue8m0_to_fp32、_unpack_fp4(E2M1 查表)、requant_dense_fp8、requant_expert_fp4。

h20-quant(把 Blackwell 量化口径降级到 SM90 公开口径,在加载时在线转换,不落盘) 5. linear.py:get_fp8_casting_granularity() 在 SM90 返回 128;use_packed_ue8m0=False; 新增 get_gemm_recipe()(SM90 → (1,128,128));ReplicatedLinear 在 SM90 也做 transform_sf_into_required_layout(..., disable_ue8m0_cast=True)。 6. weights.py:SM90 下普通 FP8 权重加载后立刻 32×32 → 128×128 重量化(requant_dense_fp8), 并跳过 set_ue8m0_sf(那是把 4 个 ue8m0 塞进 int32 的 SM100 打包)。 新增 _load_expert_params_sm90:读 per-expert FP4(int8 packed) + E8M0 scale,解包→FP8 e4m3 + fp32 128-blocks,按 (n_local*k, n_out) + (n_local*k/128, n_out/128) 布局写入。 7. containers.py:materialize_hf_parameter(packed_fp4=True) 在 SM90 走 FP8 2D 布局分支 (SM100 那条断言 granularity == 32 会直接挂)。 8. mlp.py:use_mega_moe 在 SM90 关闭(+DEEPINFER_ENABLE_MEGA_MOE=0 开关); use_mega_gate = use_mega_moe;专家 sf 转换 disable_ue8m0_cast=True; grouped GEMM 改 recipe_a=(1,128) / recipe_b=(128,128)。 9. attn.py:use_fused_norm_rope_attn_rope_cast 在 SM90 关闭(该 kernel 硬编码 packed UE8M0 + per-32),_fused_norm_rope_cast_kwargs 直接返回 None;Wv 的 sf 转换加 disable_ue8m0_cast=True;fp8_einsum 换 recipe。 10. index_attn.py:indexer 的 KV 格式在 SM90 用 FP8_FLOAT32_SF_INDEX_KV_FORMAT (FP4 tensor core 在 Hopper 上不存在)。

数值自检(scripts/h20_smoke_check.py / scripts/dbg_quant.py,已实测):

  • _unpack_fp4 与参考表逐位一致
  • dense 32→128 重量化:rel_err 2.2e-6
  • expert FP4→FP8(128 blocks):误差等于 fp8 e4m3 的三位尾数量化噪声(scale=6/448 时 max err 0.14)

5. 已过时的「还差什么」(保留作历史记录)

下面这一节是 §2.5 之前的判断,已被 TileLang 方案取代。

5.0 SM90 稀疏 MLA attention 后端

需要实现的面很窄 —— 只要 2 个函数(attention_backend.py 里就这两条路径), 其余周边(indexer topk、KV cache store/load)都是 tilelang / DeepGEMM,能跑 SM90:

需要实现 签名/语义 依赖
flash_mla_sparse_fwd q[s_q,h_q,576] bf16,kv[s_kv,1,576] bf16,indices[s_q,1,topk] int32,topk_length[s_q],attn_sink[h_q] → (out[s_q,h_q,512], max_logits, lse) 纯 torch 可实现(gather + SDPA,注意 sink 的 out *= exp(lse)/(exp(lse)+exp(sink)))
flash_mla_with_kvcache decode:paged KV cache(V41 自定义 528B/token 交错布局,fp8 + ue8m0 sf)+ indices + extra_k_cache(SWA 窗口)→ (out, lse) 可用现成的 tilelang 算子 tilelang_ops/cuda/load_paged_kv_cache.py 先把 paged cache 解量化成 bf16(它已支持 sf_format="fp32"),再 SDPA
FlashMLASchedMeta / get_mla_metadata 空壳即可(本 fork 里就是 dataclass,元数据在首次调用时生成) —
fused_norm_rope_attn_rope_cast(模块级 import) 只需提供 permute_q_b_proj / permute_wv_proj 空实现(SM90 已关闭该路径) —

接入方式:照 h20_ops 已有的 patch 机制,把 attention_backend.py 里 from flash_mla import ... 覆盖到 deepinfer.h20_ops.attn_sm90。这样不用改上游代码。

工作量估计(我的判断):

  • 功能版(正确但慢,prefill O(s_q·topk·576)、decode 走 dequant+SDPA):2~3 天, 主要时间花在把 528B KV cache 布局和 index_length/sink 语义对清楚 + 权重链路联调;
  • 性能版(tilelang 写 sparse MLA,接近可用吞吐):2~3 周,属于独立课题。

风险提示:功能版在长 prompt(32k tokens × topk 2048 × 64 heads × 576)下 SDPA 计算量约 5 PFLOPs/次 prefill,H20 上要几十秒;做「能跑通 + 数值对」的验证可以(短 prompt), 做吞吐评测不行。另外 FP4 权重转 FP8 后每卡专家权重 ~68GB,8 卡 EP 下显存仍够(~94GB/卡)。


6. 建议的下一步(按性价比排序)

  1. 先确认目标:如果目标是「在 H20 上跑 DeepInfer 做吞吐/特性验证」,那必须先做 SM90 attention 后端(上面 2~3 天那条)。如果只是「验证 V4.1 在 H20 上的数值」,官方 inference/ 已经在 TP8 checkpoint 上跑通了(5 分 49 秒端到端,素数列表全对),不需要 DeepInfer。
  2. 如果决定做:从 prefill 开始(纯 torch,最容易验证),用单层 + dummy 权重对拍 inference/ 的同层输出,再上 8 卡 EP。
  3. 补丁里 weights.py / containers.py 的专家加载路径还没被真实权重跑过(因为 import 链卡在 flash_mla),等 attention 后端就位后要第一时间验证 fp8 store/load 的字节布局, 这是最容易静默错的地方。
  4. 已有 deepinfer/_C、DeepGEMM、DeepEP 的 SM90 构建产物可直接复用,换机器只要重建容器。