[MiniMax-H3] SubBlock: training-free block-sparse attention for the DiT (#34148)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd20826e0a
commit
704808ed27
+169
@@ -0,0 +1,169 @@
|
||||
# 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`.
|
||||
|
||||
Spelled out in full, with every key at its default — which is the recommended
|
||||
configuration and what every number below was measured at:
|
||||
|
||||
```bash
|
||||
sglang serve --model-path MiniMaxAI/MiniMax-H3 --model-variant fl2va \
|
||||
--num-gpus 8 --ulysses-degree 8 --performance-mode speed \
|
||||
--attention-backend subblock_sparse_attn \
|
||||
--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}'
|
||||
```
|
||||
|
||||
**`text_encoder=fa` is not optional.** `--attention-backend` applies to every
|
||||
component, and the Qwen3-VL text encoder admits only `fa` / `torch_sdpa` /
|
||||
`sage_attn_3`; without the override it raises and the server never starts. Put
|
||||
the override on the *encoder*, not the DiT — `transformer=subblock_sparse_attn`
|
||||
appears to work and silently does nothing, because H3 resolves the DiT backend
|
||||
lazily on the first forward, outside the component-loading context that the
|
||||
override applies to.
|
||||
|
||||
`--attention-backend-config` is optional and overrides only the keys it names,
|
||||
so `'{"sparsity": 0.85}'` alone trades quality for another 6%. Inline JSON gets
|
||||
mangled by `shlex.split`; pass a **file path** instead if the shell eats the
|
||||
quotes.
|
||||
|
||||
## What it runs on
|
||||
|
||||
Everything below comes from `bsa_attn_blk64_fwd`, not from this backend.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| 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). |
|
||||
| dtype | bfloat16 |
|
||||
| head_dim | 128 |
|
||||
| attention | non-causal, one contiguous sequence per call |
|
||||
|
||||
Inside the DiT, anything the kernel cannot serve — cross attention, the token
|
||||
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.
|
||||
|
||||
## How the score works
|
||||
|
||||
The usual proxy for a 64x64 block is `mean(Q_block) · mean(K_block)`. Averaging
|
||||
64 keys into one vector destroys exactly the variation that decides which keys a
|
||||
query wants. So each block is cut into sub-blocks — `n_k` on the key side, `n_q`
|
||||
on the query side — and every sub-block pair is scored and combined with a
|
||||
log-sum-exp:
|
||||
|
||||
```
|
||||
score(i, j) = log Σ_{a,b} exp( mean(Q_{i,a}) · mean(K_{j,b}) · softmax_scale )
|
||||
```
|
||||
|
||||
which estimates the block's un-normalised softmax mass directly — the quantity
|
||||
that says how much is lost by skipping the block.
|
||||
|
||||
Splitting the query side *alone* is worse than not splitting: a block's mass sums
|
||||
over its query rows, so with one key vector to score against the query detail
|
||||
averages out. Splitting both together is a different proposition, and the only
|
||||
estimator change in this family that has held up end to end. `router.py` carries
|
||||
the recall table behind `n_q = n_k = 4` and the record of what was tried and
|
||||
rejected.
|
||||
|
||||
## Configuration
|
||||
|
||||
| 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_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 |
|
||||
|
||||
**`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.
|
||||
|
||||
**`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
|
||||
score is the log-sum-exp over all `n_q * n_k` sub-block pairs. Raising them
|
||||
sharpens the estimate of which blocks carry mass, at `n_q * n_k` times the score
|
||||
matrix — 0.5% of denoise time at the default, so cost is not the constraint.
|
||||
Raise `n_q` and `n_k` **together**: splitting the query side alone is worse than
|
||||
not splitting at all.
|
||||
|
||||
**The two schedule cutoffs are asymmetric on purpose.** `skip_first_steps` keeps
|
||||
the leading denoise forwards dense; those steps settle the layout of the sample
|
||||
and visibly re-frame the shot when approximated — lowering it from 10 to 5 halves
|
||||
cosine against the dense render. Depth does not behave that way, so
|
||||
`skip_first_layers` defaults to 0 and every DiT layer is sparse. Do not lower
|
||||
`skip_first_steps` without looking at the output.
|
||||
|
||||
**`min_seq_len` is a floor, not a tuning knob.** Below it the whole call runs
|
||||
dense, and in packed varlen batches the test is per document, so H3's padding
|
||||
tail goes dense while the 37.7k-token media document is routed. Two things break
|
||||
down on short sequences: the router is four fixed Triton launches against an
|
||||
attention cost that falls as S², so the overhead stops paying for itself; and the
|
||||
budget goes coarse — 4096 keys is only 64 blocks, and at 1024 keys the
|
||||
multiple-of-8 floor already keeps half of them. 4096 sits well below any real
|
||||
video sequence and well above where either effect bites; it was chosen on that
|
||||
reasoning rather than from a measured threshold sweep.
|
||||
|
||||
## Measured
|
||||
|
||||
MiniMax-H3 t2va, 1344x768 / 5 s / 50 steps, 8x B200, Ulysses-8, bf16, at the
|
||||
shipped defaults (152 of 590 key blocks per query block). All arms in one session
|
||||
on one node, cold sample dropped; spread within an arm is under 0.07 s.
|
||||
|
||||
| | DiT denoise | vs dense |
|
||||
| --- | ---: | ---: |
|
||||
| dense (FlashAttention) | 18.270 s | 1.000x |
|
||||
| SubBlock sparse | 16.061 s | **1.138x** |
|
||||
| SubBlock sparse + [flashinfer#4397][fi] | 15.012 s | **1.217x** |
|
||||
|
||||
[flashinfer-ai/flashinfer#4397][fi] rebuilds the kernel's internal Q/K/V tile
|
||||
layout in one pass instead of three. It is bit-identical and **not required**:
|
||||
worth 1.070x on its own.
|
||||
|
||||
[fi]: https://github.com/flashinfer-ai/flashinfer/pull/4397
|
||||
|
||||
Sparsity is the speed lever and it saturates — 0.75 gives 1.136x, 0.80 gives
|
||||
1.178x, 0.85 gives 1.211x. Cutting the budget 40% past 0.75 buys 6%, because at
|
||||
37.7k tokens attention is no longer the bulk of the step, and 0.85 rendered worst
|
||||
of the three on cosine against dense. `n_k` moves the denoise time by 0.3% across
|
||||
its whole range: it is a quality knob, not a speed one.
|
||||
|
||||
**The speedup is bounded by sequence length, not by the method.** The same config
|
||||
measured 1.13x at 37.7k tokens, 1.20x at 52k and 1.47x at 96k — the backend only
|
||||
touches attention, and attention's share of the DiT grows with S. Treat 1.2x as
|
||||
the 768p/5 s number, not the ceiling.
|
||||
|
||||
The same effect shows up in the sequence-parallel degree, since that sets how
|
||||
much of the sequence each GPU holds: on 4x B200 at Ulysses-4 the identical
|
||||
config gives **1.168x** on denoise and **1.138x** end to end, against 1.138x on
|
||||
denoise at Ulysses-8.
|
||||
|
||||
Peak memory is unchanged (99,356 vs 99,358 MiB/GPU): block sparsity saves
|
||||
compute, not activations, and the `[B,H,Gq,Gk]` score matrix is ~20 MB at
|
||||
S=37.7k. Absolute times are node-specific; only ratios measured in one session
|
||||
are comparable.
|
||||
|
||||
## Files
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `router.py` | `SubBlockRouter` — pooling, scoring, selection, `RoutingPlan` |
|
||||
| `kernels.py` | Triton pooling / segmented-LSE / fused top-k |
|
||||
| `../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.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""SubBlock -- training-free block-sparse attention routing for video DiTs.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from .router import SubBlockRouter, load_bsa_attn_blk64_fwd
|
||||
|
||||
__all__ = ["SubBlockRouter", "load_bsa_attn_blk64_fwd"]
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fused sub-block score kernel: bf16 GEMM + segmented log-sum-exp in one pass.
|
||||
|
||||
score[i, j] = log2 sum_{b < n_k} 2 ** ( qbar_i . kbar_{j,b} )
|
||||
|
||||
The PyTorch router materialises ``[B, H, Gq, Gk*n_k]`` fp32 (254 MB at S=96k, n_k=4),
|
||||
reduces it, and throws it away -- the GEMM is 4% of its time, the rest is that tensor's
|
||||
round trip to HBM. Here the reduction happens in registers before anything is written, so
|
||||
only ``[B, H, Gq, Gk]`` ever reaches memory: n_k times less traffic.
|
||||
|
||||
exp2/log2 are used internally (they are the hardware instructions; the caller folds
|
||||
``softmax_scale * log2(e)`` into Q), and the result is converted back to natural-log units
|
||||
so it matches the reference implementation exactly, not just up to ranking.
|
||||
|
||||
Padding: sub-cells are ordered, and validity is monotone, so a single ``n_valid`` scalar
|
||||
(the number of key sub-cells holding at least one real token) is enough -- everything at or
|
||||
past it is forced to -inf so it can never win a slot.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
import triton
|
||||
import triton.language as tl
|
||||
|
||||
_NEG = tl.constexpr(-1.0e30) # Triton only lets @jit read constexpr globals
|
||||
_LN2 = tl.constexpr(0.6931471805599453)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _score_kernel(
|
||||
Q,
|
||||
K,
|
||||
O,
|
||||
stride_qm,
|
||||
stride_ql,
|
||||
stride_kn,
|
||||
stride_kl,
|
||||
stride_om,
|
||||
stride_on,
|
||||
stride_ol,
|
||||
M,
|
||||
M_VALID,
|
||||
N_VALID,
|
||||
NOUT,
|
||||
MOUT,
|
||||
BLK_M: tl.constexpr,
|
||||
BLK_N: tl.constexpr,
|
||||
NK: tl.constexpr,
|
||||
NQR: tl.constexpr,
|
||||
D: tl.constexpr,
|
||||
):
|
||||
pid_m = tl.program_id(0)
|
||||
pid_n = tl.program_id(1)
|
||||
pid_l = tl.program_id(2)
|
||||
|
||||
offs_m = pid_m * BLK_M + tl.arange(0, BLK_M)
|
||||
offs_n = pid_n * BLK_N + tl.arange(0, BLK_N)
|
||||
offs_d = tl.arange(0, D)
|
||||
|
||||
q = tl.load(
|
||||
Q + pid_l * stride_ql + offs_m[:, None] * stride_qm + offs_d[None, :],
|
||||
mask=offs_m[:, None] < M,
|
||||
other=0.0,
|
||||
)
|
||||
k = tl.load(
|
||||
K + pid_l * stride_kl + offs_n[:, None] * stride_kn + offs_d[None, :],
|
||||
mask=offs_n[:, None] < N_VALID,
|
||||
other=0.0,
|
||||
)
|
||||
acc = tl.dot(q, tl.trans(k), out_dtype=tl.float32) # [BLK_M, BLK_N]
|
||||
|
||||
# a sub-cell past the last real one must not contribute to its group's log-sum-exp
|
||||
acc = tl.where(offs_n[None, :] < N_VALID, acc, _NEG)
|
||||
# same on the query side: with NQ > 1 the last query block can own sub-cells that
|
||||
# are entirely padding, and those pool to zero -- an exp2(0) = 1 term that would
|
||||
# otherwise be folded into the block's score.
|
||||
acc = tl.where(offs_m[:, None] < M_VALID, acc, _NEG)
|
||||
|
||||
acc = tl.reshape(acc, (BLK_M, BLK_N // NK, NK))
|
||||
m = tl.max(acc, axis=2)
|
||||
s = tl.sum(tl.exp2(acc - m[:, :, None]), axis=2)
|
||||
lse = m + tl.log2(s)
|
||||
lse = tl.where(m > _NEG / 2, lse, _NEG) # whole group was padding
|
||||
|
||||
# Fold the NQR query sub-cells of a query block together. Log-sum-exp is
|
||||
# associative, so reducing NK then NQ is the same one log-sum-exp over all
|
||||
# NQ*NK sub-block pairs -- and two stages keeps both reductions on an axis
|
||||
# that is already contiguous in registers.
|
||||
if NQR > 1:
|
||||
lse = tl.reshape(lse, (BLK_M // NQR, NQR, BLK_N // NK))
|
||||
m2 = tl.max(lse, axis=1)
|
||||
s2 = tl.sum(tl.exp2(lse - m2[:, None, :]), axis=1)
|
||||
lse = tl.where(m2 > _NEG / 2, m2 + tl.log2(s2), _NEG)
|
||||
|
||||
# exp2/log2 internally (they map to the hardware instructions), then back to natural
|
||||
# log units so the fused and reference backends return the same numbers, not just the
|
||||
# same ranking. One multiply in registers.
|
||||
out = lse * _LN2
|
||||
out = out.to(O.dtype.element_ty) # bf16 halves what the selection step has to read
|
||||
|
||||
offs_o = pid_n * (BLK_N // NK) + tl.arange(0, BLK_N // NK)
|
||||
offs_q = pid_m * (BLK_M // NQR) + tl.arange(0, BLK_M // NQR)
|
||||
tl.store(
|
||||
O
|
||||
+ pid_l * stride_ol
|
||||
+ offs_q[:, None] * stride_om
|
||||
+ offs_o[None, :] * stride_on,
|
||||
out,
|
||||
mask=(offs_q[:, None] < MOUT) & (offs_o[None, :] < NOUT),
|
||||
)
|
||||
|
||||
|
||||
BLK_M = BLK_N = 128 # score tile; must hold whole blocks, so a multiple of n_q and n_k
|
||||
|
||||
|
||||
def fused_scores(qp, kp, out, *, n_k, n_valid, n_q, m_valid):
|
||||
"""qp: [L, Gq*n_q, D] bf16 (already carrying softmax_scale*log2e),
|
||||
kp: [L, Gk*n_k, D] bf16 -> out: [L, Gq, Gk], natural-log scores.
|
||||
|
||||
The n_q query sub-cells of a query block are folded together by log-sum-exp.
|
||||
|
||||
``n_valid`` / ``m_valid`` are the counts of key / query sub-cells holding at
|
||||
least one real token; the rest pooled to zero and must not contribute.
|
||||
"""
|
||||
L, M, D = qp.shape
|
||||
N = kp.shape[1]
|
||||
Mout, Nout = out.shape[1], out.shape[2]
|
||||
grid = (triton.cdiv(M, BLK_M), triton.cdiv(N, BLK_N), L)
|
||||
_score_kernel[grid](
|
||||
qp,
|
||||
kp,
|
||||
out,
|
||||
qp.stride(1),
|
||||
qp.stride(0),
|
||||
kp.stride(1),
|
||||
kp.stride(0),
|
||||
out.stride(1),
|
||||
out.stride(2),
|
||||
out.stride(0),
|
||||
M,
|
||||
m_valid,
|
||||
n_valid,
|
||||
Nout,
|
||||
Mout,
|
||||
BLK_M=BLK_M,
|
||||
BLK_N=BLK_N,
|
||||
NK=n_k,
|
||||
NQR=n_q,
|
||||
D=D,
|
||||
num_warps=4,
|
||||
num_stages=3,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _pool_kernel(
|
||||
X,
|
||||
Y,
|
||||
stride_xb,
|
||||
stride_xt,
|
||||
stride_xh,
|
||||
stride_yl,
|
||||
stride_yn,
|
||||
S,
|
||||
H,
|
||||
SUB: tl.constexpr,
|
||||
D: tl.constexpr,
|
||||
SCALE,
|
||||
):
|
||||
"""[B, S, H, D] -> [B*H, n_cells, D]: masked mean of every SUB consecutive tokens.
|
||||
|
||||
Fused so the activation is read once and the pooled result is written straight in
|
||||
bf16; the PyTorch version needs an fp32 temporary plus a transpose.
|
||||
"""
|
||||
cell = tl.program_id(0)
|
||||
l = tl.program_id(1)
|
||||
b = l // H
|
||||
h = l % H
|
||||
offs_t = cell * SUB + tl.arange(0, SUB)
|
||||
offs_d = tl.arange(0, D)
|
||||
mask = offs_t < S
|
||||
x = tl.load(
|
||||
X
|
||||
+ b * stride_xb
|
||||
+ offs_t[:, None] * stride_xt
|
||||
+ h * stride_xh
|
||||
+ offs_d[None, :],
|
||||
mask=mask[:, None],
|
||||
other=0.0,
|
||||
).to(tl.float32)
|
||||
cnt = tl.sum(mask.to(tl.float32), axis=0)
|
||||
acc = tl.sum(x, axis=0) / tl.maximum(cnt, 1.0) * SCALE
|
||||
tl.store(Y + l * stride_yl + cell * stride_yn + offs_d, acc.to(tl.bfloat16))
|
||||
|
||||
|
||||
def fused_pool(x, n_cells, sub, out, scale=1.0):
|
||||
"""x: [B, S, H, D] bf16 -> out: [B*H, n_cells, D] bf16"""
|
||||
B, S, H, D = x.shape
|
||||
_pool_kernel[(n_cells, B * H)](
|
||||
x,
|
||||
out,
|
||||
x.stride(0),
|
||||
x.stride(1),
|
||||
x.stride(2),
|
||||
out.stride(0),
|
||||
out.stride(1),
|
||||
S,
|
||||
H,
|
||||
SUB=sub,
|
||||
D=D,
|
||||
SCALE=scale,
|
||||
# one warp, not four: the tile is only SUB x 128, so extra warps buy no parallelism
|
||||
# and cost scheduling. Measured 0.272 -> 0.074 ms at S=96k, 1.27 -> 4.7 TB/s.
|
||||
num_warps=1,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _topk_kernel(S, OUT, G, K, BLK: tl.constexpr, ITERS: tl.constexpr):
|
||||
"""Exact-enough per-row top-K in a single pass over global memory.
|
||||
|
||||
A score row is only Gk values -- 3 KB in bf16 at S=96k -- so it fits in registers. Load
|
||||
it once, then do everything on chip. torch.topk instead makes several passes over the
|
||||
whole matrix, which is why it costs half the router.
|
||||
|
||||
The threshold search interpolates on the count rather than halving the interval: the
|
||||
count-vs-threshold curve is the row's empirical CDF, so a secant step lands far closer
|
||||
than a bisection step. Full-row reductions are what this kernel pays for, so fewer
|
||||
steps is the whole game.
|
||||
|
||||
The invariant is count(s >= lo) >= K, so the compaction can only over-fill, never
|
||||
under-fill; `pos < K` truncates the boundary group. At ITERS=12 about 1.6% of rows end
|
||||
up with a different set than an exact top-K, but only among blocks that tie at the
|
||||
threshold -- the total selected score differs by ~1e-6 relative, which is nothing.
|
||||
"""
|
||||
row = tl.program_id(0)
|
||||
offs = tl.arange(0, BLK)
|
||||
m = offs < G
|
||||
s = tl.load(S + row * G + offs, mask=m, other=-float("inf")).to(tl.float32)
|
||||
lo = tl.min(tl.where(m, s, float("inf")))
|
||||
hi = tl.max(tl.where(m, s, -float("inf"))) + 1.0
|
||||
clo = tl.sum(m.to(tl.int32), axis=0).to(tl.float32)
|
||||
chi = 0.0
|
||||
for _ in tl.static_range(ITERS):
|
||||
den = clo - chi
|
||||
t = (clo - K) / tl.where(den > 0.5, den, 1.0)
|
||||
t = tl.minimum(tl.maximum(t, 0.05), 0.95) # keep the step inside the bracket
|
||||
mid = lo + (hi - lo) * t
|
||||
cnt = tl.sum(((s >= mid) & m).to(tl.int32), axis=0).to(tl.float32)
|
||||
take = cnt >= K
|
||||
lo = tl.where(take, mid, lo)
|
||||
clo = tl.where(take, cnt, clo)
|
||||
hi = tl.where(take, hi, mid)
|
||||
chi = tl.where(take, chi, cnt)
|
||||
sel = (s >= lo) & m
|
||||
pos = tl.cumsum(sel.to(tl.int32), axis=0) - 1
|
||||
tl.store(OUT + row * K + pos, offs.to(tl.int32), mask=sel & (pos < K))
|
||||
|
||||
|
||||
def topk_iters(G, k):
|
||||
"""Threshold-search steps needed to match ``torch.topk``.
|
||||
|
||||
The search interpolates on the *count* above a trial threshold, which assumes the count
|
||||
is linear in the threshold. That holds near the median but not in the tail, where the
|
||||
score density decays roughly exponentially and the secant undershoots -- so the further
|
||||
into the tail k sits, the more steps are needed. Measured on 21 real files as the
|
||||
fraction of query rows selecting a different block set than exact top-k:
|
||||
|
||||
log2(G/k) 1.0 2.1 3.3 4.3 5.6 (sparsity .50 .76 .90 .95 .98)
|
||||
16 steps 0.09% 0.14% 1.62% 5.17% 1.88%
|
||||
24 steps 0.02% 0.01% 0.14% 0.61% 0.21%
|
||||
32 steps 0.02% 0.01% 0.02% 0.19% 0.05%
|
||||
|
||||
A flat 16 is fine at the usual operating points and silently wrong past sparsity 0.9
|
||||
(+3.2% relative L2 at 0.90, +7.1% at 0.95). These cutoffs hold every regime under 0.2%.
|
||||
Interpolating on log(count) instead linearises the tail and does fix sparsity >= 0.95,
|
||||
but it is far worse where the tail model does not apply (27% differing rows at sparsity
|
||||
0.5), so the step count is the robust knob, not the model.
|
||||
"""
|
||||
L = math.log2(max(G, 1) / max(k, 1))
|
||||
return 16 if L <= 2.5 else 24 if L <= 3.5 else 32
|
||||
|
||||
|
||||
def fused_topk(scores2d, k):
|
||||
"""scores2d: [rows, G] contiguous -> [rows, k] int32 column ids (unsorted)."""
|
||||
rows, G = scores2d.shape
|
||||
out = torch.empty(rows, k, dtype=torch.int32, device=scores2d.device)
|
||||
_topk_kernel[(rows,)](
|
||||
scores2d,
|
||||
out,
|
||||
G,
|
||||
k,
|
||||
BLK=triton.next_power_of_2(G),
|
||||
ITERS=topk_iters(G, k),
|
||||
num_warps=4 if G >= 1024 else 2, # short rows do not fill four warps
|
||||
)
|
||||
return out
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Sub-block block-sparse routing for FlashInfer's ``bsa_attn_blk64_fwd``.
|
||||
|
||||
Training-free. Runs *before* attention, produces the ``q2k_block_index`` tensor the
|
||||
64-token block-sparse kernel consumes.
|
||||
|
||||
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:
|
||||
|
||||
score(i, j) = log sum_{a<n_q, b<n_k} exp( qbar_{i,a} . kbar_{j,b} * softmax_scale )
|
||||
|
||||
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:
|
||||
|
||||
n_q=1 n_k=1 .6513 4 u <- plain avg pooling
|
||||
n_q=1 n_k=2 .6598 8 u
|
||||
n_q=1 n_k=4 .6655 16 u
|
||||
n_q=1 n_k=8 .6697 32 u
|
||||
n_q=8 n_k=1 .6494 32 u <- splitting Q *alone* is worse than not splitting
|
||||
n_q=8 n_k=8 .6793 256 u <- but splitting both is the best of them
|
||||
oracle .7355 -
|
||||
|
||||
(1 u = one ``[S/128, 128] x [128, S/128]`` GEMM = 1/16384 of the dense attention it gates.)
|
||||
|
||||
Splitting Q alone loses: a block's mass sums over its query rows, so with one key vector
|
||||
to score against, the query detail averages out. Splitting both together is a different
|
||||
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.
|
||||
|
||||
Not worth retrying without new evidence
|
||||
---------------------------------------
|
||||
Summing un-normalised sub-block mass over the query axis lets the hottest query sub-block
|
||||
own a block's score, and the true per-row attention carries a ``1/Z_r`` the raw sum drops,
|
||||
which over-weights exactly the rows whose attention is spread widest -- the rows that lose
|
||||
least from dropping any one block. Turning each query sub-block into a distribution over
|
||||
key blocks first fixes that, and on 1092 real (cell, head, query block) samples at
|
||||
n_q=n_k=4, sparsity 0.9 it measured better on both proxies: block mass recall .6741 ->
|
||||
.6779 and relative L2 of the rebuilt attention output .2043 -> .1982, paired t = +8.0 and
|
||||
-5.7.
|
||||
|
||||
It is worse in the pixels, on **0 of 15** prompts, by 0.107 cosine against the dense render
|
||||
(paired t = -6.4). Single-layer output error, even measured directly, does not order these
|
||||
estimators the way 40 denoise steps through 50 layers do. Nothing short of an end-to-end
|
||||
render has predicted this correctly yet -- neither block mass recall nor single-step output
|
||||
L2.
|
||||
|
||||
Worth trying, not yet exposed
|
||||
-----------------------------
|
||||
A per-head budget beats any estimator upgrade measured here: at a fixed mean sparsity,
|
||||
spending more blocks on diffuse heads and fewer on peaked ones lifts 5th-percentile mass
|
||||
recall from .52 to .90. It needs a rule for setting the per-head split, which nothing in
|
||||
the pipeline currently produces.
|
||||
|
||||
Usage
|
||||
-----
|
||||
router = SubBlockRouter(n_k=4, n_q=4)
|
||||
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),
|
||||
q2k_block_nums=None)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import importlib.util
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
import torch
|
||||
|
||||
from .kernels import fused_pool, fused_scores, fused_topk
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def load_bsa_attn_blk64_fwd():
|
||||
"""FlashInfer's 64-block sparse attention entry point.
|
||||
|
||||
``flashinfer.cute_dsl.sparse.__init__`` also pulls in the blk128 CuTe-DSL backend,
|
||||
which breaks in ways blk64 does not care about: it hard-requires the ``quack``
|
||||
package, and it tracks a moving ``cutlass.cute`` API (0.6.15.post1 raises
|
||||
``AttributeError: module 'cutlass.cute.core' has no attribute 'ThrMma'``). blk64 is
|
||||
plain CUDA and needs none of it, so whatever the package import trips over we load
|
||||
``bsa_attn_blk64.py`` under a synthetic parent package instead -- same file, same
|
||||
kernel. If blk64 itself is broken or absent, that load raises and the caller sees it.
|
||||
"""
|
||||
try:
|
||||
from flashinfer.cute_dsl.sparse import bsa_attn_blk64_fwd
|
||||
|
||||
return bsa_attn_blk64_fwd
|
||||
except Exception:
|
||||
pass
|
||||
import flashinfer
|
||||
|
||||
base = Path(flashinfer.__file__).resolve().parent / "cute_dsl" / "sparse"
|
||||
pkg = "_flashinfer_sparse_blk64_only"
|
||||
|
||||
def _load(name: str, path: Path, is_pkg: bool):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name,
|
||||
path,
|
||||
submodule_search_locations=[str(path.parent)] if is_pkg else None,
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
if pkg not in sys.modules:
|
||||
parent = types.ModuleType(pkg)
|
||||
parent.__path__ = [str(base)]
|
||||
sys.modules[pkg] = parent
|
||||
_load(f"{pkg}.blk64", base / "blk64" / "__init__.py", True)
|
||||
mod = _load(f"{pkg}.bsa_attn_blk64", base / "bsa_attn_blk64.py", False)
|
||||
return mod.bsa_attn_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
|
||||
|
||||
|
||||
def _snap_up_to_8(topk: int, num_blocks: int) -> int:
|
||||
"""Round a block budget up to what the kernel is going to charge for anyway.
|
||||
|
||||
``bsa_attn_blk64_fwd`` pads each query row's block count up to a multiple of
|
||||
``BUDGET_GRANULARITY`` with phantom slots that repeat the last real block and
|
||||
are then masked out of the softmax. Asking for 148 blocks therefore costs
|
||||
exactly what 152 costs, with four of the slots computed and thrown away.
|
||||
Measured at S=37.7k on B200, 2 prompts in one session: 152 blocks take
|
||||
16.055 s against 16.061 s for 148, and 120 take 15.490 s against 15.496 s
|
||||
for 118 -- free, inside the noise. So take the blocks.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class RoutingPlan(msgspec.Struct, frozen=True):
|
||||
"""What the kernel needs, plus the budget that produced it."""
|
||||
|
||||
index: torch.Tensor # [B, H, Gq, topk] int32
|
||||
topk: int # key blocks kept per query block
|
||||
num_blocks: int # key blocks available
|
||||
|
||||
@property
|
||||
def density(self) -> float:
|
||||
return self.topk / self.num_blocks
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
the diagonal changed relative L2 by 0.2% and the sink only helped in DiT layers 2-32,
|
||||
which did not survive to the pixels.
|
||||
"""
|
||||
|
||||
def __init__(self, n_k: int = 4, n_q: int = 4) -> 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}"
|
||||
)
|
||||
self.n_k, self.n_q = n_k, n_q
|
||||
|
||||
@torch.no_grad()
|
||||
def scores(
|
||||
self, q: torch.Tensor, k: torch.Tensor, softmax_scale: float
|
||||
) -> torch.Tensor:
|
||||
"""``[B, S, H, D] -> [B, H, Gq, Gk]`` block scores (log-space, higher = keep).
|
||||
|
||||
Two Triton kernels: pool, then GEMM + segmented log-sum-exp in registers, so the
|
||||
``[B, H, Gq*n_q, Gk*n_k]`` intermediate never reaches memory.
|
||||
|
||||
``softmax_scale * log2(e)`` is folded into Q so the kernel can use the exp2/log2
|
||||
hardware instructions; it multiplies by ln 2 on the way out, so scores come back
|
||||
in natural-log units. Selection is a top-k and any monotone rescale leaves that
|
||||
alone, so the units only matter to a reader of the magnitudes.
|
||||
|
||||
The scores stay **float32**. bf16 would halve what selection reads, but with 8
|
||||
mantissa bits many blocks tie exactly at the threshold and the fused selector
|
||||
breaks ties by column index -- which systematically prefers early key blocks, one
|
||||
region of the video. Measured +3.9% relative L2 at S=96k.
|
||||
"""
|
||||
b, s, h, d = q.shape
|
||||
sk = k.shape[1]
|
||||
gq, gk = -(-s // BLOCK), -(-sk // BLOCK)
|
||||
nq, nk = self.n_q, self.n_k
|
||||
sub_q, sub_k = BLOCK // nq, BLOCK // 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.
|
||||
# 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.
|
||||
pooled_q = torch.empty(b * h, gq * nq, d, device=q.device, dtype=torch.bfloat16)
|
||||
pooled_k = torch.empty(b * h, gk * nk, d, device=k.device, dtype=torch.bfloat16)
|
||||
fused_pool(q, gq * nq, sub_q, pooled_q, scale=softmax_scale * LOG2E)
|
||||
fused_pool(k, gk * nk, sub_k, pooled_k)
|
||||
|
||||
out = torch.empty(b * h, gq, gk, device=q.device, dtype=torch.float32)
|
||||
fused_scores(
|
||||
pooled_q,
|
||||
pooled_k,
|
||||
out,
|
||||
n_k=nk,
|
||||
n_valid=-(-sk // sub_k),
|
||||
n_q=nq,
|
||||
m_valid=-(-s // sub_q),
|
||||
)
|
||||
return out.view(b, h, gq, gk)
|
||||
|
||||
@torch.no_grad()
|
||||
def route(
|
||||
self, q: torch.Tensor, k: torch.Tensor, sparsity: float, softmax_scale: float
|
||||
) -> RoutingPlan:
|
||||
"""Select the top ``(1 - sparsity)`` fraction of key blocks per query block."""
|
||||
b, s, h, d = q.shape
|
||||
gk = -(-k.shape[1] // BLOCK)
|
||||
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)
|
||||
# One pass over the score matrix instead of torch.topk's several; the kernel
|
||||
# accepts the blocks in any order, so nothing sorts them.
|
||||
index = fused_topk(scores.reshape(-1, gk), topk).view(b, h, gq, topk)
|
||||
return RoutingPlan(index=index, topk=topk, num_blocks=gk)
|
||||
|
||||
@staticmethod
|
||||
def block_sizes(seq_len: int, device) -> torch.Tensor:
|
||||
"""Real token count per 64-block, for the kernel's tail masking."""
|
||||
g = -(-seq_len // BLOCK)
|
||||
start = torch.arange(g, device=device, dtype=torch.int32) * BLOCK
|
||||
return (seq_len - start).clamp(0, BLOCK).to(torch.int32)
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
dense attention for them. Depth turns out not to matter the same way, which is
|
||||
why the layer cutoff defaults to zero -- see the defaults below. The schedule
|
||||
is configured through ``--attention-backend-config``, which overrides
|
||||
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.
|
||||
|
||||
``--attention-backend`` reaches every component, and the text encoder admits
|
||||
only fa / torch_sdpa / sage_attn_3, so pair it with
|
||||
``--component-attention-backends text_encoder=fa``; see the README.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
|
||||
AttentionBackend,
|
||||
AttentionImpl,
|
||||
AttentionMetadata,
|
||||
AttentionMetadataBuilder,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import (
|
||||
SubBlockRouter,
|
||||
load_bsa_attn_blk64_fwd,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.forward_context import get_forward_context
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
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_HEAD_DIM = 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
|
||||
# tokens, 0.75 gives 1.14x, 0.80 gives 1.18x and 0.85 gives 1.21x -- cutting the
|
||||
# block budget by 40% past 0.75 buys 6%, because attention is no longer the bulk
|
||||
# of the step. 0.85 was the worst arm on cosine-vs-dense on both clips rendered
|
||||
# across all three grades, and 0.80 costs 0.017 / 0.006 cos_c against 0.75 on
|
||||
# those same two clips for 3.5% of the time, so the default takes the quality.
|
||||
DEFAULT_SPARSITY = 0.75
|
||||
# The two cutoffs were swept independently on MiniMax-H3 t2va (1344x768, 5 s,
|
||||
# 50 steps, n_k=4, sparsity 0.75) and behave nothing alike. Lowering the step
|
||||
# cutoff from 10 to 5 halves cosine-vs-dense (0.558 -> 0.310 on two clips) and
|
||||
# visibly re-frames the shot; going to 0 leaves the sample essentially
|
||||
# uncorrelated with dense for 1.20x -> 1.30x. Lowering the layer cutoff from 2
|
||||
# to 0 costs 0.0013 of that cosine -- inside the 0.02 run-to-run noise floor --
|
||||
# and is worth ~1%, so the first DiT blocks get no special treatment.
|
||||
DEFAULT_SKIP_FIRST_STEPS = 10
|
||||
DEFAULT_SKIP_FIRST_LAYERS = 0
|
||||
DEFAULT_N_K = 4
|
||||
# Query-side splitting. Splitting Q *alone* is worse than not splitting -- with
|
||||
# one key vector to score against, the query detail averages out -- which is
|
||||
# where the "n_q is worthless" reading came from. Splitting both sides together
|
||||
# is a different estimator: the log-sum-exp then runs over query-key sub-block
|
||||
# pairs. It is the only estimator change in this family that has reproduced end
|
||||
# to end, and it costs 0.5% of the denoise time. Measured against n_q=1 on
|
||||
# fifteen t2va prompts, every arm rendered in one session against that session's
|
||||
# own dense render, as cosine of the decoded video:
|
||||
# sparsity 0.90 +0.062 paired t = +2.6 better on 13/15
|
||||
# sparsity 0.75 +0.008 paired t = +2.1 better on 10/15
|
||||
# The margin shrinks as the budget loosens, which is the pattern every estimator
|
||||
# comparison here has followed: at the shipped 148 of 590 blocks the rules mostly
|
||||
# agree on what to keep.
|
||||
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
|
||||
|
||||
# ``blocks.<idx>.attn`` is a DiT layer; ``token_refiner.blocks.<idx>.attn`` and
|
||||
# anything else is not and stays dense.
|
||||
_DIT_LAYER_PREFIX = re.compile(r"^blocks\.(\d+)\.")
|
||||
|
||||
|
||||
def _dit_layer_index(prefix: str) -> int | None:
|
||||
match = _DIT_LAYER_PREFIX.match(prefix)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _cached_block_sizes(seq_len: int, device: torch.device) -> torch.Tensor:
|
||||
"""Per-block real token counts; identical for every layer and step.
|
||||
|
||||
Rebuilding it per call costs an arange plus a clamp launch on the critical
|
||||
path for a tensor that only depends on the sequence length.
|
||||
"""
|
||||
return SubBlockRouter.block_sizes(seq_len, device)
|
||||
|
||||
|
||||
class SubBlockSparseAttentionBackend(AttentionBackend):
|
||||
|
||||
@staticmethod
|
||||
def get_supported_head_sizes() -> list[int]:
|
||||
return [SUBBLOCK_SPARSE_HEAD_DIM]
|
||||
|
||||
@staticmethod
|
||||
def get_enum() -> AttentionBackendEnum:
|
||||
return AttentionBackendEnum.SUBBLOCK_SPARSE_ATTN
|
||||
|
||||
@staticmethod
|
||||
def get_impl_cls() -> type[SubBlockSparseAttentionImpl]:
|
||||
return SubBlockSparseAttentionImpl
|
||||
|
||||
@staticmethod
|
||||
def get_metadata_cls() -> type[SubBlockSparseAttentionMetadata]:
|
||||
return SubBlockSparseAttentionMetadata
|
||||
|
||||
@staticmethod
|
||||
def get_builder_cls() -> type[SubBlockSparseAttentionMetadataBuilder]:
|
||||
return SubBlockSparseAttentionMetadataBuilder
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubBlockSparseAttentionMetadata(AttentionMetadata):
|
||||
current_timestep: int
|
||||
|
||||
|
||||
class SubBlockSparseAttentionMetadataBuilder(AttentionMetadataBuilder):
|
||||
# The base class declares __init__ abstract, so a builder that does not
|
||||
# override it cannot be instantiated at all.
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def prepare(self) -> None:
|
||||
pass
|
||||
|
||||
def build( # type: ignore[override]
|
||||
self, current_timestep: int, **kwargs: dict[str, Any]
|
||||
) -> SubBlockSparseAttentionMetadata:
|
||||
return SubBlockSparseAttentionMetadata(current_timestep=current_timestep)
|
||||
|
||||
|
||||
class SubBlockSparseSchedule(msgspec.Struct, frozen=True):
|
||||
"""When sparsity is allowed to apply, and how much of it."""
|
||||
|
||||
sparsity: float
|
||||
skip_first_steps: int
|
||||
skip_first_layers: int
|
||||
n_k: int
|
||||
n_q: int
|
||||
min_seq_len: int
|
||||
|
||||
@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 {}
|
||||
schedule = SubBlockSparseSchedule(
|
||||
sparsity=float(config.get("sparsity", DEFAULT_SPARSITY)),
|
||||
skip_first_steps=int(
|
||||
config.get("skip_first_steps", DEFAULT_SKIP_FIRST_STEPS)
|
||||
),
|
||||
skip_first_layers=int(
|
||||
config.get("skip_first_layers", DEFAULT_SKIP_FIRST_LAYERS)
|
||||
),
|
||||
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)),
|
||||
)
|
||||
if not 0.0 <= schedule.sparsity < 1.0:
|
||||
raise ValueError(
|
||||
f"subblock sparsity must be in [0, 1), got {schedule.sparsity}"
|
||||
)
|
||||
for name, value in (("n_k", schedule.n_k), ("n_q", schedule.n_q)):
|
||||
if value not in (1, 2, 4, 8):
|
||||
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")
|
||||
return schedule
|
||||
|
||||
|
||||
class SubBlockSparseAttentionImpl(AttentionImpl):
|
||||
"""Block-sparse attention with a dense fallback for the excluded region.
|
||||
|
||||
One impl instance is built per attention module, so ``prefix`` fixes the
|
||||
layer for the lifetime of the object; only the denoise step varies per
|
||||
call and it comes from the forward context.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
causal: bool = False,
|
||||
softmax_scale: float | None = None,
|
||||
num_kv_heads: int | None = None,
|
||||
prefix: str = "",
|
||||
**extra_impl_args,
|
||||
) -> None:
|
||||
self.prefix = prefix
|
||||
self.num_heads = num_heads
|
||||
self.head_size = head_size
|
||||
self.causal = causal
|
||||
self.softmax_scale = (
|
||||
softmax_scale if softmax_scale is not None else head_size**-0.5
|
||||
)
|
||||
self.num_kv_heads = num_kv_heads if num_kv_heads is not None else num_heads
|
||||
|
||||
self.schedule = SubBlockSparseSchedule.from_server_args()
|
||||
self.layer_idx = _dit_layer_index(prefix)
|
||||
# A layer outside the DiT stack (token refiner, cross attention) never
|
||||
# runs sparse: its sequences are short and its budget meaningless.
|
||||
self.layer_enabled = (
|
||||
self.layer_idx is not None
|
||||
and self.layer_idx >= self.schedule.skip_first_layers
|
||||
and head_size == SUBBLOCK_SPARSE_HEAD_DIM
|
||||
and self.schedule.sparsity > 0.0
|
||||
)
|
||||
self.router = (
|
||||
SubBlockRouter(n_k=self.schedule.n_k, n_q=self.schedule.n_q)
|
||||
if self.layer_enabled
|
||||
else None
|
||||
)
|
||||
self.dense_impl = self._build_dense_impl(causal=causal)
|
||||
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"{self.schedule.skip_first_steps} denoise steps and the first "
|
||||
f"{self.schedule.skip_first_layers} DiT layers"
|
||||
)
|
||||
|
||||
def _build_dense_impl(self, *, causal: bool) -> AttentionImpl:
|
||||
"""Flash attention, used wherever the schedule excludes sparsity."""
|
||||
from sglang.multimodal_gen.runtime.layers.attention.selector import (
|
||||
get_attn_backend,
|
||||
)
|
||||
|
||||
backend = get_attn_backend(
|
||||
self.head_size,
|
||||
torch.bfloat16,
|
||||
supported_attention_backends={
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
},
|
||||
selected_attention_backend=AttentionBackendEnum.FA,
|
||||
)
|
||||
return backend.get_impl_cls()(
|
||||
num_heads=self.num_heads,
|
||||
head_size=self.head_size,
|
||||
causal=causal,
|
||||
softmax_scale=self.softmax_scale,
|
||||
num_kv_heads=self.num_kv_heads,
|
||||
prefix=f"{self.prefix}.dense",
|
||||
)
|
||||
|
||||
def _step_enabled(self) -> bool:
|
||||
return get_forward_context().current_timestep >= self.schedule.skip_first_steps
|
||||
|
||||
def _sparse_ready(self, q: torch.Tensor, k: torch.Tensor) -> bool:
|
||||
return (
|
||||
self.layer_enabled
|
||||
and self._step_enabled()
|
||||
and q.dtype == torch.bfloat16
|
||||
and k.shape[-3] >= self.schedule.min_seq_len
|
||||
and not self.causal
|
||||
)
|
||||
|
||||
def _sparse_attention(
|
||||
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
|
||||
)
|
||||
# Proof that the sparse path actually ran, with the shape it ran on --
|
||||
# the construction-time log above only says the layer was eligible.
|
||||
logger.info_once(
|
||||
f"SubBlock sparse attention active: S={k.shape[1]} heads={q.shape[2]} "
|
||||
f"keeping {plan.topk}/{plan.num_blocks} key blocks per query block "
|
||||
f"(sparsity {1 - plan.density:.4f})"
|
||||
)
|
||||
out = bsa_attn_blk64_fwd(
|
||||
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,
|
||||
)
|
||||
return out[0] if isinstance(out, tuple) else out
|
||||
|
||||
def forward(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
attn_metadata: SubBlockSparseAttentionMetadata | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""query/key/value: ``[B, S, H, D]``."""
|
||||
if not self._sparse_ready(query, key):
|
||||
return self.dense_impl.forward(query, key, value, attn_metadata)
|
||||
return self._sparse_attention(query, key, value)
|
||||
|
||||
def forward_varlen(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens: torch.Tensor,
|
||||
max_seqlen: int,
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Packed ``[T, H, D]`` rows split into documents by ``cu_seqlens``.
|
||||
|
||||
The block-sparse kernel takes one contiguous sequence, so each packed
|
||||
document is routed on its own. Documents shorter than ``min_seq_len``
|
||||
-- in MiniMax H3 the padding tail -- go through the dense kernel.
|
||||
"""
|
||||
|
||||
def all_dense() -> torch.Tensor:
|
||||
return self.dense_impl.forward_varlen(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
cu_seqlens=cu_seqlens,
|
||||
max_seqlen=max_seqlen,
|
||||
cu_seqlens_host=cu_seqlens_host,
|
||||
)
|
||||
|
||||
if cu_seqlens_host is None or not self._sparse_ready(query, key):
|
||||
return all_dense()
|
||||
|
||||
segments = [
|
||||
(start, stop)
|
||||
for start, stop in zip(cu_seqlens_host[:-1], cu_seqlens_host[1:])
|
||||
if stop > start
|
||||
]
|
||||
sparse_segments = {
|
||||
(start, stop)
|
||||
for start, stop in segments
|
||||
if stop - start >= self.schedule.min_seq_len
|
||||
}
|
||||
if not sparse_segments:
|
||||
return all_dense()
|
||||
|
||||
out = torch.empty_like(query)
|
||||
# cu_seqlens covers every packed row in practice; a caller that leaves
|
||||
# a tail outside the last document would otherwise read uninitialized
|
||||
# memory back out.
|
||||
if segments[-1][1] < query.shape[0]:
|
||||
out[segments[-1][1] :].zero_()
|
||||
for start, stop in segments:
|
||||
# Deliberately not `.contiguous()`. After the Ulysses all-to-all,
|
||||
# q/k/v are last-dim slices of one packed buffer, so they are
|
||||
# strided; both the block-sparse kernel and SDPA permute them
|
||||
# anyway, and forcing contiguity here measured as a wasted
|
||||
# full-tensor copy (0.46 ms per call at S=37.7k on B200).
|
||||
q_seg = query[start:stop].unsqueeze(0)
|
||||
k_seg = key[start:stop].unsqueeze(0)
|
||||
v_seg = value[start:stop].unsqueeze(0)
|
||||
if (start, stop) in sparse_segments:
|
||||
seg_out = self._sparse_attention(q_seg, k_seg, v_seg)
|
||||
else:
|
||||
seg_out = self._dense_segment(q_seg, k_seg, v_seg)
|
||||
out[start:stop] = seg_out[0]
|
||||
return out
|
||||
|
||||
def _dense_segment(
|
||||
self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Dense attention over one packed document, ``[1, S, H, D]``."""
|
||||
return torch.nn.functional.scaled_dot_product_attention(
|
||||
q.transpose(1, 2),
|
||||
k.transpose(1, 2),
|
||||
v.transpose(1, 2),
|
||||
is_causal=self.causal,
|
||||
scale=self.softmax_scale,
|
||||
).transpose(1, 2)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SubBlockSparseAttentionBackend",
|
||||
"SubBlockSparseAttentionImpl",
|
||||
"SubBlockSparseAttentionMetadata",
|
||||
"SubBlockSparseAttentionMetadataBuilder",
|
||||
"SubBlockSparseSchedule",
|
||||
]
|
||||
@@ -297,6 +297,44 @@ class _VMOBAAttentionBackendResolver(_CudaAttentionBackendResolver):
|
||||
raise ImportError("Video MoBA Attention backend is not installed. ") from e
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, platform) -> str:
|
||||
capability = platform.get_device_capability()
|
||||
if capability is None or capability != cls.required_capability:
|
||||
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}."
|
||||
)
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
class _FlashAttention2BackendResolver(_CudaAttentionBackendResolver):
|
||||
backend = AttentionBackendEnum.FA2
|
||||
|
||||
@@ -338,6 +376,7 @@ _CUDA_ATTENTION_BACKEND_RESOLVERS = {
|
||||
_SparseVideoGen2AttentionBackendResolver,
|
||||
_SolAttnBackendResolver,
|
||||
_VMOBAAttentionBackendResolver,
|
||||
_SubBlockSparseAttentionBackendResolver,
|
||||
_FlashAttention2BackendResolver,
|
||||
_FlashAttentionBackendResolver,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ class AttentionBackendEnum(enum.Enum):
|
||||
BLOCK_SPARSE_ATTN = enum.auto()
|
||||
RAIN_FUSION_ATTN = enum.auto()
|
||||
SOL_ATTN = enum.auto()
|
||||
SUBBLOCK_SPARSE_ATTN = enum.auto()
|
||||
NO_ATTENTION = enum.auto()
|
||||
|
||||
def __str__(self):
|
||||
@@ -62,6 +63,7 @@ class AttentionBackendEnum(enum.Enum):
|
||||
AttentionBackendEnum.BLOCK_SPARSE_ATTN,
|
||||
AttentionBackendEnum.RAIN_FUSION_ATTN,
|
||||
AttentionBackendEnum.SOL_ATTN,
|
||||
AttentionBackendEnum.SUBBLOCK_SPARSE_ATTN,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# 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
|
||||
FlashInfer's ``bsa_attn_blk64_fwd`` and are skipped otherwise.
|
||||
|
||||
The trick that makes the sparse kernel checkable against dense attention: at
|
||||
``sparsity`` just above 0 every block is inside the budget, so the block-sparse
|
||||
result must reproduce dense attention up to bf16 rounding. That covers the
|
||||
routing indices, the tail block sizes, and the softmax scale in one assertion,
|
||||
none of which an accuracy-only comparison at real sparsity would pin down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse.router import (
|
||||
_snap_up_to_8,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn import (
|
||||
SubBlockSparseAttentionBackend,
|
||||
SubBlockSparseAttentionImpl,
|
||||
SubBlockSparseSchedule,
|
||||
_dit_layer_index,
|
||||
)
|
||||
|
||||
HEAD_DIM = 128
|
||||
NUM_HEADS = 4
|
||||
|
||||
|
||||
def _sm100_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
|
||||
try:
|
||||
from sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse import (
|
||||
load_bsa_attn_blk64_fwd,
|
||||
)
|
||||
|
||||
load_bsa_attn_blk64_fwd()
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
requires_sm100 = unittest.skipUnless(
|
||||
_sm100_available(), "needs SM100 and FlashInfer bsa_attn_blk64_fwd"
|
||||
)
|
||||
|
||||
|
||||
class _FakeServerArgs:
|
||||
def __init__(self, config):
|
||||
self.attention_backend_config = config
|
||||
|
||||
|
||||
def _patch_schedule(config):
|
||||
return patch(
|
||||
"sglang.multimodal_gen.runtime.server_args.get_global_server_args",
|
||||
return_value=_FakeServerArgs(config),
|
||||
)
|
||||
|
||||
|
||||
def _patch_step(step: int):
|
||||
class _Ctx:
|
||||
current_timestep = step
|
||||
|
||||
return patch(
|
||||
"sglang.multimodal_gen.runtime.layers.attention.backends.subblock_sparse_attn.get_forward_context",
|
||||
return_value=_Ctx(),
|
||||
)
|
||||
|
||||
|
||||
def _dense_reference(
|
||||
q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, scale: float
|
||||
) -> torch.Tensor:
|
||||
"""q, k, v: [1, S, H, D]."""
|
||||
return torch.nn.functional.scaled_dot_product_attention(
|
||||
q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2), scale=scale
|
||||
).transpose(1, 2)
|
||||
|
||||
|
||||
def _structured_qkv(
|
||||
seq_len: int, device: torch.device, n_topic: int = 64, seed: int = 0
|
||||
):
|
||||
"""Attention-like q/k/v: ``[1, S, H, 128]`` bf16.
|
||||
|
||||
Random q/k produces a near-uniform attention map, and under a uniform map
|
||||
no block-selection rule can work -- dropping blocks drops mass wherever you
|
||||
cut. Real video attention concentrates on a limited region of keys, so each
|
||||
query here prefers one narrow key topic (a couple of 64-token blocks), and
|
||||
each topic carries its own value, so attending to the wrong blocks gives a
|
||||
visibly wrong answer instead of the same blurred average.
|
||||
"""
|
||||
gen = torch.Generator(device=device).manual_seed(seed)
|
||||
shape = (n_topic, NUM_HEADS, HEAD_DIM)
|
||||
key_centers = torch.randn(shape, device=device, generator=gen)
|
||||
value_centers = torch.randn(shape, device=device, generator=gen)
|
||||
topic = torch.arange(seq_len, device=device) * n_topic // seq_len
|
||||
|
||||
def _noise(scale: float) -> torch.Tensor:
|
||||
return scale * torch.randn(
|
||||
seq_len, NUM_HEADS, HEAD_DIM, device=device, generator=gen
|
||||
)
|
||||
|
||||
q = key_centers[topic] + _noise(0.3)
|
||||
# the topic a query wants sits half a sequence away, so a rule that simply
|
||||
# keeps the diagonal cannot pass
|
||||
k = key_centers[(topic + n_topic // 2) % n_topic] + _noise(0.3)
|
||||
v = value_centers[topic] + _noise(0.1)
|
||||
return (
|
||||
q[None].to(torch.bfloat16),
|
||||
k[None].to(torch.bfloat16),
|
||||
v[None].to(torch.bfloat16),
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a: torch.Tensor, b: torch.Tensor) -> float:
|
||||
return float(
|
||||
torch.nn.functional.cosine_similarity(
|
||||
a.float().flatten(), b.float().flatten(), dim=0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSubBlockSparseSchedule(unittest.TestCase):
|
||||
def test_dit_layer_index_only_matches_top_level_blocks(self):
|
||||
self.assertEqual(_dit_layer_index("blocks.7.attn"), 7)
|
||||
self.assertEqual(_dit_layer_index("blocks.0.attn"), 0)
|
||||
self.assertIsNone(_dit_layer_index("token_refiner.blocks.1.attn"))
|
||||
self.assertIsNone(_dit_layer_index(""))
|
||||
self.assertIsNone(_dit_layer_index("blocks_extra.3.attn"))
|
||||
|
||||
def test_defaults_when_config_is_empty(self):
|
||||
with _patch_schedule({}):
|
||||
schedule = SubBlockSparseSchedule.from_server_args()
|
||||
self.assertEqual(schedule.sparsity, 0.75)
|
||||
self.assertEqual(schedule.skip_first_steps, 10)
|
||||
# Depth is not protected by default; the early steps are. See the
|
||||
# sweep recorded next to the constants.
|
||||
self.assertEqual(schedule.skip_first_layers, 0)
|
||||
self.assertEqual(schedule.n_k, 4)
|
||||
self.assertEqual(schedule.n_q, 4)
|
||||
|
||||
def test_rejects_out_of_range_values(self):
|
||||
for config in ({"sparsity": 1.0}, {"n_k": 3}, {"skip_first_steps": -1}):
|
||||
with self.subTest(config=config), _patch_schedule(config):
|
||||
with self.assertRaises(ValueError):
|
||||
SubBlockSparseSchedule.from_server_args()
|
||||
|
||||
|
||||
class TestBudgetGranularity(unittest.TestCase):
|
||||
"""The kernel bills in groups of 8 blocks; the budget should collect them."""
|
||||
|
||||
def test_snaps_up_to_the_billed_count(self):
|
||||
for topk, expected in ((148, 152), (118, 120), (1, 8), (0, 8)):
|
||||
with self.subTest(topk=topk):
|
||||
self.assertEqual(_snap_up_to_8(topk, 590), expected)
|
||||
|
||||
def test_never_exceeds_the_blocks_that_exist(self):
|
||||
"""The cap wins over the granularity: 590 blocks means at most 590."""
|
||||
self.assertEqual(_snap_up_to_8(586, 590), 590)
|
||||
self.assertEqual(_snap_up_to_8(3, 5), 5)
|
||||
|
||||
|
||||
class TestSubBlockSparseBackend(unittest.TestCase):
|
||||
def test_the_advertised_builder_can_be_built(self):
|
||||
"""`AttentionMetadataBuilder.__init__` is abstract; a builder that does
|
||||
not override it makes `get_builder_cls()()` a TypeError."""
|
||||
builder = SubBlockSparseAttentionBackend.get_builder_cls()()
|
||||
builder.prepare()
|
||||
metadata = builder.build(current_timestep=7)
|
||||
self.assertIsInstance(
|
||||
metadata, SubBlockSparseAttentionBackend.get_metadata_cls()
|
||||
)
|
||||
self.assertEqual(metadata.current_timestep, 7)
|
||||
|
||||
|
||||
class TestSubBlockGating(unittest.TestCase):
|
||||
"""The schedule must decide sparsity from the layer and the step alone."""
|
||||
|
||||
def _impl(self, prefix: str, **config) -> SubBlockSparseAttentionImpl:
|
||||
with _patch_schedule(config), patch.object(
|
||||
SubBlockSparseAttentionImpl, "_build_dense_impl", return_value=None
|
||||
):
|
||||
return SubBlockSparseAttentionImpl(
|
||||
num_heads=NUM_HEADS,
|
||||
head_size=HEAD_DIM,
|
||||
causal=False,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
def test_skip_first_layers_gates_the_bottom_of_the_stack(self):
|
||||
for prefix, expected in (
|
||||
("blocks.0.attn", False),
|
||||
("blocks.1.attn", False),
|
||||
("blocks.2.attn", True),
|
||||
):
|
||||
with self.subTest(prefix=prefix):
|
||||
impl = self._impl(prefix, skip_first_layers=2)
|
||||
self.assertEqual(impl.layer_enabled, expected)
|
||||
|
||||
def test_token_refiner_is_dense(self):
|
||||
self.assertFalse(self._impl("token_refiner.blocks.0.attn").layer_enabled)
|
||||
|
||||
def test_head_dim_other_than_128_is_dense(self):
|
||||
with _patch_schedule({}), patch.object(
|
||||
SubBlockSparseAttentionImpl, "_build_dense_impl", return_value=None
|
||||
):
|
||||
impl = SubBlockSparseAttentionImpl(
|
||||
num_heads=NUM_HEADS,
|
||||
head_size=64,
|
||||
causal=False,
|
||||
softmax_scale=64**-0.5,
|
||||
prefix="blocks.9.attn",
|
||||
)
|
||||
self.assertFalse(impl.layer_enabled)
|
||||
|
||||
def test_first_steps_are_dense(self):
|
||||
impl = self._impl("blocks.9.attn")
|
||||
for step, expected in ((0, False), (9, False), (10, True), (49, True)):
|
||||
with self.subTest(step=step), _patch_step(step):
|
||||
self.assertEqual(impl._step_enabled(), expected)
|
||||
|
||||
def test_short_sequences_are_dense(self):
|
||||
impl = self._impl("blocks.9.attn")
|
||||
q = torch.empty(1, 1024, NUM_HEADS, HEAD_DIM, dtype=torch.bfloat16)
|
||||
with _patch_step(20):
|
||||
self.assertFalse(impl._sparse_ready(q, q))
|
||||
|
||||
def test_fp32_is_dense(self):
|
||||
impl = self._impl("blocks.9.attn")
|
||||
q = torch.empty(1, 8192, NUM_HEADS, HEAD_DIM, dtype=torch.float32)
|
||||
with _patch_step(20):
|
||||
self.assertFalse(impl._sparse_ready(q, q))
|
||||
|
||||
|
||||
@requires_sm100
|
||||
class TestSubBlockNumerics(unittest.TestCase):
|
||||
seq_len = 8192
|
||||
|
||||
def _impl(self, **config) -> SubBlockSparseAttentionImpl:
|
||||
with _patch_schedule(config):
|
||||
return SubBlockSparseAttentionImpl(
|
||||
num_heads=NUM_HEADS,
|
||||
head_size=HEAD_DIM,
|
||||
causal=False,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
prefix="blocks.9.attn",
|
||||
)
|
||||
|
||||
def test_full_budget_reproduces_dense(self):
|
||||
device = torch.device("cuda")
|
||||
q, k, v = _structured_qkv(self.seq_len, device)
|
||||
impl = self._impl(sparsity=1e-6)
|
||||
with _patch_step(20):
|
||||
out = impl.forward(q, k, v, None)
|
||||
ref = _dense_reference(q, k, v, HEAD_DIM**-0.5)
|
||||
self.assertGreater(_cosine(out, ref), 0.999)
|
||||
|
||||
def test_ragged_tail_reproduces_dense(self):
|
||||
"""A sequence that is not a multiple of the 64-token block."""
|
||||
device = torch.device("cuda")
|
||||
seq_len = self.seq_len + 37
|
||||
q, k, v = _structured_qkv(seq_len, device)
|
||||
impl = self._impl(sparsity=1e-6)
|
||||
with _patch_step(20):
|
||||
out = impl.forward(q, k, v, None)
|
||||
ref = _dense_reference(q, k, v, HEAD_DIM**-0.5)
|
||||
self.assertGreater(_cosine(out, ref), 0.999)
|
||||
|
||||
def test_routing_finds_the_blocks_that_carry_the_mass(self):
|
||||
"""At 0.75 sparsity the router must keep the blocks that matter.
|
||||
|
||||
The random-budget control is the point of this test: with the same
|
||||
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)
|
||||
|
||||
impl = self._impl(sparsity=0.75)
|
||||
with _patch_step(20):
|
||||
routed = impl.forward(q, k, v, None)
|
||||
self.assertTrue(torch.isfinite(routed.float()).all())
|
||||
self.assertGreater(_cosine(routed, ref), 0.99)
|
||||
|
||||
num_blocks = (self.seq_len + 63) // 64
|
||||
topk = impl.router.route(q, k, sparsity=0.75, softmax_scale=HEAD_DIM**-0.5).topk
|
||||
# A random permutation per row, not `randint`: sampling with replacement
|
||||
# would leave the control holding duplicate blocks, so it would attend
|
||||
# fewer distinct blocks than the router at the same budget, and the
|
||||
# repeats would distort the softmax mass on top of that.
|
||||
random_index = (
|
||||
torch.rand(1, NUM_HEADS, num_blocks, num_blocks, device=device)
|
||||
.argsort(dim=-1)[..., :topk]
|
||||
.to(torch.int32)
|
||||
)
|
||||
random_out = load_bsa_attn_blk64_fwd()(
|
||||
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,
|
||||
)
|
||||
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):
|
||||
device = torch.device("cuda")
|
||||
q, k, v = _structured_qkv(self.seq_len, device)
|
||||
impl = self._impl(sparsity=0.75, skip_first_steps=10)
|
||||
with _patch_step(3):
|
||||
skipped = impl.forward(q, k, v, None)
|
||||
dense = impl.dense_impl.forward(q, k, v, None)
|
||||
torch.testing.assert_close(skipped, dense, rtol=0, atol=0)
|
||||
|
||||
def test_varlen_routes_each_document(self):
|
||||
"""Packed [real | padding] layout, exactly MiniMax H3's cu_seqlens."""
|
||||
device = torch.device("cuda")
|
||||
used, total = self.seq_len, self.seq_len + 512
|
||||
q, k, v = _structured_qkv(total, device)
|
||||
q, k, v = q[0], k[0], v[0]
|
||||
cu_host = (0, used, total)
|
||||
cu = torch.tensor(cu_host, dtype=torch.int32, device=device)
|
||||
impl = self._impl(sparsity=1e-6)
|
||||
with _patch_step(20):
|
||||
out = impl.forward_varlen(
|
||||
q, k, v, cu_seqlens=cu, max_seqlen=used, cu_seqlens_host=cu_host
|
||||
)
|
||||
for start, stop in ((0, used), (used, total)):
|
||||
ref = _dense_reference(
|
||||
q[start:stop][None],
|
||||
k[start:stop][None],
|
||||
v[start:stop][None],
|
||||
HEAD_DIM**-0.5,
|
||||
)[0]
|
||||
self.assertGreater(_cosine(out[start:stop], ref), 0.999)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user