Skip to content

Commit 94f0946

Browse files
committed
feat: add Emily.Generation decode-loop driver (CM9)
A minimal, model-agnostic driver for autoregressive generation: it JIT-compiles a caller-supplied shape-stable per-token forward (`fn token, offset, cache, params -> {logits, cache} end`) with the native single-NIF compiler and drives the loop from Elixir — offset bookkeeping, KV-cache threading, stop conditions, next-token selection (greedy by default), and per-token streaming via `:on_token`. The forward runs fully native; the loop stays in Elixir, preserving token streaming and host-side control. Emily supplies only the mechanism — the model (forward + cache + params) is the caller's, so this stays an Nx backend rather than a model library. `params` is a required argument (not a closure) because Nx rejects mixing closed-over Emily.Backend tensors with the traced Nx.Defn.Expr. Tested with a tiny hand-rolled shape-stable decoder (no model / gem_chat dependency): native greedy decode is bit-identical to the evaluator, and the loop's eos-stop / streaming / empty-output behaviour holds.
1 parent b720f76 commit 94f0946

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

RELEASE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@
5050
selection on the native path. Remaining gaps (`gather`/scatter,
5151
pooling/`window_*`, cumulative) continue to work via the graceful fallback.
5252

53+
- **`Emily.Generation` — a model-agnostic decode-loop driver.** JIT-compiles a
54+
caller-supplied **shape-stable** per-token forward (`fn token, offset, cache,
55+
params -> {logits, cache} end`) with the native single-NIF compiler and drives
56+
the autoregressive loop from Elixir: offset bookkeeping, KV-cache threading,
57+
stop conditions, next-token selection (greedy by default), and per-token
58+
streaming via `:on_token`. The forward runs fully native; the loop stays in
59+
Elixir, so token streaming and host-side control are preserved. Emily supplies
60+
only the mechanism — the model (forward + cache) is the caller's.
61+
5362
- `Emily.async_eval/1` (and `Emily.Native.async_eval/2`) schedule evaluation of
5463
one or more lazy graphs **without blocking on the GPU**, wrapping
5564
`mlx::core::async_eval`. The work is handed to the device's command queue and

