mirror of
https://github.com/deepseek-ai/DeepInfer.git
synced 2026-10-31 08:46:21 +08:00
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.
106 lines
3.9 KiB
Python
106 lines
3.9 KiB
Python
"""Validate LocalEngramGather against a torch reference, single-process and multi-rank.
|
|
|
|
torchrun --nproc_per_node=8 scripts/h20_engram_check.py
|
|
"""
|
|
|
|
import os
|
|
|
|
import torch
|
|
import torch.distributed as dist
|
|
|
|
from deepinfer.h20_ops.engram_local import LocalEngramGather
|
|
|
|
HIDDEN = 256
|
|
N_LOCAL = 4096 # rows per rank
|
|
ENTRIES = 24 # 3 n-gram tables x 8 heads
|
|
TOKENS = 128
|
|
LAYERS = 2
|
|
|
|
|
|
def quantize_table(rows, gen):
|
|
"""fp8 table + per-32 ue8m0 (stored as fp32) scales, like the checkpoint."""
|
|
groups = rows.float().view(rows.shape[0], HIDDEN // 32, 32)
|
|
amax = groups.abs().amax(-1).clamp_min(1e-30)
|
|
exp = torch.ceil(torch.log2(amax / 448.0)).clamp_min(-126)
|
|
scale = torch.pow(2.0, exp)
|
|
q = (groups / scale.unsqueeze(-1)).to(torch.float8_e4m3fn).view(rows.shape[0], HIDDEN)
|
|
return q, scale
|
|
|
|
|
|
def main():
|
|
dist.init_process_group("nccl")
|
|
rank, world = dist.get_rank(), dist.get_world_size()
|
|
torch.cuda.set_device(rank)
|
|
dev = torch.device("cuda", rank)
|
|
gen = torch.Generator(device="cpu").manual_seed(0)
|
|
|
|
torch.manual_seed(rank)
|
|
tables, scales = [], []
|
|
for _ in range(LAYERS):
|
|
rows = torch.randn(N_LOCAL, HIDDEN, generator=gen)
|
|
q, s = quantize_table(rows, gen)
|
|
tables.append(q.to(dev))
|
|
scales.append(s.to(dev))
|
|
|
|
gather = LocalEngramGather(group=None, hidden=HIDDEN)
|
|
# Use a real process group so all_to_all runs across ranks.
|
|
gather.group = dist.group.WORLD
|
|
gather.set_tables(tables, scales)
|
|
assert gather.num_local_entries == [N_LOCAL] * LAYERS
|
|
print(f"[rank {rank}] {gather.describe()}")
|
|
|
|
total = world * N_LOCAL
|
|
idx = torch.randint(0, total, (LAYERS, TOKENS, ENTRIES), generator=gen, dtype=torch.int32).to(dev)
|
|
idx[0, 0, 0] = -1 # invalid slot must come back zeroed
|
|
|
|
hooks = gather.fetch(idx)
|
|
got = hooks[0]()
|
|
|
|
# Reference: rebuild the global table from every rank.
|
|
ref_rows = [torch.empty(world * N_LOCAL, HIDDEN, dtype=torch.float8_e4m3fn, device=dev) for _ in range(LAYERS)]
|
|
ref_sf = [torch.empty(world * N_LOCAL, HIDDEN // 32, device=dev) for _ in range(LAYERS)]
|
|
for layer in range(LAYERS):
|
|
dist.all_gather_into_tensor(ref_rows[layer], tables[layer])
|
|
dist.all_gather_into_tensor(ref_sf[layer], scales[layer])
|
|
|
|
def reference(layer):
|
|
flat = idx[layer].reshape(-1).long()
|
|
valid = flat >= 0
|
|
safe = flat.clamp_min(0)
|
|
w = ref_rows[layer].index_select(0, safe).float()
|
|
s = ref_sf[layer].index_select(0, safe)
|
|
vals = w.reshape(-1, HIDDEN // 32, 32) * s.unsqueeze(-1)
|
|
vals = vals.reshape(-1, HIDDEN).to(torch.bfloat16)
|
|
vals[~valid] = 0
|
|
return vals
|
|
|
|
a, sf = got if isinstance(got, tuple) else (got, None)
|
|
ref = reference(0)
|
|
# `a` holds fp8 codes; the per-token 1x128 scales turn them back into values.
|
|
got_vals = (a.float() * sf.float().repeat_interleave(HIDDEN * ENTRIES // sf.shape[1], dim=1)).view(TOKENS, ENTRIES, HIDDEN)
|
|
# Compare in dequantized space: per-128 requantization is the only difference expected.
|
|
ref_deq = ref.float().view(TOKENS, ENTRIES, HIDDEN)
|
|
err = (got_vals - ref_deq).abs().max().item()
|
|
rel = err / max(ref_deq.abs().max().item(), 1e-9)
|
|
print(
|
|
f"[rank {rank}] out={tuple(a.shape)} sf={None if sf is None else tuple(sf.shape)} "
|
|
f"max_abs={err:.4f} rel={rel:.4f}"
|
|
)
|
|
print(f"[rank {rank}] invalid slot all-zero: {bool((got_vals[0, 0] == 0).all())}")
|
|
|
|
# Non-owner traffic actually exercised?
|
|
owners = torch.div(idx[0].reshape(-1).clamp_min(0), N_LOCAL, rounding_mode="floor")
|
|
remote = int((owners != rank).sum())
|
|
print(f"[rank {rank}] remote entries {remote}/{owners.numel()}")
|
|
|
|
ok = rel < 0.05
|
|
print(f"[rank {rank}] {'PASS' if ok else 'FAIL'}")
|
|
dist.barrier()
|
|
dist.destroy_process_group()
|
|
if not ok:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|