Files
hao-ai-lab-FastVideo/fastvideo-kernel/tests/test_block_sparse_bwd_sm100a.py

279 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Correctness tests for the sm_100a CUDA block-sparse VSA backward, 64- and 128-token blocks.
Reference: fp32 dense attention restricted to the selected blocks, keys past
variable_block_sizes masked to -inf, differentiated with torch autograd on fp32 copies of the
bf16 inputs with loss = (out * grad_o).sum(). The kernel is fed the reference's lse in Triton's
M format (logsumexp * log2e) and the reference output rounded to bf16 as ``o`` -- exactly what
the sm_100a forward hands it in FastVideo. The k2q inversion is done here in torch (a stable
sort) so the tests do not need Triton. Every case runs once per built block size.
Run with: python -m pytest tests/test_block_sparse_bwd_sm100a.py -v
"""
import itertools
import os
import pytest
import torch
from fastvideo_kernel import block_sparse_attn_bwd_sm100a as bwd
HEAD_DIM = 128
LOG2E = 1.4426950408889634
# Per-tensor tolerances on the bf16 outputs against the fp32 reference. Measured over all 15
# blk64 cases on GB200 (2026-09-03, VSA_BWD_TEST_VERBOSE=1): max|diff|/max|ref| up to 3.0e-3 (dq)
# and 5.3e-3 (dk, dv); mean|diff| up to 2.8e-4 against mean|ref| of 0.06-0.12. The bounds below
# leave about 2x (rel max) and 3.5x (mean abs) headroom; the blk128 cases sit in the same range.
REL_MAX_TOL = 1e-2 # max|got - ref| / max|ref|
MEAN_ABS_TOL = 1e-3 # mean|got - ref|
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available() or torch.cuda.get_device_capability() not in {(10, 0), (10, 3)}
or not bwd._HAS_VSA_BWD_SM100A,
reason="requires a data-center Blackwell GPU (sm_100a/sm_103a) and a fastvideo_kernel "
"extension built with block_sparse_sm100a_bwd / block_sparse_sm100a_blk128_bwd",
)
@pytest.fixture(params=bwd.BLOCKS, ids=lambda b: f"blk{b}")
def block(request):
if bwd._BWD_BY_BLOCK.get(request.param) is None:
pytest.skip(f"no {request.param}-token backward in this build")
return request.param
def make_case(block, num_blocks=8, topk=4, heads=4, batch=1, ragged=False, seed=0, kv_pool=None):
"""Random bf16 q/k/v/grad_o plus q2k metadata [B, H, Nq, topk] / [B, H, Nq] and vbs.
``kv_pool`` restricts the kv blocks a q block may select (default: all), which is how the
zero-count case leaves some kv blocks unselected by every q block.
"""
torch.manual_seed(seed)
S = num_blocks * block
shape = (batch, heads, S, HEAD_DIM) if bwd.BHSD else (batch, S, heads, HEAD_DIM)
q, k, v, grad_o = (torch.randn(shape, device="cuda", dtype=torch.bfloat16) for _ in range(4))
pool = torch.arange(num_blocks) if kv_pool is None else torch.as_tensor(list(kv_pool))
assert topk <= pool.numel()
rows = batch * heads * num_blocks
idx = torch.empty((rows, topk), dtype=torch.int32)
for r in range(rows):
idx[r] = pool[torch.randperm(pool.numel())[:topk]].sort().values.to(torch.int32)
idx = idx.view(batch, heads, num_blocks, topk).cuda()
num = torch.full((batch, heads, num_blocks), topk, dtype=torch.int32, device="cuda")
if ragged:
vbs = torch.randint(block // 2, block + 1, (num_blocks, ), dtype=torch.int32,
device="cuda")
else:
vbs = torch.full((num_blocks, ), block, dtype=torch.int32, device="cuda")
return q, k, v, grad_o, idx, num, vbs
def invert_indices_torch(q2k_idx, q2k_num, num_kv_blocks, pad_value=0):
"""k2q from q2k without Triton: a stable sort, so each row lists its q blocks ascending.
Returns (k2q_idx [B, H, num_kv_blocks, Nq] int32, k2q_num [B, H, num_kv_blocks] int32), the
layout fastvideo_kernel.triton_kernels.index.invert_indices produces. Entries past a row's
count hold ``pad_value`` -- a VALID block id, so an over-read fails by wrong values rather
than by luck.
"""
B, H, Nq, Mk = q2k_idx.shape
device = q2k_idx.device
valid = torch.arange(Mk, device=device).view(1, 1, 1, Mk) < q2k_num.view(B, H, Nq, 1)
row = (torch.arange(B * H, device=device).view(B, H, 1, 1) * num_kv_blocks
+ q2k_idx.long())[valid]
qblock = torch.arange(Nq, device=device).view(1, 1, Nq, 1).expand(B, H, Nq, Mk)[valid]
order = torch.sort(row * Nq + qblock).indices
row, qblock = row[order], qblock[order]
counts = torch.bincount(row, minlength=B * H * num_kv_blocks)
starts = torch.cumsum(counts, 0) - counts
slot = torch.arange(row.numel(), device=device) - starts[row]
k2q_idx = torch.full((B * H * num_kv_blocks, Nq), pad_value, dtype=torch.int32,
device=device)
k2q_idx[row, slot] = qblock.to(torch.int32)
return (k2q_idx.view(B, H, num_kv_blocks, Nq),
counts.to(torch.int32).view(B, H, num_kv_blocks))
def reference(block, q, k, v, grad_o, idx, num, vbs):
"""fp32 masked-dense autograd reference.
Returns (o bf16, lse fp32 [B, H, S] in M format, dq, dk, dv fp32) -- o and the grads in
q's layout.
"""
if not bwd.BHSD:
q, k, v, grad_o = (t.transpose(1, 2) for t in (q, k, v, grad_o)) # -> [B, H, S, D]
B, H, S, D = q.shape
num_blocks = vbs.numel()
scale = 1.0 / (D**0.5)
idx, num, vbs = idx.cpu(), num.cpu(), vbs.cpu()
keep = torch.zeros((B, H, S, S), dtype=torch.bool, device=q.device)
for b in range(B):
for h in range(H):
for qb in range(num_blocks):
for j in range(int(num[b, h, qb])):
kb = int(idx[b, h, qb, j])
valid = int(vbs[kb])
keep[b, h, qb * block:(qb + 1) * block, kb * block:kb * block + valid] = True
q32, k32, v32 = (t.detach().float().requires_grad_(True) for t in (q, k, v))
scores = (q32 @ k32.transpose(-1, -2)) * scale
scores = scores.masked_fill(~keep, float("-inf"))
p = torch.softmax(scores, dim=-1)
out = p @ v32
loss = (out * grad_o.float()).sum()
dq, dk, dv = torch.autograd.grad(loss, (q32, k32, v32))
lse = (torch.logsumexp(scores, dim=-1) * LOG2E).detach().contiguous()
o = out.detach().to(torch.bfloat16)
if not bwd.BHSD:
o, dq, dk, dv = (t.transpose(1, 2).contiguous() for t in (o, dq, dk, dv))
return o, lse, dq.detach(), dk.detach(), dv.detach()
def check_close(name, got, ref):
got, ref = got.float(), ref.float()
assert got.shape == ref.shape, f"{name}: shape {tuple(got.shape)} vs {tuple(ref.shape)}"
assert torch.isfinite(got).all(), f"{name}: non-finite values"
diff = (got - ref).abs()
rel_max = diff.max().item() / max(ref.abs().max().item(), 1e-6)
mean_abs = diff.mean().item()
if os.environ.get("VSA_BWD_TEST_VERBOSE"):
print(f"{name}: rel_max={rel_max:.3e} mean_abs={mean_abs:.3e} "
f"mean|ref|={ref.abs().mean().item():.3e}")
assert rel_max <= REL_MAX_TOL and mean_abs <= MEAN_ABS_TOL, (
f"{name}: max|diff|/max|ref| = {rel_max:.3e} (tol {REL_MAX_TOL:.1e}), "
f"mean|diff| = {mean_abs:.3e} (tol {MEAN_ABS_TOL:.1e}), "
f"max|ref| = {ref.abs().max().item():.3e}, mean|ref| = {ref.abs().mean().item():.3e}")
def run_and_compare(block, num_blocks=8, topk=4, heads=4, batch=1, ragged=False, seed=0,
kv_pool=None):
q, k, v, grad_o, idx, num, vbs = make_case(block, num_blocks=num_blocks, topk=topk,
heads=heads, batch=batch, ragged=ragged,
seed=seed, kv_pool=kv_pool)
assert bwd.is_supported(q, vbs)
o, lse, ref_dq, ref_dk, ref_dv = reference(block, q, k, v, grad_o, idx, num, vbs)
k2q_idx, k2q_num = invert_indices_torch(idx, num, num_blocks)
dq, dk, dv = bwd.block_sparse_attn_backward_sm100a_from_k2q(grad_o, q, k, v, o, lse,
k2q_idx, k2q_num, vbs)
torch.cuda.synchronize()
for name, got, ref in (("dq", dq, ref_dq), ("dk", dk, ref_dk), ("dv", dv, ref_dv)):
assert got.dtype == torch.bfloat16, f"{name}: dtype {got.dtype}"
check_close(name, got, ref)
return (dq, dk, dv), (ref_dq, ref_dk, ref_dv), vbs
def _to_bhsd(t):
return t if bwd.BHSD else t.transpose(1, 2)
def test_backward_matches_reference(block):
run_and_compare(block)
def test_batch_two(block):
run_and_compare(block, batch=2)
def test_ragged_block_sizes(block):
"""variable_block_sizes is what FastVideo always passes; padded keys must be masked."""
run_and_compare(block, ragged=True)
def test_ragged_padded_key_rows_are_zero(block):
"""Keys at or past a block's count get P^T = 0 in-kernel, so their dk/dv rows are exactly 0
(Triton's backward stores zeros there as well)."""
(dq, dk, dv), _, vbs = run_and_compare(block, ragged=True, seed=1)
dk, dv = _to_bhsd(dk).float(), _to_bhsd(dv).float()
checked = 0
for kb in range(vbs.numel()):
if int(vbs[kb]) == block:
continue # a full block has no padded rows
rows = slice(kb * block + int(vbs[kb]), (kb + 1) * block)
assert dk[:, :, rows].abs().max().item() == 0.0, f"dk: padded rows of kv block {kb}"
assert dv[:, :, rows].abs().max().item() == 0.0, f"dv: padded rows of kv block {kb}"
checked += 1
assert checked > 0, "the ragged draw produced no padded kv block; change the seed"
@pytest.mark.parametrize("topk", [1, 2, 3, 5, 7])
def test_topk_not_a_multiple_of_the_quad(block, topk):
"""blk64 walks each kv block's q list in quads of 4 (a ragged tail quad must be exact);
blk128 one q block per step (an odd list length must be exact)."""
run_and_compare(block, ragged=True, num_blocks=8, topk=topk)
@pytest.mark.parametrize("num_blocks", [1, 2, 4, 8, 16])
def test_sequence_lengths(block, num_blocks):
if block == 64 and num_blocks % 2:
pytest.skip("blk64 needs an even block count (128-token preprocess)")
run_and_compare(block, ragged=True, num_blocks=num_blocks, topk=min(3, num_blocks))
def test_zero_count_kv_blocks(block):
"""kv blocks no q block selects: their dk/dv rows must come back as exact zeros (the outputs
are empty_like; blk64's preprocess and blk128's main kernel write them), every other block
exact."""
num_blocks = 8
excluded = (2, 5)
pool = [kb for kb in range(num_blocks) if kb not in excluded]
(dq, dk, dv), _, _ = run_and_compare(block, num_blocks=num_blocks, topk=4, ragged=True,
kv_pool=pool)
dk, dv = _to_bhsd(dk).float(), _to_bhsd(dv).float()
for kb in excluded:
rows = slice(kb * block, (kb + 1) * block)
assert dk[:, :, rows].abs().max().item() == 0.0, f"dk: unselected kv block {kb}"
assert dv[:, :, rows].abs().max().item() == 0.0, f"dv: unselected kv block {kb}"
def test_invert_indices_torch_matches_q2k():
"""Guards the test's own k2q builder: every (q, kv) pair appears once, counts add up."""
num_blocks = 8
_, _, _, _, idx, num, _ = make_case(64, num_blocks=num_blocks, topk=3, heads=2, batch=2)
k2q_idx, k2q_num = invert_indices_torch(idx, num, num_blocks)
B, H, Nq, Mk = idx.shape
assert k2q_idx.shape == (B, H, num_blocks, Nq) and k2q_num.shape == (B, H, num_blocks)
assert int(k2q_num.sum()) == B * H * Nq * Mk
for b, h, qb, j in itertools.product(range(B), range(H), range(Nq), range(Mk)):
kb = int(idx[b, h, qb, j])
listed = k2q_idx[b, h, kb, :int(k2q_num[b, h, kb])].tolist()
assert listed.count(qb) == 1
assert listed == sorted(listed)
def test_unsupported_is_rejected(block):
q, k, v, grad_o, idx, num, vbs = make_case(block)
assert not bwd.is_supported(q.float(), vbs) # wrong dtype
assert not bwd.is_supported(q[..., :64].contiguous(), vbs) # wrong head_dim
seven = torch.full((7, ), block, dtype=torch.int32, device="cuda")
assert not bwd.is_supported(q, seven) # seqlen != block * num_blocks
q500 = (q[:, :, :500] if bwd.BHSD else q[:, :500]).contiguous()
assert not bwd.is_supported(q500, vbs) # S not a multiple of the block
if block == 64:
# An odd block count (S % 128 != 0) is refused statically too, so FastVideo falls back
# to Triton instead of tripping the binding's check.
q448 = (q[:, :, :448] if bwd.BHSD else q[:, :448]).contiguous()
assert not bwd.is_supported(q448, seven)
# batch * heads is the launch grid's y extent: 65536 (batch, head) pairs are refused.
wide = (1, 65536, block, q.shape[-1]) if bwd.BHSD else (1, block, 65536, q.shape[-1])
q_wide = torch.empty(wide, dtype=torch.bfloat16, device="cuda")
one = torch.full((1, ), block, dtype=torch.int32, device="cuda")
assert not bwd.is_supported(q_wide, one)
del q_wide
# The binding itself refuses bad dtypes before touching the GPU.
k2q_idx, k2q_num = invert_indices_torch(idx, num, vbs.numel())
o = torch.zeros_like(q)
heads = q.shape[1] if bwd.BHSD else q.shape[2]
lse = torch.zeros((q.shape[0], heads, vbs.numel() * block), dtype=torch.float32,
device="cuda")
with pytest.raises(RuntimeError):
bwd.block_sparse_attn_backward_sm100a_from_k2q(grad_o.float(), q.float(), k.float(),
v.float(), o.float(), lse, k2q_idx,
k2q_num, vbs)