Skip to content

Commit 0613ed5

Browse files
committed
refactor: rename :native_compiled compiler option to :fuse
The previous name read like a degree of `:native` ("native, but more compiled"), but the two are orthogonal: `:native` selects the single-NIF Expr compiler; the second toggle wraps that program in `mx::compile` to fuse the elementwise runs (and, for a `defn while`, each loop body). `:fuse` names the behaviour and pairs cleanly with `:native` at the call site. Unreleased — the option only exists on feat/expr-compiler, so renaming before PR #155 merges is a no-op for consumers. The ExUnit tag is renamed in lockstep so `--only fuse` keeps selecting the fusion lane. Closes #181
1 parent 8870cbc commit 0613ed5

8 files changed

Lines changed: 41 additions & 41 deletions

File tree

RELEASE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
per-op BEAM↔worker round-trips to roughly one per token. Reproduce with
118118
`bench/qwen3_tokens_per_sec.exs` (baseline vs native lanes).
119119

120-
- **Fused-while decode — `native_compiled: true`.** An opt-in lane that fuses
120+
- **Fused-while decode — `fuse: true`.** An opt-in lane that fuses
121121
the `defn while` decode loop's body under `mlx::core::compile`. The outer
122122
loop is data-dependent and host-controlled, so `mx::compile` can't trace it
123123
as a whole — but the per-token forward (the loop *body*) is shape-stable
@@ -128,7 +128,7 @@
128128
Enable it on top of the native generation path:
129129

130130
Nx.Defn.jit(&forward/1,
131-
compiler: Emily.Compiler, native: true, native_compiled: true)
131+
compiler: Emily.Compiler, native: true, fuse: true)
132132

133133
On Qwen3-0.6B this lifts greedy decode to **~5.4× the evaluator (~1.1× over
134134
the plain native lane**, ~68 vs ~62 tok/s on an M-series Mac). The trade-off:

bench/qwen3_tokens_per_sec.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# loop, replays as one NIF call per step. Run with
99
# `native_fallback: :raise`, so the number is a true
1010
# full-native measurement, not a silent fallback.
11-
# * native-fused — the native lane plus `native_compiled: true` (CM14): the
11+
# * native-fused — the native lane plus `fuse: true` (CM14): the
1212
# decode loop stays host-controlled, but each loop *body*
1313
# (the per-token forward) replays through a per-stream-cached
1414
# `mx::compile`'d callable, fusing the elementwise runs the
@@ -151,7 +151,7 @@ defmodule Emily.Bench.Qwen3 do
151151
compiler: Emily.Compiler,
152152
native: true,
153153
native_fallback: :raise,
154-
native_compiled: true
154+
fuse: true
155155
],
156156
cfg
157157
)

lib/emily/compiler.ex

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ defmodule Emily.Compiler do
8181
safe on any model. `:raise` re-raises the lowering error instead —
8282
use it in CI to prove a model lowers fully native. The per-call
8383
option wins over `config :emily, :native_fallback, :eval | :raise`.
84-
* `:native_compiled` — `true` evals the compiled program in the
84+
* `:fuse` — `true` evals the compiled program in the
8585
`mx::compile`'d mode instead of the plain replay. For a while-free
8686
forward this fuses the elementwise runs the replay leaves separate
8787
(the CM6 win); for a `Bumblebee.Text.generation` `defn while` it keeps
@@ -136,7 +136,7 @@ defmodule Emily.Compiler do
136136
:cache,
137137
:native,
138138
:native_fallback,
139-
:native_compiled
139+
:fuse
140140
]
141141

142142
@impl true
@@ -182,7 +182,7 @@ defmodule Emily.Compiler do
182182
{:ok, ([term()] -> [Nx.Tensor.t()])} | :fallback
183183
defp build_native(key, vars, fun, opts) do
184184
# Resolve (and validate) the modes up front so a misconfigured
185-
# `:native_fallback` or `:native_compiled` raises on every call —
185+
# `:native_fallback` or `:fuse` raises on every call —
186186
# including the happy path, and the lowering-failure path — rather than
187187
# lying dormant until the first lowering failure.
188188
mode = native_fallback_mode(opts)
@@ -208,15 +208,15 @@ defmodule Emily.Compiler do
208208
end
209209
end
210210

