Historical record of milestones, including ones that shipped, ones that were dropped after de-risking, and ones explicitly deferred to post-1.0. The per-milestone rationale lives here so future readers can find the trade-offs behind a current behaviour by name.
For the current shape of the library, read
ARCHITECTURE.md. For active and future work,
read ROADMAP.md. A milestone narrative below records
what was planned at the time; in a few places — notably the M14 stream
work — a "post-Mxx note" subsection records what shipped differently.
The current API is whatever lives in lib/emily/ today, and
ARCHITECTURE.md is its description.
mix new emilywith library conventionselixir_make+ Makefile;cocoa-xu/mlx-buildprebuilt fetch- Minimal NIF:
Tensorresource +from_binary/to_binary/shape/dtype/eval - Smoke tests that round-trip tensors across multiple dtypes
Exit: mix test passes; Emily.from_binary(bin, shape, dtype) |> Emily.to_binary() == bin.
- Port the MLX op surface to NIFs, organised by file (
ops/creation.cpp,ops/binary.cpp, ...) - Each NIF has an ExUnit test that calls it directly with hand-computed expected outputs
- Resource-lifecycle soak test: allocate/drop, assert MLX memory stats return to baseline
- Dtype × op smoke matrix
Testing — Layer 1 (Native): unit tests, not property tests; we're testing the shim, not the maths. Stress test for memory leaks. Error-path tests (wrong dtype/rank/axis).
Exit: every MLX op we care about is callable from Elixir with correct outputs and no leaks.
- Implement
Nx.Backendcallbacks, each delegating toEmily.Native - Zero-copy
from_binary/to_binaryon Apple Silicon - Backend-transfer implementations for
Nx.BinaryBackendinterop
Testing — Layer 2 (Backend): this is where we spend the most effort.
- Property-based oracle tests (StreamData) — for every backend
callback, generate random shapes/dtypes/values; assert output matches
Nx.BinaryBackendwithin dtype-appropriate tolerance (ulp-based for floats, exact for ints). - Nx conformance tests — replicate Nx's own backend test suite.
- Soak tests — 10k forward passes of a small model; assert memory
returns to baseline after
Emily.Memory.clear_cache/0. - Concurrency tests — 16 parallel processes, same computation, bit-for-bit identical outputs, no crashes.
Exit: Emily.Backend passes the oracle suite and Nx's own backend tests.
Nx.global_default_backend({Emily.Backend, device: :gpu})- Load
distilbert-base-uncased, run question answering - Conformance test: golden logits checked in (produced by EXLA on Linux+CUDA)
Exit: mix test --only conformance passes; Nx.Serving.batched_run
works with DistilBERT under load.
- Run
Qwen/Qwen3-0.6B-Instructend-to-end via Bumblebee causal-LM serving - Golden-output test: fixed prompt, greedy decode, first 32 tokens match a checked-in reference
- Benchmark: tokens/sec on M2/M3/M4 Mac Mini hardware, vs cocoa-xu's pure-Elixir MLX harness as ceiling
Exit: Qwen3 produces correct output and we have a tracked throughput number.
- Walk
Nx.Defn.Exprin Elixir, dispatching each node toEmily.Backend. In practice this is whatNx.Defn.Evaluatoralready does — it dispatches viaNx.Shared.list_impl!/1which finds whichever backend the operands carry.Emily.Compilervalidates options, points__to_backend__/1atEmily.Backend, pins partitions to 1 (MLX kernel dispatch isn't thread-safe), and delegates the walk. - Hold the walked plan in the closure returned by
__compile__/4; the closure is the cache. Callers that want reuse across invocations useNx.Defn.compile/3and hold the returned function — Bumblebee /Nx.Servingalready do this on warmup.- Earlier draft proposed an ETS cache keyed by
{mfa, input_signature}; rejected once we accounted for the per-call ETS deep-copy cost on a Qwen3-sized expression tree. The closure-capture path avoids the copy and matches the upstream Evaluator pattern.
- Earlier draft proposed an ETS cache keyed by
- Do not use
mlx::core::compile. M6 de-risked this and dropped it — the fusion win on transformer-shaped workloads is below the 1.20× gate. Lazy eval at the Backend layer is the shipping story.
Testing — Layer 3 (Compiler):
- Equivalence tests: a representative sample of ops (creation,
binary, reduction, shape, dot, container output) plus the
defn-only constructswhileandcond; assertcompiler: Emily.Compilermatches raw Backend execution (andNx.Defn.Evaluatorfor thedefn-only cases). The full Backend property suite isn't re-run per op — the Backend already passes its own oracle suite, and the Compiler test is structural ("did the walk reach the right backend with the right args"). - Reuse: a
Nx.Defn.compile/3closure runs many inputs of the same signature without re-walking the expression. - Callback contracts:
__to_backend__,__partitions_options__, unknown-option rejection,:max_concurrency > 1refusal.
Exit: Axon MLPs forward with compiler: Emily.Compiler; results
match Nx.Defn.Evaluator running on the same backend within float
tolerance. (Training via Nx.Defn.grad lands in M9.)
De-risked in pure C++ before paying the Backend/Compiler integration
cost, per the PLAN gate ("If <20% win, drop"). Full results:
bench/compile_microbench.md.
Summary of findings on MLX 0.25.1, Apple Silicon:
- Pure elementwise workload (harness validation): 2.78× on GPU,
1.47× on CPU — confirms
mx::compiledoes what it advertises when fusion is available. - Transformer block (Qwen3-0.6B-shaped, seq ∈ {128, 512}): 1.04–1.07× on GPU, regression (0.82–0.88×) on CPU. Fails the 1.20× gate across every workload shape tested.
Why: transformer inference is matmul-dominated, and MLX's compile does not fuse matmul kernels with adjacent elementwise ops. The fusion surface (RMSNorm chains, softmax neighbourhood, SwiGLU's silu×up) is a small fraction of block runtime, bounding whole-block speedup to single-digit percent. On CPU the tape-replay overhead exceeds the fusion gain.
The BEAM-integrated compile path could not outperform this C++ ceiling, so shipping M6 would deliver a <20% speedup at best — and a regression at worst if a user selects the CPU device.
Artefacts retained so the decision can be re-measured against future MLX releases without rebuilding the harness:
bench/native/compile_microbench.cpp— the microbench sourcelib/mix/tasks/bench.native.ex—mix bench.nativetaskbench-nativetarget in the rootMakefilebench/compile_microbench.md— results + reproduction instructions
If MLX gains matmul-adjacent fusion (bias-fused matmul, attention
fusion outside fast::scaled_dot_product_attention), re-run the bench
and revisit.
Re-measured 2026-04-22 on MLX 0.31.1+69 — decision stands. GPU
transformer-block speedup rose to 1.09–1.11× median (from 1.04–1.07×
on 0.25.1); CPU is still a regression at 0.82–0.88×. Both still fail
the 1.20× gate. See the "MLX 0.31.1+69" section in
bench/compile_microbench.md.
DistilBERT (M3) and Qwen3 (M4) cover encoder-only and decoder-only transformers but leave three architectural shapes untested: 2-D convolution, encoder-decoder cross-attention, and the Bumblebee vision/audio pipelines. M7 closes the first two gaps.
- ViT (
google/vit-base-patch16-224): vision, encoder-only, conv patch embedding, GELU FFN, classifier head. First suite to exercise theconvfallback in anger (lib/emily/backend.exstill routesconvthrough BinaryBackend as of M7 — correct but slow). - Whisper (
openai/whisper-tiny): audio, encoder-decoder, 1-D conv encoder frontend, sinusoidal position encodings, and cross-attention KV-cache in the decoder.
Each suite ships two tiers: a tiny-random tier that mirrors
Bumblebee's own test (HuggingFace Transformers reference slices) and
a full-checkpoint tier with deterministic synthetic inputs pinned
against the real-weight forward pass on Emily. Both gated as in M3
and M4: :conformance for tiny (opt in via --only conformance),
per-model :*_full tag for full (opt in separately).
Shared scaffolding (test/support/conformance_helper.ex) lifts the
setup_all backend swap and assert_all_close/3 out of each suite.
MoE / Mixtral deferred: the pinned Bumblebee ref ships no Mixtral or MoE architecture. Track as a follow-up; revisit when upstream lands.
Exit: ViT and Whisper each pass both tiers on Apple Silicon;
mix test --only conformance aggregates 14 tiny-random tests across
all four Bumblebee models.
Lift Backend.conv onto Native.conv_general (the NIF already
exists; only the Backend callback still routes through the
BinaryBackend fallback). Gated on the M7 ViT and Whisper suites
staying green through the switchover.
Training on Emily has been technically possible since M2 —
Nx.Defn.grad is pure symbolic differentiation in Elixir and lowers
to the same ops the forward pass uses. M9 turns "possible" into
"usable" by (a) lifting the training-hot indexing ops off the
via_binary fallback and (b) building the test scaffolding needed
to trust a gradient.
Primitives. Nx.Defn.grad of indexing-shaped ops lands on
indexed_add; every such backward currently ships to BinaryBackend
and back. Lift to native MLX:
indexed_add→mlx::core::scatter_addindexed_put→mlx::core::scattergather→mlx::core::gather
Window reductions stay on via_binary in M9 — pool-based conv
training is scoped to M17.
Testing — Layers 4 (Grad) and 5 (Training):
- Grad-equivalence property tests — for a zoo of
defn-expressible functions f, assertNx.Defn.grad(f)onEmily.Backendmatches the same grad onNx.BinaryBackendwithin dtype-appropriate tolerance. Reuses M2's StreamData harness; the zoo excludes non-differentiable ops (argmax,argmin,floor,sign, comparisons). - Numerical finite-difference oracle — for the differentiable
subset, assert
(f(x+ε) - f(x-ε)) / 2ε ≈ grad(f)(x). Tolerance is per-op and documented; f32 central differences bottom out around 1e-3 relative, so symbolic-grad tolerance must be relaxed accordingly where this is the oracle. Pilot on 3–4 ops before scaling the harness. - Training curve-matching — handwritten MLP and handwritten
transformer-block training step, fixed seed, 50–200 steps; assert
per-step loss trajectory matches
Nx.BinaryBackendwithin tolerance. No Axon dependency in this tier — fewer moving parts when a test goes red. - Training memory soak (
test/soak/training_test.exs,@tag :soak) — 1k training steps; MLX memory returns to baseline afterclear_cache/0. Training exercises a different allocator pattern than inference (param-grad pairs, optimizer state, long-lived activation caches). :training_full(opt-in via--only training_full, not on default CI) — Axon MLP on MNIST → >97% test accuracy. Catches systemic numerical drift that curve-matching misses because both sides useNx.BinaryBackendas the oracle.
Axon is added as a test-only dependency, used only by the
:training_full tier.
Risks specific to this milestone:
- f32 tolerance calibration for oracle (2) is per-op; the harness must support per-op tolerance tables, not a single global epsilon.
- Random-key flow through
Emily.Compilerneeds an explicit test: grad throughdropoutwith threaded keys, two invocations of the same compiled function must advance the RNG correctly. - MLX scatter semantics (out-of-bounds handling, tie-breaking) may differ from Nx expectations. Document divergence; encode property exclusions if needed.
Exit: oracles (1)–(3) green in default CI; (4) and (5) green in opt-in CI job.
The milestones below were derived from a structured review of the post-M9 codebase (see PR discussion) and ordered by user-visible value, not difficulty. Headline rationale:
- Quantization, fast kernels, zero-copy (M10–M12) move the needle most for the headline use case (Bumblebee Qwen3 on a MacBook).
- Grad oracle, serving, linalg (M13–M15) close correctness and production-readiness gaps that block real adoption.
- Mixed-precision and conv-pool training (M16–M17) finish the training story M9 started.
- Observability, errors, interop, doctor (M18–M21) are the polish that turns a working library into one new users can actually adopt without hand-holding.
- Debug flags and documentation (M22–M23) close the last pre-release gaps: caller-error detection and the HexDocs / worked- example surface new users actually land on.
- 1.0 release (M24) ships the result.
Quantization is the single largest gap between Emily and "actually run
Qwen3 on a 16 GB MacBook". MLX ships native int4/int8 affine
quantization (mx::quantize, mx::dequantize,
mx::quantized_matmul); M10 binds it at the Native and Elixir levels
and ships a direct-call helper for eager use. The Bumblebee-integrated
conformance path is split out to M10.5 — see Scope note below.
Shipped:
- Native bindings:
Native.quantize/3,Native.dequantize/5,Native.quantized_matmul/7over the MLX C++ functions.quantizereturns a 3-tuple{w_q, scales, biases}.quantized_matmul/7takestransposeas an explicit boolean rather than PLAN's original/6: AWQ packed layouts needtranspose=falsewhile fresh-from-dense weights usetranspose=true, and MLX exposes it as a required parameter. Emily.QuantizedWeight(lib/emily/quantized_weight.ex) —Nx.Container-derived struct with{value, scales, biases, group_size, bits, transpose}. Scalar metadata survives container traversal viaNx.Container'skeep:option.from_dense/2validates rank, last-axis divisibility, dtype, and bit count.Emily.Quantization.quantized_matmul/2— direct-call helper that extracts refs from an input tensor and a%QuantizedWeight{}and dispatches the fused kernel. Intended for eager/benchmark use and as the substrate for M10.5's defn-integration path.- Memory soak (
test/soak/quantized_memory_test.exs) — 1000-iter quantized-matmul loop asserts active memory returns within 4 MB of baseline afterNative.clear_cache/0. Kept separate from the fp16 memory soak because quantized inference is allocator-pattern different: packed weights load once and never re-quantize. - Native unit tests + Backend property tests — see
test/emily/native_test.exs(+7 cases) andtest/emily/quantization/(two new files). Round-tripquantize → dequantizeandquantized_matmulvs.matmul(x, dequantize(…))oracles for bothtranspose=trueandtranspose=falselayouts.
Scope note — why no Backend routing / Axon integration / conformance test in M10:
Backend.dot/7dispatch doesn't work. PLAN'd approach was "detect quantized operand structs at the Nx layer (Bumblebee tags them in the parameter map) and dispatch the matmul callback toNative.quantized_matmul". ButNx.dot/2callsNx.LazyContainer.traverse/3expecting a single%T{}; a three-tensor%QuantizedWeight{}container raises before reachingBackend.dot/7.- Axon layer-op dispatch doesn't work either.
Axon.layerops run atNx.Defn.jittrace time withNx.Defn.Exprinputs;Nx.Defn.Evaluatorwalks those expressions dispatchingNx.Backendcallbacks with materialized refs. There is no public hook to inject a custom op likeNative.quantized_matmulthat isn't already aNx.Backendcallback, anddeftransform/hook/ metadata all run at trace time (no refs available). - Bumblebee has no AWQ loader yet. The exploration for M10
confirmed
deps/bumblebeehas zero quantization-loading code (no AWQ, GPTQ, MLX-format paths). PLAN.md's "Bumblebee can already load quantized checkpoints" is aspirational.
All three of these are meaningful scope. M10 ships the substrate they all need; M10.5 picks the defn-integration strategy and ships the conformance test.
Exit: Native NIFs green under unit + property tests; QuantizedWeight container property tests green; direct-call helper green under oracle comparison; quantized memory soak clean.
Closes the gap M10 left open: Native.quantized_matmul is now
reachable from Nx.Defn.jit-traced Axon forward passes, and a
quantized Qwen3-0.6B greedy-decodes end-to-end under Bumblebee's
standard Bumblebee.Text.generation/4 serving.
Shipped:
Emily.Quantization.dequantize_defn/1(lib/emily/quantization.ex) — defn-native analogue ofQuantizedWeight.to_dense/1, built fromNx.right_shift/Nx.bitwise_and/ multiply / add. M10.5 shippedbits ∈ {2, 4, 8}only (lanes-per-u32 is integral); a later patch extended the path tobits ∈ {2, 3, 4, 6, 8}by adding a u64-pair bitstream unpacker that handles the cross-u32 packing used bybits ∈ {3, 6}. See the post-M10.5 note below for the deferred scope that landed.Emily.Quantization.Layers.quantized_dense/4(lib/emily/quantization/layers.ex) — Axon-compatible layer op (deftransform→defnp). Pattern-matches on%QuantizedWeight{}, dispatchesNx.dot(x, dequantize_defn(qw))(transpose=false) orNx.dot(x, Nx.transpose(dequantize_defn(qw)))(transpose=true). Two kernel dispatches per matmul instead of MLX's fused one — accepted trade-off for integration without forkingNx.Defn.Evaluator. M11's fast-kernel work closes the gap.Emily.Quantization.Transform(test/support/quantization_transform.ex) — graph rewriter + model-state quantizer, modeled onAxon.Quantization.quantize/3takes a dense Axon model + denseAxon.ModelStateand returns the pair with every:densenode rewritten to:quantized_denseand every dense kernel replaced with%QuantizedWeight{}. Lives intest/support/because Axon is anonly: :testdep of Emily; graduates tolib/when an upstream Bumblebee AWQ-loading path lands.:qwen3_quant_fullconformance test (test/emily/conformance/qwen3_quant_full_test.exs) — loads dense Qwen3-0.6B via Bumblebee, quantizes viaTransform.quantize/3(bits=4, group_size=128, transpose=true), runsBumblebee.Text.generation/4greedy decode for 32 tokens. Pins the continuation string as a regression gate. Opt-in viamix test --only qwen3_quant_full(mirrors:qwen3_full's model-size discipline).
Approach chosen: Option 1 (defn-native dequantize). Option 2
(Emily.Compiler custom-op intercept) and Option 3 (upstream Nx
extension) remain available if the two-kernel-vs-fused gap materially
hurts a real workload after M11.
Scope reductions from original PLAN.md M10.5:
- AWQ safetensors loader deferred. The original plan called for a
test-only loader that reads
Qwen/Qwen3-0.6B-AWQand maps to%QuantizedWeight{}. On closer inspection the AWQ→MLX conversion is meaningfully more involved than first thought: AWQ groups along theinaxis while MLX'stranspose=falsepath expects groups along the stored last axis, so correct conversion requires transposing the packed tensor, unpackingqzerosinto per-group zero-points, computingbiases = -scales * zero_points, and mapping HF param names to Bumblebee's internal naming. All tractable but substantial. The from-dense path above exercises the same defn-integration pipeline (graph rewrite + QW params + defn-native dequantize + JIT'd forward) and produces a useful regression gate; AWQ-specific loading is now a proper follow-up milestone rather than M10.5-scope. - Conformance oracle adjusted. PLAN.md originally envisioned
asserting against MLX Python's output on the same quantized
weights. Since we're now quantizing Qwen3-0.6B ourselves (not
loading AWQ), the reference is what this pipeline produces on a
clean checkout — same discipline as
qwen3_full_test.exs.
Follow-ups (out of M10.5 scope):
- AWQ safetensors loader + Bumblebee param-name mapping. When it
lands, adds a second conformance test that loads real
Qwen/Qwen3-0.6B-AWQweights end-to-end. - Optional upstream contribution to
deps/bumblebeefor AWQ loading.
Post-M10.5 note — cross-u32 bit widths landed.
The M10.5 narrative above bounded the defn-native path to
bits ∈ {2, 4, 8} because those widths divide 32 cleanly and
unpack via a single broadcast-shift + mask. bits ∈ {3, 6} were
left on the Native path on the grounds that "cross-u32 packing is
out of scope". A later patch removed that restriction by adding a
second unpack helper (unpack_cross_word_lanes/2 in
lib/emily/quantization.ex) that reads each lane's two adjacent
u32 words as a u64, shifts by rem(i * bits, 32), and masks.
Emily.Quantization.defn_supported_bits/0 now returns
[2, 3, 4, 6, 8] and Emily.Quantization.Transform picks the
expanded set up automatically. Round-trip equality vs.
QuantizedWeight.to_dense/1 is covered for every supported
{bits, group_size} combo on both Emily.Backend and
Nx.BinaryBackend.
Post-M10.5 note — all microscaled modes landed.
M10.5 left the microscaled modes (mxfp4, mxfp8, nvfp4) on
the Native path because they require additional decode work
beyond integer-lane unpacking. Three follow-up patches wired the
full set through the defn-native path:
mxfp4— 16-entry FP4-E2M1 lane LUT (+0.0, +0.5, +1.0, +1.5, +2.0, +3.0, +4.0, +6.0and negatives). Lane unpack reusesunpack_integral_lanes/2for bits=4 (lpu=8);Nx.take(fp4_lut, codes)decodes each lane. Per-group scale decode via the 256-entry FP8-E8M0 LUT (2^(s - 127)).mxfp8— 256-entry FP8-E4M3 lane LUT precomputed via MLX'sFromFP8bit-trick (strip sign, shift low 7 bits left by 7 to align E4M3 exp into f16's exponent field, multiply by 256 = 2^8 for the bias difference, restore sign). Lane unpack reusesunpack_integral_lanes/2for bits=8 (lpu=4). Per-group scale decode via the same FP8-E8M0 LUT.nvfp4— same FP4-E2M1 lane LUT asmxfp4but reuses themxfp8FP8-E4M3 LUT for the finer-grained per-group scales (group_size=16 instead of 32, FP8-E4M3 instead of FP8-E8M0 — the NVIDIA microscaled convention).
All three modes pin output dtype to bf16 to match
QuantizedWeight.to_dense/1. Every FP4 LUT entry, every
realistic FP8-E4M3 value (matched against MLX's bit-trick), and
every FP8-E8M0 power-of-two are exact in bf16, so the round-trip
is bit-identical (max abs diff = 0.0) to MLX's NIF dequant.
Emily.Quantization.Transform accepts :mode ∈ ["affine", "mxfp4", "mxfp8", "nvfp4"] (default "affine").
Orthogonal to the M6 generic-fusion drop. MLX ships handwritten fused kernels that do beat composed defn on transformer hot paths:
mx::fast::scaled_dot_product_attention— the QK^T → mask → softmax → V chain as one kernel with attention-mask broadcast handled internally. Replaces ~5 separate Native dispatches per attention layer per token.mx::fast::rms_norm— fused RMSNorm with epsilon and gain, replaces thesquare → mean → rsqrt → multiply → multiplychain.mx::fast::layer_norm— same story for LayerNorm (DistilBERT, ViT, Whisper encoder).mx::fast::rope— fused rotary embeddings withtraditional/defaultmode flags, replaces the trig + reshape + interleave chain.
Detection problem: pattern-matching subgraphs inside Nx.Defn.Expr
to recognize "this is RMSNorm, emit one fused call" is compiler-level
work. Simpler v1 path: expose them as Emily.Fast.* Elixir helpers
callable from inside defn, and ship a thin Bumblebee shim that uses
them when the active backend is Emily. The pattern-matched compiler
version is a future follow-on.
Testing:
- Native unit tests against MLX's own test vectors.
- Backend equivalence: each
Emily.Fast.*helper matches the composed-defn equivalent within a documented tolerance band (the fused kernels reorder ops slightly, so bit-match isn't expected). - Re-run all four Bumblebee
:*_fullconformance suites with the fused-kernel path active; assert the existing logits slices still match. - Pin a tokens/sec floor in
bench/qwen3_tokens_per_sec.exs.
Exit: Qwen3, ViT, Whisper, DistilBERT all run with fused kernels enabled and pass conformance; benchmark shows a measurable speedup over the M9 baseline (target ≥1.5× on M3 hardware).
PLAN design decision #9 claims unified-memory zero-copy for
from_binary / to_binary. The current code memcpys unconditionally
(emily_nif.cpp:57-58, :74-84). M12 delivers the claim for
to_binary. from_binary retains its memcpy — the one-time cost at
model load is negligible relative to the file I/O that precedes it,
and Metal's newBufferWithBytesNoCopy requires page-aligned,
page-sized memory that real-world binaries (Bumblebee/safetensors)
never provide. See dropped M12.5 below.
to_binary: wrap the materialized MLX buffer pointer as a BEAM resource binary viaenif_make_resource_binary, with the resource retaining a refcount on the MLX array so the buffer survives until the BEAM binary is GC'd. No copy; the BEAM binary aliases MLX storage directly.from_binary: memcpy retained. Acceptable cost — see M12.5.- Stride-aware materialize:
to_binarycurrently routes throughmx::contiguous; for already-contiguous arrays this is a no-op, but the wrap-as-resource path needs an explicit guard since aliasing a non-contiguous buffer would lie about its layout.
Testing:
- Allocate a tensor, call
to_binary, assert MLX active memory did not grow (aliasing, not copying). - Soak: repeated
to_binarywith cache-clear, assert peak memory is bounded by the working-set size. - Correctness: the M2 property suite must still pass — this is a perf change, not a semantics change.
- Refcount safety: drop the original tensor reference, then read the
BEAM binary returned by
to_binary; must not segfault. Use-after- free is the failure mode, so this milestone gates on an AddressSanitizer build in CI.
Exit: to_binary zero-copy verified by allocator stats; M2
property suite green; lifecycle and soak tests verify refcount
safety. AddressSanitizer CI deferred (macOS SIP prevents
DYLD_INSERT_LIBRARIES propagation through /bin/sh-launched BEAM;
requires a custom --enable-sanitizers=address OTP build).
EMILY_ASAN=1 Makefile flag ships for users with sanitizer-enabled
OTP.
Dropped. The approach required MTL::Device::newBufferWithBytesNoCopy
which needs page-aligned (4096 B), page-sized memory. Real-world
binaries from Bumblebee/safetensors never meet these preconditions:
:file.pread allocates at 8–16 byte alignment, tensor sizes are
determined by model dimensions (not page multiples), and the
safetensors format packs tensors contiguously with no inter-tensor
padding. ~99 %+ of calls would fall back to memcpy regardless.
Additional complexity — MLX's private residency API, Metal framework
linking, persistent ErlNifEnv lifecycle — was disproportionate to
the benefit, which is a one-time cost at model load (not in the
inference hot path).
M9's grad oracles are Nx.BinaryBackend symbolic grad and f32 finite
differences. Both can be wrong in the same direction — BinaryBackend's
symbolic grad is the same Nx.Defn.grad lowering Emily uses, so a bug
in the lowering passes both oracles. Finite differences have their own
ulp floor and edge-case blind spots (NaN/inf/denormal, near-zero
saddles).
EXLA-on-Linux+CUDA is the missing third opinion. M3 already established the CI pattern: run a model on EXLA, check in golden outputs, assert Emily matches. M13 extends it to gradients.
- CI job: a Linux+CUDA runner produces a JSON file of
{function_id, input_seed, expected_grad_bytes}for each function in M9's grad zoo. Checked into the repo; refreshed when the zoo changes. - Test harness:
test/emily/grad/exla_oracle_test.exs,@moduletag :grad_conformance. Loads the JSON, runs the same function underEmily.Compilerwith the same seed, asserts grad matches the EXLA-produced bytes within a tolerance tighter than BinaryBackend's (EXLA uses the same hardware-vendor kernels Emily aspires to — the gap should be small). - Coverage: M9 zoo plus a small transformer-block training step (forward + grad + SGD update of all parameters) so we catch per-op grad bugs that only manifest under composition.
Testing: the harness is the test. Tolerance calibration: pilot 3–4 ops first, document the Emily-vs-EXLA gap per dtype, ship per-op tolerance tables — not a global epsilon.
Exit: :grad_conformance green on default CI; tolerance tables
checked in alongside the goldens.
Shipped:
- Scope change: EXLA CPU backend on macOS instead of Linux+CUDA. XLA-CPU is still a fully independent oracle (different compiler, different kernels from both BinaryBackend and MLX). CUDA conformance deferred to post-1.0.
Emily.GradZoo(test/support/grad_zoo.ex) — extracted the 8defngrad functions andsoftmax_last/1fromgrad_equivalence_test.exsinto a shared module. Addedfixed_inputs/1(deterministic BinaryBackend tensors per function) andgrad_function/1(function captures). Both existing grad test files updated to import from GradZoo.Emily.ExlaGoldenData(test/support/exla_golden_data.ex) — EXLA 0.11.0 CPU-generated golden gradient values for all 8 zoo functions plus a 1-step transformer-block training step (forward + grad + SGD update of all 8 parameters). Inline Elixir float lists, consistent with the existing conformance golden pattern.Emily.Grad.ExlaOracleTest(test/emily/grad/exla_oracle_test.exs) —@moduletag :grad_conformance. Per-function tolerance table (tighter than BinaryBackend's 1e-3: linear ops at 1e-6/1e-5, compositions at 1e-4/1e-3). Runs in the default test suite.grad_dropoutexcluded (PRNG divergence across backends).- Golden generator (
bench/exla_golden_gen.exs) — standalone Elixir script usingMix.installfor{:exla, "~> 0.10"}. Runs on macOS (CPU) or Linux+CUDA. Emits a completeExlaGoldenDatamodule:elixir bench/exla_golden_gen.exs.
Emily.Compiler.__partitions_options__/1 raises on
max_concurrency > 1 — correct (Metal isn't safe for concurrent
kernel dispatch from multiple OS threads), but it silently means a
single Emily-backed Nx.Serving cannot scale past one concurrent
request. Production users will hit this in week one. M14 stops being
silent about it and ships a tested pattern.
- Stream-per-process: the primary deliverable. Expose
Native.set_default_stream/1andEmily.Stream.with_stream/2via MLX'smx::scheduler::new_stream. Each process gets its own Metal command queue; one shared model, per-process streams, no weight duplication. (Promotes streams from internal-only — see Project Decisions — to a narrowly-scoped public surface.) - Cookbook: pooled servings: documented pattern — "for K
concurrent inference requests, start K
Nx.Servinginstances behind your own pool (poolboy, Registry round-robin, etc.)". No library code; clients bring their own pool since Emily already behaves correctly under that model. Trade-off: each pool member loads its own weights — fine for small models, painful for Qwen3-7B+. - README + moduledoc updates: surface the limitation and both
patterns prominently. Today neither is mentioned outside a buried
comment in
Emily.Compiler.
Testing:
- Stream test: two processes, two streams, same model loaded once;
no SIGSEGV under sustained parallel load
(
test/soak/backend_concurrency_test.exsdocuments the SIGSEGV story for the unstreamed case — this is the negative control). :serving_fullopt-in: end-to-end per-stream large-model pattern.
Exit: both patterns documented; concurrency soak demonstrates the streamed path is stable.
Post-M14 note — eval serialisation:
MLX is not thread-safe (ml-explore/mlx#2133). The Metal
CommandEncoder is shared state — concurrent mx::eval calls from
different OS threads crash with "A command encoder is already encoding to this command buffer" (SIGABRT) or SIGSEGV from corrupted
encoder state. The M14 soak tests (8 concurrent workers via
Task.async_stream) exposed this by being the first code path to call
mx::eval from multiple dirty-CPU scheduler threads simultaneously.
Fixed by:
emily::safe_eval(): a mutex-serialisedmx::evalwrapper inc_src/emily/tensor.hpp. All eval callsites route through it. Graph-building ops (regular scheduler) remain lock-free.- Removed
set_default_streamfromwith_stream/2: the NIF mutated MLX thread-local state on BEAM scheduler threads, which is unreliable since BEAM processes migrate between OS threads. The process-dictionary-based stream routing was already correct. - Hardened
resolve_stream(-1): the -1 fallback now reads the device default directly instead of the (potentially corrupted) thread-local default.
The mutex serialises Metal dispatch at the cost of true concurrent
GPU execution. See M14.5 (MLX upgrade) for the plan to restore
concurrency via MLX's native thread-local CommandEncoder support.
Emily pins MLX 0.25.1 via pre-built binaries from cocoa-xu/mlx-build.
MLX gained native thread-safety on main in April 2026 (thread-local
CommandEncoder ml-explore/mlx#3348, ThreadLocalStream C++ API
ml-explore/mlx#3405), but neither fix is in any release yet (latest:
0.31.1). Building from source unblocks true concurrent Metal dispatch
and removes the safe_eval mutex introduced in the post-M14 fix.
- Build MLX from source (from
mainor 0.32+ when released) instead of fetching the pre-built 0.25.1 tarball. Extendmix.exsto support anMLX_SOURCEenv var pointing at a local MLX build. - Audit API changes between 0.25.1 and target version. Emily's C++ surface is narrow (core ops, linalg, streams, eval, allocator), but six months of MLX releases may rename or remove functions.
- Adopt
ThreadLocalStreamC++ API: each BEAM dirty-CPU thread gets its own MLX stream automatically, enabling true per-thread Metal command queues without the eval mutex. - Remove
emily::safe_evalmutex once native thread-safety is validated — revert to directmx::evalcalls.
Testing:
- Amplified stress test (16 workers, 100 iterations) passes without mutex under the new MLX build.
- Full test suite 20x with zero crashes.
- Benchmark
to_binarylatency under concurrent load: confirm throughput improves vs. the mutex-serialised path.
Exit: concurrent soak tests pass without mutex; MLX build-from-source documented; stress test confirms concurrent Metal dispatch is stable.
lu, svd, qr, cholesky, triangular_solve, eigh,
determinant, and friends route through via_binary today. Correct,
BinaryBackend-slow. MLX exposes most natively under mx::linalg::*.
- Bind each available
mx::linalg::*function as a Native NIF. - Replace the
via_binaryBackend callbacks with Native dispatch. - Document divergences (MLX's pivot strategy may differ from Nx's reference; numerical conditioning thresholds may differ).
Testing:
- Native unit tests against hand-computed references for small matrices (3×3, 4×4) where the answer is checkable.
- Backend property tests vs.
Nx.BinaryBackendwith shape generators biased toward well-conditioned inputs (random Gaussian → QR → reconstruct). Document the conditioning-bound failure mode for ill-conditioned cases. - Existing
via_binaryfallbacks for any op MLX doesn't implement natively; add a fallback-coverage test for the residual.
Exit: all mx::linalg::*-backed callbacks pass property suite;
remaining via_binary linalg paths documented with rationale.
Post-M15 note — intermittent test crashes: Two crash modes were observed during M15 development.
- SIGABRT (exit 134) — fixed: LAPACK errors (SVD convergence, LU
singular matrix) abort the VM because MLX's
StreamThread::thread_fnhas no catch frame — any C++ exception from aneval_cpuprimitive hitsstd::terminate. This is an MLX design constraint, not something the NIF layer can catch. Fixed by strengthening test inputs:- SVD property test now applies
make_well_conditioned/1(was the only linalg test without it). make_well_conditioned/1multiplier raised fromn*10ton*10+20— the old value landed on the diagonal-dominance boundary for n=2, allowing f32 rounding to produce singular pivots in LAPACK.
- SVD property test now applies
- SIGSEGV (exit 139) — pre-existing, not M15-related: Reproduces
at ~4/10 on main with no linalg tests. Likely an MLX Metal driver
issue (see
test_helper.exscommentary). The linalg NIFs addmx::eval()on inputs before the cross-streamcpu_stream()handoff as a defensive measure, but this does not fix the underlying SIGSEGV. Needs separate investigation outside the M15 scope.
bf16 activations + f32 master weights + loss scaling is the standard recipe for Qwen-scale training. Emily accepts bf16/f16 at the backend but ships no policy, no tolerance tables, no loss-scale primitive. Promotes "mixed-precision master weights" out of v1 non-goals.
Emily.MixedPrecision: thin Elixir module exposingcast_params/2(downcast f32 → bf16 for forward),accumulate_grad/2(upcast bf16 grad → f32 for the optimizer step),loss_scale/1/unscale/2(dynamic loss scaling with overflow detection onisfinitereductions).- Backend policy: bf16 ops dispatch to MLX bf16 kernels (already supported); f32 master weights live alongside. No type-promotion surprises — the user explicitly casts at the forward/backward boundary.
- Tolerance tables: per-op, per-dtype tolerances per the M9 harness. bf16 has ~3 decimal digits of precision; property tests must use bf16-appropriate epsilons, not f32 ones.
Testing:
- Grad equivalence under bf16 with f32 accumulation: extend M9's zoo with bf16 cases, assert match within bf16 tolerance.
- Loss scaling: deliberately overflow at bf16, assert the loss-scale dynamic adjustment (halve scale on overflow, double every N successful steps) reaches a stable scale.
- Convergence canary: extend
:training_fullMNIST with a bf16 variant; assert >97% accuracy still reached.
Exit: bf16 grad equivalence green; MNIST bf16 convergence within 0.5% of the f32 baseline; loss-scaling primitives documented with a worked example in the moduledoc.
Shipped.
Emily.MixedPrecision(lib/emily/mixed_precision.ex) —cast_params/2,accumulate_grad/2,loss_scale/1,scale_loss/2,unscale/2,update/2,has_overflow?/1. NestedLossScalerstruct with dynamic scaling (halve on overflow, double every N successful steps, floor atmin_scale). Moduledoc includes a complete mixed-precision training step as a worked example. Traversal covers maps, tuples, lists, and%Nx.Tensor{}leaves;Nx.Containerstructs (e.g.Axon.ModelState) are not traversed — documented.- bf16 grad equivalence (
test/emily/grad/bf16_grad_equivalence_test.exs) — all 8 zoo functions pass under Emily.Compiler vs BinaryBackend Evaluator, both in bf16, within 1e-2 tolerance. - Mixed-precision MLP curve-matching
(
test/emily/training/bf16_mlp_curve_test.exs) — 50-step training loop with f32 master weights, bf16 forward pass, loss scaling, and f32 gradient accumulation. Emily vs BinaryBackend within rtol 5e-2. - bf16 MNIST convergence canary
(
test/emily/training/mnist_bf16_full_test.exs) —:training_full, usesAxon.MixedPrecision.create_policy. Target ≥ 95.5%. - Backend
coercefix:Emily.Backend.wrapnow checksNative.dtype(ref)and casts if the MLX buffer dtype disagrees with the declared Nx output type. Previously only handled pred→u8; now handles all type mismatches (e.g. bf16 buffer with f32 metadata fromNx.Defn.gradtype promotion).
Originally scoped as M10 in the pre-review plan. Re-prioritized below the inference, oracle, serving, linalg, and mixed-precision milestones because its reach is narrower — small-CNN training is a real but limited use case relative to "make Bumblebee inference production-ready".
Lift window reductions (window_sum, window_max, window_min,
window_product, window_scatter_max, window_scatter_min) off
via_binary onto their native MLX counterparts. Closes the last gap
in the training primitive set and unblocks pool-based conv models
(small CNNs, ViT classifier heads trained from scratch).
Scope is unchanged: the lifts are mechanical per-op changes. Test
coverage extends the M9 grad-equivalence and curve-matching zoo to
cover the new ops, plus a small-CNN MNIST run in :training_full.
Exit: grad-equivalence on window ops green; small-CNN MNIST
training converges in :training_full.
Shipped.
c_src/ops/pooling.cpp— six new NIFs. Reductions composed asmx::pad→mx::as_strided(sliding-window view) → reduce, mirroringvendor/mlx/python/mlx/nn/layers/pooling.pygeneralised to N-D. Scatter variants add argmax-with-last-occurrence-tie-break (mask * arange(K)+ argmax, since MLX's native argmax is first-occurrence) → per-axis absolute-index computation →mx::scatter_addinto amx::full(init_value)padded buffer → slice back to the input shape.Emily.Native: 6 new NIF stubs (window_sum/max/min/product/scatter_max/scatter_min).Emily.Backend(backend.ex:1101-1225):apply_window_reduce/5andapply_window_scatter/6helpers;:valid/:samepadding resolution and dtype-specific identity (0/1/±∞, plus{:s, _}/{:u, _}min/max) done in Elixir before the NIF.- Forward-parity coverage:
test/emily/backend_window_test.exs(19 unit tests across shape × stride × padding × dilation × f32/bf16/s32/u8),test/emily/backend_window_scatter_test.exs(11 tests including overlapping windows, tie-break, non-zeroinit_value, 1-D/3-D). - Grad-equivalence extensions (
test/support/grad_zoo.ex+test/emily/grad/grad_equivalence_test.exs): three new zoo fns —grad_window_sum,grad_window_max_pool(lands onwindow_scatter_maxvia Nx's grad rule), andgrad_window_avg_pool.bf16_grad_equivalence_test.exsauto-picks-up the new zoo entries. The EXLA oracle skips un-regenerated zoo entries viaExlaGoldenData.has_golden?/1— runmix run bench/exla_golden_gen.exsto add window goldens in a follow-up. test/emily/training/cnn_curve_test.exs— handwritten 2-conv + max-pool CNN, 30-step SGD, per-step loss trajectory match vs BinaryBackend within rtol 1e-2 (looser than the MLP curve test because the CNN stacks four reductions).test/emily/training/mnist_cnn_full_test.exs—:training_fullLeNet-style Axon CNN on MNIST, ≥ 97% test accuracy. Validated: 5-epoch loss 1.60 → 0.12, test accuracy ≥ 97% on a 64-batch run.
Hitting via_binary is ~100× slower than native and emits no signal.
Whisper before M8 spent 90% of forward-pass time in a BinaryBackend
round-trip with no log. The same shape of bug will keep happening as
ops rotate on/off via_binary — make it observable.
:telemetryevents at each Native dispatch, fallback entry, and evaluation boundary. Span-style start/stop so consumers can build histograms.- Fallback warning: configurable via app env
(
config :emily, warn_on_fallback: true), emits a one-shotLogger.warningper{op, input_shape}so a Bumblebee user sees "indexed_put on shape X fell back to BinaryBackend" once, not every forward pass. - Allocator/peak-memory telemetry wired so a long-running serving
can graph memory drift without manual
get_active_memorypolling.
Testing:
- Attach a test handler, run a known fallback op, assert the event fires.
- Assert one-shot dedup of the fallback warning over 100 calls.
Exit: events documented in Emily.Telemetry moduledoc; fallback
warning behavior covered by tests.
Shipped.
Emily.Telemetry(lib/emily/telemetry.ex) — moduledoc enumerates every event;memory_stats/0samples the MLX allocator and emits[:emily, :memory, :stats].maybe_warn_fallback/2andinit_dedup_table/0are the internal helpers; dedup state lives in a named:publicETS table owned byEmily.Application.- Span events via
:telemetry.span/3:[:emily, :eval, *]onEmily.eval/1,[:emily, :to_binary, *]on bothEmily.to_binary/1andEmily.Backend.to_binary/2(the Nx.to_binary path) with:shape/:dtype/:byte_sizemetadata, and[:emily, :fallback, *]on everyvia_binaryentry with:op/:input_shapes/:input_dtypes. - One-shot
Logger.warningper{op, input_shapes}pair. Opt-in viaconfig :emily, :warn_on_fallback, true; off by default so library consumers and CI logs stay quiet. The telemetry event fires regardless — the log is a dev-time convenience on top of it. - Scope interpretation. PLAN's "each Native dispatch" was read as
the evaluation boundary (
Native.eval/Native.to_binary) rather than wrapping 300+ graph-construction call sites inEmily.Backend. Graph-construction NIFs are <10μs and do no work; the evaluation boundary is where MLX actually runs kernels, and it's the point every lazy tensor funnels through. If per-op graph-construction histograms are ever needed, the right answer is a centralised dispatch helper, not per-callback decoration. - Op-name plumbing.
via_binary/3→via_binary/4;via_binary_tuple/3→via_binary_tuple/4;apply_scatter/7→/8. The op name propagates into both the:telemetrymetadata and the dedup key. - Tests:
test/emily/telemetry_test.exscovers fallback start/stop events, 100-call dedup viacapture_log,warn_on_fallback=falsesilence,to_binaryspan metadata, andmemory_stats/0emission.async: falsebecause the dedup table is global.
Deferred — post-1.0. The original scope — typed exception hierarchy
(Emily.ShapeError, Emily.DtypeError, Emily.MLXError) with
per-NIF wrapping to carry op name + input shapes/dtypes — remains
sound, but three factors argue for waiting:
- Nx validates upfront. Shape/dtype mismatches raise in
Nx.Shape.*/Nx.Type.*before dispatch hitsEmily.Backend, with structured messages. MLX's raw surface only leaks through for directEmily.Native.*callers (tests,Emily.Quantization) and numerical failures Nx can't validate (singular matrix, SVD non-convergence) — a narrow blast radius in practice. - Ecosystem parity. No Nx backend (EXLA, BinaryBackend, EMLX,
Torchx) ships a typed exception hierarchy. Emily is not worse than
peers today; raw
ArgumentError/RuntimeErrorwith the vendor message verbatim is the ecosystem convention. - Hot-path cost. Per-NIF try/rescue wrapping adds ~70–100 ns per call (extra function call + try frame). At transformer-inference NIF counts (~10k calls per Qwen3 forward pass), that's ~1 ms of trace-time tax for errors that mostly don't happen.
When we revisit — likely bundled with a 1.x → 2.0 bump, since
introducing rescue-able exception types is a contract change users
may come to depend on — the plan is the one originally written for
M19: three defexception modules carrying :op, :input_shapes,
:input_dtypes, :message, :callback; macro-generated wrappers
over a renamed Emily.Native.Nif; a wrap_callback/2 helper in
Emily.Backend; migration of the ~13 existing ArgumentError raises
in lib/emily/backend.ex, lib/emily/quantized_weight.ex,
lib/emily/quantization.ex (keep API-misuse raises — device,
max_concurrency, unknown options — as ArgumentError per Nx
convention).
Exit (deferred): revisit at 2.0 planning.
Deferred — post-1.0. The original scope — implementing
Nx.Backend.from_pointer/5 and to_pointer/2 over MLX buffer pointers
with caller-supplied deleters — remains reachable but is not the right
shape for Emily's integration surface:
- Elixir can't represent a raw pointer.
Nx.Pointer.addressis a uint; a user can't dereference it. The only thing they can do with ato_pointerresult is hand it to another NIF. Elixir is a passive integer shuttle, not the real API. - Ecosystem parity. Neither EMLX nor Torchx exposes pointer ops in
Elixir;
Nx.BinaryBackendraises too. Emily is not worse than peers today — rawraisewith a clear message is the ecosystem convention for backends that haven't implemented the callbacks. - MLX DLPack lives in the Python bindings
(
vendor/mlx/python/src/convert.cpp), not the C++ public API. No clean capsule path to lift. - No concrete consumer. The correct design is a public C++ header
(
include/emily.h) exporting the Tensor resource type and opaquemlx::arrayaccessors so downstream NIFs can interop in C++ — a library-packaging task needing ABI stability commitments. Designing that speculatively risks shipping the wrong shape.
When we revisit — prompted by a real user with a native pipeline or a DLPack-importing tool — the plan is the one originally written for M20, restructured as a NIF-to-NIF contract rather than an Elixir surface:
- Public header
include/emily.hexporting:emily_tensor_resource_type(ErlNifEnv*)— theErlNifResourceType*so downstream NIFs canenif_get_resourceEmily tensors.emily_array_from_term(ErlNifEnv*, ERL_NIF_TERM) -> mlx::array&— read access to the underlying array.emily_array_to_term(ErlNifEnv*, mlx::array) -> ERL_NIF_TERM— wrap a caller-constructedmlx::array(including one built from a user-owned pointer via MLX'sarray::array(void*, shape, dtype, Deleter)constructor atvendor/mlx/mlx/array.h:69) as a Tensor resource.
Nx.Backend.from_pointer/5andto_pointer/2continue to raise, with messages pointing at the header-based interop path.- Lifetime contract documented explicitly: the
Deleterclosure owns the backing buffer; the Tensor resource refcount gates themlx::arraylifetime; downstream NIFs must hold the resource, not the raw pointer. - Testing: a minimal downstream NIF harness in
test/c/exercising allocate-in-emily → borrow-pointer-in-foreign-NIF → release, under AddressSanitizer (subject to the same macOS-SIP caveat recorded in M12's exit notes).
Exit (deferred): revisit when a concrete downstream consumer asks for pointer interop.
Deferred — post-1.0. The original scope was a diagnostic Mix task
covering the MLX prebuilt fetch + checksum + elixir_make chain —
the failure surface on fresh macOS machines when Emily pinned the
cocoa-xu/mlx-build prebuilt. M14.5 moved Emily to building MLX from
source, which collapses most of that failure surface:
- No prebuilt fetch to probe. Checksum mismatch, cache corruption,
and registry-unreachable paths no longer exist. The remaining build
failures (missing Xcode CLT, CMake version skew, out-of-disk)
surface as
elixir_makeerrors duringmix compilewith messages users can already read. - NIF-load and GPU-dispatch probes duplicate the test suite. A
successful
mix testalready exercises both paths more thoroughly than a single probe would. Users who can't run tests can't runmix emily.doctoreither. - No concrete adoption signal yet. The task was motivated by speculative day-one friction; we haven't seen the issues it targets filed against the repo.
When we revisit — prompted by a real pattern of setup failures that
elixir_make errors don't already explain — the plan is a narrower
version of what was originally written:
- Probe Xcode CLT version, CMake presence/version, MLX source-tree checkout state, compiled NIF load, trivial GPU dispatch, and available unified memory.
- Drop the prebuilt-presence and prebuilt-checksum probes from the original scope.
- Structured report with green/yellow/red per probe and remediation hints keyed to the observed failure modes.
- Snapshot test on a known-good configuration; negative tests that deliberately break one probe at a time.
Exit (deferred): revisit if adoption-friction issues accumulate around the source-build path.
Tracks issue #32. During
DistilBERT-QA conformance testing we hit an intermittent :nan score
when a vocab-30522 tokenizer was paired with a tiny-random model whose
embedding table had only 1124 rows. The tokenizer emitted out-of-range
token ids, and Emily.Backend.gather/4 silently returned uninitialised
memory for the OOB rows — sometimes benign floats, sometimes NaN that
propagated through softmax. Nx.BinaryBackend raises on OOB gather
because the check is free on CPU; GPU backends (Emily, EXLA,
Torch-CUDA, JAX-GPU) dispatch to kernels that do not bounds-check.
That's the GPU-backend norm, not an Emily bug — but it hides a real
class of caller error.
M22 lands a compile-time opt-in debug-assertion facility so users can re-enable these checks in CI / test / dev builds at zero default cost.
Mechanism. Application.compile_env(:emily, :<flag>, false) module
attribute + dead if branches. When the flag is false (default), the
Elixir compiler eliminates the assertion branch — zero runtime cost,
zero bytecode. Mix recompiles affected modules automatically when the
flag flips. Verified by inspecting .beam disassembly for the
default-off case, and by relying on Application.compile_env/2's
compile-vs-runtime divergence warning for the flip case.
Initial flag set (coherent subset, not every flag from the issue):
:debug_bounds_check— assert indices in range for indexing ops:gather/takeon reads andscatter/scatter_addon writes. One flag covers both because the failure class is identical (OOB indexing into a resource tensor) and users almost always want both — in inferencegatheris the hot path, in trainingscatterfires through the grad ofgather, and the DistilBERT-QA bug class shows up via either. Can be split into per-op flags if a real user later asks for cost-profile granularity.:debug_detect_nan_inf— post-op NaN/Inf scan on the hot training ops (matmul / softmax / layer-norm), so training-time numerics failures surface at the op that produced them rather than as a downstreamloss = NaN.
Deferred to follow-ups if demand materialises:
:debug_assert_same_backend— detect tensors that silently moved betweenEmily.BackendandNx.BinaryBackendmid-graph.:debug_strict_dtype— raise on silent mixed-dtype promotion.
Naming and default. :debug_* prefix across the set. Every flag
defaults to false — explicit opt-in only, including in Emily's own
config/test.exs. The per-flag unit tests (below) exercise the
flag→assertion path; there's no value in Emily's CI paying GPU-sync
cost on every mix test run, and running Emily's own tests in the
same configuration users hit in production avoids masking perf
regressions. Each flag documented in the Emily moduledoc under a
"Debug assertions" heading with a worked config/test.exs snippet
consumers can lift into their own projects.
Testing.
- Per-flag unit tests: flag off, OOB op succeeds silently (negative
control); flag on, OOB op raises with a message identifying op +
offending index / value.
:debug_bounds_checkgets coverage on both a gather-family op (gather/take) and a scatter-family op (scatter/scatter_add) since one flag now covers both. - Zero-cost verification: a single test that compiles with all flags
off and asserts the assertion-branch
fndoes not appear in the module'sbeam_disasmoutput. Guards against accidental runtime cost from a future refactor. :debug_detect_nan_infgets a training-loop test: inject a NaN (divide-by-zero or overflow) into a known op, assert the flag surfaces the failure at that op's boundary rather than at the final loss.
Risks.
- Each assertion is a GPU sync (breaks lazy-graph fusion). Documented loudly alongside each flag; the default-off story is the mitigation.
:debug_detect_nan_infis a reduction per op — meaningful overhead even off the hot path. Scope to a short allow-list of ops the issue named (matmul / softmax / layer-norm), not every op.
Exit. Two flags shipped with tests; all flags default-false,
including in Emily's own config/test.exs; README and Emily
moduledoc document the pattern with a worked opt-in snippet; zero-cost
verification test asserts the assertion branches compile out when the
flags are off.
Pre-1.0 pass over the documentation surface users actually consume — ExDoc-rendered module docs on HexDocs, the README, and worked examples. Not a rewrite; a structured audit + gap-fill.
Current state (surveyed pre-milestone):
- All 9 public modules have moduledocs. Design rationale (Backend divergences, Compiler cache story, Quantization dispatch, Stream concurrency, MixedPrecision worked example) is explained where it matters.
Emily.Backendexposes 83 public functions with only 6@docattributes — the 77 undocumented areNx.Backendcallback implementations users should not call directly.Emily.Compilerexposes 4 public functions with 0@doc— same pattern,Nx.Defn.Compilercallbacks.- No
.livemdnotebooks, noexamples/directory.bench/is internal tooling. The README points onboarding readers attest/emily/conformance/for worked model usage, which is awkward. mix.exsdocs:config is minimal:main: "readme", three extras. Nogroups_for_modules, no HexDocs link in the README.CHANGELOG.mdis a stub;RELEASE.mdholds the working M13–M18 notes. Cutover process not documented.
Scope.
- Partition
Emily.Backend/Emily.Compilercallback implementations from the public surface. The 77 Backend + 4 Compiler callback functions get@doc false(Nx backend convention — seedeps/nx/lib/nx/binary_backend.exfor precedent). The six functions genuinely intended for direct use keep their@doc. A "Public API" section in each moduledoc enumerates what users call directly vs what Nx dispatches to on their behalf. mix.exsgroups_for_modules: organise the nav by concern — Core (Emily,Emily.Backend,Emily.Compiler), Concurrency (Emily.Stream), Quantization (Emily.Quantization,Emily.QuantizedWeight), Training (Emily.MixedPrecision), Performance (Emily.Fast), Observability (Emily.Telemetry).- README: add HexDocs link (
https://hexdocs.pm/emily) under a "Documentation" heading. Link to the Livebook(s) below. notebooks/directory with at least two.livemdfiles:notebooks/distilbert_qa.livemd— smallest useful worked example; mirrors the M3 conformance test without the test harness. Telemetry-attach demonstrated inline.notebooks/qwen3_quantized.livemd— the headline use case; loads Qwen3-0.6B, quantizes viaEmily.Quantization.Transform, greedy-decodes underBumblebee.Text.generation/4. ShowsEmily.Streamfor concurrent serving. Both useMix.install/2at the top so they run standalone without checking out the repo.
- Pass over each public moduledoc for 1.0-readiness: stale
milestone references removed, examples run under the current API,
deliberate divergences from Nx documented as a consistent
"Divergences from
Nx.BinaryBackend" subsection where applicable. - CHANGELOG.md cutover: move the M13–M18 entries from
RELEASE.mdintoCHANGELOG.mdunder a0.1.0 (unreleased)heading during this milestone; document the expected cutover in the release process so future milestones know which file to touch.
Explicitly out of scope (pattern reuse, not polish):
- A full guides directory (
guides/*.md). The moduledocs carry the design material today; promoting them to separate guides is a 2.0 concern once the surface stabilises. - Contribution docs (
CONTRIBUTING.md, issue/PR templates) beyond whatever the/adopt-build-conventionsskill already ships.
Testing.
mix docsruns clean (no warnings about missing modules, broken links, or unresolvable cross-refs).- Each
.livemdfile innotebooks/is executed end-to-end as a smoke test (CI job ormix test.notebooksMix task) against the real Bumblebee models used in:*_fullconformance. Gated the same way as:qwen3_full— opt-in, not default CI. - A README link-check asserts no broken HexDocs / GitHub links.
Exit. mix docs output reviewed module-by-module; two Livebooks
land in notebooks/ and execute cleanly; README has a Documentation
section with working HexDocs + Livebook links; CHANGELOG.md holds
the 0.1.0 draft; callback-impl noise removed from the HexDocs nav.
- API docs, HexDocs, README with worked Bumblebee + quantized-Qwen3 examples
- Hex release (public), versioned per conventions (
@versionin mix.exs) RELEASE.mdaccumulated across feature branches
Thread a :mode string through the quantize / dequantize /
quantized_matmul pipeline so Emily.QuantizedWeight covers MLX's
full QuantizationMode enum (see vendor/mlx/mlx/primitives.h:155),
not just the classical "affine" int4/int8 scheme.
c_src/ops/linalg.cpp—quantize_nif,dequantize_nif,quantized_matmul_niftake astd::string modearg. The biases arg on the latter two becomesstd::optionalsince MLX'sfp_quantizereturns only(wq, scales)for microscaled modes.lib/emily/native.ex— stub arities bumped (quantize/5,dequantize/7,quantized_matmul/9).lib/emily/quantized_weight.ex— new:modefield enforced in@enforce_keys/defstruct/@type t/Nx.Container :keep.from_dense/2accepts:mode(default"affine", else one of"mxfp4","mxfp8","nvfp4") and validates mode-specific{group_size, bits}before dispatching. Microscaled modes carry a scalar-zero placeholder in:biases(the Native layer substitutesnilbefore the MLX call).lib/emily/quantization.ex—quantized_matmul/2forwardsqw.modeand passesnilbiases for microscaled modes.dequantize_defn/1raises a clearArgumentErroron non-affine modes, pointing callers atto_dense/1(the Native path).- Tests — per-mode round-trip and
quantized_matmulequivalence vsNx.dot(x, Nx.transpose(to_dense(qw)))for each microscaled mode; error path fordequantize_defnon"mxfp4". - Metal smoke tests ran green for all four modes on Apple Silicon (see agent report in the milestone feature branch).
Deferred (B4b) — MLX's to_fp8 / from_fp8 ops (vendor/mlx/ mlx/ops.h:1517-1521) need an FP8 dtype that Nx doesn't yet model.
Revisit when M16 (mixed precision) surfaces a concrete FP8 user story,
or when upstream Nx gains an FP8 variant. Options considered:
Nx upstream, shadow-type wrapper, opaque-handle operator — none a
clear win at Emily's current maturity.
Extends mx::fast::scaled_dot_product_attention's sinks parameter
(MLX v0.31.1+69; vendor/mlx/mlx/fast.h:47-55) through the stack so
Bumblebee-rewritten models and direct Emily.Fast users can opt into
StreamingLLM-style attention sinks for long-context decode. Sinks are
per-head "null destinations" that participate in the softmax
denominator only — they shift probability mass without consuming
output values.
c_src/ops/fast.cpp—fast_scaled_dot_product_attention_niftakes a second variadic-length-0-or-1sinks_arrsparameter mirroring the existingmask_arrsplumbing;std::nulloptwhen empty.lib/emily/native.ex— stub arity bumped to 8.lib/emily/fast.ex— both SDPA helpers accept:sinksas a keyword opt (default absent). When unset, the helpers emit the same optional-node as before, preserving source compatibility withEmily.Bumblebee.FastKernels. When set, a distinct op name (fast_scaled_dot_product_attention_with_sinks/…_with_mask_and_sinks) dispatches to a new Backend callback that threads the sinks tensor through the NIF.lib/emily/backend.ex— two new callbacks that forwardsinks; existing callbacks pass[]for the sinks slot and behave identically to pre-M26 output.- Fallback math (defn-side): computes
row_max = max(reduce_max(logits), sinks_broadcast), thenprobs = exp(logits - row_max) / (sum(exp(logits - row_max)) + exp(sinks - row_max)). Verified numerically against the fused kernel at max-abs-diff ~2e-7 on f32 (well under@f32_tol = 1.0e-4).
From the 2026-04-22 MLX capability audit (plan file
.claude/plans/b1-b2-b4-capability-wraps.md §"B1"). Neither Nx nor
Bumblebee exposes einsum today, so this is a wholly new user-facing
helper on top of mx::einsum rather than a backend-override redirect.
Scope (shipped). Eager-only, raise-on-non-Emily:
c_src/ops/linalg.cpp— neweinsum_nif(variadic operand count) callingmx::einsum(subscripts, operand_arrays, stream). Threaded through the async worker like the other linalg NIFs. Adds#include <mlx/einsum.h>.lib/emily/native.ex— neweinsum/3stub (worker, subscripts, refs list).lib/emily/fast.ex— neweinsum(subscripts, operands)function. Every operand must already live onEmily.Backend; any other backend raisesArgumentErrorwith a clear "transfer withNx.backend_transfer(t, Emily.Backend)first" message. Follows the same direct-call helper pattern asEmily.Quantization.quantized_matmul/2.test/emily/fast/einsum_test.exs— two-operand ("ij,jk->ik"), batched ("bij,bjk->bik"), attention-style ("bhid,bhjd->bhij"), three-operand ("ij,jk,kl->il"— verified against both hand-chosen contraction orders), and the non-Emily error path.
Deferred (out of scope for this milestone). The plan's "raise-on
non-Emily first" recommendation is what we actually shipped. A defn
fallback via Nx.Defn.Expr.optional/3 would require a correct einsum
string parser (diagonals, ellipsis, contraction path heuristic) — a
medium-sized piece of work. We will add it if a user asks for
cross-backend defn composability; until then the helper is documented
as eager-only and not defn-callable.
Capability audit impact. The B1 row in the MLX capability audit table (lines below) flips from "Candidate" to "Landed (eager-only)". Future readers should note that the defn path was explicitly considered and deferred, not forgotten.
Exit. mix precommit green; einsum_test.exs passes on every
operand arity in the suite; docs note the helper is eager-only.
- Repo: GitHub under
ausimian. Push deferred. - Publishing: public
hex.pm. Deferred. - Streams: internal in v1 except for the narrow
Emily.Streamsurface M14 introduces for the documented "big model, multi-process serving" pattern. - Training: in scope from M9 (autodiff + small-scale loops); extended by M16 (mixed precision) and M17 (conv-pool). Out of scope: distributed training and a native optimizer library.
- EMLX coordination: none — quiet ship.
Earlier milestones were planned against the cocoa-xu prebuilt MLX
(the same binary elixir-nx/emlx depends on, v0.25.1-era at the time
M6 was measured). The tree now vendors 8e649be4
(v0.31.1-69-g8e649be4) and builds from source via CMake. This audit
re-verifies the ratified decisions above and catalogues in-roadmap
MLX capabilities Emily isn't yet using. Scope limited to what is
plausibly in Emily's roadmap; see "Re-affirmed out-of-scope" at the
bottom for the explicit rejections.
M6's original drop verdict (≥1.20× gate not met) stands on 0.31.1+69.
Transformer-block GPU speedup is now 1.09–1.11× median (up from
1.04–1.07×); CPU still regresses. See the "MLX 0.31.1+69" section in
bench/compile_microbench.md. No
change to M6 status.
| # | Capability | MLX location | Emily status | Roadmap fit | Effort | Recommendation |
|---|---|---|---|---|---|---|
| B1 | einsum with automatic contraction-path optimisation |
vendor/mlx/mlx/einsum.h:14,18 |
Landed in M27 (eager-only). Emily.Fast.einsum/2 wraps mx::einsum as a direct-call helper that raises on non-Emily operands (matching Emily.Quantization.quantized_matmul/2). No Nx.Defn.Expr.optional/3 hook and no einsum-string parser — neither Nx nor Bumblebee has an einsum path to intercept, so cross-backend defn composability was explicitly deferred. |
— | Shipped. | Defn-callable fallback remains open if a user surfaces one; until then, eager-only. |
| B2 | fast::scaled_dot_product_attention attention sinks |
vendor/mlx/mlx/fast.h:47-55 (the sinks param) |
Landed in M26. Emily.Fast.scaled_dot_product_attention* now accept a :sinks keyword opt; NIF threads the sinks tensor through fast::sdpa; fallback implements sink-in-softmax-denominator maths; kernel-vs-fallback max abs diff ~2e-7 on f32 (well inside kernel tolerance). |
— | Shipped. | — |
| B3 | Sparse / MoE matmuls: gather_qmm, gather_mm, block_masked_mm, segmented_mm |
vendor/mlx/mlx/ops.h:1524, 1568, 1578, 1591 |
Emily wraps only quantized_matmul (c_src/ops/linalg.cpp). No MoE-dispatch path. |
Qwen3-MoE variants, any sparse-attention model. Outside current M4/M7 targets. | NIF wrap + Backend override + design the Emily.Quantization API extension. Medium. |
Deferred — surfaces with the first MoE model target; not a v1 blocker. |
| B4a | Microscaled quantization modes (Mxfp4, Mxfp8, Nvfp4) |
vendor/mlx/mlx/primitives.h:155 (QuantizationMode enum) |
Landed in M25. Emily.QuantizedWeight now carries a :mode field; quantize/dequantize/quantized_matmul NIFs accept the mode string; Metal smoke-tested for all four modes (affine max_diff ~3e-5, microscaled modes match MLX's own dequant-then-dot oracle within f32). |
— | Shipped. | — |
| B4b | FP8 dtype (to_fp8 / from_fp8) |
vendor/mlx/mlx/ops.h:1517-1521 |
Deferred. Requires either an FP8 variant in Nx.Type (upstream coordination) or a shadow-type wrapper like QuantizedWeight. Neither is clearly right without a concrete FP8 user story. Revisit with M16 (mixed precision) or when Nx gains FP8. |
M16 extends mixed precision to FP8 master weights. | Blocked on Nx dtype or shadow-type design. | Deferred — re-evaluate when M16 surfaces a concrete requirement. |
| B5 | ThreadLocalStream / new_thread_local_stream / set_default_stream |
vendor/mlx/mlx/stream.h:24-41 |
Emily uses mx::new_stream per worker thread (c_src/emily/worker.hpp:102) and mx::default_stream(cpu) for linalg. The newer thread-local APIs are unused. |
Could simplify the M14 per-process worker-thread model if MLX's thread-local stream semantics align with Emily's "one stream per BEAM process" guarantee. | Investigative first — may be a no-op if the current model already expresses the same invariant, or a simplification if it doesn't. Low. | Investigative — spike to confirm semantics; no code change yet. |
Catalogued to make the rejection explicit rather than silent:
mlx::distributed::*(multi-process collectives, FSDP, ring allreduce). PLAN §"Training" ratifies "no distributed training". Re-affirm; no change.mlx::export/mlx::import(AOT graph serialisation). PLAN §Non-goals ratifies "no AOT compilation". Re-affirm.fast::metal_kernel/fast::cuda_kernel(user-level GPU kernel JIT from Elixir). Orthogonal to Emily's "Nx backend, not a framework" stance. Re-affirm.- Hadamard transform, einsum path planner, shapeless compile,
mlx::event/mlx::fencefine-grained sync. Noted present; no in-roadmap model target currently demands them.