Skip to content

Commit a1c75bd

Browse files
alexzmsclaude
andcommitted
[bugfix] make the VSA-256 CuTe backward safe to land
Follow-ups found while reproducing this PR on GB200 (sm_100). video_sparse_attn_h3.py composed the compression branch onto the attention output with an in-place addcmul_. On the CuTe backend that output *is* the tensor FA4's autograd node saved for its backward, so the moment this path has a backward at all, training dies with "one of the variables needed for gradient computation has been modified by an inplace operation ... output 0 of FlashAttnFuncBackward". Same defect the BSHD branch of video_sparse_attn_bshd already fixed here; H3 landed after this branch's base and so never got it. Now out-of-place. The KV-owned backward metadata was built unconditionally. It is a pair of dense [B, H, kv_blocks, q_blocks] int32 index tensors that FA4 keeps alive on its ctx until backward runs, and inference never reads it. Gated on requires_grad: at Wan-14B 720p shape (B=1, H=40, D=128, S=92160, topk 45/360) a no_grad forward drops from 17.23 ms / 1998 MiB transient to 15.92 ms / 1918 MiB. Training keeps the metadata and is unchanged. The aux (lse) return was transposed to [B, S, H]. FA4 hands back [B, H, S] already (interface.py builds lse_shape as (batch, num_head, seqlen_q) when qv is None), which is what the Triton path's aux contract is, so the transpose made the two backends disagree and cost a 14 MiB fp32 copy per call at the shape above. Dropped, and detach happens first now. Tests - gated compression branch (compress_attn_weight is not None) backward: the branch Wan and H3 actually run, and the one the in-place bug lives in. - partially filled Q tiles, and q_len != kv_len; forward had cross coverage, backward had none. - inference forward is bitwise identical to the training forward, pinning the requires_grad gate. - aux is [B, H, S] on both entrypoints. - fastvideo/tests/attention/test_vsa_h3_backward.py covers the H3 backend on both backends and cross-checks CuTe gradients against Triton. - Gradient tolerances tightened from avg_abs 2e-2 / max_rel 0.5 to 1e-3 / 0.25; measured error across every case above is <= 1.2e-4 and <= 0.11. test_vsa_varlen.py seeds the RNG now. It draws every tensor and every variable block size from the global RNG and then asserts max_rel < 0.05, so adding any test that runs before it shifts its inputs and it fails on unlucky data. That is the "unrelated" CI failure reported on this PR: it reproduces in a full-suite run and passes in isolation. Verified on GB200 (sm_100), FA4 82d6441 + nvidia-cutlass-dsl 4.6.0.dev0 + quack-kernels 0.5.3: fastvideo-kernel/tests/ and fastvideo/tests/attention/ give 318 passed, stable across repeated runs. The one remaining failure, test_fa4_quantize_op_fake_matches_real, fails identically on an unmodified main. Reverting either fix above turns the matching new test red. pre-commit (yapf, ruff, codespell) passes on every changed file; the mypy hook only errors on the worktree's directory name, and does so identically on an unmodified main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNe3T7jDpv9N5ur7c4S2oX
1 parent 3239cc6 commit a1c75bd

6 files changed

Lines changed: 347 additions & 110 deletions

File tree

fastvideo-kernel/python/fastvideo_kernel/block_sparse_attn_cute_fwd.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,16 @@ def _build_sparse_tensors(
142142
q_len: int,
143143
q_block_size: int,
144144
kv_block_size: int,
145-
) -> Tuple[object, object]:
146-
"""Build the Q-owned forward and KV-owned backward sparse metadata."""
145+
need_backward: bool,
146+
) -> Tuple[object, object | None]:
147+
"""Build the Q-owned forward and KV-owned backward sparse metadata.
148+
149+
``need_backward`` is False on inference-only calls: the backward metadata
150+
is a pair of dense ``[B, H, kv_blocks, q_blocks]`` int32 index tensors that
151+
FA4 keeps alive on its autograd ctx until backward runs, so building it
152+
when nothing requires grad is pure overhead (~80 MiB per call at Wan-14B
153+
720p shape).
154+
"""
147155
BlockSparseTensorsTorch, _ = _load_fa4_cute()
148156
q_sparse_candidate = _choose_q_sparse_block_size(q_len)
149157
q_sparse_block_size = max(
@@ -174,6 +182,9 @@ def from_maps(full_map: torch.Tensor, mask_map: torch.Tensor) -> object:
174182
sparse_map & kv_partial,
175183
)
176184