211-
# Resolve the program eval mode from `:native_compiled`, validating up front
211+
# Resolve the program eval mode from `:fuse`, validating up front
212212
# (like `native_fallback_mode/1`) so a non-boolean raises on every native
213213
# call rather than being silently treated as truthy. `true` -> `:compiled`
214214
# (the `mx::compile` fusion — and, for a `defn while`, fusing each loop
215215
# *body* under a host-controlled decode loop; see `Emily.Program.eval`),
216216
# `false` -> `:sync` (the plain, bit-identical replay). Only the native path
217217
# calls this, so the option is ignored unless `native: true`.
218218
defp native_eval_mode(opts) do
219-
case Keyword.get(opts, :native_compiled, false) do
219+
case Keyword.get(opts, :fuse, false) do
220220
true ->
221221
:compiled
222222

@@ -225,7 +225,7 @@ defmodule Emily.Compiler do
225225

226226
other ->
227227
raise ArgumentError,
228-
"invalid :native_compiled #{inspect(other)}; expected true | false"
228+
"invalid :fuse #{inspect(other)}; expected true | false"
229229
end
230230
end
231231

@@ -292,7 +292,7 @@ defmodule Emily.Compiler do
292292
# — it ignores keys it doesn't consume, but handing it `native: true`
293293
# when we've decided *not* to compile natively would be misleading.
294294
defp drop_native_opts(opts),
295-
do: Keyword.drop(opts, [:native, :native_fallback, :native_compiled])
295+
do: Keyword.drop(opts, [:native, :native_fallback, :fuse])
296296

297297
defp native_ref(%T{data: %B{ref: r}}), do: r
298298
defp native_ref(%T{} = t), do: Nx.backend_transfer(t, B).data.ref

test/emily/compiler_test.exs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,30 +55,30 @@ defmodule Emily.CompilerTest do
5555
end
5656
end
5757

58-
test "jit rejects a non-boolean :native_compiled" do
58+
test "jit rejects a non-boolean :fuse" do
5959
# Validated up front (like :native_fallback) so a misconfigured value
6060
# raises rather than being silently treated as truthy.
6161
fun = fn x -> Nx.add(x, 1.0) end
6262

63-
assert_raise ArgumentError, ~r/invalid :native_compiled/, fn ->
63+
assert_raise ArgumentError, ~r/invalid :fuse/, fn ->
6464
Nx.Defn.jit_apply(fun, [Nx.tensor([1.0, 2.0])],
6565
compiler: Emily.Compiler,
6666
native: true,
67-
native_compiled: :yes
67+
fuse: :yes
6868
)
6969
end
7070
end
7171

72-
test "native_compiled is a no-op without native: true" do
73-
# Only the native path consults :native_compiled, so with native unset
72+
test "fuse is a no-op without native: true" do
73+
# Only the native path consults :fuse, so with native unset
7474
# it is ignored (no fusion, no error) — the defn runs the plain
7575
# evaluator walk and a bad value is never reached.
7676
fun = fn x -> Nx.add(x, 1.0) end
7777

7878
result =
7979
Nx.Defn.jit_apply(fun, [Nx.tensor([1.0, 2.0])],
8080
compiler: Emily.Compiler,
81-
native_compiled: true
81+
fuse: true
8282
)
8383

8484
assert_close(result, Nx.tensor([2.0, 3.0]))

