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.
17 KiB
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 也很可疑。数值正确性还没打通。
这一轮修掉的问题(按出现顺序)
- DeepSelect topk 在 SM90 launch 失败 →
h20_ops/deep_select_sm90.py用torch.topk复刻同一套契约(end截断 / OOB 填充 /output_idx_offset/ 原地写 view),scripts/h20_topk_check.py全部通过。 - SM90 的 MQA logits 输出是 fp32(
arch_major == 10 ? kBFloat16 : kFloat32), 而candidate_block_max只吃 bf16 → 加_as_bf16_block_logits转换。 - 共享层候选块优化在 SM90 不可用(
fp8_fp4_sparse_mqa_logits是 SM100-only), 关掉is_candidate_topk_source/use_candidate_topk,走稠密路径。 - 稀疏 MLA 的 0/0:padding 行没有有效索引时
sumexp == 0,acc_o /= 0让整个 hidden state 变 NaN → 加eps下限截断,空窗口返回 0。 - paged MQA logits 的 varlen:SM90
DG_HOST_ASSERT(not is_varlen)→ 两处indices=None。 - 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 页)。 - ViT 兜底里的 host 读取被
forbid_cuda_sync拦 →allow_cuda_sync()局部放行。
下一步(数值正确性定位)
乱码说明 hidden state 有偏差,候选源(按怀疑度排序):
- index KV 的字节布局:SM90 改成
FP8_FLOAT32_SF_INDEX_KV_FORMAT(128 fp8 + 1 f32 = 132B/token),但 store kernel 是否按「整页 values 后接整页 scales」写,未经对拍验证; 若是别的排布,indexer 选出的 KV 就是错的。 - 主 KV cache 的 ue8m0 解量化:SM90 用 fp32 scale,cache 仍存 ue8m0 字节,
load_paged_kv_cache/ store 两侧是否一致没验证。 - indexer 的 torch 兜底只保证「能算」,与 DeepGEMM 的数值没有对拍过。
- 每层的 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 上会直接 assertsf.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/)
sparse_mla_sm90.py:从官方例子拷来,去掉 V3.2 的dim/tail分裂(V4.1 的 rope 在 512 维内部, QK 和 PV 都是 512),加is_causal开关(decode 用非因果),索引读取 clamp 防越界。attn_sm90.py:实现prefill_attn_varlen_sparse/decode_mla_mqa_sparse两个入口(与attention_backend.py同签名),注册到patch_imports,上游调用点零改动。 顺带补齐FlashMLASchedMeta占位类与fused_norm_rope_attn_rope_caststub。- 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
deep_cluster/hardware/device.py:新增is_sm_major_9();get_bf16_tflops_limit()返回 148(只影响 MFU 统计)。deepinfer/platform.py:新增PlatformH20(PlatformGPU),is_sm90=True、fp8_sf_type=torch.float32;get_platform()在is_sm_major_9()时返回它。基类加is_sm90=False。deepinfer/npu_ops/patch_imports.py:新增extend_import_patches(..., override=True), 让第二个后端能覆盖已注册的 import patch(patch_imports只能调用一次,直接调会抛异常)。- 新增
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. 建议的下一步(按性价比排序)
- 先确认目标:如果目标是「在 H20 上跑 DeepInfer 做吞吐/特性验证」,那必须先做 SM90
attention 后端(上面 2~3 天那条)。如果只是「验证 V4.1 在 H20 上的数值」,官方
inference/已经在 TP8 checkpoint 上跑通了(5 分 49 秒端到端,素数列表全对),不需要 DeepInfer。 - 如果决定做:从 prefill 开始(纯 torch,最容易验证),用单层 + dummy 权重对拍
inference/的同层输出,再上 8 卡 EP。 - 补丁里
weights.py/containers.py的专家加载路径还没被真实权重跑过(因为 import 链卡在 flash_mla),等 attention 后端就位后要第一时间验证 fp8 store/load 的字节布局, 这是最容易静默错的地方。 - 已有
deepinfer/_C、DeepGEMM、DeepEP 的 SM90 构建产物可直接复用,换机器只要重建容器。