Skip to content

Commit f67b9f9

Browse files
authored
Merge pull request #12 from ausimian/feat/m5-compiler
M5: Emily.Compiler — Nx.Defn.Compiler on Emily.Backend
2 parents d340412 + 26a1a1c commit f67b9f9

6 files changed

Lines changed: 485 additions & 9 deletions

File tree

PLAN.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,21 +138,40 @@ throughput number.
138138

139139
### M5 — `Emily.Compiler`: `Nx.Defn.Compiler` implementation
140140

141-
- Walk `Nx.Defn.Expr` in Elixir, dispatching each node to `Emily.Backend`
142-
- Cache the walk result keyed by input signature in ETS
141+
- Walk `Nx.Defn.Expr` in Elixir, dispatching each node to `Emily.Backend`.
142+
In practice this is what `Nx.Defn.Evaluator` already does — it dispatches
143+
via `Nx.Shared.list_impl!/1` which finds whichever backend the operands
144+
carry. `Emily.Compiler` validates options, points `__to_backend__/1` at
145+
`Emily.Backend`, pins partitions to 1 (MLX kernel dispatch isn't
146+
thread-safe), and delegates the walk.
147+
- Hold the walked plan in the closure returned by `__compile__/4`; the
148+
closure *is* the cache. Callers that want reuse across invocations use
149+
`Nx.Defn.compile/3` and hold the returned function — Bumblebee /
150+
`Nx.Serving` already do this on warmup.
151+
- *Earlier draft proposed an ETS cache keyed by `{mfa, input_signature}`;
152+
rejected once we accounted for the per-call ETS deep-copy cost on a
153+
Qwen3-sized expression tree. The closure-capture path avoids the copy
154+
and matches the upstream Evaluator pattern.*
143155
- **Do not use `mlx::core::compile` yet.** Lazy eval at the Backend layer suffices.
144156

145157
**Testing — Layer 3 (Compiler):**
146158

