Skip to content

Commit fdb88c3

Browse files
AxelNouncursoragentmetascroy
authored
backends/mlx: runtime MoE expert-sort for decode (issue #20554) (#20685)
## Summary Replace the compile-time `sort_experts: bool` flag in `SwitchMLP` with a runtime decision inside two new custom ops (`moe_gather_inputs`, `moe_scatter_outputs`). A single exported `.pte` now handles both prefill (sorted, coalesced `gather_mm`) and decode (unsorted, no argsort overhead) without separate exports. **Wire-compatible schema:** `sorted_indices: bool` is retained on `GatherMmNode`/`GatherQmmNode`; a new optional `sorted_indices_flag: IntOrVid` field (appended last) carries runtime 0/1 values. `MLXInterpreter.h` prefers the flag when present, otherwise falls back to the static bool. **Serialization fix:** `TakeNode.index` expects `IntOrVidOrTid`; MoE handlers now pass `IntOrVidOrTid.from_tid(...)` instead of a raw `Tid` (fixes export-time FlatBuffer serialization failure). `MLXLoader.{h,cpp}` and FlatBuffer bindings are regenerated automatically by `generate.py` + `flatc` during the CMake build on Mac CI — not included in this commit, per repo convention. ## Test plan - [x] Windows: `python backends/mlx/test/validate_moe_20554.py` (all passed) - [x] Windows: export → lowering → FlatBuffer serialization validated for MoE + GatherMm/GatherQmm (Python path, no Metal) - [ ] CI: `test-mlx` job on `macos-14-xlarge` (`run_all_tests`) Fixes #20554 PR authored with Claude. cc @metascroy --------- Co-authored-by: Axel.Cffrd.Dnty <AxelNoun@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Scott Roy <scroy@meta.com>
1 parent 097b9aa commit fdb88c3

13 files changed

Lines changed: 890 additions & 54 deletions

File tree

backends/mlx/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ message(
187187
set(_mlx_patches
188188
${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_nax_has_include.patch
189189
${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_qmm_splitk_bk_align.patch
190+
${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_gather_mm_rhs_lda.patch
190191
)
191192
ExternalProject_Add(
192193
mlx_external

backends/mlx/builder/op_helpers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,27 @@ def emit_ceil_div(
359359
return P.to_int_or_vid(out_slot)
360360

361361

362+
def emit_floordiv(
363+
P: "MLXProgramBuilder",
364+
a: "IntOrVid",
365+
b: "IntOrVid",
366+
) -> "IntOrVid":
367+
"""Emit ``a // b`` (floor division), folding when both operands are
368+
static.
369+
"""
370+
from executorch.backends.mlx.serialization.mlx_graph_schema import (
371+
FloorDivideIntNode,
372+
IntOrVid,
373+
)
374+
375+
if not a.is_vid and not b.is_vid:
376+
return IntOrVid.from_literal(a.literal // b.literal)
377+
378+
_, out_slot = P.make_tmp_value_slot()
379+
P.emit(FloorDivideIntNode(a=a, b=b, out=P.slot_to_vid(out_slot)))
380+
return P.to_int_or_vid(out_slot)
381+
382+
362383
def emit_if_else(
363384
P: "MLXProgramBuilder",
364385
cond: "IntOrVid",

backends/mlx/custom_ops.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
can execute efficiently but may not have direct PyTorch equivalents.
1515
"""
1616

17-
from typing import Optional
17+
from typing import Optional, Tuple
1818

1919
import torch
2020
from torch import Tensor
@@ -285,7 +285,7 @@ def gather_mm(
285285
b: Tensor, # [E, K, N] or [..., K, N]
286286
rhs_indices: Optional[Tensor] = None, # Expert selection indices
287287
lhs_indices: Optional[Tensor] = None, # Optional LHS gather indices
288-
sorted_indices: bool = False,
288+
sorted_indices: Optional[Tensor] = None, # 0-d int; None/0 = unsorted
289289
) -> Tensor:
290290
"""
291291
Gather matrix multiply — matches mlx::core::gather_mm semantics exactly.
@@ -295,6 +295,10 @@ def gather_mm(
295295
296296
For MoE: a=[N_tokens, 1, K], b=[E, K, out], rhs_indices=[N_tokens]
297297
→ output=[N_tokens, 1, out]. Caller squeezes dim -2.
298+
299+
sorted_indices is layout-only (a correctness contract for the MLX kernel
300+
at runtime); numerics are identical either way, so the eager reference
301+
ignores it.
298302
"""
299303
if rhs_indices is not None:
300304
b_sel = b[rhs_indices]
@@ -309,7 +313,7 @@ def gather_mm_fake(
309313
b: Tensor,
310314
rhs_indices: Optional[Tensor] = None,
311315
lhs_indices: Optional[Tensor] = None,
312-
sorted_indices: bool = False,
316+
sorted_indices: Optional[Tensor] = None,
313317
) -> Tensor:
314318
# Matches MLX: output = indices.shape + [M, N]
315319
# For simplicity, use matmul shape rules after gather
@@ -334,7 +338,7 @@ def gather_qmm(
334338
group_size: int = 32,
335339
bits: int = 4,
336340
mode: str = "affine",
337-
sorted_indices: bool = False,
341+
sorted_indices: Optional[Tensor] = None, # 0-d int; None/0 = unsorted
338342
) -> Tensor:
339343
"""
340344
Gather quantized matrix multiply — matches mlx::core::gather_qmm semantics.
@@ -343,6 +347,8 @@ def gather_qmm(
343347
344348
For MoE: x=[N_tokens, 1, K], w=[E, out, K_packed], rhs_indices=[N_tokens]
345349
→ output=[N_tokens, 1, out]. Caller squeezes dim -2.
350+
351+
sorted_indices is layout-only; ignored here (see gather_mm docstring).
346352
"""
347353
# Eager fallback: gather, dequantize, matmul
348354
if rhs_indices is not None:
@@ -392,7 +398,7 @@ def gather_qmm_fake(
392398
group_size: int = 32,
393399
bits: int = 4,
394400
mode: str = "affine",
395-
sorted_indices: bool = False,
401+
sorted_indices: Optional[Tensor] = None,
396402
) -> Tensor:
397403
# Matches MLX: output = indices.shape + [M, N]
398404
M = x.shape[-2]
@@ -465,3 +471,76 @@ def sample(
465471
@torch.library.register_fake("mlx::sample")
466472
def sample_fake(logits, temperature, top_k, top_p, seed=None):
467473
return logits.new_empty(logits.shape[:-1], dtype=torch.long)
474+
475+
476+
# ---------------------------------------------------------------------
477+
# Runtime MoE expert-sort for decode (MLX backend)
478+
# ---------------------------------------------------------------------
479+
480+
481+
@torch.library.custom_op("mlx::moe_gather_inputs", mutates_args=())
482+
def moe_gather_inputs(
483+
x: Tensor, expert_indices: Tensor, top_k: int, sort_cutoff: int
484+
) -> Tuple[Tensor, Tensor, Tensor, Tensor]:
485+
"""Branch on M on purpose — this is the executable spec the lowering
486+
handler (ops.py) mirrors branch-for-branch. Sorting is an invertible
487+
permutation (identical numerics either way); the two paths exist for
488+
the lowering's sake, not the math's."""
489+
N = x.shape[0]
490+
if N > sort_cutoff: # SORTED path (handler: emit_sorted)
491+
flat = expert_indices.flatten()
492+
order = flat.argsort().to(torch.int32)
493+
inv_order = order.argsort().to(torch.int32)
494+
idx = flat[order].to(torch.int32) # [N*top_k]
495+
x_input = x[(order // top_k).to(torch.int64)].unsqueeze(-2) # [N*top_k, 1, D]
496+
sort_experts = torch.ones((), dtype=torch.int32)
497+
else: # UNSORTED path (handler: emit_unsorted)
498+
x_input = x.repeat_interleave(top_k, dim=0).unsqueeze(-2) # [N*top_k, 1, D]
499+
idx = expert_indices.flatten().to(torch.int32) # [N*top_k]
500+
sort_experts = torch.zeros((), dtype=torch.int32)
501+
# Identity permutation: inverse of "no reorder". Safe if scatter
502+
# accidentally takes the sorted branch (Take becomes a no-op).
503+
inv_order = torch.arange(N * top_k, dtype=torch.int32)
504+
return x_input, idx, sort_experts, inv_order
505+
506+
507+
@torch.library.register_fake("mlx::moe_gather_inputs")
508+
def moe_gather_inputs_fake(
509+
x: Tensor, expert_indices: Tensor, top_k: int, sort_cutoff: int
510+
) -> Tuple[Tensor, Tensor, Tensor, Tensor]:
511+
"""Must NOT branch on M (symbolic SymInt under export — data-dependent
512+
control flow on it is illegal). One shape for all M: the sorted-path
513+
shape for x_input/idx/inv_order."""
514+
N = x.shape[0]
515+
D = x.shape[-1]
516+
NK = N * top_k
517+
x_input = x.new_empty((NK, 1, D))
518+
idx = expert_indices.new_empty((NK,), dtype=torch.int32)
519+
sort_experts = x.new_empty((), dtype=torch.int32)
520+
inv_order = x.new_empty((NK,), dtype=torch.int32)
521+
return x_input, idx, sort_experts, inv_order
522+
523+
524+
@torch.library.custom_op("mlx::moe_scatter_outputs", mutates_args=())
525+
def moe_scatter_outputs(
526+
down: Tensor, sort_experts: Tensor, inv_order: Tensor, top_k: int
527+
) -> Tensor:
528+
down = down.squeeze(-2) # [N*top_k, H]
529+
if sort_experts.item(): # prefill: scatter back (handler: emit_then)
530+
down = down[inv_order]
531+
# decode: no scatter; inv_order is identity (handler: emit_else).
532+
# .clone(): output must not alias the input under mutates_args=() —
533+
# required by torch.library.opcheck's aliasing check on the no-op
534+
# (unsorted) reshape path.
535+
return down.reshape(down.shape[0] // top_k, top_k, -1).clone() # [N, top_k, H]
536+
537+
538+
@torch.library.register_fake("mlx::moe_scatter_outputs")
539+
def moe_scatter_outputs_fake(
540+
down: Tensor, sort_experts: Tensor, inv_order: Tensor, top_k: int
541+
) -> Tensor:
542+
"""Shape derived only from down + top_k — no branching needed, no
543+
dependency on inv_order's shape."""
544+
NK = down.shape[0]
545+
H = down.shape[-1]
546+
return down.new_empty((NK // top_k, top_k, H))

backends/mlx/llm/switch.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"""
4242

4343
import logging
44+
from typing import Optional
4445

4546
import torch
4647
import torch.nn as nn
@@ -171,15 +172,20 @@ def forward(
171172
self,
172173
x: torch.Tensor,
173174
indices: torch.Tensor,
174-
sorted_indices: bool = False,
175+
sorted_indices: Optional[torch.Tensor] = None,
175176
) -> torch.Tensor:
176177
"""Forward without unsqueeze/squeeze — caller manages dimensions.
177178
178179
Used by UnfusedMoEExperts which passes x as [N, 1, 1, D]
179180
and indices as [N, top_k] to handle all experts at once.
181+
182+
sorted_indices: None, or a 0-d int tensor where 0 means the expert
183+
gather is unsorted and any nonzero value means it is sorted (see
184+
gather_mm/gather_qmm docstrings in custom_ops.py). Passed straight
185+
through to those ops.
180186
"""
181187
if not self._packed:
182-
raise RuntimeError("SwitchLinear.pack() must be called before forward_raw.")
188+
raise RuntimeError("SwitchLinear.pack() must be called before forward.")
183189

184190
if self._is_quantized:
185191
return torch.ops.mlx.gather_qmm(
@@ -233,6 +239,7 @@ def __init__(
233239
activation=None,
234240
bias: bool = False,
235241
fuse_gate_up: bool = False,
242+
sort_cutoff: int = 1,
236243
):
237244
super().__init__()
238245
if activation is None:
@@ -241,6 +248,11 @@ def __init__(
241248
self.num_experts = num_experts
242249
self.intermediate_size = intermediate_size
243250
self.fuse_gate_up = fuse_gate_up
251+
# Static export-time threshold, compared against M=N inside
252+
# moe_gather_inputs to decide sort/no-sort at runtime.
253+
if sort_cutoff < 1:
254+
raise ValueError(f"sort_cutoff must be >= 1, got {sort_cutoff}")
255+
self.sort_cutoff = sort_cutoff
244256

245257
if fuse_gate_up:
246258
self.gate_up_proj = SwitchLinear(
@@ -263,7 +275,6 @@ def forward(
263275
expert_weights: torch.Tensor,
264276
expert_indices: torch.Tensor,
265277
top_k: int,
266-
sort_experts: bool = False,
267278
) -> torch.Tensor:
268279
"""Forward pass through the gated MoE MLP.
269280
@@ -272,25 +283,17 @@ def forward(
272283
expert_weights: Routing weights [N, top_k] (already softmaxed)
273284
expert_indices: Expert assignments [N, top_k]
274285
top_k: Number of experts per token
275-
sort_experts: Sort tokens by expert index for coalesced memory
276-
access during prefill. No effect on decode (single token).
277286
278287
Returns:
279288
Output tensor [N, D]
289+
290+
Sort/no-sort is a runtime decision (M vs self.sort_cutoff) made
291+
inside moe_gather_inputs, rather than a compile-time flag. Configure
292+
the threshold once via SwitchMLP(..., sort_cutoff=...).
280293
"""
281-
N = x.shape[0]
282-
283-
if sort_experts:
284-
flat_indices = expert_indices.flatten()
285-
order = flat_indices.argsort().to(torch.int32)
286-
inv_order = order.argsort().to(torch.int32)
287-
sorted_idx = flat_indices[order].to(torch.int32)
288-
x_sorted = x[(order // top_k).to(torch.int64)]
289-
x_input = x_sorted.unsqueeze(-2)
290-
idx = sorted_idx
291-
else:
292-
x_input = x.unsqueeze(-2).unsqueeze(-2)
293-
idx = expert_indices
294+
x_input, idx, sort_experts, inv_order = torch.ops.mlx.moe_gather_inputs(
295+
x, expert_indices, top_k, self.sort_cutoff
296+
)
294297

295298
if self.fuse_gate_up:
296299
gate_up = self.gate_up_proj(x_input, idx, sorted_indices=sort_experts)
@@ -302,11 +305,7 @@ def forward(
302305
h = self.activation(gate) * up
303306
down = self.down_proj(h, idx, sorted_indices=sort_experts)
304307

305-
if sort_experts:
306-
down = down.squeeze(-2)
307-
down = down[inv_order].reshape(N, top_k, -1)
308-
else:
309-
down = down.squeeze(-2)
308+
down = torch.ops.mlx.moe_scatter_outputs(down, sort_experts, inv_order, top_k)
310309

311310
return (down * expert_weights.unsqueeze(-1)).sum(dim=-2)
312311

0 commit comments

Comments
 (0)