Skip to content

Commit b22a43c

Browse files
committed
feat: make native compilation the Emily.Compiler default
`Emily.Compiler`'s `:native` option now defaults to `true` (read from `config :emily, :native`, itself defaulting to `true`), so a bare `compiler: Emily.Compiler` compiles to the single-NIF native lane — the biggest perf win, bit-identical to the evaluator, and safe because un-lowerable ops still route through the evaluator under `native_fallback: :eval`. Opt out with `native: false` (per call) or `config :emily, native: false` (app-wide) — e.g. on a memory-constrained host where the one-shot compile peak is too large. The suite uses a bare `compiler: Emily.Compiler` as its eval oracle, so `config/test.exs` pins `native: false`; tests that exercise the native lane pass `native: true` explicitly. The native-config test gains cases for the default-native and `config: false` paths. Docs updated to match: the Emily.Compiler moduledoc, README "Native compilation" / "Choosing a mode", ARCHITECTURE, and RELEASE.md.
1 parent 0e417de commit b22a43c

6 files changed

Lines changed: 101 additions & 75 deletions

File tree

ARCHITECTURE.md

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ without sifting milestone history. For per-milestone rationale see
99
```
1010
Emily.Compiler (Nx.Defn.Compiler) — validates opts, pins the result backend,
1111
selects one of two lowering lanes:
12-
• default (native: false): op-by-op
13-
via Nx.Defn.Evaluator + Emily.Backend
14-
• native (native: true): lower once to
15-
Emily.IR/Program, single-NIF replay
12+
• native (default, native: true):
13+
lower once to Emily.IR/Program,
14+
single-NIF replay
15+
• eval (native: false): op-by-op via
16+
Nx.Defn.Evaluator + Emily.Backend
1617
Emily.IR + Program (native lane) — flat IR compiled once; whole-graph replay,
1718
optionally mx::compile-fused (:fuse)
1819
Emily.Backend (Nx.Backend) — op-by-op translation to Native
@@ -33,24 +34,25 @@ see [Testing philosophy](#testing-philosophy).
3334
## Core design decisions
3435

3536
1. **Backend-first; compiler layered on top.** The `Nx.Backend` is
36-
enough to run Bumblebee; the `Nx.Defn` compiler is additive. By
37-
default `Emily.Compiler` delegates the expression walk to
38-
`Nx.Defn.Evaluator` and adds two adjustments: pin the result
39-
backend to `Emily.Backend` via `__to_backend__/1`, and cap
40-
`:max_concurrency` at 1. `native: true` switches to the single-NIF
41-
lane (decision 10), and `:fuse` wraps `mx::compile` on top of it.
37+
enough to run Bumblebee; the `Nx.Defn` compiler is additive. In
38+
both lanes `Emily.Compiler` pins the result backend to
39+
`Emily.Backend` via `__to_backend__/1` and caps `:max_concurrency`
40+
at 1. By default (`native: true`) it lowers to the single-NIF lane
41+
(decision 10); `native: false` delegates the expression walk to
42+
`Nx.Defn.Evaluator` op-by-op instead, and `:fuse` wraps
43+
`mx::compile` on top of the native lane.
4244
The original PLAN M6 measurement found whole-graph `mx::compile`
4345
below the 1.20× gate; the single-NIF replay changed that economics
4446
(fuse the elementwise runs the replay leaves separate, and cache a
4547
fused `defn while` body per stream), so fusion now ships as the
4648
opt-in `:fuse` mode.
4749

4850
2. **Trace in Elixir, not in C++.** `Nx.Defn.Expr` is already a
49-
fully traced tree. The default lane walks it from Elixir and emits
50-
one Native NIF call per node (`lib/emily/native.ex`); the native
51-
lane lowers the same tree to `Emily.IR` and emits a single NIF call
52-
for the whole graph. Either way the trace is consumed in Elixir,
53-
never re-traced in C++.
51+
fully traced tree. The default native lane lowers it to `Emily.IR`
52+
and emits a single NIF call for the whole graph; the op-by-op lane
53+
(`native: false`) walks it from Elixir and emits one Native NIF
54+
call per node (`lib/emily/native.ex`). Either way the trace is
55+
consumed in Elixir, never re-traced in C++.
5456

5557
3. **One resource type: `Tensor`** wrapping `mlx::array`. MLX's
5658
refcount does the heavy lifting; `fine`'s `ResourcePtr` adds one
@@ -112,12 +114,12 @@ see [Testing philosophy](#testing-philosophy).
112114
the NIF boundary once, captured by the compiled program. Anything
113115
the IR can't lower routes the *whole* defn back through
114116
`Nx.Defn.Evaluator` under the default `native_fallback: :eval`
115-
(firing `[:emily, :compiler, :fallback]`), so a global
116-
`native: true` install is safe on any model. Use
117-
`native_fallback: :raise` to fail instead — the conformance gates
118-
use it to prove full native lowering. The default comes from
119-
`config :emily, :native` (itself `false`); a per-call `native:`
120-
wins. `:fuse` wraps the replay in `mx::compile`, cached per stream
117+
(firing `[:emily, :compiler, :fallback]`), so native is safe as
118+
the default on any model. Use `native_fallback: :raise` to fail
119+
instead — the conformance gates use it to prove full native
120+
lowering. The default comes from `config :emily, :native` (itself
121+
`true`); set `config :emily, native: false` (or pass
122+
`native: false`) to opt a memory-constrained host back to op-by-op. `:fuse` wraps the replay in `mx::compile`, cached per stream
121123
and fusing `defn while` bodies for decode loops — not
122124
bit-identical, hence opt-in.
123125

README.md

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -204,24 +204,26 @@ and direct MLX round-trips, but most users should go through Nx.
204204

205205
## Native compilation
206206

207-
`Emily.Compiler` has three modes. Opt into a mode per-call, or globally
208-
via `Nx.Defn.global_default_options/1`; the `native` lane can also be
209-
switched on application-wide with `config :emily, native: true` (a
210-
per-call `native:` option always overrides the app-env default):
207+
`Emily.Compiler` has three modes. `native` is **on by default**; opt
208+
into a different mode per-call, globally via
209+
`Nx.Defn.global_default_options/1`, or app-wide with
210+
`config :emily, native: false | true` (a per-call `native:` option
211+
always overrides the app-env default):
211212

212213
```elixir
213-
# Default: op-by-op dispatch through Emily.Backend. Bit-identical to
214-
# the Nx evaluator.
214+
# Default: native single-NIF replay. Lowers the traced Nx.Defn.Expr to
215+
# a flat IR and replays the whole forward graph in one NIF call per
216+
# invocation — ~5× decode throughput on Qwen3-0.6B, bit-identical to
217+
# the evaluator. Anything the IR can't lower routes through
218+
# Nx.Defn.Evaluator under the default `native_fallback: :eval` (with a
219+
# `[:emily, :compiler, :fallback]` telemetry event).
215220
Nx.Defn.jit(&forward/1, compiler: Emily.Compiler).(input)
216221

