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```
1918Emily.Compiler (Nx.Defn.Compiler) — validates opts, pins the result backend
2019Emily.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)
56158Bumblebee inference works with no further configuration once the
57159backend 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
61164The 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
74186stream = Emily .Stream .new (:gpu )
75187
76188Emily .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)
79192end )
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
140255worker sync that breaks lazy-graph fusion. Leave off in release
141256builds. 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
173261mix test # fast suite (unit + property)
174262mix test --only conformance # + Bumblebee tiny-random suites
175263mix 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
176265mix test --only vit_full # full ViT-base (~330 MB)
177266mix test --only whisper_full # full whisper-tiny (~150 MB)
267+ mix test --only training_full # MNIST convergence canary
178268mix test --only soak # memory + concurrency soak harnesses
179269```
180270
0 commit comments