Skip to content

Commit d340412

Browse files
authored
Merge pull request #11 from ausimian/feat/m4-qwen3
M4: Bumblebee Qwen3 end-to-end on Emily.Backend
2 parents a4a5d9e + c369ac4 commit d340412

10 files changed

Lines changed: 487 additions & 11 deletions

File tree

RELEASE.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,69 @@
110110
last-axis fast path stays on MLX; interior-axis usage is rare on
111111
our M3/M4 critical path (transformer inference doesn't need it).
112112

113+
- M4 — Qwen3 inference. `Qwen/Qwen3-0.6B` greedy-decodes end-to-end on
114+
`Emily.Backend` through Bumblebee's causal-LM serving. Everything on
115+
Qwen3's critical path (QK-norm, rotary embeddings, GQA, SwiGLU FFN,
116+
RMSNorm, tied embeddings, KV-cache `put_slice` in a `defn` while
117+
loop) runs correctly.
118+
- **Native `put_slice/4`** in `Emily.Backend`, backed by a new
119+
`Native.slice_update/3` NIF over `mx::slice_update`. Replaces the
120+
BinaryBackend round-trip — autoregressive decoding calls
121+
`put_slice` per layer per token to append into the KV cache, and
122+
the old fallback transferred ~1 MB of cache state through the
123+
allocator on every call. Also fixes a latent bug in the old
124+
implementation: dynamic scalar-tensor `start_indices` on
125+
`Emily.Backend` used to slip through unconverted and crash inside
126+
BinaryBackend. `slice_start` is now applied to every start
127+
index, matching the `slice/5` callback.
128+
- **Operand-type promotion in `put_slice`.** `Nx.put_slice`
129+
promotes the output type across tensor/update (an s32 pad buffer
130+
clashing with an s64 decoder input becomes s64), but the backend
131+
callback still receives the original-type operands. We cast both
132+
`t` and `slice` to `out.type` via `Native.astype` before
133+
dispatching to `slice_update`. Without this the MLX buffer
134+
silently disagrees with the Nx shape metadata — the first symptom
135+
is `Nx.to_binary` returning a half-sized binary and
136+
`BinaryBackend.bitstring_part` raising a match error deep inside
137+
the tokenizer decode. Mirrors the arithmetic-op promotion fix
138+
landed in M3.
139+
- **`test/emily/conformance/qwen3_test.exs`**
140+
(`@moduletag :conformance`) — ports `Bumblebee.Text.Qwen3Test`
141+
verbatim (three architectures: `:base`,
142+
`:for_causal_language_modeling`, `:for_sequence_classification`),
143+
with HF reference slices checked in, plus a `greedy generation`
144+
describe block that drives
145+
`Bumblebee.Text.Generation.build_generate` on the tiny-random
146+
causal LM. That smoke test feeds synthetic `input_ids` in
147+
`[0, 1024)` (tokenizer vocab is 151 k but the tiny checkpoint's
148+
embedding is 1024 rows), greedy-decodes 16 tokens through the
149+
full generation pipeline (`Axon.predict` + logit processing +
150+
`Nx.argmax` + `put_slice` KV-cache update + `defn while`), and
151+
asserts bit-exact equality against both `Nx.BinaryBackend` run
152+
on the same inputs *and* a checked-in 16-token reference.
153+
- **`test/emily/conformance/qwen3_full_test.exs`**
154+
(`@moduletag :qwen3_full`, excluded from `--only conformance`
155+
because the checkpoint is ~1.5 GB) — loads `Qwen/Qwen3-0.6B`
156+
proper, greedy-decodes 32 tokens from a fixed prompt through
157+
`Nx.Serving`, and asserts the completion string matches a
158+
checked-in reference. Run with `mix test --only qwen3_full`.
159+
- **`bench/qwen3_tokens_per_sec.exs`** — standalone wall-clock
160+
throughput harness. Loads `Qwen/Qwen3-0.6B`, runs N warmup
161+
iterations + M measured iterations of greedy decode, reports
162+
tokens/sec. Prompt, token count, and iteration counts are
163+
overridable via `EMILY_BENCH_*` env vars. Baseline observed on a
164+
dev M3 host: ~13.8 tok/s at 16 new tokens under the
165+
`Nx.Defn.Evaluator` compiler (no `mlx::core::compile` wrap yet —
166+
that lands in M6). Intended as a regression gate, not a headline
167+
number.
168+
- **Bumblebee dependency** bumped from Hex 0.6.3 to a pinned `main`
169+
commit (`273805e9…`) so `Bumblebee.Text.Qwen3` is available — the
170+
text port is on main but not yet in a Hex release. Revert to a
171+
Hex version as soon as one ships Qwen3 support.
172+
- **`test_helper.exs`** extended the exclude list with
173+
`:qwen3_full` so the weights-heavy test stays out of
174+
`mix test --only conformance`.
175+
113176
- M3 — DistilBERT end-to-end on Bumblebee. Every Nx op on the
114177
transformer critical path now runs natively on MLX; the full
115178
forward pass matches HuggingFace Transformers (PyTorch) reference