185+
if not need_backward:
186+
return forward_sparse_tensors, None
187+
177188
# FA4 backward is KV-owned: for each physical KV tile, list the sparse
178189
# query tiles that selected it. Full and partial KV tiles stay separate
179190
# so the token-level validity mask only runs for padded tiles.
@@ -195,12 +206,14 @@ def _cute_attention(
195206
_, flash_attn_func = _load_fa4_cute()
196207
q_block_size = q_bshd.shape[1] // block_map.shape[2]
197208
kv_block_size = k_bshd.shape[1] // block_map.shape[3]
209+
need_backward = torch.is_grad_enabled() and any(t.requires_grad for t in (q_bshd, k_bshd, v_bshd))
198210
forward_sparse_tensors, backward_sparse_tensors = _build_sparse_tensors(
199211
block_map,
200212
variable_block_sizes,
201213
q_len=q_bshd.shape[1],
202214
q_block_size=q_block_size,
203215
kv_block_size=kv_block_size,
216+
need_backward=need_backward,
204217
)
205218
return flash_attn_func(
206219
q_bshd,
@@ -236,8 +249,10 @@ def block_sparse_attn_cute_fwd(
236249
variable_block_sizes,
237250
)
238251
out = out_bshd.transpose(1, 2).contiguous()
239-
lse_bsh = lse.transpose(1, 2).contiguous().detach()
240-
return out, lse_bsh
252+
# FA4 already returns lse as [B, H, S], matching the Triton path's aux
253+
# contract, so it needs no transpose. Detach before any further op: the
254+
# value is informational and callers never backprop through it.
255+
return out, lse.detach()
241256

242257

243258
def block_sparse_attn_cute_fwd_bshd(
@@ -258,5 +273,5 @@ def block_sparse_attn_cute_fwd_bshd(
258273
block_map,
259274
variable_block_sizes,
260275
)
261-
lse_bsh = lse.transpose(1, 2).contiguous().detach()
262-
return out, lse_bsh
276+
# lse is [B, H, S] regardless of the q/k/v layout; see above.
277+
return out, lse.detach()
Lines changed: 191 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,224 @@
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+
"""
28

39
from __future__ import annotations
410

11+
from typing import Tuple
12+
513
import pytest
614
import torch
715

816
from fastvideo_kernel import video_sparse_attn, video_sparse_attn_bshd
917

1018
from .test_vsa256_triton import _metrics, _torch_vsa256_reference
1119

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+
1229

1330
@pytest.fixture(autouse=True)
1431
def _require_cute_backend(monkeypatch):
1532
pytest.importorskip(
1633
"flash_attn.cute.block_sparsity",
1734
reason="optional FA4 CuTe build (flash_attn.cute) not installed",
1835
)
36+
if not torch.cuda.is_available():
37+
pytest.skip("CUDA is required")
1938
monkeypatch.setenv("FASTVIDEO_VSA_CUTEDSL", "1")
2039
monkeypatch.delenv("FASTVIDEO_VSA_TRITON", raising=False)
2140
monkeypatch.delenv("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", raising=False)
2241

2342

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+
24128
@pytest.mark.cuda
25129
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
26130
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)
29135

30-
torch.manual_seed(42)
31-
device = torch.device("cuda")
32-
dtype = torch.bfloat16
33136

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(),
88187
kv_var,
89188
q_var,
90-
topk_logical,
91-
block_size=(4, 8, 8),
189+
2,
190+
block_size=_BLOCK_SIZE_3D,
92191
compress_attn_weight=None,
93192
)
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()
107193

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)
130196

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

Comments
 (0)