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