|
1 | | -"""VSA-256 FA4 CuTe forward/backward parity for BHSD and BSHD APIs.""" |
| 1 | +"""VSA-256 FA4 CuTe forward/backward parity for BHSD and BSHD APIs. |
| 2 | +
|
| 3 | +Covers the shapes the CuTe backward actually sees in production: the gated |
| 4 | +compression branch (`compress_attn_weight`), partially filled Q tiles, |
| 5 | +and q_len != kv_len. Also pins the inference fast path, which must skip the |
| 6 | +KV-owned backward metadata without changing the forward result. |
| 7 | +""" |
2 | 8 |
|
3 | 9 | from __future__ import annotations |
4 | 10 |
|
| 11 | +from typing import Tuple |
| 12 | + |
5 | 13 | import pytest |
6 | 14 | import torch |
7 | 15 |
|
8 | 16 | from fastvideo_kernel import video_sparse_attn, video_sparse_attn_bshd |
9 | 17 |
|
10 | 18 | from .test_vsa256_triton import _metrics, _torch_vsa256_reference |
11 | 19 |
|
| 20 | +_BLOCK = 256 |
| 21 | +_BLOCK_SIZE_3D = (4, 8, 8) # prod == 256 |
| 22 | + |
| 23 | +# Measured on GB200 (sm_100) with bf16 inputs: grads land around 1e-4 avg_abs |
| 24 | +# and <=0.11 max_rel across every case below, so these leave ~10x headroom |
| 25 | +# without being loose enough to hide a real regression. |
| 26 | +_OUT_TOL = (1e-3, 0.2) |
| 27 | +_GRAD_TOL = (1e-3, 0.25) |
| 28 | + |
12 | 29 |
|
13 | 30 | @pytest.fixture(autouse=True) |
14 | 31 | def _require_cute_backend(monkeypatch): |
15 | 32 | pytest.importorskip( |
16 | 33 | "flash_attn.cute.block_sparsity", |
17 | 34 | reason="optional FA4 CuTe build (flash_attn.cute) not installed", |
18 | 35 | ) |
| 36 | + if not torch.cuda.is_available(): |
| 37 | + pytest.skip("CUDA is required") |
19 | 38 | monkeypatch.setenv("FASTVIDEO_VSA_CUTEDSL", "1") |
20 | 39 | monkeypatch.delenv("FASTVIDEO_VSA_TRITON", raising=False) |
21 | 40 | monkeypatch.delenv("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", raising=False) |
22 | 41 |
|
23 | 42 |
|
| 43 | +def _zero_pad_tail(x: torch.Tensor, var: torch.Tensor) -> torch.Tensor: |
| 44 | + """Zero the padded tail of every 256-token tile of a [B, H, S, D] tensor. |
| 45 | +
|
| 46 | + VSA callers scatter into a zeroed tile buffer, so padded slots are zero; |
| 47 | + both the kernel and the reference rely on that. |
| 48 | + """ |
| 49 | + bsz, heads, _, dim = x.shape |
| 50 | + blocks = var.numel() |
| 51 | + token_idx = torch.arange(_BLOCK, device=x.device, dtype=torch.int32) |
| 52 | + valid = (token_idx.view(1, -1) < var.view(-1, 1)).view(1, 1, blocks, _BLOCK, 1) |
| 53 | + valid = valid.expand(bsz, heads, blocks, _BLOCK, dim).reshape_as(x) |
| 54 | + return x * valid.to(x.dtype) |
| 55 | + |
| 56 | + |
| 57 | +def _make_inputs( |
| 58 | + q_blocks: int, |
| 59 | + kv_blocks: int, |
| 60 | + kv_var: torch.Tensor, |
| 61 | + q_var: torch.Tensor, |
| 62 | + heads: int = 2, |
| 63 | + dim: int = 128, |
| 64 | + seed: int = 42, |
| 65 | +) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: |
| 66 | + torch.manual_seed(seed) |
| 67 | + device = torch.device("cuda") |
| 68 | + dtype = torch.bfloat16 |
| 69 | + sq, skv = q_blocks * _BLOCK, kv_blocks * _BLOCK |
| 70 | + q = torch.randn(1, heads, sq, dim, device=device, dtype=dtype) |
| 71 | + k = torch.randn(1, heads, skv, dim, device=device, dtype=dtype) |
| 72 | + v = torch.randn(1, heads, skv, dim, device=device, dtype=dtype) |
| 73 | + grad_out = torch.randn_like(q) |
| 74 | + return _zero_pad_tail(q, q_var), _zero_pad_tail(k, kv_var), _zero_pad_tail(v, kv_var), grad_out |
| 75 | + |
| 76 | + |
| 77 | +def _check(tag: str, ref: torch.Tensor, got: torch.Tensor, tol: Tuple[float, float]) -> None: |
| 78 | + assert torch.isfinite(got).all().item(), f"{tag}: non-finite values" |
| 79 | + avg_abs, max_rel = _metrics(ref, got) |
| 80 | + print(f" {tag}: avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}") |
| 81 | + assert avg_abs < tol[0], f"{tag}: avg_abs {avg_abs:.3e} >= {tol[0]:.3e}" |
| 82 | + assert max_rel < tol[1], f"{tag}: max_rel {max_rel:.3e} >= {tol[1]:.3e}" |
| 83 | + |
| 84 | + |
| 85 | +def _run_bhsd(q, k, v, kv_var, q_var, topk, gate=None): |
| 86 | + qg, kg, vg = (t.detach().clone().requires_grad_(True) for t in (q, k, v)) |
| 87 | + out = video_sparse_attn(qg, kg, vg, kv_var, q_var, topk, block_size=_BLOCK_SIZE_3D, compress_attn_weight=gate) |
| 88 | + return out, (qg, kg, vg) |
| 89 | + |
| 90 | + |
| 91 | +def _run_bshd(q, k, v, kv_var, q_var, topk, gate=None): |
| 92 | + qg, kg, vg = (t.transpose(1, 2).contiguous().requires_grad_(True) for t in (q, k, v)) |
| 93 | + gate_bshd = None if gate is None else gate.transpose(1, 2).contiguous() |
| 94 | + out = video_sparse_attn_bshd(qg, |
| 95 | + kg, |
| 96 | + vg, |
| 97 | + kv_var, |
| 98 | + q_var, |
| 99 | + topk, |
| 100 | + block_size=_BLOCK_SIZE_3D, |
| 101 | + compress_attn_weight=gate_bshd) |
| 102 | + return out.transpose(1, 2), (qg, kg, vg) |
| 103 | + |
| 104 | + |
| 105 | +def _reference(q, k, v, q_var, kv_var, topk, gate=None): |
| 106 | + qr, kr, vr = (t.detach().clone().requires_grad_(True) for t in (q, k, v)) |
| 107 | + out = _torch_vsa256_reference(qr, kr, vr, q_var, kv_var, topk, compress_attn_weight=gate) |
| 108 | + return out, (qr, kr, vr) |
| 109 | + |
| 110 | + |
| 111 | +def _compare(tag, layout, q, k, v, kv_var, q_var, topk, grad_out, gate=None): |
| 112 | + runner = _run_bhsd if layout == "bhsd" else _run_bshd |
| 113 | + out, (qg, kg, vg) = runner(q, k, v, kv_var, q_var, topk, gate=gate) |
| 114 | + (out * grad_out).sum().backward() |
| 115 | + grads = [g.grad if g.grad.dim() == 4 and layout == "bhsd" else g.grad for g in (qg, kg, vg)] |
| 116 | + if layout == "bshd": |
| 117 | + grads = [g.transpose(1, 2) for g in grads] |
| 118 | + |
| 119 | + out_ref, refs = _reference(q, k, v, q_var, kv_var, topk, gate=gate) |
| 120 | + (out_ref * grad_out).sum().backward() |
| 121 | + |
| 122 | + print(f"[{tag}-{layout}]") |
| 123 | + _check("out", out_ref, out, _OUT_TOL) |
| 124 | + for name, ref, got in zip(("dq", "dk", "dv"), refs, grads): |
| 125 | + _check(name, ref.grad, got, _GRAD_TOL) |
| 126 | + |
| 127 | + |
24 | 128 | @pytest.mark.cuda |
25 | 129 | @pytest.mark.parametrize("layout", ["bhsd", "bshd"]) |
26 | 130 | def test_vsa256_cute_forward_backward_vs_torch_ref(layout: str) -> None: |
27 | | - if not torch.cuda.is_available(): |
28 | | - pytest.skip("CUDA is required") |
| 131 | + kv_var = torch.tensor([256, 173, 79, 256], dtype=torch.int32, device="cuda") |
| 132 | + q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda") |
| 133 | + q, k, v, grad_out = _make_inputs(3, 4, kv_var, q_var) |
| 134 | + _compare("vsa256-cute", layout, q, k, v, kv_var, q_var, 2, grad_out) |
29 | 135 |
|
30 | | - torch.manual_seed(42) |
31 | | - device = torch.device("cuda") |
32 | | - dtype = torch.bfloat16 |
33 | 136 |
|
34 | | - bsz, heads, dim = 1, 2, 128 |
35 | | - q_blocks_256, kv_blocks_256 = 3, 4 |
36 | | - q_block = 256 |
37 | | - kv_block = 256 |
38 | | - topk_logical = 2 |
39 | | - sq = q_blocks_256 * q_block |
40 | | - skv = kv_blocks_256 * kv_block |
41 | | - |
42 | | - q_base = torch.randn(bsz, heads, sq, dim, device=device, dtype=dtype) |
43 | | - k_base = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype) |
44 | | - v_base = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype) |
45 | | - grad_out = torch.randn_like(q_base) |
46 | | - |
47 | | - q_var = torch.full( |
48 | | - (q_blocks_256,), q_block, dtype=torch.int32, device=device |
49 | | - ) |
50 | | - kv_var = torch.tensor( |
51 | | - [256, 173, 79, 256], dtype=torch.int32, device=device |
52 | | - ) |
53 | | - token_idx = torch.arange(kv_block, device=device, dtype=torch.int32) |
54 | | - kv_valid = token_idx.view(1, -1) < kv_var.view(-1, 1) |
55 | | - kv_valid = kv_valid.view(1, 1, kv_blocks_256, kv_block, 1) |
56 | | - kv_valid = kv_valid.expand( |
57 | | - bsz, heads, kv_blocks_256, kv_block, dim |
58 | | - ).reshape(bsz, heads, skv, dim) |
59 | | - k_base = k_base * kv_valid.to(k_base.dtype) |
60 | | - v_base = v_base * kv_valid.to(v_base.dtype) |
61 | | - |
62 | | - if layout == "bhsd": |
63 | | - q = q_base.detach().clone().requires_grad_(True) |
64 | | - k = k_base.detach().clone().requires_grad_(True) |
65 | | - v = v_base.detach().clone().requires_grad_(True) |
66 | | - out = video_sparse_attn( |
67 | | - q, |
68 | | - k, |
69 | | - v, |
70 | | - kv_var, |
71 | | - q_var, |
72 | | - topk_logical, |
73 | | - block_size=(4, 8, 8), |
74 | | - compress_attn_weight=None, |
75 | | - ) |
76 | | - (out * grad_out).sum().backward() |
77 | | - out_bhsd = out |
78 | | - dq, dk, dv = q.grad, k.grad, v.grad |
79 | | - else: |
80 | | - q = q_base.transpose(1, 2).contiguous().requires_grad_(True) |
81 | | - k = k_base.transpose(1, 2).contiguous().requires_grad_(True) |
82 | | - v = v_base.transpose(1, 2).contiguous().requires_grad_(True) |
83 | | - grad_out_bshd = grad_out.transpose(1, 2).contiguous() |
84 | | - out = video_sparse_attn_bshd( |
85 | | - q, |
86 | | - k, |
87 | | - v, |
| 137 | +@pytest.mark.cuda |
| 138 | +@pytest.mark.parametrize("layout", ["bhsd", "bshd"]) |
| 139 | +def test_vsa256_cute_backward_with_compress_gate(layout: str) -> None: |
| 140 | + """The gated compression branch is what Wan and MiniMax-H3 actually run. |
| 141 | +
|
| 142 | + It is also the branch that composes the sparse output with the compression |
| 143 | + output, so it is the one that breaks if that composition mutates FA4's |
| 144 | + saved output in place. |
| 145 | + """ |
| 146 | + kv_var = torch.tensor([256, 200, 256, 91], dtype=torch.int32, device="cuda") |
| 147 | + q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda") |
| 148 | + q, k, v, grad_out = _make_inputs(3, 4, kv_var, q_var, seed=7) |
| 149 | + gate = torch.randn_like(q) * 0.1 |
| 150 | + _compare("vsa256-cute-gated", layout, q, k, v, kv_var, q_var, 2, grad_out, gate=gate) |
| 151 | + |
| 152 | + |
| 153 | +@pytest.mark.cuda |
| 154 | +@pytest.mark.parametrize("layout", ["bhsd", "bshd"]) |
| 155 | +def test_vsa256_cute_backward_partial_q_blocks(layout: str) -> None: |
| 156 | + """Q tiles that are not full: only the compression divisor depends on it, |
| 157 | + but it is the one axis the existing coverage held constant.""" |
| 158 | + kv_var = torch.tensor([256, 128, 256], dtype=torch.int32, device="cuda") |
| 159 | + q_var = torch.tensor([256, 61, 199], dtype=torch.int32, device="cuda") |
| 160 | + q, k, v, grad_out = _make_inputs(3, 3, kv_var, q_var, seed=11) |
| 161 | + _compare("vsa256-cute-partial-q", layout, q, k, v, kv_var, q_var, 2, grad_out) |
| 162 | + |
| 163 | + |
| 164 | +@pytest.mark.cuda |
| 165 | +@pytest.mark.parametrize("layout", ["bhsd", "bshd"]) |
| 166 | +def test_vsa256_cute_backward_cross_q_kv(layout: str) -> None: |
| 167 | + """q_len != kv_len: forward has coverage, backward did not.""" |
| 168 | + kv_var = torch.tensor([256, 143, 256, 256, 88], dtype=torch.int32, device="cuda") |
| 169 | + q_var = torch.full((2, ), _BLOCK, dtype=torch.int32, device="cuda") |
| 170 | + q, k, v, grad_out = _make_inputs(2, 5, kv_var, q_var, seed=13) |
| 171 | + _compare("vsa256-cute-cross", layout, q, k, v, kv_var, q_var, 3, grad_out) |
| 172 | + |
| 173 | + |
| 174 | +@pytest.mark.cuda |
| 175 | +def test_vsa256_cute_inference_matches_training_forward() -> None: |
| 176 | + """The KV-owned backward metadata is only built when something requires |
| 177 | + grad. Skipping it must not perturb the forward result.""" |
| 178 | + kv_var = torch.tensor([256, 173, 79, 256], dtype=torch.int32, device="cuda") |
| 179 | + q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda") |
| 180 | + q, k, v, _ = _make_inputs(3, 4, kv_var, q_var, seed=5) |
| 181 | + |
| 182 | + with torch.no_grad(): |
| 183 | + out_infer = video_sparse_attn_bshd( |
| 184 | + q.transpose(1, 2).contiguous(), |
| 185 | + k.transpose(1, 2).contiguous(), |
| 186 | + v.transpose(1, 2).contiguous(), |
88 | 187 | kv_var, |
89 | 188 | q_var, |
90 | | - topk_logical, |
91 | | - block_size=(4, 8, 8), |
| 189 | + 2, |
| 190 | + block_size=_BLOCK_SIZE_3D, |
92 | 191 | compress_attn_weight=None, |
93 | 192 | ) |
94 | | - (out * grad_out_bshd).sum().backward() |
95 | | - out_bhsd = out.transpose(1, 2) |
96 | | - dq = q.grad.transpose(1, 2) |
97 | | - dk = k.grad.transpose(1, 2) |
98 | | - dv = v.grad.transpose(1, 2) |
99 | | - |
100 | | - q_ref = q_base.detach().clone().requires_grad_(True) |
101 | | - k_ref = k_base.detach().clone().requires_grad_(True) |
102 | | - v_ref = v_base.detach().clone().requires_grad_(True) |
103 | | - out_ref = _torch_vsa256_reference( |
104 | | - q_ref, k_ref, v_ref, q_var, kv_var, topk_logical |
105 | | - ) |
106 | | - (out_ref * grad_out).sum().backward() |
107 | 193 |
|
108 | | - tensors = ( |
109 | | - out_bhsd, |
110 | | - dq, |
111 | | - dk, |
112 | | - dv, |
113 | | - q_ref.grad, |
114 | | - k_ref.grad, |
115 | | - v_ref.grad, |
116 | | - ) |
117 | | - assert all(torch.isfinite(t).all().item() for t in tensors) |
118 | | - |
119 | | - m_out = _metrics(out_ref, out_bhsd) |
120 | | - m_dq = _metrics(q_ref.grad, dq) |
121 | | - m_dk = _metrics(k_ref.grad, dk) |
122 | | - m_dv = _metrics(v_ref.grad, dv) |
123 | | - print( |
124 | | - f"[vsa256-cute-{layout}] " |
125 | | - f"out(avg_abs={m_out[0]:.6e}, max_rel={m_out[1]:.6e}), " |
126 | | - f"dq(avg_abs={m_dq[0]:.6e}, max_rel={m_dq[1]:.6e}), " |
127 | | - f"dk(avg_abs={m_dk[0]:.6e}, max_rel={m_dk[1]:.6e}), " |
128 | | - f"dv(avg_abs={m_dv[0]:.6e}, max_rel={m_dv[1]:.6e})" |
129 | | - ) |
| 194 | + out_train, _ = _run_bshd(q, k, v, kv_var, q_var, 2) |
| 195 | + torch.testing.assert_close(out_infer, out_train.transpose(1, 2).detach(), rtol=0, atol=0) |
130 | 196 |
|
131 | | - assert m_out[0] < 1e-3 and m_out[1] < 0.2 |
132 | | - assert m_dq[0] < 2e-2 and m_dq[1] < 0.5 |
133 | | - assert m_dk[0] < 2e-2 and m_dk[1] < 0.5 |
134 | | - assert m_dv[0] < 2e-2 and m_dv[1] < 0.5 |
| 197 | + |
| 198 | +@pytest.mark.cuda |
| 199 | +def test_vsa256_cute_lse_is_bhs() -> None: |
| 200 | + """The aux return is [B, H, S] on both entrypoints, matching the Triton |
| 201 | + path's contract.""" |
| 202 | + from fastvideo_kernel.block_sparse_attn_256 import (block_sparse_attn_256, block_sparse_attn_256_bshd) |
| 203 | + |
| 204 | + device = torch.device("cuda") |
| 205 | + heads, dim, q_blocks, kv_blocks = 2, 128, 3, 4 |
| 206 | + sq, skv = q_blocks * _BLOCK, kv_blocks * _BLOCK |
| 207 | + q = torch.randn(1, heads, sq, dim, device=device, dtype=torch.bfloat16) |
| 208 | + k = torch.randn(1, heads, skv, dim, device=device, dtype=torch.bfloat16) |
| 209 | + v = torch.randn(1, heads, skv, dim, device=device, dtype=torch.bfloat16) |
| 210 | + vbs = torch.full((kv_blocks, ), _BLOCK, dtype=torch.int32, device=device) |
| 211 | + mask = torch.zeros(1, heads, q_blocks, kv_blocks, dtype=torch.bool, device=device) |
| 212 | + mask[..., :2] = True |
| 213 | + |
| 214 | + _, lse_bhsd = block_sparse_attn_256(q, k, v, mask, vbs) |
| 215 | + assert lse_bhsd.shape == (1, heads, sq), lse_bhsd.shape |
| 216 | + |
| 217 | + _, lse_bshd = block_sparse_attn_256_bshd( |
| 218 | + q.transpose(1, 2).contiguous(), |
| 219 | + k.transpose(1, 2).contiguous(), |
| 220 | + v.transpose(1, 2).contiguous(), |
| 221 | + mask, |
| 222 | + vbs, |
| 223 | + ) |
| 224 | + assert lse_bshd.shape == (1, heads, sq), lse_bshd.shape |
0 commit comments