[Diffusion][MiniMax-H3] Add SM90 Sage compute for SubBlock sparse attention (#37982)

This commit is contained in:
HuangJi
2026-09-09 20:00:03 +08:00
committed by GitHub
parent dba34cc964
commit ffe98a4279
10 changed files with 900 additions and 68 deletions
@@ -0,0 +1,259 @@
# SPDX-License-Identifier: Apache-2.0
"""SM90 adapter from a SubBlock plan to SpargeAttention's native Sage kernel.
The public backend mode is ``sage_fp8`` across GPU generations. On Hopper,
the fastest available implementation is SageAttention2: it uses 64-token query
blocks and 128-token key blocks, quantizes Q/K to INT8, and quantizes V plus
softmax probabilities to E4M3 online. BF16 model activations and weights remain
unchanged.
"""
from __future__ import annotations
import functools
import torch
import triton
import triton.language as tl
SAGE_FP8_SM90_QUERY_BLOCK_SIZE = 64
SAGE_FP8_SM90_KEY_BLOCK_SIZE = 128
_INSTALL_HELP = (
"Install SpargeAttention with "
"`pip install git+https://github.com/thu-ml/SpargeAttn.git "
"--no-build-isolation` to use SubBlock compute_mode='sage_fp8' on SM90."
)
@functools.lru_cache(maxsize=1)
def _load_sparge_attention_sm90_ops():
"""Load every private SpargeAttention symbol used by the SM90 adapter."""
try:
import spas_sage_attn._fused as fused
import spas_sage_attn._qattn as qattn
from spas_sage_attn.utils import block_map_lut_triton, get_vanilla_qk_quant
transpose_pad_permute_cuda = fused.transpose_pad_permute_cuda
scale_fuse_quant_cuda = fused.scale_fuse_quant_cuda
kernel = (
qattn.qk_int8_sv_f8_accum_f32_block_sparse_attn_inst_buf_fuse_v_scale_sm90
)
except (ImportError, OSError, AttributeError) as exc:
raise ImportError(_INSTALL_HELP) from exc
return (
get_vanilla_qk_quant,
block_map_lut_triton,
transpose_pad_permute_cuda,
scale_fuse_quant_cuda,
kernel,
)
@triton.jit
def _routing_plan_to_block_map_kernel(
block_index,
block_counts,
block_map,
width: tl.constexpr,
num_key_blocks: tl.constexpr,
block: tl.constexpr,
):
row = tl.program_id(0)
slots = tl.arange(0, block)
count = tl.load(block_counts + row)
active = slots < count
key_block = tl.load(
block_index + row * width + slots,
mask=active,
other=0,
)
tl.store(
block_map + row * num_key_blocks + key_block,
1,
mask=active,
)
def _routing_plan_to_block_map(
block_index: torch.Tensor,
block_counts: torch.Tensor,
num_key_blocks: int,
) -> torch.Tensor:
"""Convert compact absolute block ids to SpargeAttention's dense bool map."""
if block_index.ndim != 4:
raise ValueError(
"SubBlock Sage FP8 block_index must have shape [B, H, Gq, K], got "
f"{tuple(block_index.shape)}"
)
if block_counts.shape != block_index.shape[:-1]:
raise ValueError(
"SubBlock Sage FP8 block_counts must match block_index[:-1], got "
f"{tuple(block_counts.shape)} and {tuple(block_index.shape)}"
)
width = block_index.shape[-1]
if block_index.device.type == "cuda":
flat_index = block_index.contiguous().view(-1, width)
flat_counts = block_counts.contiguous().view(-1)
block_map = torch.zeros(
(flat_index.shape[0], num_key_blocks),
dtype=torch.bool,
device=block_index.device,
)
_routing_plan_to_block_map_kernel[(flat_index.shape[0],)](
flat_index,
flat_counts,
block_map,
width=width,
num_key_blocks=num_key_blocks,
block=triton.next_power_of_2(width),
num_warps=4 if width >= 128 else 2,
)
return block_map.view(*block_index.shape[:-1], num_key_blocks)
flat_index = block_index.reshape(-1, width).long()
flat_counts = block_counts.reshape(-1)
slots = torch.arange(width, device=block_index.device)
active = slots[None, :] < flat_counts[:, None]
# Only active entries are written. This matters for heterogeneous plans:
# ignored suffix values can duplicate an active id, and scatter(False) could
# otherwise race with scatter(True) for the same destination.
row = torch.arange(flat_index.shape[0], device=block_index.device)
row = row[:, None].expand_as(flat_index)
block_map = torch.zeros(
(flat_index.shape[0], num_key_blocks),
dtype=torch.bool,
device=block_index.device,
)
block_map[row[active], flat_index[active]] = True
return block_map.view(*block_index.shape[:-1], num_key_blocks)
@torch.no_grad()
def subblock_sage_fp8_sm90_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
block_index: torch.Tensor,
topk: int,
softmax_scale: float,
block_counts: torch.Tensor | None = None,
) -> torch.Tensor:
"""Run a 64x128 SubBlock plan with the native SM90 SageAttention2 kernel.
Q/K/V use SGLang's normal ``[batch, sequence, heads, head_dim]`` layout.
Quantization is performed online and the returned tensor has the same BF16
dtype and layout as Q.
"""
(
get_vanilla_qk_quant,
block_map_lut_triton,
transpose_pad_permute_cuda,
scale_fuse_quant_cuda,
kernel,
) = _load_sparge_attention_sm90_ops()
if q.device.type != "cuda":
raise ValueError("SubBlock Sage FP8 requires CUDA tensors.")
if torch.cuda.get_device_capability(q.device) != (9, 0):
raise ValueError("This SubBlock Sage FP8 implementation requires SM90.")
if q.dtype != torch.bfloat16 or k.dtype != q.dtype or v.dtype != q.dtype:
raise ValueError("SubBlock Sage FP8 requires BF16 Q, K, and V.")
if k.device != q.device or v.device != q.device:
raise ValueError("SubBlock Sage FP8 requires Q, K, and V on one device.")
if q.ndim != 4 or k.ndim != 4 or v.ndim != 4:
raise ValueError("SubBlock Sage FP8 expects Q, K, and V in [B, S, H, D].")
if (
q.shape[0] != k.shape[0]
or q.shape[0] != v.shape[0]
or q.shape[2:] != k.shape[2:]
or q.shape[2:] != v.shape[2:]
or k.shape[1] != v.shape[1]
or q.shape[-1] != 128
):
raise ValueError(
"SubBlock Sage FP8 requires compatible Q, K, and V with head_dim=128."
)
expected_q_blocks = -(-q.shape[1] // SAGE_FP8_SM90_QUERY_BLOCK_SIZE)
num_key_blocks = -(-k.shape[1] // SAGE_FP8_SM90_KEY_BLOCK_SIZE)
if block_index.shape[:3] != (q.shape[0], q.shape[2], expected_q_blocks):
raise ValueError(
"SubBlock Sage FP8 routing shape does not match Q: expected "
f"{(q.shape[0], q.shape[2], expected_q_blocks)}, got "
f"{tuple(block_index.shape[:3])}"
)
if block_counts is None:
block_counts = torch.full(
block_index.shape[:-1],
topk,
dtype=torch.int32,
device=block_index.device,
)
block_map = _routing_plan_to_block_map(block_index, block_counts, num_key_blocks)
# Keep the external package's online quantizers and native Hopper kernel,
# but not its second P/V-threshold sparsifier. SubBlock already chose the
# exact blocks to compute; another pruning rule would alter that plan and
# add a reduction to every iteration.
with torch.cuda.device(q.device):
q_hnd = q.transpose(1, 2).contiguous()
k_hnd = k.transpose(1, 2).contiguous()
v_hnd = v.transpose(1, 2).contiguous()
k_mean = k_hnd.mean(dim=-2, keepdim=True)
q_int8, q_scale, k_int8, k_scale = get_vanilla_qk_quant(
q_hnd,
k_hnd,
k_mean,
SAGE_FP8_SM90_QUERY_BLOCK_SIZE,
SAGE_FP8_SM90_KEY_BLOCK_SIZE,
)
lut, valid_block_counts = block_map_lut_triton(block_map)
padded_kv_len = num_key_blocks * SAGE_FP8_SM90_KEY_BLOCK_SIZE
v_transposed = torch.empty(
(q.shape[0], q.shape[2], q.shape[3], padded_kv_len),
dtype=v.dtype,
device=v.device,
)
transpose_pad_permute_cuda(v_hnd, v_transposed, 1)
v_fp8 = torch.empty_like(v_transposed, dtype=torch.float8_e4m3fn)
v_scale = torch.empty(
(q.shape[0], q.shape[2], q.shape[3]),
dtype=torch.float32,
device=v.device,
)
scale_fuse_quant_cuda(
v_transposed,
v_fp8,
v_scale,
k.shape[1],
2.25,
1,
)
# The extension honors output strides. Let its HND kernel write through
# an HND view of BSHD storage, avoiding a full output transpose/copy.
output_bshd = torch.empty_like(q)
output_hnd = output_bshd.transpose(1, 2)
kernel(
q_int8,
k_int8,
v_fp8,
output_hnd,
lut,
valid_block_counts,
q_scale,
k_scale,
v_scale,
1, # HND tensor layout
False,
1, # per-block Q/K scales
softmax_scale,
)
return output_bshd
__all__ = ["subblock_sage_fp8_sm90_attention"]
@@ -260,6 +260,32 @@ class MiniMaxH3PipelineConfig(PipelineConfig):
)
if selected_backend is None:
return
if selected_backend is AttentionBackendEnum.SUBBLOCK_SPARSE_ATTN:
attention_config = server_args.attention_backend_config or {}
compute_mode = str(attention_config.get("compute_mode", "bf16"))
if compute_mode not in ("bf16", "sage_fp8"):
raise ValueError(
"SubBlock compute_mode must be 'bf16' or 'sage_fp8', got "
f"{compute_mode!r}."
)
if compute_mode == "sage_fp8":
capability = current_platform.get_device_capability()
if capability is None or capability.to_int() != 90:
found = (
capability.as_version_str()
if capability is not None
else "unknown"
)
raise ValueError(
"MiniMax-H3 SubBlock compute_mode='sage_fp8' currently "
"requires SM90 (compute capability 9.0); "
f"found {found}."
)
from sglang.kernels.ops.attention.subblock_sage_fp8_sm90 import (
_load_sparge_attention_sm90_ops,
)
_load_sparge_attention_sm90_ops()
if selected_backend is AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3:
if server_args.ring_degree > 1:
raise ValueError(
@@ -1,8 +1,11 @@
# SubBlock sparse attention — training-free block sparsity for the MiniMax-H3 DiT
Routes the same 64-token SubBlock plan to SGLang's CuTe-DSL block-sparse
FlashAttention kernel on SM90 or FlashInfer's architecture-specific blk64
kernels on SM100 and SM120.
Routes a SubBlock plan to SGLang's CuTe-DSL block-sparse FlashAttention kernel
on SM90 or FlashInfer's architecture-specific blk64 kernels on SM100 and SM120.
The architecture-neutral `"compute_mode": "sage_fp8"` selects online Sage-style
FP8 compute; its current SM90 implementation uses the native SageAttention2
INT8-QK/FP8-PV Hopper kernel. Future SM100 and SM120 Sage FP8 implementations
can register under the same configuration value.
Nothing is trained and no weights change: a cheap estimator runs before
attention and hands the selected kernel a `q2k_block_index`.
@@ -16,7 +19,7 @@ sglang serve --model-path MiniMaxAI/MiniMax-H3 --model-variant fl2va \
--component-attention-backends text_encoder=fa \
--attention-backend-config '{"sparsity": 0.75, "n_k": 4, "n_q": 4,
"skip_first_steps": 10, "skip_first_layers": 0,
"min_seq_len": 4096}'
"min_seq_len": 4096, "compute_mode": "bf16"}'
```
**The text-encoder override is not optional.** `--attention-backend` applies to
@@ -43,7 +46,7 @@ are listed below.
| | |
| --- | --- |
| GPU | **compute capability 9.0, 10.0, or 12.0** — H100 / H200 use SGLang's CuTe-DSL SM90 block-sparse FlashAttention kernel; B200 / GB200 use FlashInfer's architecture-specific `sm_100a` kernel; SM120 devices use FlashInfer's `bsa_attn_sm120_blk64_fwd` CuTe-DSL kernel. Other capabilities, including 10.3 (B300 / GB300), are rejected. |
| GPU | **compute capability 9.0, 10.0, or 12.0** — H100 / H200 use SGLang's CuTe-DSL SM90 block-sparse FlashAttention kernel or the current `sage_fp8` implementation; B200 / GB200 use FlashInfer's architecture-specific `sm_100a` BF16 kernel; SM120 devices use FlashInfer's `bsa_attn_sm120_blk64_fwd` CuTe-DSL BF16 kernel. Other capabilities, including 10.3 (B300 / GB300), are rejected. |
| dtype | bfloat16 |
| head_dim | 128 |
| attention | non-causal, one contiguous sequence per call |
@@ -86,17 +89,33 @@ rejected.
| key | default | meaning |
| --- | ---: | --- |
| `sparsity` | 0.75 | key blocks dropped per query block, as an upper bound |
| `n_k` | 4 | key sub-blocks per 64-token block (1, 2, 4, 8) |
| `n_k` | 4 (`sage_fp8` on SM90: 8) | key sub-blocks per key block (1, 2, 4, 8) |
| `n_q` | 4 | query sub-blocks per 64-token block (1, 2, 4, 8) |
| `skip_first_steps` | 10 | leading denoise forwards kept dense |
| `skip_first_layers` | 0 | leading DiT blocks kept dense |
| `min_seq_len` | 4096 | shorter sequences run dense |
| `compute_mode` | `"bf16"` | compute path: `"bf16"` or architecture-dispatched `"sage_fp8"` |
**`sparsity` is an upper bound, not an exact figure.** The kernel pads each query
row's block count up to a multiple of 8 with phantom slots it then masks out, so
148 blocks costs exactly what 152 costs; the router takes the 152. At 590 blocks,
0.75 requested delivers 0.7424, and the startup log reports what was kept. It is
the speed lever — see below — and the only knob most users should touch.
`compute_mode="sage_fp8"` is the stable public name, not a promise that every
architecture quantizes every operand identically. On SM90 it calls
SpargeAttention's native Hopper SageAttention2 kernel: Q/K are quantized online
to INT8 and V/P to E4M3. Its Q64 x K128 geometry defaults `n_k` to 8, preserving
the normal router's 16-token key pooling cells. Install the optional dependency:
```bash
pip install git+https://github.com/thu-ml/SpargeAttn.git --no-build-isolation
```
Enable it on SM90 with:
```bash
--attention-backend-config '{"compute_mode":"sage_fp8"}'
```
**`sparsity` is an upper bound, not always an exact figure.** BF16 plans round
the budget up to groups of 8: 148 blocks costs what 152 costs. Native SM90
`sage_fp8` uses 128-token key blocks without that rounding; at S=37,760 and
sparsity 0.75 it keeps 74 of 295 blocks. Startup logs report the actual budget.
**`n_k` and `n_q` buy score accuracy, not speed.** They set how finely a block is
cut before scoring: `n_k=4` means four 16-token key sub-blocks, and the block's
@@ -168,10 +187,11 @@ are comparable.
| --- | --- |
| `router.py` | `SubBlockRouter` — pooling, scoring, selection, `RoutingPlan` |
| `kernels.py` | Triton pooling / segmented-LSE / fused top-k |
| `../../../../../../kernels/ops/attention/subblock_sage_fp8_sm90.py` | production adapter to native SM90 SageAttention2 |
| `../subblock_sparse_attn.py` | the `AttentionBackend`: schedule, gating, dense fallback |
Tests: `test/unit/test_subblock_sparse_attention.py`. The trick that makes the sparse
kernel checkable against dense is running it at a sparsity just above 0 — every
block is then inside the budget, so the result must reproduce dense attention up
to bf16 rounding, which pins the routing indices, the ragged tail block sizes
and the softmax scale in one assertion.
Tests: `test/unit/test_subblock_sparse_attention.py` and
`test/registered/kernel/attention/test_subblock_sage_fp8_sm90.py`. The GPU
test covers the native production dispatch. Running at a full block budget must
reproduce dense attention up to the expected quantization error, pinning routing
indices, ragged tails, scale domains and the softmax scale in one check.
@@ -6,9 +6,9 @@ Originally vendored from the standalone SubBlock repository; ``router.py`` and
``router.py`` scores every (query block, key block) pair from sub-block-pooled
Q/K and turns the scores into a ``q2k_block_index`` consumed by SGLang's SM90
CuTe-DSL block-sparse FlashAttention or FlashInfer's architecture-specific
SM100/SM120 blk64 kernels (bf16, head_dim 128). The estimator and the
measurements behind its defaults are documented there.
CuTe-DSL block-sparse FlashAttention, the native SM90 Sage FP8 adapter, or
FlashInfer's architecture-specific SM100/SM120 blk64 kernels. The estimator and
the measurements behind its defaults are documented there.
"""
from .router import (
@@ -1,15 +1,18 @@
# SPDX-License-Identifier: Apache-2.0
"""Sub-block block-sparse routing for FlashInfer's ``bsa_attn_blk64_fwd``.
"""Sub-block block-sparse routing for architecture-specific attention kernels.
Training-free. Runs *before* attention, produces the ``q2k_block_index`` tensor the
64-token block-sparse kernel consumes.
Training-free. Runs *before* attention and produces the ``q2k_block_index`` tensor
the selected block-sparse kernel consumes. The current BF16 kernels use Q64 x K64
on SM90, SM100, and SM120; SM90 ``sage_fp8`` uses Q64 x K128. The router accepts
both block widths so each compute path receives indices in its native geometry.
Why sub-blocks
--------------
The usual proxy score for a 64x64 block is ``mean(Q_block) . mean(K_block)``. Averaging
64 keys into one vector throws away exactly the variation that decides which keys a query
wants. Splitting each 64-token block into ``n`` sub-blocks of ``64/n`` tokens, scoring all
sub-block pairs and combining them with a log-sum-exp recovers most of that:
For the Q64 x K64 BF16 paths, the usual proxy score is
``mean(Q_block) . mean(K_block)``. Averaging a whole key block into one vector throws
away exactly the variation that decides which keys a query wants. Splitting the query
and key blocks into ``n_q`` and ``n_k`` sub-blocks, scoring all sub-block pairs, and
combining them with a log-sum-exp recovers most of that:
score(i, j) = log sum_{a<n_q, b<n_k} exp( qbar_{i,a} . kbar_{j,b} * softmax_scale )
@@ -17,8 +20,9 @@ which is a direct estimate of the block's un-normalised softmax mass
``sum_{r in i, c in j} exp(q_r . k_c * scale)`` -- the quantity that decides how much
attention mass is lost when the block is skipped.
Measured on 567 (task x denoise-step x layer x head) samples of MiniMax-H3 DiT attention,
mean recall of the retained softmax mass at 0.9 block sparsity:
Measured with Q64 x K64 geometry on 567 (task x denoise-step x layer x head)
samples of MiniMax-H3 DiT attention, mean recall of the retained softmax mass at
0.9 block sparsity:
n_q=1 n_k=1 .6513 4 u <- plain avg pooling
n_q=1 n_k=2 .6598 8 u
@@ -35,7 +39,9 @@ to score against, the query detail averages out. Splitting both together is a di
proposition -- the log-sum-exp then runs over query-key sub-block *pairs*, and "some part
of this query block wants some part of that key block" is a signal that survives the
averaging. That is the best row in the table, and it is the only estimator change in this
family that has separated from anything else end to end. ``n_q = n_k = 4`` ships.
family that has separated from anything else end to end. The Q64 x K64 BF16 paths
ship with ``n_q = n_k = 4``. SM90 ``sage_fp8`` uses ``n_q = 4, n_k = 8`` with
Q64 x K128, preserving the same 16-token pooling cells.
Not worth retrying without new evidence
---------------------------------------
@@ -63,7 +69,7 @@ the pipeline currently produces.
Usage
-----
router = SubBlockRouter(n_k=4, n_q=4)
router = SubBlockRouter(n_k=4, n_q=4) # current BF16 geometry
plan = router.route(q, k, sparsity=0.8, softmax_scale=d**-0.5) # q, k: [B, S, H, D]
out, _ = bsa_attn_blk64_fwd(q, k, v, plan.index, plan.topk,
block_sizes=SubBlockRouter.block_sizes(S, q.device),
@@ -145,9 +151,9 @@ def load_bsa_attn_sm120_blk64_fwd():
LOG2E = 1.4426950408889634
BLOCK = 64 # the kernel's block granularity (kSparseBlockSize=64)
BUDGET_GRANULARITY = 8 # blocks per query row the kernel bills in, padding to fit
VALID_N = (1, 2, 4, 8) # sub-blocks per 64-token block -> 64 / 32 / 16 / 8 tokens
BLOCK = 64 # default Q/K block size for the current BF16 consumers
BUDGET_GRANULARITY = 8 # Q64 x K64 default; SM90 sage_fp8 uses exact block counts
VALID_N = (1, 2, 4, 8) # sub-blocks per query/key block
def _snap_up_to_8(topk: int, num_blocks: int) -> int:
@@ -164,7 +170,14 @@ def _snap_up_to_8(topk: int, num_blocks: int) -> int:
The consequence for the caller is that ``sparsity`` is an upper bound rather
than an exact figure: 0.75 of 590 blocks becomes 152 kept, 0.7424 dropped.
"""
return min(num_blocks, max(1, -(-topk // BUDGET_GRANULARITY)) * BUDGET_GRANULARITY)
return _snap_up(topk, num_blocks, BUDGET_GRANULARITY)
def _snap_up(topk: int, num_blocks: int, granularity: int) -> int:
"""Clamp a requested budget and round it to a kernel's billing unit."""
if granularity < 1:
raise ValueError(f"budget granularity must be positive, got {granularity}")
return min(num_blocks, max(1, -(-topk // granularity)) * granularity)
class RoutingPlan(msgspec.Struct, frozen=True):
@@ -183,11 +196,18 @@ class SubBlockRouter:
"""Builds ``q2k_block_index`` from sub-block-pooled Q/K.
Args:
n_k: key sub-blocks per 64-token block (1, 2, 4 or 8). 1 reproduces plain avg
pooling; 4 is the quality/cost point the recall table above lands on.
n_q: query sub-blocks, same values. Splitting Q *alone* (n_q>1 with n_k=1) is
worse than not splitting; splitting both sides together is what the default
does. Costs n_q times the score matrix, 0.5% of the denoise time.
n_k: key sub-blocks per key block (1, 2, 4 or 8). 1 reproduces plain
average pooling. The BF16 Q64 x K64 paths default to 4; SM90
``sage_fp8`` defaults to 8 for its K128 blocks.
n_q: query sub-blocks per query block, with the same allowed values.
Splitting Q *alone* (n_q>1 with n_k=1) is worse than not splitting;
splitting both sides together is what the defaults do. Costs n_q
times the score matrix, 0.5% of denoise time.
block_size_k: native key-block width. It is 64 for the BF16 kernels and
128 for SM90 ``sage_fp8``.
budget_granularity: rounding applied to the selected block count. The
Q64 x K64 paths retain the established default of 8 (required by
FlashInfer on SM100/SM120); SM90 ``sage_fp8`` uses 1.
Structural block reservation (an attention sink, or forcing the diagonal j == i) was
measured on 200 real H3 attention cells and is deliberately absent: at a fixed budget
@@ -195,12 +215,30 @@ class SubBlockRouter:
which did not survive to the pixels.
"""
def __init__(self, n_k: int = 4, n_q: int = 4) -> None:
def __init__(
self,
n_k: int = 4,
n_q: int = 4,
*,
block_size_k: int = BLOCK,
budget_granularity: int = BUDGET_GRANULARITY,
) -> None:
if n_k not in VALID_N or n_q not in VALID_N:
raise ValueError(
f"n_q/n_k must be one of {VALID_N}, got n_q={n_q}, n_k={n_k}"
)
if block_size_k <= 0 or block_size_k % n_k:
raise ValueError(
f"key block size {block_size_k} must be positive and divisible "
f"by n_k={n_k}"
)
if budget_granularity < 1:
raise ValueError(
f"budget granularity must be positive, got {budget_granularity}"
)
self.n_k, self.n_q = n_k, n_q
self.block_size_k = block_size_k
self.budget_granularity = budget_granularity
@torch.no_grad()
def scores(
@@ -223,12 +261,14 @@ class SubBlockRouter:
"""
b, s, h, d = q.shape
sk = k.shape[1]
gq, gk = -(-s // BLOCK), -(-sk // BLOCK)
gq = -(-s // BLOCK)
gk = -(-sk // self.block_size_k)
nq, nk = self.n_q, self.n_k
sub_q, sub_k = BLOCK // nq, BLOCK // nk
sub_q = BLOCK // nq
sub_k = self.block_size_k // nk
# Pooling handles the ragged tail on the *pooled* tensor: padding q/k up to
# G*BLOCK first would copy the whole 300+ MB activation to add a few rows.
# complete native blocks first would copy the whole 300+ MB activation.
# Sub-cells past the last real token pool to zero, and `*_valid` tells the score
# kernel to drop them -- left in, each would contribute an exp(0)=1 term that
# both inflates the score and flattens the differences the ranking depends on.
@@ -255,13 +295,14 @@ class SubBlockRouter:
) -> RoutingPlan:
"""Select the top ``(1 - sparsity)`` fraction of key blocks per query block."""
b, s, h, d = q.shape
gk = -(-k.shape[1] // BLOCK)
gk = -(-k.shape[1] // self.block_size_k)
scores = self.scores(q, k, softmax_scale) # [B, H, Gq, Gk]
gq = scores.shape[2]
topk = _snap_up_to_8(math.ceil((1.0 - sparsity) * gk), gk)
topk = _snap_up(math.ceil((1.0 - sparsity) * gk), gk, self.budget_granularity)
# One pass over the score matrix instead of torch.topk's several. The
# output order is unspecified: SM100 consumes it directly, while the
# SM90 backend sorts compact active prefixes before heterogeneous expansion.
# output order is unspecified. SM100/SM120 BF16 consume it directly;
# SM90 sage_fp8 converts it to an order-independent block map. Only the
# SM90 CuTe BF16 consumer sorts compact active prefixes before expansion.
index = fused_topk(scores.reshape(-1, gk), topk).view(b, h, gq, topk)
return RoutingPlan(index=index, topk=topk, num_blocks=gk)
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
"""SubBlock block-sparse attention backend.
Routes the same 64-token SubBlock plan to SGLang's CuTe-DSL block-sparse
FlashAttention kernel on SM90 or FlashInfer's architecture-specific kernels on
SM100 and SM120. A log-sum-exp over query/key sub-block pairs selects the blocks
(see ``backends/subblock_sparse/``).
Routes a SubBlock plan to SGLang's CuTe-DSL block-sparse FlashAttention kernel
on SM90 or FlashInfer's architecture-specific kernels on SM100 and SM120. A
log-sum-exp over query/key sub-block pairs selects the blocks (see
``backends/subblock_sparse/``).
Everything is training-free: the router runs before attention and produces
the ``q2k_block_index`` the selected kernel consumes.
@@ -20,10 +20,13 @@ individual keys of the defaults below::
Requirements inherited from the kernels: compute capability 9.0 (Hopper) or
10.0/12.0 (Blackwell), bf16, head_dim 128. Hopper uses SGLang's CuTe-DSL SM90
block-sparse FlashAttention kernel; B200 and SM120 devices use FlashInfer's
architecture-specific blk64 kernels. Inside the DiT, any call the kernels cannot
serve -- cross/refiner attention, short sequences, non-bf16 -- runs dense instead.
On any other GPU the resolver refuses the backend at startup rather than falling back.
block-sparse FlashAttention kernel by default; ``compute_mode="sage_fp8"``
selects its native SageAttention2 INT8-QK/FP8-PV kernel. The mode name is stable
across architectures, so future SM100/SM120 implementations can use their own
Sage FP8 arithmetic without changing server configuration. B200 and SM120
devices currently use FlashInfer's architecture-specific blk64 BF16 kernels.
Inside the DiT, unsupported calls run dense instead; unsupported GPU
architectures are rejected.
``--attention-backend`` reaches every component, and the text encoder admits
only fa / torch_sdpa / sage_attn_3. Pair it with
@@ -58,9 +61,10 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
# The kernel is fixed at 64-token blocks and 128-wide heads.
# Query blocks and heads are fixed; Hopper Sage FP8 widens K blocks to 128.
SUBBLOCK_SPARSE_BLOCK_SIZE = 64
SUBBLOCK_SPARSE_HEAD_DIM = 128
SAGE_FP8_SM90_KEY_BLOCK_SIZE = 128
# Defaults for the schedule; override through --attention-backend-config.
# Sparsity is the speed lever, and it saturates: on MiniMax-H3 t2va at 37.7k
@@ -97,6 +101,9 @@ DEFAULT_N_Q = 4
# Below this many keys the router costs more than the blocks it saves, and the
# top-k budget collapses to a handful of blocks.
DEFAULT_MIN_SEQ_LEN = 4096
# Keep the established BF16 kernel as the default. ``sage_fp8`` is explicit
# until its model-level quality/performance envelope has been validated.
DEFAULT_COMPUTE_MODE = "bf16"
# ``blocks.<idx>.attn`` is a DiT layer; ``token_refiner.blocks.<idx>.attn`` and
# anything else is not and stays dense.
@@ -178,6 +185,31 @@ def _sm90_sparse_attention(
return out
def _sm90_sage_fp8_sparse_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_block_index: torch.Tensor,
topk: int,
softmax_scale: float,
block_counts: torch.Tensor | None = None,
) -> torch.Tensor:
"""Run a 64x128 plan with the native SM90 Sage INT8-QK/FP8-PV kernel."""
from sglang.kernels.ops.attention.subblock_sage_fp8_sm90 import (
subblock_sage_fp8_sm90_attention,
)
return subblock_sage_fp8_sm90_attention(
q,
k,
v,
q2k_block_index,
topk,
softmax_scale,
block_counts,
)
def _sm100_sparse_attention(
q: torch.Tensor,
k: torch.Tensor,
@@ -226,14 +258,31 @@ def _sm120_sparse_attention(
@functools.lru_cache(maxsize=None)
def _get_subblock_sparse_attention_runner(device: torch.device):
"""Resolve the architecture-specific kernel once per CUDA device."""
def _get_subblock_sparse_attention_runner(
device: torch.device, compute_mode: str = DEFAULT_COMPUTE_MODE
):
"""Resolve the architecture/mode-specific kernel once per CUDA device."""
capability = torch.cuda.get_device_capability(device)
if compute_mode not in ("bf16", "sage_fp8"):
raise ValueError(f"unknown SubBlock compute mode {compute_mode!r}")
if capability == (9, 0):
return _sm90_sparse_attention
if compute_mode == "bf16":
return _sm90_sparse_attention
if compute_mode == "sage_fp8":
return _sm90_sage_fp8_sparse_attention
if capability == (10, 0):
if compute_mode != "bf16":
raise RuntimeError(
f"SubBlock compute_mode={compute_mode!r} currently targets SM90; "
"the SM100 FlashInfer Sage adapter is not wired in SGLang yet."
)
return _sm100_sparse_attention
if capability == (12, 0):
if compute_mode != "bf16":
raise RuntimeError(
f"SubBlock compute_mode={compute_mode!r} currently targets SM90; "
"the SM120 FlashInfer Sage adapter is not wired in SGLang yet."
)
return _sm120_sparse_attention
raise RuntimeError(
"SubBlock sparse attention supports compute capability 9.0, 10.0, or 12.0; "
@@ -249,15 +298,16 @@ def _run_subblock_sparse_attention(
topk: int,
softmax_scale: float,
block_counts: torch.Tensor | None = None,
compute_mode: str = DEFAULT_COMPUTE_MODE,
) -> torch.Tensor:
"""Dispatch a prepared 64x64 routing plan to Hopper or Blackwell.
"""Dispatch a prepared routing plan to Hopper or Blackwell.
SM90 requires every active index prefix to be sorted in ascending order;
SM100 and SM120 accept the router's original order. Heterogeneous callers
must sort compact sparse prefixes before expanding them to full-width dense
rows.
"""
runner = _get_subblock_sparse_attention_runner(q.device)
runner = _get_subblock_sparse_attention_runner(q.device, compute_mode)
return runner(
q,
k,
@@ -320,12 +370,17 @@ class SubBlockSparseSchedule(msgspec.Struct, frozen=True):
n_k: int
n_q: int
min_seq_len: int
compute_mode: str
@classmethod
def from_server_args(cls) -> SubBlockSparseSchedule:
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
config = get_global_server_args().attention_backend_config or {}
compute_mode = str(config.get("compute_mode", DEFAULT_COMPUTE_MODE))
# Hopper's native kernel consumes 128-token K blocks. Eight sub-blocks
# preserve the default router's 16-token key pooling cells.
default_n_k = 8 if compute_mode == "sage_fp8" else DEFAULT_N_K
schedule = SubBlockSparseSchedule(
sparsity=float(config.get("sparsity", DEFAULT_SPARSITY)),
skip_first_steps=int(
@@ -334,9 +389,10 @@ class SubBlockSparseSchedule(msgspec.Struct, frozen=True):
skip_first_layers=int(
config.get("skip_first_layers", DEFAULT_SKIP_FIRST_LAYERS)
),
n_k=int(config.get("n_k", DEFAULT_N_K)),
n_k=int(config.get("n_k", default_n_k)),
n_q=int(config.get("n_q", DEFAULT_N_Q)),
min_seq_len=int(config.get("min_seq_len", DEFAULT_MIN_SEQ_LEN)),
compute_mode=compute_mode,
)
if not 0.0 <= schedule.sparsity < 1.0:
raise ValueError(
@@ -347,6 +403,11 @@ class SubBlockSparseSchedule(msgspec.Struct, frozen=True):
raise ValueError(f"subblock {name} must be 1, 2, 4 or 8, got {value}")
if schedule.skip_first_steps < 0 or schedule.skip_first_layers < 0:
raise ValueError("subblock skip_first_* must be non-negative")
if schedule.compute_mode not in ("bf16", "sage_fp8"):
raise ValueError(
"subblock compute_mode must be 'bf16' or 'sage_fp8', got "
f"{schedule.compute_mode!r}"
)
return schedule
@@ -388,7 +449,18 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
and self.schedule.sparsity > 0.0
)
self.router = (
SubBlockRouter(n_k=self.schedule.n_k, n_q=self.schedule.n_q)
SubBlockRouter(
n_k=self.schedule.n_k,
n_q=self.schedule.n_q,
block_size_k=(
SAGE_FP8_SM90_KEY_BLOCK_SIZE
if self.schedule.compute_mode == "sage_fp8"
else SUBBLOCK_SPARSE_BLOCK_SIZE
),
budget_granularity=(
1 if self.schedule.compute_mode == "sage_fp8" else 8
),
)
if self.layer_enabled
else None
)
@@ -396,7 +468,8 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
if self.layer_enabled:
logger.info_once(
f"SubBlock sparse attention: sparsity={self.schedule.sparsity:.3f} "
f"n_k={self.schedule.n_k} n_q={self.schedule.n_q}, dense for the first "
f"n_k={self.schedule.n_k} n_q={self.schedule.n_q} "
f"compute_mode={self.schedule.compute_mode}, dense for the first "
f"{self.schedule.skip_first_steps} denoise steps and the first "
f"{self.schedule.skip_first_layers} DiT layers"
)
@@ -475,7 +548,9 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
"query blocks are dense"
)
block_counts = None
runner = _get_subblock_sparse_attention_runner(q.device)
runner = _get_subblock_sparse_attention_runner(
q.device, self.schedule.compute_mode
)
block_index = (
plan.index.sort(dim=-1).values
if runner is _sm90_sparse_attention
@@ -522,6 +597,7 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
kernel_topk,
self.softmax_scale,
block_counts,
self.schedule.compute_mode,
)
def forward(
@@ -581,6 +581,7 @@ def test_validate_server_args_accepts_transformer_backend_override():
server_args = SimpleNamespace(
component_attention_backends={"transformer": "subblock_sparse_attn"},
attention_backend="fa",
attention_backend_config={},
ring_degree=1,
resolve_component_attention_backend=lambda *_names: (
AttentionBackendEnum.SUBBLOCK_SPARSE_ATTN,
@@ -21,6 +21,7 @@ from unittest.mock import Mock, patch
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse.router import (
SubBlockRouter,
_snap_up_to_8,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn import (
@@ -163,9 +164,25 @@ class TestSubBlockSparseSchedule(unittest.TestCase):
self.assertEqual(schedule.skip_first_layers, 0)
self.assertEqual(schedule.n_k, 4)
self.assertEqual(schedule.n_q, 4)
self.assertEqual(schedule.compute_mode, "bf16")
def test_sage_fp8_uses_16_token_key_subblocks_by_default(self):
with _patch_schedule({"compute_mode": "sage_fp8"}):
schedule = SubBlockSparseSchedule.from_server_args()
self.assertEqual(schedule.n_k, 8)
def test_explicit_sage_fp8_n_k_is_respected(self):
with _patch_schedule({"compute_mode": "sage_fp8", "n_k": 4}):
schedule = SubBlockSparseSchedule.from_server_args()
self.assertEqual(schedule.n_k, 4)
def test_rejects_out_of_range_values(self):
for config in ({"sparsity": 1.0}, {"n_k": 3}, {"skip_first_steps": -1}):
for config in (
{"sparsity": 1.0},
{"n_k": 3},
{"skip_first_steps": -1},
{"compute_mode": "fp8"},
):
with self.subTest(config=config), _patch_schedule(config):
with self.assertRaises(ValueError):
SubBlockSparseSchedule.from_server_args()
@@ -185,6 +202,23 @@ class TestBudgetGranularity(unittest.TestCase):
self.assertEqual(_snap_up_to_8(3, 5), 5)
class TestRouterGeometry(unittest.TestCase):
def test_sm90_sage_fp8_preserves_16_token_pooling_cells(self):
router = SubBlockRouter(
n_q=4,
n_k=8,
block_size_k=128,
budget_granularity=1,
)
self.assertEqual(64 // router.n_q, 16)
self.assertEqual(router.block_size_k // router.n_k, 16)
self.assertEqual(router.budget_granularity, 1)
def test_rejects_non_divisible_block_geometry(self):
with self.assertRaisesRegex(ValueError, "divisible"):
SubBlockRouter(n_q=4, n_k=8, block_size_k=100)
class TestSubBlockSparseBackend(unittest.TestCase):
def test_sm90_adapter_uses_presorted_indices_and_64x64_blocks(self):
captured = {}
@@ -286,6 +320,12 @@ class TestSubBlockGating(unittest.TestCase):
with _patch_step(20):
self.assertFalse(impl._sparse_ready(q, q))
def test_sage_fp8_builds_the_sm90_64x128_router(self):
impl = self._impl("blocks.9.attn", compute_mode="sage_fp8")
self.assertEqual(impl.router.block_size_k, 128)
self.assertEqual(impl.router.n_k, 8)
self.assertEqual(impl.router.budget_granularity, 1)
@requires_subblock_kernel
class TestSubBlockNumerics(unittest.TestCase):