[diffusion][Minimax H3]support subblock sparse attention on SM90 (#34680)

This commit is contained in:
HuangJi
2026-08-19 10:31:45 +08:00
committed by GitHub
parent eb085524c8
commit ee1f2e8dfd
9 changed files with 459 additions and 75 deletions
@@ -294,8 +294,37 @@ def produce_block_sparse_loads(
)
mask_begin, mask_end = split_block_range(curr_mask_block_cnt, split_idx, num_splits)
full_begin, full_end = split_block_range(curr_full_block_cnt, split_idx, num_splits)
mask_empty = mask_begin == mask_end
# Normalization guarantees that the full count and index are both present
# or both absent. ``mask_empty`` is a runtime value, so CuTe still traces
# both sides of the dynamic branches below; without this specialization,
# the full-list side would subscript ``None`` and fail to compile.
if const_expr(blocksparse_tensors.full_block_cnt is None):
kv_producer_state = load_block_list(
curr_mask_block_idx,
mask_begin,
mask_end,
first_block_preloaded=False,
kv_producer_state=kv_producer_state,
load_K=load_K,
load_V=load_V,
pipeline_k=pipeline_k,
pipeline_v=pipeline_v,
intra_wg_overlap=intra_wg_overlap,
)
if const_expr(intra_wg_overlap) and not mask_empty:
kv_producer_state = finish_overlap_v_load(
curr_mask_block_idx,
mask_begin,
mask_end,
load_V,
pipeline_v,
kv_producer_state,
)
return kv_producer_state
full_begin, full_end = split_block_range(curr_full_block_cnt, split_idx, num_splits)
full_empty = full_begin == full_end
if mask_empty:
@@ -488,7 +517,10 @@ def consume_block_sparse_loads(
if split_full_block_cnt == 0:
warp_scheduler_barrier_arrive()
if split_full_block_cnt > 0:
if (
const_expr(blocksparse_tensors.full_block_cnt is not None)
and split_full_block_cnt > 0
):
full_n_block = curr_full_block_idx[full_end - 1]
if split_mask_block_cnt == 0:
warp_scheduler_barrier_sync()
@@ -561,7 +593,10 @@ def consume_block_sparse_loads(
)
O_should_accumulate = True
if split_full_block_cnt > 0:
if (
const_expr(blocksparse_tensors.full_block_cnt is not None)
and split_full_block_cnt > 0
):
full_n_block = curr_full_block_idx[full_end - 1]
if split_mask_block_cnt == 0:
kv_consumer_state = process_first_half_block(
@@ -151,16 +151,34 @@ class FwdConfig:
def _tile_size_fwd_sm90(
head_dim, head_dim_v, is_causal, is_local, sparse_block_size_q=None
head_dim,
head_dim_v,
is_causal,
is_local,
sparse_block_size_q=None,
sparse_block_size_kv=None,
):
"""Return FwdConfig for SM90 forward.
Tile sizes and flags based on tile_size_fwd_sm90 in hopper/tile_size.h, adjusted
for the Python kernel's different register/smem tradeoffs (benchmarked on H100 SXM).
When sparse_block_size_q is set, tile_m must divide it. For head_dim <= 96 the
optimal tile_m=192 is used when compatible, otherwise we fall back to 128.
When sparse block sizes are set, the compute tiles must respect both axes of
the sparse mask. The 64x64 case is used by SubBlock attention: every 64-row
query block has its own independently routed list of 64-row KV blocks, so it
cannot be coarsened to the usual 128x128 tile without changing the mask.
For other sparse masks, tile_m must divide sparse_block_size_q. For
head_dim <= 96 the optimal tile_m=192 is used when compatible, otherwise we
fall back to 128.
"""
if (
head_dim == 128
and sparse_block_size_q == 64
and sparse_block_size_kv == 64
):
return FwdConfig(64, 64, True, True)
if head_dim <= 64:
# C++: 192×192 non-causal, 192×128 causal/local.
# Python: 192×128 RS+OL is consistently best across seqlens.
@@ -718,8 +736,19 @@ def _flash_attn_fwd(
fwd_cfg = FwdConfig(128, 64, True, True) # SM80, should tune
elif arch // 10 == 9:
sparse_q = get_sparse_q_block_size(block_sparse_tensors, seqlen_q)
sparse_block_size_kv = (
block_sparse_tensors.block_size[1]
if block_sparse_tensors is not None
and block_sparse_tensors.block_size is not None
else None
)
fwd_cfg = _tile_size_fwd_sm90(
head_dim, head_dim_v, causal, local, sparse_block_size_q=sparse_q
head_dim,
head_dim_v,
causal,
local,
sparse_block_size_q=sparse_q,
sparse_block_size_kv=sparse_block_size_kv,
)
else:
fwd_cfg = FwdConfig(
@@ -1,8 +1,9 @@
# SubBlock sparse attention — training-free block sparsity for the MiniMax-H3 DiT
Routes FlashInfer's 64-token block-sparse kernel (`bsa_attn_blk64_fwd`) with a
sub-block score. Nothing is trained and no weights change: a cheap estimator
runs before attention and hands the kernel a `q2k_block_index`.
Routes the same 64-token SubBlock plan to SGLang's CuTe-DSL block-sparse
FlashAttention kernel on SM90 or FlashInfer's `bsa_attn_blk64_fwd` on SM100.
Nothing is trained and no weights change: a cheap estimator runs before
attention and hands the selected kernel a `q2k_block_index`.
Spelled out in full, with every key at its default — which is the recommended
configuration and what every number below was measured at:
@@ -32,11 +33,12 @@ quotes.
## What it runs on
Everything below comes from `bsa_attn_blk64_fwd`, not from this backend.
The backend selects an architecture-specific kernel; their shared constraints
are listed below.
| | |
| --- | --- |
| GPU | **compute capability 10.0 only**B200 / GB200 class. The kernel is built `-gencode=arch=compute_100a,code=sm_100a`, which is arch-specific and does not forward-run on 10.3 (B300 / GB300) or 12.x (RTX PRO 6000, RTX 50xx). |
| GPU | **compute capability 9.0 or 10.0**H100 / H200 use SGLang's CuTe-DSL SM90 block-sparse FlashAttention kernel; B200 / GB200 use FlashInfer's architecture-specific `sm_100a` kernel. Other capabilities, including 10.3 (B300 / GB300) and 12.x (RTX PRO 6000, RTX 50xx), are rejected. |
| dtype | bfloat16 |
| head_dim | 128 |
| attention | non-causal, one contiguous sequence per call |
@@ -46,10 +48,10 @@ refiner, sequences under `min_seq_len`, non-bf16 activations, head_dim != 128
falls back to dense for that call, so no layer has to be excluded by hand.
**On an unsupported GPU it is not a fallback, it is an error at startup.** The
resolver checks the compute capability before anything loads and refuses
anything but 10.0, so an H100 or a B300 fails at launch rather than after ten
dense denoise steps. Do not rely on the kernel's own guard for this: it compares
only the major version, so it would accept 10.3 and then fail with no cubin.
resolver accepts exactly compute capability 9.0 or 10.0 before loading either
kernel, so a B300 or an SM12x GPU fails at launch rather than after ten dense
denoise steps. The exact 10.0 check is required because FlashInfer's kernel is
built for `sm_100a` and has no forward-compatible 10.3 cubin.
## How the score works
@@ -5,9 +5,10 @@ Originally vendored from the standalone SubBlock repository; ``router.py`` and
``kernels.py`` have since diverged from it.
``router.py`` scores every (query block, key block) pair from sub-block-pooled
Q/K and turns the scores into the ``q2k_block_index`` that FlashInfer's
``bsa_attn_blk64_fwd`` consumes (SM100, bf16, head_dim 128). The estimator and
the measurements behind its defaults are documented there.
Q/K and turns the scores into a ``q2k_block_index`` consumed by SGLang's SM90
CuTe-DSL block-sparse FlashAttention or FlashInfer's SM100
``bsa_attn_blk64_fwd`` (bf16, head_dim 128). The estimator and the measurements
behind its defaults are documented there.
"""
from .router import SubBlockRouter, load_bsa_attn_blk64_fwd
@@ -1,10 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
"""SubBlock block-sparse attention backend.
Routes FlashInfer's 64-token block-sparse kernel with a K-side sub-block
log-sum-exp score (see ``backends/subblock_sparse/``). Everything is training-free:
the router runs before attention and produces the ``q2k_block_index`` the
kernel consumes.
Routes the same 64-token SubBlock plan to SGLang's CuTe-DSL block-sparse
FlashAttention kernel on SM90 or FlashInfer's kernel on SM100. 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.
Sparsity is not applied everywhere. The early denoise steps settle the layout
of the sample and tolerate approximation badly, so the backend falls back to
@@ -16,11 +17,12 @@ individual keys of the defaults below::
--attention-backend subblock_sparse_attn \
--attention-backend-config '{"sparsity": 0.85}'
Requirements inherited from the kernel: compute capability 10.0 (B200 / GB200
class -- it is built for ``sm_100a``, which does not forward-run on 10.3 or
12.x), bf16, head_dim 128. Inside the DiT, any call the kernel 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.
Requirements inherited from the kernels: compute capability 9.0 (Hopper) or
10.0 (B200 / GB200), bf16, head_dim 128. Hopper uses SGLang's CuTe-DSL SM90
block-sparse FlashAttention kernel; B200 uses FlashInfer's ``sm_100a`` blk64
kernel. 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.
``--attention-backend`` reaches every component, and the text encoder admits
only fa / torch_sdpa / sage_attn_3, so pair it with
@@ -54,6 +56,7 @@ 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.
SUBBLOCK_SPARSE_BLOCK_SIZE = 64
SUBBLOCK_SPARSE_HEAD_DIM = 128
# Defaults for the schedule; override through --attention-backend-config.
@@ -112,8 +115,124 @@ def _cached_block_sizes(seq_len: int, device: torch.device) -> torch.Tensor:
return SubBlockRouter.block_sizes(seq_len, device)
class SubBlockSparseAttentionBackend(AttentionBackend):
@functools.lru_cache(maxsize=1)
def _load_sm90_block_sparse_attention():
"""Load the CuTe-DSL Hopper path only when an SM90 device selects it.
Keeping these imports lazy avoids pulling the sizeable CuTe dependency tree
into the existing SM100 path, whose FlashInfer blk64 kernel is plain CUDA.
"""
from sglang.kernels.ops.attention.flash_attn.cute.block_sparsity import (
BlockSparseTensorsTorch,
)
from sglang.kernels.ops.attention.flash_attn.cute.interface import flash_attn_func
return BlockSparseTensorsTorch, flash_attn_func
def _sm90_sparse_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_block_index: torch.Tensor,
topk: int,
softmax_scale: float,
) -> torch.Tensor:
"""Run a SubBlock routing plan through the existing SM90 CuTe kernel."""
BlockSparseTensorsTorch, flash_attn_func = _load_sm90_block_sparse_attention()
# The router contract permits indices in any order, while the SM90 sparse
# pipeline consumes each list from high slot to low slot and applies
# sequence-tail masking to the first block. Sort explicitly so the largest
# block id -- the possible ragged tail -- occupies the highest slot without
# depending on the fused top-k kernel's current ascending output order.
ordered_index = q2k_block_index.sort(dim=-1).values
block_counts = torch.full(
ordered_index.shape[:-1],
topk,
dtype=torch.int32,
device=ordered_index.device,
)
sparse_tensors = BlockSparseTensorsTorch(
mask_block_cnt=block_counts,
mask_block_idx=ordered_index,
# There are no always-dense blocks in a SubBlock routing plan. The
# block-sparse broadcast pattern records both absent tensors as None
# and participates in the compile key, so mask-only and mask+full calls
# cannot share a compiled kernel.
full_block_cnt=None,
full_block_idx=None,
block_size=(SUBBLOCK_SPARSE_BLOCK_SIZE, SUBBLOCK_SPARSE_BLOCK_SIZE),
)
out, _ = flash_attn_func(
q,
k,
v,
softmax_scale=softmax_scale,
causal=False,
num_splits=1,
block_sparse_tensors=sparse_tensors,
)
return out
def _sm100_sparse_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_block_index: torch.Tensor,
topk: int,
softmax_scale: float,
) -> torch.Tensor:
"""Run a SubBlock routing plan through FlashInfer's SM100 kernel."""
out = load_bsa_attn_blk64_fwd()(
q,
k,
v,
q2k_block_index,
topk,
block_sizes=_cached_block_sizes(k.shape[1], k.device),
q2k_block_nums=None, # the budget is uniform across rows
softmax_scale=softmax_scale,
)
return out[0] if isinstance(out, tuple) else out
@functools.lru_cache(maxsize=None)
def _get_subblock_sparse_attention_runner(device: torch.device):
"""Resolve the architecture-specific kernel once per CUDA device."""
capability = torch.cuda.get_device_capability(device)
if capability == (9, 0):
return _sm90_sparse_attention
if capability == (10, 0):
return _sm100_sparse_attention
raise RuntimeError(
"SubBlock sparse attention supports compute capability 9.0 or 10.0; "
f"this tensor is on a {capability[0]}.{capability[1]} device."
)
def _run_subblock_sparse_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_block_index: torch.Tensor,
topk: int,
softmax_scale: float,
) -> torch.Tensor:
"""Dispatch the same 64x64 routing plan to Hopper or Blackwell."""
runner = _get_subblock_sparse_attention_runner(q.device)
return runner(
q,
k,
v,
q2k_block_index,
topk,
softmax_scale,
)
class SubBlockSparseAttentionBackend(AttentionBackend):
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [SUBBLOCK_SPARSE_HEAD_DIM]
@@ -285,7 +404,6 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor
) -> torch.Tensor:
"""q, k, v: ``[1, S, H, 128]`` bf16 -> same shape."""
bsa_attn_blk64_fwd = load_bsa_attn_blk64_fwd()
plan = self.router.route(
q, k, sparsity=self.schedule.sparsity, softmax_scale=self.softmax_scale
)
@@ -296,17 +414,14 @@ class SubBlockSparseAttentionImpl(AttentionImpl):
f"keeping {plan.topk}/{plan.num_blocks} key blocks per query block "
f"(sparsity {1 - plan.density:.4f})"
)
out = bsa_attn_blk64_fwd(
return _run_subblock_sparse_attention(
q,
k,
v,
plan.index,
plan.topk,
block_sizes=_cached_block_sizes(k.shape[1], k.device),
q2k_block_nums=None, # the budget is uniform across rows
softmax_scale=self.softmax_scale,
self.softmax_scale,
)
return out[0] if isinstance(out, tuple) else out
def forward(
self,
@@ -297,39 +297,53 @@ class _VMOBAAttentionBackendResolver(_CudaAttentionBackendResolver):
class _SubBlockSparseAttentionBackendResolver(_CudaAttentionBackendResolver):
backend = AttentionBackendEnum.SUBBLOCK_SPARSE_ATTN
# The blk64 kernel is built `-gencode=arch=compute_100a,code=sm_100a`, which
# is arch-specific: 10.3 (B300 / GB300) and 12.x have no cubin. Its own guard
# only compares the major version, so it would accept 10.3 and fail later.
required_capability = (10, 0)
# Hopper uses SGLang's SM90 CuTe-DSL block-sparse kernel. Blackwell uses the
# FlashInfer blk64 kernel built specifically for sm_100a; 10.3 and 12.x do
# not have a compatible cubin and must still fail closed.
supported_capabilities = {(9, 0), (10, 0)}
@classmethod
def resolve(cls, platform) -> str:
capability = platform.get_device_capability()
if capability is None or capability != cls.required_capability:
capability_tuple = (
(capability.major, capability.minor) if capability is not None else None
)
if capability_tuple not in cls.supported_capabilities:
found = capability.as_version_str() if capability else "unknown"
raise ValueError(
"SubBlock sparse attention needs compute capability "
f"{'.'.join(map(str, cls.required_capability))} (B200 / GB200); "
f"this device reports {found}."
"SubBlock sparse attention needs compute capability 9.0 "
f"(Hopper) or 10.0 (B200 / GB200); this device reports {found}."
)
try:
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import ( # noqa: F401
load_bsa_attn_blk64_fwd,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn import ( # noqa: F401
SubBlockSparseAttentionBackend,
)
# Importing the entry point catches a missing or broken FlashInfer;
# the CUDA extension itself is built lazily on the first call.
load_bsa_attn_blk64_fwd()
if capability_tuple == (9, 0):
# Importing catches missing/incompatible CuTe-DSL and Quack;
# the CUDA kernel itself is compiled lazily on the first call.
from sglang.kernels.ops.attention.flash_attn.cute.block_sparsity import ( # noqa: F401
BlockSparseTensorsTorch,
)
from sglang.kernels.ops.attention.flash_attn.cute.interface import ( # noqa: F401
flash_attn_func,
)
else:
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import ( # noqa: F401
load_bsa_attn_blk64_fwd,
)
load_bsa_attn_blk64_fwd()
return "sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn.SubBlockSparseAttentionBackend"
except Exception as e:
logger.error("Failed to import SubBlock sparse attention: %s", str(e))
raise ImportError(
"SubBlock sparse attention needs FlashInfer with the blk64 "
"block-sparse kernel (flashinfer.cute_dsl.sparse.bsa_attn_blk64_fwd)."
) from e
dependency = (
"SGLang's SM90 CuTe-DSL FlashAttention dependencies"
if capability_tuple == (9, 0)
else "FlashInfer with the blk64 block-sparse kernel "
"(flashinfer.cute_dsl.sparse.bsa_attn_blk64_fwd)"
)
raise ImportError(f"SubBlock sparse attention needs {dependency}.") from e
class _FlashAttention2BackendResolver(_CudaAttentionBackendResolver):
@@ -1,7 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
"""SubBlock block-sparse attention backend.
The schedule tests are pure CPU. The numerical tests need an SM100 GPU with
The schedule and adapter tests are pure CPU. The numerical tests need either
an SM90 GPU with SGLang's CuTe-DSL dependencies or an SM100 GPU with
FlashInfer's ``bsa_attn_blk64_fwd`` and are skipped otherwise.
The trick that makes the sparse kernel checkable against dense attention: at
@@ -26,31 +27,40 @@ from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_att
SubBlockSparseAttentionImpl,
SubBlockSparseSchedule,
_dit_layer_index,
_run_subblock_sparse_attention,
_sm90_sparse_attention,
)
HEAD_DIM = 128
NUM_HEADS = 4
def _sm100_available() -> bool:
def _subblock_kernel_available() -> bool:
if not torch.cuda.is_available():
return False
# Exactly 10.0: the kernel is built for sm_100a, and 10.3 has no cubin.
if torch.cuda.get_device_capability(0) != (10, 0):
return False
capability = torch.cuda.get_device_capability(0)
try:
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import (
load_bsa_attn_blk64_fwd,
)
if capability == (9, 0):
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn import (
_load_sm90_block_sparse_attention,
)
load_bsa_attn_blk64_fwd()
_load_sm90_block_sparse_attention()
elif capability == (10, 0):
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import (
load_bsa_attn_blk64_fwd,
)
load_bsa_attn_blk64_fwd()
else:
return False
except Exception:
return False
return True
requires_sm100 = unittest.skipUnless(
_sm100_available(), "needs SM100 and FlashInfer bsa_attn_blk64_fwd"
requires_subblock_kernel = unittest.skipUnless(
_subblock_kernel_available(), "needs an SM90 or SM100 SubBlock attention kernel"
)
@@ -180,6 +190,40 @@ class TestSubBlockSparseBackend(unittest.TestCase):
)
self.assertEqual(metadata.current_timestep, 7)
def test_sm90_adapter_sorts_indices_and_uses_64x64_blocks(self):
captured = {}
class _FakeBlockSparseTensors:
def __init__(self, **kwargs):
captured.update(kwargs)
self.__dict__.update(kwargs)
def fake_flash_attn_func(q, k, v, **kwargs):
captured.update(kwargs)
return q, None
index = torch.tensor([[[[5, 1, 7, 3]]]], dtype=torch.int32)
q = torch.empty(1, 64, 1, HEAD_DIM, dtype=torch.bfloat16)
with patch(
"sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn._load_sm90_block_sparse_attention",
return_value=(_FakeBlockSparseTensors, fake_flash_attn_func),
):
out = _sm90_sparse_attention(q, q, q, index, 4, HEAD_DIM**-0.5)
self.assertIs(out, q)
torch.testing.assert_close(
captured["mask_block_idx"],
torch.tensor([[[[1, 3, 5, 7]]]], dtype=torch.int32),
)
self.assertEqual(captured["mask_block_cnt"].item(), 4)
self.assertIsNone(captured["full_block_cnt"])
self.assertIsNone(captured["full_block_idx"])
self.assertEqual(captured["block_size"], (64, 64))
self.assertIs(
captured["block_sparse_tensors"].mask_block_idx,
captured["mask_block_idx"],
)
class TestSubBlockGating(unittest.TestCase):
"""The schedule must decide sparsity from the layer and the step alone."""
@@ -241,7 +285,7 @@ class TestSubBlockGating(unittest.TestCase):
self.assertFalse(impl._sparse_ready(q, q))
@requires_sm100
@requires_subblock_kernel
class TestSubBlockNumerics(unittest.TestCase):
seq_len = 8192
@@ -275,6 +319,46 @@ class TestSubBlockNumerics(unittest.TestCase):
ref = _dense_reference(q, k, v, HEAD_DIM**-0.5)
self.assertGreater(_cosine(out, ref), 0.999)
def test_unsorted_ragged_tail_oversubscribes_sms(self):
"""Make tail-mask ordering observable across multiple SM waves."""
if torch.cuda.get_device_capability() != (9, 0):
self.skipTest("the reverse-consumption constraint is specific to SM90")
device = torch.device("cuda")
seq_len = self.seq_len + 37
shape = (1, seq_len, NUM_HEADS, HEAD_DIM)
q = torch.zeros(shape, device=device, dtype=torch.bfloat16)
k = torch.zeros_like(q)
v = torch.ones_like(q)
num_blocks = (seq_len + 63) // 64
num_tiles = NUM_HEADS * num_blocks
num_sms = torch.cuda.get_device_properties(device).multi_processor_count
self.assertGreater(num_tiles, 2 * num_sms)
# The SM90 consumer visits slots from high to low and applies the tail
# mask to the first block. Put the ragged block in the lowest slot, so
# removing the adapter's sort leaves its 27 padded rows unmasked. With
# zero Q/K and unit V that changes the output magnitude from 1 to
# (7 * 64 + 37) / (8 * 64), which an assert_close cannot overlook.
topk = 8
tail_block = num_blocks - 1
unsorted_blocks = torch.tensor(
[tail_block, 0, 1, 2, 3, 4, 5, 6],
device=device,
dtype=torch.int32,
)
unsorted_index = (
unsorted_blocks.view(1, 1, 1, topk)
.expand(1, NUM_HEADS, num_blocks, topk)
.clone()
)
out = _run_subblock_sparse_attention(
q, k, v, unsorted_index, topk, HEAD_DIM**-0.5
)
torch.testing.assert_close(out, torch.ones_like(out), rtol=0, atol=2e-3)
def test_routing_finds_the_blocks_that_carry_the_mass(self):
"""At 0.75 sparsity the router must keep the blocks that matter.
@@ -282,11 +366,6 @@ class TestSubBlockNumerics(unittest.TestCase):
number of blocks but chosen at random the output collapses, so a high
cosine here measures the routing, not a forgiving fixture.
"""
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import (
SubBlockRouter,
load_bsa_attn_blk64_fwd,
)
device = torch.device("cuda")
q, k, v = _structured_qkv(self.seq_len, device)
ref = _dense_reference(q, k, v, HEAD_DIM**-0.5)
@@ -308,17 +387,14 @@ class TestSubBlockNumerics(unittest.TestCase):
.argsort(dim=-1)[..., :topk]
.to(torch.int32)
)
random_out = load_bsa_attn_blk64_fwd()(
random_out = _run_subblock_sparse_attention(
q,
k,
v,
random_index,
topk,
block_sizes=SubBlockRouter.block_sizes(self.seq_len, device),
q2k_block_nums=None,
softmax_scale=HEAD_DIM**-0.5,
HEAD_DIM**-0.5,
)
random_out = random_out[0] if isinstance(random_out, tuple) else random_out
self.assertLess(_cosine(random_out, ref), 0.9)
def test_skipped_step_is_bitwise_dense(self):
@@ -0,0 +1,54 @@
# SPDX-License-Identifier: Apache-2.0
import unittest
from unittest.mock import patch
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn import (
_get_subblock_sparse_attention_runner,
_sm90_sparse_attention,
_sm100_sparse_attention,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-b-test-cpu")
class TestSubBlockSparseAttentionDispatch(CustomTestCase):
def setUp(self):
_get_subblock_sparse_attention_runner.cache_clear()
self.addCleanup(_get_subblock_sparse_attention_runner.cache_clear)
def test_dispatch_is_resolved_once_per_device(self):
device = torch.device("cuda:0")
with patch(
"torch.cuda.get_device_capability", return_value=(9, 0)
) as get_capability:
first = _get_subblock_sparse_attention_runner(device)
second = _get_subblock_sparse_attention_runner(device)
self.assertIs(first, _sm90_sparse_attention)
self.assertIs(second, first)
get_capability.assert_called_once_with(device)
def test_dispatches_sm100(self):
device = torch.device("cuda:0")
with patch("torch.cuda.get_device_capability", return_value=(10, 0)):
runner = _get_subblock_sparse_attention_runner(device)
self.assertIs(runner, _sm100_sparse_attention)
def test_rejects_unsupported_compute_capability(self):
device = torch.device("cuda:0")
with patch("torch.cuda.get_device_capability", return_value=(10, 3)):
with self.assertRaisesRegex(
RuntimeError,
"supports compute capability 9.0 or 10.0;.*10.3 device",
):
_get_subblock_sparse_attention_runner(device)
if __name__ == "__main__":
unittest.main(verbosity=3)
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: Apache-2.0
"""SM90-specific invariants for SubBlock sparse attention."""
import unittest
import torch
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import CustomTestCase
register_cuda_ci(est_time=10, stage="base-b-kernel-unit", runner_config="1-gpu-large")
requires_sm90 = unittest.skipUnless(
torch.cuda.is_available() and torch.cuda.get_device_capability() == (9, 0),
"requires SM90 (Hopper)",
)
@requires_sm90
class TestSubBlockSparseSM90(CustomTestCase):
def test_64x64_routing_mask_uses_matching_compute_tile(self):
"""A tile spanning routing rows would apply one row's mask to another row."""
from sglang.kernels.ops.attention.flash_attn.cute.interface import (
_tile_size_fwd_sm90,
)
config = _tile_size_fwd_sm90(
head_dim=128,
head_dim_v=128,
is_causal=False,
is_local=False,
sparse_block_size_q=64,
sparse_block_size_kv=64,
)
self.assertEqual(config.m_block_size, 64)
self.assertEqual(config.n_block_size, 64)
def test_64x64_special_case_is_limited_to_head_dim_128(self):
from sglang.kernels.ops.attention.flash_attn.cute.interface import (
_tile_size_fwd_sm90,
)
config = _tile_size_fwd_sm90(
head_dim=96,
head_dim_v=96,
is_causal=False,
is_local=False,
sparse_block_size_q=64,
sparse_block_size_kv=64,
)
self.assertEqual(config.m_block_size, 128)
self.assertEqual(config.n_block_size, 128)
if __name__ == "__main__":
unittest.main(verbosity=3)