Skip to content

perf(models): general MoE decode gap vs mlx-lm (dots.llm1 0.51x, qwen3_moe 0.69x, nemotron MoE) #268

Closed

Description

@inureyes

Problem / Background

On M1 Ultra, dots.llm1.inst-mixed-4-6bit decodes at ~13.2 tok/s in mlxcel versus ~25.7 tok/s in mlx-lm (0.51x, a ~2x gap). This is NOT a Mamba2 hybrid. dots.llm1 is a DeepSeek-V3-style MoE (sigmoid router with e_score_correction_bias selection-only bias, unbiased gather, norm_topk_prob, routed_scaling_factor) with mixed 4-bit/6-bit expert quantization (v_proj/down_proj at 6-bit). For comparison on the same host and harness, the other MoE family (LFM2-8B-A1B, lfm2-moe) is at parity (~1.00x) and dense granite-3.3-2b is at ~0.99x, so the gap is specific to this model's routing / attention / mixed-precision-gather path, not a general MoE problem.

Measured on an Apple M1 Ultra (128 GB, macOS 26.5.1, MLX commit a6ec712) against mlx-lm 0.31.3. Decode throughput was measured with mlxcel-bench-decode (warmup pass + measured pass in one process, --no-chat-template) versus scripts/bench_mlxlm.py (mlx-lm's own prompt_tps/generation_tps), identical checkpoint, raw continuation prompt, 100 decode tokens, best-of-3 with cooldown, temp-0 greedy.

Proposed Solution

Investigate and root-cause the ~2x decode gap. Suspects to profile:

  • SwitchGLU / gather_qmm with mixed 4/6-bit experts (the per-tensor bit inference added in PR feat(models): add dots.llm1 (dots1) MoE #263 / switch_layers.rs).
  • The attention path (whether dots.llm1 uses MLA and how the absorbed/decompressed projections are implemented vs mlx-lm).
  • Router overhead (group_mask_scores, sigmoid + correction-bias selection).
  • Any extra eval() / synchronization points or avoidable astype/graph nodes in the decode step.

Use scripts/bench_decode.sh / mlxcel-bench-decode and scripts/bench_mlxlm.py; the mlxcel-gpu-profiling skill may help localize the hotspot.

Acceptance Criteria

  • Dominant cause of the ~2x decode gap identified and documented.
  • Gap closed meaningfully (target parity-ish with mlx-lm).
  • Greedy temp-0 output unchanged.

Activity

  1. added
    area:modelsModel architectures, weights, loading, metadata
    area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)
    platform:macosmacOS (Apple Silicon) specific
    on Jun 13, 2026
  2. added and removed on Jun 13, 2026
  3. inureyes commented on Jun 13, 2026

    @inureyes
    MemberAuthor

    Investigation: this is a general MoE-decode gap, not dots.llm1-specific

    Benchmarked several MoE families on M1 Ultra (decode tok/s, mlxcel-bench-decode vs mlx-lm 0.31.3, raw prompt, 100 tok after warmup, temp 0):

    Model active params mlxcel mlx-lm ratio
    LFM2-8B-A1B (lfm2_moe) ~1B 184 184 1.00×
    qwen3-30b-a3b (qwen3_moe) ~3B 47.2 68.7 0.69×
    nemotron-h-30b (MoE ~60% of decode) — 55 92 0.60×
    dots.llm1 (dots1) ~14B 13.2 25.7 0.51×

    The gap scales with MoE compute and appears across three different MoE implementations (qwen3_moe, the dots1 DeepSeek-V3 router, nemotron's local SwitchMLP). lfm2_moe (smallest active set) is at parity. So this is a general MoE-decode issue; dots.llm1 is just the worst case.

    Ruled out

    • Forced per-layer evals. The only eval() calls in qwen3_moe.rs / switch_layers.rs are in forward_profiled (profiling-only) and test code. Production forward is clean. dots1.rs has zero eval(). So this is not a perf(models): gate Mamba2 mixer eval boundary to M5 Max #266-style fusion-breaking sync.
    • SwitchGLU orchestration. src/models/switch_layers.rs SwitchGLU::forward matches mlx-lm's switch_layers.py exactly: same do_sort = total >= 64 threshold (so decode takes the unsorted path on both), same gather_qmm(transpose=True, sorted_indices=do_sort).
    • Router / combine algorithm. dots1 router (sigmoid + e_score_correction_bias selection-only + unbiased gather + norm_topk_prob + routed_scaling_factor) and moe_weighted_sum match the mlx-lm reference op-for-op.

    Localization (qwen3-30b-a3b, MLXCEL_PROFILE_BLOCKS=1 MLXCEL_PROFILE_QWEN3_MOE_DETAIL=1)

    Per MoE layer (profile mode forces per-step eval, so absolute ms are inflated; the split is the signal):

    • Layer: ~79% MoE (mlp), ~21% attention.
    • Within MoE (~1.5 ms): expert gather_qmm ~39%, router ~25%, combine ~20%, residual/overhead ~16%.

    Router + combine together (~45% of MoE) are many tiny ops on [1, n_experts] / [1, top_k] tensors. On a single decode token these are latency/dispatch-bound, not throughput-bound.

    Conclusion / next steps

    The production MoE decode path is structurally identical to mlx-lm and uses the same MLX gather_qmm kernel (commit a6ec712), yet mlxcel is slower in proportion to MoE compute. The gap is execution efficiency in the decode MoE (per-op dispatch on small tensors + the gather_qmm path), not the algorithm. This needs a Metal-trace comparison against mlx-lm to attribute the difference (gather_qmm kernel time vs op-dispatch overhead vs lazy-graph fusion), then likely a fused router/combine path or a fused decode-MoE kernel. It is a deliberate kernel/fusion effort, not a one-line fix, so it is parked here with this diagnosis rather than rushed.

    Related: the nemotron-h decode gap (#267 follow-up) is the same MoE bottleneck; qwen3_moe is affected too. Whatever fix lands here should be validated across all three.

  4. added and removed on Jun 13, 2026
  5. changed the title [-]perf(models): dots.llm1 decodes at ~0.51x of mlx-lm (MoE/MLA path)[/-] [+]perf(models): general MoE decode gap vs mlx-lm (dots.llm1 0.51x, qwen3_moe 0.69x, nemotron MoE)[/+] on Jun 13, 2026
  6. inureyes commented on Jun 13, 2026

    @inureyes
    MemberAuthor

    GPU profiling: the MoE gap is GPU-bound dtype-conversion plumbing (773 AsType/token)

    Profiled qwen3-30b-a3b-4bit decode on M1 Ultra (the fast-iterating MoE proxy; 0.69× of mlx-lm) using the mlxcel-gpu-profiling hooks.

    Step 0 — it's GPU-bound, not CPU/dispatch

    MLXCEL_PROFILE_PIPELINE_DETAIL: async_eval (GPU encode+execute) 20.3 ms/tok vs forward (Rust graph build) 0.8 ms/tok vs item_wait 0.14 ms. So ~95% of decode is GPU; the Rust/FFI graph-build is negligible. Command-buffer batching (MLX_MAX_OPS_PER_BUFFER swept 100–1000) is flat at ~47 tok/s, so it is not command-buffer-gap bound either.

    Step 1 — not at the memory wall

    ~3B active params at 4-bit ≈ 1.7 GB streamed/token; 47 tok/s × 1.7 GB ≈ 80 GB/s effective, ~16–20% of M1 Ultra GEMV peak (~450 GB/s). The GPU is mostly idle — it spends its 20.3 ms on many small kernels, not on streaming weights. (mlx-lm at 68.7 tok/s ≈ 117 GB/s, also well under the wall — MoE per-expert GEMVs underutilize the GPU on both, but mlxcel worse.)

    Step 2 — the concentrated excess: AsType

    One-decode-token op histogram (MLXCEL_EXPORT_DECODE_DOT vs mlx-lm mx.export_to_dot), 48 layers:

    Op mlxcel mlx-lm excess
    AsType 773 0 +773
    Slice 288 144 +144
    Reshape 435 336 +99
    ExpandDims 144 96 +48
    QuantizedMatmul 145 241 −96
    GatherQMM / RMSNorm / Transpose / SDPA / Sum / RoPE / Add = = 0
    total nodes 3476 2505 +971

    mlx-lm emits zero dtype conversions in its decode graph; mlxcel emits 773/token (16/layer) — ~80% of the total node excess. They are not from mlxcel Rust code (≤6 explicit astype in the whole hot path, all in the unrelated KV-cache turbo module); they are inserted by the MLX C++ quantized-op layer. With 289 quantized ops/token (145 QuantizedMatmul + 144 GatherQMM), that is ≈2.7 AsType per quantized op — i.e. the activations entering/leaving quantized_matmul/gather_qmm are a different dtype than the quantized params, so MLX inserts conversions. mlx-lm keeps the whole decode in one dtype and pays none.

    This is the same root cause the mlxcel-gpu-profiling skill documented for Gemma3n (#762: "AsType from f16/bf16 mixing"), here at larger magnitude because MoE op density is higher.

    Fix direction

    Trace the decode dtype flow around the quantized ops and make the activation dtype match the quantized params so MLX inserts no AsType (mlx-lm's invariant). Likely the same bf16/f16 mixing class as #762 / [[bf16-quant-scales]]; start at the most frequent site (the per-layer q/k/v/o + router quantized matmuls and the gate/up/down gather_qmm).

    Caveat (from #762): MLX fuses elementwise AsType chains at eval, so the static node count overstates GPU kernels — Gemma3n recovered only ~2.6% from AsType removal. So validate the runtime payoff per change, do not assume 773→0 maps to the full 0.69×→1.0×. But it is the clear lead, and MoE density is higher than Gemma3n, so it warrants a measured attempt.

    Validation harness: re-bench qwen3-30b-a3b (cheap, 16 GB) for each change, then confirm on dots.llm1 + nemotron-h; greedy temp-0 output byte-identical; RMS < 5e-3 for any fused-kernel substitution.

  7. 17 remaining items

  8. self-assigned this
    on Jun 16, 2026
  9. added this to the 0.4 milestone on Jun 21, 2026
  10. inureyes commented on Jun 22, 2026

    @inureyes
    MemberAuthor

    Resolution

    The MoE decode-gap work for this issue is complete and shipped default-on. Summarizing for closure.

    What landed

    A fused single-token decode-MoE Metal kernel, built and tuned over a series of merged PRs:

    Subsequently the same kernel was wired to more families: qwen2_moe (#308), mixtral (#311), CUDA (#319), and minimax (#390), plus olmoe.

    Per-model decode gains (M1 Ultra, single-token, default-on)

    Model Gain Notes
    gemma4 (GeGLU) +13% byte-identical (chat template)
    qwen3.5 / 3.6 +8.7% f16 jitter-class (documented)
    dots.llm1 (4+6-bit) +4.7% byte-identical
    qwen3-30b-a3b +3.5% byte-identical
    qwen1.5-moe ~par small/fast MoE, little to gain
    nemotron-h ~0% reverted from default; gap is SSM/attn

    Acceptance criteria

    • Dominant cause of the decode gap identified and documented (investigation + design docs above).
    • Gap closed meaningfully: fused kernel default-on with the per-model gains above.
    • Greedy temp-0 output unchanged: byte-identical where measured, or documented f16 jitter-class on deep hybrids (qwen3.5) with the design harness allowing "byte-identical OR jitter class."

    Residual

    The headline dots.llm1 figure improves from 0.51x to 0.53x of mlx-lm. The deeper finding (documented in the investigation report and the nemotron-h #284 profile) is that the remaining gap is fundamental single-token GPU underutilization across a diffuse set of small per-op overheads, with no remaining single high-value lever; an AsType-reduction campaign caps at roughly +2.6% on this class. Further single-token MoE decode speedup would need a different approach (e.g. batching/speculative decode) tracked separately, not more fusion.

    Closing as completed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)area:modelsModel architectures, weights, loading, metadataplatform:macosmacOS (Apple Silicon) specificpriority:mediumMedium prioritystatus:doneCompletedtype:performancePerformance improvements

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions