Skip to content

Commit 0de531f

Browse files
committed
M23: public documentation & examples review
Pre-1.0 pass over the documentation surface users consume on HexDocs. - Moduledoc pass: added Public API sections to Emily, Emily.Backend, and Emily.Compiler to distinguish user-facing helpers from Nx.Backend / Nx.Defn.Compiler callback implementations. Added iex> examples to Emily.{to_binary, shape, dtype}, Emily.Compiler, Emily.Stream, Emily.Quantization, Emily.QuantizedWeight, Emily.MixedPrecision, and Emily.Telemetry. Reorganised Emily.Backend's divergences into a dedicated "Divergences from Nx.BinaryBackend" subsection. - Emily.to_binary/1 now documents the zero-copy semantics: the pinned buffer refcount disqualifies MLX's donation path, and the ProcBin accounting footgun is flagged with mitigations. - HexDocs nav via mix.exs: extras now grouped into README and Notebooks; modules grouped into Core / Concurrency / Quantization / Training / Performance / Observability. - README rewrite: Overview + Features bullets, Prerequisites with the xcodebuild -downloadComponent MetalToolchain step, Building with a "Why vendored?" subsection covering ml-explore/mlx#3348 and #3405, and an expanded Concurrency model section covering the worker-thread dispatch architecture. - Two runnable Livebooks under notebooks/: distilbert_qa.livemd (Bumblebee QA through Emily.Compiler) and qwen3_quantized.livemd (Qwen3-0.6B int4-quantized + Emily.Stream concurrent serving). - CHANGELOG cutover: moved the M0-M22 release history from RELEASE.md into CHANGELOG.md, then drastically simplified it to a top-level feature summary under [0.1.0] - unreleased. Users who want per-milestone detail can consult the git history. RELEASE.md now carries only M23 notes. - mix docs runs clean (zero warnings).
1 parent b152409 commit 0de531f

16 files changed

Lines changed: 924 additions & 1154 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ emily-*.tar
2525
# Native build artefacts
2626
/priv/libemily.*
2727
/priv/mlx/
28+
/priv/mlx.metallib
2829

2930
# Dialyzer PLTs
3031
/priv/plts/

CHANGELOG.md

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

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

10-
## [Unreleased]
10+
## [0.1.0] - unreleased
11+
12+
Initial release. See the git history for per-milestone detail.
1113

1214
### Added
1315

