Skip to content

Commit 8a08ecd

Browse files
authored
Leave SDPA calls the Triton kernel cannot take to the regular CUDA lowering (pytorch#23349)
## What is wrong today When the CUDA backend lowers a model, `ReplaceEdgeOpWithTritonOpPass` swaps every `scaled_dot_product_attention` call for a Triton SDPA kernel: the standard one, or split-K for long-cache decode. But those kernels reject many calls that PyTorch accepts, and a rejected call fails the whole export. For example: ``` RuntimeError: attn_mask must have dtype torch.bool RuntimeError: Expected bfloat16 inputs RuntimeError: attn_mask shape mismatch: expected [B=1, 1, L_q=8, L_kv=8], got torch.Size([1, 1, 1, 8]) RuntimeError: Head dimension must match across Q, K, V; got D_q=16, D_k=16, D_v=32. RuntimeError: Causal masking requires L_q == L_kv; got L_q=4, L_kv=8. ``` The same happens with dropout, or a key/value batch or head count that only broadcasts. The only way around it today is `triton_kernel_mode="OFF"`, which also gives up the Triton kernels for the calls they do support. ## What this change does The pass still picks the kernel as before. It now runs that kernel's own input checks on the fake tensors first (`_validate_sdpa_inputs`, `_validate_qkv_shapes` and `_prepare_mask_params`, plus the standard kernel's causal length rule), and replaces the call only if they pass. Any other call stays in the graph, and the regular AOTInductor lowering compiles it, as it does with `triton_kernel_mode="OFF"`. The pass logs the kernel's own reason for each skipped call. Because the pass asks the kernels instead of copying their rules, the two cannot drift apart. Calls that were replaced before are still replaced, with the same kernel. Masked decode and causal decode still use split-K. ## What was tested - New export tests in `test_sdpa_splitk_replacement.py` for a float mask, float32 inputs, a broadcast mask, a batch-1 mask under batch 2, dropout, a different value head size, a key/value batch of 1 under batch 2, one shared key/value head without `enable_gqa`, and causal attention with different lengths. Each export now finishes and replaces no node; each fails before this change. Exports with a full boolean mask still replace the call, and masked decode, causal decode and decode with one shared key/value head still use split-K on CUDA. Hand-built graphs cover 2D inputs and query, key and value passed by name. - Removing any one of the kernel checks from the pass makes the matching tests fail. - The existing split-K replacement tests give the same result before and after. - Ran on a Linux aarch64 machine with a CUDA GPU.
1 parent ecf5c39 commit 8a08ecd

3 files changed

Lines changed: 208 additions & 8 deletions

File tree

‎backends/cuda/cuda_backend.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ def get_custom_passes(cls, compile_specs: List[CompileSpec]) -> List[typing.Any]
769769
Return CUDA-specific passes: ReplaceEdgeOpWithTritonOpPass.
770770
771771
The Triton kernel replacement behavior can be controlled via compile_specs:
772-
- triton_kernel_mode="ON": Always use Triton kernels
772+
- triton_kernel_mode="ON": Use Triton kernels for the calls they support; other calls use the regular lowering
773773
- triton_kernel_mode="OFF": Never use Triton kernels and fallback to other implementations like cuda or decomposed operator.
774774
"""
775775
# Parse compile_specs for triton_kernel_mode
@@ -929,8 +929,7 @@ def _combined():
929929
# Force any remaining PyTorch SDPA ops to use the MATH
930930
# backend during compilation so AOTI can lower / decompose
931931
# them. SDPA ops already replaced by Triton kernels via
932-
# `ReplaceEdgeOpWithTritonOpPass` are unaffected; this is
933-
# only the fallback for the `triton_kernel_mode="OFF"` path.
932+
# `ReplaceEdgeOpWithTritonOpPass` are unaffected.
934933
stack.enter_context(torch.nn.attention.sdpa_kernel([SDPBackend.MATH]))
935934
stack.enter_context(target_smem_context())
936935
if low_memory_mode == "ON":

‎backends/cuda/tests/test_sdpa_splitk_replacement.py‎

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
"""Test ReplaceEdgeOpWithTritonOpPass split-K SDPA kernel selection.
7+
"""Test which SDPA calls ReplaceEdgeOpWithTritonOpPass replaces, and with which kernel.
88
9-
Exports a minimal model containing F.scaled_dot_product_attention through the
10-
CUDA backend and verifies that CUDA routes eligible decode shapes to split-K,
11-
while ROCm and other shapes use standard SDPA.
9+
Exports minimal models containing F.scaled_dot_product_attention through the
10+
CUDA backend. CUDA routes eligible decode shapes to split-K, ROCm and other
11+
shapes use standard SDPA, and calls neither kernel accepts stay with the
12+
regular lowering.
1213
"""
1314

15+
import importlib.util
1416
import logging
1517
import unittest
1618

@@ -24,6 +26,11 @@ def _require_cuda(tc: unittest.TestCase) -> None:
2426
tc.skipTest("CUDA required")
2527

2628

29+
def _require_splitk(tc: unittest.TestCase) -> None:
30+
if torch.version.hip is not None:
31+
tc.skipTest("split-K is off on ROCm")
32+
33+
2734
class SDPAModule(nn.Module):
2835
"""Single-layer model with SDPA and a static KV cache buffer."""
2936

@@ -199,5 +206,154 @@ def test_non_pow2_head_dim_uses_standard(self):
199206
self.assertEqual(len(splitk), 0, f"Expected no split-K for D=96. Got: {splitk}")
200207

201208

209+
class MaskedSDPAModule(nn.Module):
210+
def __init__(self, is_causal=False, dropout_p=0.0):
211+
super().__init__()
212+
self.is_causal = is_causal
213+
self.dropout_p = dropout_p
214+
215+
def forward(self, q, k, v, mask=None):
216+
return F.scaled_dot_product_attention(
217+
q,
218+
k,
219+
v,
220+
attn_mask=mask,
221+
dropout_p=self.dropout_p,
222+
is_causal=self.is_causal,
223+
)
224+
225+
226+
def _bf16(*shape):
227+
return torch.randn(*shape, dtype=torch.bfloat16)
228+
229+
230+
class TestUnsupportedSDPAStaysWithRegularLowering(unittest.TestCase):
231+
"""SDPA calls the Triton kernels do not accept are left to the regular lowering."""
232+
233+
def setUp(self):
234+
_require_cuda(self)
235+
236+
def _logs(self, q, k, v, mask=None, **kwargs):
237+
model = MaskedSDPAModule(**kwargs)
238+
args = (q, k, v) if mask is None else (q, k, v, mask)
239+
_, msgs = _capture_pass_logs(lambda: _export_through_cuda_backend(model, args))
240+
return msgs
241+
242+
def assertStays(self, msgs, reason):
243+
self.assertTrue(any(reason in m for m in msgs), msgs)
244+
self.assertTrue(any("Replaced 0 nodes" in m for m in msgs), msgs)
245+
246+
def test_float_mask_is_not_replaced(self):
247+
q = _bf16(1, 2, 8, 16)
248+
mask = torch.zeros(1, 1, 8, 8, dtype=torch.bfloat16)
249+
self.assertStays(self._logs(q, q, q, mask), "attn_mask must have dtype")
250+
251+
def test_float32_inputs_are_not_replaced(self):
252+
q = torch.randn(1, 2, 8, 16)
253+
self.assertStays(self._logs(q, q, q), "Expected bfloat16 inputs")
254+
255+
def test_broadcast_bool_mask_is_not_replaced(self):
256+
q = _bf16(1, 2, 8, 16)
257+
mask = torch.ones(1, 1, 1, 8, dtype=torch.bool)
258+
self.assertStays(self._logs(q, q, q, mask), "attn_mask shape mismatch")
259+
260+
def test_batch_1_mask_under_batch_2_is_not_replaced(self):
261+
q = _bf16(2, 2, 8, 16)
262+
mask = torch.ones(1, 1, 8, 8, dtype=torch.bool)
263+
self.assertStays(self._logs(q, q, q, mask), "attn_mask shape mismatch")
264+
265+
def test_value_head_size_other_than_query_is_not_replaced(self):
266+
q, v = _bf16(1, 2, 1, 16), _bf16(1, 2, 256, 32)
267+
self.assertStays(self._logs(q, _bf16(1, 2, 256, 16), v), "Head dimension")
268+
269+
def test_key_value_batch_1_under_batch_2_is_not_replaced(self):
270+
q, kv = _bf16(2, 2, 1, 16), _bf16(1, 2, 256, 16)
271+
self.assertStays(self._logs(q, kv, kv), "Batch dimension must match")
272+
273+
def test_shared_key_value_head_without_gqa_is_not_replaced(self):
274+
q, kv = _bf16(1, 4, 8, 16), _bf16(1, 1, 8, 16)
275+
self.assertStays(self._logs(q, kv, kv), "Head counts must match")
276+
277+
def test_causal_with_other_lengths_is_not_replaced(self):
278+
q, kv = _bf16(1, 2, 4, 16), _bf16(1, 2, 8, 16)
279+
self.assertStays(self._logs(q, kv, kv, is_causal=True), "Causal masking")
280+
281+
def test_dropout_is_not_replaced(self):
282+
q = _bf16(1, 2, 8, 16)
283+
self.assertStays(self._logs(q, q, q, dropout_p=0.1), "dropout_p must be 0.0")
284+
285+
def test_bool_mask_is_still_replaced(self):
286+
q = _bf16(1, 2, 8, 16)
287+
mask = torch.ones(1, 1, 8, 8, dtype=torch.bool)
288+
msgs = self._logs(q, q, q, mask)
289+
self.assertTrue(any("Replaced 1 nodes" in m for m in msgs), msgs)
290+
291+
def assertSplitK(self, msgs):
292+
self.assertTrue(any("Using split-K decode SDPA" in m for m in msgs), msgs)
293+
self.assertTrue(any("Replaced 1 nodes" in m for m in msgs), msgs)
294+
295+
def test_masked_decode_still_uses_splitk(self):
296+
_require_splitk(self)
297+
q, kv = _bf16(1, 4, 1, 64), _bf16(1, 4, 512, 64)
298+
mask = torch.ones(1, 1, 1, 512, dtype=torch.bool)
299+
self.assertSplitK(self._logs(q, kv, kv, mask))
300+
301+
def test_causal_decode_still_uses_splitk(self):
302+
_require_splitk(self)
303+
q, kv = _bf16(1, 4, 1, 64), _bf16(1, 4, 512, 64)
304+
self.assertSplitK(self._logs(q, kv, kv, is_causal=True))
305+
306+
def test_decode_with_a_shared_key_value_head_still_uses_splitk(self):
307+
_require_splitk(self)
308+
q, kv = _bf16(1, 4, 1, 64), _bf16(1, 1, 512, 64)
309+
self.assertSplitK(self._logs(q, kv, kv))
310+
311+
312+
class TestSDPAKernelSupportCheck(unittest.TestCase):
313+
"""Calls that cannot be exported end to end, on hand-built graphs."""
314+
315+
def setUp(self):
316+
if importlib.util.find_spec("triton") is None:
317+
self.skipTest("Triton required")
318+
319+
def _sdpa_node(self, shape, dropout_p=0.0):
320+
from executorch.exir.dialects._ops import ops as exir_ops
321+
from torch._subclasses.fake_tensor import FakeTensorMode
322+
323+
graph = torch.fx.Graph()
324+
with FakeTensorMode():
325+
q = torch.empty(*shape, dtype=torch.bfloat16, device="cuda")
326+
inputs = []
327+
for name in ("q", "k", "v"):
328+
node = graph.placeholder(name)
329+
node.meta["val"] = q
330+
inputs.append(node)
331+
return graph.call_function(
332+
exir_ops.edge.aten.scaled_dot_product_attention.default,
333+
(*inputs, None, dropout_p),
334+
)
335+
336+
def test_2d_inputs_are_not_supported(self):
337+
# The regular lowering of a 2D SDPA fails later in Inductor, so this
338+
# case cannot be exported end to end.
339+
from executorch.backends.cuda.triton.replacement_pass import (
340+
ReplaceEdgeOpWithTritonOpPass,
341+
)
342+
343+
node = self._sdpa_node((8, 16))
344+
self.assertFalse(ReplaceEdgeOpWithTritonOpPass._sdpa_kernel_supports(node))
345+
346+
def test_named_inputs_are_not_supported(self):
347+
from executorch.backends.cuda.triton.replacement_pass import (
348+
ReplaceEdgeOpWithTritonOpPass,
349+
)
350+
351+
node = self._sdpa_node((1, 2, 8, 16))
352+
query, key, value = node.args[:3]
353+
node.args = ()
354+
node.kwargs = {"query": query, "key": key, "value": value}
355+
self.assertFalse(ReplaceEdgeOpWithTritonOpPass._sdpa_kernel_supports(node))
356+
357+
202358
if __name__ == "__main__":
203359
unittest.main()

‎backends/cuda/triton/replacement_pass.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
import logging
1414

1515
import torch
16+
from executorch.backends.cuda.triton.kernels.sdpa import (
17+
_prepare_mask_params,
18+
_validate_qkv_shapes,
19+
_validate_sdpa_inputs,
20+
)
1621
from executorch.exir.dialects._ops import ops as exir_ops
1722

1823
from torch.fx import GraphModule, Node
@@ -114,7 +119,6 @@ def _pick_sdpa_kernel(node: Node):
114119
and D > 0
115120
and (D & (D - 1)) == 0 # power of 2
116121
):
117-
logger.info(f"Using split-K decode SDPA (L_kv={L_kv}, D={D})")
118122
return triton.sdpa_decode_splitk
119123

