Skip to content

feat: Expr→MLX single-NIF compiler — lowerer + core primitive coverage (CM1) - #147

Merged
ausimian merged 5 commits into
feat/expr-compilerfrom
feat/expr-compiler-cm1
Jun 4, 2026
Merged

feat: Expr→MLX single-NIF compiler — lowerer + core primitive coverage (CM1)#147
ausimian merged 5 commits into
feat/expr-compilerfrom
feat/expr-compiler-cm1

Conversation

@ausimian

@ausimian ausimian commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Second milestone (CM1) of the Nx.Defn.Expr → MLX single-NIF compiler,
on top of CM0's program-replay engine. Targets feat/expr-compiler.

What this adds

A real Nx.Defn.Expr → flat IR lowerer and the Emily.Compiler
native: true
path: a traced defn is lowered once, compiled into one
Program, and replayed in a single NIF call per invocation — instead of
one BEAM↔worker round-trip per op (the Evaluator walk). The Evaluator
path stays the default; the compiled path is opt-in.

  • Emily.IR.lower/1 walks the Expr DAG (memoized by node id),
    porting Emily.Backend's per-op logic incl. dtype coercion. Parameters
    become input slots; constants / tensor literals / iota are materialized
    as captured refs. Unsupported ops raise — no silent fallback.
  • IR wire format grows per-instruction attributes (iattrs: dtype
    codes, shapes, axes); compile_program/eval_program/describe_program
    thread them.
  • Op coverage: elementwise (unary + binary arith/compare/logical/
    bitwise), cast, shape (reshape/transpose/squeeze/broadcast), dot
    (matmul/tensordot + batched), reductions (sum/product/max/min/all/
    any), select/where, static slice, iota.

Correctness gate

compiler_equivalence_test runs each function two ways — the native
single-NIF path vs Nx.Defn.Evaluator through Emily.Backend — and
asserts bit-identical output. 27 cases cover unary, binary (incl.
broadcasting + mixed-dtype coercion), compare (bool→u8), cast/shape,
dot (2D/vector/tensordot/batched), reductions + softmax composite,
select/relu, slice, iota, a two-layer MLP forward, DAG sharing,
tuple outputs, and closure reuse. Plus per-opcode replay-vs-eager and IR
round-trip tests.

mix precommit green: 40 doctests, 79 properties, 596 tests, 0
failures
; credo --strict clean.

Review

Ran a multi-angle /code-review; addressed findings in 093e3ef
(untrack a stray .claude lock file + gitignore it; fix the stale
describe/1 spec; coerce unary output to out.type for full
wrap-parity; dedupe the materialization blocks).

Scope

CM1 covers the core primitive set + MLP single-NIF (no fallback). Ops a
full DistilBERT forward additionally needs (gather/embeddings, layernorm
neighbourhood, etc.) fold into later coverage work; fused kernels +
quantized_matmul are CM2. No public API or decoder numerics change (the
native path is opt-in).

ausimian added 5 commits June 4, 2026 23:08
Grow the Expr-compiler IR wire format with an `iattrs` field (integer
attributes: dtype codes, shapes, axes) so ops beyond elementwise can be
replayed, and add the elementwise / cast / shape opcode set:

- C++: opcodes.hpp gains ~40 opcodes (binary arith/compare/logical/
  bitwise, unary, astype/reshape/transpose/squeeze/broadcast_to) with a
  dispatch_op switch calling the same mlx::core::* entry points as the
  eager NIFs; dtype codes decoded via to_mlx_dtype_code. compile_program
  / eval_program / describe_program thread iattrs through.
- Elixir: Emily.IR gains the full opcode table, dtype_code/1, and
  per-instruction :iattrs; Emily.Program ships them.

Tests: hand-built IR replay-vs-eager for unary+cast+binary chains,
reshape/transpose, broadcast_to, plus an iattrs describe round-trip. The
wire-format change updates the CM0 call sites. The Expr->IR lowerer and
Emily.Compiler integration follow next.
Lower a traced Nx.Defn.Expr to the flat IR and run it through the
program-replay engine in one NIF call, behind Emily.Compiler's new
`native: true` option (the Evaluator path stays the default).

- Emily.IR.lower/1 walks the Expr DAG (memoized by node id), porting
  Emily.Backend's per-op logic incl. dtype coercion: arithmetic casts
  operands to out.type; compares cast to merge(a,b) then coerce bool->u8;
  shape/broadcast mirror the backend's reshape + broadcast_to. Parameters
  become input slots; constants and tensor literals are materialized as
  captured refs. Unsupported ops raise (no silent fallback).
- Emily.Compiler gains compile_native/2: trace -> lower -> compile a
  Program once (cached in the closure) -> replay per call, realizing
  param thunks to refs and reassembling the output container.

Tests: compiler_equivalence_test runs each function via the native path
vs the Evaluator and asserts bit-identical across unary, binary (incl.
broadcasting + mixed-dtype coercion), compare, cast/shape, constants,
tensor captures, DAG sharing, tuple outputs, identity, and reuse.

Coverage so far is elementwise + cast + shape; dot, reductions,
gather/scatter and the remaining primitives follow in later CM1 commits
toward the Axon MLP / DistilBERT gate.
Add matmul/tensordot and reduction (sum/product/max/min/all/any) opcodes
and lower the corresponding Nx ops, porting Emily.Backend's logic:

- dot: non-batched -> tensordot over the contraction axes; batched ->
  permute/flatten to 3-D, MLX matmul, reshape back (mirrors
  Backend.batched_matmul/7). Non-float batched dot raises (MLX matmul is
  float-only; the compiler does not fall back).
- reductions: axes default to all (Nx.axes/1), keepdims threaded via
  iattrs; result coerced to the node's out.type like Backend.wrap.

Tests: compiler_equivalence cases for 2D / vector / tensordot / batched
dot, sum/product/max/min with keep_axes, a softmax-style exp/sum/divide
composite, and a full two-layer MLP forward — all bit-identical to the
Evaluator. The matmul-dominated path now runs single-NIF.
- select -> cast pred to {:pred, 1} then where (mirrors Backend.select/4).
- slice -> mx::slice with static integer starts; stops = starts+lengths.
  Dynamic (tensor) starts raise (deferred to CM3's offset-as-input).
- iota -> materialized as a captured constant (shape/axis/type static).

Equivalence tests: select/where (+ relu via select), static slice (incl.
the [[range]] sugar), and iota — all bit-identical to the Evaluator.
- Stop tracking .claude/scheduled_tasks.lock (a machine/run-specific
  runtime lock) and add it to .gitignore.
- Fix the stale Emily.Program.describe/1 @spec/@doc — it now returns the
  7-tuple (with iattrs) the NIF produces.
- Coerce the unary-op output to the node's out.type, matching
  Emily.Backend.wrap/3 (every eager op is wrapped through coerce). It's a
  no-op astype on aligned dtypes but keeps the node dtype exact and
  consistent with the reduction/dot/select/slice clauses.
- Dedupe the constant/tensor/iota materialization into
  materialize_const/2 and materialize_capture/2 so the from_binary +
  list-prepend + index-bump can't desync across clauses.
@ausimian
ausimian merged commit 38c7154 into feat/expr-compiler Jun 4, 2026
3 checks passed
@ausimian
ausimian deleted the feat/expr-compiler-cm1 branch June 4, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant