DeepInfer/scripts/h20_attn_check.py
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

160 lines
7.2 KiB
Python

"""Validate the SM90 attention path (TileLang sparse MLA) against a torch reference.
Shapes follow DeepSeek-V4.1-Flash: head_dim 512 with RoPE inside, 64 query heads, MQA (kv_group 1).
python scripts/h20_attn_check.py # correctness + perf
"""
import torch
from tilelang.profiler import do_bench
from deepinfer.h20_ops.attn_sm90 import (
CACHE_ELEMENTS,
CACHE_ELEMENTS_PER_SF,
decode_mla_mqa_sparse,
prefill_attn_varlen_sparse,
)
DEV = "cuda"
HEADS = 64
DIM = 512
def ref_attention(q, kv, indices, scale, sink=None):
"""q [n, h, d], kv [m, 1, d], indices [n, topk] -> [n, h, d] float32."""
n, h, d = q.shape
topk = indices.shape[1]
valid = indices >= 0
idx = indices.clamp_min(0)
k = kv[idx.reshape(-1), 0, :].view(n, topk, d).float() # [n, topk, d]
v = k
scores = torch.einsum("nhd,nkd->nhk", q.float(), k) * scale # [n, h, topk]
scores = scores.masked_fill(~valid.unsqueeze(1), float("-inf"))
probs = scores.softmax(dim=-1)
out = torch.einsum("nhk,nkd->nhd", probs, v)
lse = torch.logsumexp(scores, dim=-1)
if sink is not None:
out = out * torch.sigmoid(lse - sink.float().unsqueeze(0)).unsqueeze(-1)
return out, lse
def make_indices(n, m, topk, gen):
"""Causal top-k like the indexer produces: random subset of [0, position], -1 padded."""
indices = torch.full((n, topk), -1, dtype=torch.int32, device=DEV)
for t in range(n):
hi = min(t + 1, m)
take = min(topk, hi)
picked = torch.randperm(hi, generator=gen)[:take]
indices[t, :take] = picked.to(torch.int32)
return indices
def quantize_cache(kv, block_size=64):
"""Build a V41-format paged cache ([rows, 528] uint8) from bf16 kv [m, 1, d]."""
m = kv.shape[0]
values = kv[:, 0, :].float()
groups = values.view(m, DIM // CACHE_ELEMENTS_PER_SF, CACHE_ELEMENTS_PER_SF)
amax = groups.abs().amax(dim=-1).clamp_min(torch.finfo(torch.float32).tiny)
# UE8M0: round the per-32 scale up to a power of two so the byte encoding is exact.
exp = torch.ceil(torch.log2(amax / 448.0))
exp = exp.clamp_min(-126)
scale = torch.pow(2.0, exp)
q = (groups / scale.unsqueeze(-1)).to(torch.float8_e4m3fn)
packed = torch.cat(
[q.reshape(m, DIM).view(torch.uint8), (exp + 127).to(torch.uint8)], dim=1
) # [m, 528]
deq = (q.float() * scale.unsqueeze(-1)).reshape(m, DIM).to(torch.bfloat16)
rows = packed.contiguous()
n_blocks = (m + block_size - 1) // block_size
pad = n_blocks * block_size - m
if pad:
rows = torch.cat([rows, rows.new_zeros(pad, rows.shape[1])], dim=0)
deq = torch.cat([deq, deq.new_zeros(pad, deq.shape[1])], dim=0)
return rows.view(n_blocks, block_size, 1, -1), deq
def main():
torch.manual_seed(0)
gen = torch.Generator(device="cpu").manual_seed(0)
scale = DIM**-0.5
# ---------------- prefill: n query tokens packed in one sequence ----------------
n, m, topk = 512, 1024, 512
q = torch.randn(n, HEADS, DIM, device=DEV, dtype=torch.bfloat16)
kv = torch.randn(m, 1, DIM, device=DEV, dtype=torch.bfloat16)
indices = make_indices(n, m, topk, gen)
sink = torch.randn(HEADS, device=DEV, dtype=torch.float32) * 2
out, lse = prefill_attn_varlen_sparse(q, kv, indices, scale, attn_sink=sink)
ref, _ = ref_attention(q, kv, indices, scale, sink=sink)
err = (out.float() - ref).abs().max().item()
print(f"prefill (n={n}, m={m}, topk={topk}, heads={HEADS}): max_abs_err={err:.4f}")
# ---------------- absolute positions: row index must not act as a causal bound --------
# The engine's KV buffer is `[cached swa | current swa | compressed]`, so indices are
# absolute positions that can exceed the query's row number. Deriving causality from the
# row index (the original TileLang example did) silently drops every index here.
n_abs, m_abs, topk_abs = 16, 4096, 256
pad = 3000 # stand-in for `total_k_swa_cached + total_k_swa_cur`
q_abs = torch.randn(n_abs, HEADS, DIM, device=DEV, dtype=torch.bfloat16)
kv_abs = torch.randn(m_abs, 1, DIM, device=DEV, dtype=torch.bfloat16)
gen_abs = torch.Generator(device="cpu").manual_seed(7)
idx_abs = torch.full((n_abs, topk_abs), -1, dtype=torch.int32, device=DEV)
for t in range(n_abs):
lo = pad + t
picked = lo + torch.randperm(min(64, m_abs - lo - 1), generator=gen_abs)[:64]
idx_abs[t, : picked.numel()] = picked.to(torch.int32)
assert (idx_abs >= 0).sum() > 0 and int(idx_abs.max()) > n_abs
out_abs, _ = prefill_attn_varlen_sparse(q_abs, kv_abs, idx_abs, scale)
ref_abs, _ = ref_attention(q_abs, kv_abs, idx_abs, scale)
err_abs = (out_abs.float() - ref_abs).abs().max().item()
print(f"absolute positions (indices up to {int(idx_abs.max())} vs rows {n_abs}): max_abs_err={err_abs:.4f}")
assert err_abs < 0.2, "row-index causal bound regression"
# ---------------- empty window: all-`-1` indices must yield zeros, not NaN ----------------
# A padded batch looks exactly like this (no valid top-k for any row); a 0/0 softmax here
# used to turn the entire hidden state into NaN on the next layer.
q_empty = torch.randn(8, HEADS, DIM, device=DEV, dtype=torch.bfloat16)
kv_empty = torch.randn(64, 1, DIM, device=DEV, dtype=torch.bfloat16)
idx_empty = torch.full((8, 128), -1, dtype=torch.int32, device=DEV)
out_e, lse_e = prefill_attn_varlen_sparse(q_empty, kv_empty, idx_empty, scale)
print(
f"empty window: finite={bool(torch.isfinite(out_e).all())} "
f"zero={bool((out_e == 0).all())} lse_max={lse_e.max().item():.3e}"
)
assert torch.isfinite(out_e).all() and (out_e == 0).all()
# ---------------- decode: paged fp8 cache + gather ----------------
n_dec, m_dec, topk_dec = 8, 4096, 1024
q2 = torch.randn(n_dec, HEADS, DIM, device=DEV, dtype=torch.bfloat16)
kv2 = torch.randn(m_dec, 1, DIM, device=DEV, dtype=torch.bfloat16)
cache, kv_deq = quantize_cache(kv2)
idx2 = torch.randint(0, m_dec, (n_dec, topk_dec), device=DEV, dtype=torch.int32)
dec, _ = decode_mla_mqa_sparse(
q2, cache, DIM, idx2, sched_meta=None, softmax_scale=scale, attn_sink=sink
)
ref2, _ = ref_attention(q2, kv_deq.unsqueeze(1), idx2, scale, sink=sink)
err2 = (dec.float() - ref2).abs().max().item()
corr2 = torch.cosine_similarity(dec.float().flatten(), ref2.flatten(), dim=0).item()
print(f"decode (n={n_dec}, cache={m_dec}, topk={topk_dec}): max_abs_err={err2:.4f} corr={corr2:.6f}")
ms2 = do_bench(lambda: decode_mla_mqa_sparse(q2, cache, DIM, idx2, sched_meta=None, softmax_scale=scale, attn_sink=sink), warmup=5, rep=20)
print(f"decode perf: batch={n_dec} topk={topk_dec} -> {ms2:.2f} ms/step")
# ---------------- perf: full-size prefill ----------------
n_big, m_big, topk_big = 4096, 8192, 2048
qb = torch.randn(n_big, HEADS, DIM, device=DEV, dtype=torch.bfloat16)
kvb = torch.randn(m_big, 1, DIM, device=DEV, dtype=torch.bfloat16)
ib = make_indices(n_big, m_big, topk_big, gen)
ms = do_bench(lambda: prefill_attn_varlen_sparse(qb, kvb, ib, scale), warmup=10, rep=50)
tflops = n_big * 2 * 2 * topk_big * DIM * HEADS / (ms * 1e-3) / 1e12
print(f"prefill perf: {ms:.2f} ms -> {tflops:.1f} TFLOP/s (H20 bf16 peak ~148)")
print("ATTN_CHECK_DONE")
if __name__ == "__main__":
main()