test/emily/compiler_while_test.exs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ defmodule Emily.CompilerWhileTest do
1919
# CM14: the opt-in fused-while lane — the host-controlled decode loop with
2020
# each loop *body* fused under `mx::compile`. The fusion reassociates f32,
2121
# so this is all-close, not bit-identical (asserted with a tolerance below).
22-
@native_compiled [
22+
@fuse [
2323
compiler: Emily.Compiler,
2424
native: true,
2525
native_fallback: :raise,
26-
native_compiled: true
26+
fuse: true
2727
]
2828

2929
defp t(data), do: Nx.tensor(data, backend: Emily.Backend)
@@ -94,11 +94,11 @@ defmodule Emily.CompilerWhileTest do
9494
acc
9595
end
9696

97-
describe "fused-while (native_compiled) == evaluator within f32 tol" do
97+
describe "fused-while (fuse) == evaluator within f32 tol" do
9898
test "loop body with a softmax run fuses and stays all-close" do
9999
x = t([[1.0, 2.0, 3.0], [0.5, -1.0, 2.0]])
100100

101-
fused = Nx.Defn.jit(&loop_softmax/1, @native_compiled).(x)
101+
fused = Nx.Defn.jit(&loop_softmax/1, @fuse).(x)
102102
eval = Nx.Defn.jit(&loop_softmax/1, @eval).(x)
103103

104104
assert %Emily.Backend{} = fused.data
@@ -118,7 +118,7 @@ defmodule Emily.CompilerWhileTest do
118118
# the condition is still evaluated each step, so a varying trip count
119119
# still tracks the input (same property as the plain native lane).
120120
for data <- [[1.0, 1.0], [5.0, 5.0], [20.0, 20.0]] do
121-
fused = Nx.Defn.jit(&count_until/1, @native_compiled).(t(data))
121+
fused = Nx.Defn.jit(&count_until/1, @fuse).(t(data))
122122
eval = Nx.Defn.jit(&count_until/1, @eval).(t(data))
123123

124124
drift =
@@ -131,7 +131,7 @@ defmodule Emily.CompilerWhileTest do
131131

132132
test "zero iterations returns the initial state unchanged (fused lane)" do
133133
x = t([3.0, 4.0])
134-
out = Nx.Defn.jit(&zero_iter/1, @native_compiled).(x)
134+
out = Nx.Defn.jit(&zero_iter/1, @fuse).(x)
135135
# No body ran, so nothing was fused — bit-identical to the input.
136136
assert Nx.to_binary(out) == Nx.to_binary(x)
137137
end
@@ -144,7 +144,7 @@ defmodule Emily.CompilerWhileTest do
144144
# result is bit-identical to both the evaluator and the plain native
145145
# lane here, which pins the dynamic-slice-under-fusion path.
146146
buf0 = Nx.broadcast(t(0.0), {4})
147-
fused = Nx.Defn.jit(&fill_buffer/1, @native_compiled).(buf0)
147+
fused = Nx.Defn.jit(&fill_buffer/1, @fuse).(buf0)
148148
eval = Nx.Defn.jit(&fill_buffer/1, @eval).(buf0)
149149

150150
assert %Emily.Backend{} = fused.data

test/emily/conformance/generation_native_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ defmodule Emily.Conformance.GenerationNativeTest do
3434
# decode loop's body is fused under `mx::compile`. The fusion reassociates
3535
# f32, so logits are not bit-identical; greedy argmax over them is, so the
3636
# acceptance gate is a token-id match rather than a binary-identical one.
37-
@native_compiled [
37+
@fuse [
3838
compiler: Emily.Compiler,
3939
native: true,
4040
native_fallback: :raise,
41-
native_compiled: true
41+
fuse: true
4242
]
4343

4444
# Run `model`'s generation through `build_generate` on a fixed in-vocab
@@ -89,7 +89,7 @@ defmodule Emily.Conformance.GenerationNativeTest do
8989
# flip a token on another model/prompt. Sampling strategies would
9090
# diverge under fusion, so only greedy is gated.
9191
gc = configure(ctx.gen_config, %{type: :greedy_search})
92-
fused = generate_ids(ctx.model_info, gc, @native_compiled)
92+
fused = generate_ids(ctx.model_info, gc, @fuse)
9393
eval = generate_ids(ctx.model_info, gc, @eval)
9494
assert fused == eval
9595
end

test/support/conformance_helper.ex

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ defmodule Emily.ConformanceHelper do
5050
* the native lane binds `predict_opts` to
5151
`[compiler: Emily.Compiler, native: true, native_fallback: :raise]`
5252
and is additionally tagged `:native`;
53-
* the fusion lane adds `native_compiled: true` (wrapping the replay in
54-
`mx::compile`) and is additionally tagged `:native_compiled`.
53+
* the fusion lane adds `fuse: true` (wrapping the replay in
54+
`mx::compile`) and is additionally tagged `:fuse`.
5555
5656
The module is already tagged `:conformance`, so the native and fusion
5757
lanes carry that tag too: `mix test --only conformance` runs all three,
58-
while `mix test --only native` / `mix test --only native_compiled` run
58+
while `mix test --only native` / `mix test --only fuse` run
5959
one lane each. Because every lane resolves the same HuggingFace repos,
6060
whichever runs first reads from `~/.cache/bumblebee` for the rest — the
6161
download is paid once.
@@ -91,7 +91,7 @@ defmodule Emily.ConformanceHelper do
9191
9292
* `:lane_tags` (default `true`) — when `false`, the native and fusion
9393
lanes are emitted *without* the cross-cutting `:native` /
94-
`:native_compiled` tags. The heavyweight `*_full` suites pass
94+
`:fuse` tags. The heavyweight `*_full` suites pass
9595
`lane_tags: false` so their compiler lanes stay gated behind the
9696
suite's own `:*_full` moduletag; otherwise `--only native` would
9797
start pulling full-size checkpoints. `--only vit_full` then runs all
@@ -116,10 +116,10 @@ defmodule Emily.ConformanceHelper do
116116
body
117117
),
118118
lane(
119-
[extra_tag, tag_lanes? && :native_compiled],
119+
[extra_tag, tag_lanes? && :fuse],
120120
name,
121-
" [native_compiled]",
122-
[compiler: Emily.Compiler, native: true, native_fallback: :raise, native_compiled: true],
121+
" [fuse]",
122+
[compiler: Emily.Compiler, native: true, native_fallback: :raise, fuse: true],
123123
body
124124
)
125125
]
@@ -132,7 +132,7 @@ defmodule Emily.ConformanceHelper do
132132
# Build one `mode_test` lane: a `test` that binds `predict_opts` for the
133133
# body, preceded by one `@tag` per entry in `tags` (nil/false entries are
134134
# dropped). The `*_full` suites pass `lane_tags: false` to drop the
135-
# `:native` / `:native_compiled` tags and rely on their own `:*_full`
135+
# `:native` / `:fuse` tags and rely on their own `:*_full`
136136
# moduletag (or an explicit `:tag`) instead.
137137
defp lane(tags, name, suffix, predict_opts, body) do
138138
tags = Enum.reject(tags, &(&1 in [nil, false]))

