Skip to content

Commit 0bb6219

Browse files
committed
M10.5: Bumblebee quantized inference integration
Closes the gap M10 left open: `Native.quantized_matmul` is now reachable from `Nx.Defn.jit`-traced Axon forward passes, and a quantized Qwen3-0.6B greedy-decodes end-to-end on Emily through stock `Bumblebee.Text.generation/4`. Approach: defn-native dequantize (Option 1 from PLAN.md M10.5). Rejected Option 2 (fork Evaluator — fragile) and Option 3 (upstream Nx custom-op hook — too slow). Trade-off is two kernels per matmul (dequantize + dot) instead of MLX's single fused quantized_matmul; M11's fast-kernel work closes the gap. - Emily.Quantization.dequantize_defn/1 — defn-native MLX affine dequantize built from Nx.right_shift / bitwise_and / multiply / add. Supports bits ∈ {2, 4, 8}; {3, 6} out of scope (cross-u32 packing). Uses Nx.flatten with negative axes and reshape :auto. - Emily.Quantization.Layers.quantized_dense/4 — Axon-compatible layer op. Pattern-matches on %QuantizedWeight{}; transpose flag carried as compile-time constant via deftransform. - Emily.Quantization.Transform — graph rewriter + state quantizer modeled on Axon.Quantization.quantize/2. quantize/3 takes a dense Axon model + dense ModelState and returns the quantized pair. Lives under test/support/ because Axon is only: :test. - qwen3_quant_full opt-in conformance — loads dense Qwen3-0.6B, quantizes via Transform.quantize/3 (bits=4, group_size=128, transpose=true), greedy-decodes 32 tokens, pins continuation text as regression gate. ~14s after model cache. - 26 new unit/property tests across quantization/ covering dequant equality, layer op under both Nx.Defn.Evaluator and Emily.Compiler, graph-rewrite round-trip, and validation. Scope reductions from original PLAN.md M10.5: - AWQ safetensors loader deferred. On closer inspection AWQ groups along `in` while MLX transpose=false expects groups along the stored last axis (out); correct conversion requires transposing packed tensors, unpacking qzeros into biases, and mapping HF param names to Bumblebee names. All tractable but additional scope. The from-dense path exercises the same defn-integration pipeline end-to-end. - Conformance oracle is Emily's own first-run output (same discipline as qwen3_full_test.exs) rather than MLX Python. mix precommit: 263 tests + 66 properties + 1 doctest, 0 failures, credo clean. qwen3_quant_full green (opt-in).
1 parent 2c1f3e0 commit 0bb6219

10 files changed

Lines changed: 1030 additions & 71 deletions

File tree

PLAN.md

Lines changed: 68 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -411,46 +411,74 @@ comparison; quantized memory soak clean.
411411

412412
### M10.5 — Bumblebee quantized inference integration
413413

