Skip to content

Commit 4747ab7

Browse files
Run attention on MLX wherever the fused kernel can compute it (pytorch#22419)
### The problem Attention on rank-3 tensors exports without complaint and then fails when you run the model: ``` MLX execute failed: [scaled_dot_product_attention] input with shape (2,16,64) expected to be rank 4 ``` PyTorch accepts rank 2, 3, 4 and 5 here. The fused kernel takes rank 4 only, and has other requirements besides. The handler matched the operator by name and checked none of them, so it claimed calls the kernel cannot run. ### Adapting the shapes that can be adapted `ExpandDimsNode` → `SdpaNode` → `SqueezeNode`, as suggested in review, so rank 3 keeps the fused kernel. Rank 2 needs it too and takes two added dimensions. The dimensions go at the front. For a rank-3 input the first dimension is already the head one, so inserting in the middle moves it into the batch slot, which misaligns masks and grouped heads. Admitting a lower rank also reaches code that was only ever given rank 4. The grouped key and value unwrap looks for a repeat on dimension 1, which is the head dimension at rank 4 and the key sequence below it, so at rank 3 it would absorb a repeat carrying real keys. Without a mask the result still matches, because a duplicated key and its value give the same weighted sum, which is what makes this easy to miss. With a causal mask it is off by 5.1. Both unwraps now run only at rank 4. ### Three shapes left to decompose Decomposed calls still run on this backend, which the tests assert rather than assume. | Call | Today | Here | | --- | --- | --- | | Rank 5 and above | fails at execute | decomposes, 3.6e-07 | | Unequal batch at rank 4 | fails at execute | decomposes, 2.4e-07 | | Causal at rank 2 or 3, query shorter than key | not reachable | decomposes, 4.2e-07 | The causal one needs a word. Torch anchors a causal mask at the top left and MLX at the bottom right, so the two disagree whenever the lengths differ, silently. Rank 4 already reaches the kernel today and keeps its current behaviour: fixing that needs either a change to what the speech example computes or a fix to an off-by-one in the decomposed path, so it is filed separately as pytorch#22426. What this change does is avoid opening two more ranks onto the same trap. ### Declining a call is not free Preservation from decomposition is requested per operator, so one claimed call keeps the operator whole for a declined one in the same graph, and that call is then neither lowered nor decomposed: ``` RuntimeError: Missing out variants: {'aten::scaled_dot_product_attention'} ``` Give the whole operator back when any of its calls is unsupported. The framework does offer a per-node filter that would keep the other calls fused. It is deliberately not used, and the docstring says why: on an attention block that reshapes its output, with a declined call in the same graph, that path fails to lower at all with `Cannot view a tensor with shape (1,16,4,16) and strides (1024,16,256,1)`. It trades the fusion cost for a hard failure on a very ordinary shape. The cost of the coarser choice is real and written down: one declined call unfuses the operator's other calls in that graph. This half is not specific to attention. Two calls to `torch.roll` in one graph, one supported and one not, already fail at execute today for the same reason. ### Test plan Fifteen partitioner tests. **Eleven fail before this change**, all fifteen pass after. They assert on serialized nodes, so they check which path a call took rather than only that it answered, and the rejection cases also assert the work stayed on this backend, so they cannot pass when nothing is delegated at all. A rank-3 case was added to the operator suite too, which runs the compiled runtime. Ran every combination on an Apple Silicon Mac against eager: plain, causal, explicit scale, float mask, boolean mask, grouped-query attention, batch size 1, float16 and bfloat16, ranks 2 through 5. - rank 3 matches to 3.6e-07 and stays fused in all seven variants; rank 2 to 3.6e-07 - rank 5 decomposes at 3.6e-07 where it previously failed; unequal batch from failing to 2.4e-07 - the grouped key case measured both ways: absorbed and correct at rank 4, and off by 5.1 at rank 3 with a causal mask if absorbed, which is what the guard prevents - a zero key head count used to divide by zero and abort the whole export; it now declines that node - a mixed supported and unsupported graph exports and runs; two rolls in one graph go from failing at execute to matching eager exactly Exported the speech example and compared it against the same export without this change: **identical serialized node counts and an identical greedy token sequence over twelve decode steps**, so that model is unaffected. Ran the neighbouring backend test files, 71 tests, all passing. Co-authored-by: PyTorch Bot <pytorchbot@users.noreply.github.com>
1 parent a6b115b commit 4747ab7

4 files changed

Lines changed: 395 additions & 8 deletions

File tree

‎backends/mlx/partitioner.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ def ops_to_not_decompose(
133133
handler that rejects the 6-arg edge form, for instance). Preserving an op
134134
the handler then rejects is worse than not preserving it, because the op
135135
neither decomposes into something delegatable nor lowers itself.
136+
137+
A target is only preserved when every node carrying it is supported. One
138+
unsupported node is enough to give the whole operator back to decomposition,
139+
because keeping it would leave that node neither lowered nor decomposed and
140+
export would stop.
141+
142+
The second return value is a per-node filter, which would keep the supported
143+
calls fused and decompose only the rest. It is deliberately not used: it puts
144+
the program on exir's EDGE_DO_NOT_DECOMP path, which fails on an ordinary
145+
attention block that reshapes its output, a shape this backend has to lower.
146+
The cost of the coarser choice is that one declined call also unfuses the
147+
operator's other calls in that graph.
136148
"""
137149
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
138150

@@ -157,6 +169,7 @@ def ops_to_not_decompose(
157169

158170
# Collect ops for nodes that are actually supported
159171
do_not_decompose: list[torch._ops.OpOverload] = []
172+
declined: set[torch._ops.OpOverload] = set()
160173

161174
for node in ep.graph.nodes:
162175
if node.op == "call_function" and isinstance(
@@ -166,6 +179,10 @@ def ops_to_not_decompose(
166179
if info is not None and info.supported:
167180
if node.target not in do_not_decompose:
168181
do_not_decompose.append(node.target)
182+
else:
183+
declined.add(node.target)
184+
185+
do_not_decompose = [op for op in do_not_decompose if op not in declined]
169186

170187
self._not_decompose_cache = (weakref.ref(ep), do_not_decompose)
171188

‎backends/mlx/patterns.py‎

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
AddIntNode,
4545
AddNode,
4646
AsTypeNode,
47+
ExpandDimsNode,
4748
IndexCopyNode,
4849
IntOrVid,
4950
ModIntNode,
@@ -52,10 +53,12 @@
5253
SdpaNode,
5354
SliceNode,
5455
SliceUpdateNode,
56+
SqueezeNode,
5557
SubtractIntNode,
5658
SymSizeNode,
5759
)
5860
from torch.export.exported_program import ExportedProgram
61+
from torch.fx.experimental.symbolic_shapes import statically_known_true
5962
from torch.fx.node import Node
6063

6164

@@ -527,6 +530,61 @@ def _try_unwrap_repeat_kv(cls, node: Node) -> Optional[Tuple[Node, List[Node]]]:
527530
body = [e for e in entries if e is not None]
528531
return base, body
529532

533+
@classmethod
534+
def _kernel_can_compute(cls, sdpa_node: Node) -> bool:
535+
"""Whether the fused kernel can compute this call faithfully.
536+
537+
Its preconditions are narrower than what PyTorch accepts, and a claimed call
538+
that the kernel cannot compute fails loudly at execute rather than falling
539+
back, so declining it here is what sends it to decomposition instead.
540+
"""
541+
q, k, v, attn_mask, _, is_causal, _, _ = cls._parse_sdpa_args_and_kwargs(
542+
sdpa_node
543+
)
544+
operand_vals = [
545+
operand.meta.get("val") if isinstance(operand, Node) else None
546+
for operand in (q, k, v)
547+
]
548+
if any(val is None for val in operand_vals):
549+
return False
550+
551+
# Ranks 2 and 3 are lifted to 4 on emission. Beyond 4 the leading dimensions
552+
# would have to fold together, and a fold pairs the wrong operands as soon as
553+
# one of them broadcasts a batch the others do not.
554+
ranks = {val.dim() for val in operand_vals}
555+
if len(ranks) != 1 or not 2 <= next(iter(ranks)) <= 4:
556+
return False
557+
558+
# Torch anchors a causal mask at the top left and MLX at the bottom right, so
559+
# the two agree only when the query and key lengths are equal. Rank 4 already
560+
# reaches the kernel today and is left alone; the lower ranks are newly lifted
561+
# here, so do not open a path that returns wrong values with no error.
562+
if (
563+
next(iter(ranks)) < 4
564+
and is_causal
565+
and not statically_known_true(
566+
operand_vals[0].shape[-2] == operand_vals[1].shape[-2]
567+
)
568+
):
569+
return False
570+
571+
# The kernel requires the batch sizes to match and rejects the call otherwise,
572+
# so a broadcast batch has to be decomposed rather than fused.
573+
if next(iter(ranks)) == 4 and any(
574+
not statically_known_true(val.shape[0] == operand_vals[0].shape[0])
575+
for val in operand_vals[1:]
576+
):
577+
return False
578+
579+
if attn_mask is not None:
580+
mask_val = (
581+
attn_mask.meta.get("val") if isinstance(attn_mask, Node) else None
582+
)
583+
if mask_val is None or mask_val.dim() > 4:
584+
return False
585+
586+
return True
587+
530588
@classmethod
531589
def maybe_create(cls, ep: ExportedProgram, head: Node) -> Optional["SDPAHandler"]:
532590
sdpa_node = head
@@ -535,15 +593,23 @@ def maybe_create(cls, ep: ExportedProgram, head: Node) -> Optional["SDPAHandler"
535593
):
536594
return None
537595

596+
if not cls._kernel_can_compute(sdpa_node):
597+
return None
598+
538599
q, k, v, _, _, _, _, _ = cls._parse_sdpa_args_and_kwargs(sdpa_node)
539600

540-
# Detect grouped kv attention pattern with repeat_interleave before SDPA
601+
# Detect grouped kv attention pattern with repeat_interleave before SDPA.
602+
# Both unwraps below key on dim 1, which is the head dimension only at rank 4.
603+
# At a lower rank dim 1 is the key sequence, and absorbing a repeat there
604+
# drops keys, which a causal mask then turns into a wrong answer.
605+
is_rank4 = q.meta["val"].dim() == 4 if isinstance(q, Node) else False
541606
is_grouped_kv = False
542607
k_base = k
543608
v_base = v
544609
body: List[Node] = []
545610
if (
546-
match_target(k, torch.ops.aten.repeat_interleave.self_int)
611+
is_rank4
612+
and match_target(k, torch.ops.aten.repeat_interleave.self_int)
547613
and has_single_user(k)
548614
and (len(k.args) == 3)
549615
and (len(k.kwargs) == 0)
@@ -563,7 +629,7 @@ def maybe_create(cls, ep: ExportedProgram, head: Node) -> Optional["SDPAHandler"
563629

564630
# Detect HuggingFace repeat_kv pattern:
565631
# unsqueeze(dim=2) → expand → clone → view
566-
if not is_grouped_kv:
632+
if is_rank4 and not is_grouped_kv:
567633
k_unwrap = cls._try_unwrap_repeat_kv(k)
568634
v_unwrap = cls._try_unwrap_repeat_kv(v)
569635
if k_unwrap is not None and v_unwrap is not None:
@@ -572,6 +638,27 @@ def maybe_create(cls, ep: ExportedProgram, head: Node) -> Optional["SDPAHandler"
572638
is_grouped_kv = True
573639
body = k_body + v_body
574640

641+
# Checked after the unwrapping above, because grouped-query attention reaches
642+
# the kernel with its original head counts. MLX pairs heads only when the key
643+
# and value agree and the query is a whole multiple of them.
644+
kernel_vals = [
645+
node.meta.get("val") if isinstance(node, Node) else None
646+
for node in (q, k_base, v_base)
647+
]
648+
if any(val is None for val in kernel_vals):
649+
return None
650+
q_heads, k_heads, v_heads = (
651+
1 if val.dim() == 2 else val.shape[-3] for val in kernel_vals
652+
)
653+
# A zero head count would make the multiple test below divide by zero, and a
654+
# raise here aborts the whole export rather than declining this one node.
655+
if not statically_known_true(k_heads > 0):
656+
return None
657+
if not statically_known_true(k_heads == v_heads):
658+
return None
659+
if not statically_known_true(q_heads % k_heads == 0):
660+
return None
661+
575662
head = sdpa_node
576663
if not is_grouped_kv:
577664
body = []
@@ -593,19 +680,49 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot:
593680
assert dropout_p == 0.0, "SDPA with dropout is not supported"
594681

595682
q, k, v, attn_mask = P.slot_map([q, k, v, attn_mask])
683+
# Add the dimensions the kernel is missing at the front, never in the middle.
684+
# For a rank-3 input the first dimension is already the head one, so inserting
685+
# there would move it into the batch slot and misalign masks and grouped heads.
686+
input_nodes = (self.q_node, self.k_node, self.v_node)
687+
inputs = [q, k, v]
688+
for i, input_node in enumerate(input_nodes):
689+
for _ in range(4 - input_node.meta["val"].dim()):
690+
_, expanded = P.make_tmp_slot()
691+
P.emit(
692+
ExpandDimsNode(
693+
x=P.slot_to_tid(inputs[i]),
694+
out=P.slot_to_tid(expanded),
695+
axis=0,
696+
)
697+
)
698+
inputs[i] = expanded
699+
700+
output_rank = n.meta["val"].dim()
701+
596702
out = P.make_or_get_slot(n)
703+
sdpa_out = out
704+
if output_rank < 4:
705+
_, sdpa_out = P.make_tmp_slot()
597706

598707
P.emit(
599708
SdpaNode(
600-
q=P.slot_to_tid(q),
601-
k=P.slot_to_tid(k),
602-
v=P.slot_to_tid(v),
603-
out=P.slot_to_tid(out),
709+
q=P.slot_to_tid(inputs[0]),
710+
k=P.slot_to_tid(inputs[1]),
711+
v=P.slot_to_tid(inputs[2]),
712+
out=P.slot_to_tid(sdpa_out),
604713
scale=scale,
605714
mask=P.slot_to_tid(attn_mask) if attn_mask else None,
606715
causal=is_causal,
607716
)
608717
)
718+
if output_rank < 4:
719+
P.emit(
720+
SqueezeNode(
721+
x=P.slot_to_tid(sdpa_out),
722+
out=P.slot_to_tid(out),
723+
dims=list(range(4 - output_rank)),
724+
)
725+
)
609726
return out
610727

611728

‎backends/mlx/test/test_ops.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6275,6 +6275,30 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
62756275
return (q, k, v)
62766276

62776277

6278+
@register_test
6279+
class SDPARank3Test(OpTestCase):
6280+
"""Attention on rank-3 tensors, which PyTorch accepts and the fused kernel does not.
6281+
6282+
The node counts are the point of the test: they assert the fused kernel is still
6283+
used, rather than the operator having been decomposed into primitives.
6284+
"""
6285+
6286+
name = "sdpa_rank3"
6287+
rtol = 1e-3
6288+
atol = 1e-3
6289+
expected_node_counts = {
6290+
"SdpaNode": 1,
6291+
"ExpandDimsNode": 3,
6292+
"SqueezeNode": 1,
6293+
}
6294+
6295+
def create_model(self) -> nn.Module:
6296+
return SDPAModel()
6297+
6298+
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
6299+
return tuple(torch.randn(2, 16, 64) for _ in range(3))
6300+
6301+
62786302
class CustomSDPAModel(nn.Module):
62796303
"""
62806304
Test model for mlx::custom_sdpa with KVCache.

0 commit comments

Comments
 (0)