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

196 lines
7.3 KiB
Python

from typing import Any
import deep_gemm
import tile_kernels
import torch
from deepinfer import PLATFORM
from deepinfer.model_executor.utils import reconstruct_param
_OUTPUT_ALIGNMENT_BYTES = 32
def get_fp8_casting_granularity() -> int:
"""FP8 GEMM quantization block. SM100 uses MXFP8 per-32; Hopper uses per-128."""
return 128 if PLATFORM.is_sm90 else 32
def get_gemm_recipe() -> tuple[int, int, int]:
"""GEMM SF recipe. SM90 only has 1D2D (per-token A, 128x128 B); SM100 uses 1D1D per-32."""
gran = get_fp8_casting_granularity()
return (1, gran, gran) if PLATFORM.is_sm90 else (1, 1, gran)
def get_fp8_cast_params() -> dict[str, Any]:
return {
"fmt": "e4m3",
"num_per_channels": get_fp8_casting_granularity(),
"round_sf": True,
# SM100 packs four UE8M0 exponents into one int32; SM90 wants plain FP32 scales.
"use_packed_ue8m0": not PLATFORM.is_sm90,
"use_tma_aligned_col_major_sf": True,
}
def bf16_gemm_nt(
a: torch.Tensor,
b: torch.Tensor,
d: torch.Tensor,
c: torch.Tensor | None = None,
) -> None:
deep_gemm.bf16_gemm_nt(a, b, d, c)
class ReplicatedLinear(torch.nn.Module):
"""Replicated linear layer.
Args:
input_size: input dimension of the linear layer.
output_size: output dimension of the linear layer.
use_fp32_output: If true, output fp32 instead of input dtype (bf16 weight only).
weight: weight parameter of shape (output_size, input_size).
bias: optional bias parameter.
"""
def __init__(
self,
input_size: int,
output_size: int,
use_fp32_output: bool = False,
*,
weight: torch.nn.Parameter,
bias: torch.nn.Parameter | None = None,
) -> None:
super().__init__()
# Keep input parameters
self.input_size = input_size
self.output_size = output_size
if weight.dtype != torch.bfloat16:
assert not use_fp32_output
self.use_fp32_output = use_fp32_output
assert weight.shape == (output_size, input_size)
self.weight = reconstruct_param(weight)
if self.weight.element_size() == 1:
self.weight.sf = weight.sf
if self.weight.sf.dtype != PLATFORM.fp8_sf_type or PLATFORM.is_sm90:
# SM90 keeps FP32 scales but still needs DeepGEMM's TMA-aligned layout.
self.weight.sf = deep_gemm.transform_sf_into_required_layout(
self.weight.sf,
self.weight.size(0),
self.weight.size(1),
recipe=(1, get_fp8_casting_granularity(), get_fp8_casting_granularity()),
is_sfa=False,
disable_ue8m0_cast=PLATFORM.is_sm90,
)
if bias is None:
self.register_parameter("bias", None)
else:
self.bias = bias
def _allocate_output(
self,
shape: tuple[int, ...],
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
assert len(shape) >= 2 and shape[-1] == self.output_size, f"{shape=}, {self.output_size=}"
itemsize = dtype.itemsize
assert _OUTPUT_ALIGNMENT_BYTES % itemsize == 0, f"{dtype=}"
outputs_per_alignment = _OUTPUT_ALIGNMENT_BYTES // itemsize
physical_output_size = (self.output_size + outputs_per_alignment - 1) // outputs_per_alignment * outputs_per_alignment
storage = torch.empty((*shape[:-1], physical_output_size), dtype=dtype, device=device)
return storage[..., : self.output_size]
def _has_aligned_output_stride(self, output: torch.Tensor) -> bool:
assert output.ndim >= 2 and output.shape[-1] == self.output_size and output.stride(-1) == 1
return output.stride(-2) * output.element_size() % _OUTPUT_ALIGNMENT_BYTES == 0
def _flatten_output(self, output: torch.Tensor) -> torch.Tensor:
assert output.ndim >= 2 and output.shape[-1] == self.output_size and output.stride(-1) == 1
assert all(output.stride(i) == output.stride(i + 1) * output.shape[i + 1] for i in range(output.ndim - 2)), (
f"{output.shape=}, {output.stride()=}"
)
m = output.shape[:-1].numel()
return output.as_strided((m, self.output_size), (output.stride(-2), 1))
def _align_output(self, output: torch.Tensor) -> torch.Tensor:
if self._has_aligned_output_stride(output):
return output
aligned = self._allocate_output(tuple(output.shape), output.dtype, output.device)
aligned.copy_(output)
return aligned
def apply_weight_bf16(
self,
x: torch.Tensor,
bias: torch.Tensor | None = None,
out_acc: torch.Tensor | None = None,
) -> torch.Tensor:
weight = self.weight
m_shape = x.shape[:-1]
m = m_shape.numel()
n, k = weight.shape
if out_acc is None:
out = self._allocate_output((*m_shape, n), torch.float if self.use_fp32_output else x.dtype, x.device)
else:
out = out_acc
assert out.shape == (*m_shape, n)
assert out.dtype == (torch.float if self.use_fp32_output else x.dtype)
assert out.device == x.device
out_acc = self._flatten_output(out_acc)
assert self._has_aligned_output_stride(out), f"unaligned output: {out.shape=}, {out.stride()=}, {out.dtype=}"
bf16_gemm_nt(x.view(m, k), weight, self._flatten_output(out), out_acc)
if bias is not None:
out += bias
return out
def apply_weight_fp8(
self,
x: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
bias: torch.Tensor | None = None,
out_acc: torch.Tensor | None = None,
) -> torch.Tensor:
assert bias is None
if isinstance(x, torch.Tensor):
assert x.dim() == 2, f"{x.shape=}, {x.stride()=}"
x = tile_kernels.quant.per_token_cast(x, **get_fp8_cast_params())
# FP8 GEMM.
weight = (self.weight, self.weight.sf)
out_shape = x[0].shape[:-1] + (self.weight.shape[0],)
if out_acc is None:
out = self._allocate_output(out_shape, torch.bfloat16, x[0].device)
else:
out = out_acc
assert out.shape == out_shape
assert out.dtype == torch.bfloat16
assert out.device == x[0].device
assert self._has_aligned_output_stride(out), f"unaligned output: {out.shape=}, {out.stride()=}, {out.dtype=}"
assert x[1].dtype == weight[1].dtype == PLATFORM.fp8_sf_type, f"{x[1].dtype=}, {weight[1].dtype=}"
deep_gemm.fp8_fp4_gemm_nt(x, weight, out, out_acc, recipe=get_gemm_recipe(), disable_ue8m0_cast=PLATFORM.is_sm90)
return out
def apply_weight(
self,
x: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
bias: torch.Tensor | None,
out_acc: torch.Tensor | None,
) -> torch.Tensor:
if self.weight.dtype == torch.bfloat16:
return self.apply_weight_bf16(x, bias, out_acc)
if self.weight.element_size() == 1:
return self.apply_weight_fp8(x, bias, out_acc)
raise TypeError(f"unsupported weight dtype: {self.weight.dtype}")
def forward(
self,
x: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
out_acc: torch.Tensor | None = None,
) -> torch.Tensor:
return self.apply_weight(x, self.bias, out_acc)