- Fix SIGABRT/SIGSEGV from concurrent
mx::evaldispatch. MLX's MetalCommandEncoderis not thread-safe (ml-explore/mlx#2133). Concurrentmx::evalcalls from BEAM dirty-CPU scheduler threads triggered"A command encoder is already encoding"assertions or SIGSEGV from corrupted encoder state. Fixed by serialising allmx::evalcalls throughemily::safe_eval()(mutex inc_src/emily/tensor.hpp). Also removedset_default_streamcalls fromwith_stream/2— the NIF mutated MLX thread-local state which is unreliable under BEAM process migration. Hardenedresolve_stream(-1)to avoid reading the thread-local default. - Relax MNIST convergence canary threshold from 97% to 96% to eliminate stochastic flaps (observed 96.99% on occasional runs). The test is a sanity gate, not a performance benchmark.
-
M14 — Serving concurrency: stream-per-process.
Emily.Streamlets each BEAM process use its own Metal command queue for concurrent inference.Emily.Stream.new/1creates a stream,Emily.Stream.with_stream/2scopes all ops in a block to that stream, andEmily.Stream.synchronize/1waits for completion. The stream index is passed explicitly to every op NIF (no thread-local race) via a-1sentinel for "use default stream" (backwards-compatible).Emily.Compiler.__partitions_options__/1error message now points toEmily.Stream.- New files:
c_src/stream.cpp(4 stream management NIFs),lib/emily/stream.ex(Emily.Streamstruct + API),test/emily/stream_test.exs,test/soak/stream_concurrency_test.exs. - Modified: every op NIF gained a trailing
int64_t sstream parameter;Emily.Nativestubs,Emily.Backend, and all test files updated accordingly. - README now documents both concurrency patterns (stream-per-process and pooled servings).
- New files:
-
M13 — EXLA gradient conformance. Adds a third gradient oracle — EXLA (XLA CPU backend) — to catch bugs where Emily and BinaryBackend agree on the wrong gradient (they share the same
Nx.Defn.gradlowering). Eight zoo functions plus a full transformer-block training step (forward + grad + SGD update) are tested against checked-in EXLA golden values. Per-function tolerance tables (linear ops at 1e-6, compositions at 1e-4) are calibrated against EXLA 0.11.0 CPU output.- New files:
test/support/grad_zoo.ex(shared defn functions),test/support/exla_golden_data.ex(golden values),test/emily/grad/exla_oracle_test.exs(test harness),bench/exla_golden_gen.exs(standalone golden generator script). - Refactored:
grad_equivalence_test.exsandfinite_diff_test.exsnow import shared functions fromEmily.GradZooinstead of defining inline copies. - CUDA conformance deferred to post-1.0.
- New files:
-
M12 — Zero-copy
to_binary.Emily.to_binary/1(and everything that routes throughNx.to_binaryon the Emily backend) now returns a BEAM resource binary that aliases the MLX buffer directly, instead of memcpying the bytes into a fresh BEAM binary. The resource binary's lifetime pins a freshTensorresource so the underlying MLX storage survives until the binary is GC'd. Savings are most visible when handing large tensors back to Nx (logits from inference, weight exports): one memcpy eliminated per call.- NIF change (
c_src/emily_nif.cpp):to_binarynow returnsfine::Termviafine::make_resource_binary. Defensive assert onrow_contiguousaftermx::contiguousguards against aliasing a strided buffer. from_binaryunchanged. The memcpy is a one-time cost at model load. BEAM → MLX zero-copy viaMTL::Device::newBufferWithBytesNoCopywas investigated and dropped (M12.5) — real-world binaries from safetensors never meet the page-alignment preconditions.- Tests: round-trip lifetime test at
test/emily_test.exs("to_binary aliased binary survives after tensor goes out of scope"); zero-copy memory soak attest/soak/zero_copy_roundtrip_test.exs(asserts MLX active memory and BEAM binary heap both stay flat across 200 round trips). M2 property suite still green — semantics unchanged. - Build:
EMILY_ASAN=1env var enables an AddressSanitizer build of the NIF (Makefile). Requires an OTP built with--enable-sanitizers=addressso beam.smp links the ASan runtime at startup — macOS SIP stripsDYLD_INSERT_LIBRARIESfrom processes launched through/bin/sh(whicherl/elixiruse), and loading libasan late (via dlopen of the NIF) fails because the malloc/free interceptors must be installed before any allocation. With a sanitizer-enabled OTP:NoEMILY_ASAN=1 mix compile --force ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 mix testDYLD_INSERT_LIBRARIESorERL_FLAGSneeded; beam.smp already has the runtime linked. A CI job for this is deferred until the custom OTP build cost is justified; the lifecycle and soak tests provide empirical refcount-safety coverage. - Behavioral change:
to_binaryreturns a resource binary aliasing MLX storage. BEAM's binary-vheap GC heuristics don't account for the aliased external data (the ProcBin is ~64 bytes regardless of the underlying MLX buffer size). In tight loops callingto_binary, resource binaries can accumulate without triggering collection, holding MLX memory longer than expected. Callers in hot paths should trigger:erlang.garbage_collect/0periodically or ensure the binary escapes to a short-lived process where GC runs naturally.
- NIF change (
-
M11 — MLX fused transformer kernels. Wires MLX's handwritten
mx::fast::*fused kernels (RMSNorm, LayerNorm, RoPE, scaled-dot- product attention) into Emily asdefn-callable helpers, and ships a Bumblebee shim that swaps these in for the stock composed-defn implementations when the Axon graph is rewritten withEmily.Bumblebee.FastKernels.apply/1.- Native NIFs (
c_src/ops/fast.cpp) overmx::fast::rms_norm,mx::fast::layer_norm,mx::fast::rope, andmx::fast::scaled_dot_product_attention. Nullable weight / bias / freqs arguments marshal viastd::optional; the SDPA mask argument list marshals viastd::vector<fine::ResourcePtr<Tensor>>. Emily.Fast(lib/emily/fast.ex) —rms_norm/3,layer_norm/4,rope/3,rope_with_freqs/4,scaled_dot_product_attention/4,scaled_dot_product_attention_with_mask/5. Each helper emits aNx.Defn.Expr.optional/3node whose op name matches a custom callback onEmily.Backend; the Evaluator dispatches to the fused kernel under Emily and falls back to a defn composition on any other backend. This makes the helpers safe to drop into Bumblebee inference paths without breaking BinaryBackend conformance runs.Emily.Backend.fast_*— six custom callbacks (not part of theNx.Backendbehaviour) that Evaluator picks up when the input tensors carry Emily data. They unwrap refs and call the Native NIF directly.Emily.Bumblebee.FastKernels(test/support/) — Axon graph rewriter, mirroring the M10.5Emily.Quantization.Transformpattern. Rewrites:rms_normand:layer_normnodes viaAxon.map_nodes,Bumblebee.Layers.apply_rotary_embedding/5by function-reference match, and coalescesattention_weights_impl + attention_output_implinto one fused SDPA layer viaAxon.rewrite_nodes. RoPE handles all four Bumblebee scaling strategies (:linear,:dynamic,:longrope,:llama3) by precomputing the inverse-frequency table Elixir- side and passing it to MLX via thefreqs-override overload.- Tests: per-kernel Native/defn/equivalence suites at
test/emily/fast/; shim unit tests attest/emily/bumblebee/fast_kernels_test.exs; fused-kernel variants of every*_fullconformance suite tagged:fast_kernels_full(excluded by default like the other*_fulltags). Run explicitly:mix test --only fast_kernels_full. - Bench:
bench/qwen3_tokens_per_sec.exsgains anEMILY_BENCH_FAST_KERNELS=1mode that benchmarks baseline vs fused side-by-side, plus anEMILY_BENCH_PIN=<multiplier>flag that fails with a non-zero exit when the fused mean throughput doesn't clear the multiplier × baseline threshold.
- Native NIFs (
-
M10 (partial) — Quantized inference primitives. Exposes MLX's affine int4/int8 group-wise quantization at the Native and Elixir levels, plus a direct-call helper for eager use. Enough to quantize a dense weight, store it packed at rest, and dispatch a fused quantized matmul against it in plain Elixir code.
- Native
Native.quantize/3,Native.dequantize/5,Native.quantized_matmul/7(c_src/ops/linalg.cpp) overmx::quantize,mx::dequantize,mx::quantized_matmul.quantizereturns a 3-tuple{w_q, scales, biases}— first NIF in the tree returning a tuple of resources.quantized_matmultakes thetransposeflag as an explicit arg rather than the PLAN-spec'd/6signature: AWQ packed layouts needtranspose=falsewhile fresh-from-dense weights usetranspose=true, and MLX exposes the flag as a required parameter on its C++ API. Emily.QuantizedWeight(lib/emily/quantized_weight.ex) —Nx.Container-derived struct bundling{value, scales, biases}with{group_size, bits, transpose}metadata (the latter flaggedkeep:so the scalars survive container traversal — defn trace,backend_transfer, parameter-map walks).from_dense/2validates rank ≥ 2, last-axis divisibility, dtype ∈ {f16, bf16, f32}, and bits ∈ {2,3,4,6,8} before dispatch.to_dense/1inverses viaNative.dequantize.Emily.Quantization.quantized_matmul/2(lib/emily/quantization.ex) — direct-call helper over materialized tensors. Extracts refs from the input%T{}and the three tensors inside a%QuantizedWeight{}, dispatches to the Native NIF, and rewraps the result. Input dtype must matchqw.scales.type(raises with a targeted error otherwise); BinaryBackend inputs are transferred transparently. Used by the soak and property tests today; the fused-kernel perf win is available here but not yet from defn-traced forward passes (see below).test/emily/native_test.exs(+7 cases) — hand-computed quantize-shape checks (group_size=64, bits=4→ 8 u32/row; bits=8 → 16 u32/row; smaller group_size → more scale/bias rows),dequantizeround-trip within int4 step tolerance, validation of indivisible last axes, andquantized_matmulequivalence vs.matmul(x, dequantize(…))for bothtranspose=trueandtranspose=falselayouts.test/emily/quantization/quantized_weight_test.exs(new) —from_dense/2metadata/shape/dtype assertions, validation-raise cases (indivisible axis, unsupported bits, unsupported dtype, rank < 2), bits=8-tighter-than-bits=4 property, and anNx.Containertraversal test confirmingkeep:preservesgroup_size/bits/transposeacrossbackend_transfer. Property test: random group-shaped weights round-tripfrom_dense |> to_densewithin a 0.15 tolerance band.test/emily/quantization/quantized_matmul_test.exs(new) — property test fortranspose=trueagainst aNx.dot(x, Nx.transpose(to_dense(qw)))oracle on BinaryBackend; explicittranspose=falsecase assertingNx.dot(x, to_dense(qw))convention; dtype-mismatch raise test; BinaryBackend-input auto-transfer test.test/soak/quantized_memory_test.exs(@moduletag :soak, default suite) — 1000-iteration quantized-matmul soak asserting active memory returns within 4 MB of baseline afterclear_cache/0. Separate frommemory_test.exsbecause quantized inference is allocator-pattern different from fp16: packed weights load once (not re-quantized per call), and the per-iter activation/output budget is smaller.- Scope narrowed from PLAN.md. PLAN spec'd a
Backend.dot/7dispatch path that inspects the operand struct, plus an Axon layer-replacement (Emily.Quantization.Layers.quantized_dense+Emily.Quantization.quantize/2) and aQwen/Qwen3-0.6B-AWQconformance test. Investigation uncovered thatNx.dot/2callsNx.LazyContainer.traverse/3expecting a single%T{}— a three-tensor%QuantizedWeight{}container raises before reachingBackend.dot/7. The alternative — an Axon layer op callingNative.quantized_matmulduring forward pass — fails for a different reason: Axon layer ops run atNx.Defn.jittrace time withNx.Defn.Exprinputs, andNx.Defn.Evaluatorhas no public hook to inject a custom op that isn't aNx.Backendcallback. Closing the gap requires either (a) a defn-native dequantize built from Nx bit primitives (loses the fused kernel), or (b) an Emily-specificNx.Defn.Compilervariant that recognizes a sentinelExprnode and routes toNative.quantized_matmul. Both are meaningful scope; tracked as M10.5. The Native + container + direct-call helper surface shipped in M10 is the substrate that either of those approaches will build on.
- Native
-
M9 — Gradient conformance and training primitives. Makes
Nx.Defn.gradusable on Emily by lifting the three ops that grad lands on most heavily off thevia_binaryfallback, and adds the test scaffolding to trust a gradient. Training on Emily has been technically possible since M2 (grad is symbolic in Elixir and lowers to forward ops), but every embedding-style backward was round-tripping toNx.BinaryBackend.- Native
Native.gather/4,Native.scatter/4,Native.scatter_add/4backingmx::gather,mx::scatter,mx::scatter_add. List-of- per-axis-indices form (MLX's native shape) rather than the{N, rank}tensor Nx passes around — the Backend layer does the translation. Emily.Backend.gather/4,indexed_add/5,indexed_put/5rewired.gatherretains the single-axisNative.takefast path (now with an explicit output reshape — fixes a latent shape lie exposed when downstream MLX ops inspect the ref shape directly, e.g.Nx.gatherfollowed byNx.dotin a grad); the multi-axis path is native. Sharedapply_scatter/6helper handles index splitting (split_indices_per_axis/3) and MLX's updates-shape rewrap — Nx ships{batch ++ non_indexed_dims}but MLX requires{batch ++ per_axis_slot}with a length-1 dim at every indexed- axis position (updates_shape_for_scatter/3). Fallback tovia_binaryon shapes outside the covered contract.- Duplicate-index divergence. MLX
scatteris parallel and unordered on duplicate indices;Nx.indexed_putis deterministic last-write. Documented in the NIF and Backend; grad property generators dedupe.scatter_addis commutative so duplicates accumulate correctly either way. test/emily/native_test.exs(+5 cases) — scalar-write, partial-axis slice write, and the load-bearing{B, L, D}target withaxes: [0, 1]rewrap case.test/emily/backend_test.exs(+8 tests) — targeted cases for all three ops exercising the Backend translation paths end-to-end via Nx.test/emily/grad/grad_equivalence_test.exs— property zoo covering sum, dot, reshape∘transpose, broadcast, gather, indexed_add, plus two composition cases (gather→dot→softmax and a mini-attention block). Each case runs undercompiler: Emily.Compileron Emily.Backend andcompiler: Nx.Defn.EvaluatoronNx.BinaryBackend, asserting the grad matches within a grad-scaled tolerance. Also covers the M9 PRNG-key-threading risk called out inPLAN.md: grad through aNx.Random.uniform_split-driven dropout with fixed keys produces bit-identical results across repeat runs.test/emily/grad/finite_diff_test.exs+test/support/grad_helper.ex— finite-difference numerical- gradient oracle. Pilot of four ops (sum,dot,logsumexp,sigmoid) with per-op tolerance tables; the harness catches the class of bug where symbolic-grad-on-Emily and symbolic-grad-on- BinaryBackend agree but are both wrong.test/emily/training/mlp_curve_test.exs+test/emily/training/transformer_block_curve_test.exs+test/support/training_helper.ex— handwritten MLP and single transformer block (attention + FFN + residuals) trained with vanilla SGD for 50 steps on a fixed synthetic batch. Two tolerance bands asserted in each: per-step lossrtol = 1e-3(silent-drift canary — MLX parallel reductions diverge from BinaryBackend sequential reductions, so strict bit-match would be flaky) and final-lossrtol = 1e-4(convergence correctness). No Axon dep at this tier; the training loop is handwritten so a red test points at backend/grad numerics.test/soak/training_test.exs— 1k-iteration training-loop memory soak,@moduletag :soak(default suite). Reuses the handwritten MLP; asserts active memory returns within 2 MB of baseline afterNative.clear_cache/0. Training exercises a different allocator pattern than inference (param–grad pairs, activations), hence a dedicated soak alongsidememory_test.exs.test/emily/training/mnist_full_test.exs— opt-in MNIST convergence canary,@moduletag :training_full(excluded by default; run viamix test --only training_full). Loads MNIST throughscidata, trains an Axon MLP onEmily.Compilerfor 5 epochs, asserts >97% test accuracy. Catches systemic grad drift that curve-matching misses because curve-matching uses BinaryBackend as its own oracle — MNIST convergence is an independent cross-check against real-world training dynamics. Typical wall time ~10 s on Apple Silicon.{:scidata, "~> 0.1", only: :test}added for MNIST loading. Kept test-only; Emily itself has no dataset-loading dep.test/test_helper.exs—:training_fulladded to the default-exclude list, documented alongside the other heavyweight tags.PLAN.md— M9 scope formalized; M10 (conv-pool training) and M11 (1.0 release) renumbered.
- Native
-
M8 — Native
conv.Emily.Backend.conv/4now dispatches directly toNative.conv_general(already bound tomlx::core::conv_generalsince M1) instead of round-tripping throughNx.BinaryBackend. The previous fallback was correct but CPU-bound — ≥90% of the ViT and Whisper forward-pass cost. ViT/Whisper full-checkpoint conformance tests drop from tens of seconds to under 2 s as a side-effect.- Layout translation. MLX
conv_generalexpects NHWC input and OHWI weight and returns NHWC; Nx's canonical layout is NCHW/OIHW. The new callback composes the caller'sinput_permutation,kernel_permutation, andoutput_permutationopts with the NCHW↔NHWC and OIHW↔OHWI transposes, applying the inverse ofoutput_permutationon the way out (Nx delivers it in user→canonical form; seedeps/nx/lib/nx/shape.ex:729-735). Two ordered transposes per tensor rather than one composed transpose — MLX's lazy graph fuses them and the step-wise form is obviously correct across rank 3, 4, and 5. - Integer-operand coercion.
Nx.convreturns a float but does not cast its operands; MLX conv is float-only. The backend now runsNative.astype(ref, out.type)on input and kernel before the transpose chain. Same-type astype is elided by MLX. - Remaining fallbacks.
batch_group_size > 1has no MLX primitive and still routes throughvia_binary. Complex-typed conv ditto. Neither appears in the pinned Bumblebee ref. test/emily/backend_conv_test.exs(new) — oracle suite vsNx.BinaryBackend. Covers 1-D / 2-D / 3-D; stride,:same/:valid/ explicit asymmetric padding;kernel_dilationandinput_dilation > 1; grouped and depthwise conv; all three permutation options (independently and combined for an NHWC end-to-end caller); and integer-input→float-output coercion.test/emily/backend_fallbacks_test.exs— removed the now-obsolete "conv routes through BinaryBackend" test. Added abatch_group_size > 1fallback case asserting the rare path still matches BinaryBackend.
- Layout translation. MLX
-
M7 — Bumblebee conformance breadth. Two new models across four new test suites extend M3 (DistilBERT) and M4 (Qwen3) beyond encoder-only/decoder-only text into vision and audio.
test/emily/conformance/vit_test.exs(@moduletag :conformance) — portsBumblebee.Vision.VitTestverbatim: three tiny-random architectures (:base,:for_image_classification,:for_masked_image_modeling) driven with synthetic pixel inputNx.broadcast(0.5, {1, 30, 30, 3}), asserted against the same PyTorch-produced reference slices Bumblebee's own suite pins. First conformance suite to exercise theconvfallback path in anger.test/emily/conformance/vit_full_test.exs(@moduletag :vit_full, excluded from--only conformancebecause the checkpoint is ~330 MB) — loadsgoogle/vit-base-patch16-224, runs a forward pass on a deterministic constant-gray pixel tensor, asserts a pinned leading-5 logits slice plus argmax == 763 (ImageNet class "revolver"). Uses synthetic input rather than a checked-in JPEG fixture so the repo stays free of binary assets and the featurizer doesn't enter the assertion surface. Run withmix test --only vit_full.test/emily/conformance/whisper_test.exs(@moduletag :conformance) — portsBumblebee.Audio.WhisperTestverbatim: two tiny-random architectures (:base,:for_conditional_generation) driven with the sameNx.sin(Nx.iota({1, 60, 80}))mel features and decoder ids, asserted against Bumblebee's reference slices. First conformance suite to exercise encoder-decoder cross-attention on Emily, and the first with strided 1-D conv in the encoder frontend.test/emily/conformance/whisper_full_test.exs(@moduletag :whisper_full, excluded from--only conformancebecause the checkpoint is ~150 MB) — loadsopenai/whisper-tiny, runs a forward pass on a synthetic 30-s mel window (sin(iota({1, 3000, 80}) * 0.01)), asserts pinned leading 3×3 logits slice + decoder-last-step argmax. Run withmix test --only whisper_full.test/support/conformance_helper.ex— shareduse-able module lifting thesetup_allbackend-swap block andassert_all_close/3out of DistilBERT, Qwen3, ViT, and Whisper suites. Net change before the two new suites was ~zero LOC; keeps future conformance additions terse.test_helper.exs— exclude list extended with:vit_fulland:whisper_full. Comment rewritten to document each heavyweight tag and its cache footprint.PLAN.md— renumbered: M7 = conformance breadth (this), M8 = native conv, M9 = 1.0 release (was M7). MoE / Mixtral tracked as deferred pending upstream Bumblebee support.
Emily.Backend.via_binary/via_binary_tuple— pin the default backend toNx.BinaryBackendfor the duration of the fallbackfuncall. Surfaced when ViT tiny-random exercisedconv: the helpers transferred input tensors correctly, butNx.convconstructs a scalar internally (Nx.pad(t, 0, ...)builds a zero-pad tensor) and that scalar landed on whatever the current global default was —Emily.Backend, because the conformancesetup_allinstalls it. BinaryBackend then saw a mixed-backend operand list and crashed with a FunctionClauseError onto_binary. Never surfaced before becausetest/emily/backend_fallbacks_test.exsdoesn't install Emily as the global default (tensors built withbackend: Emily.Backendopt-in) and every prior Bumblebee suite had its hot-path ops off the fallback by M4.
- M6 —
mlx::core::compilewrapping: dropped after Phase-1 de-risk. A pure-C++ microbenchmark on MLX 0.25.1 against an Apple Silicon GPU showed the fusion win on a Qwen3-0.6B-shaped transformer block is 1.04–1.07× on GPU (below the PLAN's 1.20× gate) and a regression on CPU (0.82–0.88×). A sanity workload (pure elementwise chain) in the same harness shows the expected 2.78× GPU / 1.47× CPU wins, confirming the measurement is trustworthy — the limiting factor is that MLX compile doesn't fuse matmul with surrounding elementwise ops, and transformer inference is matmul-dominated. The BEAM- integrated compile path could not exceed this C++ ceiling, so Phase 2 and 3 were not built.bench/native/compile_microbench.cpp— standalone C++ microbench (hand-written RMSNorm + GQA-lite attention + SwiGLU block, plus an 8-op elementwise sanity test). Links against the vendored libmlx via the same rpath the NIF uses.mix bench.native(lib/mix/tasks/bench.native.ex) — Mix task that invokes the newbench-nativeMakefile target with the same envelixir_makesets, ensuring the bench uses the project's pinned MLX without a second fetch. Supports--seq,--warmup,--itersargs viamix bench.native -- <args>.bench-nativetarget added to the rootMakefile, producing$(BUILD_DIR)/compile_microbench.bench/compile_microbench.md— full results table + reproduction instructions. Retained so the decision can be re-measured against future MLX releases without rebuilding the harness.PLAN.mdupdated: M6 section rewritten to record the drop, core design decision #1 and the M5 section footnote updated to match.
-
M5 —
Emily.Compiler, anNx.Defn.Compilerimplementation that runsdefncomputations onEmily.Backend. WrapsNx.Defn.Evaluatorafter validating options and pinning the result backend; the Evaluator already walksNx.Defn.Exprin Elixir and dispatches each op viaNx.Shared.list_impl!/1, which findsEmily.Backendwhenever the operands carry it.__to_backend__/1returns{Emily.Backend, [device: …]}soNx.Defn.to_backend/1(consulted byNx.Servingand friends) allocates inputs and outputs on Emily rather than the process default backend. Honours:deviceopt; defaults to:gpu.__partitions_options__/1pins to a single partition. MLX's Metal runtime is not safe for concurrent kernel dispatch from multiple OS threads (the same constraint that forcesmax_cases: 1intest_helper.exs); a multi-partition serving would race the driver.:max_concurrencyis accepted forNx.ServingAPI compatibility but values >1 raise.- No external compile cache.
__compile__/4returns a closure that captures the walked plan; 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. PLAN.md originally specified an ETS cache keyed by{mfa, input_signature}; deliberately deviated after accounting for the per-call ETS deep-copy cost on a Qwen3-sized expression tree. PLAN.md updated to record the rationale. - No
mlx::core::compilewrapping. That is M6; lazy evaluation at the Backend layer suffices for correctness. test/emily/compiler_test.exs— callback-contract tests (__to_backend__device routing, partition pinning, unknown-option rejection,:max_concurrency > 1refusal); op-equivalence tests across elementwise / reduction / shape / linalg / container-output paths; control-flow equivalence underdefnforwhile(the construct Qwen3's KV-cache update relies on) andcond; aNx.Defn.compile/3reuse test confirming the closure executes repeatedly without re-walking.test/emily/compiler_axon_test.exs— the M5 exit criterion. A 3-layer Axon MLP forward pass underEmily.CompilermatchesNx.Defn.Evaluatoron the same backend within float tolerance, plus aNx.Defn.compile/3reuse case driving multiple inputs through one walk.:axonadded as an explicitonly: :testdep — already transitively available via Bumblebee, but the Axon MLP test reaches for it directly and shouldn't be hostage to a Bumblebee dep change.
-
M0 scaffold: mix project, MLX 0.25.1 prebuilt fetch pipeline, Makefile wiring
fine+ MLX,Emily.NativeNIF surface for tensor round-trip, application supervisor skeleton, smoke test suite. -
M1 —
Emily.Nativeop inventory. Shared headers inc_src/emily/(dtype mapping, Tensor resource, helpers); per-category op files underc_src/ops/:- Creation:
zeros,ones,full,arange,eye. - Cast:
astype. - Unary elementwise:
negative,abs,sign,floor,ceil,sqrt,rsqrt,exp,expm1,log,log1p,log2,log10, trig/inverse-trig/hyperbolic family,sigmoid,erf,erfinv,square,reciprocal,logical_not,bitwise_invert,isnan,isinf,isfinite,conjugate,real,imag,stop_gradient,round(with decimals). - Binary elementwise:
add,subtract,multiply,divide,floor_divide,remainder,power,maximum,minimum,logaddexp,arctan2. - Compare:
equal,not_equal,less,less_equal,greater,greater_equal. - Logical:
logical_and,logical_or. - Bitwise:
bitwise_and,bitwise_or,bitwise_xor,left_shift,right_shift. - Reductions (axes + keepdims):
sum,mean,prod,max,min,all,any,logsumexp; plusvar/stdwithddof,argmax/argmin, cumulativecumsum/cumprod/cummax/cummin/logcumsumexp. - Shape:
reshape,transpose,squeeze,expand_dims,broadcast_to,concatenate,stack,flatten,tile,swapaxes,pad,repeat. - Sort family:
sort,argsort,partition,argpartition,topk. - Indexing:
slice,take,where,take_along_axis,put_along_axis,scatter_add_axis. - Misc:
clip,roll,softmax,array_equal. - Linalg:
matmul,tensordot,outer,inner. - Convolution:
conv_general(N-D with asymmetric padding, dilation, groups, flip). - Random:
random_key,random_split,random_uniform,random_normal,random_randint,random_bernoulli,random_gumbel,random_categorical— keys passed as optional tensor args (nil uses MLX's default key sequence). - FFT:
fftn,ifftn,rfftn,irfftn. - Memory:
get_active_memory,get_peak_memory,reset_peak_memory,get_cache_memory,clear_cache— exposed so the soak harness can observe allocator state.
- Creation:
-
Emily.Native.to_binary/1routes throughmx::contiguousso strided views (transpose/slice/swapaxes/broadcast) materialize correctly. -
test/support/tensor_helpers.ex— shared build/inspect helpers. -
test/soak/memory_test.exs(@tag :soak, excluded by default) — 5000-iteration allocate/eval/drop loop; asserts MLX active memory returns within 1 MB of baseline afterclear_cache. -
test/emily/dtype_matrix_test.exs— smoke matrix covering every supported dtype across creation, cast, unary (float + numeric), binary, reductions, and comparisons. -
Makefile compiles
c_src/**/*.cpprecursively. -
M2 —
Emily.Backend, theNx.Backendimplementation. Wraps every required callback with a thinEmily.Nativedelegation, so any Nx computation can run on MLX viaNx.global_default_backend(Emily.Backend)orbackend:opts.- Creation, cast, unary, binary, shape, indexing, reductions, cumulative reductions, sort family, dot, FFT, top_k, take, take_along_axis, all_close — all routed directly to MLX NIFs.
- Compositions where no single MLX primitive exists:
erfc(1 - erf),cbrt(sign(x) * |x|^(1/3)),logical_xor(xor of boolean-casted operands),reverse(take with reversed indices per axis). - BinaryBackend round-trip fallback for ops that need non-trivial
composition in v1:
conv, multi-axisgather,put_slice, batcheddot,reduce/window_reduce,window_sum/_max/etc.,window_scatter_*,indexed_add/put, and advanced linalg (lu,svd,triangular_solve). Correct but slow; direct MLX paths land incrementally as downstream consumers need them. - Hard error on
{:f, 64}(Metal has no f64) and onbitcast,from_pointer/to_pointer,population_count,count_leading_zeros— no MLX primitive. - Scalar-on-foreign-backend handling: any tensor the callback
receives that isn't on
Emily.Backend(Nx routinely passes scalars onNx.BinaryBackend) is transferred in transparently. - u8↔pred coercion: MLX comparison/logical ops yield
mx::bool_; Nx expects{:u, 8}. Any callback whose declared output dtype is{:u, 8}but whose MLX result ispredis cast at the wrap boundary. test/support/backend_generators.ex— StreamData generators for shape, dtype, and tensor values;assert_close/3with dtype-aware tolerance.test/emily/backend_test.exs— property-based oracle tests vs.Nx.BinaryBackendacross creation, cast, every unary/binary, shape, indexing, reductions, sort, and dot.test/emily/backend_lifecycle_test.exs— init/from_binary/ to_binary/backend_copy/backend_transfer/inspect/to_batched/bitcast raisers.test/soak/backend_soak_test.exs—@tag :soak500-iteration MLP forward pass; asserts MLX active memory returns to baseline.test/soak/backend_concurrency_test.exs—@tag :soakcross-process determinism check. Runs workers sequentially (max_concurrency: 1) because MLX's Metal runtime is not safe for concurrent kernel dispatch from multiple OS threads; the limitation is upstream and documented in the test moduledoc.
-
Interior-axis cumulative reductions (
cumulative_sumand friends withaxis: iwherei != rank - 1) route through BinaryBackend. MLX's cumulative kernels raise "Unable to safely factor shape" on several 4-D-and-up view patterns — both the straight call and a transpose-to-last-axis workaround hit the same factoring path. The last-axis fast path stays on MLX; interior-axis usage is rare on our M3/M4 critical path (transformer inference doesn't need it). -
M4 — Qwen3 inference.
Qwen/Qwen3-0.6Bgreedy-decodes end-to-end onEmily.Backendthrough Bumblebee's causal-LM serving. Everything on Qwen3's critical path (QK-norm, rotary embeddings, GQA, SwiGLU FFN, RMSNorm, tied embeddings, KV-cacheput_slicein adefnwhile loop) runs correctly.- Native
put_slice/4inEmily.Backend, backed by a newNative.slice_update/3NIF overmx::slice_update. Replaces the BinaryBackend round-trip — autoregressive decoding callsput_sliceper layer per token to append into the KV cache, and the old fallback transferred ~1 MB of cache state through the allocator on every call. Also fixes a latent bug in the old implementation: dynamic scalar-tensorstart_indicesonEmily.Backendused to slip through unconverted and crash inside BinaryBackend.slice_startis now applied to every start index, matching theslice/5callback. - Operand-type promotion in
put_slice.Nx.put_slicepromotes the output type across tensor/update (an s32 pad buffer clashing with an s64 decoder input becomes s64), but the backend callback still receives the original-type operands. We cast bothtandslicetoout.typeviaNative.astypebefore dispatching toslice_update. Without this the MLX buffer silently disagrees with the Nx shape metadata — the first symptom isNx.to_binaryreturning a half-sized binary andBinaryBackend.bitstring_partraising a match error deep inside the tokenizer decode. Mirrors the arithmetic-op promotion fix landed in M3. test/emily/conformance/qwen3_test.exs(@moduletag :conformance) — portsBumblebee.Text.Qwen3Testverbatim (three architectures::base,:for_causal_language_modeling,:for_sequence_classification), with HF reference slices checked in, plus agreedy generationdescribe block that drivesBumblebee.Text.Generation.build_generateon the tiny-random causal LM. That smoke test feeds syntheticinput_idsin[0, 1024)(tokenizer vocab is 151 k but the tiny checkpoint's embedding is 1024 rows), greedy-decodes 16 tokens through the full generation pipeline (Axon.predict+ logit processing +Nx.argmax+put_sliceKV-cache update +defn while), and asserts bit-exact equality against bothNx.BinaryBackendrun on the same inputs and a checked-in 16-token reference.test/emily/conformance/qwen3_full_test.exs(@moduletag :qwen3_full, excluded from--only conformancebecause the checkpoint is ~1.5 GB) — loadsQwen/Qwen3-0.6Bproper, greedy-decodes 32 tokens from a fixed prompt throughNx.Serving, and asserts the completion string matches a checked-in reference. Run withmix test --only qwen3_full.bench/qwen3_tokens_per_sec.exs— standalone wall-clock throughput harness. LoadsQwen/Qwen3-0.6B, runs N warmup iterations + M measured iterations of greedy decode, reports tokens/sec. Prompt, token count, and iteration counts are overridable viaEMILY_BENCH_*env vars. Baseline observed on a dev M3 host: ~13.8 tok/s at 16 new tokens under theNx.Defn.Evaluatorcompiler (nomlx::core::compilewrap yet — that lands in M6). Intended as a regression gate, not a headline number.- Bumblebee dependency bumped from Hex 0.6.3 to a pinned
maincommit (273805e9…) soBumblebee.Text.Qwen3is available — the text port is on main but not yet in a Hex release. Revert to a Hex version as soon as one ships Qwen3 support. test_helper.exsextended the exclude list with:qwen3_fullso the weights-heavy test stays out ofmix test --only conformance.
- Native
-
M3 — DistilBERT end-to-end on Bumblebee. Every Nx op on the transformer critical path now runs natively on MLX; the full forward pass matches HuggingFace Transformers (PyTorch) reference values within f32 tolerance.
- Native batched
dot/7inEmily.Backend, replacing the BinaryBackend bounce. Permutes operands to[batch… , free… , contract…]/[batch…, contract…, free…], collapses to 3-D, dispatches toNative.matmul(which treats leading dims as batch), reshapes to Nx's canonicalbatch ++ free_a ++ free_blayout. Hits 12× per DistilBERT forward pass (2× per attention layer × 6 layers). Falls back to BinaryBackend for non-float dtypes — MLX matmul is float-only. - Binary op type promotion fixed at the Backend boundary.
MLX's cross-type promotion for mixed integer widths (e.g.
right_shift(u64, s32)) falls to float32 and then rejects the op.Emily.Backendnow casts both operands to the Nx-computed output type (for arithmetic/bitwise) or merged input type (for compare/logical) before dispatching to MLX. UnblocksNx.Random.key, which is pulled in transitively even in inference-only models via Axon's dropout defn. - Dynamic
slicestarts. Nx passes scalar-tensor starts underdefnevaluation;Emily.Backend.slicenow materialises them to their concrete values on the fly. bitcastimplemented viamx::view(zero-copy reinterpret cast between equal-width dtypes). Required byNx.Randomto move between f32 and u32 bit patterns.argmax/argminkeep-axis robustness. Derive the keep-axis flag fromout.shapevs input rank instead of trusting the raw opts key (Nx's user-facing API uses:keep_axis, singular, while some callers pass:keep_axes).test/emily/conformance/distilbert_test.exs(@moduletag :conformance, excluded by default; run withmix test --only conformance) — ports Bumblebee's own DistilBERT tests verbatim. Six architecture variants (:base,:for_masked_language_modeling,:for_sequence_classification,:for_token_classification,:for_question_answering,:for_multiple_choice) plus anNx.Serving.batched_runsmoke test exercising the QA pipeline end-to-end (tokenizer → model → postprocess).- CI runs the conformance suite on every push/PR as a separate
step after
mix precommit.~/Library/Caches/bumblebeeis cached across runs so the ~3 MB HF fixture download happens once. Localmix testremains opt-in via--only conformanceso a fresh-clone/offline contributor isn't blocked by network. - Batched-dot property tests added to
test/emily/backend_test.exs— 1- and 2-axis batch cases plus edge shapes (scalar output, multi-free-axis both sides). - Test-only deps:
bumblebee ~> 0.6,tokenizers ~> 0.5(bothonly: :test). Nx pinned to~> 0.10(down from 0.11) to match Bumblebee's current constraint; emily's own API is unaffected.
- Native batched
Emily.Backend.put_slice/4— swappedsliceandstart_indicesparameters. Latent since M2 because the callback routes through the BinaryBackend fallback and had no direct test. Surfaced by the new fallback-coverage suite.
test/emily/backend_fallbacks_test.exs— smoke coverage for everyvia_binarybranch (put_slice, multi-axisgather,conv,reduce,window_reduce,window_sum/_product/_max/_min,window_scatter_max/_min,indexed_add/_put,lu,triangular_solve,svd) plus the forced-fallback branches (integer batcheddot, interior-axiscumulative_*). The fallback dispatches to BinaryBackend, so comparing against BinaryBackend is tautological — these tests verify the transfer / compute / rewrap round-trip runs clean, not numerical correctness.- Extended
test/emily/backend_lifecycle_test.exswith the three raise-only callbacks (count_leading_zeros,population_count,padwith interior padding), thebackend_transfer(t, Nx.Tensor)identity case, thefrom_binaryiodata path, and theinspect:infinitylimit branch. - Aggregate coverage with
mix test --cover --include conformance: 74.7% → 81.9% total;Emily.Backend73.5% → 82.3%. Remaining uncovered inEmily.Backendis a handful of functional ops not yet in the property suite (fft/ifft/fft2/ifft2,argsort,top_k,erfc,cbrt,all_closewithequal_nan: true) plus unreachable defensive branches.
- Ops files use anonymous namespaces to prevent NIF function names
(
sin,log1p,sqrt, ...) from colliding with C math-library symbols pulled in by MLX headers. - Deferred beyond M1: the full
scatter/scatter_add/... family with vector-of-indices (only the axis-aligned forms are bound),hadamard_transform, quantized matmul,linalg.*decompositions (LU, QR, Cholesky, SVD). These will be added opportunistically when M2/M3 callers need them. - Deferred beyond M3: native
convtranslation (PLAN lists it under M3, but DistilBERT and M4's Qwen3 don't use it; the BinaryBackend fallback remains until a CV model lands on Emily).