Skip to content

Commit 6a0adb0

Browse files
committed
[test]: VSA-256 forward / cross-backend / vbs / triton parity tests
Four GPU correctness tests covering the new VSA-256 path: - test_vsa256_forward.py: CuTe forward vs dense torch reference (qk_equal and qk_diff shapes). - test_vsa256_forward_vbs.py: CuTe forward with variable KV block sizes (<256) vs token-masked torch reference. - test_vsa256_triton.py: Route-A Triton fwd + bwd vs token-masked torch reference (forces FASTVIDEO_VSA_TRITON). - test_vsa256_forward_cross.py: Three-way parity: torch ref vs CuTe vs Triton on the same inputs. All tests skip cleanly when CUDA is unavailable. They exercise the public fastvideo_kernel.video_sparse_attn entrypoint with block_size=(4, 8, 8) and rely on its built-in block_elements=256 dispatch.
1 parent 54c63b3 commit 6a0adb0

4 files changed

Lines changed: 505 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""VSA-256 CuTe forward correctness vs. a dense torch reference."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
from typing import Tuple
7+
8+
import pytest
9+
import torch
10+
11+
from fastvideo_kernel import video_sparse_attn
12+
13+
from .utils import create_full_mask_from_block_mask
14+
15+
16+
def _dense_reference(
17+
q: torch.Tensor,
18+
k: torch.Tensor,
19+
v: torch.Tensor,
20+
full_mask: torch.Tensor,
21+
) -> torch.Tensor:
22+
# q,k,v: [B,H,S,D]; full_mask: [H,Sq,Skv].
23+
qf = q.float()
24+
kf = k.float()
25+
vf = v.float()
26+
attn = torch.matmul(qf, kf.transpose(-2, -1)) / math.sqrt(q.shape[-1])
27+
attn = attn.masked_fill(~full_mask.unsqueeze(0), float("-inf"))
28+
prob = torch.softmax(attn, dim=-1)
29+
return torch.matmul(prob, vf).to(q.dtype)
30+
31+
32+
def _run_case(
33+
heads: int,
34+
head_dim: int,
35+
q_blocks_256: int,
36+
kv_blocks_256: int,
37+
topk_logical: int,
38+
) -> Tuple[float, float]:
39+
assert torch.cuda.is_available()
40+
# Default dispatch routes (4,8,8) -> CuTe; no env var needed.
41+
42+
device = torch.device("cuda")
43+
dtype = torch.bfloat16
44+
batch = 1
45+
q_block = 256
46+
kv_block_logical = 256
47+
48+
sq = q_blocks_256 * q_block
49+
skv = kv_blocks_256 * kv_block_logical
50+
q = torch.randn(batch, heads, sq, head_dim, device=device, dtype=dtype)
51+
k = torch.randn(batch, heads, skv, head_dim, device=device, dtype=dtype)
52+
v = torch.randn(batch, heads, skv, head_dim, device=device, dtype=dtype)
53+
54+
q_var = torch.full((q_blocks_256,), q_block, dtype=torch.int32, device=device)
55+
kv_var = torch.full((kv_blocks_256,), kv_block_logical, dtype=torch.int32, device=device)
56+
57+
# Reproduce the compression branch's per-block average so we can pre-compute
58+
# the top-k mask that the kernel will see and feed it to the dense reference.
59+
q_c = q.view(batch, heads, q_blocks_256, q_block, head_dim)
60+
k_c = k.view(batch, heads, kv_blocks_256, kv_block_logical, head_dim)
61+
v_c = v.view(batch, heads, kv_blocks_256, kv_block_logical, head_dim)
62+
q_c = (q_c.float().sum(dim=3) / q_var.view(1, 1, -1, 1)).to(q.dtype)
63+
k_c = (k_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(k.dtype)
64+
v_c = (v_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(v.dtype)
65+
66+
scores = torch.matmul(q_c, k_c.transpose(-2, -1)) / (head_dim ** 0.5)
67+
attn = torch.softmax(scores, dim=-1)
68+
out_c = torch.matmul(attn, v_c)
69+
out_c = (
70+
out_c.view(batch, heads, q_blocks_256, 1, head_dim)
71+
.repeat(1, 1, 1, q_block, 1)
72+
.view(batch, heads, sq, head_dim)
73+
)
74+
topk_idx = torch.topk(scores, topk_logical, dim=-1).indices
75+
mask_256 = torch.zeros_like(scores, dtype=torch.bool).scatter_(-1, topk_idx, True)[0]
76+
full_mask = create_full_mask_from_block_mask(mask_256, q_var, kv_var, device=device)
77+
out_ref = out_c + _dense_reference(q, k, v, full_mask)
78+
79+
out = video_sparse_attn(
80+
q, k, v,
81+
kv_var,
82+
q_var,
83+
topk_logical,
84+
block_size=(4, 8, 8),
85+
compress_attn_weight=None,
86+
)
87+
88+
assert torch.isfinite(out).all().item(), "NaN/Inf in kernel output"
89+
diff = (out_ref - out).abs()
90+
avg_abs = diff.mean().item()
91+
max_rel = (diff.max() / (out_ref.abs().mean() + 1e-6)).item()
92+
return avg_abs, max_rel
93+
94+
95+
@pytest.mark.cuda
96+
def test_vsa256_forward_qk_equal() -> None:
97+
if not torch.cuda.is_available():
98+
pytest.skip("CUDA is required")
99+
avg_abs, max_rel = _run_case(
100+
heads=8, head_dim=128, q_blocks_256=8, kv_blocks_256=8, topk_logical=2,
101+
)
102+
print(f"[vsa256 qk_equal] avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}")
103+
assert avg_abs < 5e-2
104+
assert max_rel < 2.0
105+
106+
107+
@pytest.mark.cuda
108+
def test_vsa256_forward_qk_diff() -> None:
109+
if not torch.cuda.is_available():
110+
pytest.skip("CUDA is required")
111+
avg_abs, max_rel = _run_case(
112+
heads=8, head_dim=128, q_blocks_256=8, kv_blocks_256=12, topk_logical=2,
113+
)
114+
print(f"[vsa256 qk_diff] avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}")
115+
assert avg_abs < 5e-2
116+
assert max_rel < 2.0
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Three-way parity for the VSA-256 forward: torch reference vs CuTe vs Triton."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
7+
import pytest
8+
import torch
9+
10+
from fastvideo_kernel import video_sparse_attn
11+
12+
13+
def _torch_vsa256_reference(
14+
q: torch.Tensor,
15+
k: torch.Tensor,
16+
v: torch.Tensor,
17+
q_var: torch.Tensor,
18+
kv_var: torch.Tensor,
19+
topk_logical: int,
20+
) -> torch.Tensor:
21+
bsz, heads, _sq, dim = q.shape
22+
q_blocks = q_var.numel()
23+
kv_blocks = kv_var.numel()
24+
q_block = int(q_var[0].item())
25+
kv_block = int(kv_var[0].item())
26+
27+
q_c = q.view(bsz, heads, q_blocks, q_block, dim)
28+
k_c = k.view(bsz, heads, kv_blocks, kv_block, dim)
29+
v_c = v.view(bsz, heads, kv_blocks, kv_block, dim)
30+
q_c = (q_c.float().sum(dim=3) / q_var.view(1, 1, -1, 1)).to(q.dtype)
31+
k_c = (k_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(k.dtype)
32+
v_c = (v_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(v.dtype)
33+
34+
scores = torch.matmul(q_c, k_c.transpose(-2, -1)) / math.sqrt(dim)
35+
attn = torch.softmax(scores, dim=-1)
36+
out_c = torch.matmul(attn, v_c)
37+
out_c = (
38+
out_c.view(bsz, heads, q_blocks, 1, dim)
39+
.repeat(1, 1, 1, q_block, 1)
40+
.view_as(q)
41+
)
42+
43+
topk_idx = torch.topk(scores, topk_logical, dim=-1).indices
44+
block_mask = torch.zeros_like(scores, dtype=torch.bool).scatter_(-1, topk_idx, True)
45+
token_mask = (
46+
block_mask
47+
.repeat_interleave(q_block, dim=2)
48+
.repeat_interleave(kv_block, dim=3)
49+
.to(torch.bool)
50+
)
51+
52+
qf, kf, vf = q.float(), k.float(), v.float()
53+
logits = torch.matmul(qf, kf.transpose(-2, -1)) / math.sqrt(dim)
54+
logits = logits.masked_fill(~token_mask, float("-inf"))
55+
prob = torch.softmax(logits, dim=-1)
56+
out_s = torch.matmul(prob, vf).to(q.dtype)
57+
return out_c + out_s
58+
59+
60+
def _metrics(a: torch.Tensor, b: torch.Tensor) -> tuple[float, float]:
61+
diff = (a - b).abs()
62+
avg_abs = diff.mean().item()
63+
max_rel = (diff.max() / (a.abs().mean() + 1e-6)).item()
64+
return avg_abs, max_rel
65+
66+
67+
@pytest.mark.cuda
68+
def test_vsa256_forward_cross_torch_cute_triton(monkeypatch) -> None:
69+
if not torch.cuda.is_available():
70+
pytest.skip("CUDA is required")
71+
72+
torch.manual_seed(0)
73+
device = torch.device("cuda")
74+
dtype = torch.bfloat16
75+
76+
bsz, heads, dim = 1, 8, 128
77+
q_blocks_256, kv_blocks_256 = 8, 12
78+
topk_logical = 2
79+
q_block = 256
80+
kv_block = 256
81+
82+
sq = q_blocks_256 * q_block
83+
skv = kv_blocks_256 * kv_block
84+
q = torch.randn(bsz, heads, sq, dim, device=device, dtype=dtype)
85+
k = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype)
86+
v = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype)
87+
q_var = torch.full((q_blocks_256,), q_block, dtype=torch.int32, device=device)
88+
kv_var = torch.full((kv_blocks_256,), kv_block, dtype=torch.int32, device=device)
89+
90+
out_torch = _torch_vsa256_reference(q, k, v, q_var, kv_var, topk_logical)
91+
92+
# CuTe (default).
93+
out_cute = video_sparse_attn(
94+
q, k, v,
95+
kv_var, q_var, topk_logical,
96+
block_size=(4, 8, 8),
97+
compress_attn_weight=None,
98+
)
99+
100+
# Triton via route-A 256->64 expansion.
101+
monkeypatch.setenv("FASTVIDEO_VSA_TRITON", "1")
102+
out_triton = video_sparse_attn(
103+
q, k, v,
104+
kv_var, q_var, topk_logical,
105+
block_size=(4, 8, 8),
106+
compress_attn_weight=None,
107+
)
108+
109+
for t in (out_torch, out_cute, out_triton):
110+
assert torch.isfinite(t).all().item()
111+
112+
torch_vs_cute = _metrics(out_torch, out_cute)
113+
torch_vs_triton = _metrics(out_torch, out_triton)
114+
cute_vs_triton = _metrics(out_cute, out_triton)
115+
print(
116+
"[cross-forward] "
117+
f"torch_vs_cute(avg_abs={torch_vs_cute[0]:.6e}, max_rel={torch_vs_cute[1]:.6e}), "
118+
f"torch_vs_triton(avg_abs={torch_vs_triton[0]:.6e}, max_rel={torch_vs_triton[1]:.6e}), "
119+
f"cute_vs_triton(avg_abs={cute_vs_triton[0]:.6e}, max_rel={cute_vs_triton[1]:.6e})"
120+
)
121+
122+
assert torch_vs_cute[0] < 1e-3 and torch_vs_cute[1] < 0.2
123+
assert torch_vs_triton[0] < 1e-3 and torch_vs_triton[1] < 0.2
124+
assert cute_vs_triton[0] < 1e-3 and cute_vs_triton[1] < 0.2
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""VSA-256 CuTe correctness with variable KV block sizes (<256)."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
7+
import pytest
8+
import torch
9+
10+
from fastvideo_kernel import video_sparse_attn
11+
12+
13+
def _torch_vsa256_reference(
14+
q: torch.Tensor,
15+
k: torch.Tensor,
16+
v: torch.Tensor,
17+
q_var: torch.Tensor,
18+
kv_var: torch.Tensor,
19+
topk_logical: int,
20+
) -> torch.Tensor:
21+
bsz, heads, _sq, dim = q.shape
22+
q_blocks = q_var.numel()
23+
kv_blocks = kv_var.numel()
24+
q_block = q.shape[2] // q_blocks
25+
kv_block = k.shape[2] // kv_blocks
26+
27+
q_c = q.view(bsz, heads, q_blocks, q_block, dim)
28+
k_c = k.view(bsz, heads, kv_blocks, kv_block, dim)
29+
v_c = v.view(bsz, heads, kv_blocks, kv_block, dim)
30+
q_c = (q_c.float().sum(dim=3) / q_var.view(1, 1, -1, 1)).to(q.dtype)
31+
k_c = (k_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(k.dtype)
32+
v_c = (v_c.float().sum(dim=3) / kv_var.view(1, 1, -1, 1)).to(v.dtype)
33+
34+
scores = torch.matmul(q_c, k_c.transpose(-2, -1)) / math.sqrt(dim)
35+
attn = torch.softmax(scores, dim=-1)
36+
out_c = torch.matmul(attn, v_c)
37+
out_c = (
38+
out_c.view(bsz, heads, q_blocks, 1, dim)
39+
.repeat(1, 1, 1, q_block, 1)
40+
.view_as(q)
41+
)
42+
43+
with torch.no_grad():
44+
topk_idx = torch.topk(scores.detach(), topk_logical, dim=-1).indices
45+
block_mask = torch.zeros_like(scores, dtype=torch.bool).scatter_(-1, topk_idx, True)
46+
block_token_idx = torch.arange(kv_block, device=kv_var.device, dtype=torch.int32)
47+
kv_token_valid_by_block = (
48+
block_token_idx.view(1, -1) < kv_var.to(torch.int32).view(-1, 1)
49+
).to(torch.bool)
50+
kv_token_valid = kv_token_valid_by_block.reshape(1, 1, 1, kv_blocks * kv_block)
51+
token_mask = (
52+
block_mask
53+
.repeat_interleave(q_block, dim=2)
54+
.repeat_interleave(kv_block, dim=3)
55+
)
56+
token_mask = token_mask & kv_token_valid
57+
58+
qf, kf, vf = q.float(), k.float(), v.float()
59+
logits = torch.matmul(qf, kf.transpose(-2, -1)) / math.sqrt(dim)
60+
logits = logits.masked_fill(~token_mask, float("-inf"))
61+
prob = torch.softmax(logits, dim=-1)
62+
out_s = torch.matmul(prob, vf).to(q.dtype)
63+
return out_c + out_s
64+
65+
66+
@pytest.mark.cuda
67+
def test_vsa256_cute_variable_block_size_vs_torch_ref() -> None:
68+
if not torch.cuda.is_available():
69+
pytest.skip("CUDA is required")
70+
71+
torch.manual_seed(0)
72+
device = torch.device("cuda")
73+
dtype = torch.bfloat16
74+
75+
bsz, heads, dim = 1, 8, 128
76+
q_blocks_256, kv_blocks_256 = 8, 12
77+
q_block = 256
78+
kv_block = 256
79+
topk_logical = 2
80+
sq = q_blocks_256 * q_block
81+
skv = kv_blocks_256 * kv_block
82+
83+
q = torch.randn(bsz, heads, sq, dim, device=device, dtype=dtype)
84+
k = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype)
85+
v = torch.randn(bsz, heads, skv, dim, device=device, dtype=dtype)
86+
87+
q_var = torch.full((q_blocks_256,), q_block, dtype=torch.int32, device=device)
88+
kv_var = torch.randint(16, kv_block + 1, (kv_blocks_256,), dtype=torch.int32, device=device)
89+
token_idx = torch.arange(kv_block, device=device, dtype=torch.int32)
90+
kv_valid = token_idx.view(1, -1) < kv_var.view(-1, 1)
91+
kv_valid = kv_valid.view(1, 1, kv_blocks_256, kv_block, 1)
92+
kv_valid = kv_valid.expand(bsz, heads, kv_blocks_256, kv_block, dim).reshape(bsz, heads, skv, dim)
93+
k = k * kv_valid.to(k.dtype)
94+
v = v * kv_valid.to(v.dtype)
95+
96+
out = video_sparse_attn(
97+
q, k, v,
98+
kv_var,
99+
q_var,
100+
topk_logical,
101+
block_size=(4, 8, 8),
102+
compress_attn_weight=None,
103+
)
104+
out_ref = _torch_vsa256_reference(q, k, v, q_var, kv_var, topk_logical)
105+
106+
diff = (out - out_ref).abs()
107+
avg_abs = diff.mean().item()
108+
max_rel = (diff.max() / (out_ref.abs().mean() + 1e-6)).item()
109+
print(
110+
f"[vsa256-cute-vbs] kv_var[min={int(kv_var.min().item())}, "
111+
f"max={int(kv_var.max().item())}], "
112+
f"avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}"
113+
)
114+
115+
assert avg_abs < 1e-3 and max_rel < 0.2

0 commit comments

Comments
 (0)