Skip to content

Commit 6151f84

Browse files
authored
Merge pull request #156 from ausimian/feat/expr-compiler-cm7
feat: graceful whole-defn fallback for the native compiler (CM7)
2 parents 5e3e4d2 + 495bd7a commit 6151f84

8 files changed

Lines changed: 285 additions & 21 deletions

File tree

RELEASE.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,28 @@
2121
dynamic KV-cache writes (`put_slice` at a runtime offset), container/tuple
2222
outputs, and `cond` (lowered to a select-chain). DistilBERT and ViT forwards
2323
run end-to-end under the compiler with `config :emily, :fallback, :raise`.
24-
Unsupported constructs — `while` loops and arbitrary BEAM `reduce` functions —
25-
raise a clear compile-time error rather than silently falling back, so
26-
generation loops stay driven from Elixir.
24+
Constructs the IR can't lower yet — `while` loops, arbitrary BEAM `reduce`
25+
functions — are handled by the graceful fallback below.
2726

2827
An opt-in compiled eval mode additionally wraps the replay in
2928
`mlx::core::compile`, fusing the elementwise runs (rms-norm, softmax, SiLU
3029
gating, residual adds) the replay leaves as separate kernels — measured at
3130
~1.5–1.6× over the plain replay on a decode-shaped transformer block.
3231

32+
- **Graceful native fallback — `native_fallback: :eval` (the default).** When a
33+
`native: true` defn contains an op or construct the Expr compiler can't lower
34+
yet, the *whole* defn now routes through `Nx.Defn.Evaluator` (each op then
35+
dispatches through `Emily.Backend`, with its own per-op fallback) and emits a
36+
one-shot `[:emily, :compiler, :fallback]` telemetry event — instead of
37+
raising. This makes it safe to install the compiler globally on any model:
38+
39+
Nx.Defn.global_default_options(compiler: Emily.Compiler, native: true)
40+
41+
Covered subgraphs (e.g. encoder forwards) run fully native; the rest is
42+
transparently evaluated. Pass `native_fallback: :raise` (or set
43+
`config :emily, native_fallback: :raise`) to fail instead — the conformance
44+
suites use this to prove a model lowers fully native.
45+
3346
- `Emily.async_eval/1` (and `Emily.Native.async_eval/2`) schedule evaluation of
3447
one or more lazy graphs **without blocking on the GPU**, wrapping
3548
`mlx::core::async_eval`. The work is handed to the device's command queue and

config/test.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,11 @@ config :emily,
99
debug_detect_nan_inf: false,
1010
test_fixture_debug_bounds_check: true,
1111
test_fixture_debug_detect_nan_inf: true
12+
13+
# Keep the native compiler strict in the test suite: an op the Expr
14+
# compiler can't lower raises rather than silently falling back to the
15+
# Evaluator. This preserves the no-fallback conformance gates (CM5) and
16+
# the unsupported-op assertions. The runtime default is `:eval` (graceful
17+
# fallback); tests that exercise the fallback pass `native_fallback: :eval`
18+
# per call.
19+
config :emily, native_fallback: :raise

lib/emily/compiler.ex

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ 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.
74+
* `:native_fallback` — `:eval` (default) or `:raise`. Controls what
75+
happens when `native: true` but the expression contains an op or
76+
construct the IR can't lower yet. `:eval` routes the *whole* defn
77+
through `Nx.Defn.Evaluator` (each op then dispatches through
78+
`Emily.Backend`, with its own per-op `via_binary` fallback) and
79+
fires a one-shot `[:emily, :compiler, :fallback]` event, so
80+
installing `compiler: Emily.Compiler, native: true` globally is
81+
safe on any model. `:raise` re-raises the lowering error instead —
82+
use it in CI to prove a model lowers fully native. The per-call
83+
option wins over `config :emily, :native_fallback, :eval | :raise`.
7184
7285
Any other option is silently dropped. This matches how
7386
`Nx.Defn.Evaluator` and EXLA handle their own option lists, and is
@@ -106,15 +119,19 @@ defmodule Emily.Compiler do
106119
:max_concurrency,
107120
:batch_keys,
108121
:cache,
109-
:native
122+
:native,
123+
:native_fallback
110124
]
111125

112126
@impl true
113127
def __jit__(key, vars, fun, args_list, opts) do
114128
opts = take_known_opts(opts)
115129

116130
if Keyword.get(opts, :native, false) do
117-
compile_native(vars, fun).(args_list)
131+
case build_native(key, vars, fun, opts) do
132+
{:ok, run} -> run.(args_list)
133+
:fallback -> Evaluator.__jit__(key, vars, fun, args_list, drop_native_opts(opts))
134+
end
118135
else
119136
Evaluator.__jit__(key, vars, fun, args_list, opts)
120137
end
@@ -125,24 +142,73 @@ defmodule Emily.Compiler do
125142
opts = take_known_opts(opts)
126143