147-
- **Equivalence tests**: every Backend test run wrapped in `defn` with
148-
`compiler: Emily.Compiler`, assert identical output.
149-
- **Cache tests**: same call → hit; different shape → miss.
150-
- **Recompilation**: changed dtype → recompile; same shape+dtype → no
151-
recompile.
159+
- **Equivalence tests**: a representative sample of ops (creation,
160+
binary, reduction, shape, dot, container output) plus the `defn`-only
161+
constructs `while` and `cond`; assert `compiler: Emily.Compiler` matches
162+
raw Backend execution (and `Nx.Defn.Evaluator` for the `defn`-only
163+
cases). The full Backend property suite isn't re-run per op — the
164+
Backend already passes its own oracle suite, and the Compiler test
165+
is structural ("did the walk reach the right backend with the right
166+
args").
167+
- **Reuse**: a `Nx.Defn.compile/3` closure runs many inputs of the same
168+
signature without re-walking the expression.
169+
- **Callback contracts**: `__to_backend__`, `__partitions_options__`,
170+
unknown-option rejection, `:max_concurrency > 1` refusal.
152171

153172
**Exit:** Axon MLPs forward with `compiler: Emily.Compiler`; results
154-
match Backend-only mode within float tolerance. (Training is out of
155-
scope for v1.)
173+
match `Nx.Defn.Evaluator` running on the same backend within float
174+
tolerance. (Training is out of scope for v1.)
156175

157176
### M6 — `mlx::core::compile` wrapping
158177

RELEASE.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,49 @@
22

33
## Added
44

5+
- M5 — `Emily.Compiler`, an `Nx.Defn.Compiler` implementation that runs
6+
`defn` computations on `Emily.Backend`. Wraps `Nx.Defn.Evaluator` after
7+
validating options and pinning the result backend; the Evaluator
8+
already walks `Nx.Defn.Expr` in Elixir and dispatches each op via
9+
`Nx.Shared.list_impl!/1`, which finds `Emily.Backend` whenever the
10+
operands carry it.
11+
- **`__to_backend__/1`** returns `{Emily.Backend, [device: …]}` so
12+
`Nx.Defn.to_backend/1` (consulted by `Nx.Serving` and friends)
13+
allocates inputs and outputs on Emily rather than the process default
14+
backend. Honours `:device` opt; defaults to `:gpu`.
15+
- **`__partitions_options__/1`** pins to a single partition. MLX's
16+
Metal runtime is not safe for concurrent kernel dispatch from
17+
multiple OS threads (the same constraint that forces
18+
`max_cases: 1` in `test_helper.exs`); a multi-partition serving
19+
would race the driver. `:max_concurrency` is accepted for
20+
`Nx.Serving` API compatibility but values >1 raise.
21+
- **No external compile cache.** `__compile__/4` returns a closure
22+
that captures the walked plan; the closure *is* the cache. Callers
23+
that want reuse across invocations use `Nx.Defn.compile/3` and hold
24+
the returned function — Bumblebee / `Nx.Serving` already do this on
25+
warmup. PLAN.md originally specified an ETS cache keyed by
26+
`{mfa, input_signature}`; deliberately deviated after accounting
27+
for the per-call ETS deep-copy cost on a Qwen3-sized expression
28+
tree. PLAN.md updated to record the rationale.
29+
- **No `mlx::core::compile` wrapping.** That is M6; lazy evaluation
30+
at the Backend layer suffices for correctness.
31+
- **`test/emily/compiler_test.exs`** — callback-contract tests
32+
(`__to_backend__` device routing, partition pinning, unknown-option
33+
rejection, `:max_concurrency > 1` refusal); op-equivalence tests
34+
across elementwise / reduction / shape / linalg / container-output
35+
paths; control-flow equivalence under `defn` for `while` (the
36+
construct Qwen3's KV-cache update relies on) and `cond`; a
37+
`Nx.Defn.compile/3` reuse test confirming the closure executes
38+
repeatedly without re-walking.
39+
- **`test/emily/compiler_axon_test.exs`** — the M5 exit criterion. A
40+
3-layer Axon MLP forward pass under `Emily.Compiler` matches
41+
`Nx.Defn.Evaluator` on the same backend within float tolerance,
42+
plus a `Nx.Defn.compile/3` reuse case driving multiple inputs
43+
through one walk.
44+
- **`:axon`** added as an explicit `only: :test` dep — already
45+
transitively available via Bumblebee, but the Axon MLP test reaches
46+
for it directly and shouldn't be hostage to a Bumblebee dep change.
47+
548
- M0 scaffold: mix project, MLX 0.25.1 prebuilt fetch pipeline,
649
Makefile wiring `fine` + MLX, `Emily.Native` NIF surface for tensor
750
round-trip, application supervisor skeleton, smoke test suite.

lib/emily/compiler.ex

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
defmodule Emily.Compiler do
2+
@moduledoc """
3+
`Nx.Defn.Compiler` implementation that runs `defn` computations on
4+
`Emily.Backend`.
5+
6+
The compiler walks `Nx.Defn.Expr` in Elixir and dispatches each node
7+
through the active backend — exactly what `Nx.Defn.Evaluator` already
8+
does — with two adjustments specific to Emily:
9+
10+
* `c:__to_backend__/1` returns `{Emily.Backend, [device: …]}` so
11+
`Nx.Defn.to_backend/1` (and the callers that consult it, including
12+
`Nx.Serving`) allocate inputs and outputs on Emily rather than the
13+
process-default backend.
14+
* `c:__partitions_options__/1` always returns a single partition.
15+
MLX's Metal runtime is not safe for concurrent kernel dispatch
16+
from multiple OS threads (see `test/soak/backend_concurrency_test.exs`
17+
for the SIGSEGV story); a multi-partition serving would race the
18+
driver. `:max_concurrency` is accepted for API compatibility with
19+
`Nx.Serving` but capped at 1.
20+
21+
## Why this is so thin
22+
23+
M5 deliberately avoids two pieces of complexity:
24+
25+
1. **No external cache.** `__compile__/4` walks the expression once
26+
and returns a closure that captures the walked plan; the closure
27+
*is* the cache. Callers that want reuse across invocations use
28+
`Nx.Defn.compile/3` and hold the returned function (Bumblebee /
29+
`Nx.Serving` already do this on warmup). The PLAN.md note about
30+
caching the walk in ETS was rejected once we accounted for the
31+
per-call ETS deep-copy cost on a Qwen3-sized expression tree.
32+
33+
2. **No `mlx::core::compile` wrapping.** Lazy evaluation at the
34+
Backend layer suffices for correctness; kernel-fusion via
35+
`mlx::core::compile` is M6.
36+
37+
Concretely, `__jit__/5` and `__compile__/4` delegate to
38+
`Nx.Defn.Evaluator` after option validation. The Evaluator dispatches
39+
every op via `Nx.Shared.list_impl!/1`, which finds `Emily.Backend`
40+
whenever the operands carry it — and `c:__to_backend__/1` ensures the
41+
operands do.
42+
43+
## Options
44+
45+
* `:device` — `:gpu` (default) or `:cpu`. Forwarded to `Emily.Backend`
46+
via the `c:__to_backend__/1` callback.
47+
* `:hooks`, `:debug_options`, `:garbage_collect` — passed through to
48+
`Nx.Defn.Evaluator` unchanged. See its moduledoc.
49+
* `:max_concurrency` — accepted for `Nx.Serving` compatibility, but
50+
multi-partition serving is rejected because MLX kernel dispatch
51+
isn't thread-safe. Pass `1` (the default) to silence.
52+
"""
53+
54+
@behaviour Nx.Defn.Compiler
55+
56+
alias Nx.Defn.Evaluator
57+
58+
@valid_opts [:device, :hooks, :debug_options, :garbage_collect, :max_concurrency]
59+
60+
@impl true
61+
def __jit__(key, vars, fun, args_list, opts) do
62+
opts = validate_opts!(opts)
63+
Evaluator.__jit__(key, vars, fun, args_list, opts)
64+
end
65+
66+
@impl true
67+
def __compile__(key, vars, fun, opts) do
68+
opts = validate_opts!(opts)
69+
Evaluator.__compile__(key, vars, fun, opts)
70+
end
71+
72+
@impl true
73+
def __partitions_options__(opts) do
74+
opts = validate_opts!(opts)
75+
76+
case Keyword.get(opts, :max_concurrency, 1) do
77+
n when n in [nil, 1] ->
78+
[opts]
79+
80+
n when is_integer(n) and n > 1 ->
81+
raise ArgumentError,
82+
"Emily.Compiler does not support :max_concurrency > 1 — MLX's Metal " <>
83+
"runtime isn't safe for concurrent kernel dispatch from multiple OS " <>
84+
"threads. Got: #{n}"
85+
end
86+
end
87+
88+
@impl true
89+
def __to_backend__(opts) do
90+
opts = validate_opts!(opts)
91+
{Emily.Backend, [device: Keyword.get(opts, :device, :gpu)]}
92+
end
93+
94+
defp validate_opts!(opts) do
95+
case Enum.reject(Keyword.keys(opts), &(&1 in @valid_opts)) do
96+
[] ->
97+
opts
98+
99+
unknown ->
100+
raise ArgumentError,
101+
"Emily.Compiler received unknown option(s): #{inspect(unknown)}. " <>
102+
"Valid options: #{inspect(@valid_opts)}"
103+
end
104+
end
105+
end

mix.exs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ defmodule Emily.MixProject do
5959
{:bumblebee,
6060
github: "elixir-nx/bumblebee", ref: "273805e95507dc7866b958d90e0012a3abad1761", only: :test},
6161
{:tokenizers, "~> 0.5", only: :test},
62+
# Axon is already pulled in transitively by Bumblebee, but the M5
63+
# exit-criterion test (Axon MLP forward under `Emily.Compiler`)
64+
# reaches for it directly — pin it explicitly so the test isn't
65+
# hostage to a Bumblebee dep change.
66+
{:axon, "~> 0.7", only: :test},
6267
{:stream_data, "~> 1.1", only: [:dev, :test]},
6368
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
6469
{:ex_doc, "~> 0.34", only: :docs, runtime: false}

test/emily/compiler_axon_test.exs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
defmodule Emily.CompilerAxonTest do
2+
@moduledoc """
3+
M5 exit-criterion test: an Axon MLP forward pass under
4+
`Emily.Compiler` produces results matching `Nx.Defn.Evaluator` on the
5+
same backend within float tolerance.
6+
7+
Both compilers execute through `Emily.Backend`; the comparison is
8+
structural — does our compiler walk the expression and reach the same
9+
ops with the same operands as the reference walker.
10+
"""
11+
12+
use ExUnit.Case, async: false
13+
14+
import Emily.BackendGenerators, only: [assert_close: 2]
15+
16+
setup do
17+
prev = Nx.default_backend()
18+
Nx.default_backend(Emily.Backend)
19+
on_exit(fn -> Nx.default_backend(prev) end)
20+
:ok
21+
end
22+
23+
test "3-layer MLP forward pass matches Nx.Defn.Evaluator" do
24+
model =
25+
Axon.input("input", shape: {nil, 16})
26+
|> Axon.dense(32, activation: :relu)
27+
|> Axon.dense(16, activation: :relu)
28+
|> Axon.dense(10)
29+
30+
{init_fn, predict_fn} = Axon.build(model)
31+
32+
template = Nx.template({1, 16}, :f32)
33+
params = init_fn.(template, Axon.ModelState.empty())
34+
35+
# Deterministic input — we don't care about the values, only that
36+
# both compilers see the same numbers.
37+
input =
38+
Nx.iota({4, 16}, type: :f32)
39+
|> Nx.divide(64.0)
40+
|> Nx.subtract(0.5)
41+
42+
eval =
43+
Nx.Defn.jit_apply(predict_fn, [params, input], compiler: Nx.Defn.Evaluator)
44+
45+
emily =
46+
Nx.Defn.jit_apply(predict_fn, [params, input], compiler: Emily.Compiler)
47+
48+
assert Nx.shape(emily) == {4, 10}
49+
assert_close(emily, eval)
50+
end
51+
52+
test "Nx.Defn.compile reuse — closure runs many inputs through one walk" do
53+
model =
54+
Axon.input("input", shape: {nil, 8})
55+
|> Axon.dense(8, activation: :tanh)
56+
|> Axon.dense(4)
57+
58+
{init_fn, predict_fn} = Axon.build(model)
59+
template = Nx.template({1, 8}, :f32)
60+
params = init_fn.(template, Axon.ModelState.empty())
61+
62+
compiled =
63+
Nx.Defn.compile(
64+
predict_fn,
65+
[params, Nx.template({2, 8}, :f32)],
66+
compiler: Emily.Compiler
67+
)
68+
69+
inputs = [
70+
Nx.iota({2, 8}, type: :f32),
71+
Nx.broadcast(0.25, {2, 8}),
72+
Nx.tensor(for _ <- 1..2, do: for(_ <- 1..8, do: 0.5))
73+
]
74+
75+
for input <- inputs do
76+
out = compiled.(params, input)
77+
assert Nx.shape(out) == {2, 4}
78+
79+
ref =
80+
Nx.Defn.jit_apply(predict_fn, [params, input], compiler: Nx.Defn.Evaluator)
81+
82+
assert_close(out, ref)
83+
end
84+
end
85+
end

0 commit comments

Comments
 (0)