bench/qwen3_tokens_per_sec.exs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Qwen3-0.6B greedy-decode throughput on `Emily.Backend`.
2+
#
3+
# Usage:
4+
#
5+
# MIX_ENV=test mix run bench/qwen3_tokens_per_sec.exs
6+
#
7+
# Optional environment variables:
8+
#
9+
# EMILY_BENCH_MODEL HuggingFace repo id. Defaults to
10+
# "Qwen/Qwen3-0.6B".
11+
# EMILY_BENCH_NEW_TOKENS Number of tokens to greedy-decode per
12+
# run. Defaults to 64.
13+
# EMILY_BENCH_PROMPT Prompt text. Defaults to a fixed short
14+
# English sentence.
15+
# EMILY_BENCH_WARMUP Number of warm-up runs (not measured).
16+
# Defaults to 1.
17+
# EMILY_BENCH_RUNS Number of measured runs. Defaults to 3.
18+
#
19+
# The first run downloads the model (~1.2 GB at f32, ~600 MB at f16).
20+
# We deliberately avoid `Benchee` — this benchmark has one workload and
21+
# one metric (tokens/sec). The whole script is standalone so a reader
22+
# can follow the generation flow without chasing macros.
23+
24+
defmodule Emily.Bench.Qwen3 do
25+
@default_model "Qwen/Qwen3-0.6B"
26+
@default_prompt "The quick brown fox jumps over the lazy dog."
27+
@default_new_tokens 64
28+
@default_warmup 1
29+
@default_runs 3
30+
31+
def run do
32+
Nx.global_default_backend(Emily.Backend)
33+
34+
model_repo = System.get_env("EMILY_BENCH_MODEL", @default_model)
35+
prompt = System.get_env("EMILY_BENCH_PROMPT", @default_prompt)
36+
37+
new_tokens =
38+
System.get_env("EMILY_BENCH_NEW_TOKENS")
39+
|> env_int(@default_new_tokens)
40+
41+
warmup = System.get_env("EMILY_BENCH_WARMUP") |> env_int(@default_warmup)
42+
runs = System.get_env("EMILY_BENCH_RUNS") |> env_int(@default_runs)
43+
44+
IO.puts("Emily / Qwen3 throughput benchmark")
45+
IO.puts(" model : #{model_repo}")
46+
IO.puts(" prompt : #{inspect(prompt)}")
47+
IO.puts(" new tokens : #{new_tokens}")
48+
IO.puts(" warmup : #{warmup}")
49+
IO.puts(" runs : #{runs}")
50+
IO.puts("")
51+
52+
{:ok, model_info} = Bumblebee.load_model({:hf, model_repo})
53+
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, model_repo})
54+
55+
{:ok, generation_config} = Bumblebee.load_generation_config({:hf, model_repo})
56+
57+
generation_config =
58+
Bumblebee.configure(generation_config,
59+
max_new_tokens: new_tokens,
60+
strategy: %{type: :greedy_search}
61+
)
62+
63+
serving =
64+
Bumblebee.Text.generation(model_info, tokenizer, generation_config,
65+
defn_options: [compiler: Nx.Defn.Evaluator]
66+
)
67+
68+
for _ <- Stream.duplicate(:ok, warmup) do
69+
IO.puts("[warmup] generating…")
70+
%{results: [_]} = Nx.Serving.run(serving, prompt)
71+
end
72+
73+
measurements =
74+
for n <- 1..runs//1 do
75+
{elapsed_us, %{results: [%{text: text}]}} =
76+
:timer.tc(fn -> Nx.Serving.run(serving, prompt) end)
77+
78+
secs = elapsed_us / 1_000_000
79+
tps = new_tokens / secs
80+
IO.puts("[run #{n}] #{Float.round(secs, 3)} s, #{Float.round(tps, 2)} tok/s")
81+
{secs, tps, text}
82+
end
83+
84+
tps_list = Enum.map(measurements, fn {_, tps, _} -> tps end)
85+
[{_, _, sample} | _] = measurements
86+
mean = Enum.sum(tps_list) / length(tps_list)
87+
{min_tps, max_tps} = Enum.min_max(tps_list)
88+
89+
IO.puts("")
90+
IO.puts("tokens/sec mean=#{Float.round(mean, 2)} min=#{Float.round(min_tps, 2)} max=#{Float.round(max_tps, 2)}")
91+
IO.puts("")
92+
IO.puts("first completion:")
93+
IO.puts(String.slice(sample, 0, 500))
94+
end
95+
96+
defp env_int(nil, default), do: default
97+
defp env_int(s, default) do
98+
case Integer.parse(s) do
99+
{n, ""} -> n
100+
_ -> default
101+
end
102+
end
103+
end
104+
105+
Emily.Bench.Qwen3.run()

