Skip to content

Commit b0907b8

Browse files
authored
Merge pull request #13 from ausimian/feat/m6-compile-derisk
M6: drop mlx::core::compile wrapping after Phase-1 de-risk
2 parents f67b9f9 + b2623ac commit b0907b8

7 files changed

Lines changed: 617 additions & 17 deletions

File tree

Makefile

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,34 @@ endif
3030

3131
MAKE_JOBS ?= $(JOBS)
3232

33-
.PHONY: all clean
33+
.PHONY: all clean bench-native
3434

3535
all: $(NIF_SO)
3636

37+
# ------------------------------------------------------------------
38+
# bench-native: standalone C++ microbenchmarks under bench/native/.
39+
#
40+
# Invoked by `mix bench.native` (which sets the same env elixir_make
41+
# uses for the NIF build). Links against the vendored libmlx via the
42+
# same rpath the NIF does, so the binary finds its own shared library
43+
# without relying on global DYLD/LD paths.
44+
# ------------------------------------------------------------------
45+
46+
BENCH_NATIVE_SRC := bench/native/compile_microbench.cpp
47+
BENCH_NATIVE_BIN := $(BUILD_DIR)/compile_microbench
48+
49+
$(BENCH_NATIVE_BIN): $(BENCH_NATIVE_SRC) | $(BUILD_DIR)
50+
$(CXX) -std=c++17 -O3 -Wall -Wextra \
51+
-isystem $(MLX_INCLUDE_DIR) \
52+
$(BENCH_NATIVE_SRC) \
53+
-L$(MLX_LIB_DIR) -lmlx \
54+
-Wl,-rpath,$(MLX_LIB_DIR) \
55+
-o $(BENCH_NATIVE_BIN)
56+
57+
bench-native: $(BENCH_NATIVE_BIN)
58+
@echo "Running $(BENCH_NATIVE_BIN)"
59+
@$(BENCH_NATIVE_BIN) $(BENCH_NATIVE_ARGS)
60+
3761
$(BUILD_DIR):
3862
@mkdir -p $(BUILD_DIR)
3963

PLAN.md

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ into BEAM.
4141
## Core design decisions
4242

4343
1. **Backend-first; compiler layered on top.** The Backend is enough to
44-
run Bumblebee. `mlx::core::compile` is an opt-in optimisation added
45-
last.
44+
run Bumblebee. Wrapping `mlx::core::compile` was planned as an
45+
opt-in optimisation on top, but was dropped after de-risking — see
46+
M6 for the measurement and reasoning.
4647
2. **Trace in Elixir, not in C++.** `Nx.Defn.Expr` is already a fully
4748
traced tree; we walk it from Elixir and emit one `Emily.Native` call
4849
per node. No C++→BEAM callbacks.
@@ -152,7 +153,9 @@ throughput number.
152153
rejected once we accounted for the per-call ETS deep-copy cost on a
153154
Qwen3-sized expression tree. The closure-capture path avoids the copy
154155
and matches the upstream Evaluator pattern.*
155-
- **Do not use `mlx::core::compile` yet.** Lazy eval at the Backend layer suffices.
156+
- **Do not use `mlx::core::compile`.** M6 de-risked this and dropped
157+
it — the fusion win on transformer-shaped workloads is below the
158+
1.20× gate. Lazy eval at the Backend layer is the shipping story.
156159

157160
**Testing — Layer 3 (Compiler):**
158161

@@ -173,20 +176,43 @@ throughput number.
173176
match `Nx.Defn.Evaluator` running on the same backend within float
174177
tolerance. (Training is out of scope for v1.)
175178

176-
### M6 — `mlx::core::compile` wrapping
179+
### M6 — `mlx::core::compile` wrapping**dropped**
177180

178-
- After Compiler has built the lazy op sequence, optionally wrap it in
179-
`mlx::core::compile` for shape-pinned specialisation
180-
- Purely an optimisation; Backend-only path remains the default
181-
- Thunk constructed in a single NIF call from a recorded op list; MLX
182-
traces a closure that replays ops against placeholders — no BEAM
183-
callbacks
181+
De-risked in pure C++ before paying the Backend/Compiler integration
182+
cost, per the PLAN gate ("If <20% win, drop"). Full results:
183+
[`bench/compile_microbench.md`](bench/compile_microbench.md).
184184

185-
**Testing:** equivalence tests rerun with `mlx_compile: true`; benchmark
186-
speedup on a Qwen3 forward pass. If <20% win, drop.
185+
Summary of findings on MLX 0.25.1, Apple Silicon:
187186