414-
Closes the gap M10 left open: getting `Native.quantized_matmul`
415-
reachable from `Nx.Defn.jit`-traced Axon forward passes so Bumblebee's
416-
AWQ-loading (when it lands) routes through the fused kernel.
417-
418-
Approach choices (pick before starting):
419-
420-
1. **Defn-native dequantize** — implement MLX's int4/int8 affine
421-
dequantize using Nx bit primitives (right-shift + mask + multiply +
422-
add). `Emily.Quantization.Layers.quantized_dense/3` becomes
423-
`Nx.dot(x, dequantize_defn(qw))`. Correct and unblocks the full
424-
Axon/Bumblebee path, but uses two kernels (dequantize + matmul)
425-
instead of MLX's fused one — M11's fast-kernel work subsumes the
426-
perf gap.
427-
2. **Emily.Compiler custom-op intercept** — fork `Nx.Defn.Evaluator`
428-
under Emily to recognise a sentinel `Expr` node and route to
429-
`Native.quantized_matmul`. Full fused-kernel story but large
430-
surface; fragile against upstream Nx evolution.
431-
3. **Upstream Nx extension** — add a custom-backend-op hook to
432-
`Nx.Defn.Compiler` / `Nx.Defn.Evaluator`. Cleanest long-term
433-
solution; slowest to land because it needs upstream review/merge.
434-
435-
Also in scope for M10.5:
436-
437-
- **Test-only AWQ loader** (`test/support/awq_loader.ex`) — reads
438-
`Qwen/Qwen3-0.6B-AWQ` safetensors, extracts `qweight`, `scales`,
439-
`qzeros`, maps to MLX's `(w_q, scales, biases)` layout. The
440-
trickiest bit is the AWQ zero-point → MLX bias conversion
441-
(`biases = -scales * zero_points`) and the AWQ `[in, out/pack]` vs.
442-
MLX `[out, in]` layout difference.
443-
- **`:qwen3_quant_full` conformance test** — greedy-decode
444-
Qwen3-0.6B-AWQ on Emily, assert the completion matches a checked-in
445-
reference produced by MLX's Python bindings on the same quantized
446-
weights.
447-
- **Bumblebee upstream contribution (optional, follow-up to M10.5)**
448-
upstream the AWQ loader into `deps/bumblebee` so the test-only path
449-
becomes unnecessary.
450-
451-
**Exit**: Qwen3-0.6B-AWQ greedy-decodes end-to-end on Emily under
452-
`Nx.Defn.jit`; conformance test green; Axon-integrated quantization
453-
documented.
414+
Closes the gap M10 left open: `Native.quantized_matmul` is now
415+
reachable from `Nx.Defn.jit`-traced Axon forward passes, and a
416+
quantized Qwen3-0.6B greedy-decodes end-to-end under Bumblebee's
417+
standard `Bumblebee.Text.generation/4` serving.
418+
419+
**Shipped**:
420+
421+
- **`Emily.Quantization.dequantize_defn/1`** (`lib/emily/quantization.ex`)
422+
— defn-native analogue of `QuantizedWeight.to_dense/1`, built from
423+
`Nx.right_shift` / `Nx.bitwise_and` / multiply / add. Supports
424+
`bits ∈ {2, 4, 8}` (lanes-per-u32 is integral). `bits ∈ {3, 6}`
425+
(cross-u32 packing) is out of scope — the Native path remains for
426+
those.
427+
- **`Emily.Quantization.Layers.quantized_dense/4`**
428+
(`lib/emily/quantization/layers.ex`) — Axon-compatible layer op
429+
(`deftransform``defnp`). Pattern-matches on `%QuantizedWeight{}`,
430+
dispatches `Nx.dot(x, dequantize_defn(qw))` (`transpose=false`) or
431+
`Nx.dot(x, Nx.transpose(dequantize_defn(qw)))` (`transpose=true`).
432+
Two kernel dispatches per matmul instead of MLX's fused one —
433+
accepted trade-off for integration without forking
434+
`Nx.Defn.Evaluator`. M11's fast-kernel work closes the gap.
435+
- **`Emily.Quantization.Transform`** (`test/support/quantization_transform.ex`)
436+
— graph rewriter + model-state quantizer, modeled on
437+
`Axon.Quantization`. `quantize/3` takes a dense Axon model + dense
438+
`Axon.ModelState` and returns the pair with every `:dense` node
439+
rewritten to `:quantized_dense` and every dense kernel replaced
440+
with `%QuantizedWeight{}`. Lives in `test/support/` because Axon is
441+
an `only: :test` dep of Emily; graduates to `lib/` when an upstream
442+
Bumblebee AWQ-loading path lands.
443+
- **`:qwen3_quant_full` conformance test** (`test/emily/conformance/qwen3_quant_full_test.exs`)
444+
— loads dense Qwen3-0.6B via Bumblebee, quantizes via `Transform.quantize/3`
445+
(`bits=4, group_size=128, transpose=true`), runs
446+
`Bumblebee.Text.generation/4` greedy decode for 32 tokens. Pins the
447+
continuation string as a regression gate. Opt-in via `mix test --only
448+
qwen3_quant_full` (mirrors `:qwen3_full`'s model-size discipline).
449+
450+
**Approach chosen**: Option 1 (defn-native dequantize). Option 2
451+
(`Emily.Compiler` custom-op intercept) and Option 3 (upstream Nx
452+
extension) remain available if the two-kernel-vs-fused gap materially
453+
hurts a real workload after M11.
454+
455+
**Scope reductions from original PLAN.md M10.5**:
456+
457+
- **AWQ safetensors loader deferred.** The original plan called for a
458+
test-only loader that reads `Qwen/Qwen3-0.6B-AWQ` and maps to
459+
`%QuantizedWeight{}`. On closer inspection the AWQ→MLX conversion is
460+
meaningfully more involved than first thought: AWQ groups along the
461+
`in` axis while MLX's `transpose=false` path expects groups along the
462+
stored last axis, so correct conversion requires transposing the
463+
packed tensor, unpacking `qzeros` into per-group zero-points,
464+
computing `biases = -scales * zero_points`, and mapping HF param
465+
names to Bumblebee's internal naming. All tractable but substantial.
466+
The from-dense path above exercises the same defn-integration
467+
pipeline (graph rewrite + QW params + defn-native dequantize + JIT'd
468+
forward) and produces a useful regression gate; AWQ-specific loading
469+
is now a proper follow-up milestone rather than M10.5-scope.
470+
- **Conformance oracle adjusted.** PLAN.md originally envisioned
471+
asserting against MLX Python's output on the same quantized
472+
weights. Since we're now quantizing Qwen3-0.6B ourselves (not
473+
loading AWQ), the reference is what this pipeline produces on a
474+
clean checkout — same discipline as `qwen3_full_test.exs`.
475+
476+
**Follow-ups** (out of M10.5 scope):
477+
478+
- AWQ safetensors loader + Bumblebee param-name mapping. When it
479+
lands, adds a second conformance test that loads real
480+
`Qwen/Qwen3-0.6B-AWQ` weights end-to-end.
481+
- Optional upstream contribution to `deps/bumblebee` for AWQ loading.
454482

