Skip to content

Commit ae2f34c

Browse files
committed
M10 (partial): quantized inference primitives
Binds MLX's affine int4/int8 group-wise quantization at the Native and Elixir levels plus a direct-call helper for eager use. Scope narrowed vs. PLAN.md's original M10 — the Axon-layer dispatch, Bumblebee AWQ conformance, and Backend.dot/7 routing are deferred to M10.5 because `Nx.Defn.Evaluator` has no public hook to inject a custom op that isn't an `Nx.Backend` callback, and `Nx.dot/2` collapses multi-tensor containers before reaching the backend. See RELEASE.md and PLAN.md §M10.5 for the three candidate defn- integration approaches. - Native NIFs (c_src/ops/linalg.cpp): quantize/3 returning a 3-tuple {w_q, scales, biases}, dequantize/5, quantized_matmul/7 with explicit transpose (AWQ layouts need transpose=false). - Emily.QuantizedWeight — Nx.Container-derived struct with keep: so scalar metadata (group_size, bits, transpose) survives container traversal. Validates rank, last-axis divisibility, dtype, bit count before dispatch. - Emily.Quantization.quantized_matmul/2 — direct-call helper for eager/benchmark use over materialized tensors. - Tests: +7 cases in test/emily/native_test.exs, 13 cases across test/emily/quantization/, and a 1k-iter memory soak in test/soak/quantized_memory_test.exs asserting packed-weight footprint invariants. mix precommit: 238 tests, 0 failures, credo clean.
1 parent ea09d6a commit ae2f34c

10 files changed

Lines changed: 1008 additions & 39 deletions

File tree

PLAN.md

Lines changed: 107 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ and back. Lift to native MLX:
277277
- `gather``mlx::core::gather`
278278

279279
Window reductions stay on `via_binary` in M9 — pool-based conv
280-
training is scoped to M10.
280+
training is scoped to M17.
281281

282282
**Testing — Layers 4 (Grad) and 5 (Training):**
283283

@@ -341,47 +341,116 @@ user-visible value, not difficulty. Headline rationale:
341341
without hand-holding.
342342
5. **1.0 release** (M22) ships the result.
343343

344-
### M10 — Quantized inference
344+
### M10 — Quantized inference primitives (partial)
345345