c_src/ops/index.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ fine::ResourcePtr<Tensor> slice(
3131
}
3232
FINE_NIF(slice, 0);
3333

34+
// slice_update/3: write `update` into `src` starting at `start`. `stop`
35+
// is derived as `start + shape(update)` and strides default to 1 on
36+
// every axis (Nx.put_slice has no stride parameter). Output shape
37+
// equals `src.shape`.
38+
fine::ResourcePtr<Tensor> slice_update(
39+
ErlNifEnv *,
40+
fine::ResourcePtr<Tensor> src,
41+
fine::ResourcePtr<Tensor> update,
42+
std::vector<int64_t> start) {
43+
const auto &update_shape = update->array.shape();
44+
mx::Shape start_shape = to_mlx_shape(start);
45+
mx::Shape stop_shape;
46+
stop_shape.reserve(start_shape.size());
47+
for (size_t i = 0; i < start_shape.size(); ++i) {
48+
stop_shape.push_back(start_shape[i] + update_shape[i]);
49+
}
50+
return wrap(mx::slice_update(
51+
src->array, update->array, std::move(start_shape), std::move(stop_shape)));
52+
}
53+
FINE_NIF(slice_update, 0);
54+
3455
// take/3: gather along `axis` using integer indices.
3556
fine::ResourcePtr<Tensor> take(
3657
ErlNifEnv *,

lib/emily/backend.ex

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,13 +592,21 @@ defmodule Emily.Backend do
592592
defp slice_start(i) when is_integer(i), do: i
593593
defp slice_start(%T{} = t), do: t |> Nx.backend_copy(Nx.BinaryBackend) |> Nx.to_number()
594594

595-
# put_slice: MLX has no direct primitive; route via BinaryBackend.
596-
# Nx's Backend contract order is (out, tensor, start_indices, slice).
597-
# start_indices arrive as scalar tensors on Emily.Backend; Nx auto-
598-
# transfers them when the BinaryBackend call goes through `to_indices`.
595+
# put_slice: implemented natively via MLX `slice_update`. Nx promotes
596+
# operand types at the API layer — `Nx.put_slice(s32_buf, _, s64_upd)`
597+
# declares an s64 output — but our callback arguments still carry the
598+
# original backend types. We cast both `t` and `slice` to `out.type`
599+
# before dispatching so the MLX buffer matches Nx's shape/type view.
600+
# Scalar-tensor starts are materialised to integers here (dynamic
601+
# indices show up when autoregressive loops dispatch put_slice from
602+
# within `defn`).
599603
@impl true
600-
def put_slice(out, t, start_indices, slice),
601-
do: via_binary(out, [t, slice], &Nx.put_slice(&1, start_indices, &2))
604+
def put_slice(%T{type: type} = out, %T{} = t, start_indices, %T{} = slice) do
605+
starts = Enum.map(start_indices, &slice_start/1)
606+
src_ref = Native.astype(ref(t), type)
607+
update_ref = Native.astype(ref(slice), type)
608+
Native.slice_update(src_ref, update_ref, starts) |> wrap(out)
609+
end
602610

603611
@impl true
604612
def select(%T{} = out, pred, on_true, on_false) do

lib/emily/native.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ defmodule Emily.Native do
221221
@spec slice(tensor(), [integer()], [integer()], [integer()]) :: tensor()
222222
def slice(_a, _start, _stop, _strides), do: nif()
223223

224+
@spec slice_update(tensor(), tensor(), [integer()]) :: tensor()
225+
def slice_update(_src, _update, _start), do: nif()
226+
224227
@spec take(tensor(), tensor(), integer()) :: tensor()
225228
def take(_a, _indices, _axis), do: nif()
226229

mix.exs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ defmodule Emily.MixProject do
5252
{:elixir_make, "~> 0.9"},
5353
{:fine, "~> 0.1"},
5454
{:nx, "~> 0.10"},
55-
{:bumblebee, "~> 0.6", only: :test},
55+
# Bumblebee >= 0.6.3 (the latest Hex release) lacks Qwen3 support.
56+
# Pinned to a `main` commit that contains `Bumblebee.Text.Qwen3` so
57+
# M4 can exercise Qwen3-0.6B end-to-end. Bump deliberately when a
58+
# newer release lands on Hex.
59+
{:bumblebee,
60+
github: "elixir-nx/bumblebee", ref: "273805e95507dc7866b958d90e0012a3abad1761", only: :test},
5661
{:tokenizers, "~> 0.5", only: :test},
5762
{:stream_data, "~> 1.1", only: [:dev, :test]},
5863
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},