217-
# Native single-NIF replay. Lowers the traced Nx.Defn.Expr to a flat
218-
# IR and replays the whole forward graph in one NIF call per
219-
# invocation — ~5× decode throughput on Qwen3-0.6B, bit-identical to
220-
# the evaluator. Safe to install globally: anything the IR can't lower
221-
# routes through Nx.Defn.Evaluator under the default
222-
# `native_fallback: :eval` (with a `[:emily, :compiler, :fallback]`
223-
# telemetry event).
224-
Nx.Defn.jit(&forward/1, compiler: Emily.Compiler, native: true).(input)
222+
# Op-by-op dispatch through Emily.Backend — opt out of native with
223+
# `native: false`. Bit-identical to the Nx evaluator, with no
224+
# whole-graph compile step, so no one-shot compile-time memory spike;
225+
# use it as a numerics reference or on memory-constrained hosts.
226+
Nx.Defn.jit(&forward/1, compiler: Emily.Compiler, native: false).(input)
225227

226228
# Native + mx::compile kernel fusion. Fuses elementwise runs the plain
227229
# replay leaves as separate kernels (RMSNorm / softmax / SiLU gating /
@@ -239,11 +241,14 @@ ids matched the evaluator's exactly in our benchmarks), but
239241
**sampling strategies will diverge** from the evaluator even with a
240242
fixed seed.
241243