test/test_helper.exs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,28 +42,28 @@
4242
#
4343
# mix test --only fast_kernels_full
4444
#
45-
# `:native` and `:native_compiled` are the expression-compiler lanes of
45+
# `:native` and `:fuse` are the expression-compiler lanes of
4646
# the tiny-random conformance suites: every `mode_test` (see
4747
# `Emily.ConformanceHelper`) re-runs the forward pass under
4848
# `compiler: Emily.Compiler, native: true, native_fallback: :raise`
49-
# (`:native`) and again with `native_compiled: true` wrapping the replay
50-
# in `mx::compile` (`:native_compiled`), so the same PyTorch reference
49+
# (`:native`) and again with `fuse: true` wrapping the replay
50+
# in `mx::compile` (`:fuse`), so the same PyTorch reference
5151
# slice validates the evaluator, the native-compiled, and the fused
5252
# paths. Those tests carry `:conformance` too, so `--only conformance`
5353
# runs all three lanes; select one lane alone with:
5454
#
5555
# mix test --only native
56-
# mix test --only native_compiled
56+
# mix test --only fuse
5757
#
5858
# Listed in the default exclude defensively — every such test is already
5959
# `:conformance`-tagged, but this keeps a future `:native`-only or
60-
# `:native_compiled`-only test out of the default suite.
60+
# `:fuse`-only test out of the default suite.
6161
ExUnit.start(
6262
max_cases: System.schedulers_online(),
6363
exclude: [
6464
:conformance,
6565
:native,
66-
:native_compiled,
66+
:fuse,
6767
:vit_full,
6868
:whisper_full,
6969
:distilbert_full,

0 commit comments

Comments
 (0)