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`).
This commit is contained in:
loopyt 2026-10-25 14:02:58 +08:00
parent c6a65fe6ad
commit f25e5d8b6c
17 changed files with 1407 additions and 1471 deletions

55
TODO.md
View File

@ -595,7 +595,7 @@ import 路径上。修完之后 `import deepinfer` 在纯 Triton 环境不再需
把热路径逐个换成 Triton。§2.10b 说明了大头在哪(模型前向直接调 `tile_kernels` 的
quant/MoE/MHC/engram,25 处模块级 import / 23 个 API)。
### 6.0 当前到哪一步了(2026-10-24 晚更新,**端到端已跑通**)
### 6.0 当前到哪一步了(2026-10-25 更新,**无 tilelang 端到端已跑通**)
| # | 事项 | 状态 |
|---|---|---|
@ -603,10 +603,12 @@ quant/MoE/MHC/engram,25 处模块级 import / 23 个 API)。
| 2 | 算子数值等价(23 个 API 实际调用 + 18 用例两模式对比) | ✅ 见 6.2 |
| 3 | 路由层 + 25 处 import 替换 | ✅ 已通 |
| 4 | **无 tilelang 下 23 个 API 冒烟** | ✅ `ALL_CALLS_OK` |
| 5 | **端到端(`DEEPINFER_KERNELS=ref`)** | ✅ **exit=0,8 DP 一致,回答正确** |
| 6 | **与编译版逐 token 一致** | ❌ **16/16 样本不同**(见 6.5,已确认非采样噪声) |
| 7 | 完全不用 tilelang(含 attention) | ❌ sparse MLA 仍只有 TileLang 实现(见 6.6) |
| 8 | 性能 | ❓ 仍未测 |
| 5 | **端到端(`DEEPINFER_KERNELS=ref`)** | ✅ exit=0,8 DP 一致,回答正确 |
| 6 | **稀疏 MLA 换成 Triton(FlagGems)** | ✅ 见 6.7,7 形状 + decode 5 用例全一致 |
| 7 | **屏蔽 tilelang 的端到端** | ✅ **exit=0,0 回退,回答正确**(需关 MTP,见 6.7) |
| 8 | **与 TileLang 版逐 token 一致** | ❌ 前 15/1 个 token 后分叉(控制实验证明非噪声,见 6.5) |
| 9 | 百分之百不用 tilelang(含采样器 verify 变体) | ❌ `fused_sample` 的 rejection-sampling 仍需(见 6.7) |
| 10 | 性能 | ❓ 仍未测 |
### 6.5 端到端结果:功能对,但**不逐 token 一致**
@ -659,13 +661,42 @@ causal,用行号当因果边界会导致整层 NaN,这个 bug 已经发生
验证脚本与 vendor 副本:`scripts/sparse_mla_port/`(`README.md` 有完整对照表与未做项)。
**还差的三步**(都比重写 kernel 小得多):
1. **接线**:`sparse_mla_sm90.py` 仍 `import tilelang`。用现成的 `patch_imports` 挂一个 Triton
后端即可,不动上游调用点。
2. **decode 路径单独测**:今天 decode 是 gather→bf16 + identity indices,与上面 7 组形状不同,
需要对着 fp8 cache 路径另做一次 A/B。
3. **返回值适配**:它返回 `(out, max_logits, lse)`,DeepInfer 要 `(out, lse)`;且 TileLang 的
`lse` 是 log2 单位、FlagGems 是自然对数,**必须转换**,否则下游读到的 scale 差 1.4427 倍。
### 6.7 接线完成,无 tilelang 端到端跑通(2026-10-25)
三步都做完了,且每步先过单测再往下走:
| 步 | 内容 | 结果 |
|---|---|---|
| 1 | 内联 FlagGems 5.0.2 kernel + 7 形状 A/B | `ALL_MATCH` ≤2 ULP |
| 2 | decode 路径 A/B(fp8 cache、5 用例含双缓存) | `ALL_MATCH` |
| 3 | wrapper 契约单测(返回元数、lse 单位、dtype、两种调用约定) | `ALL_PASSED` 5 组 |
| 4 | 分发层 + `attn_sm90` 两个入口单测 | `ALL_PASSED` 4 组 |
| 5 | 屏蔽 tilelang 端到端 | `exit=0`,0 回退,回答正确 |
| 6 | 有 tilelang 端到端(对照) | `exit=0`,0 回退,回答正确 |
**接线方式**:新增 `h20_ops/sparse_mla.py` 作分发层,`DEEPINFER_SPARSE_MLA_BACKEND=tilelang|triton`
显式选择,不设则「能 import tilelang 就用它,否则 Triton」。`attn_sm90` 只改了 import 那一行,
两个调用点未动。每个 rank 打印一次实际选择。
**为什么内联而不依赖 `flag_gems`**:`flag_gems/__init__.py` 会 `torch.library.Library("aten","IMPL")`
+ `replace_customized_ops`,**进程级替换 ATen 算子**;为了一个 kernel 去 import 它,会把整个引擎的
算子悄悄换掉。PyPI 5.0.2 的那个文件零内部依赖,内联后只需 torch/triton。
**踩到并修掉的两个坑**:
1. **FlagGems 5.0.2 的 block 跳过守卫是错的**:`if ck * BK <= max_col` 按索引在数组里的**位置**
判断,非因果时 `max_col = SKV-1`,于是 `topk > SKV` 时超出前 SKV 个的块被整块跳过。
SQ=16/SKV=128/topk=512 时输出差 **2.8e-2**。任何短于 512 token 的 prompt 都会踩到。
已本地修正为数据相关守卫(master 版就是这个写法),文件里有 `LOCAL FIX` 标注。
2. **lse 单位**:kernel 出自然对数,TileLang 出 log2。不转换会让 `_apply_sink` 的系数差 1.4427 倍。
单测里专门断言 ratio=1.0(未转换会是 0.693),避免以后静默回归。
**仍需注意**:
- 纯 Triton 端到端**必须 `DEEPINFER_USE_NEXTN_DECODE=0`**(关投机解码)。开着 MTP 时会走
`fused_sample` 的 rejection-sampling 变体,那是唯一还依赖 TileLang 的 kernel(Philox 随机流 +
固定 reduction 顺序,Triton 版会改变生成文本,见 `triton/__init__.py` 的说明)。
- **不与 TileLang 版逐 token 一致**:prompt A 前 15 个 token 一致、第 16 个分叉;prompt B 第 2 个
分叉。控制实验(TileLang 连跑两次)**逐 token 相同**,故非采样噪声,是累加顺序差异沿 40+ 层
累积,与 §6.5 记录的 2.7% logits 漂移同源。
`tilelang_ops/cuda/` 那 29 个模块已有 `tilelang_ops/triton/` 对应版本,靠
`DEEPINFER_OPS_BACKEND=triton` 切换,本来就不是缺口。

View File

@ -6,8 +6,9 @@ re-implemented here and registered through the existing `patch_imports` hook, so
sites stay unchanged — and `attention_backend.py` is never imported on SM90, which matters
because its module-level `from flash_mla import ...` cannot be satisfied on this platform.
* :func:`prefill_attn_varlen_sparse` — dense KV is already in memory, so the sparse TileLang
kernel (``h20_ops.sparse_mla_sm90``) consumes it directly, with indices selecting top-k.
* :func:`prefill_attn_varlen_sparse` — dense KV is already in memory, so the sparse attention
kernel (``h20_ops.sparse_mla``, either backend) consumes it directly, with indices selecting
top-k.
* :func:`decode_mla_mqa_sparse` — KV lives in the paged fp8 cache; the selected rows are
gathered as raw bytes (``tilelang_ops.gather`` zero-fills out-of-range rows) and dequantized
to bf16, then the same kernel attends over them with identity indices and no causal mask.
@ -19,7 +20,7 @@ from __future__ import annotations
import torch
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn
from deepinfer.h20_ops.sparse_mla import sparse_mla_fwd_attn
# V41_FORMAT: 512 fp8 values followed by 512/32 = 16 UE8M0 scale bytes per token.
CACHE_ELEMENTS = 512

View File

@ -0,0 +1,112 @@
"""Pick the SM90 sparse-MLA backend, so callers do not have to know which exists.
Two implementations, both numerically verified against each other on every shape the engine uses:
* `sparse_mla_sm90` -- TileLang. The original; needs `tilelang` importable.
* `sparse_mla_triton` -- FlagGems' Triton kernel. Needs nothing beyond torch and triton.
`DEEPINFER_SPARSE_MLA_BACKEND` selects explicitly (`tilelang` / `triton`). Unset means auto:
TileLang when it imports, Triton otherwise. Auto has to catch `ModuleNotFoundError` rather than test
for the package by name, because on a host where TileLang was never installed the import fails at
`import tilelang` inside `sparse_mla_sm90`, not at the top-level module lookup.
Selection is by import capability, not by platform, deliberately: a card can have TileLang installed
and still be the wrong choice for a given run, which is what the explicit override is for. The
chosen backend is logged once at first use -- an end-to-end log that does not say this cannot be
used to attribute a numerical difference.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Callable
import torch
logger = logging.getLogger(__name__)
_ENV = "DEEPINFER_SPARSE_MLA_BACKEND"
_RESOLVED: Callable[..., tuple[torch.Tensor, torch.Tensor]] | None = None
_NAME: str | None = None
def _load_tilelang() -> Callable[..., tuple[torch.Tensor, torch.Tensor]]:
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn
return sparse_mla_fwd_attn
def _load_triton() -> Callable[..., tuple[torch.Tensor, torch.Tensor]]:
from deepinfer.h20_ops.sparse_mla_triton import sparse_mla_fwd_attn
return sparse_mla_fwd_attn
def _resolve() -> tuple[Callable[..., tuple[torch.Tensor, torch.Tensor]], str]:
forced = os.getenv(_ENV, "").strip().lower()
if forced:
if forced == "tilelang":
try:
return _load_tilelang(), "tilelang"
except ModuleNotFoundError as exc:
raise RuntimeError(
f"{_ENV}=tilelang was requested but the TileLang backend is not importable "
f"({exc}). Unset it to fall back to the Triton backend, or install TileLang."
) from exc
if forced == "triton":
return _load_triton(), "triton"
raise RuntimeError(f"{_ENV}={forced!r} is not recognised; use 'tilelang' or 'triton'")
try:
return _load_tilelang(), "tilelang"
except ModuleNotFoundError as exc:
# Only swallow a *missing* dependency. A module that is present but broken must not be
# silently replaced by the other backend, or a real failure would look like a working run.
if "tilelang" not in str(exc) and "tvm_ffi" not in str(exc):
raise
logger.info("sparse MLA: TileLang unavailable (%s); using the Triton backend", exc)
return _load_triton(), "triton"
def backend() -> str:
"""Name of the backend that `sparse_mla_fwd_attn` will use. Resolves on first call."""
global _RESOLVED, _NAME
if _RESOLVED is None:
_RESOLVED, _NAME = _resolve()
logger.info("sparse MLA backend: %s", _NAME)
return str(_NAME)
def sparse_mla_fwd_attn(
q: torch.Tensor,
kv: torch.Tensor,
indices: torch.Tensor,
*,
sm_scale: float | None = None,
is_causal: bool = True,
block_I: int = 64,
num_stages: int = 2,
threads: int = 256,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Sparse MLA forward on the selected backend. Same contract as `sparse_mla_sm90`'s function."""
global _RESOLVED
if _RESOLVED is None:
backend()
assert _RESOLVED is not None
return _RESOLVED(
q,
kv,
indices,
sm_scale=sm_scale,
is_causal=is_causal,
block_I=block_I,
num_stages=num_stages,
threads=threads,
)
def reset() -> None:
"""Forget the resolved backend. Only for tests that change the environment mid-process."""
global _RESOLVED, _NAME
_RESOLVED, _NAME = None, None

View File

@ -0,0 +1,117 @@
"""Sparse MLA on SM90 using FlagGems' Triton kernel instead of TileLang.
Drop-in for :func:`deepinfer.h20_ops.sparse_mla_sm90.sparse_mla_fwd_attn`: same arguments, same
return contract, so `attn_sm90` can pick either without its call sites changing.
Two differences from the vendored kernel are handled here, and neither is optional:
1. **Return arity.** The kernel returns ``(out, max_logits, lse)``; this returns ``(out, lse)``.
2. **lse units.** The kernel's lse is a natural log. The TileLang kernel -- and therefore every
consumer, including ``_apply_sink`` which does ``sigmoid(lse * LN2 - sink)`` -- uses log2. Handing
the natural-log value straight through would scale every sink decision by 1.4427x and producing
plausible-looking but wrong output, which is the kind of bug that survives a smoke test.
The kernel's operands have no batch axis, while callers pass one. Both conventions in this repo are
accepted and unified here:
* Shared KV (prefill): ``kv.shape[0] == 1`` and ``q.shape[0]`` is the number of query tokens in one
pack. The batch axis is folded away and indices are used as given.
* Per-query KV (decode): ``kv.shape[0] == q.shape[0]``. Each query owns its own gathered window, so
the windows are concatenated into one KV and query ``b`` gets its indices shifted by
``b * seq_kv``. This costs no extra memory -- the concatenated tensor is the one the caller
already materialised -- and it stays a single launch. See ``scripts/sparse_mla_port/ab_decode.py``
for the comparison that established this against the two-cache (SWA + full) decode case too.
"""
from __future__ import annotations
import torch
from deepinfer.h20_ops.vendor.flashmla_sparse import flash_mla_sparse_fwd
# lse comes out of the vendored kernel in natural-log units; the rest of the engine speaks log2.
_INV_LN2 = 1.4426950408889634
def sparse_mla_fwd_attn(
q: torch.Tensor,
kv: torch.Tensor,
indices: torch.Tensor,
*,
sm_scale: float | None = None,
is_causal: bool = True,
block_I: int = 64,
num_stages: int = 2,
threads: int = 256,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Sparse MLA forward; mirrors ``sparse_mla_sm90.sparse_mla_fwd_attn``.
Args:
q: ``[batch, seq, heads, head_dim]`` bf16, RoPE already applied.
kv: ``[batch_or_1, seq_kv, 1, head_dim]`` bf16.
indices: ``[batch, seq, 1, topk]`` int32; negative entries mean "invalid".
sm_scale: softmax scale; defaults to ``head_dim ** -0.5``.
is_causal: must be False. See below.
block_I, num_stages, threads: accepted for signature compatibility only. The vendored
kernel derives its own tiling and autotunes ``num_stages``/``num_warps``.
Returns:
``(out [batch, seq, heads, head_dim], lse [batch, seq, heads])`` with lse in **log2** units.
"""
if is_causal:
# The vendored kernel implements causality by bounding on the *row index*, which is only
# meaningful when an index value equals its position. DeepInfer's indices are absolute
# positions in a packed KV buffer that `kv_index_cat` has already shifted, so that bound
# would drop nearly every index -- the failure mode documented at attn_sm90.py:110, which
# collapses the layer to NaN. Both call sites pass False; refuse rather than guess.
raise NotImplementedError(
"the Triton sparse-MLA backend only supports is_causal=False: causality is carried by "
"the indices, and bounding on the row index is wrong for absolute-position indices"
)
batch, seq, heads, dim = q.shape
kv_batch, seq_kv, kv_heads, head_dim_kv = kv.shape
if kv_heads != 1:
raise ValueError(f"kv must carry a single KV head, got {kv_heads}")
if head_dim_kv != dim:
raise ValueError(f"q and kv disagree on head_dim: {dim} vs {head_dim_kv}")
# Reject shapes the kernel cannot express before it fails somewhere less legible. It asserts
# these itself, but at a point where the error no longer names the caller.
if heads not in (64, 128):
raise ValueError(f"the Triton sparse-MLA kernel supports heads in {{64, 128}}, got {heads}")
scale = dim**-0.5 if sm_scale is None else sm_scale
idx4 = indices.reshape(batch, seq, 1, -1).to(torch.int32)
if kv_batch == 1:
# Shared KV (prefill, and decode with a single query): fold the batch axis away and use the
# indices as given. Checked first because prefill legitimately passes batch=1, which would
# otherwise match the per-query condition below.
flat_kv = kv.reshape(seq_kv, 1, head_dim_kv).contiguous()
idx = idx4.reshape(batch * seq, 1, -1).contiguous()
elif kv_batch == batch:
# Per-query KV (decode): concatenate the windows, then shift query b's indices by
# b * seq_kv so they address its own window in the concatenated buffer.
flat_kv = kv.reshape(batch * seq_kv, 1, head_dim_kv).contiguous()
bases = (torch.arange(batch, dtype=torch.int32, device=idx4.device) * seq_kv).view(batch, 1, 1, 1)
idx = (idx4 + bases).reshape(batch * seq, 1, -1).contiguous()
else:
raise ValueError(
"kv's batch axis must be 1 (shared KV) or equal to q's (one window per query); "
f"got kv.shape[0]={kv_batch} with q.shape[0]={batch}"
)
out, _max_logits, lse_natural = flash_mla_sparse_fwd(
q.reshape(batch * seq, heads, dim).contiguous(),
flat_kv,
idx,
sm_scale=float(scale),
d_v=dim,
)
out = out.view(batch, seq, heads, dim)
# Natural log -> log2, so downstream sink maths keeps working unchanged. float32 matches what
# the TileLang kernel returns for lse, which callers already rely on.
lse = lse_natural.view(batch, seq, heads).to(torch.float32) * _INV_LN2
return out, lse

1
deepinfer/h20_ops/vendor/__init__.py vendored Normal file
View File

@ -0,0 +1 @@
"""Vendored third-party code. Excluded from lint and format by `extend-exclude` in pyproject."""

View File

@ -0,0 +1,392 @@
# Copyright 2026 FlagOS Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Vendored from FlagGems, not written here. Provenance:
#
# package : flag_gems 5.0.2 (PyPI)
# module : flag_gems/fused/flashmla_sparse.py
# md5 : f38ce8a998f7db9f4796a681d9ccf475
# license : Apache-2.0 (above)
#
# Copied from the wheel with exactly two deviations, both stated here because vendored code that
# drifts silently is worse than no vendored code:
#
# 1. This header (the wheel's copy omits the license text the license requires).
# 2. One fix in the kernel body, marked `LOCAL FIX, not upstream`: the released file guards each
# top-k block with `if ck * BK <= max_col`, which drops blocks by their *position* in the index
# array rather than by their contents. That is only sound when index value tracks position,
# and it is wrong whenever topk > SKV -- which DeepInfer hits on any prompt shorter than the
# 512-entry top-k. FlagGems master uses the data-dependent form and is correct.
#
# Keep the body byte-identical to upstream apart from that one change: do not run a formatter over
# it, or the next re-vendor becomes a manual diff of stylistic noise. `pyproject.toml` excludes
# this file from both lint and format for that reason.
#
# `scripts/sparse_mla_port/ab_vendored.py` is what keeps it honest -- it compares against the
# TileLang kernel on every shape the engine uses, so upstream drift or a bad edit shows up as a
# test failure rather than a silent numerical change.
#
# Why vendored instead of depending on `flag_gems`:
#
# 1. `flag_gems/__init__.py` registers ATen implementations process-wide
# (`torch.library.Library("aten", "IMPL")` then `runtime.replace_customized_ops`). Importing
# the package to reach one kernel would silently swap operators under the whole engine, which
# changes numerics of code that has nothing to do with attention. This module imports only
# torch and triton, so it cannot do that.
# 2. The released 5.0.2 file has no `flag_gems` internal imports at all, so there is nothing to
# gain from the dependency.
#
# Why 5.0.2 rather than FlagGems master: master's copy is ~1200 lines with a TLE fast path gated on
# triton internals, and its accumulation is fp16. This one is 329 lines, accumulates in fp32, and
# guards `TDP > 0` -- which is what makes it work at V4.1's shape, where RoPE is folded inside the
# 512-wide latent and the rope tail is therefore zero-width.
#
# Do not edit the kernel body. If a change is genuinely needed, make it upstream in FlagGems and
# re-vendor with the md5 above updated.
from typing import Optional, Tuple
import torch
import triton
import triton.language as tl
flash_mla_sparse_fwd_configs = [
triton.Config({"num_stages": 4, "num_warps": 8}),
triton.Config({"num_stages": 2, "num_warps": 4}),
]
@triton.autotune( # Decorate the kernel
configs=flash_mla_sparse_fwd_configs,
key=["K", "is_causal"],
)
@triton.jit
def triton_flash_mla_sparse_fwd(
q,
kv,
indices,
attn_sink,
topk_length,
sm_scale: tl.constexpr,
output,
max_logits,
lse,
stride_qh,
stride_qm,
stride_qd,
stride_kvg,
stride_kvn,
stride_kvd,
stride_tg,
stride_tm,
stride_tt, # indices dim
stride_attn_sink_h,
stride_topk_length_s,
stride_oh,
stride_om,
stride_od,
stride_mh,
stride_mm,
stride_lh,
stride_lm,
SQ: tl.constexpr, # seqlen
SKV: tl.constexpr, # seqlen_kv
K: tl.constexpr, # topk
D: tl.constexpr, # QKV dim
TD: tl.constexpr, # tail dim
DP: tl.constexpr,
TDP: tl.constexpr,
G: tl.constexpr, # group_size
BK: tl.constexpr,
BH: tl.constexpr,
is_causal: tl.constexpr,
q_idx_i64: tl.constexpr,
output_idx_i64: tl.constexpr,
HAVE_ATTN_SINK: tl.constexpr,
HAVE_TOPK_LENGTH: tl.constexpr,
):
i_sq, i_gbh = tl.program_id(0), tl.program_id(1)
i_g, i_bh = i_gbh // G, i_gbh % G
if not q_idx_i64:
q_base = q + i_sq * stride_qm + i_gbh * (BH * stride_qh)
else:
q_base = q + i_sq * tl.cast(stride_qm, tl.int64) + i_gbh * (BH * stride_qh)
tq_base = q_base + D * stride_qd
kv_base = kv + i_g * stride_kvg
tkv_base = kv_base + D * stride_kvd
t_base = indices + i_sq * stride_tm + i_g * stride_tg
attn_sink_ptr = (
attn_sink + i_gbh * (BH * stride_attn_sink_h) if HAVE_ATTN_SINK else 0
)
topk_length_ptr = (
topk_length + i_sq * stride_topk_length_s if HAVE_TOPK_LENGTH else 0
)
if not output_idx_i64:
o_base = output + i_sq * stride_om + i_gbh * (BH * stride_oh)
else:
o_base = output + i_sq * tl.cast(stride_om, tl.int64) + i_gbh * (BH * stride_oh)
max_log_base = max_logits + i_sq * stride_mm + i_gbh * (BH * stride_mh)
l_base = lse + i_sq * stride_lm + i_gbh * (BH * stride_lh)
offs_h = tl.arange(0, BH)
offs_d = tl.arange(0, DP)
offs_td = tl.arange(0, TDP) if TDP > 0 else None
offs_od = tl.arange(0, DP)
offs_t = tl.arange(0, BK)
mask_h = i_bh * BH + offs_h < G
mask_d = offs_d < D
mask_td = offs_td < TD if TDP > 0 else None
mask_od = mask_d
q_ptr = q_base + offs_h[:, None] * stride_qh + offs_d[None, :] * stride_qd
q_msk = mask_h[:, None] & mask_d[None, :]
q_blk = tl.load(q_ptr, q_msk, other=0.0).to(tl.float32)
tq_blk = None
if TDP > 0:
tq_ptr = tq_base + offs_h[:, None] * stride_qh + offs_td[None, :] * stride_qd
tq_msk = mask_h[:, None] & mask_td[None, :]
tq_blk = tl.load(tq_ptr, tq_msk, other=0.0).to(tl.float32)
max_log = tl.full([BH], float("-inf"), dtype=tl.float32)
sum_exp = tl.full([BH], 0.0, dtype=tl.float32)
acc = tl.zeros([BH, DP], dtype=tl.float32)
qk = tl.zeros([BH, BK], dtype=tl.float32)
max_col = i_sq if is_causal else SKV - 1
topk_len = tl.load(topk_length_ptr).to(tl.int32) if HAVE_TOPK_LENGTH else K
NK = tl.cdiv(K, BK)
for ck in range(NK):
# step1: load indices
t_ptr = (BK * ck + offs_t) * stride_tt
t_msk = t_ptr < topk_len
t_ptr += t_base
kv_ids = tl.load(t_ptr, t_msk, other=-1)
mask_ids = (kv_ids <= max_col) & (kv_ids >= 0)
# filter invalid index that may cause overflow in mul
kv_ids = tl.where(mask_ids, kv_ids, 0)
# LOCAL FIX, not upstream -- see the file header. Upstream guards this block with
# `if ck * BK <= max_col`, i.e. by the block's position in the index array. With
# is_causal=False, max_col is SKV-1, so when topk > SKV every block past the first SKV
# entries is skipped although it holds valid indices: at SQ=16/SKV=128/topk=512 the output
# is off by 2.8e-2 against the TileLang kernel. The data-dependent form below is what
# FlagGems master uses (`valid0 = t_msk0 & (kv_ids0 <= max_col) & (kv_ids0 >= 0)`).
# if mask_ids.max(0) > 0:
if tl.max(mask_ids, axis=0) > 0:
# step2: gather kv with indices
kv_ptr = (
kv_base + offs_d[:, None] * stride_kvd + kv_ids[None, :] * stride_kvn
)
kv_msk = mask_d[:, None] & mask_ids[None, :]
kv_blk = tl.load(kv_ptr, kv_msk, other=0.0).to(tl.float32) # [DP, BK]
# step3: (q @ kv) * sm_scale
qk = tl.dot(q_blk, kv_blk)
if TDP > 0:
tkv_ptr = (
tkv_base
+ offs_td[:, None] * stride_kvd
+ kv_ids[None, :] * stride_kvn
)
tkv_msk = mask_td[:, None] & mask_ids[None, :]
tkv_blk = tl.load(tkv_ptr, tkv_msk, other=0.0).to(
tl.float32
) # [TDP, BK]
qk = tl.dot(tq_blk, tkv_blk, qk) * sm_scale
else:
qk = qk * sm_scale
# step4: preprocess for logsumexp
qk = tl.where(mask_ids[None, :], qk, float("-inf")) # [BH, BK]
# step5: lse=log2sumexp2(qk), loop part
new_max = tl.maximum(max_log, tl.max(qk, axis=1)) # [BH]
# avoid nan generated by ((-inf) - (-inf))
tmp = qk - new_max[:, None]
tmp = tl.where(
(~mask_ids[None, :]) & (new_max[:, None] == float("-inf")),
float("-inf"),
tmp,
)
exp_qk = tl.math.exp(tmp) # [BH, BK]
sum_qk = tl.sum(exp_qk, axis=1) # [BH]
# avoid nan generated by ((-inf) - (-inf))
tmp2 = max_log - new_max
tmp2 = tl.where(
(max_log == float("-inf")) & (new_max == float("-inf")),
float("-inf"),
tmp2,
)
alpha = tl.math.exp(tmp2) # [BH]
sum_exp = tl.fma(sum_exp, alpha, sum_qk) # [BH]
acc = acc * alpha[:, None] # [BH, DP]
# step6: exp2(qk-lse) @ gathered_kv.trans(), loop part
acc = tl.dot(exp_qk, kv_blk.trans(), acc) # [BH, DP]
max_log = new_max
# step7: store max_logits
max_log_ptr = max_log_base + offs_h * stride_lh
tl.store(max_log_ptr, max_log, mask_h) # [BH], float32
# step8: lse=log2sumexp2(qk) final part, store lse
orig_lse = max_log + tl.math.log(sum_exp)
lse_out = tl.where(orig_lse == float("-inf"), float("inf"), orig_lse)
l_ptr = l_base + offs_h * stride_lh
l_msk = mask_h
tl.store(l_ptr, lse_out, l_msk) # [BH], float32
# step9: exp2(qk-lse) @ gathered_kv.trans(), final part
if HAVE_ATTN_SINK:
# step10: attn_sink
exp_max_qk = tl.math.exp(max_log) # [BH]
exp_orig_lse = tl.math.exp(orig_lse)
sink = tl.load(attn_sink_ptr + offs_h).to(tl.float32) # [BH]
exp_sink = tl.math.exp(sink)
sum_exp_new_lse = exp_orig_lse + exp_sink
# avoid divide 0
sum_exp_new_lse = tl.where(sum_exp_new_lse == 0.0, 1.0, sum_exp_new_lse)
factor = exp_max_qk / sum_exp_new_lse
out_vals = acc * factor[:, None]
else:
# avoid divide 0
sum_exp = tl.where(sum_exp == 0, 1.0, sum_exp)
out_vals = acc / sum_exp[:, None]
# step11: store output
o_ptr = (
o_base + offs_h[:, None] * stride_oh + offs_od[None, :] * stride_od
) # [BH, DP]
o_msk = mask_h[:, None] & mask_od[None, :]
tl.store(o_ptr, out_vals.to(q_blk.dtype), o_msk)
def flash_mla_sparse_fwd(
q: torch.Tensor,
kv: torch.Tensor,
indices: torch.Tensor,
sm_scale: float,
d_v: int = 512,
attn_sink: Optional[torch.Tensor] = None,
topk_length: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Sparse attention prefill kernel
Args:
q: [s_q, h_q, d_qk], bfloat16
kv: [s_kv, h_kv, d_qk], bfloat16
indices: [s_q, h_kv, topk], int32. Invalid indices should be set to -1 or numbers >= s_kv
sm_scale: float
d_v: The dimension of value vectors. Can only be 512
attn_sink: optional, [h_q], float32.
If attn_sink is provided, when computing output, output will be additionally multiplied by
exp(lse) / (exp(lse) + exp(attn_sink)). +-inf in attn_sink will be handled normally (i.e., -inf has no
effect, +inf will make corresponding output all zeros).
This argument has no effect on lse and max_logits.
topk_length: optional, [s_q], int32.
If provided, the i-th q token will only attend to k tokens specified by indices[i, :, :topk_length[i]],
ignoring later k/v tokens (even if provided in indices). In extremely rare cases (topk_length provided,
there is a valid topk index between topk_length[i] ~ s_kv, and that topk index points to a k token
containing NaN), operator output will contain NaN, so please avoid this situation.
Returns:
(output, max_logits, lse)
Please refer to tests/ref.py for the precise definitions of these parameters.
- output: [s_q, h_q, d_v], bfloat16
- max_logits: [s_q, h_q], float
- lse: [s_q, h_q], float, log-sum-exp of attention scores
"""
is_causal = False # turn off opt for causal sparse attention
assert q.is_contiguous() and kv.is_contiguous() and indices.is_contiguous()
SQ, H, DT = q.shape
SKV, VG, _ = kv.shape
assert d_v == 512, "Unsupported d_v"
D = d_v
assert kv.shape[-1] == DT
TD = DT - D
DP = triton.next_power_of_2(D)
TDP = triton.next_power_of_2(TD)
_, _, K = indices.shape
assert indices.shape == (SQ, VG, K)
if attn_sink is not None:
assert attn_sink.shape == (H,), "attn_sink error shape"
if topk_length is not None:
assert topk_length.shape == (SQ,), "topk_length error shape"
# check from FlashMLA
assert VG == 1, "h_kv is expected to be 1"
assert H == 64 or H == 128, "Unsupported h_q"
assert DT == 576 or DT == 512, "Unsupported d_qk"
G = H // VG
BH = max(16, min(32, triton.next_power_of_2(G)))
NH = triton.cdiv(G, BH)
BK = 16 # used to be out of memory for 32
output = torch.zeros((SQ, H, D), device=q.device, dtype=q.dtype)
max_logits = torch.full(
(SQ, H), float("-inf"), device=q.device, dtype=torch.float32
)
lse = torch.full((SQ, H), float("-inf"), device=q.device, dtype=torch.float32)
INT32_MAX = 2147483647
q_idx_i64 = q.numel() > INT32_MAX
output_idx_i64 = output.numel() > INT32_MAX
grid = (SQ, VG * NH, 1)
triton_flash_mla_sparse_fwd[grid](
q,
kv,
indices,
attn_sink,
topk_length,
sm_scale,
output,
max_logits,
lse,
q.stride(1),
q.stride(0),
q.stride(2),
kv.stride(1),
kv.stride(0),
kv.stride(2),
indices.stride(1),
indices.stride(0),
indices.stride(2),
attn_sink.stride(0) if attn_sink is not None else 0,
topk_length.stride(0) if topk_length is not None else 0,
output.stride(1),
output.stride(0),
output.stride(2),
max_logits.stride(1),
max_logits.stride(0),
lse.stride(1),
lse.stride(0),
SQ,
SKV,
K,
D,
TD,
DP,
TDP,
G,
BK,
BH,
is_causal,
q_idx_i64,
output_idx_i64,
attn_sink is not None,
topk_length is not None,
)
return output, max_logits, lse

View File

@ -1,14 +1,23 @@
# FlagGems sparse-MLA as the SM90 attention backend
The SM90 sparse MLA kernel (`deepinfer/h20_ops/sparse_mla_sm90.py`) is TileLang-only, so with
TileLang blocked the model cannot start. FlagGems ships a pure-Triton implementation of the same
operator, and at DeepSeek-V4.1's exact shape it agrees with the TileLang kernel.
TileLang blocked the model could not start. FlagGems ships a pure-Triton implementation of the same
operator; it is now vendored, verified against the TileLang kernel, and wired in behind a dispatcher
(`deepinfer/h20_ops/sparse_mla.py`). With TileLang unimportable the engine runs end to end on it.
## Source
`flagos-ai/FlagGems`, file `src/flag_gems/fused/flashmla_sparse.py`, function
`flash_mla_sparse_fwd`. Copied verbatim into `vendor/` for the tests; it depends only on
`flag_gems.utils.triton_version_utils` (a Triton version probe), stubbed in `vendor/flag_gems/`.
`flash_mla_sparse_fwd`. Vendored as `deepinfer/h20_ops/vendor/flashmla_sparse.py` from the **PyPI
5.0.2 wheel**, which has no `flag_gems` internal imports at all -- only torch and triton. That
matters: importing `flag_gems` itself registers ATen implementations process-wide
(`torch.library.Library("aten", "IMPL")` then `runtime.replace_customized_ops`), so depending on the
package to reach one kernel would swap operators under the whole engine. The file header records the
source md5 and the two local deviations.
Master's copy (~1200 lines, TLE fast path, fp16 accumulate) also works and was verified first; 5.0.2
is used instead because it is a release, is 329 lines, accumulates in fp32, and already guards the
zero-width rope tail that V4.1 needs.
Do not confuse it with `src/flag_gems/fused/DSA/sparse_mla.py`
(`triton_sparse_mla_fwd_interface`). That one hardcodes `is_causal=True` and requires the V3.2
@ -39,8 +48,9 @@ same reason.
## Result
`ab_vs_tilelang.py`, 7 shapes, TileLang as reference: **all match** within 2 bf16 ULP, including
`topk_length` and `attn_sink`.
`ab_vendored.py`, 7 shapes, TileLang as reference: **all match** within 2 bf16 ULP, including
`topk_length` and `attn_sink`. This runs the in-tree vendored file, so it is what the engine
actually executes.
| case | max abs diff | tol |
|---|---|---|
@ -54,11 +64,68 @@ same reason.
## What is not done yet
* Not wired in. `sparse_mla_sm90.py` still imports TileLang; a Triton backend behind the same
`patch_imports` hook is the next step.
* Decode path untested end to end. Today decode gathers to bf16 and calls the kernel with identity
indices and no causal; that is a different call shape than the ones above and needs its own test
against the fp8-cache path.
* The kernel returns `(out, max_logits, lse)` while DeepInfer's returns `(out, lse)`, and TileLang's
`lse` is in log2 units where FlagGems' is natural log. A wrapper must drop `max_logits` and
convert, or every downstream consumer reads a 1.44x-wrong scale.
* Decode path end to end is covered by `ab_decode.py` and `test_wiring.py` at the entry-point
level, but the engine's own decode was only exercised through the end-to-end runs below, not
against a long-context trace.
* Not token-identical to the TileLang backend. See below -- it runs and answers correctly, but the
two backends do not produce the same tokens.
* `fused_sample`'s rejection-sampling variant still needs TileLang, so the pure-Triton end-to-end run
has to disable speculative decoding (`DEEPINFER_USE_NEXTN_DECODE=0`). With MTP on, that one kernel
is the remaining TileLang dependency.
---
## Wiring status
`h20_ops/sparse_mla.py` selects the backend: `DEEPINFER_SPARSE_MLA_BACKEND=tilelang|triton`, unset
means TileLang if it imports and Triton otherwise. `attn_sm90` imports from the dispatcher, so its
two entry points were the only call sites to touch. The chosen backend is logged once per rank.
### Verification, in the order it was done
Each step had to pass before the next was attempted, so a failure would point at one thing.
| step | what | result |
|---|---|---|
| 1 | `ab_vendored.py` — vendored kernel vs TileLang, 7 shapes | `ALL_MATCH`, ≤2 bf16 ULP |
| 2 | `ab_decode.py` — decode call shape, fp8 cache, 5 cases incl. two-cache | `ALL_MATCH` |
| 3 | `test_sparse_mla_triton.py` — adapter contract | `ALL_PASSED` (5 tests) |
| 4 | `test_wiring.py` — selection + both entry points | `ALL_PASSED` (4 tests) |
| 5 | end-to-end, TileLang blocked | `exit=0`, 0 fallbacks, correct answers |
| 6 | end-to-end control, TileLang present | `exit=0`, 0 fallbacks, correct answers |
Two things the vendoring had to fix, both caught by step 1 rather than by luck:
* FlagGems 5.0.2 guards each top-k block by its **position** in the index array
(`if ck * BK <= max_col`). Non-causal, `max_col` is `SKV-1`, so with `topk > SKV` every block past
the first `SKV` entries is skipped. At SQ=16/SKV=128/topk=512 that is a 2.8e-2 error. Any prompt
shorter than the 512-entry top-k hits it. Fixed locally, marked in the file; FlagGems master uses
the data-dependent form.
* The kernel's lse is a natural log; the TileLang one is log2. Passed through unconverted it would
scale `_apply_sink` by 1.4427x -- plausible output, wrong numbers. `test_sparse_mla_triton.py`
asserts the ratio is 1.0 rather than 0.693 so this cannot regress silently.
### End-to-end result
Same command, only the TileLang availability differs:
```
DEEPINFER_SIMULATE_NO_TILELANG=1 DEEPINFER_KERNELS=ref DEEPINFER_OPS_STRICT=1 \
DEEPINFER_USE_NEXTN_DECODE=0 bash scripts/h20_e2e_report.sh
```
| | TileLang blocked | TileLang present |
|---|---|---|
| exit code | 0 | 0 |
| sparse MLA backend (per rank) | `triton` x8 | `tilelang` x8 |
| `ModuleNotFoundError` failures | 0 | 0 |
| kernels served by the default backend | *(none)* | *(none)* |
| DP ranks agreeing | 8/8 | 8/8 |
| answer | 中国的首都→北京,日本的首都→东京 | 同上,另有 1949-09-27 定都的展开 |
**Not token-identical.** Comparing the two: prompt A shares 15 tokens then diverges, prompt B shares
1 then diverges. A control run of the TileLang backend twice is bit-identical, so the harness is
deterministic and the difference is real -- it comes from accumulation order, ≤2 ULP per layer
compounding across 40+ layers, consistent with the 2.7% logits drift already recorded in `TODO.md`
for the SM90 quantisation floor. It is not a wiring defect, and not something this kernel can fix on
its own.

View File

@ -0,0 +1,134 @@
"""Decode-path A/B: does the vendored kernel serve the decode call shape at all?
Decode differs from prefill in a way that matters for this kernel. Prefill passes one KV buffer
shared by every query and lets the indices pick per-query top-k. Decode gathers first, so each query
owns a *different* KV window. The vendored kernel's `kv` operand is 3-D with no batch axis, so it
cannot be handed a different KV per query.
It does not need to. Concatenate every query's window into one KV buffer and give query q the slice
of index space `[q*W, q*W+W)`. That is one launch, and the buffer is the tensor decode already
materialises -- no extra memory.
This test exercises that adapter against the current TileLang call, end to end from an fp8 paged
cache in the V41 layout (512 fp8 values + 16 UE8M0 scale bytes per token), including the SWA + full
two-cache concatenation that `decode_mla_mqa_sparse` supports.
"""
import sys
import torch
from deepinfer.h20_ops.attn_sm90 import (
CACHE_ELEMENTS,
CACHE_ELEMENTS_PER_SF,
UE8M0_BIAS,
_gather_dequant,
)
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn
from deepinfer.h20_ops.vendor.flashmla_sparse import flash_mla_sparse_fwd
DEV = "cuda"
HQ = 64
D = 512
FAILED = []
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 in a narrow band around the bias so scales stay near 1.
exponents = torch.randint(-3, 4, (rows, n_sf), device=DEV, dtype=torch.int32)
sf = (exponents + UE8M0_BIAS).to(torch.uint8)
packed = torch.cat([fp8, sf], dim=1)
return packed.view(num_blocks, block_size, 1, -1)
def compare(tag: str, a: torch.Tensor, b: torch.Tensor) -> None:
af, bf = a.float(), b.float()
if af.shape != bf.shape:
print(f" FAIL {tag}: shape {tuple(af.shape)} vs {tuple(bf.shape)}")
FAILED.append(tag)
return
diff = (af - bf).abs()
scale = af.abs().max().item()
tol = 2.0**-8 * max(scale, 1e-9) * 2
ok = diff.max().item() <= tol and bool(torch.isfinite(bf).all())
print(f" {'ok ' if ok else 'FAIL'} {tag}: max|ref| {scale:.4f} max diff {diff.max().item():.3e} tol {tol:.3e}")
if not ok:
FAILED.append(tag)
def tl_decode(q4: torch.Tensor, kv4: torch.Tensor, scale: float) -> torch.Tensor:
"""What `decode_mla_mqa_sparse` does today: one batch row per query, identity indices."""
total_q, window, _, hd = kv4.shape
identity = torch.arange(window, dtype=torch.int32, device=q4.device).view(1, 1, 1, -1)
indices = identity.expand(total_q, 1, 1, window).contiguous()
out, _ = sparse_mla_fwd_attn(q4, kv4, indices, sm_scale=scale, is_causal=False)
return out.view(total_q, HQ, hd)
def triton_decode(q4: torch.Tensor, kv4: torch.Tensor, scale: float) -> torch.Tensor:
"""The proposed adapter: one shared KV, per-query offset index window."""
total_q, window, _, hd = kv4.shape
flat_kv = kv4.reshape(total_q * window, 1, hd).contiguous()
offsets = torch.arange(window, dtype=torch.int32, device=q4.device).view(1, window)
base = (torch.arange(total_q, dtype=torch.int32, device=q4.device) * window).view(total_q, 1)
indices = (base + offsets).view(total_q, 1, window).contiguous()
out, _, _ = flash_mla_sparse_fwd(q4.view(total_q, HQ, hd).contiguous(), flat_kv, indices, sm_scale=scale, d_v=hd)
return out
def run_case(tag: str, total_q: int, topk: int, seed: int, two_caches: bool) -> None:
print(f"\n[{tag}] total_q={total_q} window={topk} two_caches={two_caches}")
scale = D**-0.5
torch.manual_seed(seed)
q = (torch.randn((total_q, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
cache = make_cache(8, 64, seed) # 512 rows
n_rows = 8 * 64
idx = torch.randint(0, n_rows, (total_q, topk), device=DEV, dtype=torch.int32)
gathered = _gather_dequant(cache, idx, None, D)
if two_caches:
cache2 = make_cache(8, 64, seed + 1000)
idx2 = torch.randint(0, n_rows, (total_q, topk // 2), device=DEV, dtype=torch.int32)
extra = _gather_dequant(cache2, idx2, None, D)
gathered = torch.cat([gathered, extra], dim=1)
window = gathered.shape[1]
kv4 = gathered.view(total_q, window, 1, D)
q4 = q.view(total_q, 1, HQ, D)
tl_out = tl_decode(q4, kv4, scale)
torch.cuda.synchronize()
tri_out = triton_decode(q4, kv4, scale)
torch.cuda.synchronize()
compare("decode out", tl_out, tri_out)
# The gathered KV itself must be identical for the two paths, or the comparison would be
# hiding a layout bug behind a numeric one.
if not torch.equal(gathered.view(total_q, window, D), kv4.view(total_q, window, D)):
print(" FAIL gathered kv changed shape semantics")
FAILED.append("gather")
CASES = [
("decode, topk 512", 32, 512, 0, False),
("decode, topk 256", 64, 256, 1, False),
("decode, single query", 1, 512, 2, False),
("decode, SWA + full (two caches)", 16, 512, 3, True),
("decode, topk 128 (short window)", 128, 128, 4, False),
]
if __name__ == "__main__":
for c in CASES:
run_case(*c)
print()
if FAILED:
print(f"MISMATCH {len(FAILED)}: {FAILED}")
sys.exit(1)
print(f"ALL_MATCH ({len(CASES)} cases)")

View File

@ -0,0 +1,86 @@
"""A/B the vendored FlagGems 5.0.2 kernel against the TileLang kernel, 7 shapes.
Same cases as scripts/sparse_mla_port/ab_vs_tilelang.py, but importing the in-tree copy instead of
the downloaded one, so this is what actually runs in the engine.
"""
import math
import sys
import torch
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn
from deepinfer.h20_ops.vendor.flashmla_sparse import flash_mla_sparse_fwd
DEV = "cuda"
HQ, HKV, D, TOPK = 64, 1, 512, 512
FAILED = []
def tl_call(q3: torch.Tensor, kv3: torch.Tensor, idx3: torch.Tensor, scale: float) -> tuple[torch.Tensor, torch.Tensor]:
s_q, s_kv = q3.shape[0], kv3.shape[0]
out, lse = sparse_mla_fwd_attn(
q3.view(1, s_q, HQ, D),
kv3.view(1, s_kv, 1, D),
idx3.view(1, s_q, 1, -1),
sm_scale=scale,
is_causal=False,
)
return out.view(s_q, HQ, D), lse.view(s_q, HQ)
def compare(tag: str, a: torch.Tensor, b: torch.Tensor) -> None:
af, bf = a.float(), b.float()
if af.shape != bf.shape:
print(f" FAIL {tag}: shape {tuple(af.shape)} vs {tuple(bf.shape)}")
FAILED.append(tag)
return
diff = (af - bf).abs()
scale = af.abs().max().item()
tol = 2.0**-8 * max(scale, 1e-9) * 2
ok = diff.max().item() <= tol and bool(torch.isfinite(bf).all())
print(f" {'ok ' if ok else 'FAIL'} {tag}: max|ref| {scale:.4f} max diff {diff.max().item():.3e} tol {tol:.3e}")
if not ok:
FAILED.append(tag)
def case(tag: str, SQ: int, SKV: int, seed: int, distinct: bool = True) -> None:
torch.manual_seed(seed)
q = (torch.randn((SQ, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((SKV, HKV, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
if distinct and SKV > TOPK:
idx = torch.stack([torch.randperm(SKV, device=DEV)[:TOPK] for _ in range(SQ)])
idx = idx.view(SQ, 1, TOPK).to(torch.int32)
else:
idx = torch.randint(0, SKV, (SQ, 1, TOPK), device=DEV, dtype=torch.int32)
idx = idx.contiguous()
print(f"\n[{tag}] SQ={SQ} SKV={SKV} distinct={distinct}")
scale = D**-0.5
tl_out, tl_lse = tl_call(q, kv, idx, scale)
torch.cuda.synchronize()
fg_out, fg_max, fg_lse = flash_mla_sparse_fwd(q, kv, idx, sm_scale=scale, d_v=D)
torch.cuda.synchronize()
compare("out", tl_out, fg_out)
# lse units differ: TileLang returns log2, FlagGems natural log.
compare("lse (after log2->ln)", tl_lse.float() * math.log(2), fg_lse)
CASES = [
("prefill, distinct top-k", 64, 2048, 0, True),
("prefill, duplicate top-k", 32, 512, 1, False),
("decode-like (SQ=1)", 1, 4096, 2, True),
("odd SQ", 7, 1024, 3, True),
("short KV < topk", 16, 128, 4, False),
("larger prefill", 256, 4096, 5, True),
("equal SQ==SKV", 512, 512, 6, False),
]
if __name__ == "__main__":
for c in CASES:
case(*c)
print()
if FAILED:
print(f"MISMATCH {len(FAILED)}: {FAILED}")
sys.exit(1)
print(f"ALL_MATCH ({len(CASES)} cases)")

View File

@ -1,153 +0,0 @@
"""FlagGems' `flash_mla_sparse_fwd` vs DeepInfer's TileLang sparse MLA, on the engine's own contract.
Why this pairing and not the DSA kernel: `attn_sm90.prefill_attn_varlen_sparse` calls the TileLang
kernel with `is_causal=False`, documented in the source -- the indexer only scores positions before
each query, so the indices carry causality, and bounding on the row index instead collapses the
layer to NaN (that bug already happened once). `flash_mla_sparse_fwd` has no causal parameter for
the same reason. The DSA kernel (`triton_sparse_mla_fwd_interface`) hardcodes causal and needs the
V3.2 576-wide split layout, which V4.1 does not have, so it is not a candidate here.
Argument correspondence, which is closer than expected:
prefill_attn_varlen_sparse(query, kv, index_topk, softmax_scale,
attn_sink, index_length)
flash_mla_sparse_fwd(q, kv, indices, sm_scale,
attn_sink=..., topk_length=...)
Both return (out, ...). `index_length` is a per-query valid count applied as -1 padding upstream;
`topk_length` is the same thing applied inside the kernel.
"""
import sys
from pathlib import Path
import torch
# The vendored copy imports `flag_gems.utils...`; its stub sits next to it, so put that on the
# path here rather than making the caller set PYTHONPATH for a layout detail.
sys.path.insert(0, str(Path(__file__).resolve().parent / "vendor"))
from vendor.fg_flashmla_sparse import flash_mla_sparse_fwd
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn
DEV = "cuda"
HQ, HKV, D, TOPK = 64, 1, 512, 512
FAILED = []
def tl_call(q3, kv3, idx4, scale):
"""DeepInfer's shape convention: (S,H,D)/(S,1,D)/(S,1,topk) -> pad a batch axis."""
s_q = q3.shape[0]
s_kv = kv3.shape[0]
out, lse = sparse_mla_fwd_attn(
q3.view(1, s_q, HQ, D),
kv3.view(1, s_kv, 1, D),
idx4.view(1, s_q, 1, -1),
sm_scale=scale,
is_causal=False,
)
return out.view(s_q, HQ, D), lse.view(s_q, HQ)
def compare(tag, a, b, extra=""):
af, bf = a.float(), b.float()
if af.shape != bf.shape:
print(f" FAIL {tag}: shape {tuple(af.shape)} vs {tuple(bf.shape)}")
FAILED.append(tag)
return
diff = (af - bf).abs()
scale = af.abs().max().item()
tol = 2.0**-8 * max(scale, 1e-9) * 2
finite = bool(torch.isfinite(bf).all())
ok = diff.max().item() <= tol and finite
print(
f" {'ok ' if ok else 'FAIL'} {tag}: max|ref| {scale:.4f} max diff {diff.max().item():.3e}"
f" mean {diff.mean().item():.3e} tol {tol:.3e} finite {finite}{extra}"
)
if not ok:
FAILED.append(tag)
def make(SQ, SKV, seed, distinct):
torch.manual_seed(seed)
q = (torch.randn((SQ, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((SKV, HKV, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
if distinct and SKV > TOPK:
rows = [torch.randperm(SKV, device=DEV)[:TOPK] for _ in range(SQ)]
idx = torch.stack(rows).view(SQ, 1, TOPK).to(torch.int32)
else:
idx = torch.randint(0, SKV, (SQ, 1, TOPK), device=DEV, dtype=torch.int32)
return q, kv, idx.contiguous()
def case(tag, SQ, SKV, seed, distinct=True, use_length=False, use_sink=False):
q, kv, idx = make(SQ, SKV, seed, distinct)
scale = D**-0.5
print(f"\n[{tag}] SQ={SQ} SKV={SKV} topk={TOPK} distinct={distinct}"
f"{' +topk_length' if use_length else ''}{' +attn_sink' if use_sink else ''}")
# DeepInfer applies `index_length` by masking the tail of the index row to -1 upstream
# (`_mask_beyond_length`), so the reference call is the same kernel with a padded index.
idx_ref = idx
tl_len = None
if use_length:
torch.manual_seed(seed + 100)
tl_len = torch.randint(64, TOPK, (SQ,), device=DEV, dtype=torch.int32)
idx_ref = idx.clone()
ar = torch.arange(TOPK, device=DEV).view(1, 1, TOPK)
idx_ref[(ar >= tl_len.view(SQ, 1, 1)).expand(SQ, 1, TOPK)] = -1
idx_ref = idx_ref.contiguous()
tl_out, _ = tl_call(q, kv, idx_ref, scale)
torch.cuda.synchronize()
sink = None
if use_sink:
torch.manual_seed(seed + 200)
sink = (torch.randn((HQ,), device=DEV, dtype=torch.float32) * 0.5)
# DeepInfer's sink is folded into the output after the kernel; feed the kernel no sink and
# compare the raw attention, then check the sink formula separately below.
fg_out, fg_max, fg_lse = flash_mla_sparse_fwd(
q, kv, idx_ref, sm_scale=scale, d_v=D,
attn_sink=sink,
topk_length=tl_len if use_length else None,
)
torch.cuda.synchronize()
if use_sink:
# Compare against the reference with the sink applied the same way the kernel documents:
# out * exp(lse) / (exp(lse) + exp(sink)).
tl_lse = sparse_mla_fwd_attn(
q.view(1, SQ, HQ, D), kv.view(1, SKV, 1, D), idx_ref.view(1, SQ, 1, -1),
sm_scale=scale, is_causal=False,
)[1].view(SQ, HQ)
import math
w = torch.exp(tl_lse.float() * math.log(2)) # TileLang lse is log2 units
# tl_out is (SQ, HQ, D), so the weight must gain a trailing axis to broadcast over D.
s = sink.view(1, HQ, 1)
gate = w.unsqueeze(-1) / (w.unsqueeze(-1) + torch.exp(s))
applied = tl_out.float() * gate
compare("sink applied", applied, fg_out)
else:
compare("out", tl_out, fg_out)
CASES = [
("prefill, distinct top-k", dict(SQ=64, SKV=2048, seed=0)),
("prefill, duplicate top-k", dict(SQ=32, SKV=512, seed=1, distinct=False)),
("decode (SQ=1)", dict(SQ=1, SKV=4096, seed=2)),
("odd SQ", dict(SQ=7, SKV=1024, seed=3)),
("short KV (< topk)", dict(SQ=16, SKV=128, seed=4, distinct=False)),
("with topk_length", dict(SQ=32, SKV=2048, seed=5, use_length=True)),
("with attn_sink", dict(SQ=16, SKV=1024, seed=6, use_sink=True)),
]
if __name__ == "__main__":
# Run from the repo root: PYTHONPATH=. python scripts/sparse_mla_port/ab_vs_tilelang.py
for tag, kw in CASES:
case(tag, **kw)
print()
if FAILED:
print(f"MISMATCH {len(FAILED)}: {FAILED}")
sys.exit(1)
print(f"ALL_MATCH ({len(CASES)} cases)")

View File

@ -1,55 +0,0 @@
"""Can FlagGems' Triton sparse-MLA kernel run on H20 (SM90) at DeepSeek-V4.1's shape?
Deliberately narrow: first prove the kernel compiles and produces finite output, then (in
sparse_mla_ab.py) compare it against the TileLang kernel DeepInfer uses today. Mixing the two into
one script makes a compile failure look like a numerical mismatch.
Shape comes from the real config: 64 query heads, 1 kv head, head_dim 512, index_topk 512.
"""
import sys
from pathlib import Path
import traceback
import torch
# The vendored copy imports `flag_gems.utils...`; its stub sits next to it, so put that on the
# path here rather than making the caller set PYTHONPATH for a layout detail.
sys.path.insert(0, str(Path(__file__).resolve().parent / "vendor"))
from vendor.fg_flashmla_sparse import flash_mla_sparse_fwd
torch.manual_seed(0)
DEV = "cuda"
B, SQ, HQ, HKV, D, TOPK, SKV = 1, 8, 64, 1, 512, 512, 64
q = torch.randn((B, SQ, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1
kv = torch.randn((B, SKV, HKV, D), device=DEV, dtype=torch.bfloat16) * 0.1
# Valid indices into SKV, no duplicates within a row is not required by the kernel.
idx = torch.randint(0, SKV, (B, SQ, HKV, TOPK), device=DEV, dtype=torch.int32)
# The kernel takes varlen shapes: fold the batch dim away (B is 1 here).
q2 = q.view(B * SQ, HQ, D).contiguous()
kv2 = kv.view(B * SKV, HKV, D).contiguous()
idx2 = idx.view(B * SQ, HKV, TOPK).contiguous()
print(f"q {tuple(q2.shape)} kv {tuple(kv2.shape)} idx {tuple(idx2.shape)}", flush=True)
print("h_q=64, h_kv=1, d=512 -- all inside the kernel's asserts", flush=True)
try:
out, max_logits, lse = flash_mla_sparse_fwd(
q2, kv2, idx2, sm_scale=D**-0.5, d_v=D
)
torch.cuda.synchronize()
print(f"OK out {tuple(out.shape)} {out.dtype}")
print(f" max_logits {tuple(max_logits.shape)} {max_logits.dtype}")
print(f" lse {tuple(lse.shape)} {lse.dtype}")
print(f" finite: out={torch.isfinite(out).all().item()} "
f"lse={torch.isfinite(lse).all().item()}")
print(f" mean |out| {out.float().abs().mean().item():.6f}")
except Exception:
print("FAIL")
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,201 @@
"""Unit tests for the Triton sparse-MLA adapter, against the TileLang kernel as reference.
The adapter's job is not the attention maths -- that is the vendored kernel's, and
`ab_vendored.py` covers it. The job is the *contract*: return arity, lse units, dtype, and the two
calling conventions `attn_sm90` uses. Those are what this file tests, because each of them fails
silently if wrong:
* returning natural-log lse makes `_apply_sink` scale outputs by exp(lse*(1-ln2)) -- plausible
output, wrong numbers;
* returning (out, max_logits, lse) instead of (out, lse) makes every `out, lse = ...` unpack the
wrong tensor;
* mismapping the per-query convention attends the wrong query's window.
Run: PYTHONPATH=. python scripts/sparse_mla_port/test_sparse_mla_triton.py
"""
from __future__ import annotations
import math
import sys
import torch
from deepinfer.h20_ops import sparse_mla_triton
from deepinfer.h20_ops.sparse_mla_sm90 import sparse_mla_fwd_attn as tl_sparse_mla
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) -> 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 = 2.0**-8 * max(scale, 1e-9) * 2
check(tag, diff <= tol, f"max diff {diff:.3e} tol {tol:.3e}")
def test_prefill_contract() -> None:
"""Shared-KV convention: the shape prefill actually uses."""
print("\n[prefill: shared KV, batch=1]")
torch.manual_seed(0)
sq, skv, topk = 64, 2048, 512
q = (torch.randn((1, sq, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((1, skv, 1, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
idx = torch.stack([torch.randperm(skv, device=DEV)[:topk] for _ in range(sq)])
idx = idx.view(1, sq, 1, topk).to(torch.int32).contiguous()
scale = D**-0.5
tl_out, tl_lse = tl_sparse_mla(q, kv, idx, sm_scale=scale, is_causal=False)
tri_out, tri_lse = sparse_mla_triton.sparse_mla_fwd_attn(q, kv, idx, sm_scale=scale, is_causal=False)
torch.cuda.synchronize()
check("returns exactly two tensors", isinstance((tri_out, tri_lse), tuple) and len((tri_out, tri_lse)) == 2)
check("out dtype matches reference", tri_out.dtype == tl_out.dtype, str(tri_out.dtype))
check("lse dtype matches reference", tri_lse.dtype == tl_lse.dtype, str(tri_lse.dtype))
check("out shape preserved", tri_out.shape == q.shape, str(tuple(tri_out.shape)))
check("lse shape", tri_lse.shape == (1, sq, HQ), str(tuple(tri_lse.shape)))
close("out", tl_out, tri_out)
close("lse (log2)", tl_lse, tri_lse)
def test_lse_units() -> None:
"""lse must be log2, not the kernel's natural log.
Asserted independently of the out comparison: an adapter that forgot the conversion would still
produce the right `out` here (the kernel applies no sink), so comparing out alone cannot catch
it. The ratio between the two units is exactly 1/ln2, so it is checkable without a reference.
"""
print("\n[lse units: log2, not natural log]")
torch.manual_seed(1)
sq, skv, topk = 16, 1024, 512
q = (torch.randn((1, sq, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((1, skv, 1, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
idx = torch.randint(0, skv, (1, sq, 1, topk), device=DEV, dtype=torch.int32).contiguous()
_tl_out, tl_lse = tl_sparse_mla(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
_tri_out, tri_lse = sparse_mla_triton.sparse_mla_fwd_attn(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
torch.cuda.synchronize()
# If the adapter had passed the natural log through, tri_lse would equal tl_lse * ln2 instead
# of tl_lse, i.e. be smaller by a factor of 1.4427. Check the ratio is ~1, not ~0.693.
ratio = (tri_lse.float() / tl_lse.float().clamp_min(1e-6)).median().item()
check("lse ratio to reference is ~1.0 (would be 0.693 if unconverted)", abs(ratio - 1.0) < 0.02, f"ratio {ratio:.4f}")
# And the natural-log interpretation must be measurably different, or the test is vacuous.
natural_ratio = (tl_lse.float() * math.log(2) / tl_lse.float().clamp_min(1e-6)).median().item()
check("the two units are distinguishable for this input", abs(natural_ratio - 1.0) > 0.2, f"ln/log2 = {natural_ratio:.3f}")
def test_decode_convention() -> None:
"""Per-query KV convention: what decode passes (batch axis is the query axis)."""
print("\n[decode: per-query KV, batch=total_q]")
torch.manual_seed(2)
total_q, window = 32, 512
q = (torch.randn((total_q, 1, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
# Every query gets a different window, so a mis-mapped convention shows up as wrong numbers.
kv = (torch.randn((total_q, window, 1, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
identity = torch.arange(window, dtype=torch.int32, device=DEV).view(1, 1, 1, -1)
idx = identity.expand(total_q, 1, 1, window).contiguous()
tl_out, tl_lse = tl_sparse_mla(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
tri_out, tri_lse = sparse_mla_triton.sparse_mla_fwd_attn(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
torch.cuda.synchronize()
close("decode out", tl_out.view(total_q, HQ, D), tri_out.view(total_q, HQ, D))
close("decode lse", tl_lse.view(total_q, HQ), tri_lse.view(total_q, HQ))
def test_rejects_unsupported() -> None:
"""Loud failure beats a silently wrong result."""
print("\n[rejects what it cannot do]")
q = torch.zeros((1, 4, HQ, D), device=DEV, dtype=torch.bfloat16)
kv = torch.zeros((1, 8, 1, D), device=DEV, dtype=torch.bfloat16)
idx = torch.zeros((1, 4, 1, 8), device=DEV, dtype=torch.int32)
try:
sparse_mla_triton.sparse_mla_fwd_attn(q, kv, idx, is_causal=True)
check("is_causal=True raises", False)
except NotImplementedError as exc:
check("is_causal=True raises", "is_causal=False" in str(exc))
q2 = torch.zeros((1, 4, 32, D), device=DEV, dtype=torch.bfloat16)
try:
sparse_mla_triton.sparse_mla_fwd_attn(q2, kv, idx, is_causal=False)
check("unsupported head count raises", False)
except ValueError as exc:
check("unsupported head count raises", "heads" in str(exc))
kv3 = torch.zeros((3, 8, 1, D), device=DEV, dtype=torch.bfloat16)
try:
sparse_mla_triton.sparse_mla_fwd_attn(q, kv3, idx, is_causal=False)
check("mismatched kv batch raises", False)
except ValueError as exc:
check("mismatched kv batch raises", "batch axis" in str(exc))
def test_match_sink_behaviour() -> None:
"""The consumer of lse is `_apply_sink`; check it two ways rather than one.
Comparing only the final bf16 tensors is the wrong instrument. `_apply_sink` multiplies an
already-bf16 `out` (itself differing by ~1.4 ULP) by a factor and rounds again, so the result can
differ by a couple ULP even when both inputs are correct -- measured 1.83e-4 against 1.76e-4 for
2 ULP of the input magnitude. That is a tolerance artifact, not a disagreement, so this asserts
the thing that actually matters on its own: the multiplicative factor, in fp32, is a pure
function of lse and must agree tightly. That is precisely the claim "lse units are right".
"""
print("\n[attn_sink path agrees]")
from deepinfer.h20_ops.attn_sm90 import _apply_sink
torch.manual_seed(3)
sq, skv, topk = 16, 1024, 512
q = (torch.randn((1, sq, HQ, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
kv = (torch.randn((1, skv, 1, D), device=DEV, dtype=torch.bfloat16) * 0.1).contiguous()
idx = torch.randint(0, skv, (1, sq, 1, topk), device=DEV, dtype=torch.int32).contiguous()
sink = torch.randn((HQ,), device=DEV, dtype=torch.float32) * 0.5
tl_out, tl_lse = tl_sparse_mla(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
tri_out, tri_lse = sparse_mla_triton.sparse_mla_fwd_attn(q, kv, idx, sm_scale=D**-0.5, is_causal=False)
torch.cuda.synchronize()
f_tl = torch.sigmoid(tl_lse.float() * math.log(2) - sink.view(1, 1, HQ)).unsqueeze(-1)
f_tri = torch.sigmoid(tri_lse.float() * math.log(2) - sink.view(1, 1, HQ)).unsqueeze(-1)
rel = (f_tl - f_tri).abs().max().item() / max(f_tl.abs().max().item(), 1e-9)
check("sink factor relative diff < 1e-5", rel < 1e-5, f"rel {rel:.3e}")
# Composed path: two bf16 roundings, so 4 ULP of the input magnitude, not 2.
a = _apply_sink(tl_out, tl_lse, sink).float()
b = _apply_sink(tri_out, tri_lse, sink).float()
diff = (a - b).abs().max().item()
scale = tl_out.float().abs().max().item()
tol = 4 * 2.0**-8 * max(scale, 1e-9)
check("_apply_sink output within 4 ULP", diff <= tol, f"max diff {diff:.3e} tol {tol:.3e}")
TESTS = [
test_prefill_contract,
test_lse_units,
test_decode_convention,
test_rejects_unsupported,
test_match_sink_behaviour,
]
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)")

View File

@ -0,0 +1,236 @@
"""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)")

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
"""Minimal stand-in so the FlagGems fused kernels can be imported standalone."""

View File

@ -1,27 +0,0 @@
"""Stand-in for FlagGems' version probe.
The real module gates TLE (Triton Language Extensions) on the triton version. Here it reports
whatever the installed triton actually supports, because the point is running the kernel, not
reproducing FlagGems' packaging.
"""
import triton
def _version_tuple() -> tuple[int, ...]:
parts = []
for piece in triton.__version__.split(".")[:3]:
digits = "".join(c for c in piece if c.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
def has_triton_tle(*minimum: int) -> bool:
try:
import triton.experimental.tle.language # noqa: F401
except Exception:
return False
return _version_tuple() >= tuple(minimum)
HAS_TLE = has_triton_tle(3, 6, 0)