Skip to content

Commit d86618a

Browse files
committed
feat: compile Bumblebee Text.generation fully native (CM13)
The goal gate: Bumblebee.Text.generation — greedy and multinomial sampling — now compiles fully native under the single-NIF replay (no fallback), bit-identical to the Evaluator. The whole generation graph (transformer forward, the defn while decode loop, dynamic KV-cache writes, cumsum position ids, argmax / sampling token selection, threefry) lowers in one piece. Wiring this to completion needed: - cumsum/cumprod/cummax/cummin (last-axis native fast path; interior axes fall through to the block's composed expansion, mirroring the backend) - gather: single-axis via take, multi-axis via the split-indices + MLX multi-index gather path (ports Emily.Backend.gather/4) - stack - n_inputs fix: the compiled program pins n_inputs to the true flattened parameter count (length of the flattened vars), not IR.lower's referenced-parameter count — an unused input (e.g. the seed in greedy) no longer desyncs the eval-time input count Gates: generation_native_test.exs (greedy + sampling, native vs evaluator, native_fallback: :raise, default :conformance) on the cached tiny-random Qwen3 causal LM, driven through build_generate on in-vocab input_ids (no tokenizer, no OOB-gather pitfall). Plus compiler_indexing_test.exs pinning cumsum/gather/stack in isolation.
1 parent c7b5a12 commit d86618a

6 files changed

Lines changed: 295 additions & 5 deletions

File tree

RELEASE.md

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

53+
- **`Bumblebee.Text.generation` compiles fully native — greedy and sampling.**
54+
The headline result: an end-to-end Bumblebee generation (the transformer
55+
forward, the `defn while` decode loop, dynamic KV-cache writes, `cumsum`
56+
position ids, `argmax`/multinomial token selection, threefry sampling) now
57+
lowers to the single-NIF replay with **no fallback**, producing token ids
58+
bit-identical to the Evaluator. Wiring this required `cumsum`/`cumprod`/
59+
`cummax`/`cummin` (last-axis fast path), single- and multi-axis `gather`,
60+
and `stack`. Greedy and multinomial sampling are gated in
61+
`generation_native_test.exs`.
62+
5363
- **`defn while` compiles native.** Data-dependent loops — including
5464
`Bumblebee.Text.generation`'s decode loop — now lower to the single-NIF
5565
replay instead of falling back. The condition and body become nested

c_src/emily/opcodes.hpp

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,20 @@ enum class Opcode : int64_t {
142142
// path indexes by a loop counter). operands [a, start(s32 [naxes])];
143143
// iattrs [[axes...], [slice_sizes...]]
144144
DynSlice = 74,
145+
// Inclusive cumulative reductions along an axis. operands [a];
146+
// iattrs [[axis], [reverse]] (Nx cumulation is always inclusive).
147+
CumSum = 75,
148+
CumProd = 76,
149+
CumMax = 77,
150+
CumMin = 78,
151+
// Multi-axis gather. operands [input, idx0, idx1, ...] (one s32 index
152+
// array per gathered axis); iattrs [[axes...], [slice_sizes...]]
153+
Gather = 79,
154+
// Stack tensors along a new axis. operands [t0, t1, ...]; iattrs [[axis]]
155+
Stack = 80,
145156
};
146157

147-
inline constexpr int64_t kOpcodeCount = 75;
158+
inline constexpr int64_t kOpcodeCount = 81;
148159

149160
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
150161
inline std::string qmode_from_code(int64_t code) {
@@ -565,6 +576,37 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
565576
return mx::slice(in[0], in[1],
566577
emily::to_int_vec(attr_at(iattrs, 0, "dyn_slice")),
567578
emily::to_mlx_shape(attr_at(iattrs, 1, "dyn_slice")), s);
579+
case Opcode::CumSum:
580+
return mx::cumsum(arg1(in, "cumsum"),
581+
emily::checked_int(scalar_at(iattrs, 0, "cumsum"), "axis"),
582+
scalar_at(iattrs, 1, "cumsum") != 0, /*inclusive=*/true, s);
583+
case Opcode::CumProd:
584+
return mx::cumprod(arg1(in, "cumprod"),
585+
emily::checked_int(scalar_at(iattrs, 0, "cumprod"), "axis"),
586+
scalar_at(iattrs, 1, "cumprod") != 0, /*inclusive=*/true, s);
587+
case Opcode::CumMax:
588+
return mx::cummax(arg1(in, "cummax"),
589+
emily::checked_int(scalar_at(iattrs, 0, "cummax"), "axis"),
590+
scalar_at(iattrs, 1, "cummax") != 0, /*inclusive=*/true, s);
591+
case Opcode::CumMin:
592+
return mx::cummin(arg1(in, "cummin"),
593+
emily::checked_int(scalar_at(iattrs, 0, "cummin"), "axis"),
594+
scalar_at(iattrs, 1, "cummin") != 0, /*inclusive=*/true, s);
595+
case Opcode::Gather: {
596+
if (in.size() < 2) {
597+
throw std::invalid_argument("gather expects input + >=1 index operand");
598+
}
599+
std::vector<mx::array> indices(in.begin() + 1, in.end());
600+
return mx::gather(in[0], indices,
601+
emily::to_int_vec(attr_at(iattrs, 0, "gather")),
602+
emily::to_mlx_shape(attr_at(iattrs, 1, "gather")), s);
603+
}
604+
case Opcode::Stack:
605+
if (in.empty()) {
606+
throw std::invalid_argument("stack expects >= 1 operand");
607+
}
608+
return mx::stack(in, emily::checked_int(scalar_at(iattrs, 0, "stack"), "axis"),
609+
s);
568610
}
569611
throw std::invalid_argument("unknown opcode " +
570612
std::to_string(static_cast<int64_t>(op)));

lib/emily/compiler.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,15 @@ defmodule Emily.Compiler do
177177
{template, leaves_rev} =
178178
Composite.traverse(expr, [], fn leaf, acc -> {Nx.to_template(leaf), [leaf | acc]} end)
179179

180+
# The flattened parameter leaves are the true input count and slot order
181+
# (the closure realises them in this order; `{:input, i}` indexes it).
182+
# `IR.lower` only counts the parameters it *references*, which undercounts
183+
# when an input is unused (e.g. the `seed` in greedy generation) — pin
184+
# `n_inputs` to the real arity so the eval-time input count matches.
185+
n_inputs = length(Composite.flatten_list(vars))
186+
180187
case lower(Enum.reverse(leaves_rev), mode, key) do
181-
{:ok, ir} -> {:ok, replay_closure(template, ir)}
188+
{:ok, ir} -> {:ok, replay_closure(template, %{ir | n_inputs: n_inputs})}
182189
:fallback -> :fallback
183190
end
184191
end

lib/emily/ir.ex

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,15 @@ defmodule Emily.IR do
124124
# RNG / dynamic indexing primitives
125125
bitcast: 72,
126126
erf_inv: 73,
127-
dyn_slice: 74
127+
dyn_slice: 74,
128+
# inclusive cumulative reductions (iattrs [[axis],[reverse]])
129+
cumsum: 75,
130+
cumprod: 76,
131+
cummax: 77,
132+
cummin: 78,
133+
# multi-axis gather: operands [input, idx0, ...]; iattrs [[axes],[slice_sizes]]
134+
gather: 79,
135+
stack: 80
128136
}
129137

