Skip to content

Commit 9975a72

Browse files
authored
CUDA backend: keep wide attention heads that are not a power of 2 off the Triton SDPA kernel (#24427)
### What is wrong today The CUDA backend replaces `scaled_dot_product_attention` with its own Triton kernel when the kernel accepts the call. For head sizes that are not a power of 2, that kernel holds the whole head in one tile of at most 256 values. Wider heads of that kind, such as 288, 320, 384, 640 or 768, are still accepted. The kernel then uses only the first 256 values of each query and key, and it never writes the rest of the output. The program runs and returns wrong numbers, with no error. For example, the mid-block attention of the LingBot-VA video VAE encoder has heads of 640, and its lowered encoder drifts far from eager. ```python q, k, v = (torch.randn(2, 1, 64, 640, device="cuda", dtype=torch.bfloat16) for _ in range(3)) sdpa(q, k, v) # max error against PyTorch: about 4 ``` ### What this change does The shape check that the kernel and the replacement pass share now rejects head sizes that are not a power of 2 and are wider than that one tile. The pass already leaves calls the kernel rejects to the regular CUDA lowering, so such attention in an exported model now gives the right result. Code that calls the Triton kernel directly, such as the off-graph KV lowering, now gets an error instead of wrong numbers. Heads that are a power of 2, including 512 and 1024, use the other Triton kernel, which covers the whole head, so they still use Triton. Because this fixes wrong results, it should also go to `release/1.6`. ### What was tested On an A100 with torch 2.14: - `test_sdpa_head_sizes` lowers attention with heads of 96, 192, 256, 512, 288, 320, 384, 640 and 768, runs it, and compares each result with eager. Without this change the last five fail. - `test_heads_the_kernel_takes_are_supported` checks that the pass still gives heads of 192, 256, 512 and 1024 to the Triton kernel. `test_wide_head_that_is_not_a_power_of_2_is_not_replaced` checks that a head of 640 exports through the regular lowering and logs why. `test_wide_non_pow2_head_rejected` checks that a direct call with a head of 640 raises. - The other Triton SDPA kernel tests and `test_sdpa_splitk_replacement.py` still pass. - At 64 tokens and head 640, one lowered attention call takes about as long as before: 767 µs now, 784 µs with the wrong result. This was measured at 64 tokens only. The regular lowering builds the full score matrix, so for long sequences it can need much more time and memory than the Triton kernel did. - With torch 2.15, the LingBot-VA VAE encoder, lowered with default settings, now matches eager within bfloat16 precision.
1 parent e2bcb9d commit 9975a72

4 files changed

Lines changed: 70 additions & 2 deletions

File tree

‎backends/cuda/tests/test_cuda_export.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,43 @@ def test_sdpa_single_kernel(self):
815815
"SDPA single kernel operation export failed",
816816
)
817817