188-
**Exit:** compile mode is off by default, opt-in, demonstrably faster,
189-
zero regressions.
187+
- **Pure elementwise workload (harness validation):** 2.78× on GPU,
188+
1.47× on CPU — confirms `mx::compile` does what it advertises when
189+
fusion is available.
190+
- **Transformer block (Qwen3-0.6B-shaped, seq ∈ {128, 512}):**
191+
1.04–1.07× on GPU, **regression** (0.82–0.88×) on CPU. Fails the
192+
1.20× gate across every workload shape tested.
193+
194+
Why: transformer inference is matmul-dominated, and MLX's compile does
195+
not fuse matmul kernels with adjacent elementwise ops. The fusion
196+
surface (RMSNorm chains, softmax neighbourhood, SwiGLU's silu×up) is a
197+
small fraction of block runtime, bounding whole-block speedup to
198+
single-digit percent. On CPU the tape-replay overhead exceeds the
199+
fusion gain.
200+
201+
The BEAM-integrated compile path could not outperform this C++ ceiling,
202+
so shipping M6 would deliver a <20% speedup at best — and a regression
203+
at worst if a user selects the CPU device.
204+
205+
**Artefacts retained** so the decision can be re-measured against
206+
future MLX releases without rebuilding the harness:
207+
208+
- `bench/native/compile_microbench.cpp` — the microbench source
209+
- `lib/mix/tasks/bench.native.ex``mix bench.native` task
210+
- `bench-native` target in the root `Makefile`
211+
- `bench/compile_microbench.md` — results + reproduction instructions
212+
213+
If MLX gains matmul-adjacent fusion (bias-fused matmul, attention
214+
fusion outside `fast::scaled_dot_product_attention`), re-run the bench
215+
and revisit.
190216

191217
### M7 — 1.0 release
192218

RELEASE.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# Release notes for next release
22

3+
## Changed
4+
5+
- M6 — `mlx::core::compile` wrapping: **dropped** after Phase-1
6+
de-risk. A pure-C++ microbenchmark on MLX 0.25.1 against an Apple
7+
Silicon GPU showed the fusion win on a Qwen3-0.6B-shaped transformer
8+
block is 1.04–1.07× on GPU (below the PLAN's 1.20× gate) and a
9+
regression on CPU (0.82–0.88×). A sanity workload (pure elementwise
10+
chain) in the same harness shows the expected 2.78× GPU / 1.47× CPU
11+
wins, confirming the measurement is trustworthy — the limiting factor
12+
is that MLX compile doesn't fuse matmul with surrounding elementwise
13+
ops, and transformer inference is matmul-dominated. The BEAM-
14+
integrated compile path could not exceed this C++ ceiling, so Phase 2
15+
and 3 were not built.
16+
- **`bench/native/compile_microbench.cpp`** — standalone C++
17+
microbench (hand-written RMSNorm + GQA-lite attention + SwiGLU
18+
block, plus an 8-op elementwise sanity test). Links against the
19+
vendored libmlx via the same rpath the NIF uses.
20+
- **`mix bench.native`** (`lib/mix/tasks/bench.native.ex`) — Mix task
21+
that invokes the new `bench-native` Makefile target with the same
22+
env `elixir_make` sets, ensuring the bench uses the project's
23+
pinned MLX without a second fetch. Supports `--seq`, `--warmup`,
24+
`--iters` args via `mix bench.native -- <args>`.
25+
- **`bench-native`** target added to the root `Makefile`, producing
26+
`$(BUILD_DIR)/compile_microbench`.
27+
- **`bench/compile_microbench.md`** — full results table +
28+
reproduction instructions. Retained so the decision can be
29+
re-measured against future MLX releases without rebuilding the
30+
harness.
31+
- **`PLAN.md`** updated: M6 section rewritten to record the drop,
32+
core design decision #1 and the M5 section footnote updated to
33+
match.
34+
335
## Added
436

537
- M5 — `Emily.Compiler`, an `Nx.Defn.Compiler` implementation that runs

bench/compile_microbench.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# M6 de-risk — `mlx::core::compile` microbenchmark
2+
3+
Phase-1 measurement for PLAN milestone M6. The PLAN gates the whole
4+
milestone on a ≥20% win from wrapping `mlx::core::compile`:
5+
6+
> Equivalence tests rerun with `mlx_compile: true`; benchmark speedup on
7+
> a Qwen3 forward pass. **If <20% win, drop.**
8+
>
9+
> `PLAN.md:186-189`
10+
11+
Rather than pay the full Backend/Compiler integration cost to find out,
12+
we answer the question in pure C++ against the vendored MLX 0.25.1 on the
13+
same Apple Silicon target Emily runs on. If compile doesn't help a
14+
transformer block in raw C++, it can't help under BEAM.
15+
16+
## Setup
17+
18+
- Binary: `bench/native/compile_microbench.cpp`
19+
- Harness: `mix bench.native` (Mix task that invokes the `bench-native`
20+
target in the root `Makefile` with the same env elixir_make sets)
21+
- MLX: 0.25.1 (cocoa-xu prebuilt)
22+
- Host: M-series Mac (Metal GPU)
23+
- Each benchmark runs 50-iteration warmup + 1000 measured iterations
24+
(500 for seq=512), reporting min/median/p95 wall-time per iteration.
25+
- Both variants call `mx::eval(out); mx::synchronize()` at the end of
26+
every iteration so compile vs. uncompiled are compared apples-to-apples.
27+
28+
## Results
29+
30+
### Sanity: 8-op elementwise chain (1M elements)
31+
32+
Validates the harness: a pure elementwise workload is exactly what
33+
`mx::compile` is designed to fuse.
34+
35+
| Device | Variant | min (ms) | median (ms) | p95 (ms) |
36+
|--------|------------|---------:|------------:|---------:|
37+
| GPU | uncompiled | 1.656 | 1.746 | 2.016 |
38+
| GPU | compiled | 0.552 | 0.628 | 0.727 |
39+
| CPU | uncompiled | 1.423 | 1.463 | 1.625 |
40+
| CPU | compiled | 0.986 | 0.997 | 1.036 |
41+
42+
**GPU speedup: 2.78× median** (fusion collapses 8 kernel launches into 1).
43+
**CPU speedup: 1.47× median**. Harness verified.
44+
45+
### Transformer block — Qwen3-0.6B-shaped (seq=128)
46+
47+
RMSNorm → Q/K/V proj → SDPA (matmul, scale, softmax, matmul) → output
48+
proj → residual → RMSNorm → SwiGLU FFN → residual. hidden=1024,
49+
heads=16, head_dim=64, intermediate=2816, batch=1.
50+
51+
| Device | Variant | min (ms) | median (ms) | p95 (ms) |
52+
|--------|------------|---------:|------------:|---------:|
53+
| GPU | uncompiled | 2.588 | 3.072 | 3.964 |
54+
| GPU | compiled | 2.624 | 2.943 | 3.536 |
55+
| CPU | uncompiled | 6.835 | 7.151 | 8.109 |
56+
| CPU | compiled | 7.792 | 8.082 | 8.810 |
57+
58+
**GPU speedup: 1.04× median — FAILS 1.20× gate.**
59+
**CPU speedup: 0.88× median — compile is slower on CPU.**
60+
61+
### Transformer block — longer seq (seq=512)
62+
63+
Tests whether scaling attention (which grows O(seq²)) shifts the fusion
64+
ratio. It does not.
65+
66+
| Device | Variant | min (ms) | median (ms) | p95 (ms) |
67+
|--------|------------|---------:|------------:|---------:|
68+
| GPU | uncompiled | 10.804 | 11.463 | 12.068 |
69+
| GPU | compiled | 10.206 | 10.758 | 11.240 |
70+
| CPU | uncompiled | 21.067 | 21.744 | 22.524 |
71+
| CPU | compiled | 25.750 | 26.544 | 27.340 |
72+
73+
**GPU speedup: 1.07× median — FAILS 1.20× gate.**
74+
**CPU speedup: 0.82× median.**
75+
76+
## Interpretation
77+
78+
1. The harness is correct: a pure-elementwise sanity workload yields
79+
the expected 2-3× compile win.
80+
2. A transformer block is matmul-dominated. MLX's `mx::compile` fuses
81+
elementwise chains but does **not** fuse matmul kernels with their
82+
surrounding elementwise ops. The fusion surface (RMSNorm chains,
83+
softmax neighbourhood, SwiGLU's silu×up) is a small fraction of
84+
block runtime, bounding the whole-block speedup to single-digit
85+
percent on GPU.
86+
3. On CPU, compile is a **regression**. Tape-replay overhead exceeds
87+
fusion gains for workloads that aren't Metal-kernel-launch-bound.
88+
4. Scaling sequence length (128 → 512) does not materially change the
89+
ratio. This isn't a "small workload" problem; it's a workload-shape
90+
problem.
91+
92+
## Decision
93+
94+
**Drop M6.** The Phase-1 gate is not met and the measurement explains
95+
why in a way that Phase-2/3 BEAM integration cannot change: the
96+
BEAM-integrated compile path cannot outperform its C++ ceiling, and
97+
that ceiling is 1.04–1.10× on the target workload (transformer
98+
inference).
99+
100+
The microbench source and harness remain in `bench/native/` so this
101+
result can be re-measured against future MLX releases — if MLX adds
102+
matmul-adjacent fusion (e.g. bias-fused matmul or attention fusion
103+
outside `fast::scaled_dot_product_attention`), M6 becomes worth
104+
revisiting.
105+
106+
## Reproduce
107+
108+
```bash
109+
mix bench.native # default: warmup 50, iters 1000, seq 128
110+
mix bench.native -- --seq 512 --iters 500
111+
mix bench.native -- --warmup 20 --iters 200 # quick smoke run
112+
```

0 commit comments

Comments
 (0)