120124
return triton.sdpa
@@ -145,6 +149,44 @@ def _should_replace_node(self, node: Node) -> bool:
145149
logger.info(f"Skipping topk replacement: N={N} > {self._TOPK_MAX_N}")
146150
return False
147151

152+
if node.target == exir_ops.edge.aten.scaled_dot_product_attention.default:
153+
return self._sdpa_kernel_supports(node)
154+
155+
return True
156+
157+
@classmethod
158+
def _sdpa_kernel_supports(cls, node: Node) -> bool:
159+
"""Whether the Triton SDPA kernel the pass would pick accepts this call.
160+
161+
Runs the kernels' own input checks on the fake tensors, so calls they
162+
reject stay with the regular lowering instead of failing the export.
163+
"""
164+
# The kernel picker reads query and key by position.
165+
if len(node.args) < 3:
166+
return False
167+
names = ("attn_mask", "dropout_p", "is_causal", "scale", "enable_gqa")
168+
defaults = (None, 0.0, False, None, False)
169+
attn_mask, dropout_p, is_causal, _, enable_gqa = (
170+
node.args[3 + i] if 3 + i < len(node.args) else node.kwargs.get(name, d)
171+
for i, (name, d) in enumerate(zip(names, defaults))
172+
)
173+
query, key, value = (arg.meta["val"] for arg in node.args[:3])
174+
try:
175+
_validate_sdpa_inputs(query, key, value, dropout_p, enable_gqa)
176+
splitk = cls._pick_sdpa_kernel(node) is triton.sdpa_decode_splitk
177+
# Split-K groups query heads over key/value heads on its own.
178+
B, _, _, L_q, L_kv, _, _ = _validate_qkv_shapes(
179+
query, key, value, enable_gqa or splitk
180+
)
181+
if is_causal and L_q != L_kv and not splitk:
182+
raise RuntimeError(
183+
f"Causal masking requires L_q == L_kv; got L_q={L_q}, L_kv={L_kv}."
184+
)
185+
mask = None if attn_mask is None else attn_mask.meta["val"]
186+
_prepare_mask_params(mask, B, L_q, L_kv)
187+
except RuntimeError as e:
188+
logger.info(f"Skipping SDPA replacement: {e}")
189+
return False
148190
return True
149191

150192
def _replace_node_with_triton(self, graph_module: GraphModule, node: Node) -> None:
@@ -166,6 +208,9 @@ def _replace_node_with_triton(self, graph_module: GraphModule, node: Node) -> No
166208

167209
if target == exir_ops.edge.aten.scaled_dot_product_attention.default:
168210
triton_kernel_fn = self._pick_sdpa_kernel(node)
211+
if triton_kernel_fn is triton.sdpa_decode_splitk:
212+
L_kv, D = node.args[1].meta["val"].shape[2:]
213+
logger.info(f"Using split-K decode SDPA (L_kv={L_kv}, D={D})")
169214

170215
# Create a new node with the Triton kernel
171216
with graph_module.graph.inserting_before(node):

0 commit comments

Comments
 (0)