346346
Quantization is the single largest gap between Emily and "actually run
347347
Qwen3 on a 16 GB MacBook". MLX ships native int4/int8 affine
348348
quantization (`mx::quantize`, `mx::dequantize`,
349-
`mx::quantized_matmul`) and Bumblebee can already load quantized
350-
checkpoints — Emily just silently can't consume them today. Without
351-
this, the library's headline value proposition only works for users
352-
with enough RAM for fp16 weights.
353-
354-
- **Native bindings**: `Native.quantize/3`, `Native.dequantize/4`,
355-
`Native.quantized_matmul/6` over the MLX C++ functions. Pass group
356-
size and bit-width as integers; default to MLX's `group_size=64,
357-
bits=4` but expose overrides.
358-
- **Backend routing**: detect quantized operand structs at the Nx
359-
layer (Bumblebee tags them in the parameter map) and dispatch the
360-
matmul callback to `Native.quantized_matmul` rather than
361-
`Native.matmul`. Non-matmul ops (norms, residual adds, embeddings)
362-
stay on the existing fp16/bf16 fast paths — only the linear
363-
projection is quantized.
364-
- **Memory accounting**: quantized inference is allocator-pattern
365-
different from fp16 — packed weights load once and never
366-
re-quantize. Add a soak case asserting peak memory matches the
367-
expected packed-weight footprint within ~10%.
368-
369-
**Testing**:
370-
- Native unit tests with hand-computed packed-weight expected values
371-
at small group sizes so the bit-packing is checkable.
372-
- Backend property test: `quantize → dequantize → matmul` against
373-
`Nx.BinaryBackend` matmul on the dequantized weights, asserting
374-
agreement within the documented quantization-error bound.
375-
- Conformance: add `Qwen/Qwen3-0.6B-AWQ` (or the MLX-community
376-
quantized variant) as `:qwen3_quant_full`. Greedy-decode the same
377-
prompt as `qwen3_full` and assert the completion matches a
378-
checked-in reference produced by MLX's own Python bindings on the
379-
same quantized weights — *not* by the f16 model, because the whole
380-
point is to catch quantization-specific drift.
381-
382-
**Exit:** Qwen3-0.6B-AWQ greedy-decodes end-to-end on Emily; Backend
383-
property tests green; quantized soak harness asserts allocator
384-
invariants.
349+
`mx::quantized_matmul`); M10 binds it at the Native and Elixir levels
350+
and ships a direct-call helper for eager use. The Bumblebee-integrated
351+
conformance path is split out to M10.5 — see **Scope note** below.
352+
353+
**Shipped**:
354+
355+
- **Native bindings**: `Native.quantize/3`, `Native.dequantize/5`,
356+
`Native.quantized_matmul/7` over the MLX C++ functions. `quantize`
357+
returns a 3-tuple `{w_q, scales, biases}`. `quantized_matmul/7` takes
358+
`transpose` as an explicit boolean rather than PLAN's original `/6`:
359+
AWQ packed layouts need `transpose=false` while fresh-from-dense
360+
weights use `transpose=true`, and MLX exposes it as a required
361+
parameter.
362+
- **`Emily.QuantizedWeight`** (`lib/emily/quantized_weight.ex`) —
363+
`Nx.Container`-derived struct with `{value, scales, biases,
364+
group_size, bits, transpose}`. Scalar metadata survives container
365+
traversal via `Nx.Container`'s `keep:` option. `from_dense/2`
366+
validates rank, last-axis divisibility, dtype, and bit count.
367+
- **`Emily.Quantization.quantized_matmul/2`** — direct-call helper that
368+
extracts refs from an input tensor and a `%QuantizedWeight{}` and
369+
dispatches the fused kernel. Intended for eager/benchmark use and as
370+
the substrate for M10.5's defn-integration path.
371+
- **Memory soak** (`test/soak/quantized_memory_test.exs`) — 1000-iter
372+
quantized-matmul loop asserts active memory returns within 4 MB of
373+
baseline after `Native.clear_cache/0`. Kept separate from the fp16
374+
memory soak because quantized inference is allocator-pattern
375+
different: packed weights load once and never re-quantize.
376+
- **Native unit tests + Backend property tests** — see
377+
`test/emily/native_test.exs` (+7 cases) and
378+
`test/emily/quantization/` (two new files). Round-trip `quantize →
379+
dequantize` and `quantized_matmul` vs. `matmul(x, dequantize(…))`
380+
oracles for both `transpose=true` and `transpose=false` layouts.
381+
382+
**Scope note — why no Backend routing / Axon integration / conformance
383+
test in M10**:
384+
385+
- **`Backend.dot/7` dispatch doesn't work.** PLAN'd approach was
386+
"detect quantized operand structs at the Nx layer (Bumblebee tags
387+
them in the parameter map) and dispatch the matmul callback to
388+
`Native.quantized_matmul`". But `Nx.dot/2` calls
389+
`Nx.LazyContainer.traverse/3` expecting a single `%T{}`; a
390+
three-tensor `%QuantizedWeight{}` container raises before reaching
391+
`Backend.dot/7`.
392+
- **Axon layer-op dispatch doesn't work either.** `Axon.layer` ops run
393+
at `Nx.Defn.jit` trace time with `Nx.Defn.Expr` inputs;
394+
`Nx.Defn.Evaluator` walks those expressions dispatching `Nx.Backend`
395+
callbacks with materialized refs. There is no public hook to inject
396+
a custom op like `Native.quantized_matmul` that isn't already a
397+
`Nx.Backend` callback, and `deftransform` / `hook` / metadata all
398+
run at trace time (no refs available).
399+
- **Bumblebee has no AWQ loader yet.** The exploration for M10
400+
confirmed `deps/bumblebee` has zero quantization-loading code (no
401+
AWQ, GPTQ, MLX-format paths). PLAN.md's "Bumblebee can already load
402+
quantized checkpoints" is aspirational.
403+
404+
All three of these are meaningful scope. M10 ships the substrate they
405+
all need; M10.5 picks the defn-integration strategy and ships the
406+
conformance test.
407+
408+
**Exit**: Native NIFs green under unit + property tests; QuantizedWeight
409+
container property tests green; direct-call helper green under oracle
410+
comparison; quantized memory soak clean.
411+
412+
### M10.5 — Bumblebee quantized inference integration
413+
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.
385454

386455
### M11 — `mlx::fast::*` fused kernels
387456

RELEASE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,86 @@
22

33
## Added
44

5+
- M10 (partial) — Quantized inference primitives. Exposes MLX's affine
6+
int4/int8 group-wise quantization at the Native and Elixir levels, plus
7+
a direct-call helper for eager use. Enough to quantize a dense weight,
8+
store it packed at rest, and dispatch a fused quantized matmul against
9+
it in plain Elixir code.
10+
- **Native `Native.quantize/3`, `Native.dequantize/5`,
11+
`Native.quantized_matmul/7`** (`c_src/ops/linalg.cpp`) over
12+
`mx::quantize`, `mx::dequantize`, `mx::quantized_matmul`. `quantize`
13+
returns a 3-tuple `{w_q, scales, biases}` — first NIF in the tree
14+
returning a tuple of resources. `quantized_matmul` takes the
15+
`transpose` flag as an explicit arg rather than the PLAN-spec'd `/6`
16+
signature: AWQ packed layouts need `transpose=false` while
17+
fresh-from-dense weights use `transpose=true`, and MLX exposes the
18+
flag as a required parameter on its C++ API.
19+
- **`Emily.QuantizedWeight`** (`lib/emily/quantized_weight.ex`) —
20+
`Nx.Container`-derived struct bundling `{value, scales, biases}`
21+
with `{group_size, bits, transpose}` metadata (the latter flagged
22+
`keep:` so the scalars survive container traversal — defn trace,
23+
`backend_transfer`, parameter-map walks). `from_dense/2` validates
24+
rank ≥ 2, last-axis divisibility, dtype ∈ {f16, bf16, f32}, and bits
25+
∈ {2,3,4,6,8} before dispatch. `to_dense/1` inverses via
26+
`Native.dequantize`.
27+
- **`Emily.Quantization.quantized_matmul/2`**
28+
(`lib/emily/quantization.ex`) — direct-call helper over
29+
materialized tensors. Extracts refs from the input `%T{}` and the
30+
three tensors inside a `%QuantizedWeight{}`, dispatches to the
31+
Native NIF, and rewraps the result. Input dtype must match
32+
`qw.scales.type` (raises with a targeted error otherwise);
33+
BinaryBackend inputs are transferred transparently. Used by the
34+
soak and property tests today; the fused-kernel perf win is
35+
available here but not yet from defn-traced forward passes (see
36+
below).
37+
- **`test/emily/native_test.exs`** (+7 cases) — hand-computed
38+
quantize-shape checks (`group_size=64, bits=4` → 8 u32/row; bits=8
39+
→ 16 u32/row; smaller group_size → more scale/bias rows),
40+
`dequantize` round-trip within int4 step tolerance, validation of
41+
indivisible last axes, and `quantized_matmul` equivalence vs.
42+
`matmul(x, dequantize(…))` for both `transpose=true` and
43+
`transpose=false` layouts.
44+
- **`test/emily/quantization/quantized_weight_test.exs`** (new) —
45+
`from_dense/2` metadata/shape/dtype assertions, validation-raise
46+
cases (indivisible axis, unsupported bits, unsupported dtype,
47+
rank < 2), bits=8-tighter-than-bits=4 property, and an
48+
`Nx.Container` traversal test confirming `keep:` preserves
49+
`group_size`/`bits`/`transpose` across `backend_transfer`. Property
50+
test: random group-shaped weights round-trip `from_dense |>
51+
to_dense` within a 0.15 tolerance band.
52+
- **`test/emily/quantization/quantized_matmul_test.exs`** (new) —
53+
property test for `transpose=true` against a
54+
`Nx.dot(x, Nx.transpose(to_dense(qw)))` oracle on BinaryBackend;
55+
explicit `transpose=false` case asserting `Nx.dot(x, to_dense(qw))`
56+
convention; dtype-mismatch raise test; BinaryBackend-input
57+
auto-transfer test.
58+
- **`test/soak/quantized_memory_test.exs`** (`@moduletag :soak`,
59+
default suite) — 1000-iteration quantized-matmul soak asserting
60+
active memory returns within 4 MB of baseline after `clear_cache/0`.
61+
Separate from `memory_test.exs` because quantized inference is
62+
allocator-pattern different from fp16: packed weights load once
63+
(not re-quantized per call), and the per-iter activation/output
64+
budget is smaller.
65+
- **Scope narrowed from PLAN.md.** PLAN spec'd a `Backend.dot/7`
66+
dispatch path that inspects the operand struct, plus an Axon
67+
layer-replacement (`Emily.Quantization.Layers.quantized_dense` +
68+
`Emily.Quantization.quantize/2`) and a `Qwen/Qwen3-0.6B-AWQ`
69+
conformance test. Investigation uncovered that `Nx.dot/2` calls
70+
`Nx.LazyContainer.traverse/3` expecting a single `%T{}` — a
71+
three-tensor `%QuantizedWeight{}` container raises before reaching
72+
`Backend.dot/7`. The alternative — an Axon layer op calling
73+
`Native.quantized_matmul` during forward pass — fails for a
74+
different reason: Axon layer ops run at `Nx.Defn.jit` trace time
75+
with `Nx.Defn.Expr` inputs, and `Nx.Defn.Evaluator` has no public
76+
hook to inject a custom op that isn't a `Nx.Backend` callback.
77+
Closing the gap requires either (a) a defn-native dequantize built
78+
from Nx bit primitives (loses the fused kernel), or (b) an
79+
Emily-specific `Nx.Defn.Compiler` variant that recognizes a
80+
sentinel `Expr` node and routes to `Native.quantized_matmul`. Both
81+
are meaningful scope; tracked as M10.5. The Native + container +
82+
direct-call helper surface shipped in M10 is the substrate that
83+
either of those approaches will build on.
84+
585
- M9 — Gradient conformance and training primitives. Makes
686
`Nx.Defn.grad` usable on Emily by lifting the three ops that grad
787
lands on most heavily off the `via_binary` fallback, and adds the

c_src/ops/linalg.cpp

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
// Linear algebra: matmul, tensordot, outer, inner.
1+
// Linear algebra: matmul, tensordot, outer, inner, and affine int4/int8
2+
// quantization primitives (quantize / dequantize / quantized_matmul).
23

34
#include "../emily/tensor.hpp"
45

56
#include <fine.hpp>
67
#include <mlx/mlx.h>
78

89
#include <cstdint>
10+
#include <tuple>
911
#include <vector>
1012

1113
namespace mx = mlx::core;
@@ -51,4 +53,71 @@ fine::ResourcePtr<Tensor> inner(
5153
}
5254
FINE_NIF(inner, 0);
5355

56+
// Affine quantization along the last axis.
57+
//
58+
// Returns {w_q, scales, biases}:
59+
// - w_q is packed uint32 with (last_dim * bits / 32) elements per row
60+
// - scales and biases share shape (..., last_dim / group_size), dtype
61+
// matching the input.
62+
//
63+
// MLX requires last_dim % group_size == 0; bits ∈ {2, 3, 4, 6, 8}.
64+
std::tuple<fine::ResourcePtr<Tensor>,
65+
fine::ResourcePtr<Tensor>,
66+
fine::ResourcePtr<Tensor>>
67+
quantize(
68+
ErlNifEnv *,
69+
fine::ResourcePtr<Tensor> w,
70+
int64_t group_size,
71+
int64_t bits) {
72+
auto triple = mx::quantize(
73+
w->array, static_cast<int>(group_size), static_cast<int>(bits));
74+
return std::make_tuple(
75+
wrap(std::move(std::get<0>(triple))),
76+
wrap(std::move(std::get<1>(triple))),
77+
wrap(std::move(std::get<2>(triple))));
78+
}
79+
FINE_NIF(quantize, 0);
80+
81+
// Inverse of quantize. Reconstructs a dense tensor from packed w_q plus
82+
// per-group scales and biases.
83+
fine::ResourcePtr<Tensor> dequantize(
84+
ErlNifEnv *,
85+
fine::ResourcePtr<Tensor> w_q,
86+
fine::ResourcePtr<Tensor> scales,
87+
fine::ResourcePtr<Tensor> biases,
88+
int64_t group_size,
89+
int64_t bits) {
90+
return wrap(mx::dequantize(
91+
w_q->array,
92+
scales->array,
93+
biases->array,
94+
static_cast<int>(group_size),
95+
static_cast<int>(bits)));
96+
}
97+
FINE_NIF(dequantize, 0);
98+
99+
// Matmul against a quantized weight. `transpose` is wired through
100+
// explicitly because AWQ-style packed checkpoints ship in a different
101+
// layout than freshly-quantized weights, and MLX's kernel selection
102+
// depends on the flag.
103+
fine::ResourcePtr<Tensor> quantized_matmul(
104+
ErlNifEnv *,
105+
fine::ResourcePtr<Tensor> x,
106+
fine::ResourcePtr<Tensor> w_q,
107+
fine::ResourcePtr<Tensor> scales,
108+
fine::ResourcePtr<Tensor> biases,
109+
bool transpose,
110+
int64_t group_size,
111+
int64_t bits) {
112+
return wrap(mx::quantized_matmul(
113+
x->array,
114+
w_q->array,
115+
scales->array,
116+
biases->array,
117+
transpose,
118+
static_cast<int>(group_size),
119+
static_cast<int>(bits)));
120+
}
121+
FINE_NIF(quantized_matmul, 0);
122+
54123
} // namespace

lib/emily/native.ex

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,36 @@ defmodule Emily.Native do
244244
@spec inner(tensor(), tensor()) :: tensor()
245245
def inner(_a, _b), do: nif()
246246

247+
# --- Quantization ------------------------------------------------
248+
249+
@spec quantize(tensor(), integer(), integer()) ::
250+
{tensor(), tensor(), tensor()}
251+
def quantize(_w, _group_size, _bits), do: nif()
252+
253+
@spec dequantize(tensor(), tensor(), tensor(), integer(), integer()) ::
254+
tensor()
255+
def dequantize(_w_q, _scales, _biases, _group_size, _bits), do: nif()
256+
257+
@spec quantized_matmul(
258+
tensor(),
259+
tensor(),
260+
tensor(),
261+
tensor(),
262+
boolean(),
263+
integer(),
264+
integer()
265+
) :: tensor()
266+
def quantized_matmul(
267+
_x,
268+
_w_q,
269+
_scales,
270+
_biases,
271+
_transpose,
272+
_group_size,
273+
_bits
274+
),
275+
do: nif()
276+
247277
# --- Sort --------------------------------------------------------
248278

249279
@spec sort(tensor(), integer()) :: tensor()

0 commit comments

Comments
 (0)