Skip to content

Commit bbc6f33

Browse files
authored
Reuse shared transform passes in MLX (pytorch#22858)
Use shared RMSNorm and GQA fusion passes in MLX, and normalize SDPA input ranks before decomposition checks. Replace the SDPA pattern with a direct op handler while preserving MLX support checks and causal alignment. Extend shared GQA fusion to handle repeat_interleave and update test coverage.
1 parent 0aa375a commit bbc6f33

11 files changed

Lines changed: 698 additions & 600 deletions

‎backends/mlx/ops.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
RoundNode,
134134
RsqrtNode,
135135
ScatterAddNode,
136+
SdpaNode,
136137
SigmoidNode,
137138
SignNode,
138139
SiluNode,
@@ -178,6 +179,7 @@
178179
from executorch.extension.llm.cache import ( # noqa: F401
179180
update_and_attend as _kvcache_op,
180181
)
182+
from torch.fx.experimental.symbolic_shapes import statically_known_true
181183
from torch.fx.node import Node
182184

183185
_LEAKY_RELU_DEFAULT_NEGATIVE_SLOPE = 0.01
@@ -3074,6 +3076,108 @@ def _arange_start_step_handler(P: MLXProgramBuilder, n: Node) -> Slot:
30743076
return out
30753077

30763078

3079+
@REGISTRY.register(target=[torch.ops.aten.scaled_dot_product_attention.default])
3080+
def _aten_sdpa_handler(P: MLXProgramBuilder, n: Node) -> Slot: # noqa: C901
3081+
op_name = "aten.scaled_dot_product_attention"
3082+
require_args(n.args, 0, 6, op_name)
3083+
require_kwargs(
3084+
n.kwargs,
3085+
{
3086+
"query",
3087+
"key",
3088+
"value",
3089+
"attn_mask",
3090+
"dropout_p",
3091+
"is_causal",
3092+
"scale",
3093+
"enable_gqa",
3094+
},
3095+
op_name,
3096+
)
3097+
3098+
def arg(index, name, default=None):
3099+
return n.args[index] if len(n.args) > index else n.kwargs.get(name, default)
3100+
3101+
q_node, k_node, v_node = (
3102+
arg(i, name) for i, name in enumerate(("query", "key", "value"))
3103+
)
3104+
attn_mask = arg(3, "attn_mask")
3105+
dropout_p = arg(4, "dropout_p", 0.0)
3106+
is_causal = arg(5, "is_causal", False)
3107+
scale = n.kwargs.get("scale")
3108+
enable_gqa = n.kwargs.get("enable_gqa", False)
3109+
operand_vals = [
3110+
node.meta.get("val") if isinstance(node, Node) else None
3111+
for node in (q_node, k_node, v_node)
3112+
]
3113+
if any(not isinstance(val, torch.Tensor) or val.dim() != 4 for val in operand_vals):
3114+
raise NotImplementedError("SDPA requires normalized rank-4 Q/K/V")
3115+
q_val, k_val, v_val = operand_vals
3116+
if any(
3117+
not statically_known_true(val.shape[0] == q_val.shape[0])
3118+
for val in (k_val, v_val)
3119+
):
3120+
raise NotImplementedError("SDPA batch broadcasting is not supported")
3121+
3122+
q_heads, k_heads, v_heads = (val.shape[1] for val in operand_vals)
3123+
if not statically_known_true(k_heads > 0):
3124+
raise NotImplementedError("SDPA requires positive KV heads")
3125+
if not statically_known_true(k_heads == v_heads) or not statically_known_true(
3126+
q_heads % k_heads == 0
3127+
):
3128+
raise NotImplementedError("SDPA head counts are not supported")
3129+
if attn_mask is not None:
3130+
mask_val = attn_mask.meta.get("val") if isinstance(attn_mask, Node) else None
3131+
if not isinstance(mask_val, torch.Tensor) or mask_val.dim() > 4:
3132+
raise NotImplementedError("SDPA mask rank is not supported")
3133+
3134+
require_static_float(dropout_p, "dropout_p", op_name)
3135+
if dropout_p != 0.0:
3136+
raise NotImplementedError("SDPA with dropout is not supported")
3137+
if not isinstance(is_causal, bool) or not isinstance(enable_gqa, bool):
3138+
raise NotImplementedError("SDPA flags must be static booleans")
3139+
q_len, k_len = q_val.shape[-2], k_val.shape[-2]
3140+
if is_causal and not statically_known_true(q_len <= k_len):
3141+
raise NotImplementedError("Causal SDPA requires query length <= key length")
3142+
if scale is None:
3143+
scale = q_val.shape[-1] ** -0.5
3144+
require_static_float(scale, "scale", op_name)
3145+
3146+
q, k, v, mask = P.slot_map([q_node, k_node, v_node, attn_mask])
3147+
inputs = [q, k, v]
3148+
# Slicing K/V makes causal attention square, reconciling PyTorch's top-left
3149+
# mask with MLX's bottom-right mask. Keys past the last query are never read.
3150+
if is_causal and not statically_known_true(q_len == k_len):
3151+
rows = emit_shape(P, q_node, q)[-2]
3152+
for i in (1, 2):
3153+
_, sliced = P.make_tmp_slot()
3154+
P.emit(
3155+
SliceNode(
3156+
x=P.slot_to_tid(inputs[i]),
3157+
out=P.slot_to_tid(sliced),
3158+
axis=IntOrVid.from_literal(2),
3159+
start=IntOrVid.from_literal(0),
3160+
stop=rows,
3161+
)
3162+
)
3163+
inputs[i] = sliced
3164+
3165+
out = P.make_or_get_slot(n)
3166+
# MLX infers GQA from the Q/KV head counts; no separate flag is serialized.
3167+
P.emit(
3168+
SdpaNode(
3169+
q=P.slot_to_tid(inputs[0]),
3170+
k=P.slot_to_tid(inputs[1]),
3171+
v=P.slot_to_tid(inputs[2]),
3172+
out=P.slot_to_tid(out),
3173+
scale=scale,
3174+
mask=P.slot_to_tid(mask) if mask is not None else None,
3175+
causal=is_causal,
3176+
)
3177+
)
3178+
return out
3179+
3180+
30773181
@REGISTRY.register(target=[torch.ops.aten.rms_norm.default])
30783182
def _aten_rms_norm_handler(P: MLXProgramBuilder, n: Node) -> Slot:
30793183
args = P.args(n)

‎backends/mlx/partitioner.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from executorch.backends.mlx._logging import logger
2424
from executorch.backends.mlx._memprofile import mem_phase
2525
from executorch.backends.mlx.preprocess import MLXBackend
26+
from executorch.backends.transforms.normalize_sdpa_input_rank import (
27+
NormalizeSDPAInputRankPass,
28+
)
2629
from executorch.exir.backend.backend_details import CompileSpec
2730
from executorch.exir.backend.canonical_partitioners.pattern_op_partitioner import (
2831
generate_partitions_from_list_of_nodes,
@@ -33,6 +36,7 @@
3336
PartitionResult,
3437
)
3538
from executorch.exir.backend.utils import tag_constant_data, tag_mutated_buffer
39+
from executorch.exir.pass_manager import ExportedProgramPassManager
3640
from torch.export.exported_program import ExportedProgram
3741
from torch.fx.passes.infra.partitioner import Partition
3842
from torch.fx.passes.operator_support import OperatorSupportBase
@@ -110,6 +114,14 @@ def __init__(self, compile_specs: List[CompileSpec] | None = None) -> None:
110114
| None
111115
) = None
112116

