Skip to content

Commit 2278422

Browse files
committed
docs(bench): refresh benchmarks against emlx 0.4.1
Bump the emlx pin from ~> 0.3.1 to ~> 0.4.0 in both bench scripts and rerun the full five-tier suite plus the Qwen3-4B addendum. EMLX 0.4 is a major performance upgrade: its DistilBERT time roughly halved and its Qwen3 decode throughput jumped ~5x, while the EXLA and Emily lanes were flat, so the published Emily-vs-EMLX ratios were badly stale. - regenerate bench/emily_vs_exla_results.md (emlx 0.4.1, emily 1.0.0) and re-append the Qwen3-4B addendum section - rewrite bench/emily_vs_exla_report.md with the new numbers and a 'changes since 0.3.1' section - update the README EMLX comparison: 2.72x/5.82x/~3.2x becomes 1.44x (DistilBERT) / 1.26x (Qwen3-0.6B) / 1.17x (Qwen3-4B) - EMLX 0.4.1 still does not complete the ViT-base or Whisper-tiny tiers; Emily-vs-EXLA numbers are essentially unchanged - add RELEASE.md entry
1 parent a1034ca commit 2278422

6 files changed

Lines changed: 174 additions & 146 deletions

File tree

README.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,11 @@ tensor size**, not model kind:
294294
| Workload (M4 Pro, f32) | Best Emily lane vs EXLA-CPU |
295295
| ---------------------------------------- | --------------------------- |
296296
| Large matmul (2048²) | **5.0× faster** |
297-
| ViT-base image classification | **2.35× faster** |
298-
| Qwen3-0.6B greedy decode | **1.67× faster** (`fuse`) |
299-
| DistilBERT QA (one encoder forward) | **1.27× faster** |
297+
| ViT-base image classification | **2.56× faster** |
298+
| Qwen3-0.6B greedy decode | **1.68× faster** (`fuse`) |
299+
| DistilBERT QA (one encoder forward) | **1.28× faster** |
300300
| Whisper-tiny transcription | **11× slower** |
301-
| Elementwise / matmul ≤ ~512 per dim | up to ~2.3× slower |
301+
| Elementwise / matmul ≤ ~512 per dim | up to ~2.2× slower |
302302

303303
**Rule of thumb: reach for Emily when the per-op tensors are large**
304304
hidden dim ≥ 768 and matmuls ≥ 1024 per dimension, which covers Qwen3,
@@ -311,17 +311,19 @@ with zero fallbacks**, so these gaps are kernel/dispatch efficiency,
311311
not coverage holes.
312312

313313
**Versus EMLX (GPU-vs-GPU).** The same benchmark runs an `emlx` lane —
314-
the older MLX-backed Nx backend, also on the Metal GPU — so the
315-
comparison isn't only against the CPU. Here Emily's *compiler* is the
316-
differentiator: eager Emily is roughly EMLX-like, but native/fuse pull
317-
far ahead — **2.72× faster on DistilBERT QA**, **5.82× faster on
318-
Qwen3-0.6B decode**, and ~3.2× faster on the Qwen3-4B addendum. (EMLX
319-
did not complete the ViT-base or Whisper-tiny tiers in this harness.)
314+
the other MLX-backed Nx backend, also on the Metal GPU — so the
315+
comparison isn't only against the CPU. EMLX 0.4 is a much stronger
316+
baseline than 0.3 was (its times roughly halved or better), making this
317+
a compiler-vs-compiler contest: emlx comfortably beats Emily's *eager*
318+
lane on decode, while Emily's native/fuse lanes keep a consistent lead —
319+
**1.44× faster on DistilBERT QA**, **1.26× faster on Qwen3-0.6B
320+
decode**, and **1.17× faster on the Qwen3-4B addendum**. (EMLX did not
321+
complete the ViT-base or Whisper-tiny tiers in this harness.)
320322

321323
For decode, use the native compiler rather than the eager backend:
322-
eager Qwen3 decode is 3.2× *slower* than EXLA, while native is 1.5×
323-
faster and fuse 1.67× — a 5.3× throughput swing from eager to fuse
324-
(12.5166.42 tok/s) that is purely the per-op dispatch floor, paid
324+
eager Qwen3 decode is 3.4× *slower* than EXLA, while native is 1.5×
325+
faster and fuse 1.68× — a 5.6× throughput swing from eager to fuse
326+
(11.9667.57 tok/s) that is purely the per-op dispatch floor, paid
325327
once per tiny op across thousands of decode steps.
326328