lib/emily/generation.ex

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
defmodule Emily.Generation do
2+
@moduledoc """
3+
A minimal, model-agnostic **decode-loop driver** for autoregressive
4+
generation on Emily's native compiler.
5+
6+
Emily is an Nx backend, not a model library — so this module supplies
7+
only the *mechanism*. It JIT-compiles a caller-supplied **shape-stable**
8+
per-token forward with the single-NIF native compiler (see
9+
`Emily.Compiler`) and drives the token loop from Elixir: offset
10+
bookkeeping, KV-cache threading, the stop conditions, next-token
11+
selection, and streaming. The caller owns the *model*: it provides the
12+
forward and the (pre-filled) cache.
13+
14+
This is the "loop in Elixir" half of the generation story — it preserves
15+
per-token streaming and host-side control. (`Bumblebee.Text.generation`
16+
compiles its own `defn while` loop instead; that path is handled by the
17+
native `while` opcode, not this driver.)
18+
19+
## The forward contract
20+
21+
The forward is an arity-4 function `fn token, offset, cache, params ->
22+
{logits, cache} end`, traceable by `Nx.Defn` and **shape-stable**: its
23+
argument and result shapes must not depend on the runtime value of
24+
`offset`, so a single compiled program serves every position.
25+
Concretely:
26+
27+
* `token` — an `s32` `{1}` tensor (the id to decode at this step),
28+
* `offset` — an `s32` scalar tensor (the absolute position; thread it
29+
as a runtime input, e.g. a dynamic `Nx.put_slice` into a fixed-size
30+
KV buffer plus a length mask, rather than a growing slice),
31+
* `cache` — an `Nx.Container` of fixed-shape KV buffers,
32+
* `params` — an `Nx.Container` of the model weights,
33+
34+
returning `{logits, cache}` where `logits` is the last position's logit
35+
vector and `cache` has the same structure/shapes as the input.
36+
37+
The driver does **not** bound `offset` against the cache window — sizing
38+
`offset + max_new_tokens` to fit the fixed KV buffer is the caller's
39+
responsibility (overflowing it silently corrupts the cache via the
40+
out-of-bounds `put_slice`, it does not raise).
41+
42+
`params` is a *required argument* rather than a closure on purpose: Nx
43+
rejects mixing closed-over `Emily.Backend` tensors with the traced
44+
`Nx.Defn.Expr`, and passing them as an argument also hands their refs to
45+
the compiled program zero-copy (captured once).
46+
47+
## Example
48+
49+
# `forward`, `cache0`, and `params` come from your model.
50+
tokens =
51+
Emily.Generation.stream(forward,
52+
cache: cache0,
53+
params: params,
54+
first_token: bos_id,
55+
offset: prompt_len,
56+
max_new_tokens: 64,
57+
eos: [eos_id],
58+
on_token: fn id -> send(self(), {:token, id}) end
59+
)
60+
61+
Returns the list of generated token ids (including the stop token, if
62+
one is hit). `:select` defaults to greedy `argmax`; pass your own
63+
`fn logits -> token_tensor end` for sampling.
64+
"""
65+
66+
@default_defn_options [compiler: Emily.Compiler, native: true]
67+
68+
@doc """
69+
Greedy next-token selector: `argmax` over the vocabulary (last) axis.
70+
"""
71+
@spec greedy(Nx.Tensor.t()) :: Nx.Tensor.t()
72+
def greedy(logits), do: Nx.argmax(logits, axis: -1)
73+
74+
@doc """
75+
Drive an autoregressive decode loop over a shape-stable `forward`.
76+
77+
## Options
78+
79+
* `:cache` (required) — the initial (pre-filled) KV-cache container.
80+
* `:params` (required) — the model weights container, passed to the
81+
forward each step.
82+
* `:first_token` (required) — the first token id to decode.
83+
* `:max_new_tokens` (required) — the maximum number of tokens to emit.
84+
* `:offset` — the starting absolute position (default `0`).
85+
* `:eos` — a stop token id or list of ids (default `[]`).
86+
* `:select` — `fn logits -> token_tensor end` (default `greedy/1`).
87+
* `:on_token` — `fn token_id -> any end`, called with each generated
88+
id as it is produced (default no-op).
89+
* `:defn_options` — options for `Nx.Defn.compile/3` (default
90+
`#{inspect(@default_defn_options)}`). Override to disable native
91+
compilation or pick a different compiler.
92+
93+
Returns the list of generated token ids.
94+
"""
95+
@spec stream(
96+
(Nx.Tensor.t(), Nx.Tensor.t(), Nx.Container.t(), Nx.Container.t() ->
97+
{Nx.Tensor.t(), Nx.Container.t()}),
98+
keyword()
99+
) :: [integer()]
100+
def stream(forward, opts) when is_function(forward, 4) do
101+
cache = Keyword.fetch!(opts, :cache)
102+
params = Keyword.fetch!(opts, :params)
103+
first = Keyword.fetch!(opts, :first_token)
104+
max_new = Keyword.fetch!(opts, :max_new_tokens)
105+
offset = Keyword.get(opts, :offset, 0)
106+
eos = opts |> Keyword.get(:eos, []) |> List.wrap() |> MapSet.new()
107+
select = Keyword.get(opts, :select, &greedy/1)
108+
on_token = Keyword.get(opts, :on_token, fn _ -> :ok end)
109+
defn_options = Keyword.get(opts, :defn_options, @default_defn_options)
110+
111+
# Compile the forward once: `offset` and the token id are runtime
112+
# inputs, so the program is reused across every step (and every request
113+
# with the same cache/param shapes). The concrete `cache`/`params`
114+
# supply the container templates.
115+
step =
116+
Nx.Defn.compile(
117+
forward,
118+
[Nx.template({1}, :s32), Nx.template({}, :s32), cache, params],
119+
defn_options
120+
)
121+
122+
# The loop-invariant context (compiled step, params, stop set, selector,
123+
# streaming callback) travels as one map so only the changing state —
124+
# current token, cache, offset, budget, accumulator — is threaded.
125+
ctx = %{step: step, params: params, eos: eos, select: select, on_token: on_token}
126+
loop(ctx, first, cache, offset, max_new, [])
127+
end
128+
129+
defp loop(_ctx, _cur, _cache, _offset, budget, acc) when budget <= 0,
130+
do: Enum.reverse(acc)
131+
132+
defp loop(ctx, cur, cache, offset, budget, acc) do
133+
token = Nx.tensor([cur], type: :s32, backend: Emily.Backend)
134+
offset_t = Nx.tensor(offset, type: :s32, backend: Emily.Backend)
135+
136+
{logits, cache} = ctx.step.(token, offset_t, cache, ctx.params)
137+
next = ctx.select.(logits) |> Nx.to_number()
138+
ctx.on_token.(next)
139+
acc = [next | acc]
140+
141+
if MapSet.member?(ctx.eos, next) do
142+
Enum.reverse(acc)
143+
else
144+
loop(ctx, next, cache, offset + 1, budget - 1, acc)
145+
end
146+
end
147+
end