117+
def transform_for_pre_decomposition(
118+
self, exported_program: ExportedProgram
119+
) -> ExportedProgram:
120+
"""Normalize SDPA ranks before deciding which ops to preserve."""
121+
return ExportedProgramPassManager([NormalizeSDPAInputRankPass()])(
122+
exported_program
123+
).exported_program
124+
113125
def ops_to_not_decompose(
114126
self, ep: ExportedProgram
115127
) -> tuple[list[torch._ops.OpOverload], Callable[[torch.fx.Node], bool] | None]:

‎backends/mlx/passes.py‎

Lines changed: 7 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,31 @@
88
Graph transformation passes for the MLX backend.
99
"""
1010

11-
from dataclasses import dataclass
12-
from typing import List, Optional
11+
from typing import List
1312

1413
import torch
15-
from executorch.backends.mlx.pattern_utils import (
16-
extract_lifted_tensor_constant,
17-
match_target,
18-
OpStep,
19-
PatternMatch,
20-
walk_back,
21-
)
2214
from executorch.backends.transforms.collapse_view_copy import CollapseViewCopyPass
15+
from executorch.backends.transforms.fuse_gqa_with_sdpa import FuseGQAWithSDPAPass
16+
from executorch.backends.transforms.fuse_rms_norm import FuseRMSNormPass
2317
from executorch.exir.dialects._ops import ops as exir_ops
2418
from executorch.exir.pass_base import (
2519
ExportedProgramPassBase,
2620
ExportedProgramPassResult,
2721
ExportPass,
2822
PassResult,
2923
)
24+
from executorch.exir.pass_manager import PassType
3025
from executorch.exir.passes.cse_pass import CSEPass
3126
from torch.fx import GraphModule, Node
3227

3328

34-
def get_default_passes() -> List[ExportPass]:
29+
def get_default_passes() -> List[PassType]:
3530
"""
3631
Returns a list of passes that are enabled by default for the MLX backend.
3732
"""
3833
return [
39-
FuseRMSNormPass(),
34+
FuseRMSNormPass(fold_dtype_casts=True, allow_lossy_weight_casts=True),
35+
FuseGQAWithSDPAPass(),
4036
CanonicalizePermutePass(),
4137
CollapseViewCopyPass(),
4238
CollapsePermutePass(),
@@ -103,175 +99,6 @@ def call(self, exported_program) -> ExportedProgramPassResult:
10399
return ExportedProgramPassResult(exported_program, True)
104100

105101

106-
@dataclass
107-
class RMSNormMatch(PatternMatch):
108-
"""
109-
Matched RMSNorm pattern.
110-
111-
HuggingFace Llama's RMSNorm decomposes into:
112-
variance = hidden_states.pow(2).mean(-1, keepdim=True)
113-
hidden_states = hidden_states * torch.rsqrt(variance + eps)
114-
return weight * hidden_states.to(input_dtype)
115-
116-
Graph pattern:
117-
_to_copy (to f32) [optional]
118-
pow(x, 2)
119-
mean_dim(pow_out, [-1], keepdim=True)
120-
add(mean_out, eps_tensor)
121-
rsqrt(add_out)
122-
mul(to_copy_out, rsqrt_out)
123-
_to_copy (back to original dtype) [optional]
124-
mul(weight, to_copy_out)
125-
"""
126-
127-
input_node: Node = None # type: ignore[assignment]
128-
weight_node: Node = None # type: ignore[assignment]
129-
eps: float = 0.0
130-
131-
@classmethod
132-
def maybe_create(cls, head: Node, **context) -> Optional["RMSNormMatch"]:
133-
"""Match RMSNorm pattern starting from final mul(weight, normalized)."""
134-
# Head must be mul
135-
if not match_target(head, torch.ops.aten.mul.Tensor):
136-
return None
137-
138-
if len(head.args) < 2:
139-
return None
140-
141-
# Try both orderings: mul(weight, normalized) or mul(normalized, weight)
142-
for weight_idx, norm_idx in [(0, 1), (1, 0)]:
143-
weight_node = head.args[weight_idx]
144-
norm_node = head.args[norm_idx]
145-
146-
if not isinstance(norm_node, Node):
147-
continue
148-
149-
# Match entire chain with single walk_back:
150-
# [_to_copy] -> mul(input, rsqrt) -> rsqrt -> add -> mean -> pow -> [_to_copy]
151-
# The mul follows arg_index=1 to get rsqrt (not input)
152-
result = walk_back(
153-
norm_node,
154-
[
155-
OpStep(
156-
op=torch.ops.aten._to_copy.default,
157-
optional=True,
158-
kwargs={
159-
"dtype",
160-
"layout",
161-
"device",
162-
"pin_memory",
163-
"non_blocking",
164-
"memory_format",
165-
},
166-
),
167-
OpStep(op=torch.ops.aten.mul.Tensor, nargs=2, arg_index=1),
168-
OpStep(op=torch.ops.aten.rsqrt.default),
169-
OpStep(op=torch.ops.aten.add.Tensor, nargs=2),
170-
OpStep(op=torch.ops.aten.mean.dim, nargs=(2, 3), kwargs={"dtype"}),
171-
OpStep(op=torch.ops.aten.pow.Tensor_Scalar, nargs=2),
172-
OpStep(
173-
op=torch.ops.aten._to_copy.default,
174-
optional=True,
175-
require_single_user=False, # _to_copy output used by both pow and mul
176-
kwargs={
177-
"dtype",
178-
"layout",
179-
"device",
180-
"pin_memory",
181-
"non_blocking",
182-
"memory_format",
183-
},
184-
),
185-
],
186-
)
187-
if result is None:
188-
continue
189-
190-
original_input, entries = result
191-
to_copy_out, mul, rsqrt, add, mean, pow, to_copy_in = entries
192-
193-
# If input _to_copy matched, verify it has exactly 2 users: pow and mul
194-
if to_copy_in is not None:
195-
users = set(to_copy_in.users.keys())
196-
expected_users = {pow, mul}
197-
if users != expected_users:
198-
continue
199-
200-
# Validate pow exponent is 2
201-
if pow.args[1] != 2:
202-
continue
203-
204-
# Extract epsilon from add node (it's a lifted tensor constant)
205-
eps_value = None
206-
for arg in add.args:
207-
eps_value = extract_lifted_tensor_constant(arg)
208-
if eps_value is not None:
209-
break
210-
211-
if eps_value is None:
212-
continue
213-
214-
# Build body from non-None entries
215-
body = [n for n in entries if n is not None]
216-
217-
return cls(
218-
head=head,
219-
body=body,
220-
input_node=original_input,
221-
weight_node=weight_node,
222-
eps=eps_value,
223-
)
224-
225-
return None
226-
227-
228-
class FuseRMSNormPass(ExportPass):
229-
"""
230-
Fuses decomposed RMSNorm operations into aten.rms_norm.
231-
232-
This reduces ~7 ops to 1 fused op per RMSNorm layer.
233-
"""
234-
235-
def call(self, graph_module: GraphModule) -> PassResult:
236-
graph = graph_module.graph
237-
modified = False
238-
239-
for node in list(graph.nodes):
240-
match = RMSNormMatch.maybe_create(node)
241-
if match is None:
242-
continue
243-
244-
# Get input shape for normalized_shape
245-
input_meta = match.input_node.meta.get("val")
246-
if input_meta is None:
247-
continue
248-
249-
# Create fused rms_norm node
250-
with graph.inserting_before(node):
251-
normalized_shape = [input_meta.shape[-1]]
252-
rms_norm_node = graph.call_function(
253-
torch.ops.aten.rms_norm.default,
254-
args=(
255-
match.input_node,
256-
normalized_shape,
257-
match.weight_node,
258-
match.eps,
259-
),
260-
)
261-
rms_norm_node.meta = node.meta.copy()
262-
263-
node.replace_all_uses_with(rms_norm_node)
264-
match.remove_body_nodes(graph)
265-
graph.erase_node(node)
266-
modified = True
267-
268-
if modified:
269-
graph.eliminate_dead_code()
270-
graph.lint()
271-
272-
return PassResult(graph_module, modified)
273-
274-
275102
class CanonicalizePermutePass(ExportPass):
276103
"""
277104
Converts transpose_copy to permute_copy in the edge dialect graph.

0 commit comments

Comments
 (0)