Skip to content

Commit 3515c86

Browse files
authored
Merge pull request #198 from ausimian/docs/perf-emlx-and-version
docs: add EMLX baseline to perf section and fix report version
2 parents 9f74cf8 + e1b3678 commit 3515c86

5 files changed

Lines changed: 99 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77

88
<!-- %% CHANGELOG_ENTRIES %% -->
99

10+
## 0.7.2 - 2026-06-13
11+
12+
### Fixed
13+
14+
- The README performance section now compares Emily against both
15+
benchmark baselines — EXLA (host CPU) and EMLX (the older MLX-backed
16+
Nx backend on the Metal GPU) — instead of EXLA alone, and its
17+
rule-of-thumb figures (ViT-base, DistilBERT) are reconciled with the
18+
current benchmark report.
19+
- The benchmark report's environment block now records the Emily
20+
version the numbers were produced on (0.7.0) and drops a misleading
21+
run timestamp.
22+
- The `MAINTAINING.md` release runbook is corrected: `mix publisho` is
23+
no longer described as pushing (it only commits and tags), and the
24+
obsolete manual draft-promotion step is dropped — `release-nif.yml`
25+
now publishes the release automatically once the NIFs are built.
26+
1027
## 0.7.1 - 2026-06-13
1128

1229
### Fixed

MAINTAINING.md

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@ default `aot`) through `config/config.exs` and stash the atom as
5050
## Cutting a release
5151