455483
### M11 — `mlx::fast::*` fused kernels
456484

lib/emily/quantization.ex

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,47 @@ defmodule Emily.Quantization do
22
@moduledoc """
33
Quantized inference primitives.
44
5-
M10 ships the Native bindings (`Emily.Native.quantize/3`,
6-
`Emily.Native.dequantize/5`, `Emily.Native.quantized_matmul/7`), the
7-
`Emily.QuantizedWeight` container, and this direct-call helper. This is
8-
enough to:
9-
10-
* quantize a dense weight, store it packed at rest, and later dispatch a
11-
fused quantized matmul against it;
12-
* benchmark the quantized path against an `Nx.dot`-on-dequantized oracle;
13-
* run quantized inference in *eager* code (plain Elixir, outside `defn`).
14-
15-
## What M10 does NOT ship
16-
17-
Integration with `Nx.Defn`-traced Axon forward passes (and therefore
18-
`Bumblebee` serving with AWQ checkpoints) is deferred to a follow-up.
19-
`Nx.Defn.Evaluator` walks `Nx.Defn.Expr` and dispatches via the
20-
`Nx.Backend` behaviour; there is no public hook to inject a custom op like
21-
`Native.quantized_matmul`, and none of `deftransform`, `hook`, or
22-
`metadata` can call NIFs on tensors that are still `Expr` nodes at trace
23-
time. Closing that gap requires either (a) a defn-native dequantize built
24-
from Nx bit primitives (skips the fused kernel), or (b) an Emily-specific
25-
compiler variant that recognizes a sentinel `Expr` node. Both are
26-
meaningful scope; M10.5 will tackle one of them.
27-
28-
Use `quantized_matmul/2` below for any eager quantized inference today.
5+
Two entry points:
6+
7+
* `quantized_matmul/2` — eager-mode fused kernel (materialized
8+
tensors only). Extracts refs from a `%QuantizedWeight{}` and
9+
calls `Native.quantized_matmul/7`.
10+
* `dequantize_defn/1` — defn-native analogue of
11+
`QuantizedWeight.to_dense/1`, composed from `Nx.right_shift` /
12+
`Nx.bitwise_and` / multiply / add. Use inside `Nx.Defn.jit`-traced
13+
Axon forward passes; `Nx.dot(x, dequantize_defn(qw))` replaces
14+
the fused `quantized_matmul` kernel with two dispatches — M11's
15+
fast-kernel work closes that gap.
16+
17+
`dequantize_defn/1` supports `bits ∈ #{inspect([2, 4, 8])}`;
18+
`bits ∈ {3, 6}` use cross-u32 lane packing (out of scope here — use
19+
`QuantizedWeight.to_dense/1`).
20+
21+
See `Emily.Quantization.Layers.quantized_dense/4` for the
22+
Axon-compatible layer op built on `dequantize_defn/1`.
2923
"""
3024

