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

105 lines
3.6 KiB
Python

"""One short prompt, greedy, few tokens — a fast semantic sanity check on SM90.
`offline_inference.py` runs five prompts for up to 128 tokens each, which is minutes of decode
per attempt. This asks a single question and stops after `--max_tokens`, so a bring-up run tells
us what we actually care about: whether the first tokens are words rather than noise.
python scripts/h20_ask.py "问题:中国的首都是哪里?\n答案:" --max_tokens 8
"""
import argparse
import os
from _config import DEFAULT_MODEL_NAME, create_llm, setup_common_env
from _launch import launch_dp
from deepinfer.sampling_params import SamplingParams
parser = argparse.ArgumentParser()
parser.add_argument(
"prompt",
nargs="*",
default=["问题:中国的首都是哪里?\n答案:", "问题:中国的首都是哪里?\n答案:北京"],
)
parser.add_argument("--max_tokens", type=int, default=8)
parser.add_argument("--ep", type=int, default=8)
parser.add_argument("--cp", type=int, default=1)
parser.add_argument("--model_name", default=DEFAULT_MODEL_NAME)
parser.add_argument("--temperature", type=float, default=0.0, help="0 = greedy, so the answer is reproducible")
parser.add_argument(
"--ids",
default="",
help="Comma-separated prompt token ids. Use this to feed *exactly* the ids the reference model saw "
"(the official generate.py wraps text in a chat template, so raw strings are not comparable).",
)
args = parser.parse_args()
ep_size, cp_size = args.ep, args.cp
dp_size = max(ep_size // cp_size, 1)
world_size = dp_size * cp_size
assert world_size % ep_size == 0
def main(dp_size: int, dp_rank: int) -> None:
setup_common_env(
model_name=args.model_name,
sampler_force_topk=4096,
set_override_experts=False,
enable_eplb=False,
)
os.environ.setdefault("DEEPINFER_BATCH_INVARIANT", "1")
llm = create_llm(
args.model_name,
dp_rank,
ep_size=ep_size,
cp_size=cp_size,
env={"DEEPINFER_LOGGING_INTERVAL_STEP": "10"},
data_parallel_size=dp_size,
max_num_batched_tokens=4096,
max_model_len=4096,
max_num_seqs=max(1, len(args.prompt)),
gpu_memory_utilization=0.85,
lookahead_tokens=0,
)
sampling_params = [
SamplingParams(
temperature=args.temperature,
max_tokens=args.max_tokens,
ignore_eos=True,
top_logprobs=4,
seq_seed=0,
)
for _ in args.prompt
]
if args.ids:
ids = [int(x) for x in args.ids.replace(" ", "").split(",") if x]
outputs = llm.generate(
prompts=None, prompt_token_ids=[ids], sampling_params=sampling_params[:1], use_tqdm=False
)
shown = [f"<ids len={len(ids)}>"]
else:
outputs = llm.generate(prompts=args.prompt, sampling_params=sampling_params, use_tqdm=False)
shown = args.prompt
for i, output in enumerate(outputs):
assert output, f"sample {i}: no generated tokens"
tokens = [x.token_id for x in output]
logps = [x.token_logprobs for x in output]
text = llm.llm_engine.tokenizer.decode(tokens)
label = shown[i] if args.ids else args.prompt[i]
print(f"\n=== DP {dp_rank} sample {i}")
print(f"=== prompt: {label!r}")
print(f"=== token ids ({len(tokens)}): {tokens}")
print(f"=== per-token logprob: {[round(x, 3) for x in logps]}")
print(f"=== continuation: {text!r}", flush=True)
if args.ids:
print(f"=== ids in / out: {len(ids)} -> {tokens}")
llm.close()
if __name__ == "__main__":
launch_dp(main, dp_size)