loopyt f25e5d8b6c feat(h20): run sparse MLA on FlagGems' Triton kernel, so TileLang is no longer required
The last thing that kept the SM90 path on TileLang was the sparse MLA attention kernel. FlagGems
has a pure-Triton implementation of the same operator, and it turns out to be drop-in once two
differences are handled. With TileLang unimportable, the engine now runs end to end.

`h20_ops/sparse_mla.py` picks the backend: `DEEPINFER_SPARSE_MLA_BACKEND=tilelang|triton`, unset
means TileLang when it imports and Triton otherwise. `attn_sm90` imports from the dispatcher, so its
two entry points were the only call sites touched. Auto resolution catches `ModuleNotFoundError`
rather than testing for the package by name, because a missing TileLang fails inside
`sparse_mla_sm90`, not at the module lookup -- and it only swallows a *missing* dependency, so a
present-but-broken module still surfaces instead of being swapped out.

Vendored rather than depended upon: `flag_gems/__init__.py` registers ATen implementations
process-wide (`torch.library.Library("aten", "IMPL")` then `replace_customized_ops`). Importing the
package to reach one kernel would swap operators under the whole engine. The PyPI 5.0.2 file has no
`flag_gems` internal imports at all, so there was nothing to gain; it is 329 lines, accumulates in
fp32, and already guards the zero-width rope tail that V4.1 needs (RoPE folded inside the 512-wide
latent). The file header carries the source md5 and both deviations.

Two things it needed, both found by comparison rather than by reading:

* 5.0.2 guards each top-k block by its **position** in the index array (`if ck * BK <= max_col`).
  Non-causal that is `SKV-1`, so when `topk > SKV` every block past the first `SKV` entries is
  skipped. At SQ=16/SKV=128/topk=512 the output is off by 2.8e-2 -- and any prompt shorter than the
  512-entry top-k hits it. Restored the data-dependent guard FlagGems master uses, marked in place.
* Its lse is a natural log; the TileLang one is log2. Unconverted, `_apply_sink` would be off by
  1.4427x: plausible output, wrong numbers. The adapter converts, and the test asserts the ratio is
  1.0 rather than 0.693 so it cannot regress quietly.

The kernel's operands have no batch axis, while decode hands it a different KV window per query.
Rather than loop, the adapter concatenates the windows and shifts query b's indices by b*seq_kv --
one launch, no extra memory, since the concatenated buffer is the tensor decode already built.

Verification ran in order, each step gating the next: 7 shapes against TileLang (`ALL_MATCH`, <=2
bf16 ULP, including `topk_length` and `attn_sink`), the decode call shape incl. the SWA+full
two-cache case (`ALL_MATCH`), the adapter contract (5 tests), the dispatcher and both `attn_sm90`
entry points (4 tests), then end to end with TileLang blocked: exit=0, 8/8 DP ranks agreeing, zero
kernel fallbacks, correct answers. A control run with TileLang present is unchanged.

Not token-identical, and the doc says so: prompt A shares 15 tokens then diverges, prompt B shares
1. A control run of the TileLang backend twice is bit-identical, so this is accumulation order, not
noise -- consistent with the 2.7% logits drift already recorded for the SM90 quantisation floor.

One TileLang dependency remains, and it is the deliberate one: `fused_sample`'s rejection-sampling
variant, which needs TileLang's Philox stream to reproduce the same tokens. So the pure-Triton run
disables speculative decoding (`DEEPINFER_USE_NEXTN_DECODE=0`).
2026-10-25 14:02:58 +08:00

237 lines
9.0 KiB
Python