818+
@unittest.skipIf(
819+
torch.version.hip is not None,
820+
"loads through the Python runtime, which the ROCm build does not include",
821+
)
822+
def test_sdpa_head_sizes(self):
823+
from executorch.runtime import Runtime
824+
825+
class Attention(torch.nn.Module):
826+
def forward(self, *qkv):
827+
return tuple(
828+
torch.nn.functional.scaled_dot_product_attention(*qkv[i : i + 3])
829+
for i in range(0, len(qkv), 3)
830+
)
831+
832+
# Heads that are not a power of 2 and are above 256 are wider than one
833+
# tile of the Triton kernel for such heads.
834+
head_sizes = (96, 192, 256, 512, 288, 320, 384, 640, 768)
835+
inputs = tuple(
836+
torch.randn(1, 2, 64, size, dtype=torch.bfloat16)
837+
for size in head_sizes
838+
for _ in range(3)
839+
)
840+
model = Attention()
841+
edge = self._export_to_cuda_with_lower(model, inputs)
842+
with tempfile.TemporaryDirectory() as directory:
843+
path = os.path.join(directory, "model.pte")
844+
with open(path, "wb") as f:
845+
edge.to_executorch().write_to_file(f)
846+
method = Runtime.get().load_program(path).load_method("forward")
847+
outputs = method.execute(list(inputs))
848+
expected = model(*(x.cuda() for x in inputs))
849+
for size, output, reference in zip(head_sizes, outputs, expected, strict=True):
850+
with self.subTest(head_size=size):
851+
torch.testing.assert_close(
852+
output.cpu(), reference.cpu(), atol=2e-2, rtol=2e-2
853+
)
854+
818855
def test_triton_kernel_mode_off(self):
819856
"""
820857
Test CUDA export with triton_kernel_mode set to OFF for SDPA kernel.

‎backends/cuda/tests/test_sdpa_splitk_replacement.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ def test_value_head_size_other_than_query_is_not_replaced(self):
275275
q, v = _bf16(1, 2, 1, 16), _bf16(1, 2, 256, 32)
276276
self.assertStays(self._logs(q, _bf16(1, 2, 256, 16), v), "Head dimension")
277277

278+
def test_wide_head_that_is_not_a_power_of_2_is_not_replaced(self):
279+
q = _bf16(1, 2, 64, 640)
280+
self.assertStays(self._logs(q, q, q), "not a power of 2")
281+
278282
def test_key_value_batch_1_under_batch_2_is_not_replaced(self):
279283
q, kv = _bf16(2, 2, 1, 16), _bf16(1, 2, 256, 16)
280284
self.assertStays(self._logs(q, kv, kv), "Batch dimension must match")
@@ -320,7 +324,7 @@ def test_decode_with_a_shared_key_value_head_still_uses_splitk(self):
320324

321325

322326
class TestSDPAKernelSupportCheck(unittest.TestCase):
323-
"""Calls that cannot be exported end to end, on hand-built graphs."""
327+
"""Support checks on hand-built graphs, which compile nothing."""
324328

325329
def setUp(self):
326330
if importlib.util.find_spec("triton") is None:
@@ -364,6 +368,19 @@ def test_named_inputs_are_not_supported(self):
364368
node.kwargs = {"query": query, "key": key, "value": value}
365369
self.assertFalse(ReplaceEdgeOpWithTritonOpPass._sdpa_kernel_supports(node))
366370

371+
def test_heads_the_kernel_takes_are_supported(self):
372+
# Some CI GPUs lack the shared memory to compile heads of 1024.
373+
from executorch.backends.cuda.triton.replacement_pass import (
374+
ReplaceEdgeOpWithTritonOpPass,
375+
)
376+
377+
for size in (192, 256, 512, 1024):
378+
with self.subTest(head_size=size):
379+
node = self._sdpa_node((1, 2, 64, size))
380+
self.assertTrue(
381+
ReplaceEdgeOpWithTritonOpPass._sdpa_kernel_supports(node)
382+
)
383+
367384

368385
if __name__ == "__main__":
369386
unittest.main()

‎backends/cuda/tests/test_triton_sdpa.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,12 @@ def test_causal_lq_ne_lkv_rejected(self):
603603
with self.assertRaises(RuntimeError):
604604
self.sdpa(q, k, v, is_causal=True)
605605

606+
def test_wide_non_pow2_head_rejected(self):
607+
"""A non-pow2 head wider than one tile should raise, not be cut short."""
608+
q = torch.randn(1, 2, 64, 640, dtype=torch.bfloat16, device="cuda")
609+
with self.assertRaises(RuntimeError):
610+
self.sdpa(q, q, q)
611+
606612
def test_non_pow2_no_mask(self):
607613
"""Non-pow2 head dim without mask should work (mask_ptr=0 path)."""
608614
B, H, Lq, Lk, D = 1, 4, 4, 64, 40 # D=40 is not pow2

‎backends/cuda/triton/kernels/sdpa.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ def _validate_qkv_shapes(
270270
raise RuntimeError(
271271
f"Head dimension must match across Q, K, V; got D_q={D_q}, D_k={D_k}, D_v={D_v}."
272272
)
273+
# Heads that are not a power of 2 must fit in one tile, and
274+
# _next_power_of_2 caps the tile at 256.
275+
if not _is_power_of_2(D_q) and _next_power_of_2(D_q) < D_q:
276+
raise RuntimeError(
277+
"Head dimensions that are not a power of 2 must be at most "
278+
f"{_next_power_of_2(D_q)}; got D={D_q}."
279+
)
273280
# Key and Value sequence lengths must match
274281
if L_kv_k != L_kv_v:
275282
raise RuntimeError(
@@ -1301,7 +1308,8 @@ def sdpa(
13011308
share K/V loads) based on a tile-utilization heuristic from FlashAttention.
13021309
13031310
Args:
1304-
query: Query tensor [B, H_q, L_q, D], dtype torch.bfloat16
1311+
query: Query tensor [B, H_q, L_q, D], dtype torch.bfloat16. A D that
1312+
is not a power of 2 must be at most 256.
13051313
key: Key tensor [B, H_kv, L_kv, D], dtype torch.bfloat16
13061314
value: Value tensor [B, H_kv, L_kv, D], dtype torch.bfloat16
13071315
attn_mask: Optional bool mask [B, 1, L_q, L_kv] (broadcast over heads)

0 commit comments

Comments
 (0)