25+
import Nx.Defn
26+
3127
alias Emily.Backend, as: B
3228
alias Emily.Native
3329
alias Emily.QuantizedWeight
3430
alias Nx.Tensor, as: T
3531

32+
@defn_supported_bits [2, 4, 8]
33+
34+
@doc """
35+
Bit widths supported by `dequantize_defn/1` (and therefore by
36+
`Emily.Quantization.Layers.quantized_dense/4` /
37+
`Emily.Quantization.Transform`).
38+
39+
`bits ∈ {3, 6}` use cross-u32 lane packing and aren't supported by
40+
the defn-native path; `QuantizedWeight.to_dense/1` (the Native path)
41+
still handles them.
42+
"""
43+
@spec defn_supported_bits() :: [pos_integer()]
44+
def defn_supported_bits, do: @defn_supported_bits
45+
3646
@doc """
3747
Compute `x @ W^T` where `W` is represented as a `QuantizedWeight`.
3848
@@ -96,4 +106,105 @@ defmodule Emily.Quantization do
96106
"match scales dtype #{inspect(s_type)}. Cast the input with " <>
97107
"`Nx.as_type/2` before calling."
98108
end
109+
110+
# ================================================================
111+
# Defn-native dequantize
112+
# ================================================================
113+
114+
@doc """
115+
Reconstruct a dense tensor from a `QuantizedWeight`, built entirely
116+
from Nx primitives so it composes inside `defn` traces.
117+
118+
This is the defn-compatible analogue of `QuantizedWeight.to_dense/1`.
119+
The math is identical to MLX's `dequantize`:
120+
121+
w[i] = (w_q_packed >> ((i mod lpu) * bits)) & mask * scales[g] + biases[g]
122+
123+
where `lpu = div(32, bits)` (lanes per u32), `mask = (1 <<< bits) - 1`,
124+
and `g = div(i, group_size)` is the group index along the last axis.
125+
126+
Supported: `bits ∈ #{inspect(@defn_supported_bits)}`. `bits ∈ {3, 6}`
127+
pack across u32 boundaries and are out of scope here.
128+
129+
## Example
130+
131+
qw = Emily.QuantizedWeight.from_dense(w, group_size: 64, bits: 4)
132+
dense_defn = Emily.Quantization.dequantize_defn(qw)
133+
dense_native = Emily.QuantizedWeight.to_dense(qw)
134+
# element-wise identical
135+
"""
136+
@spec dequantize_defn(QuantizedWeight.t()) :: Nx.Tensor.t()
137+
deftransform dequantize_defn(qw) do
138+
%QuantizedWeight{
139+
value: q,
140+
scales: s,
141+
biases: b,
142+
group_size: group_size,
143+
bits: bits
144+
} = qw
145+
146+
validate_defn_bits!(bits)
147+
148+
dequantize_impl(q, s, b, group_size: group_size, bits: bits)
149+
end
150+
151+
defp validate_defn_bits!(bits) when bits in @defn_supported_bits, do: :ok
152+
153+
defp validate_defn_bits!(bits) do
154+
raise ArgumentError,
155+
"Emily.Quantization.dequantize_defn/1: bits=#{bits} uses cross-u32 " <>
156+
"lane packing, which is out of scope for the defn-native path. " <>
157+
"Supported: #{inspect(@defn_supported_bits)}. Use " <>
158+
"`Emily.QuantizedWeight.to_dense/1` (the Native path) for unsupported " <>
159+
"bit widths."
160+
end
161+
162+
# Expects `opts` to carry compile-time `:group_size` and `:bits`. Both
163+
# are used for shape arithmetic (lanes-per-u32, per-group reshape) so
164+
# they must be trace-time constants.
165+
defnp dequantize_impl(w_q, scales, biases, opts \\ []) do
166+
opts = keyword!(opts, [:group_size, :bits])
167+
group_size = opts[:group_size]
168+
bits = opts[:bits]
169+
170+
# Unpack: (..., packed) → (..., packed, lpu) via broadcast-shift
171+
# with a length-lpu shift vector, then mask to `bits`-width nibbles.
172+
shifts = build_shifts(bits)
173+
mask = build_mask(bits)
174+
175+
# new_axis appends a length-1 axis; right_shift broadcasts against shifts.
176+
shifted = Nx.right_shift(Nx.new_axis(w_q, -1), shifts)
177+
masked = Nx.bitwise_and(shifted, mask)
178+
179+
# Flatten (packed, lpu) → orig_last, then regroup to (..., groups,
180+
# group_size) so per-group scale/bias broadcast trivially.
181+
grouped = masked |> Nx.flatten(axes: [-2, -1]) |> group_last_axis(group_size)
182+
183+
# Cast u32 → scales dtype, then per-group affine recombine; flatten
184+
# back to (..., orig_last).
185+
grouped_f = Nx.as_type(grouped, Nx.type(scales))
186+
dequantized = grouped_f * Nx.new_axis(scales, -1) + Nx.new_axis(biases, -1)
187+
188+
Nx.flatten(dequantized, axes: [-2, -1])
189+
end
190+
191+
deftransformp build_shifts(bits) do
192+
lpu = div(32, bits)
193+
shifts = for i <- 0..(lpu - 1), do: i * bits
194+
Nx.tensor(shifts, type: :u32)
195+
end
196+
197+
deftransformp build_mask(bits) do
198+
import Bitwise
199+
Nx.tensor((1 <<< bits) - 1, type: :u32)
200+
end
201+
202+
# Reshape `(..., n)` → `(..., n / group_size, group_size)`. Uses
203+
# `:auto` so we don't recompute the quotient.
204+
deftransformp group_last_axis(t, group_size) do
205+
shape = Nx.shape(t)
206+
rank = tuple_size(shape)
207+
new_shape = shape |> put_elem(rank - 1, :auto) |> Tuple.insert_at(rank, group_size)
208+
Nx.reshape(t, new_shape)
209+
end
99210
end

lib/emily/quantization/layers.ex

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
defmodule Emily.Quantization.Layers do
2+
@moduledoc """
3+
Defn-traceable quantized layer op for use inside Axon graphs.
4+
5+
`quantized_dense/4` is the drop-in replacement for `Axon.Layers.dense/4`
6+
on a `%Emily.QuantizedWeight{}` kernel. See `Emily.Quantization` for
7+
the defn-integration trade-offs and
8+
`Emily.Quantization.Transform.quantize_dense_layers/2` for the
9+
graph-rewrite that wires it in.
10+
"""
11+
12+
import Nx.Defn
13+
14+
alias Emily.QuantizedWeight
15+
16+
@doc """
17+
Axon layer op: `x @ W (+ bias)` where `W` is a `%QuantizedWeight{}`.
18+
19+
Mirrors the signature of `Axon.Quantization.Layers.weight_only_quantized_dense/4`:
20+
21+
* `input` — activation tensor, shape `(..., in)`.
22+
* `kernel` — `%QuantizedWeight{}`. The stored layout is determined
23+
by `kernel.transpose`:
24+
* `transpose: false` (the AWQ / Axon-native layout) — packed
25+
representation of a `[in, out]` weight; the layer computes
26+
`Nx.dot(x, dense)`.
27+
* `transpose: true` (the MLX / PyTorch-native layout, i.e. fresh
28+
output of `QuantizedWeight.from_dense/2` on a `[out, in]`
29+
weight) — packed representation of a `[out, in]` weight; the
30+
layer computes `Nx.dot(x, Nx.transpose(dense))`.
31+
* `bias` — either an `Nx.Tensor`, a number, or a keyword list (in
32+
which case it's treated as `opts` and bias defaults to 0). Matches
33+
`Axon.Quantization.Layers.weight_only_quantized_dense/4`'s
34+
signature for drop-in use under `Axon.layer/3`.
35+
* `opts` — reserved for Axon-layer metadata; not used by this
36+
implementation directly (all state lives on the
37+
`%QuantizedWeight{}`).
38+
"""
39+
deftransform quantized_dense(input, kernel, bias \\ 0, opts \\ []) do
40+
# When Axon.dense registers `use_bias: false`, the generated op call
41+
# is arity-3 with layer opts as the third arg (matches
42+
# `Axon.Quantization.Layers.weight_only_quantized_dense/4`'s contract).
43+
{bias, opts} =
44+
case bias do
45+
b when is_list(b) -> {Nx.tensor(0), Keyword.merge(opts, b)}
46+
b -> {b, opts}
47+
end
48+
49+
%QuantizedWeight{transpose: transpose} = kernel
50+
opts = Keyword.put(opts, :transpose, transpose)
51+
quantized_dense_impl(input, kernel, bias, opts)
52+
end
53+
54+
# `transpose` is threaded through `opts` as a compile-time constant so
55+
# the branch selects at trace time (no runtime `if` over booleans).
56+
defnp quantized_dense_impl(x, kernel, bias, opts \\ []) do
57+
# `:mode` is injected by Axon's compiler (`:inference` / `:train`)
58+
# for every layer op; accept-and-ignore here since weight-only
59+
# quantization has no mode-dependent behavior.
60+
opts = keyword!(opts, [:transpose, mode: :inference])
61+
dense = Emily.Quantization.dequantize_defn(kernel)
62+
63+
y =
64+
if opts[:transpose] do
65+
Nx.dot(x, Nx.transpose(dense))
66+
else
67+
Nx.dot(x, dense)
68+
end
69+
70+
Nx.add(y, bias)
71+
end
72+
end

lib/emily/quantized_weight.ex

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ defmodule Emily.QuantizedWeight do
3030
tensor — so the direct-call helper is the supported path.
3131
3232
> #### Defn-traced Axon forward passes {: .info}
33-
> Routing through `Nx.Defn.jit`-traced Axon models (and therefore
34-
> Bumblebee AWQ checkpoints) is not yet shipped; the layer-op dispatch
35-
> path requires either a defn-native dequantize or an
36-
> `Nx.Defn.Evaluator`-level custom-op hook. Tracked as M10.5 in
37-
> `PLAN.md`. Use `quantized_matmul/2` directly on materialized tensors
38-
> today.
33+
> `Emily.Quantization.dequantize_defn/1` (M10.5) is the defn-native
34+
> analogue of `to_dense/1`; pair it with
35+
> `Emily.Quantization.Layers.quantized_dense/4` to splice a quantized
36+
> linear into any `Nx.Defn.jit`-traced Axon forward pass. The layer
37+
> performs `Nx.dot(x, dequantize_defn(qw))` instead of MLX's single
38+
> fused `quantized_matmul` — two kernels vs. one, but fully
39+
> integrated with the rest of Bumblebee's defn graph. M11's fast-kernel
40+
> work will close the perf gap.
3941
"""
4042

4143
@derive {Nx.Container,

0 commit comments

Comments
 (0)