Skip to content

Commit 5253dfa

Browse files
[release/1.6] Lower fused CUDA attention variants (#24491)
Supersedes #24455 with a clean release pick from #24426. Restores inference-only fused flash, efficient and cuDNN attention calls to scaled_dot_product_attention when the source pass can preserve their semantics. The fused-attention test is moved unchanged to avoid conflicts with the earlier indexing pick. The existing release missing-kernel regression test is retained. ## Scope and landing order Land after #24490 in the verified landing order. This branch starts directly at release/1.6 commit `dfb78b9ecd933a5fee33ebc94f69ed4b71cbb1e7`. Its GitHub diff contains 5 files for this pick; it does not carry duplicate #24298 history or the excluded unmerged QNN work. Dependencies are documented here rather than embedded as other cherry-pick commits. ## Verification All changed Python files compile and pass Ruff F821 checks. All seven replacements merge cleanly in the documented sequence, including simulated squash merges. No QNN files are changed. The existing CPU graph rewrite test passed its nine cases using the real inherited AotiPartitioner implementation. The GPU import/pass-registration test could not run locally because Triton is unavailable on macOS. Native CUDA, OpenVINO and portable C++ suites were not run locally on this macOS host; PR CI remains required before landing. Prepared with OpenAI Codex.
1 parent f3a9b06 commit 5253dfa

5 files changed

Lines changed: 337 additions & 1 deletion

File tree

‎backends/cuda/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ fbcode_target(
7979
"passes/__init__.py",
8080
"passes/move_cond_predicate_to_cpu.py",
8181
"passes/lower_offgraph_kv.py",
82+
"passes/replace_fused_sdpa.py",
8283
"passes/replace_int64_floordiv.py",
8384
],
8485
visibility = [

‎backends/cuda/cuda_backend.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
from executorch.backends.cuda.passes.move_cond_predicate_to_cpu import (
4040
MoveCondPredicateToCpuPass,
4141
)
42+
from executorch.backends.cuda.passes.replace_fused_sdpa import (
43+
ReplaceFusedSdpaWithSdpaPass,
44+
)
4245
from executorch.backends.cuda.passes.replace_int64_floordiv import (
4346
ReplaceInt64FloorDivWithFloatPass,
4447
)
@@ -1013,7 +1016,11 @@ def get_custom_passes(cls, compile_specs: List[CompileSpec]) -> List[typing.Any]
10131016
f"Invalid triton_kernel_mode: {mode}. Expected 'ON' or 'OFF'."
10141017
)
10151018
triton_kernel_mode = mode
1016-
passes = [MoveCondPredicateToCpuPass(), ReplaceInt64FloorDivWithFloatPass()]
1019+
passes = [
1020+
MoveCondPredicateToCpuPass(),
1021+
ReplaceInt64FloorDivWithFloatPass(),
1022+
ReplaceFusedSdpaWithSdpaPass(),
1023+
]
10171024
cell_layout = any(
10181025
spec.key == OFFGRAPH_KV_COMPILE_SPEC
10191026
and parse_offgraph_kv_manifest(spec.value)["layout"] == "cell"
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""
8+
Graph Transformation Pass for Fused Attention Replacement.
9+
10+
Decomposing ``scaled_dot_product_attention`` on CUDA, as ``torch.export``'s
11+
default decompositions and Torch-TensorRT do, turns it into one of its fused
12+
CUDA variants. The backend has no kernel for those, so this pass turns them
13+
back into ``scaled_dot_product_attention``, which the Triton SDPA kernel or
14+
Inductor's math decomposition lowers.
15+
"""
16+
17+
import logging
18+
import operator
19+
from typing import Any, Dict, Optional
20+
21+
from executorch.exir.dialects._ops import ops as exir_ops
22+
from torch.export import ExportedProgram
23+
from torch.export.graph_signature import ExportGraphSignature
24+
from torch.fx import GraphModule, Node
25+
from torch.fx.experimental.symbolic_shapes import statically_known_true
26+
27+
logger = logging.getLogger(__name__)
28+
29+
_FUSED_SDPA_OPS = (
30+
exir_ops.edge.aten._scaled_dot_product_flash_attention.default,
31+
exir_ops.edge.aten._scaled_dot_product_efficient_attention.default,
32+
exir_ops.edge.aten._scaled_dot_product_cudnn_attention.default,
33+
)
34+
35+
36+
class ReplaceFusedSdpaWithSdpaPass:
37+
"""
38+
Replace each fused SDPA node with ``scaled_dot_product_attention`` when
39+
only its attention output is used and ``scaled_dot_product_attention``
40+
computes the same result. Any other node stays as it is.
41+
"""
42+
43+
requires_exported_program = True
44+
45+
def __call__(self, exported_program: ExportedProgram) -> None:
46+
program_module = exported_program.graph_module
47+
for module in program_module.modules():
48+
if not isinstance(module, GraphModule):
49+
continue
50+
# The signature names the outputs of the program's own graph only.
51+
signature = (
52+
exported_program.graph_signature if module is program_module else None
53+
)
54+
modified = False
55+
for node in list(module.graph.nodes):
56+
if node.op != "call_function" or node.target not in _FUSED_SDPA_OPS:
57+
continue
58+
values = _arguments(node)
59+
reason = _reason_to_keep(node, values)
60+
if reason is None:
61+
_replace_with_sdpa(module, node, values, signature)
62+
modified = True
63+
else:
64+
logger.warning(
65+
"The CUDA backend cannot lower %s, since %s",
66+
node.format_node(),
67+
reason,
68+
)
69+
if modified:
70+
module.recompile()
71+
72+
73+
def _arguments(node: Node) -> Dict[str, Any]:
74+
arguments = node.target._schema.arguments
75+
values = {a.name: a.default_value for a in arguments}
76+
values.update(zip((a.name for a in arguments), node.args))
77+
values.update(node.kwargs)
78+
return values
79+
80+
81+
def _reason_to_keep(node: Node, values: Dict[str, Any]) -> Optional[str]:
82+
if any(
83+
user.target is not operator.getitem or user.args[1] != 0 for user in node.users
84+
):
85+
return "an output other than the attention output is used"
86+
if not values["is_causal"]:
87+
return None
88+
if values.get("attn_bias") is not None:
89+
return "scaled_dot_product_attention takes no mask together with is_causal"
90+
# Flash attention aligns the causal mask to the bottom right, the other
91+
# variants and scaled_dot_product_attention to the top left, and the Triton
92+
# decode kernel ignores it.
93+
query, key = values["query"].meta["val"], values["key"].meta["val"]
94+
if not statically_known_true(query.shape[-2] == key.shape[-2]):
95+
return "its causal mask may have different query and key lengths"
96+
return None
97+
98+
99+
def _replace_with_sdpa(
100+
module: GraphModule,
101+
node: Node,
102+
values: Dict[str, Any],
103+
signature: Optional[ExportGraphSignature],
104+
) -> None:
105+
query, key = values["query"], values["key"]
106+
kwargs = {}
107+
# The Triton kernels take a float scale, so leave out the default.
108+
if values["scale"] is not None:
109+
kwargs["scale"] = values["scale"]
110+
# The fused variants that accept fewer key heads than query heads group
111+
# them as enable_gqa does.
112+
if not statically_known_true(
113+
query.meta["val"].shape[-3] == key.meta["val"].shape[-3]
114+
):
115+
kwargs["enable_gqa"] = True
116+
with module.graph.inserting_before(node):
117+
sdpa = module.graph.call_function(
118+
exir_ops.edge.aten.scaled_dot_product_attention.default,
119+
args=(
120+
query,
121+
key,
122+
values["value"],
123+
# Only the flash variant has no bias.
124+
values.get("attn_bias"),
125+
values["dropout_p"],
126+
values["is_causal"],
127+
),
128+
kwargs=kwargs,
129+
)
130+
sdpa.meta = {**node.meta, "val": node.meta["val"][0]}
131+
for user in list(node.users):
132+
user.replace_all_uses_with(sdpa)
133+
if signature is not None:
134+
signature.replace_all_uses(user.name, sdpa.name)
135+
module.graph.erase_node(user)
136+
module.graph.erase_node(node)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import unittest
8+
9+
import torch
10+
from executorch.backends.aoti.passes.replace_view_copy_with_view import (
11+
ReplaceViewCopyWithViewPass,
12+
)
13+
from executorch.backends.cuda.cuda_backend import CudaBackend
14+
from executorch.backends.cuda.cuda_partitioner import CudaPartitioner
15+
from executorch.backends.cuda.passes.replace_fused_sdpa import (
16+
ReplaceFusedSdpaWithSdpaPass,
17+
)
18+
from executorch.exir import EdgeCompileConfig, to_edge
19+
from executorch.exir.dialects._ops import ops as exir_ops
20+
from torch.export import export
21+
22+
23+
def _program_for_cuda_passes(model, inputs, dynamic_shapes=None):
24+
"""The Edge program of ``model`` as the CUDA backend's passes get it: no
25+
op decomposed, as the CUDA partitioner asks, and views for copies."""
26+
program = export(model, inputs, dynamic_shapes=dynamic_shapes, strict=True)
27+
# to_edge_transform_and_lower functionalizes the program before it asks
28+
# the partitioner which ops to keep.
29+
program = program.run_decompositions({})
30+
ops, _ = CudaPartitioner([]).ops_to_not_decompose(program)
31+
program = to_edge(
32+
program,
33+
compile_config=EdgeCompileConfig(_check_ir_validity=False, preserve_ops=ops),
34+
).exported_program()
35+
ReplaceViewCopyWithViewPass()(program.graph_module)
36+
return program
37+
38+
39+
class _Fn(torch.nn.Module):
40+
def __init__(self, fn):
41+
super().__init__()
42+
self.fn = fn
43+
44+
def forward(self, *args):
45+
return self.fn(*args)
46+
47+
48+
class TestReplaceFusedSdpaWithSdpaPass(unittest.TestCase):
49+
def test_runs_before_triton_replacement(self):
50+
"""The calls it rewrites must still reach the Triton SDPA kernels."""
51+
names = [type(p).__name__ for p in CudaBackend.get_custom_passes([])]
52+
self.assertLess(
53+
names.index("ReplaceFusedSdpaWithSdpaPass"),
54+
names.index("ReplaceEdgeOpWithTritonOpPass"),
55+
)
56+
57+
def test_rewrite(self):
58+
"""Fused attention becomes scaled_dot_product_attention only when that
59+
computes the same result."""
60+
aten = torch.ops.aten
61+
62+
def cudnn(q, k):
63+
return aten._scaled_dot_product_cudnn_attention(q, k, k, None, False)[0]
64+
65+
def causal(q, k, bias=None):
66+
return aten._scaled_dot_product_efficient_attention(
67+
q, k, k, bias, False, is_causal=True
68+
)[0]
69+
70+
def grouped(q, k):
71+
return aten._scaled_dot_product_flash_attention(q, k, k, scale=0.2)[0]
72+
73+
def with_log_sum_exp(q, k):
74+
return aten._scaled_dot_product_efficient_attention(q, k, k, None, True)[:2]
75+
76+
def flash(q, k):
77+
return aten._scaled_dot_product_flash_attention(q, k, k)[0]
78+
79+
def in_branch(q, k):
80+
return torch.cond(q.sum() > 0, flash, lambda q, k: q * 2, (q, k))
81+
82+
q = torch.randn(1, 2, 16, 64)
83+
one_head = torch.randn(1, 1, 16, 64)
84+
decode = q[:, :, :1].clone()
85+
length = torch.export.Dim("length", min=2, max=64)
86+
keys = torch.export.Dim("keys", min=2, max=64)
87+
# The last item is the keyword arguments of the new call, or None when
88+
# the fused call has to stay.
89+
for fn, inputs, lengths, kwargs in (
90+
(cudnn, (q, q), None, {}),
91+
(causal, (q, q), None, {}),
92+
(causal, (q, q.clone()), (length, length), {}),
93+
(grouped, (q, one_head), None, {"scale": 0.2, "enable_gqa": True}),
94+
(in_branch, (q, q), None, {}),
95+
# With different query and key lengths, the variants and the Triton
96+
# decode kernel place the causal mask differently.
97+
(causal, (decode, q), None, None),
98+
(causal, (q, q.clone()), (length, keys), None),
99+
(causal, (q, q, torch.randn(1, 2, 16, 16)), None, None),
100+
(with_log_sum_exp, (q, q), None, None),
101+
):
102+
shapes = [t.shape for t in inputs]
103+
with self.subTest(fn=fn.__name__, shapes=shapes, lengths=lengths):
104+
dynamic_shapes = None
105+
if lengths:
106+
dynamic_shapes = {"args": tuple({2: n} for n in lengths)}
107+
program = _program_for_cuda_passes(_Fn(fn), inputs, dynamic_shapes)
108+
ReplaceFusedSdpaWithSdpaPass()(program)
109+
program.validate()
110+
nodes = [
111+
node
112+
for module in program.graph_module.modules()
113+
if isinstance(module, torch.fx.GraphModule)
114+
for node in module.graph.nodes
115+
]
116+
sdpa = exir_ops.edge.aten.scaled_dot_product_attention.default
117+
calls = [node.kwargs for node in nodes if node.target == sdpa]
118+
self.assertEqual(calls, [] if kwargs is None else [kwargs])
119+
fused = [
120+
node
121+
for node in nodes
122+
if "aten._scaled_dot_product_" in str(node.target)
123+
]
124+
self.assertEqual(not fused, kwargs is not None)

‎backends/cuda/tests/test_cuda_export.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,74 @@ def _export_to_cuda_with_lower(
381381

382382
return edge_program_manager
383383

384+
@unittest.skipIf(
385+
torch.version.hip is not None,
386+
"loads through the Python runtime, which the ROCm build does not include",
387+
)
388+
def test_fused_sdpa(self):
389+
"""Decomposing attention on CUDA, as Torch-TensorRT does, gives fused
390+
variants that the backend has no kernel for."""
391+
from executorch.runtime import Runtime
392+
393+
class Attention(torch.nn.Module):
394+
def forward(self, q, k, v, mask):
395+
attention = torch.nn.functional.scaled_dot_product_attention
396+
# Flash attention with fewer key heads and a custom scale.
397+
grouped = attention(
398+
q,
399+
k[:, :2, :16],
400+
v[:, :2, :16],
401+
is_causal=True,
402+
scale=0.2,
403+
enable_gqa=True,
404+
)
405+
# Efficient attention, with the mask turned into a bias.
406+
masked = attention(q, k[:, :, :16], v[:, :, :16], attn_mask=mask)
407+
# Flash attention of one query over many keys, which runs in
408+
# the Triton decode kernel.
409+
decode = attention(q[:, :, :1], k, v)
410+
return grouped, masked, decode
411+
412+
model = Attention()
413+
inputs = (
414+
torch.randn(1, 4, 16, 64, dtype=torch.bfloat16, device="cuda"),
415+
torch.randn(1, 4, 256, 64, dtype=torch.bfloat16, device="cuda"),
416+
torch.randn(1, 4, 256, 64, dtype=torch.bfloat16, device="cuda"),
417+
torch.ones(16, 16, dtype=torch.bool, device="cuda").tril(),
418+
)
419+
program = export(model, inputs, strict=True).run_decompositions()
420+
fused = [
421+
node
422+
for node in program.graph.nodes
423+
if "aten._scaled_dot_product_" in str(node.target)
424+
]
425+
self.assertEqual(len(fused), 3)
426+
with self.assertLogs(
427+
"executorch.backends.cuda.triton.replacement_pass", "INFO"
428+
) as logs:
429+
edge = to_edge_transform_and_lower(
430+
program,
431+
partitioner=[
432+
CudaPartitioner(
433+
[CudaBackend.generate_method_name_compile_spec("forward")]
434+
)
435+
],
436+
compile_config=EdgeCompileConfig(_check_ir_validity=False),
437+
)
438+
# The masked call keeps a float bias, which the Triton kernels do not take.
439+
output = "\n".join(logs.output)
440+
self.assertIn("Replaced 2 nodes with Triton kernels", output)
441+
self.assertIn("Using split-K decode SDPA", output)
442+
with tempfile.TemporaryDirectory() as directory:
443+
path = os.path.join(directory, "model.pte")
444+
with open(path, "wb") as f:
445+
edge.to_executorch().write_to_file(f)
446+
method = Runtime.get().load_program(path).load_method("forward")
447+
outputs = method.execute([t.cpu() for t in inputs])
448+
for output, reference in zip(outputs, model(*inputs), strict=True):
449+
torch.testing.assert_close(output, reference.cpu(), atol=2e-2, rtol=2e-2)
450+
451+
384452
def test_simple_add(self):
385453
"""Test CUDA export for simple element-wise addition."""
386454

0 commit comments

Comments
 (0)