14-
- Initial project scaffold (M0): mix project, MLX prebuilt fetcher,
15-
Makefile, minimal NIF with `Tensor` resource and
16-
`from_binary`/`to_binary`/`shape`/`dtype`/`eval` surface.
16+
- **Nx backend.** `Emily.Backend` implements every required
17+
`Nx.Backend` callback against MLX, with transparent fallback to
18+
`Nx.BinaryBackend` for ops without a native primitive.
19+
- **Defn compiler.** `Emily.Compiler` runs `defn` / `Nx.Serving` /
20+
Bumblebee on Emily; pins the result backend and caps partition
21+
concurrency so `Nx.Serving` stays compatible.
22+
- **Fused transformer kernels.** `Emily.Fast` exposes
23+
`mx::fast::rms_norm`, `layer_norm`, `rope`, and scaled-dot-product
24+
attention as defn-callable helpers with composed-defn fallbacks
25+
for non-Emily backends.
26+
- **Affine group-wise quantization.** `Emily.QuantizedWeight` and
27+
`Emily.Quantization` wrap MLX `quantize` / `dequantize` /
28+
`quantized_matmul` for int2 / int4 / int8 inference.
29+
`Emily.Quantization.dequantize_defn/1` provides a defn-native
30+
dequantize for use inside Axon forward passes.
31+
- **Mixed-precision training.** `Emily.MixedPrecision` ships the
32+
bf16 recipe: `cast_params` for the forward pass, f32 master
33+
weights, dynamic loss scaling with overflow detection.
34+
- **Per-process Metal streams.** `Emily.Stream` lets each BEAM
35+
process own its own Metal command queue, enabling concurrent
36+
inference on a shared model.
37+
- **Zero-copy `to_binary`.** `Nx.to_binary/1` on an Emily tensor
38+
returns a BEAM resource binary aliasing the MLX buffer — no memcpy.
39+
- **Native gradient + training primitives.** `gather`, `scatter`,
40+
`scatter_add`, `conv`, and the window-reduction family lower
41+
directly to MLX so `Nx.Defn.grad` and CNN training stay native.
42+
- **Native linalg.** `lu`, `svd`, `qr`, `cholesky`, `eigh`, `solve`,
43+
and `triangular_solve` dispatch to `mx::linalg::*` instead of
44+
rounding through `Nx.BinaryBackend`.
45+
- **Telemetry.** `[:emily, :eval, *]`, `[:emily, :to_binary, *]`,
46+
`[:emily, :fallback, *]`, and `[:emily, :memory, :stats]` span
47+
events; opt-in one-shot fallback warnings via
48+
`config :emily, :warn_on_fallback, true`.
49+
- **Compile-time debug flags.** `:debug_bounds_check` and
50+
`:debug_detect_nan_inf` re-enable runtime assertions on hot paths;
51+
default off with zero runtime cost.
52+
- **Bumblebee conformance.** End-to-end suites for DistilBERT,
53+
Qwen3-0.6B (dense and quantized), ViT-base, and Whisper-tiny,
54+
pinned against HuggingFace reference values.
55+
- **Worker-thread dispatch.** Each MLX stream is owned by a
56+
dedicated OS thread; NIFs hand work to the worker via a
57+
promise/future, keeping per-thread Metal `CommandEncoder` state
58+
consistent across BEAM scheduler migration.
59+
- **Vendored MLX build.** MLX is built from source via cmake from
60+
`vendor/mlx` (git submodule); no prebuilt download. Build cache
61+
keyed on the submodule SHA under `~/Library/Caches/emily/`.
62+
- **Documentation.** Per-module HexDocs, two runnable Livebooks
63+
(`notebooks/distilbert_qa.livemd`,
64+
`notebooks/qwen3_quantized.livemd`), and worked Bumblebee
65+
examples in the conformance suite.

README.md

Lines changed: 157 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,140 @@
1-
# emily
1+
# Emily
22