127144
if Keyword.get(opts, :native, false) do
128-
compile_native(vars, fun)
145+
case build_native(key, vars, fun, opts) do
146+
{:ok, run} -> run
147+
:fallback -> Evaluator.__compile__(key, vars, fun, drop_native_opts(opts))
148+
end
129149
else
130150
Evaluator.__compile__(key, vars, fun, opts)
131151
end
132152
end
133153

134-
# The single-NIF compiled path (CM1+): trace the function into an
135-
# Nx.Defn.Expr, lower it to a flat IR once, compile it into a `Program`
136-
# resource (captured in this closure), and replay the whole graph in
137-
# one NIF call per invocation. Op coverage is still partial — an
138-
# unsupported op raises in `Emily.IR.lower/1` (no silent fallback).
139-
defp compile_native(vars, fun) do
154+
# Build the single-NIF native closure for `fun`, or signal `:fallback`.
155+
#
156+
# The Expr trace (`fun.(vars)`) runs *outside* the rescue so a genuine
157+
# caller error surfaces unchanged; only the lowering + program build is
158+
# guarded. `Emily.IR.lower/1` raises `ArgumentError` on an op or
159+
# construct it can't lower yet. Unless `:native_fallback` is `:raise`,
160+
# we emit a one-shot `[:emily, :compiler, :fallback]` event and return
161+
# `:fallback`, so the caller routes the whole defn through
162+
# `Nx.Defn.Evaluator` (which dispatches each op through `Emily.Backend`,
163+
# with its own per-op `via_binary` fallback). This keeps a global
164+
# `native: true` install safe on any model.
165+
@spec build_native(term(), [Nx.Tensor.t()], fun(), keyword()) ::
166+
{:ok, ([term()] -> [Nx.Tensor.t()])} | :fallback
167+
defp build_native(key, vars, fun, opts) do
168+
# Resolve (and validate) the mode up front so a misconfigured
169+
# `:native_fallback` raises on every call — including the happy path —
170+
# rather than lying dormant until the first lowering failure.
171+
mode = native_fallback_mode(opts)
172+
173+
# The Expr trace runs outside `lower/3`'s guard so a genuine caller
174+
# error surfaces unchanged.
140175
expr = fun.(vars)
141176

142177
{template, leaves_rev} =
143178
Composite.traverse(expr, [], fn leaf, acc -> {Nx.to_template(leaf), [leaf | acc]} end)
144179

145-
program = leaves_rev |> Enum.reverse() |> IR.lower() |> Program.compile()
180+
case lower(Enum.reverse(leaves_rev), mode, key) do
181+
{:ok, ir} -> {:ok, replay_closure(template, ir)}
182+
:fallback -> :fallback
183+
end
184+
end
185+
186+
# Lower the output leaves to a flat IR. `Emily.IR.lower/1` is the *only*
187+
# guarded step: it raises `ArgumentError` on an op or construct it can't
188+
# lower yet, which we turn into a graceful `:fallback` (or re-raise in
189+
# `:raise` mode). `Program.compile/1` is deliberately kept outside the
190+
# rescue (in `replay_closure/2`) — it raises only on malformed IR, i.e. a
191+
# compiler bug, which must surface loudly rather than be masked as an
192+
# "unsupported op" fallback.
193+
defp lower(leaves, mode, key) do
194+
{:ok, IR.lower(leaves)}
195+
rescue
196+
e in ArgumentError ->
197+
case mode do
198+
:raise ->
199+
reraise(e, __STACKTRACE__)
200+
201+
:eval ->
202+
Emily.Telemetry.compiler_fallback(key, e)
203+
:fallback
204+
end
205+
end
206+
207+
# The single-NIF compiled path (CM1+): compile the lowered IR into a
208+
# `Program` resource (captured in this closure) and replay the whole
209+
# graph in one NIF call per invocation.
210+
defp replay_closure(template, ir) do
211+
program = Program.compile(ir)
146212

147213
fn [params] ->
148214
worker = Emily.MlxStream.default_worker()
@@ -155,6 +221,32 @@ defmodule Emily.Compiler do
155221
end
156222
end
157223