242-
**Choosing a mode.** `native: true` is the right default for model
243-
inference: it's bit-identical to the evaluator, safe to install
244-
globally (un-lowerable ops route through the evaluator), and is the
245-
single biggest win — eager → native is the largest jump in every
246-
[benchmark](#performance) tier. `fuse: true` is **not** a universal
244+
**Choosing a mode.** `native` is the default and the right one for
245+
model inference: it's bit-identical to the evaluator, safe globally
246+
(un-lowerable ops route through the evaluator), and the single biggest
247+
win — eager → native is the largest jump in every
248+
[benchmark](#performance) tier. Opt out with `native: false` only when
249+
you need the op-by-op evaluator — a numerics reference, or a
250+
memory-constrained host where the one-shot compile peak is too large.
251+
`fuse: true` is **not** a universal
247252
add-on. It pays off only when the fused callable is *reused*, which in
248253
practice means autoregressive decode: the `defn while` body is
249254
`mx::compile`d once and cache-hits every step (the best lane on

RELEASE.md

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
### Added
22

3-
- **Native Expr compiler — `Nx.Defn.jit` / `compile` with
4-
`compiler: Emily.Compiler, native: true`.** Lowers a traced
5-
`Nx.Defn.Expr` to a flat IR once and replays the whole forward graph
6-
in a **single NIF call per invocation**, collapsing the per-op
7-
BEAM↔worker round-trips a step-evaluated decode loop would otherwise
8-
pay. Weights cross the NIF boundary once (captured by the compiled
9-
program) and are never re-serialised per call. Opt in per call:
3+
- **Native Expr compiler — on by default under
4+
`compiler: Emily.Compiler`.** Lowers a traced `Nx.Defn.Expr` to a
5+
flat IR once and replays the whole forward graph in a **single NIF
6+
call per invocation**, collapsing the per-op BEAM↔worker round-trips
7+
a step-evaluated decode loop would otherwise pay. Weights cross the
8+
NIF boundary once (captured by the compiled program) and are never
9+
re-serialised per call. It is the default, so a bare
10+
`compiler: Emily.Compiler` compiles native:
1011

11-
Nx.Defn.jit(&forward/1, compiler: Emily.Compiler, native: true).(input)
12+
Nx.Defn.jit(&forward/1, compiler: Emily.Compiler).(input)
1213

1314
Coverage is the full Nx primitive set (with `Emily.Backend`'s
1415
dtype-coercion and op-composition semantics ported into the
@@ -20,15 +21,13 @@
2021
`defn while` (with the host loop driven entirely from the worker
2122
thread). Anything the IR can't lower yet routes through
2223
`Nx.Defn.Evaluator` under the default `native_fallback: :eval` (with
23-
a one-shot `[:emily, :compiler, :fallback]` telemetry event), so
24-
installing the compiler globally is safe on any model:
25-
26-
Nx.Defn.global_default_options(compiler: Emily.Compiler, native: true)
27-
28-
The `native` default is also read from `config :emily, :native`
29-
(defaulting to `false`), so `config :emily, native: true` opts every
30-
defn into the native lane application-wide without a per-call option;
31-
a per-call `native:` option always wins over the app-env default.
24+
a one-shot `[:emily, :compiler, :fallback]` telemetry event), so the
25+
native lane is safe as the default on any model. The default is read
26+
from `config :emily, :native` (defaulting to `true`), so
27+
`config :emily, native: false` opts every defn out of the native lane
28+
application-wide — e.g. on a memory-constrained host where the
29+
one-shot compile peak is too large; a per-call `native:` option
30+
always wins over the app-env default.
3231

3332
`native_fallback: :raise` fails instead — the conformance suites use
3433
this to prove a model lowers fully native.

config/test.exs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ config :emily,
1717
# fallback); tests that exercise the fallback pass `native_fallback: :eval`
1818
# per call.
1919
config :emily, native_fallback: :raise
20+
21+
# The runtime default for `:native` is `true`; a bare
22+
# `compiler: Emily.Compiler` compiles native. The suite pins it to
23+
# `false` so a bare `compiler: Emily.Compiler` stays the *eval* oracle
24+
# the equivalence tests compare the native lane against — tests that
25+
# exercise the native path pass `native: true` explicitly.
26+
config :emily, native: false

lib/emily/compiler.ex

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ defmodule Emily.Compiler do
6868
and Bumblebee passes `:cache` through for its own per-scope
6969
cache suffixing. Neither is used by the Evaluator walk, but
7070
rejecting them would break those servings.
71-
* `:native` — `true` compiles the traced `Nx.Defn.Expr` to a flat
72-
IR and replays the whole graph in a single NIF call per invocation.
73-
Defaults to `false`, which runs the op-by-op Evaluator walk. The
74-
default is read from `config :emily, :native` (itself defaulting to
75-
`false`), so `config :emily, native: true` opts every defn into the
76-
native lane application-wide without a per-call option. The per-call
77-
option wins over the app env, so an explicit `native: false`
78-
overrides a global `config :emily, native: true`. A non-boolean
71+
* `:native` — `true` (the default) compiles the traced
72+
`Nx.Defn.Expr` to a flat IR and replays the whole graph in a
73+
single NIF call per invocation; `false` runs the op-by-op
74+
Evaluator walk instead. The default is read from
75+
`config :emily, :native` (itself defaulting to `true`), so
76+
`config :emily, native: false` opts every defn out of the native
77+
lane application-wide — e.g. on a memory-constrained host where
78+
the one-shot compile peak is too large. The per-call option wins
79+
over the app env, so an explicit `native: false` overrides a
80+
global `config :emily, native: true` and vice versa. A non-boolean
7981
raises `ArgumentError`.
8082
* `:native_fallback` — `:eval` (default) or `:raise`. Controls what
8183
happens when `native: true` but the expression contains an op or
@@ -174,15 +176,15 @@ defmodule Emily.Compiler do
174176
end
175177

176178
# Per-call `:native` opt wins over `config :emily, :native`, defaulting
177-
# to `false` (the op-by-op Evaluator walk). `Keyword.fetch/2` (not
179+
# to `true` (the native single-NIF lane). `Keyword.fetch/2` (not
178180
# `Keyword.get/3`) so an explicit per-call `native: false` overrides a
179-
# global `config :emily, native: true`, rather than being treated as
180-
# "unset" and falling through to the app env.
181+
# global default, rather than being treated as "unset" and falling
182+
# through to the app env.
181183
defp native?(opts) do
182184
enabled =
183185
case Keyword.fetch(opts, :native) do
184186
{:ok, native} -> native
185-
:error -> Application.get_env(:emily, :native, false)
187+
:error -> Application.get_env(:emily, :native, true)
186188
end
187189

188190
case enabled do

test/emily/compiler_native_config_test.exs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
defmodule Emily.CompilerNativeConfigTest do
22
@moduledoc """
33
`config :emily, :native` sets the default for the compiler's `:native`
4-
option when a call passes none. A per-call `native:` always wins over the
5-
app env. Mutates the application env, so `async: false`.
4+
option when a call passes none; the shipped default (no config) is
5+
`true`. A per-call `native:` always wins over the app env. Mutates the
6+
application env, so `async: false`.
67
78
Probe: `Nx.reduce/3` with a BEAM reducer can never lower to the single-NIF
89
replay (the reducer would have to run on the host mid-graph). Under
910
`native_fallback: :raise` it therefore raises *iff* native is enabled —
10-
a clean signal for how `:native` resolved, without needing to inspect the
11+
a clean signal for how `:native` resolved, without inspecting the
1112
compiled program.
1213
"""
1314
use ExUnit.Case, async: false
@@ -29,7 +30,17 @@ defmodule Emily.CompilerNativeConfigTest do
2930
:ok
3031
end
3132

32-
test "config :emily, native: true enables native with no per-call option" do
33+
test "with no config the default is native (no per-call option)" do
34+
Application.delete_env(:emily, :native)
35+
36+
assert_raise ArgumentError, ~r/reduce/, fn ->
37+
Nx.Defn.jit(unsupported_fun(), compiler: Emily.Compiler, native_fallback: :raise).(
38+
t([1.0, 2.0])
39+
)
40+
end
41+
end
42+
43+
test "config :emily, native: true enables native (no per-call option)" do
3344
Application.put_env(:emily, :native, true)
3445

3546
assert_raise ArgumentError, ~r/reduce/, fn ->
@@ -39,8 +50,8 @@ defmodule Emily.CompilerNativeConfigTest do
3950
end
4051
end
4152

42-
test "defaults to false — an un-lowerable op routes through the evaluator" do
43-
Application.delete_env(:emily, :native)
53+
test "config :emily, native: false routes through the evaluator" do
54+
Application.put_env(:emily, :native, false)
4455

4556
out =
4657
Nx.Defn.jit(unsupported_fun(), compiler: Emily.Compiler, native_fallback: :raise).(

0 commit comments

Comments
 (0)