3-
Elixir bindings and Nx backend for Apple's [MLX](https://github.com/ml-explore/mlx).
3+
Elixir bindings and Nx backend for Apple's
4+
[MLX](https://github.com/ml-explore/mlx).
45

5-
**Status: M14 — Serving concurrency.** Stream-per-process
6-
concurrent inference via `Emily.Stream`. See [`PLAN.md`](PLAN.md) for
7-
the full roadmap and [`RELEASE.md`](RELEASE.md) for unreleased-version
8-
notes.
6+
## Overview
97

10-
## Why
8+
Emily runs `Nx` computations on Apple Silicon through MLX. Installing
9+
it as the default Nx backend is enough to get Bumblebee models
10+
executing on the Metal GPU with no further integration work —
11+
DistilBERT, Qwen3, ViT, and Whisper all run against pinned reference
12+
outputs in the conformance suite today.
1113

12-
To run Bumblebee models (notably Qwen3) on Apple Silicon with Metal
13-
acceleration, via a layered architecture that keeps each layer
14-
independently testable.
15-
16-
## Architecture
14+
The library is structured as four thin layers, each independently
15+
testable against its own oracle:
1716

1817
```
1918
Emily.Compiler (Nx.Defn.Compiler) — validates opts, pins the result backend
2019
Emily.Backend (Nx.Backend) — op-by-op translation to Native
21-
Emily.Native (thin NIF shim) — one function per MLX op, no policy
22-
MLX C++ (vendored binary) — cocoa-xu/mlx-build prebuilts, pinned
20+
Emily.Native (NIF shim) — one function per MLX op, no policy
21+
MLX C++ (vendored source) — built from `vendor/mlx` via cmake
2322
```
2423

25-
One-directional dispatch: Elixir → C++ → MLX. C++ never calls back
26-
into BEAM.
24+
Dispatch is one-directional (Elixir → C++ → MLX); the C++ side never
25+
calls back into the BEAM.
26+
27+
## Features
28+
29+
- **Nx backend.** Every `Nx.*` op dispatches to MLX; ops without a
30+
native primitive fall back transparently to `Nx.BinaryBackend`
31+
with a `[:emily, :fallback, *]` telemetry event. See
32+
`Emily.Backend`.
33+
- **Defn compiler.** `Emily.Compiler` runs `defn` / `Nx.Serving` /
34+
Bumblebee inference on MLX. Backs the results with lazy MLX graphs.
35+
- **Fused transformer kernels.** `Emily.Fast` exposes
36+
`mx::fast::rms_norm`, `layer_norm`, `rope`, and scaled-dot-product
37+
attention as defn-callable helpers with composed-defn fallbacks for
38+
other backends.
39+
- **Affine group-wise quantization.** `Emily.QuantizedWeight` +
40+
`Emily.Quantization` wrap MLX `quantize` / `dequantize` /
41+
`quantized_matmul` for int2 / int4 / int8 inference. Includes a
42+
defn-native `dequantize_defn/1` for quantized layers inside Axon
43+
forward passes.
44+
- **Mixed-precision training.** `Emily.MixedPrecision` provides the
45+
bf16 recipe (cast params for the forward, keep f32 master, dynamic
46+
loss scaling with overflow detection).
47+
- **Per-process Metal streams.** `Emily.Stream` lets each BEAM
48+
process own its own Metal command queue, so multiple processes can
49+
share a model and run inference concurrently.
50+
- **Zero-copy `to_binary`.** `Nx.to_binary/1` on an Emily tensor
51+
returns a BEAM resource binary aliasing the MLX buffer — no memcpy.
52+
- **Telemetry.** `[:emily, :eval, *]`, `[:emily, :to_binary, *]`,
53+
`[:emily, :fallback, *]`, and `[:emily, :memory, :stats]` span
54+
events. See `Emily.Telemetry`.
55+
- **Compile-time debug flags.** `:debug_bounds_check` and
56+
`:debug_detect_nan_inf` re-enable runtime assertions on hot paths
57+
that GPU backends skip by default. Both default off with zero
58+
runtime cost.
59+
60+
## Prerequisites
61+
62+
- **macOS.** Apple Silicon recommended; x86_64 is supported but
63+
without GPU acceleration.
64+
- **Elixir 1.18+ / OTP 27+.** Development is pinned to Elixir 1.19.5
65+
/ OTP 28.3 via `.tool-versions`.
66+
- **Xcode with the Metal toolchain.** The Command Line Tools alone
67+
are not enough — the build invokes `xcrun -sdk macosx metal`,
68+
which is only reachable from a full Xcode install. From a fresh
69+
macOS:
70+
71+
```sh
72+
# 1. Install Xcode from the App Store, then point xcode-select at it:
73+
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
74+
75+
# 2. If Xcode is present but the Metal Toolchain component is missing:
76+
xcodebuild -downloadComponent MetalToolchain
77+
```
78+
79+
`mix compile` will surface the correct command in its error message
80+
if the toolchain is unreachable.
81+
- **cmake.** Used to build MLX from the vendored source tree. Install
82+
via Homebrew (`brew install cmake`) or the equivalent for your
83+
setup.
84+
85+
## Building
86+
87+
MLX is vendored as a git submodule under `vendor/mlx` and built from
88+
source during `mix compile`. There is no prebuilt download step.
89+
90+
### Why vendored?
91+
92+
The vendored pin sits ahead of the latest tagged MLX release because
93+
Emily depends on two thread-safety changes that were merged to MLX
94+
`main` after the last release:
95+
96+
- [ml-explore/mlx#3348](https://github.com/ml-explore/mlx/pull/3348)
97+
thread-local `CommandEncoder`. Each MLX stream's Metal encoder lives
98+
on the thread that created it, which lets Emily pin a stream to a
99+
dedicated worker thread without colliding with other streams.
100+
- [ml-explore/mlx#3405](https://github.com/ml-explore/mlx/pull/3405)
101+
`ThreadLocalStream` API. Lets the worker thread set its stream as
102+
the per-thread default so MLX ops dispatched on that thread go to
103+
the right queue without explicit threading at every call site.
104+
105+
Before these landed, concurrent dispatch from multiple OS threads
106+
would race the Metal driver (the conformance suite hit SIGABRTs and
107+
SIGSEGVs when the BEAM migrated processes between schedulers). With
108+
them, Emily can run one MLX stream per worker thread and let multiple
109+
BEAM processes drive inference concurrently — see the
110+
[Concurrency model](#concurrency-model) section below.
111+
112+
Once a tagged MLX release contains both PRs, Emily can switch to a
113+
released version and drop the submodule. Until then, vendoring is the
114+
cleanest way to pin a known-good `main` commit reproducibly.
115+
116+
### How to build
117+
118+
```sh
119+
git clone --recurse-submodules https://github.com/ausimian/emily.git
120+
cd emily
121+
mix deps.get
122+
mix compile
123+
```
27124

28-
## Requirements
125+
If you cloned without `--recurse-submodules`, initialise them:
29126

30-
- macOS (Apple Silicon recommended; x86_64 supported)
31-
- Elixir 1.18+ / OTP 27+ (development pinned to 1.19.5 / OTP 28 via `.tool-versions`)
127+
```sh
128+
git submodule update --init --recursive
129+
```
32130

33-
MLX 0.25.1 is fetched as a prebuilt from
34-
[cocoa-xu/mlx-build](https://github.com/cocoa-xu/mlx-build) during
35-
`mix compile`; no separate install step.
131+
The first build takes several minutes to compile MLX itself. The
132+
artefact (`libmlx.a` + the Metal shader library `mlx.metallib`) is
133+
cached under `$EMILY_CACHE` (default:
134+
`~/Library/Caches/emily/mlx-<submodule-hash>`) and reused across
135+
builds until the submodule pin changes. Override the cache location
136+
with `EMILY_CACHE=/some/path mix compile`, or force a rebuild with
137+
`mix compile.emily_mlx --force`.
36138

37139
## Usage
38140

@@ -56,41 +158,54 @@ Nx.Defn.global_default_options(compiler: Emily.Compiler)
56158
Bumblebee inference works with no further configuration once the
57159
backend is installed — see the conformance suites under
58160
`test/emily/conformance/` for worked DistilBERT, Qwen3, ViT, and
59-
Whisper pipelines.
161+
Whisper pipelines, and the Notebooks section of the HexDocs nav for
162+
runnable Livebooks.
60163

61164
The low-level tensor API (`Emily.from_binary/3`, `to_binary/1`,
62-
`shape/1`, `dtype/1`, `eval/1`) remains available for diagnostics and
63-
direct MLX round-trips, but most users should go through Nx.
165+
`shape/1`, `dtype/1`, `eval/1`) remains available for diagnostics
166+
and direct MLX round-trips, but most users should go through Nx.
64167

65-
## Concurrency
168+
## Concurrency model
66169

67-
MLX dispatches GPU work through Metal command queues. By default all
68-
ops share one queue (the default stream), which is not safe for
69-
concurrent dispatch from multiple OS threads.
170+
MLX dispatches GPU work through Metal command queues. Emily owns one
171+
worker thread per command queue; each worker is a dedicated OS thread
172+
that runs the MLX ops on behalf of BEAM schedulers. NIFs hand their
173+
work to a worker via a `run_sync` promise (blocks the caller for
174+
~1–10 µs) rather than executing on the scheduler thread directly,
175+
which keeps MLX's per-thread `CommandEncoder` consistent and lets
176+
the BEAM migrate Elixir processes freely.
177+
178+
By default, every op uses the **default worker** owned by the
179+
`Emily.MlxStream.Default` GenServer under the application supervisor.
180+
That single queue serialises all GPU work across the VM — correct
181+
and simple, but a bottleneck under concurrent inference.
70182

71183
**Stream-per-process** — for concurrent inference on a shared model:
72184

73185
```elixir
74186
stream = Emily.Stream.new(:gpu)
75187

76188
Emily.Stream.with_stream(stream, fn ->
77-
# All Emily ops here dispatch on this stream's command queue.
189+
# Every Emily op in this block dispatches on `stream`'s
190+
# Metal command queue — concurrent with other streams.
78191
model.(input)
79192
end)
80193
```
81194

82-
Each stream maps to its own Metal command queue. Multiple processes
83-
can run inference concurrently — one shared model, no weight
84-
duplication. Create streams at init time (one per serving process),
85-
not per-request.
195+
Each `Emily.Stream` maps to its own `WorkerThread` and its own Metal
196+
command queue. Weights are shared across streams (MLX arrays are
197+
refcounted and thread-safe for reads), so the per-stream cost is the
198+
command buffer, not the model. Create streams once at serving-worker
199+
init, not per-request.
86200

87-
**Pooled servings** — for simpler setups with small models, start K
88-
`Nx.Serving` instances behind a pool (poolboy, Registry, etc.). Each
89-
instance loads its own weights and runs on the default stream. No
90-
`Emily.Stream` needed. Trade-off: each pool member holds its own
91-
weight copy.
201+
**Pooled servings** — for small models where duplicating weights is
202+
cheap, start K `Nx.Serving` instances behind poolboy / Registry /
203+
etc. Each instance holds its own weights and runs on the default
204+
stream. No `Emily.Stream` needed. Trade-off: memory scales linearly
205+
with K.
92206

93-
See `Emily.Stream` moduledoc for details.
207+
See `Emily.Stream` for details and the `qwen3_quantized` notebook
208+
under Notebooks for a worked multi-stream example.
94209

95210
## Observability
96211

@@ -140,41 +255,16 @@ Each check is a per-op MLX reduction plus a scalar readback — a
140255
worker sync that breaks lazy-graph fusion. Leave off in release
141256
builds. See the `Emily` moduledoc for the full opt-in snippet.
142257

143-
## Milestones shipped
144-
145-
- **M0** — NIF scaffold, MLX prebuilt fetch, tensor round-trip.
146-
- **M1**`Emily.Native` op inventory (creation, unary, binary,
147-
reductions, shape, indexing, sort, linalg, FFT, random, memory).
148-
- **M2**`Emily.Backend` (`Nx.Backend`). StreamData property oracle
149-
vs. `Nx.BinaryBackend`; soak + concurrency harnesses.
150-
- **M3** — DistilBERT end-to-end on Bumblebee; native batched `dot`,
151-
type promotion, `bitcast`.
152-
- **M4** — Qwen3 (`Qwen/Qwen3-0.6B`) greedy decode end-to-end; native
153-
`put_slice` for KV-cache.
154-
- **M5**`Emily.Compiler` (`Nx.Defn.Compiler`): validates opts, pins
155-
the result backend, delegates the walk to `Nx.Defn.Evaluator`.
156-
- **M6****dropped** after Phase-1 de-risk.
157-
`mlx::core::compile` wrapping measured <1.20× on transformer-shaped
158-
workloads (regression on CPU) and was cut. Microbench harness
159-
retained at `bench/native/compile_microbench.cpp` /
160-
`mix bench.native` so the decision can be re-measured against
161-
future MLX releases. Full results:
162-
[`bench/compile_microbench.md`](bench/compile_microbench.md).
163-
- **M7** — Bumblebee conformance breadth. ViT
164-
(`google/vit-base-patch16-224`) and Whisper (`openai/whisper-tiny`)
165-
each ship tiny-random (`@moduletag :conformance`) and
166-
full-checkpoint (`@moduletag :vit_full` / `:whisper_full`) tiers.
167-
`mix test --only conformance` now aggregates 14 tiny-random tests
168-
across DistilBERT, Qwen3, ViT, and Whisper.
169-
170258
## Testing
171259

172260
```bash
173261
mix test # fast suite (unit + property)
174262
mix test --only conformance # + Bumblebee tiny-random suites
175263
mix test --only qwen3_full # full Qwen3-0.6B checkpoint (~1.5 GB)
264+
mix test --only qwen3_quant_full # quantized Qwen3-0.6B end-to-end
176265
mix test --only vit_full # full ViT-base (~330 MB)
177266
mix test --only whisper_full # full whisper-tiny (~150 MB)
267+
mix test --only training_full # MNIST convergence canary
178268
mix test --only soak # memory + concurrency soak harnesses
179269
```
180270

0 commit comments

Comments
 (0)