-
Single-NIF native compiler —
Nx.Defn.jit/compilewithcompiler: Emily.Compiler, native: true. A realNx.Defn.Compilerpath that lowers a tracedNx.Defn.Exprto a flat IR once and replays the whole forward graph in a single NIF call per invocation, instead of one BEAM↔worker round-trip per op. For dispatch-bound workloads — autoregressive decode, where the structurally-identical graph is otherwise rebuilt op-by-op every token — this collapses the per-token dispatch cost (a 100-op microbench shows a >15× build/dispatch collapse). Weights cross the NIF boundary once (captured by the compiled program) and are never re-serialized per call. Opt in per call:Nx.Defn.jit(&forward/1, compiler: Emily.Compiler, native: true).(input)Coverage is no-fallback: the full Nx primitive set (with
Emily.Backend's dtype-coercion and op-composition semantics ported into the lowering), the fusedEmily.Fast.*/Nx.Block.*kernels (RMSNorm, LayerNorm, RoPE, scaled dot-product attention and its mask/sink variants, the LinAlg blocks), quantized matmul (now anNx.blocknode so it fuses under the compiler too), dynamic KV-cache writes (put_sliceat a runtime offset), container/tuple outputs, andcond(lowered to a select-chain). DistilBERT and ViT forwards run end-to-end under the compiler withconfig :emily, :fallback, :raise. Constructs the IR can't lower yet —whileloops, arbitrary BEAMreducefunctions — are handled by the graceful fallback below.An opt-in compiled eval mode additionally wraps the replay in
mlx::core::compile, fusing the elementwise runs (rms-norm, softmax, SiLU gating, residual adds) the replay leaves as separate kernels — measured at ~1.5–1.6× over the plain replay on a decode-shaped transformer block. -
Graceful native fallback —
native_fallback: :eval(the default). When anative: truedefn contains an op or construct the Expr compiler can't lower yet, the whole defn now routes throughNx.Defn.Evaluator(each op then dispatches throughEmily.Backend, with its own per-op fallback) and emits a one-shot[:emily, :compiler, :fallback]telemetry event — instead of raising. This makes it safe to install the compiler globally on any model:Nx.Defn.global_default_options(compiler: Emily.Compiler, native: true)Covered subgraphs (e.g. encoder forwards) run fully native; the rest is transparently evaluated. Pass
native_fallback: :raise(or setconfig :emily, native_fallback: :raise) to fail instead — the conformance suites use this to prove a model lowers fully native. -
More ops lower natively —
argmax/argmin,clip, andsort/argsort(ascending and descending) now compile under the native single-NIF path rather than routing through the fallback, each mirroring itsEmily.Backendcallback bit-for-bit.argmaxin particular puts greedy-decode token selection on the native path. Remaining gaps (gather/scatter, pooling/window_*, cumulative) continue to work via the graceful fallback. -
19 more unary ops lower natively —
expm1,tan,sinh,cosh,acos/asin/atan,acosh/asinh/atanh,round,bitwise_not,is_nan/is_infinity,conjugate,real/imagroute to the samemx::*primitive as the eager unary NIF (bit-identical to the Evaluator);erfcandcbrtcompose from existing ops, mirroringEmily.Backend's eager composition. Closes the largest cluster on the Expr op-coverage checklist (#188). -
atan2,quotient,logical_xorlower natively — closes the binary/compare cluster on the Expr op-coverage checklist (#188).atan2slots into the @arith_binary mapping (cast both to out.type, thenmx::arctan2);quotientroutes throughmx::floor_dividethe same wayEmily.Backend.quotient/3does;logical_xor(no MLX primitive) lowers to(a != 0) != (b != 0), mirroring the eager Backend composition. All three are bit-identical to the Evaluator. -
pad,eye,triangular_solvelower natively — closes the top-level-Expr-op cluster on #188.padis the constant-pad path (interior dilation raises, same asEmily.Backend.pad/4);eyematerialises as a captured constant (identity matrix at lower time, same trickiotauses);triangular_solvedecomposes all fourtransform_a×left_sidecombinations into transposes around a barelinalg_solve_triangularopcode, mirroringEmily.Backend.triangular_solve/4. The C++ dispatcher routesmx::linalg::solve_triangularto the CPU stream per call (it's CPU-only), matching the eager NIF. -
Nx.Block.LogicalNot,Nx.Block.AllClose,Nx.Block.Phaselower natively — closes the misc-block cluster on #188. No new opcodes:LogicalNotemits the existing:logical_notop directly (same asEmily.Backend.native_logical_not/2);AllClosecomposes five existing primitives —astype→abs(a - b) <= atol + rtol * abs(b)→ optionalisnanOR forequal_nan: true→ reduce-all — exactly matchingEmily.Backend.native_all_close/4;Phaselowers the block'satan2(imag(t), real(t))expansion via TopK-style parameter seeding (every primitive in the expansion was already on the native path), bit-identical to the Evaluator. -
take_along_axislowers natively —Nx.take_along_axis(theNx.Block.TakeAlongAxisblock) now compiles under the native single-NIF path, mirroringEmily.Backend.native_take_along_axis/4(cast indices to s32, thenmlx::core::take_along_axis) bit-for-bit. This was the last op forcing a fallback inBumblebee.Text.question_answering's answer-span gather, so a DistilBERT question-answeringNx.Servingforward now runs fully native — and fused — undernative_fallback: :raise. -
The FFT family lowers natively —
Nx.fft/ifft(1-D, trailing axis) and thefft2/ifft2/rfft/irfftblocks now compile under the native single-NIF path instead of falling back. Each mirrors its eagerEmily.Backendwrapper, routing to the samemlx::core::fft::*kernel (unnormalizedFFTNorm::Backward) bit-for-bit, including the complex64 outputs. This was the op forcing a graceful fallback in a Whisperspeech_to_textserving — the log-mel featurizer's STFT — so that path now compiles fully native too. -
indexed_put/indexed_add(scatter) lower natively — both now compile under the native single-NIF path for MLX-scatter-compatible index layouts (the same layout the native gather already requires), mirroringEmily.Backend's scatter — split the index tensor into per-axis s32 arrays, reshape updates into MLX's layout, thenmx::scatter(overwrite) /mx::scatter_add(accumulate) bit-for-bit. Layouts MLX can't scatter still route through the evaluator undernative_fallback: :eval. -
Nx.top_klowers natively. TheNx.Block.TopKblock — a multi-output{values, indices}— now compiles instead of raising.Emily.Backendhas notop_koverride (mx::topkyields values only, not the indices Nx's contract requires), so the evaluator computes it via the block's default expansion (argsort(desc)→take_along_axis→slicethe top k); the compiler lowers that same expansion — every op in it already lowers — and projects the two leaves via:elem, bit-identical to the evaluator. -
Window (pooling) ops lower natively — forward and backward. The forward window family (
window_sum/window_max/window_min/window_product, i.e. average and max pooling), the select-and-scatter backward (window_scatter_max/window_scatter_min, the MaxPool/MinPool gradient), andreverse(the conv-backward kernel flip) all now compile under the native single-NIF path instead of falling back. The pad → sliding-window → reduce/scatter cores moved intoemily/op_cores.hppso the eager NIFs and the compiled replay share one implementation. A small-CNN training step (conv + maxpool forward and backward, grad, SGD) now lowers fully native undernative_fallback: :raise, producing a loss bit-identical to the evaluator. Native training is now convergence-tested, not just verified-lowering: handwritten CNN (30 SGD steps) and MLP (50 steps) trajectories match the op-by-op evaluator bit-for-bit and aBinaryBackendoracle to f32 tolerance, and full Axon training drives native end-to-end —Axon.Loop.runforwardsnative: true/native_fallback:to the defn jit, so a LeNet CNN and a dense MLP train on real MNIST entirely through the single-NIF path (forward, categorical-cross-entropy, backward, Adam) and reach the same >97% / >96% accuracy as the evaluator (:training_full). -
Bumblebee.Text.generationcompiles fully native — greedy and sampling. The headline result: an end-to-end Bumblebee generation (the transformer forward, thedefn whiledecode loop, dynamic KV-cache writes,cumsumposition ids,argmax/multinomial token selection, threefry sampling) now lowers to the single-NIF replay with no fallback, producing token ids bit-identical to the Evaluator. Wiring this requiredcumsum/cumprod/cummax/cummin(last-axis fast path), single- and multi-axisgather, andstack. Greedy and multinomial sampling are gated ingeneration_native_test.exs. On Qwen3-0.6B this measures ~5× the evaluator's decode throughput (~61 vs ~12 tok/s on an M-series Mac), with byte-identical completions — the single-NIF replay collapses the per-op BEAM↔worker round-trips to roughly one per token. Reproduce withbench/qwen3_tokens_per_sec.exs(baseline vs native lanes). -
Fused-while decode —
fuse: true. An opt-in lane that fuses thedefn whiledecode loop's body undermlx::core::compile. The outer loop is data-dependent and host-controlled, somx::compilecan't trace it as a whole — but the per-token forward (the loop body) is shape-stable (offsetis a runtime input), so each iteration replays through a per-stream-cached compiled callable that fuses the elementwise runs the plain replay leaves separate (RMSNorm, softmax, SiLU gating, residual adds). The compiled body cache-hits across tokens rather than recompiling per step. Enable it on top of the native generation path:Nx.Defn.jit(&forward/1, compiler: Emily.Compiler, native: true, fuse: true)On Qwen3-0.6B this lifts greedy decode to ~5.4× the evaluator (~1.1× over the plain native lane, ~68 vs ~62 tok/s on an M-series Mac). The trade-off:
mx::compilereassociates f32, so logits drift by a few ULP and the output is not bit-identical to the evaluator. Greedy argmax is robust to that drift, so in our Qwen3-0.6B run the generated token ids matched the evaluator's exactly (byte-identical completions) — but that is an empirical result, not a guarantee: a near-tie top-2 logit can flip a token, and any decision the drift can tip over — argmax, or a loop trip count whose condition reads a reassociated reduction — diverges by more than a few ULP once it flips. Sampling strategies (e.g. multinomial) will diverge from the evaluator under fusion even with a fixed seed; the gate and bench cover the greedy lane only. Thenative-fusedlane inbench/qwen3_tokens_per_sec.exsmeasures throughput;generation_native_test.exsgates the greedy token match. -
defn whilecompiles native. Data-dependent loops — includingBumblebee.Text.generation's decode loop — now lower to the single-NIF replay instead of falling back. The condition and body become nested sub-programs (their loop-carried state bound as inputs); the worker thread runs the loop, evaluating the condition each iteration to decide whether to continue, so the whole loop is one NIF call with no per-iteration BEAM↔worker round-trip. Thewhileinstruction is multi-output — its outputs are the final loop-carried state — and:elemprojects them. (The opt-inmx::compileeval mode can't trace the data-dependent host loop as a whole, so it fuses each loop body instead — see the fused-while lane below.) -
Nx.Randomcompiles native. The PRNG surface (split,uniform,normal,randint,gumbel,choice) now lowers under the native compiler, so sampling-based generation runs on the single-NIF path and a PRNG key threads through a decode loop as ordinary carried state. This needed three primitives:bitcast(random bits → float),erf_inv(normal), and a dynamic-startslice— threefry indexes its rotation table by the loop counter, a genuine runtime-start slice (the eager backend materialises the index to a host int; the compiled replay threads it as a runtimes32instead, same result). -
Emily.Generation— a model-agnostic decode-loop driver. JIT-compiles a caller-supplied shape-stable per-token forward (fn token, offset, cache, params -> {logits, cache} end) with the native single-NIF compiler and drives the autoregressive loop from Elixir: offset bookkeeping, KV-cache threading, stop conditions, next-token selection (greedy by default), and per-token streaming via:on_token. The forward runs fully native; the loop stays in Elixir, so token streaming and host-side control are preserved. Emily supplies only the mechanism — the model (forward + cache) is the caller's. -
Emily.async_eval/1(andEmily.Native.async_eval/2) schedule evaluation of one or more lazy graphs without blocking on the GPU, wrappingmlx::core::async_eval. The work is handed to the device's command queue and the call returns as soon as it is enqueued — not when it finishes. This lets a caller keep dispatching the next step's ops while the device computes the current one (e.g. an autoregressive decode loop), blocking only when a value is actually read back on the host viato_binary/1/eval/1. Pass every output of a step (logits plus all KV-cache buffers) in one call. -
Emily.Native.fast_rope_int/8— RoPE with an integer absolute-positionoffset(routing to MLX's int-offsetropeoverload), for incremental decode where the caller tracks position host-side. Complements the existing tensor-offsetfast_rope/8. Note: feed the kernel the 4-D{batch, heads, seq, head_dim}layout — in 3-D, MLX 0.31 mis-rotates single-token (seq == 1) inputs.
-
A tuple-returning
condhard-crashed the native compiler instead of lowering. Acond/ifwhose branches return a tuple (multi-output) hit aFunctionClauseErrorin the lowerer — and because that isn't anArgumentError, it escaped the graceful-fallback rescue and faulted rather than degrading to the evaluator. It now lowers to onewhere-chain per leaf (same wholesale-select semantics as a single-outputcond), projected by:elem; a nested/non-tensor container raises cleanly (graceful fallback). Surfaced by a Whisperspeech_to_textserving — with this, plus the nativefftandindexed_putlowering above, the full Whisper serving (featurizer STFT + encoder/decoder + autoregressive decode loop) compiles fully native end-to-end, gated bywhisper_full_test.exs. -
Dilated window reductions (
window_dilations > 1) returned wrong values.window_sum/window_max/window_min/window_productwith a dilated kernel silently produced garbage for windows past the first stride positions, on both the eager backend and the native compiler (they share the window-reduce core). A dilated kernel axis gets anas_stridedstride > 1, so the sliding-window view aliases fewer physical elements than its logical size; MLX's strided-reduce fast path then read past the aliased buffer. The view is now materialised contiguously before the reduce when any dilation > 1 (the common non-dilated pooling path is unchanged and stays copy-free).