5252
Consumers verify each NIF tarball against the checksum pinned in
53-
`native_checksums.txt`, which ships in the hex package. Publishing is two
54-
commands (step 4): `mix emily.publish` regenerates that file from the
55-
freshly-built release artifacts, then `mix hex.publish` publishes — so
56-
there is nothing to update or commit by hand (the file is git-ignored and
57-
can't go stale).
53+
`native_checksums.txt`, which ships in the hex package. The publish step
54+
itself is two commands (steps 4–5): `mix emily.publish` regenerates that
55+
file from the built release artifacts, then `mix hex.publish` publishes —
56+
so there is nothing to update or commit by hand (the file is git-ignored
57+
and can't go stale).
5858

5959
### 1. Land changes on `main`
6060

@@ -68,8 +68,16 @@ mix publisho patch # or minor / major
6868
```
6969

7070
Bumps `@version`, rolls `RELEASE.md` into `CHANGELOG.md` under a
71-
dated `## <v>` heading, commits `Version <v>`, tags bare semver (no
72-
`v` prefix), and pushes both the commit and the tag.
71+
dated `## <v>` heading, commits `Version <v>`, and creates an annotated
72+
bare-semver tag (no `v` prefix). It does **not** push — that's the next
73+
step.
74+
75+
### 3. Push the commit and the tag
76+
77+
```sh
78+
git push # the `Version <v>` commit
79+
git push origin <v> # the tag — this is what fires the release workflow
80+
```
7381

7482
The tag push fires `.github/workflows/release-nif.yml`, which fans
7583
out `{variant × target}`:
@@ -89,9 +97,46 @@ sidecar (informational — consumers verify against the pinned
8997
`https://github.com/ausimian/emily/releases/tag/<v>` — the URL the
9098
consumer's `compile.emily_nif` step fetches from.
9199

92-
### 3. Verify end-to-end
100+
Once both cells finish, the workflow's `publish-release` job flips the
101+
release out of draft **automatically** (`gh release edit <v> --draft=false`),
102+
so its assets become public with no manual step. That job is gated to the
103+
tag-push path, so a manual `workflow_dispatch` rebuild leaves the release
104+
untouched — see *Rebuilding without retagging*.
105+
106+
### 4. Pin the checksums
107+
108+
Once the workflow has finished and the release is public:
93109

94-
In a throwaway project:
110+
```sh
111+
mix emily.publish # alias for `mix emily.checksums`
112+
```
113+
114+
Downloads each tarball from the now-public release and records its SHA256
115+
into `native_checksums.txt`. It does **not** trust the `.sha256` sidecars —
116+
it hashes the bytes itself. If the release isn't public yet, the download
117+
404s and the task aborts before writing anything.
118+
119+
### 5. Publish to Hex
120+
121+
```sh
122+
mix hex.publish # publishes the package (incl. native_checksums.txt) + docs
123+
```
124+
125+
Packages `native_checksums.txt` — so it's covered by Hex's package hash in
126+
the consumer's `mix.lock`, a trust root independent of the mutable GitHub
127+
release — and publishes the package + docs.
128+
129+
Steps 4 and 5 are two separate commands on purpose — they can't be folded
130+
into one alias. Mix only loads the Hex archive for the task *named on the
131+
command line*, so a `hex.publish` step chained inside an alias (whose CLI
132+
name is `emily.publish`) fails with `** (Mix) The task "hex.publish" could
133+
not be found`. Running `mix hex.publish` directly is what loads Hex.
134+
`hex.publish` keeps its `:docs` `preferred_env`, so it publishes docs as
135+
well as the package.
136+
137+
### 6. Verify end-to-end (recommended)
138+
139+
After publishing, in a throwaway project:
95140

96141
```sh
97142
mix new /tmp/emily-verify && cd /tmp/emily-verify
@@ -106,39 +151,9 @@ iex -S mix
106151
downloads the tarball, verifies, validates entries, extracts. A
107152
variant-mismatched consumer (`config :emily, variant: :jit`) should
108153
download the JIT tarball instead — worth spot-checking both lanes on
109-
the first release of a bump. (The pin only ever exists in the
110-
*published* package — `native_checksums.txt` is git-ignored and
111-
generated during `mix hex.publish` — so run this end-to-end verify
112-
against the published package, i.e. after step 4.)
113-
114-
### 4. Promote the draft and publish
115-
116-
Promote the release so its assets are public, then publish:
117-
118-
```sh
119-
gh release edit <v> --repo ausimian/emily --draft=false # assets go public
120-
mix emily.publish # regenerate native_checksums.txt
121-
mix hex.publish # publish package + docs
122-
```
123-
124-
`mix emily.publish` runs `mix emily.checksums`: it downloads each tarball
125-
from the (now-public) release and records its SHA256 into
126-
`native_checksums.txt`. Then `mix hex.publish` packages that file and
127-
publishes the package + docs.
128-
129-
These are two separate commands on purpose — they can't be folded into one
130-
alias. Mix only loads the Hex archive for the task *named on the command
131-
line*, so a `hex.publish` step chained inside an alias (whose CLI name is
132-
`emily.publish`) fails with `** (Mix) The task "hex.publish" could not be
133-
found`. Running `mix hex.publish` directly is what loads Hex. `hex.publish`
134-
keeps its `:docs` `preferred_env`, so it publishes docs as well as the
135-
package.
136-
137-
So the consumer verifies downloads against a trust root that lives in the
138-
immutable Hex package, not the mutable GitHub release — with no file to
139-
maintain and nothing to commit. The file is git-ignored and regenerated on
140-
every publish, so it can't go stale. If the draft isn't public yet,
141-
`mix emily.checksums` 404s and aborts before you publish.
154+
the first release of a bump. The checksum pin only exists in the
155+
*published* package, so this must run against the published package,
156+
i.e. after step 5.
142157

143158
### Rebuilding without retagging
144159

README.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,15 @@ KV-cache threading, stop conditions, and per-token streaming.
273273
## Performance
274274

275275
Emily targets **GPU-friendly model inference on Apple Silicon**. The
276-
[Emily-vs-EXLA benchmark][bench-report] compares Emily (MLX, Metal GPU)
277-
against EXLA — which on macOS arm64 ships no GPU client and runs on the
278-
**CPU**. So the practical choice most Elixir-on-Apple-Silicon users
276+
[benchmark][bench-report] compares Emily (MLX, Metal GPU) against two
277+
baselines: **EXLA** — which on macOS arm64 ships no GPU client and runs
278+
on the **CPU** — and **EMLX**, the older MLX-backed Nx backend, which
279+
like Emily runs on the **Metal GPU**. EXLA answers the cross-hardware
280+
question (is the GPU faster than XLA on the CPU here?); EMLX answers the
281+
same-hardware one (is Emily's compiler/runtime faster than the older
282+
MLX-backed Nx stack?).
283+
284+
Against EXLA, the practical choice most Elixir-on-Apple-Silicon users
279285
face is GPU-via-Emily vs CPU-via-EXLA, and the two have opposite cost
280286
structures: the GPU has a higher fixed per-op latency floor
281287
(~160–280 µs — a BEAM↔worker hop, a Metal command-buffer commit, and a
@@ -286,9 +292,9 @@ tensor size**, not model kind:
286292
| Workload (M4 Pro, f32) | Best Emily lane vs EXLA-CPU |
287293
| ---------------------------------------- | --------------------------- |
288294
| Large matmul (2048²) | **5.0× faster** |
289-
| ViT-base image classification | **2.2× faster** |
295+
| ViT-base image classification | **2.35× faster** |
290296
| Qwen3-0.6B greedy decode | **1.67× faster** (`fuse`) |
291-
| DistilBERT QA (one encoder forward) | ~parity (1.06×) |
297+
| DistilBERT QA (one encoder forward) | **1.27× faster** |
292298
| Whisper-tiny transcription | **11× slower** |
293299
| Elementwise / matmul ≤ ~512 per dim | up to ~2.3× slower |
294300

@@ -302,9 +308,18 @@ unused. Notably, every model in the benchmark lowered **fully native
302308
with zero fallbacks**, so these gaps are kernel/dispatch efficiency,
303309
not coverage holes.
304310

311+
**Versus EMLX (GPU-vs-GPU).** The same benchmark runs an `emlx` lane —
312+
the older MLX-backed Nx backend, also on the Metal GPU — so the
313+
comparison isn't only against the CPU. Here Emily's *compiler* is the
314+
differentiator: eager Emily is roughly EMLX-like, but native/fuse pull
315+
far ahead — **2.72× faster on DistilBERT QA**, **5.82× faster on
316+
Qwen3-0.6B decode**, and ~3.2× faster on the Qwen3-4B addendum. (EMLX
317+
did not complete the ViT-base or Whisper-tiny tiers in this harness.)
318+
305319
For decode, use the native compiler rather than the eager backend:
306-
eager Qwen3 decode is 3.5× *slower* than EXLA where native is 1.67×
307-
faster — a 5.8× swing that is purely the per-op dispatch floor, paid
320+
eager Qwen3 decode is 3.2× *slower* than EXLA, while native is 1.5×
321+
faster and fuse 1.67× — a 5.3× throughput swing from eager to fuse
322+
(12.51 → 66.42 tok/s) that is purely the per-op dispatch floor, paid
308323
once per tiny op across thousands of decode steps.
309324

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

bench/emily_vs_exla_report.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ comparison is therefore Qwen3-0.6B in the main suite.
3030

3131
## Environment
3232

33-
Fresh run: 2026-06-13 12:24, Apple M4 Pro MacBook Pro with 24 GB RAM.
33+
Run on an Apple M4 Pro MacBook Pro with 24 GB RAM.
3434

3535
| Component | Version / backend |
3636
| --- | --- |
3737
| Elixir / OTP | 1.19.5 / 28 |
3838
| Nx | 0.12.1 |
39-
| Emily | 0.6.1 local checkout |
39+
| Emily | 0.7.0 local checkout |
4040
| EMLX | 0.3.1, Metal GPU |
4141
| EXLA | 0.12.0, host CPU client |
4242

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Emily.MixProject do
22
use Mix.Project
33

44
@app :emily
5-
@version "0.7.1"
5+
@version "0.7.2"
66
@source_url "https://github.com/ausimian/emily"
77

88
# MLX pin. Drives the git tag the `:mlx_src` dep is cloned at (see

0 commit comments

Comments
 (0)