130138
# Quant mode string -> code; decoded by qmode_from_code in
@@ -409,6 +417,14 @@ defmodule Emily.IR do
409417
reduce_min: :min
410418
}
411419

420+
# Cumulative reductions arrive as `Nx.block/4` nodes. Block struct -> opcode.
421+
@cumulative_blocks %{
422+
Nx.Block.CumulativeSum => :cumsum,
423+
Nx.Block.CumulativeProduct => :cumprod,
424+
Nx.Block.CumulativeMax => :cummax,
425+
Nx.Block.CumulativeMin => :cummin
426+
}
427+
412428
defp lower_op(%T{data: %Nx.Defn.Expr{op: op, args: [a, opts]}} = t, state)
413429
when is_map_key(@reductions, op) do
414430
{ra, state} = lower_node(a, state)
@@ -568,6 +584,41 @@ defmodule Emily.IR do
568584
end
569585
end
570586

587+
# gather(input, indices, opts). Mirrors Emily.Backend.gather/4: single-axis
588+
# gathers `take` along the one axis (indices cast to s32); multi-axis
589+
# gathers split the `{..., R}` index tensor into R per-axis index arrays
590+
# and use MLX's multi-index gather. Both reshape to the output shape (token
591+
# selection in sampling is this shape). A layout MLX gather can't express
592+
# raises, so the graceful fallback handles it.
593+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :gather, args: [input, indices, opts]}} = t, state) do
594+
axes = opts[:axes]
595+
indices_shape = Tuple.to_list(indices.shape)
596+
597+
{ri, state} = lower_node(input, state)
598+
{rx, state} = lower_node(indices, state)
599+
600+
{r, state} =
601+
case axes do
602+
[axis] ->
603+
{ix, state} = emit(state, :astype, [rx], [[dtype_code({:s, 32})]])
604+
emit(state, :take, [ri, ix], [[axis]])
605+
606+
_ when is_list(axes) ->
607+
unless scatter_gather_compatible?(indices_shape, axes) do
608+
raise ArgumentError,
609+
"Emily Expr compiler: gather index layout #{inspect(indices_shape)} " <>
610+
"for axes #{inspect(axes)} is not MLX-gather-compatible."
611+
end
612+
613+
{idx_refs, state} = split_indices_for_gather(rx, indices_shape, length(axes), state)
614+
slice_sizes = slice_sizes_for_gather(input.shape, axes)
615+
emit(state, :gather, [ri | idx_refs], [axes, slice_sizes])
616+
end
617+
618+
{r, state} = emit(state, :reshape, [r], [Tuple.to_list(t.shape)])
619+
coerce(r, t.type, state)
620+
end
621+
571622
# put_slice(src, start_indices, slice): write `slice` into `src` at
572623
# `start_indices`. Mirrors Emily.Backend.put_slice/4 (cast src + update
573624
# to out.type), but supports RUNTIME (tensor) start indices — the decode
@@ -623,6 +674,12 @@ defmodule Emily.IR do
623674
emit_coerced(state, :concatenate, refs, [[axis]], t.type)
624675
end
625676

