Skip to content

Commit 5ccc2ef

Browse files
authored
Merge pull request #169 from ausimian/feat/expr-compiler-cm14-docs-drift
docs: scope the fused-while drift caveats honestly
2 parents feee25f + 1dccb55 commit 5ccc2ef

4 files changed

Lines changed: 34 additions & 14 deletions

File tree

RELEASE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,17 @@
8080
On Qwen3-0.6B this lifts greedy decode to **~5.4× the evaluator (~1.1× over
8181
the plain native lane**, ~68 vs ~62 tok/s on an M-series Mac). The trade-off:
8282
`mx::compile` reassociates f32, so logits drift by a few ULP and the output
83-
is **not** bit-identical to the evaluator — greedy argmax is stable under
84-
that, so the generated token ids still match exactly (the completions are
85-
byte-identical). The `native-fused` lane in `bench/qwen3_tokens_per_sec.exs`
86-
measures it; `generation_native_test.exs` gates the greedy token match.
83+
is **not** bit-identical to the evaluator. Greedy argmax is robust to that
84+
drift, so in our Qwen3-0.6B run the generated token ids matched the
85+
evaluator's exactly (byte-identical completions) — but that is an *empirical*
86+
result, not a guarantee: a near-tie top-2 logit can flip a token, and any
87+
decision the drift can tip over — argmax, or a loop trip count whose
88+
condition reads a reassociated reduction — diverges by more than a few ULP
89+
once it flips. **Sampling strategies (e.g. multinomial) will diverge from the
90+
evaluator under fusion** even with a fixed seed; the gate and bench cover the
91+
greedy lane only. The `native-fused` lane in `bench/qwen3_tokens_per_sec.exs`
92+
measures throughput; `generation_native_test.exs` gates the greedy token
93+
match.
8794

8895
- **`defn while` compiles native.** Data-dependent loops — including
8996
`Bumblebee.Text.generation`'s decode loop — now lower to the single-NIF

lib/emily/compiler.ex

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ defmodule Emily.Compiler do
8888
the decode loop host-controlled but fuses each loop **body** under
8989
`mx::compile`, replaying the cached fused callable every token. Defaults
9090
to `false`; a non-boolean raises `ArgumentError`. Opt-in because the
91-
fusion reassociates f32 to within a few ULP — greedy token ids still
92-
match the evaluator, but logits are not bit-identical. Only the native
93-
path consults it, so it is ignored unless `native: true`.
91+
fusion reassociates f32 to within a few ULP — logits are not
92+
bit-identical to the evaluator. Greedy argmax is robust to that drift
93+
(greedy token ids matched the evaluator in our tests), but the match is
94+
empirical, not guaranteed: any discrete decision the drift can tip —
95+
argmax on a near-tie, or a `while` trip count whose condition reads a
96+
reassociated reduction — diverges once it flips. **Sampling strategies
97+
diverge from the evaluator under fusion** even with a fixed seed. Only
98+
the native path consults it, so it is ignored unless `native: true`.
9499
95100
Any other option is silently dropped. This matches how
96101
`Nx.Defn.Evaluator` and EXLA handle their own option lists, and is
@@ -228,7 +233,7 @@ defmodule Emily.Compiler do
228233
# guarded step: it raises `ArgumentError` on an op or construct it can't
229234
# lower yet, which we turn into a graceful `:fallback` (or re-raise in
230235
# `:raise` mode). `Program.compile/1` is deliberately kept outside the
231-
# rescue (in `replay_closure/2`) — it raises only on malformed IR, i.e. a
236+
# rescue (in `replay_closure/3`) — it raises only on malformed IR, i.e. a
232237
# compiler bug, which must surface loudly rather than be masked as an
233238
# "unsupported op" fallback.
234239
defp lower(leaves, mode, key) do

lib/emily/program.ex

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,14 @@ defmodule Emily.Program do
7575
can't trace) is instead replayed host-controlled with each loop
7676
*body* fused under `mx::compile` and cached per stream — the body
7777
is shape-stable, so the fused callable cache-hits across
78-
iterations rather than recompiling per step. Either way the
79-
fusion reassociates f32 to within a few ULP, so the result is
80-
not bit-identical to `:sync`.
78+
iterations rather than recompiling per step. The loop condition
79+
is left as a raw (un-fused) replay. Either way the fusion
80+
reassociates f32 to within a few ULP, so the result is not
81+
bit-identical to `:sync` — and for a `while` whose condition
82+
reads a reassociated reduction of the fused body, the drift can
83+
change the *trip count*, diverging by more than a few ULP. Safe
84+
for the generation loop (its condition is an integer offset, not
85+
a function of the drifted logits).
8186
"""
8287
@spec eval(Native.worker(), t(), [Native.tensor()], keyword()) :: [Native.tensor()]
8388
def eval(worker, program, inputs, opts \\ []) do

test/emily/conformance/generation_native_test.exs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,12 @@ defmodule Emily.Conformance.GenerationNativeTest do
8282
# The opt-in fused-while lane (CM14): the `defn while` decode loop runs
8383
# host-controlled, but each per-token forward (the loop body) replays
8484
# through a per-stream-cached `mx::compile`'d callable. `mx::compile`
85-
# reassociates f32 so the logits drift by a few ULP — but greedy argmax
86-
# is stable under that, so the generated token ids still match the
87-
# evaluator exactly. (Not a binary-identical gate, by construction.)
85+
# reassociates f32 so the logits drift by a few ULP — greedy argmax is
86+
# robust to that drift, so the token ids match here. This is a
87+
# token-id gate, not a binary-identical one (the logits aren't), and
88+
# the match is empirical: a near-tie top-2 logit could in principle
89+
# flip a token on another model/prompt. Sampling strategies would
90+
# diverge under fusion, so only greedy is gated.
8891
gc = configure(ctx.gen_config, %{type: :greedy_search})
8992
fused = generate_ids(ctx.model_info, gc, @native_compiled)
9093
eval = generate_ids(ctx.model_info, gc, @eval)

0 commit comments

Comments
 (0)