327329
Re-run the benchmark with `elixir bench/emily_vs_exla.exs`; the full

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
### Changed
2+
3+
- Refreshed the published benchmarks against emlx 0.4.1 (was 0.3.1). EMLX 0.4
4+
is a much stronger GPU baseline, so the Emily-vs-EMLX comparisons in the
5+
README and benchmark report now read 1.44× (DistilBERT QA), 1.26×
6+
(Qwen3-0.6B decode) and 1.17× (Qwen3-4B) in Emily's favour, down from
7+
2.72×/5.82×/~3.2×. Emily's own lane numbers are essentially unchanged; EMLX
8+
still does not complete the ViT-base or Whisper-tiny tiers.

bench/emily_vs_exla.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
Mix.install([
6363
{:emily, path: Path.expand("..", __DIR__)},
6464
{:exla, "~> 0.12"},
65-
{:emlx, "~> 0.3.1"},
65+
{:emlx, "~> 0.4.0"},
6666
{:bumblebee, "~> 0.7"},
6767
{:axon, "~> 0.8"},
6868
{:tokenizers, "~> 0.5"},

bench/emily_vs_exla_report.md

Lines changed: 93 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This report compares Emily against two Nx backend baselines:
44

55
* **EXLA**: XLA host/CPU backend on Apple Silicon.
6-
* **EMLX**: older MLX-backed Nx backend on the Metal GPU.
6+
* **EMLX**: MLX-backed Nx backend on the Metal GPU.
77
* **Emily**: local MLX/Metal backend, reported across eager, native, and fuse
88
lanes. For conclusions, use the best Emily lane for the workload.
99

@@ -21,7 +21,7 @@ Those lanes provide two complementary baselines:
2121
| Baseline | Question answered |
2222
| --- | --- |
2323
| EXLA CPU | Is MLX/Metal GPU faster than XLA host CPU for this workload? |
24-
| EMLX GPU | Is Emily's compiler/runtime faster than the older MLX-backed Nx stack? |
24+
| EMLX GPU | Is Emily's compiler/runtime faster than the other MLX-backed Nx stack? |
2525

2626
The focused Qwen3-4B script includes an EXLA lane for explicit experiments, but
2727
its default run is GPU-only. On this 24 GB M4 Pro, Qwen3-4B bf16 on EXLA-CPU was
@@ -36,33 +36,50 @@ Run on an Apple M4 Pro MacBook Pro with 24 GB RAM.
3636
| --- | --- |
3737
| Elixir / OTP | 1.19.5 / 28 |
3838
| Nx | 0.12.1 |
39-
| Emily | 0.7.0 local checkout |
40-
| EMLX | 0.3.1, Metal GPU |
39+
| Emily | 1.0.0 local checkout |
40+
| EMLX | 0.4.1, Metal GPU |
4141
| EXLA | 0.12.0, host CPU client |
4242

43+
## Changes since the emlx 0.3.1 run (2026-06-13)
44+
45+
EMLX 0.4 is a major performance upgrade over 0.3.1, and it reshapes the
46+
Emily-vs-EMLX story. On the tiers EMLX completes, its times roughly halved
47+
(DistilBERT 19.19 ms -> 9.97 ms) or better (Qwen3-0.6B decode 11.42 -> 53.81
48+
tok/s, Qwen3-4B 7.33 -> 20.28 tok/s). The EXLA and Emily lanes moved only a few
49+
percent between runs, so the delta is the emlx upgrade itself, not machine
50+
drift.
51+
52+
Concretely, best-Emily-vs-EMLX went from 2.72x to 1.44x on DistilBERT, from
53+
5.82x to 1.26x on Qwen3-0.6B decode, and from 3.20x to 1.17x on the Qwen3-4B
54+
addendum. EMLX 0.4.1 still does not complete the ViT-base or Whisper-tiny
55+
tiers in this harness.
56+
4357
## Executive summary
4458

4559
Emily's best lane wins the main model tiers that are GPU-friendly:
4660

4761
| Tier | EXLA | EMLX | Best Emily | Best lane | vs EXLA | vs EMLX |
4862
| --- | ---: | ---: | ---: | --- | ---: | ---: |
49-
| DistilBERT QA | 8.99 ms | 19.19 ms | 7.06 ms | native | 1.27x faster | 2.72x faster |
50-
| Qwen3-0.6B decode | 39.84 tok/s | 11.42 tok/s | 66.42 tok/s | fuse | 1.67x faster | 5.82x faster |
51-
| ViT-base image classification | 56.19 ms | ERR | 23.93 ms | native | 2.35x faster | n/a |
52-
| Whisper-tiny transcription | 88.49 ms | ERR | 961.68 ms | native | 10.9x slower | n/a |
63+
| DistilBERT QA | 8.89 ms | 9.97 ms | 6.94 ms | fuse | 1.28x faster | 1.44x faster |
64+
| Qwen3-0.6B decode | 40.15 tok/s | 53.81 tok/s | 67.57 tok/s | fuse | 1.68x faster | 1.26x faster |
65+
| ViT-base image classification | 55.88 ms | ERR | 21.79 ms | fuse | 2.56x faster | n/a |
66+
| Whisper-tiny transcription | 87.83 ms | ERR | 928.35 ms | native | 10.6x slower | n/a |
5367

54-
The Qwen3-4B addendum shows the qualitative Emily-vs-EMLX difference clearly on
55-
the largest practical Bumblebee model for this machine:
68+
The Qwen3-4B addendum on the largest practical Bumblebee model for this
69+
machine:
5670

5771
| Lane | Qwen3-4B tok/s | vs EMLX |
5872
| --- | ---: | ---: |
59-
| EMLX | 7.33 | 1.00x |
60-
| Emily eager | 8.03 | 1.10x |
61-
| Emily native | 22.27 | 3.04x |
62-
| Emily fuse | 23.46 | 3.20x |
73+
| EMLX | 20.28 | 1.00x |
74+
| Emily eager | 7.88 | 0.39x |
75+
| Emily native | 22.63 | 1.12x |
76+
| Emily fuse | 23.78 | 1.17x |
6377

64-
So the headline is: **Emily's compiler path is the differentiator.** Eager is
65-
roughly EMLX-like on Qwen3-4B; native/fuse are about 3.2x faster.
78+
The headline is still: **Emily's compiler path is the differentiator** — but
79+
the shape has changed with emlx 0.4. EMLX's own compiled lane now decisively
80+
beats op-by-op execution (including Emily's eager lane) on decode, so the
81+
contest is compiler-vs-compiler, and Emily native/fuse hold a consistent
82+
1.2-1.4x lead on every tier both stacks complete.
6683

6784
## Visual summary
6885

@@ -71,31 +88,31 @@ xychart-beta
7188
title "Qwen3-0.6B decode throughput"
7289
x-axis ["EXLA", "EMLX", "Eager", "Native", "Fuse"]
7390
y-axis "tokens/sec" 0 --> 70
74-
bar [39.84, 11.42, 12.51, 59.96, 66.42]
91+
bar [40.15, 53.81, 11.96, 61.70, 67.57]
7592
```
7693

7794
```mermaid
7895
xychart-beta
7996
title "Qwen3-4B decode throughput"
8097
x-axis ["EMLX", "Eager", "Native", "Fuse"]
8198
y-axis "tokens/sec" 0 --> 25
82-
bar [7.33, 8.03, 22.27, 23.46]
99+
bar [20.28, 7.88, 22.63, 23.78]
83100
```
84101

85102
```mermaid
86103
xychart-beta
87104
title "Best Emily lane vs EXLA"
88105
x-axis ["DistilBERT", "Qwen3", "ViT", "Whisper"]
89106
y-axis "speedup x" 0 --> 3
90-
bar [1.27, 1.67, 2.35, 0.09]
107+
bar [1.28, 1.68, 2.56, 0.09]
91108
```
92109

93110
```mermaid
94111
xychart-beta
95112
title "Matmul crossover: best Emily / EXLA"
96113
x-axis ["128", "512", "1024", "2048"]
97114
y-axis "ratio (lower is faster)" 0 --> 2
98-
line [1.80, 1.12, 0.31, 0.20]
115+
line [1.87, 1.20, 0.32, 0.20]
99116
```
100117

101118
## Tier 1 - op microbenchmarks
@@ -108,98 +125,106 @@ Examples from the fresh run:
108125

109126
| Op | Size | Winner | Signal |
110127
| --- | ---: | --- | --- |
111-
| add | 256 | EXLA | best Emily is 2.28x slower than EXLA |
112-
| add | 4096 | Emily | best Emily is 2.0x faster than EXLA |
113-
| exp | 4096 | Emily | best Emily is 3.2x faster than EXLA |
114-
| softmax | 4096 | Emily fuse | best Emily is 2.4x faster than EXLA and 1.29x faster than EMLX |
128+
| add | 256 | EXLA | best Emily is 2.08x slower than EXLA |
129+
| add | 4096 | Emily | best Emily is 2.2x faster than EXLA |
130+
| exp | 4096 | Emily | best Emily is 3.3x faster than EXLA |
131+
| softmax | 4096 | Emily/EMLX tie | both MLX lanes are ~2.6x faster than EXLA |
115132
| matmul | 2048 | Emily/EMLX tie | both MLX lanes are about 5x faster than EXLA |
116133

117134
Against EMLX, Emily's best op lane is usually close: sometimes a little faster,
118-
sometimes a little slower. The bigger EMLX-vs-Emily separation appears in traced
119-
model execution, especially Qwen decode, where Emily native/fuse avoid the
120-
op-by-op execution shape.
135+
sometimes a little slower. Per-op parity is expected — both stacks dispatch the
136+
same MLX kernels — so the EMLX-vs-Emily separation appears in traced model
137+
execution, where compiler and dispatch strategy differ.
121138

122139
## Tier 2 - DistilBERT QA
123140

124-
DistilBERT is a clean three-way win for Emily native:
141+
DistilBERT is a three-way win for Emily native/fuse:
125142

126143
| Lane | ms/call |
127144
| --- | ---: |
128-
| EXLA CPU | 8.99 |
129-
| EMLX GPU | 19.19 |
130-
| Emily eager | 16.45 |
131-
| Emily native | 7.06 |
132-
| Emily fuse | 8.50 |
145+
| EXLA CPU | 8.89 |
146+
| EMLX GPU | 9.97 |
147+
| Emily eager | 14.82 |
148+
| Emily native | 7.02 |
149+
| Emily fuse | 6.94 |
133150

134-
Native is the right Emily option here. Fuse is not universally better; it helps
135-
most when a compiled body is reused, such as decode loops.
151+
Native and fuse are within a few percent of each other here; either is the
152+
right Emily option for a single-forward workload. The EMLX lane roughly halved
153+
its 0.3.1 time but showed high run-to-run variance in this run (11.7 / 12.4 /
154+
5.8 ms), so treat its mean with some caution.
136155

137156
## Tier 3 - Qwen3-0.6B decode
138157

139158
Qwen3-0.6B is the canonical completed three-way generation benchmark:
140159

141160
| Lane | tok/s |
142161
| --- | ---: |
143-
| EXLA CPU | 39.84 |
144-
| EMLX GPU | 11.42 |
145-
| Emily eager | 12.51 |
146-
| Emily native | 59.96 |
147-
| Emily fuse | 66.42 |
162+
| EXLA CPU | 40.15 |
163+
| EMLX GPU | 53.81 |
164+
| Emily eager | 11.96 |
165+
| Emily native | 61.70 |
166+
| Emily fuse | 67.57 |
148167

149-
This is the clearest main-suite result. Emily eager is near EMLX, while
150-
native/fuse jump far ahead. Fuse is the best choice for decode loops.
168+
The 0.4 emlx lane now comfortably beats both EXLA-CPU and Emily's eager lane —
169+
op-by-op decode is simply not competitive from either stack. Emily native/fuse
170+
stay ahead at 1.15-1.26x over EMLX. Fuse is the best choice for decode loops.
151171

152172
## Tier 4 - ViT-base image classification
153173

154-
ViT-base strongly favors Emily native:
174+
ViT-base strongly favors Emily:
155175

156176
| Lane | ms/call |
157177
| --- | ---: |
158-
| EXLA CPU | 56.19 |
178+
| EXLA CPU | 55.88 |
159179
| EMLX GPU | ERR |
160-
| Emily eager | 38.78 |
161-
| Emily native | 23.93 |
162-
| Emily fuse | 27.50 |
180+
| Emily eager | 35.58 |
181+
| Emily native | 23.96 |
182+
| Emily fuse | 21.79 |
163183

164184
This tier is GPU-friendly: larger matrix multiplies and enough work per forward
165-
for the GPU path to dominate. The EMLX lane did not complete in this harness, so
166-
the meaningful comparison here is Emily vs EXLA.
185+
for the GPU path to dominate. The EMLX lane did not complete in this harness
186+
(on 0.3.1 or 0.4.1), so the meaningful comparison here is Emily vs EXLA.
167187

168188
## Tier 5 - Whisper-tiny transcription
169189

170190
Whisper-tiny remains Emily's bad case:
171191

172192
| Lane | ms/call |
173193
| --- | ---: |
174-
| EXLA CPU | 88.49 |
194+
| EXLA CPU | 87.83 |
175195
| EMLX GPU | ERR |
176-
| Emily eager | 1815.80 |
177-
| Emily native | 961.68 |
178-
| Emily fuse | 981.89 |
196+
| Emily eager | 1920.57 |
197+
| Emily native | 928.35 |
198+
| Emily fuse | 955.36 |
179199

180200
This is not a coverage win for EXLA; the Emily lanes reported zero fallbacks in
181201
the live run. It is a workload-shape problem: Whisper-tiny is made of many small
182202
kernels where CPU launch overhead and cache locality beat GPU dispatch. Native
183203
cuts eager roughly in half, but still cannot remove the underlying small-kernel
184-
cost. Fuse does not help this workload.
204+
cost. Fuse does not help this workload. The EMLX lane did not complete this
205+
tier on 0.3.1 or 0.4.1.
185206

186207
## Qwen3-4B addendum
187208

188-
The focused Qwen3-4B script was rerun after the three-way reorganization. Its
189-
safe default remains GPU-only:
209+
The focused Qwen3-4B script's safe default remains GPU-only:
190210

191211
```sh
192212
elixir bench/qwen3_4b_emily_vs_emlx.exs
193213
```
194214

195-
Fresh result:
215+
Fresh result (emlx 0.4.1):
196216

197217
| Lane | mean tok/s | min | max | vs EMLX |
198218
| --- | ---: | ---: | ---: | ---: |
199-
| EMLX | 7.33 | 7.31 | 7.37 | 1.00x |
200-
| Emily eager | 8.03 | 7.98 | 8.08 | 1.10x |
201-
| Emily native | 22.27 | 22.16 | 22.32 | 3.04x |
202-
| Emily fuse | 23.46 | 23.35 | 23.58 | 3.20x |
219+
| EMLX | 20.28 | 20.07 | 20.44 | 1.00x |
220+
| Emily eager | 7.88 | 7.81 | 7.97 | 0.39x |
221+
| Emily native | 22.63 | 22.61 | 22.64 | 1.12x |
222+
| Emily fuse | 23.78 | 23.55 | 23.97 | 1.17x |
223+
224+
All four lanes produced the identical greedy completion, so the throughput
225+
comparison is on the same decode. Note the Emily lanes are essentially
226+
unchanged from the 0.3.1-era run (native 22.27 -> 22.63, fuse 23.46 -> 23.78)
227+
while EMLX jumped 7.33 -> 20.28 tok/s.
203228

204229
An explicit EXLA smoke attempt on Qwen3-4B:
205230

@@ -218,9 +243,13 @@ used as the canonical three-way comparison on this 24 GB machine.
218243
Use `emily-fuse` for autoregressive decode loops. It is best on Qwen3-0.6B and
219244
Qwen3-4B because the loop body is reused.
220245

221-
Use `emily-native` as the default best Emily lane for single-forward model
222-
benchmarks. It wins DistilBERT and ViT here; fuse can be neutral or slower when
223-
there is no repeated body to amortize.
246+
Use `emily-native` or `emily-fuse` for single-forward model workloads — they
247+
are within a few percent of each other on DistilBERT and ViT. Fuse's edge grows
248+
when a compiled body is reused, as in decode.
249+
250+
Avoid op-by-op execution for decode from either stack: Emily eager and
251+
pre-compiler EMLX both sit near 12 tok/s on Qwen3-0.6B where the compiled lanes
252+
reach 54-68 tok/s.
224253

225254
Keep EXLA out of the default Qwen3-4B run on 24 GB machines. The EXLA 4B smoke
226255
was killed by the OS, while Qwen3-0.6B gives a completed three-way generation

0 commit comments

Comments
 (0)