Skip to content

Commit 820086d

Browse files
authored
Cortex-M: decompose SDPA with score scaling after matmul (pytorch#22892)
### Summary Decompose eligible `aten.scaled_dot_product_attention` directly in the Cortex-M `transform_for_annotation` pipeline. The Cortex-M decomposition is: scores = matmul(query, key.transpose(-2, -1)) scores = scores * scale attn = softmax(scores, dim=-1) output = matmul(attn, value) The existing `MatmulToBmmPass` then converts the matmuls to BMMs. This replaces the previous approach that rewrote the output of `DecomposeSDPAWithRegularSoftmaxPass`. That Arm decomposition is not part of the Cortex-M pipeline, so the previous pass did not run in the real flow. Keeping the attention scale as a single MUL on the score tensor allows the stock `CortexMQuantizer` to produce proportional qparams around that MUL, making it suitable for later folding without adding Cortex-M-specific qspec logic to the quantizer. The decomposition is intentionally limited to fp32, unmasked, non-causal, zero-dropout, non-GQA SDPA with static, non-empty shapes of rank 3 or higher and matching leading dimensions. Unsupported forms are left unchanged. Addresses the scale/decomposition part of pytorch#21943. ### Tests Added a full SDPA operator test starting from `torch.nn.functional.scaled_dot_product_attention`, with no manual SDPA decomposition. The tests verify that: - raw SDPA is removed by the Cortex-M transform-for-annotation pipeline; - the two matmuls become BMMs; - exactly one scale MUL and one softmax remain, and the decomposed graph matches eager SDPA; - the MUL sits between a dequantize and quantize with `out_scale ~= in_scale * scale` and equal zero points, so it can be folded later; - unsupported SDPA forms, including dynamic shapes, are left unchanged. The SDPA dialect cases are xfailed because no lowering pass folds the scale MUL yet, using the existing `xfails` mechanism of `parametrize` with its default strict behavior. `nn.MultiheadAttention(need_weights=False)` regresses with this change. On main it lowers with the attention left in fp32. With this change its attention BMMs are quantized, and a BMM then gets an int8 input together with the int32 output of a retraced folded linear, the separate issue from pytorch#21943. That case is also xfailed. Local verification: - SDPA tests without implementation/FVP coverage: 19 passed, 5 xfailed, 5 deselected; - existing BMM/matmul/softmax tests passed; - lintrunner passed; - full Cortex-M suite comparison introduced no new failures versus the parent commit. FVP implementation tests were not run locally and are covered by the Cortex-M CI job.
1 parent 00e5030 commit 820086d

4 files changed

Lines changed: 334 additions & 0 deletions

File tree

‎backends/cortex_m/passes/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ fbcode_target(_kind = runtime.python_library,
3535
"cortex_m_pass_manager.py",
3636
"decompose_hardswish_pass.py",
3737
"decompose_mean_pass.py",
38+
"decompose_sdpa_pass.py",
3839
"explicit_layout_pass.py",
3940
"fuse_conv_padding_pass.py",
4041
"initialize_scratch_buffers_pass.py",

‎backends/cortex_m/passes/cortex_m_pass_manager.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .clamp_hardswish_pass import ClampHardswishPass
4141
from .decompose_hardswish_pass import DecomposeHardswishPass
4242
from .decompose_mean_pass import DecomposeMeanPass
43+
from .decompose_sdpa_pass import DecomposeSDPAPass
4344
from .explicit_layout_pass import (
4445
CortexMCanonicalizeViewCopyPermutePass,
4546
CortexMReplaceOpsWithChannelsLastVariants,
@@ -121,6 +122,7 @@ class CortexMPassManager(ExportedProgramPassManager):
121122
pass_list = legacy_pass_list
122123

123124
pass_list_transform_for_annotation: list[PassClass] = [
125+
DecomposeSDPAPass,
124126
ScalarsToAttributePass,
125127
ReplaceScalarWithTensorArgPass,
126128
ClampHardswishPass,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import math
7+
from typing import Dict
8+
9+
import torch
10+
from executorch.exir.pass_base import ExportPass, NodeMetadata, ProxyValue
11+
from torch._ops import OpOverload
12+
from torch.fx.node import Argument
13+
14+
15+
class DecomposeSDPAPass(ExportPass):
16+
"""
17+
Decomposes float32 scaled_dot_product_attention without mask, causal
18+
masking, dropout or GQA into matmul -> mul -> softmax -> matmul. Only
19+
static, non-empty shapes where query, key and value have the same leading
20+
dimensions are decomposed, since MatmulToBmmPass turns exactly those
21+
matmuls into bmm.
22+
23+
The default SDPA decomposition splits the scale over query and key before
24+
the score matmul. Applying it once to the scores instead means the
25+
quantizer observes the scores before and after the scale, so their qparams
26+
differ by the scale and the mul can be folded during lowering.
27+
28+
Without a mask a score row can only be all -inf for non-finite inputs, so
29+
regular softmax is used rather than _safe_softmax.
30+
"""
31+
32+
_known_kwargs = {"attn_mask", "dropout_p", "is_causal", "scale", "enable_gqa"}
33+
34+
def call_operator(
35+
self,
36+
op: OpOverload,
37+
args: tuple[Argument, ...],
38+
kwargs: Dict[str, Argument],
39+
meta: NodeMetadata,
40+
) -> ProxyValue:
41+
if op != torch.ops.aten.scaled_dot_product_attention.default:
42+
return super().call_operator(op, args, kwargs, meta)
43+
44+
query, key, value = args[:3]
45+
attn_mask = args[3] if len(args) > 3 else kwargs.get("attn_mask")
46+
dropout_p = args[4] if len(args) > 4 else kwargs.get("dropout_p", 0.0)
47+
is_causal = args[5] if len(args) > 5 else kwargs.get("is_causal", False)
48+
scale = kwargs.get("scale")
49+
50+
if (
51+
not isinstance(query, ProxyValue)
52+
or not isinstance(key, ProxyValue)
53+
or not isinstance(value, ProxyValue)
54+
or not kwargs.keys() <= self._known_kwargs
55+
or attn_mask is not None
56+
or dropout_p != 0.0
57+
or is_causal
58+
or kwargs.get("enable_gqa", False)
59+
):
60+
return super().call_operator(op, args, kwargs, meta)
61+
62+
shapes = [t.to_tensor().shape for t in (query, key, value)]
63+
if (
64+
# Scaling after the score matmul could overflow in half precision,
65+
# which is why the default decomposition splits the scale.
66+
query.to_tensor().dtype != torch.float32
67+
# MatmulToBmmPass reshapes with -1, which is ambiguous for empty
68+
# tensors.
69+
or not all(isinstance(dim, int) and dim > 0 for s in shapes for dim in s)
70+
or len(shapes[0]) < 3
71+
or not shapes[0][:-2] == shapes[1][:-2] == shapes[2][:-2]
72+
):
73+
return super().call_operator(op, args, kwargs, meta)
74+
75+
if scale is None:
76+
scale = 1.0 / math.sqrt(shapes[0][-1])
77+
78+
key_t = super().call_operator(
79+
torch.ops.aten.transpose.int, (key, -2, -1), {}, meta
80+
)
81+
scores = super().call_operator(
82+
torch.ops.aten.matmul.default, (query, key_t), {}, meta
83+
)
84+
# A Python scalar, unlike mul.Scalar, becomes a constant buffer in
85+
# ScalarsToAttributePass rather than a full op evaluated at runtime.
86+
scores = super().call_operator(
87+
torch.ops.aten.mul.Tensor, (scores, scale), {}, meta
88+
)
89+
attn = super().call_operator(torch.ops.aten.softmax.int, (scores, -1), {}, meta)
90+
return super().call_operator(
91+
torch.ops.aten.matmul.default, (attn, value), {}, meta
92+
)
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import math
7+
from collections import Counter
8+
9+
import torch
10+
from executorch.backends.arm.test.common import parametrize, xfail_type
11+
from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer
12+
from executorch.backends.cortex_m.test.tester import CortexMTester, McuTestCase
13+
14+
15+
class CortexMSDPA(torch.nn.Module):
16+
ops_before_transforms = {
17+
"executorch_exir_dialects_edge__ops_aten_bmm_default": 2,
18+
"executorch_exir_dialects_edge__ops_aten_mul_Tensor": 1,
19+
"executorch_exir_dialects_edge__ops_aten__softmax_default": 1,
20+
}
21+
22+
ops_after_transforms = {
23+
"executorch_exir_dialects_edge__ops_cortex_m_quantized_batch_matmul_default": 2,
24+
"executorch_exir_dialects_edge__ops_cortex_m_softmax_default": 1,
25+
"executorch_exir_dialects_edge__ops_aten_mul_Tensor": 0,
26+
}
27+
28+
def __init__(self, attn_mask: torch.Tensor | None = None, **sdpa_kwargs):
29+
super().__init__()
30+
self.register_buffer("attn_mask", attn_mask)
31+
self.sdpa_kwargs = sdpa_kwargs
32+
33+
def forward(self, query, key, value):
34+
return torch.nn.functional.scaled_dot_product_attention(
35+
query, key, value, attn_mask=self.attn_mask, **self.sdpa_kwargs
36+
)
37+
38+
39+
class CortexMMultiheadAttention(torch.nn.Module):
40+
ops_before_transforms = {
41+
"executorch_exir_dialects_edge__ops_aten_linear_default": 2,
42+
"executorch_exir_dialects_edge__ops_aten_bmm_default": 2,
43+
"executorch_exir_dialects_edge__ops_aten_mul_Tensor": 1,
44+
"executorch_exir_dialects_edge__ops_aten__softmax_default": 1,
45+
}
46+
47+
ops_after_transforms = {
48+
"executorch_exir_dialects_edge__ops_cortex_m_quantized_linear_default": 2,
49+
"executorch_exir_dialects_edge__ops_cortex_m_quantized_batch_matmul_default": 2,
50+
"executorch_exir_dialects_edge__ops_cortex_m_softmax_default": 1,
51+
"executorch_exir_dialects_edge__ops_aten_mul_Tensor": 0,
52+
}
53+
54+
def __init__(self, embed_dim: int, num_heads: int):
55+
super().__init__()
56+
self.attention = torch.nn.MultiheadAttention(
57+
embed_dim, num_heads, batch_first=True
58+
)
59+
60+
def forward(self, x):
61+
return self.attention(x, x, x, need_weights=False)[0]
62+
63+
64+
def _qkv(query_shape, key_shape, value_shape, dtype=torch.float32):
65+
return (
66+
torch.randn(query_shape, dtype=dtype),
67+
torch.randn(key_shape, dtype=dtype),
68+
torch.randn(value_shape, dtype=dtype),
69+
)
70+
71+
72+
test_cases = {
73+
"self_attention": McuTestCase(
74+
CortexMSDPA(), _qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
75+
),
76+
"cross_attention": McuTestCase(
77+
CortexMSDPA(), _qkv((1, 2, 4, 32), (1, 2, 12, 32), (1, 2, 12, 8))
78+
),
79+
"rank3": McuTestCase(CortexMSDPA(), _qkv((2, 8, 64), (2, 8, 64), (2, 8, 64))),
80+
"explicit_scale": McuTestCase(
81+
CortexMSDPA(scale=0.3), _qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
82+
),
83+
"multihead_attention": McuTestCase(
84+
CortexMMultiheadAttention(embed_dim=32, num_heads=4),
85+
(torch.randn(1, 8, 32),),
86+
),
87+
}
88+
89+
not_decomposed_test_cases = {
90+
"attn_mask": McuTestCase(
91+
CortexMSDPA(attn_mask=torch.randn(8, 8)),
92+
_qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16)),
93+
),
94+
"causal": McuTestCase(
95+
CortexMSDPA(is_causal=True), _qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
96+
),
97+
"dropout": McuTestCase(
98+
CortexMSDPA(dropout_p=0.5), _qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
99+
),
100+
"gqa": McuTestCase(
101+
CortexMSDPA(enable_gqa=True), _qkv((1, 4, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
102+
),
103+
"float16": McuTestCase(
104+
CortexMSDPA(),
105+
_qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16), dtype=torch.float16),
106+
),
107+
"empty_key": McuTestCase(
108+
CortexMSDPA(), _qkv((1, 2, 8, 16), (1, 2, 0, 16), (1, 2, 0, 16))
109+
),
110+
"broadcast_batch": McuTestCase(
111+
CortexMSDPA(), _qkv((2, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16))
112+
),
113+
"rank2": McuTestCase(CortexMSDPA(), _qkv((8, 16), (8, 16), (8, 16))),
114+
}
115+
116+
_SCALE_NOT_FOLDED = (
117+
"The attention scale mul is left as an fp32 aten.mul between dequantize "
118+
"and quantize, no pass folds it into the quantize yet"
119+
)
120+
_LINEAR_RETRACED_AS_INT32 = (
121+
"Retracing a folded quantized linear gives an int32 output, which fails "
122+
"the dtype check of the bmm consuming it"
123+
)
124+
125+
xfail_cases_dialect: dict[str, xfail_type] = {
126+
"self_attention": (_SCALE_NOT_FOLDED, RuntimeError),
127+
"cross_attention": (_SCALE_NOT_FOLDED, RuntimeError),
128+
"rank3": (_SCALE_NOT_FOLDED, RuntimeError),
129+
"explicit_scale": (_SCALE_NOT_FOLDED, RuntimeError),
130+
"multihead_attention": (_LINEAR_RETRACED_AS_INT32, Exception),
131+
}
132+
xfail_cases_impl: dict[str, xfail_type] = {
133+
"multihead_attention": (_LINEAR_RETRACED_AS_INT32, Exception),
134+
}
135+
136+
137+
def _transform_for_annotation(model, example_inputs, dynamic_shapes=None):
138+
# Without check_guards=False, whether export adds a _guards_fn call_module,
139+
# which ExportPass rejects, depends on the path of the calling file.
140+
graph_module = torch.export.export(
141+
model, example_inputs, dynamic_shapes=dynamic_shapes, strict=True
142+
).module(check_guards=False)
143+
return CortexMQuantizer().transform_for_annotation(graph_module)
144+
145+
146+
def _op_counts(graph_module) -> Counter:
147+
return Counter(
148+
node.target for node in graph_module.graph.nodes if node.op == "call_function"
149+
)
150+
151+
152+
@parametrize("test_case", test_cases, xfails=xfail_cases_dialect)
153+
def test_dialect_sdpa(test_case, cortex_m_target):
154+
tester = CortexMTester(
155+
test_case.model, test_case.example_inputs, target_config=cortex_m_target
156+
)
157+
tester.test_dialect(
158+
test_case.model.ops_before_transforms,
159+
test_case.model.ops_after_transforms,
160+
qtol=2,
161+
)
162+
163+
164+
@parametrize("test_case", test_cases, xfails=xfail_cases_impl)
165+
def test_implementation_sdpa(test_case, cortex_m_target):
166+
tester = CortexMTester(
167+
test_case.model, test_case.example_inputs, target_config=cortex_m_target
168+
)
169+
tester.test_implementation(qtol=2)
170+
171+
172+
@parametrize("test_case", test_cases)
173+
def test_sdpa_decomposition(test_case):
174+
inputs = test_case.get_example_inputs()
175+
graph_module = _transform_for_annotation(test_case.model, inputs)
176+
177+
op_counts = _op_counts(graph_module)
178+
assert op_counts[torch.ops.aten.scaled_dot_product_attention.default] == 0
179+
assert op_counts[torch.ops.aten.matmul.default] == 0
180+
assert op_counts[torch.ops.aten.bmm.default] == 2
181+
assert op_counts[torch.ops.aten.mul.Tensor] == 1
182+
assert op_counts[torch.ops.aten.softmax.int] == 1
183+
torch.testing.assert_close(graph_module(*inputs), test_case.model(*inputs))
184+
185+
186+
@parametrize("test_case", not_decomposed_test_cases)
187+
def test_sdpa_not_decomposed(test_case):
188+
graph_module = _transform_for_annotation(
189+
test_case.model, test_case.get_example_inputs()
190+
)
191+
192+
assert (
193+
_op_counts(graph_module)[torch.ops.aten.scaled_dot_product_attention.default]
194+
== 1
195+
)
196+
197+
198+
def test_sdpa_with_dynamic_shape_not_decomposed():
199+
seq_len = torch.export.Dim("seq_len", min=2, max=64)
200+
graph_module = _transform_for_annotation(
201+
CortexMSDPA(),
202+
_qkv((1, 2, 8, 16), (1, 2, 8, 16), (1, 2, 8, 16)),
203+
dynamic_shapes=({2: seq_len}, {2: seq_len}, {2: seq_len}),
204+
)
205+
206+
assert (
207+
_op_counts(graph_module)[torch.ops.aten.scaled_dot_product_attention.default]
208+
== 1
209+
)
210+
211+
212+
@parametrize("test_case", test_cases)
213+
def test_sdpa_scale_mul_qparams_allow_folding(test_case):
214+
"""The scale mul can only be folded into the quantize after it without
215+
changing any int8 value if that quantize uses the scale of the dequantize
216+
before it times the attention scale, and the same zero point."""
217+
tester = CortexMTester(test_case.model, test_case.example_inputs)
218+
tester.quantize()
219+
graph_module = tester.get_artifact()
220+
221+
muls = [
222+
node
223+
for node in graph_module.graph.nodes
224+
if node.target == torch.ops.aten.mul.Tensor
225+
]
226+
assert len(muls) == 1
227+
dequantize, scale_constant = muls[0].args
228+
(quantize,) = muls[0].users
229+
assert (
230+
dequantize.target
231+
== torch.ops.quantized_decomposed.dequantize_per_tensor.default
232+
)
233+
assert quantize.target == torch.ops.quantized_decomposed.quantize_per_tensor.default
234+
235+
scale = getattr(graph_module, scale_constant.target).item()
236+
in_scale, in_zero_point = dequantize.args[1:3]
237+
out_scale, out_zero_point = quantize.args[1:3]
238+
assert out_zero_point == in_zero_point
239+
assert math.isclose(out_scale, in_scale * scale, rel_tol=1e-6)

0 commit comments

Comments
 (0)