Trying the mlxfast challenge: speeding up AI on a Mac
A beginner-friendly field report. No leaderboard win yet — but we learned a lot about how modern on-device inference is already optimized, and how easy it is to fool yourself with noisy benchmarks.
What is mlxfast, in plain English?
Imagine a cooking contest where every chef gets the same recipe book and the same ingredients. You are not allowed to invent a new dish. You have to make the exact same meal — same bites in the same order — but finish faster.
That is mlxfast.
- The meal = the next token an AI model would produce (greedy / temperature-zero behavior).
- The kitchen = Apple Silicon (Metal GPU + unified memory), not NVIDIA GPUs.
- The recipe = Poolside Laguna XS 2.1, a medium-size MoE language model shipped in a compact 4-bit format called NVFP4.
- The stopwatch = official ranking on a self-hosted M5 Max 128 GB machine. Your laptop numbers are practice only.
The public score blends two speeds:
score = decode_speedup^0.75 × prefill_speedup^0.25
Prefill is “read the whole prompt once.” Decode is “emit the next token, one at a time.” Decode is weighted more heavily because chat feels slow when each word crawls out.
One more hard rule: this track is serial. You may not cheat by drafting future tokens or running speculative multi-token tricks. Each one-token decode request is allowed to advance exactly one position. That keeps the contest honest about “single-step inference work.”
- Token — a word piece the model thinks in (not always a full English word).
- MoE (Mixture of Experts) — instead of one giant dense brain, the model routes each token through a few of many small “expert” networks. Laguna uses 256 routed experts + a shared expert, picking 8 experts per token on sparse layers.
- NVFP4 — a compact 4-bit weight format. Smaller memory traffic usually means faster decode on bandwidth-bound hardware.
- Fusion — combining several tiny GPU steps into one kernel so you pay less launch/sync overhead.
- Bit-exact / class A — optimizations that should not change the numeric result people care about (here: the greedy token stream under the harness gates).
What we set up
We did this on a Mac Studio-class M4 Max with 64 GB unified memory. That is enough to load the ~21.6 GB text tower plus working room (the docs say roughly 36 GB practical minimum; the ranked box has 128 GB and stays more comfortable).
github.com/Layr-Labs/mlxfast-challengeCommand Line Tools alone are not enough for
mlx.metallib.~21.6 GB from the organizer mirror, SHA-256 checked against the repo manifest.
Trusted harness (
mlxfast-swift) + sandboxed worker (mlxfast-runtime-worker) plus Metal library../benchmark.sh --local-iterate for correctness smoke + directional timing.Useful local knobs we hit immediately:
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
export MLXFAST_LOCAL_COOL_GATE=0 # desk GPU often won't sit ≤40°C
export MLXFAST_LOCAL_ALLOW_GOLDEN_DRIFT=1 # M5 goldens can near-tie differently on M4
./benchmark.sh --local-iterate
What “already optimized” looks like
We turned on the challenge’s fusion tracer and watched the worker. On the stock main tree, twenty-four fused paths announced themselves during the timed window — attention fusions, MoE gather/QMV fusions, residual+norm+router fusions, lm_head pruning, packed scale layouts, and more.
In beginner terms: most of the obvious “glue many small ops together” work is already done in upstream main. If you walk in expecting a free +20% from one clever idea, you will be disappointed.
The editable surface is also broader than a typical “tune configs” contest. You can touch:
- the Swift runtime model path,
- the offline weight transform,
- and listed vendored MLX Metal kernels.
But you still have to pass hidden correctness gates and a paired timed measurement on M5. Bigger wins must often be chunked across submissions because a single run’s acceptance band caps about +5% versus the pinned calibration reference.
Experiment 1: a tiny kernel tweak (negative result)
Using a coding agent (Nous-hosted DeepSeek V4 Flash as a helper while our local Spark lane was briefly down), we tried a reversible, default-off micro-opt on the gated NVFP4 attention output-projection kernel:
- drop a dead
+0.0seed in the first qdot group, and - fold the E4M3 scale sign the same way sibling MoE kernels already do.
It compiled. Fusion traces proved the kernel path actually fired (not a silent fallback). Then we measured n=3 off vs on.
| Arm | Mean decode s/tok | Result |
|---|---|---|
| OFF (stock) | 0.008204 | — |
| ON (micro-opt) | 0.008268 | +0.78% slower |
Verdict: revert. The change was honest, narrow, and reversible — and still not a win. That is a good outcome. Publishing only green numbers teaches the wrong lesson.
How we decided what “counts” as a win
Before chasing more flags, we measured a clean-main noise floor (n=5):
| Metric | Mean | Range |
|---|---|---|
| Decode s/token | 0.008276 | 4.2% |
| Prefill s/token | 0.000706 | 9.2% |
We set a local kill bar of roughly 3.3% mean decode improvement (about 2× standard deviation, floored at 1%), with multi-run interleaved A/B. Anything smaller is “maybe weather.”
Experiment 2: async scheduling (the schedule of GPU work)
Even when every math kernel is fused, the CPU still has to feed the GPU. mlxfast can insert process-once async boundaries during a decode step — not to compute future tokens, but to overlap already-built work. Think of it as prepping the next pan while the current one sizzles, without changing the recipe.
On a warm thermal plateau (~60°C), interleaved n=3:
| Schedule | Mean decode | vs default |
|---|---|---|
Default at:0,1,7,15,23,31,39 | 0.011991 | — |
ladder1 (boundary every layer) | 0.011226 | −6.4% (faster) |
off | 0.013864 | +15.6% (slower) |
Two takeaways:
- Async is load-bearing. Turning it off hurts a lot.
ladder1looked better when hot — but absolute speeds were far worse than the cool baseline (~0.008). Heat can change which schedule wins. We are not promoting a default change until we re-measure on a cool machine (ideally with fan control via the challenge’stools/fan-control.sh, which needs ansmcbinary we do not have installed yet).
./benchmark.sh --local-iterate can move several percent just from temperature, cache warmth, and desktop load. Interleave arms. Repeat. Write the noise floor down before you declare victory.
What we did not do (yet)
- Submit to the official Yukon / M5 leaderboard (needs the separate
mlxfastCLI + account). - Edit ranked Metal
_naxkernels aimed at M5 specifically. - Claim a production speedup for anything beyond “async off is bad.”
A mental model if you want to try yourself
- Get green setup first. Xcode, Metal toolchain, weights hash, both binaries, local iterate.
- Trace before you invent. See which fusions already fire.
- Measure a noise floor on unmodified main.
- Change one axis. Prefer default-off flags or tiny reversible patches.
- Prove the path ran (trace / log), then multi-run A/B.
- Respect the band. Large honest wins may need multiple ranked submissions.
- Remember the authority machine. M4 is a practice piano. M5 is the recital.
# directional local loop we used
cd ~/code/forks/mlxfast-challenge
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
export MLXFAST_LOCAL_COOL_GATE=0
export MLXFAST_LOCAL_ALLOW_GOLDEN_DRIFT=1
./setup.sh # once
./benchmark.sh --local-iterate
DARKBLOOM_TRACE_FUSION=1 ./benchmark.sh --local-iterate # see what fires
Overnight follow-up (through morning)
Cooler interleaved A/B of async schedules (n=5 each arm when complete):
| Arm | Mean decode s/tok | Median | Notes |
|---|---|---|---|
| Default schedule | 0.008163 | 0.008165 | shipped at:0,1,7,... |
ladder1 | 0.008169 | 0.008188 | Δ mean +0.07% |
Cool-regime absolute speeds acceptable: True. Promote criterion met: False.
Default patch: skipped.
Extra env probes vs control (negative = faster):
C_suffix_affine: -0.53%C_control: +0.00%C_async_ladder1: +0.62%C_async_at_comment: +1.79%C_async_off: +14.77%
M5 Max baseline (ranked silicon class)
We brought the same tree up on the lab M5 Max 128 GB (.18). Unlike M4, goldens match with no drift flag.
| Regime | Decode s/tok | ~tok/s | Prefill s/tok | Est score | Goldens |
|---|---|---|---|---|---|
| M4 cool median (ablation control) | 0.00797 | ~125 | ~0.00068 | ~1.29 | drift allowed |
| M5 cold/warm (runs 1–2) | 0.0075–0.0080 | ~125–133 | noisy | 1.30–1.72 | pass |
| M5 steady (runs 3–5) | 0.00563 | ~178 | ~0.00071 | ~1.67 | pass |
Env ablations on cool M4 confirmed the big load-bearing defaults: turning off decode async costs ~+15% decode; turning off lm_head prune costs ~+4.5%. Other knobs (packed scales, QMV R1, prefill async density, ladder1 vs default) sat inside noise. Next real wins need Metal/runtime work measured on this M5 loop, not more flag thrash.
Bottom line
mlxfast is a great way to learn real on-device inference constraints: memory traffic, kernel fusion, correctness under near-ties, thermal gates, and paired scoring. Our first pass did not produce a keepable speedup — and that is fine. The useful artifacts are the setup path, the noise floor, the fusion inventory, and two clean negative/conditional results:
- a tempting Metal micro-opt that did nothing good, and
- an async schedule candidate that needs a cool re-test before anyone changes defaults.
If you are new to this space: start by reproducing the baseline. Resist the urge to “optimize” until you can explain your measurement error bars. The challenge’s main tree is already a high bar.
- Layr-Labs / mlxfast-challenge
- Poolside Laguna XS 2.1 (model card / license; weights via challenge setup)
- Related on this site: Flash-0731 dual Spark recipe (different stack: NVIDIA Sparks, not mlxfast)