- 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).