"""Unit tests for the backend selection and for `attn_sm90`'s two entry points.
Two different things are under test, and they fail differently:
* the *dispatcher* (`h20_ops.sparse_mla`) -- a wrong choice means the wrong kernel runs, which no
numerical test downstream could attribute correctly;
* the *entry points* -- both must give the same answer whichever backend is selected, including the
work around the kernel (`_mask_beyond_length`, `_apply_sink`, the fp8 gather-and-dequant), not
just the kernel call.
The "no TileLang installed" case runs in a subprocess: `tilelang` is importable here, and the thing
being tested is what happens when it is not. `scripts/simulate_no_tilelang` provides that, and the
engine's own end-to-end check uses the same trick.
Run: PYTHONPATH=. python scripts/sparse_mla_port/test_wiring.py
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from collections.abc import Callable
from typing import Any
import torch
from deepinfer.h20_ops import sparse_mla, sparse_mla_triton
from deepinfer.h20_ops.attn_sm90 import (
CACHE_ELEMENTS,
CACHE_ELEMENTS_PER_SF,
UE8M0_BIAS,
decode_mla_mqa_sparse,
prefill_attn_varlen_sparse,
)
DEV = "cuda"
HQ = 64
D = 512
FAILED: list[str] = []
def check(tag: str, cond: bool, detail: str = "") -> None:
print(f" {'ok ' if cond else 'FAIL'} {tag}{' ' + detail if detail else ''}")
if not cond:
FAILED.append(tag)
def close(tag: str, a: torch.Tensor, b: torch.Tensor, ulp: float = 2.0) -> None:
af, bf = a.float(), b.float()
if af.shape != bf.shape:
check(tag, False, f"shape {tuple(af.shape)} vs {tuple(bf.shape)}")
return
diff = (af - bf).abs().max().item()
scale = af.abs().max().item()
tol = ulp * 2.0**-8 * max(scale, 1e-9)
check(tag, diff <= tol and bool(torch.isfinite(bf).all()), f"max diff {diff:.3e} tol {tol:.3e}")
def make_cache(num_blocks: int, block_size: int, seed: int) -> torch.Tensor:
"""A V41-layout fp8 cache: 512 e4m3 values then 16 UE8M0 exponent bytes, per token."""
torch.manual_seed(seed)
rows = num_blocks * block_size
values = torch.randn((rows, CACHE_ELEMENTS), device=DEV, dtype=torch.float32) * 0.5
fp8 = values.to(torch.float8_e4m3fn).view(torch.uint8)
n_sf = CACHE_ELEMENTS // CACHE_ELEMENTS_PER_SF
exponents = torch.randint(-3, 4, (rows, n_sf), device=DEV, dtype=torch.int32)
packed = torch.cat([fp8, (exponents + UE8M0_BIAS).to(torch.uint8)], dim=1)
return packed.view(num_blocks, block_size, 1, -1)
def run_with_kernel(
kernel_module: Any,
entry: Callable[..., Any],
args: tuple,
kwargs: dict[str, Any],
) -> Any:
"""Run `entry` with its module-level kernel swapped to another backend.
`attn_sm90` binds the kernel by name at module scope, so replacing that global is how the other
backend gets exercised without an environment change and a second process. Restored in a
`finally` so a failure here cannot corrupt the following test.
"""
original = entry.__globals__["sparse_mla_fwd_attn"]
entry.__globals__["sparse_mla_fwd_attn"] = kernel_module.sparse_mla_fwd_attn
try:
return entry(*args, **kwargs)
finally:
entry.__globals__["sparse_mla_fwd_attn"] = original
def test_selection_logic() -> None:
"""Explicit values win; a bad value is refused rather than defaulted."""
print("\n[selection: explicit override]")
os.environ["DEEPINFER_SPARSE_MLA_BACKEND"] = "triton"
sparse_mla.reset()
check("triton forced", sparse_mla.backend() == "triton")
os.environ["DEEPINFER_SPARSE_MLA_BACKEND"] = "tilelang"
sparse_mla.reset()
check("tilelang forced", sparse_mla.backend() == "tilelang")
os.environ["DEEPINFER_SPARSE_MLA_BACKEND"] = "nonsense"
sparse_mla.reset()
try:
sparse_mla.backend()
check("bad value raises", False)
except RuntimeError as exc:
check("bad value raises", "not recognised" in str(exc))
os.environ.pop("DEEPINFER_SPARSE_MLA_BACKEND", None)
sparse_mla.reset()
check("auto picks tilelang where it imports", sparse_mla.backend() == "tilelang")
def test_selection_without_tilelang() -> None:
"""Auto must fall back when TileLang is genuinely absent; forcing it must explain why."""
print("\n[selection: TileLang unimportable]")
code = """
import json, os
from deepinfer.h20_ops import sparse_mla
out = {"backend": sparse_mla.backend()}
try:
os.environ["DEEPINFER_SPARSE_MLA_BACKEND"] = "tilelang"
sparse_mla.reset()
sparse_mla.backend()
out["forced"] = "no error"
except RuntimeError as exc:
out["forced"] = "raised"
out["message_shown"] = "not importable" in str(exc)
print(json.dumps(out))
"""
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
env = dict(os.environ)
env["PYTHONPATH"] = os.pathsep.join([os.path.join(root, "scripts", "simulate_no_tilelang"), root])
env["DEEPINFER_SIMULATE_NO_TILELANG"] = "1"
env.pop("DEEPINFER_SPARSE_MLA_BACKEND", None)
proc = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
env=env,
cwd=root,
timeout=600,
check=False,
)
payload = None
for line in reversed(proc.stdout.splitlines()):
if line.strip().startswith("{"):
payload = json.loads(line)
break
if payload is None:
tail = proc.stderr.strip().splitlines()[-1] if proc.stderr.strip() else "no output"
check("subprocess ran", False, tail)
return
check("auto falls back to triton", payload["backend"] == "triton", payload["backend"])
check("forcing tilelang raises", payload.get("forced") == "raised")
check("message explains why", payload.get("message_shown", False))
def test_prefill_entry_point() -> None:
"""`prefill_attn_varlen_sparse`, both backends, with and without the optional arguments.
`index_length` and `attn_sink` are covered because they are the two places the entry point does
work around the kernel -- `_mask_beyond_length` before it, `_apply_sink` after -- and a backend
swap that got either wrong would look fine without them.
"""
print("\n[attn_sm90.prefill_attn_varlen_sparse]")
torch.manual_seed(10)
total_q, total_k, topk = 48, 1536, 512
q = (torch.randn((total_q, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((total_k, 1, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
idx = torch.stack([torch.randperm(total_k, device=DEV)[:topk] for _ in range(total_q)])
idx = idx.to(torch.int32).contiguous()
length = torch.randint(64, topk, (total_q,), device=DEV, dtype=torch.int32)
sink = torch.randn((HQ,), device=DEV, dtype=torch.float32) * 0.5
kwargs = {"attn_sink": sink, "index_length": length}
args = (q, kv, idx, D**-0.5)
tl_out, tl_lse = prefill_attn_varlen_sparse(*args, **kwargs)
tri_out, tri_lse = run_with_kernel(sparse_mla_triton, prefill_attn_varlen_sparse, args, kwargs)
torch.cuda.synchronize()
close("prefill out", tl_out, tri_out)
close("prefill lse", tl_lse, tri_lse)
tl_out2, tl_lse2 = prefill_attn_varlen_sparse(*args)
tri_out2, tri_lse2 = run_with_kernel(sparse_mla_triton, prefill_attn_varlen_sparse, args, {})
torch.cuda.synchronize()
close("prefill out (no sink/length)", tl_out2, tri_out2)
close("prefill lse (no sink/length)", tl_lse2, tri_lse2)
def test_decode_entry_point() -> None:
"""`decode_mla_mqa_sparse`, both backends, single cache and SWA + full two-cache."""
print("\n[attn_sm90.decode_mla_mqa_sparse]")
torch.manual_seed(11)
total_q, topk = 24, 512
q = (torch.randn((total_q, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
cache = make_cache(8, 64, 11)
idx = torch.randint(0, 512, (total_q, topk), device=DEV, dtype=torch.int32)
sink = torch.randn((HQ,), device=DEV, dtype=torch.float32) * 0.5
args = (q, cache, D, idx, None, D**-0.5)
tl_out, tl_lse = decode_mla_mqa_sparse(*args, attn_sink=sink)
tri_out, tri_lse = run_with_kernel(sparse_mla_triton, decode_mla_mqa_sparse, args, {"attn_sink": sink})
torch.cuda.synchronize()
close("decode out", tl_out, tri_out)
close("decode lse", tl_lse, tri_lse)
cache2 = make_cache(8, 64, 111)
idx2 = torch.randint(0, 512, (total_q, topk // 2), device=DEV, dtype=torch.int32)
kwargs2 = {"attn_sink": sink, "key_cache_extra": cache2, "indices_in_kvcache_extra": idx2}
tl_out2, tl_lse2 = decode_mla_mqa_sparse(*args, **kwargs2)
tri_out2, tri_lse2 = run_with_kernel(sparse_mla_triton, decode_mla_mqa_sparse, args, kwargs2)
torch.cuda.synchronize()
close("decode two-cache out", tl_out2, tri_out2)
close("decode two-cache lse", tl_lse2, tri_lse2)
TESTS = [
test_selection_logic,
test_selection_without_tilelang,
test_prefill_entry_point,
test_decode_entry_point,
]
if __name__ == "__main__":
for test in TESTS:
test()
print()
if FAILED:
print(f"FAILED {len(FAILED)}: {FAILED}")
sys.exit(1)
print(f"ALL_PASSED ({len(TESTS)} tests)")