224+
# Per-call `:native_fallback` opt wins over `config :emily,
225+
# :native_fallback`, defaulting to `:eval`. `Keyword.fetch/2` (not `||`)
226+
# so an explicit `native_fallback: false` is rejected, not silently
227+
# treated as "unset".
228+
defp native_fallback_mode(opts) do
229+
mode =
230+
case Keyword.fetch(opts, :native_fallback) do
231+
{:ok, m} -> m
232+
:error -> Application.get_env(:emily, :native_fallback, :eval)
233+
end
234+
235+
case mode do
236+
m when m in [:eval, :raise] ->
237+
m
238+
239+
other ->
240+
raise ArgumentError,
241+
"invalid :native_fallback #{inspect(other)}; expected :eval | :raise"
242+
end
243+
end
244+
245+
# Strip the Emily-only native knobs before delegating to the Evaluator
246+
# — it ignores keys it doesn't consume, but handing it `native: true`
247+
# when we've decided *not* to compile natively would be misleading.
248+
defp drop_native_opts(opts), do: Keyword.drop(opts, [:native, :native_fallback])
249+
158250
defp native_ref(%T{data: %B{ref: r}}), do: r
159251
defp native_ref(%T{} = t), do: Nx.backend_transfer(t, B).data.ref
160252

lib/emily/telemetry.ex

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ defmodule Emily.Telemetry do
4646
`:fallback` is unset (`true` → `:warn`, `false` → `:silent`).
4747
Prefer `:fallback` in new code; if both are set, `:fallback` wins.
4848
49+
### Native-compiler fallback
50+
51+
`[:emily, :compiler, :fallback]` — a discrete event (not a span) that
52+
fires when a `native: true` defn can't be lowered by the Expr compiler
53+
and routes through `Nx.Defn.Evaluator` instead (see
54+
`Emily.Compiler`'s `:native_fallback` option). Measurements:
55+
`:count` (always `1`). Metadata: `:key` (the JIT key) and `:reason`
56+
(the lowering error message, which names the unsupported op or
57+
construct). A one-shot `Logger.warning` per distinct `:reason` is also
58+
emitted — set `config :emily, :native_fallback, :raise` to fail
59+
instead of falling back.
60+
4961
### Memory stats (poll-driven)
5062
5163
`[:emily, :memory, :stats]` — discrete event, not a span. Call
@@ -137,13 +149,21 @@ defmodule Emily.Telemetry do
137149
end
138150

139151
defp warn_fallback(op, input_shapes) do
140-
key = {op, input_shapes}
152+
warn_once(
153+
{op, input_shapes},
154+
"Emily: #{op} on shapes #{inspect(input_shapes)} fell back to " <>
155+
"Nx.BinaryBackend; this path is ~100× slower than native MLX."
156+
)
157+
end
141158

159+
# One-shot `Logger.warning` per distinct `key`, deduped via the shared
160+
# ETS table so a hot path that repeatedly falls back logs once, not per
161+
# call. Keys are namespaced by caller (`{op, shapes}` for the backend
162+
# fallback, `{:compiler, reason}` for the native-compiler fallback) so
163+
# the two never collide.
164+
defp warn_once(key, message) do
142165
if :ets.insert_new(@dedup_table, {key}) do
143-
Logger.warning(
144-
"Emily: #{op} on shapes #{inspect(input_shapes)} fell back to " <>
145-
"Nx.BinaryBackend; this path is ~100× slower than native MLX."
146-
)
166+
Logger.warning(message)
147167
end
148168

149169
:ok
@@ -155,6 +175,32 @@ defmodule Emily.Telemetry do
155175
"Set `config :emily, fallback: :warn` to log instead, or `:silent` to ignore."
156176
end
157177

178+
@doc false
179+
# Called from `Emily.Compiler` when a `native: true` defn cannot be
180+
# lowered by the Expr compiler and routes through `Nx.Defn.Evaluator`
181+
# instead. Fires the discrete `[:emily, :compiler, :fallback]` event
182+
# (always) and a one-shot `Logger.warning` per distinct reason, deduped
183+
# via the shared ETS table. `reason` is the lowering error message — it
184+
# names the unsupported op/construct.
185+
@spec compiler_fallback(term(), Exception.t()) :: :ok
186+
def compiler_fallback(key, exception) do
187+
reason = Exception.message(exception)
188+
189+
:telemetry.execute(
190+
[:emily, :compiler, :fallback],
191+
%{count: 1},
192+
%{key: key, reason: reason}
193+
)
194+
195+
warn_once(
196+
{:compiler, reason},
197+
"Emily: native compilation fell back to Nx.Defn.Evaluator — #{reason} " <>
198+
"The defn ran op-by-op via the evaluator. Set " <>
199+
"`config :emily, native_fallback: :raise` (or pass " <>
200+
"`native_fallback: :raise`) to fail instead."
201+
)
202+
end
203+
158204
@doc false
159205
# Called exactly once from `Emily.Application.start/2` before the
160206
# supervisor starts. Safe to call again; an existing table is

test/emily/compiler_control_flow_test.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ defmodule Emily.CompilerControlFlowTest do
77
use ExUnit.Case, async: true
88
import Nx.Defn
99

10-
@native [compiler: Emily.Compiler, native: true]
10+
# `native_fallback: :raise` is explicit (not relying on config/test.exs)
11+
# so the "unsupported control flow raises" assertions below stay a local,
12+
# config-independent gate.
13+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
1114
@eval [compiler: Emily.Compiler]
1215

1316
defn if_fn(x) do
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
defmodule Emily.CompilerFallbackTest do
2+
@moduledoc """
3+
CM7 — graceful whole-defn fallback. When a `native: true` defn hits an
4+
op the Expr compiler can't lower, the default (`native_fallback: :eval`)
5+
routes the *whole* defn through `Nx.Defn.Evaluator` (each op then
6+
dispatches through `Emily.Backend`) and fires a
7+
`[:emily, :compiler, :fallback]` telemetry event — rather than raising.
8+
`native_fallback: :raise` restores the strict no-fallback behaviour the
9+
conformance gates rely on.
10+
11+
These pass `:native_fallback` per call (the test suite default is
12+
`:raise`, set in `config/test.exs`), so no global state is mutated and
13+
the cases stay `async`.
14+
"""
15+
use ExUnit.Case, async: true
16+
import Nx.Defn
17+
18+
# `Nx.sort` is not lowered by the Expr compiler yet (CM7) but IS
19+
# supported by `Emily.Backend`, so the evaluator path produces a correct
20+
# result to compare against.
21+
defn(sort_fn(x), do: Nx.sort(x))
22+
23+
# Fully supported by the IR — must still compile native, no fallback.
24+
defn(supported_fn(x), do: Nx.add(Nx.multiply(x, 2.0), 1.0))
25+
26+
defp t(data), do: Nx.tensor(data, backend: Emily.Backend)
27+
28+
# Named module-function handler (not an anonymous fn) so `:telemetry`
29+
# doesn't log its local-function performance note during the run.
30+
@doc false
31+
def forward_event(_event, meas, meta, {pid, ref}), do: send(pid, {ref, :event, meas, meta})
32+
33+
defp attach(event, ref) do
34+
id = "cm7-#{inspect(ref)}"
35+
:telemetry.attach(id, event, &__MODULE__.forward_event/4, {self(), ref})
36+
on_exit(fn -> :telemetry.detach(id) end)
37+
end
38+
39+
describe "native_fallback: :eval (graceful, the runtime default)" do
40+
test "an unsupported op falls back to the evaluator and matches it" do
41+
x = t([3.0, 1.0, 2.0, 0.0])
42+
43+
native =
44+
Nx.Defn.jit(&sort_fn/1, compiler: Emily.Compiler, native: true, native_fallback: :eval).(
45+
x
46+
)
47+
48+
eval = Nx.Defn.jit(&sort_fn/1, compiler: Emily.Compiler).(x)
49+
50+
assert %Emily.Backend{} = native.data
51+
assert Nx.to_binary(native) == Nx.to_binary(eval)
52+
end
53+
54+
test "fires a [:emily, :compiler, :fallback] event naming the op" do
55+
ref = make_ref()
56+
attach([:emily, :compiler, :fallback], ref)
57+
58+
Nx.Defn.jit(&sort_fn/1, compiler: Emily.Compiler, native: true, native_fallback: :eval).(
59+
t([2.0, 1.0])
60+
)
61+
62+
assert_receive {^ref, :event, %{count: 1}, %{reason: reason}}
63+
assert reason =~ "sort"
64+
end
65+
66+
test "a fully supported defn compiles native rather than falling back" do
67+
# Asserted under `:raise` (not by checking the absence of a global
68+
# telemetry event, which would race with concurrent async modules):
69+
# with no fallback available, success proves the defn lowered fully
70+
# native instead of silently degrading to the evaluator.
71+
x = t([1.0, 2.0, 3.0])
72+
73+
out =
74+
Nx.Defn.jit(&supported_fn/1,
75+
compiler: Emily.Compiler,
76+
native: true,
77+
native_fallback: :raise
78+
).(x)
79+
80+
assert %Emily.Backend{} = out.data
81+
82+
assert Nx.to_binary(out) ==
83+
Nx.to_binary(Nx.Defn.jit(&supported_fn/1, compiler: Emily.Compiler).(x))
84+
end
85+
end
86+
87+
describe "native_fallback: :raise (strict, the conformance-gate mode)" do
88+
test "an unsupported op raises rather than falling back" do
89+
assert_raise ArgumentError, ~r/sort/, fn ->
90+
Nx.Defn.jit(&sort_fn/1, compiler: Emily.Compiler, native: true, native_fallback: :raise).(
91+
t([1.0, 2.0])
92+
)
93+
end
94+
end
95+
end
96+
end

0 commit comments

Comments
 (0)