mix.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
%{
22
"axon": {:hex, :axon, "0.7.0", "2e2c6d93b4afcfa812566b8922204fa022b60081e86ebd411df4db7ea30f5457", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "ee9857a143c9486597ceff434e6ca833dc1241be6158b01025b8217757ed1036"},
3-
"bumblebee": {:hex, :bumblebee, "0.6.3", "c0028643c92de93258a9804da1d4d48797eaf7911b702464b3b3dd2cc7f938f1", [:mix], [{:axon, "~> 0.7.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.9.0 or ~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "c619197787561f8e5fb2ffba269c341654accaec9d591999b7fddd55761dd079"},
3+
"bumblebee": {:git, "https://github.com/elixir-nx/bumblebee.git", "273805e95507dc7866b958d90e0012a3abad1761", [ref: "273805e95507dc7866b958d90e0012a3abad1761"]},
44
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
55
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
66
"complex": {:hex, :complex, "0.6.0", "b0130086a7a8c33574d293b2e0e250f4685580418eac52a5658a4bd148f3ccf1", [:mix], [], "hexpm", "0a5fa95580dcaf30fcd60fe1aaf24327c0fe401e98c24d892e172e79498269f9"},
@@ -27,5 +27,5 @@
2727
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
2828
"tokenizers": {:hex, :tokenizers, "0.5.1", "b0975d92b4ee5b18e8f47b5d65b9d5f1e583d9130189b1a2620401af4e7d4b35", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5f08d97cc7f2ed3d71d370d68120da6d3de010948ccf676c9c0eb591ba4bacc9"},
2929
"unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"},
30-
"unzip": {:hex, :unzip, "0.12.0", "beed92238724732418b41eba77dcb7f51e235b707406c05b1732a3052d1c0f36", [:mix], [], "hexpm", "95655b72db368e5a84951f0bed586ac053b55ee3815fd96062fce10ce4fc998d"},
30+
"unzip": {:hex, :unzip, "0.13.0", "bf5ec6ac6063c69e6ec54c8b4a3b8dcd7a2719d28d10d7025776ab107957cde9", [:mix], [], "hexpm", "4bcb9892ecbf2042606b43ab685a1bffe03c14003e6246f5453db2c829237fd9"},
3131
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
defmodule Emily.Conformance.Qwen3FullTest do
2+
@moduledoc """
3+
Full `Qwen/Qwen3-0.6B` end-to-end conformance test.
4+
5+
This test is excluded even from `mix test --only conformance`: the
6+
model is ~1.5 GB on first fetch, so blowing it out into CI on every
7+
push is the wrong default. Run explicitly:
8+
9+
mix test --only qwen3_full
10+
11+
The reference text pinned below is the greedy decode produced by
12+
`Emily.Backend` on an Apple-Silicon host. A failure means the
13+
backend has drifted, Bumblebee's Qwen3 port has changed, or the HF
14+
checkpoint has been republished — all of which are real signals.
15+
"""
16+
17+
use ExUnit.Case, async: false
18+
19+
@moduletag :qwen3_full
20+
@moduletag capture_log: true
21+
@moduletag timeout: 600_000
22+
23+
@prompt "The quick brown fox jumps over the lazy dog."
24+
@reference_text " The quick brown fox is a character in the story. The quick brown fox is a character in the story. The quick brown fox is a character in the story"
25+
26+
setup_all do
27+
prev = Nx.default_backend()
28+
Nx.global_default_backend(Emily.Backend)
29+
on_exit(fn -> Nx.global_default_backend(prev) end)
30+
:ok
31+
end
32+
33+
test "Qwen/Qwen3-0.6B greedy decodes the pinned continuation" do
34+
{:ok, model_info} = Bumblebee.load_model({:hf, "Qwen/Qwen3-0.6B"})
35+
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "Qwen/Qwen3-0.6B"})
36+
{:ok, generation_config} = Bumblebee.load_generation_config({:hf, "Qwen/Qwen3-0.6B"})
37+
38+
config =
39+
Bumblebee.configure(generation_config,
40+
max_new_tokens: 32,
41+
strategy: %{type: :greedy_search}
42+
)
43+
44+
serving =
45+
Bumblebee.Text.generation(model_info, tokenizer, config,
46+
defn_options: [compiler: Nx.Defn.Evaluator]
47+
)
48+
49+
%{results: [%{text: text, token_summary: summary}]} =
50+
Nx.Serving.run(serving, @prompt)
51+
52+
assert summary.output == 32
53+
assert text == @reference_text
54+
end
55+
end

0 commit comments

Comments
 (0)