@@ -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
0 commit comments