test/emily/generation_test.exs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
defmodule Emily.GenerationTest do
2+
@moduledoc """
3+
CM9 — the model-agnostic decode-loop driver. A tiny hand-rolled
4+
shape-stable decoder (embedding → dynamic KV write at `offset` → length
5+
mask → context sum → tied-ish head) exercises the driver end-to-end: the
6+
native single-NIF forward must produce token ids bit-identical to the
7+
Evaluator path, and the loop's stop/streaming behaviour must hold.
8+
9+
No real model / gem_chat dependency — Emily supplies only the mechanism.
10+
"""
11+
use ExUnit.Case, async: true
12+
13+
@v 8
14+
@h 4
15+
@l 6
16+
17+
# A minimal shape-stable per-token forward: `fn token, offset, cache,
18+
# params -> {logits, cache} end`. `offset` is a runtime s32 scalar, so one
19+
# compiled program serves every position (the KV write is a dynamic
20+
# put_slice and the window is masked by length, never resized). Weights
21+
# are passed via `params` (a required arg, not a closure).
22+
defp tiny_decoder do
23+
params = %{
24+
embed: Nx.divide(Nx.iota({@v, @h}, type: :f32, backend: Emily.Backend), 10.0),
25+
head: Nx.divide(Nx.iota({@h, @v}, type: :f32, backend: Emily.Backend), 7.0)
26+
}
27+
28+
cache0 = Nx.broadcast(Nx.tensor(0.0, backend: Emily.Backend), {1, @l, @h})
29+
30+
forward = fn token, offset, cache, params ->
31+
x = params.embed |> Nx.take(token) |> Nx.reshape({1, 1, @h})
32+
cache = Nx.put_slice(cache, [0, offset, 0], x)
33+
34+
maskf =
35+
Nx.iota({@l}, type: :s32)
36+
|> Nx.less_equal(offset)
37+
|> Nx.as_type(:f32)
38+
|> Nx.reshape({1, @l, 1})
39+
40+
ctx = cache |> Nx.multiply(maskf) |> Nx.sum(axes: [1])
41+
logits = ctx |> Nx.dot([1], params.head, [0]) |> Nx.reshape({@v})
42+
{logits, cache}
43+
end
44+
45+
{forward, cache0, params}
46+
end
47+
48+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
49+
@eval [compiler: Emily.Compiler]
50+
51+
test "greedy decode under the native compiler matches the evaluator bit-for-bit" do
52+
{fwd, cache0, params} = tiny_decoder()
53+
base = [cache: cache0, params: params, first_token: 1, offset: 0, max_new_tokens: 6]
54+
55+
native = Emily.Generation.stream(fwd, base ++ [defn_options: @native])
56+
eval = Emily.Generation.stream(fwd, base ++ [defn_options: @eval])
57+
58+
assert native == eval
59+
assert length(native) == 6
60+
assert Enum.all?(native, &(&1 in 0..(@v - 1)))
61+
end
62+
63+
test "stops at an eos token (the stop token is included)" do
64+
{fwd, cache0, params} = tiny_decoder()
65+
base = [cache: cache0, params: params, first_token: 1, offset: 0, max_new_tokens: 6]
66+
67+
# Whatever the first generated token is, make it the eos and re-run:
68+
# the loop must emit exactly that token and stop.
69+
[first | _] = Emily.Generation.stream(fwd, base)
70+
assert Emily.Generation.stream(fwd, base ++ [eos: [first]]) == [first]
71+
end
72+
73+
test "streams each generated token via :on_token, in order" do
74+
{fwd, cache0, params} = tiny_decoder()
75+
parent = self()
76+
77+
out =
78+
Emily.Generation.stream(fwd,
79+
cache: cache0,
80+
params: params,
81+
first_token: 1,
82+
offset: 0,
83+
max_new_tokens: 4,
84+
on_token: fn id -> send(parent, {:tok, id}) end
85+
)
86+
87+
streamed = for _ <- out, do: receive(do: ({:tok, id} -> id))
88+
assert streamed == out
89+
end
90+
91+
test "max_new_tokens: 0 generates nothing" do
92+
{fwd, cache0, params} = tiny_decoder()
93+
94+
assert Emily.Generation.stream(fwd,
95+
cache: cache0,
96+
params: params,
97+
first_token: 1,
98+
max_new_tokens: 0
99+
) == []
100+
end
101+
end

0 commit comments

Comments
 (0)