677+
# stack(tensors, axis): join along a NEW axis. Mirrors Emily.Backend.stack/3.
678+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :stack, args: [tensors, axis]}} = t, state) do
679+
{refs, state} = Enum.map_reduce(tensors, state, &lower_node/2)
680+
emit_coerced(state, :stack, refs, [[axis]], t.type)
681+
end
682+
626683
# conv: ports Emily.Backend.conv/4 — permute input -> NHWC and kernel ->
627684
# OHWI (casting both to out.type), mx::conv_general, then permute the
628685
# result NHWC -> NCHW -> the user's output layout. batch_group_size > 1
@@ -858,11 +915,26 @@ defmodule Emily.IR do
858915
emit_coerced(state, :take, [ri, rx], [[axis]], t.type)
859916
end
860917

918+
# Cumulative families. Like Emily.Backend.block/4, the last-axis case uses
919+
# the native MLX `cumsum`/`cumprod`/`cummax`/`cummin` kernel; interior axes
920+
# (which MLX can't always factor) fall back to the block's composed
921+
# expansion. Nx cumulation is always inclusive.
922+
defp lower_block(%mod{axis: axis, reverse: reverse}, [t], expr, out, state)
923+
when is_map_key(@cumulative_blocks, mod) do
924+
if axis == tuple_size(out.shape) - 1 do
925+
{rt, state} = lower_node(t, state)
926+
op = Map.fetch!(@cumulative_blocks, mod)
927+
emit_coerced(state, op, [rt], [[axis], [bool_int(reverse)]], out.type)
928+
else
929+
lower_node(expr, state)
930+
end
931+
end
932+
861933
# Any other block struct raises. Lowering the block's composed
862934
# expansion would silently diverge from the Evaluator whenever
863935
# Emily.Backend.block/4 dispatches that struct through a fused / native
864-
# kernel (e.g. SDPAWithSinks, the Nx.Block.LinAlg.* / Take / FFT /
865-
# cumulative families) — a worse failure than a clear "unsupported".
936+
# kernel (e.g. SDPAWithSinks, the Nx.Block.LinAlg.* / FFT families) — a
937+
# worse failure than a clear "unsupported".
866938
# Additional fused blocks are added alongside their opcode.
867939
defp lower_block(struct, _in_args, _expr, _t, _state) do
868940
raise ArgumentError,
@@ -1007,4 +1079,39 @@ defmodule Emily.IR do
10071079
{r, state} = emit(state, :maximum, [ref, lo_c])
10081080
emit(state, :minimum, [r, hi_c])
10091081
end
1082+
1083+
# MLX's multi-index gather needs the index tensor's leading dims to be the
1084+
# batch and the last axis to select across `axes` (mirrors
1085+
# Emily.Backend.scatter_gather_compatible?/2).
1086+
defp scatter_gather_compatible?(indices_shape, axes) do
1087+
is_list(axes) and axes != [] and length(indices_shape) >= 2 and
1088+
List.last(indices_shape) == length(axes)
1089+
end
1090+
1091+
# Split an `{..., R}` index tensor into R per-axis s32 index arrays (each
1092+
# the leading batch with the last axis dropped) — ports
1093+
# Emily.Backend.split_indices_per_axis/4 with static slices.
1094+
defp split_indices_for_gather(indices_ref, indices_shape, n_axes, state) do
1095+
rank = length(indices_shape)
1096+
last_axis = rank - 1
1097+
batch_shape = Enum.take(indices_shape, last_axis)
1098+
strides = List.duplicate(1, rank)
1099+
batch_zeros = List.duplicate(0, last_axis)
1100+
1101+
Enum.map_reduce(0..(n_axes - 1)//1, state, fn i, state ->
1102+
{r, state} =
1103+
emit(state, :slice, [indices_ref], [batch_zeros ++ [i], batch_shape ++ [i + 1], strides])
1104+
1105+
{r, state} = emit(state, :squeeze, [r], [[last_axis]])
1106+
emit(state, :astype, [r], [[dtype_code({:s, 32})]])
1107+
end)
1108+
end
1109+
1110+
# Per-axis slice size for gather: 1 on a gathered axis, the full extent
1111+
# otherwise (mirrors Emily.Backend.slice_sizes_for_gather/2).
1112+
defp slice_sizes_for_gather(input_shape, axes) do
1113+
axes_set = MapSet.new(axes)
1114+
rank = tuple_size(input_shape)
1115+
for i <- 0..(rank - 1)//1, do: if(i in axes_set, do: 1, else: elem(input_shape, i))
1116+
end
10101117
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule Emily.CompilerIndexingTest do
2+
@moduledoc """
3+
CM13 — the gather / stack / cumulative ops Bumblebee's generation loop
4+
needs, lowered single-NIF and bit-identical to the Evaluator. (The
5+
generation conformance test exercises them in anger; these pin each op in
6+
isolation.) Run under `native_fallback: :raise` so a regression raises
7+
rather than silently falling back.
8+
"""
9+
use ExUnit.Case, async: true
10+
11+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
12+
@eval [compiler: Emily.Compiler]
13+
14+
defp t(data, opts \\ []), do: Nx.tensor(data, [backend: Emily.Backend] ++ opts)
15+
16+
defp equiv(fun, args) do
17+
native = apply(Nx.Defn.jit(fun, @native), args)
18+
eval = apply(Nx.Defn.jit(fun, @eval), args)
19+
assert %Emily.Backend{} = native.data
20+
assert native.shape == eval.shape and native.type == eval.type
21+
assert Nx.to_binary(native) == Nx.to_binary(eval)
22+
native
23+
end
24+
25+
test "cumulative sum/product/max/min along the last axis (forward and reverse)" do
26+
x = t([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
27+
28+
for op <- [:cumulative_sum, :cumulative_product, :cumulative_max, :cumulative_min] do
29+
equiv(fn x -> apply(Nx, op, [x, [axis: 1]]) end, [x])
30+
equiv(fn x -> apply(Nx, op, [x, [axis: 1, reverse: true]]) end, [x])
31+
end
32+
end
33+
34+
test "single-axis gather" do
35+
x = t([10.0, 20.0, 30.0, 40.0])
36+
idx = t([[3], [1], [0]], type: :s64)
37+
out = equiv(fn x, i -> Nx.gather(x, i) end, [x, idx])
38+
assert Nx.to_flat_list(out) == [40.0, 20.0, 10.0]
39+
end
40+
41+
test "multi-axis gather (the sampling path)" do
42+
x = t([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
43+
idx = t([[0, 1], [2, 0]], type: :s64)
44+
out = equiv(fn x, i -> Nx.gather(x, i) end, [x, idx])
45+
assert Nx.to_flat_list(out) == [2.0, 5.0]
46+
end
47+
48+
test "stack along a new axis" do
49+
a = t([1.0, 2.0, 3.0])
50+
b = t([4.0, 5.0, 6.0])
51+
equiv(fn a, b -> Nx.stack([a, b]) end, [a, b])
52+
equiv(fn a, b -> Nx.stack([a, b], axis: 1) end, [a, b])
53+
end
54+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Emily.Conformance.GenerationNativeTest do
2+
@moduledoc """
3+
CM13 — the goal gate: `Bumblebee.Text.generation`'s own decode loop (a
4+
`defn while`) compiles **fully native** under `compiler: Emily.Compiler,
5+
native: true`, producing token ids bit-identical to the Evaluator path.
6+
7+
`native_fallback: :raise` makes this a no-fallback gate: every op in the
8+
whole generation graph — the transformer forward, the `while` loop, the
9+
dynamic KV-cache writes, `cumsum` for position ids, `argmax`/sampling for
10+
token selection, threefry for the sampling seed — must lower, or the build
11+
raises rather than silently degrading to the evaluator.
12+
13+
Driven through `build_generate` on small in-vocabulary `input_ids` (not
14+
text) so the test needs no tokenizer and stays clear of the OOB-gather
15+
pitfall of pairing a tiny-random model with a full-vocab tokenizer.
16+
"""
17+
use ExUnit.Case, async: false
18+
@moduletag :conformance
19+
20+
alias Bumblebee.Text.Generation
21+
22+
@repo {:hf, "bumblebee-testing/tiny-random-Qwen3ForCausalLM"}
23+
24+
setup_all do
25+
Nx.global_default_backend(Emily.Backend)
26+
{:ok, model_info} = Bumblebee.load_model(@repo)
27+
{:ok, gen_config} = Bumblebee.load_generation_config(@repo)
28+
%{model_info: model_info, gen_config: gen_config}
29+
end
30+
31+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
32+
@eval [compiler: Emily.Compiler]
33+
34+
# Run `model`'s generation through `build_generate` on a fixed in-vocab
35+
# prompt, returning the generated token ids.
36+
defp generate_ids(%{model: model, params: params, spec: spec}, gen_config, defn_options) do
37+
generate = Generation.build_generate(model, spec, gen_config)
38+
39+
inputs = %{
40+
"input_ids" => Nx.tensor([[1, 2, 3, 4, 5, 6]], type: :s64, backend: Emily.Backend),
41+
"seed" => Nx.tensor([0], type: :s64, backend: Emily.Backend)
42+
}
43+
44+
Nx.Defn.jit(generate, defn_options).(params, inputs).token_ids
45+
|> Nx.to_flat_list()
46+
end
47+
48+
defp configure(gen_config, strategy) do
49+
Bumblebee.configure(gen_config,
50+
max_new_tokens: 6,
51+
strategy: strategy,
52+
pad_token_id: 0,
53+
eos_token_id: 0
54+
)
55+
end
56+
57+
test "greedy generation compiles fully native, bit-identical to the evaluator", ctx do
58+
gc = configure(ctx.gen_config, %{type: :greedy_search})
59+
native = generate_ids(ctx.model_info, gc, @native)
60+
eval = generate_ids(ctx.model_info, gc, @eval)
61+
assert native == eval
62+
end
63+
64+
test "multinomial sampling compiles fully native, bit-identical to the evaluator", ctx do
65+
gc = configure(ctx.gen_config, %{type: :multinomial_sampling})
66+
native = generate_ids(ctx.model_info, gc, @native)
67+
eval = generate_ids(ctx.model_info, gc, @eval)
68+
assert native == eval
69+
end
70+
end

0 commit comments

Comments
 (0)