Skip to content

Commit d5a35db

Browse files
authored
Merge pull request #200 from ausimian/perf/fused-quantized-matmul
perf: fuse quantized_dense onto mx::quantized_matmul kernel
2 parents 3515c86 + 61b36f4 commit d5a35db

5 files changed

Lines changed: 287 additions & 28 deletions

File tree

RELEASE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
### Changed
2+
3+
- Quantized dense layers now use the fused `mx::quantized_matmul` kernel
4+
instead of dequantizing the full weight to bf16 and running a dense
5+
matmul. The packed low-bit weights are streamed directly, so a decode
6+
step no longer re-dequantizes the entire model on every token. On a
7+
4-bit Qwen3-0.6B this makes native quantized generation roughly 13×
8+
faster end-to-end — and quantized inference is now *faster* than dense,
9+
as it should be, rather than slower. Non-Emily backends keep the
10+
composed dequantize + `Nx.dot` fallback.

bench/qmm_microbench.exs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Micro-benchmark: fused quantized_matmul (mx::quantized_matmul) vs the
2+
# current quantized_dense path (dequantize_defn + Nx.dot), on GPU.
3+
# No Bumblebee / model download needed. Single-token (batch=1) decode-shaped.
4+
#
5+
# mix run bench/qmm_microbench.exs
6+
alias Emily.Quantization
7+
alias Emily.Quantization.Layers
8+
alias Emily.QuantizedWeight
9+
10+
Nx.default_backend(Emily.Backend)
11+
12+
native = [compiler: Emily.Compiler, native: true]
13+
14+
dtype = :bf16
15+
group_size = 64
16+
bits = 4
17+
warmup = 100
18+
iters = 2000
19+
20+
# Qwen3-0.6B-shaped projections. Weight is [out, in] (transpose: true, the
21+
# from_dense default); activation is [1, in] (one decode token).
22+
shapes = [
23+
{"q_proj [2048,1024]", 2048, 1024},
24+
{"kv_proj [1024,1024]", 1024, 1024},
25+
{"o_proj [1024,2048]", 1024, 2048},
26+
{"mlp_up [3072,1024]", 3072, 1024},
27+
{"mlp_dn [1024,3072]", 1024, 3072}
28+
]
29+
30+
# Force a full worker sync on the result (native :sync already blocks on
31+
# mx::eval, but realizing the bytes is belt-and-suspenders).
32+
sync = fn t -> Nx.to_binary(t) end
33+
34+
time_fn = fn compiled, x, qw ->
35+
Enum.each(1..warmup, fn _ -> compiled.(x, qw) end)
36+
sync.(compiled.(x, qw))
37+
t0 = System.monotonic_time(:microsecond)
38+
Enum.each(1..iters, fn _ -> compiled.(x, qw) end)
39+
sync.(compiled.(x, qw))
40+
t1 = System.monotonic_time(:microsecond)
41+
iters / ((t1 - t0) / 1_000_000)
42+
end
43+
44+
IO.puts("dtype=#{dtype} group_size=#{group_size} bits=#{bits} warmup=#{warmup} iters=#{iters}\n")
45+
IO.puts(" before = old quantized_dense (dequantize_defn + Nx.dot)")
46+
IO.puts(" after = quantized_dense now (#197: fused mx::quantized_matmul)\n")
47+
48+
IO.puts(String.pad_trailing("shape", 22) <> " before(it/s) after(it/s) speedup maxΔ")
49+
IO.puts(String.duplicate("-", 68))
50+
51+
for {label, out_f, in_f} <- shapes do
52+
{w, _} = Nx.Random.normal(Nx.Random.key(0), shape: {out_f, in_f}, type: dtype)
53+
qw = QuantizedWeight.from_dense(w, group_size: group_size, bits: bits)
54+
{x, _} = Nx.Random.normal(Nx.Random.key(1), shape: {1, in_f}, type: dtype)
55+
56+
# Pass the QuantizedWeight (an Nx.Container) as a jit ARGUMENT, not a
57+
# closure — its tensors become Expr params and its keep-metadata
58+
# (group_size/bits/transpose/mode) stays available at trace time. This
59+
# mirrors how Bumblebee threads quantized model params into the forward.
60+
#
61+
# `before` = the old layer body (dequantize the full bf16 weight, then
62+
# dense Nx.dot). `after` = the shipped layer, which now lowers to the
63+
# fused mx::quantized_matmul kernel.
64+
before = fn x, qw -> Nx.dot(x, Nx.transpose(Quantization.dequantize_defn(qw))) end
65+
after_fn = fn x, qw -> Layers.quantized_dense(x, qw) end
66+
67+
before_compiled = Nx.Defn.jit(before, native)
68+
after_compiled = Nx.Defn.jit(after_fn, native)
69+
70+
# correctness: after (fused) vs before (dequant), same math up to fp reorder
71+
b = before_compiled.(x, qw)
72+
a = after_compiled.(x, qw)
73+
max_delta = Nx.subtract(b, a) |> Nx.abs() |> Nx.reduce_max() |> Nx.to_number()
74+
75+
before_rate = time_fn.(before_compiled, x, qw)
76+
after_rate = time_fn.(after_compiled, x, qw)
77+
78+
IO.puts(
79+
String.pad_trailing(label, 22) <>
80+
" " <>
81+
String.pad_trailing(:erlang.float_to_binary(before_rate, decimals: 0), 12) <>
82+
" " <>
83+
String.pad_trailing(:erlang.float_to_binary(after_rate, decimals: 0), 11) <>
84+
" " <>
85+
String.pad_trailing(
86+
:erlang.float_to_binary(after_rate / before_rate, decimals: 2) <> "x",
87+
7
88+
) <>
89+
" " <> :erlang.float_to_binary(max_delta, decimals: 4)
90+
)
91+
end

bench/qwen3_quantized_tps.exs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# End-to-end quantized Qwen3-0.6B greedy-decode throughput on Emily's
2+
# native lane. Loads the dense model, rewrites every dense layer to
3+
# `Emily.Quantization.Layers.quantized_dense/4` + quantizes the params
4+
# (affine 4-bit) via `Emily.Quantization.Transform`, then measures
5+
# tokens/sec through a compiled `Bumblebee.Text.generation` serving.
6+
#
7+
# Run in TEST env so `Emily.Quantization.Transform` (test/support) is on
8+
# the compile path, and in-project (NOT Mix.install) so it exercises the
9+
# LOCAL build:
10+
#
11+
# MIX_ENV=test mix run bench/qwen3_quantized_tps.exs
12+
#
13+
# Optional env: EMILY_BENCH_MODEL, EMILY_BENCH_NEW_TOKENS (64),
14+
# EMILY_BENCH_WARMUP (1), EMILY_BENCH_RUNS (3), EMILY_BENCH_GROUP_SIZE (64).
15+
#
16+
# Greedy decode is deterministic, so before/after (dequant+dot vs fused)
17+
# generate the identical token sequence — the wall-clock RATIO is exact
18+
# even if generation stops before max_new_tokens.
19+
20+
Nx.global_default_backend(Emily.Backend)
21+
22+
env_int = fn name, default ->
23+
case System.get_env(name) do
24+
nil -> default
25+
s -> case Integer.parse(s), do: ({n, _} -> n; _ -> default)
26+
end
27+
end
28+
29+
repo = System.get_env("EMILY_BENCH_MODEL", "Qwen/Qwen3-0.6B")
30+
prompt = System.get_env("EMILY_BENCH_PROMPT", "The quick brown fox jumps over the lazy dog.")
31+
new_tokens = env_int.("EMILY_BENCH_NEW_TOKENS", 64)
32+
warmup = env_int.("EMILY_BENCH_WARMUP", 1)
33+
runs = env_int.("EMILY_BENCH_RUNS", 3)
34+
group_size = env_int.("EMILY_BENCH_GROUP_SIZE", 64)
35+
36+
IO.puts("Emily / Qwen3 QUANTIZED (affine 4-bit, group_size=#{group_size}) throughput")
37+
IO.puts(" model : #{repo}")
38+
IO.puts(" new tokens : #{new_tokens} warmup: #{warmup} runs: #{runs}")
39+
IO.puts(" lane : native (Emily.Compiler, native: true, native_fallback: :raise)\n")
40+
41+
{:ok, model_info} = Bumblebee.load_model({:hf, repo})
42+
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, repo})
43+
{:ok, gen_config} = Bumblebee.load_generation_config({:hf, repo})
44+
45+
gen_config =
46+
Bumblebee.configure(gen_config,
47+
max_new_tokens: new_tokens,
48+
strategy: %{type: :greedy_search}
49+
)
50+
51+
# EMILY_BENCH_QUANTIZE=0 skips quantization → a dense-native calibration
52+
# lane (anchors what "as fast as it should be" looks like on this host).
53+
quantize? = System.get_env("EMILY_BENCH_QUANTIZE", "1") != "0"
54+
55+
serving_model_info =
56+
if quantize? do
57+
IO.puts("Quantizing dense layers -> quantized_dense (%QuantizedWeight{})…")
58+
59+
{qmodel, qparams} =
60+
Emily.Quantization.Transform.quantize(model_info.model, model_info.params,
61+
bits: 4,
62+
group_size: group_size,
63+
transpose: true
64+
)
65+
66+
%{model_info | model: qmodel, params: qparams}
67+
else
68+
IO.puts("Dense (no quantization) calibration lane")
69+
model_info
70+
end
71+
72+
serving =
73+
Bumblebee.Text.generation(serving_model_info, tokenizer, gen_config,
74+
defn_options: [compiler: Emily.Compiler, native: true, native_fallback: :raise]
75+
)
76+
77+
for _ <- 1..warmup do
78+
IO.puts("[warmup] generating…")
79+
%{results: [_]} = Nx.Serving.run(serving, prompt)
80+
end
81+
82+
measurements =
83+
for n <- 1..runs//1 do
84+
{elapsed_us, %{results: [%{text: text}]}} =
85+
:timer.tc(fn -> Nx.Serving.run(serving, prompt) end)
86+
87+
secs = elapsed_us / 1_000_000
88+
tps = new_tokens / secs
89+
IO.puts("[run #{n}] #{Float.round(secs, 3)} s, #{Float.round(tps, 2)} tok/s")
90+
{secs, tps, text}
91+
end
92+
93+
tps_list = Enum.map(measurements, fn {_, tps, _} -> tps end)
94+
secs_list = Enum.map(measurements, fn {secs, _, _} -> secs end)
95+
[{_, _, sample} | _] = measurements
96+
mean = Enum.sum(tps_list) / length(tps_list)
97+
{min_tps, max_tps} = Enum.min_max(tps_list)
98+
median_secs = secs_list |> Enum.sort() |> Enum.at(div(length(secs_list), 2))
99+
100+
IO.puts("\ntokens/sec : mean=#{Float.round(mean, 2)} min=#{Float.round(min_tps, 2)} max=#{Float.round(max_tps, 2)}")
101+
IO.puts("median secs : #{Float.round(median_secs, 4)} (use the ratio of this across before/after runs)")
102+
IO.puts("sample :\n #{String.slice(sample, 0, 300)}")

lib/emily/quantization/layers.ex

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ defmodule Emily.Quantization.Layers do
33
Defn-traceable quantized layer op for use inside Axon graphs.
44
55
`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; the `qwen3_quantized` notebook walks
8-
through a concrete `Axon.rewrite_nodes/2`-based graph rewrite that
9-
swaps every `:dense` for a layer calling this op.
6+
on a `%Emily.QuantizedWeight{}` kernel. It lowers to the fused
7+
`mx::quantized_matmul` kernel (via `Emily.Quantization.quantized_matmul_defn/2`),
8+
which streams the packed low-bit weights directly rather than
9+
materializing a dense weight per call — the single-kernel path decode
10+
loops want. The `qwen3_quantized` notebook walks through a concrete
11+
`Axon.rewrite_nodes/2`-based graph rewrite that swaps every `:dense`
12+
for a layer calling this op.
1013
"""
1114

1215
import Nx.Defn
@@ -20,14 +23,15 @@ defmodule Emily.Quantization.Layers do
2023
2124
* `input` — activation tensor, shape `(..., in)`.
2225
* `kernel` — `%QuantizedWeight{}`. The stored layout is determined
23-
by `kernel.transpose`:
26+
by `kernel.transpose` (passed straight through to the fused
27+
kernel):
2428
* `transpose: false` (the AWQ / Axon-native layout) — packed
2529
representation of a `[in, out]` weight; the layer computes
26-
`Nx.dot(x, dense)`.
30+
`x @ W`.
2731
* `transpose: true` (the MLX / PyTorch-native layout, i.e. fresh
2832
output of `QuantizedWeight.from_dense/2` on a `[out, in]`
2933
weight) — packed representation of a `[out, in]` weight; the
30-
layer computes `Nx.dot(x, Nx.transpose(dense))`.
34+
layer computes `x @ Wᵀ`.
3135
* `bias` — either an `Nx.Tensor`, a number, or a keyword list (in
3236
which case it's treated as `opts` and bias defaults to 0). Matches
3337
`Axon.Quantization.Layers.weight_only_quantized_dense/4`'s
@@ -50,33 +54,33 @@ defmodule Emily.Quantization.Layers do
5054
# When Axon.dense registers `use_bias: false`, the generated op call
5155
# is arity-3 with layer opts as the third arg (matches
5256
# `Axon.Quantization.Layers.weight_only_quantized_dense/4`'s contract).
53-
{bias, opts} =
57+
# Axon also injects `:mode` (`:inference` / `:train`); weight-only
58+
# quantization has no mode-dependent behaviour, so `opts` is absorbed
59+
# and ignored here.
60+
{bias, _opts} =
5461
case bias do
5562
b when is_list(b) -> {Nx.tensor(0), Keyword.merge(opts, b)}
5663
b -> {b, opts}
5764
end
5865

59-
%QuantizedWeight{transpose: transpose} = kernel
60-
opts = Keyword.put(opts, :transpose, transpose)
61-
quantized_dense_impl(input, kernel, bias, opts)
66+
# Assert the kernel is a %QuantizedWeight{} at the layer boundary so a
67+
# bad kernel fails here rather than deep inside the fused kernel. Its
68+
# layout/mode/bits/group_size are read off the struct by
69+
# `quantized_matmul_defn/2`, so nothing extra needs threading through.
70+
%QuantizedWeight{} = kernel
71+
quantized_dense_impl(input, kernel, bias)
6272
end
6373

64-
# `transpose` is threaded through `opts` as a compile-time constant so
65-
# the branch selects at trace time (no runtime `if` over booleans).
66-
defnp quantized_dense_impl(x, kernel, bias, opts \\ []) do
67-
# `:mode` is injected by Axon's compiler (`:inference` / `:train`)
68-
# for every layer op; accept-and-ignore here since weight-only
69-
# quantization has no mode-dependent behavior.
70-
opts = keyword!(opts, [:transpose, mode: :inference])
71-
dense = Emily.Quantization.dequantize_defn(kernel)
72-
73-
y =
74-
if opts[:transpose] do
75-
Nx.dot(x, Nx.transpose(dense))
76-
else
77-
Nx.dot(x, dense)
78-
end
79-
80-
Nx.add(y, bias)
74+
defnp quantized_dense_impl(x, kernel, bias) do
75+
# Fused single-kernel `mx::quantized_matmul` — streams the 4-bit
76+
# packed weights directly — instead of dequantizing the full weight
77+
# to bf16 and then running a dense `Nx.dot`. Decode is
78+
# memory-bandwidth bound on the weight, so this is ~2-4x faster per
79+
# matmul (larger gains on the fatter MLP projections). `transpose`,
80+
# `mode`, `bits`, and `group_size` are read off the `%QuantizedWeight{}`
81+
# by `quantized_matmul_defn/2`; non-Emily backends still get the
82+
# composed `dequantize_defn/1` + `Nx.dot/2` via the block's fallback.
83+
Emily.Quantization.quantized_matmul_defn(x, kernel)
84+
|> Nx.add(bias)
8185
end
8286
end

test/emily/quantization/layers_test.exs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,56 @@ defmodule Emily.Quantization.LayersTest do
137137
assert_close(actual, expected, tol: 1.0e-3)
138138
end
139139
end
140+
141+
describe "quantized_dense/4 — native lane lowers to the fused kernel" do
142+
# #197: under the native single-NIF compiler (the decode-loop path),
143+
# the layer must lower to the fused `mx::quantized_matmul` opcode, not
144+
# fall back to op-by-op eval. `native_fallback: :raise` fails the test
145+
# if any node can't be lowered natively; the eager `quantized_matmul/2`
146+
# (same C++ kernel) is the numeric oracle.
147+
@native_raise [compiler: Emily.Compiler, native: true, native_fallback: :raise]
148+
149+
test "transpose=true: fused native lowering matches eager quantized_matmul" do
150+
w =
151+
Nx.iota({4, 128}, backend: Emily.Backend, type: :f32)
152+
|> Nx.divide(4 * 128 / 2)
153+
|> Nx.subtract(1.0)
154+
155+
x =
156+
Nx.iota({3, 128}, backend: Emily.Backend, type: :f32)
157+
|> Nx.divide(128.0)
158+
|> Nx.subtract(0.5)
159+
160+
qw = QuantizedWeight.from_dense(w, group_size: 64, bits: 4, transpose: true)
161+
162+
fun = fn x, qw -> Layers.quantized_dense(x, qw) end
163+
actual = Nx.Defn.jit(fun, @native_raise).(x, qw)
164+
expected = Quantization.quantized_matmul(x, qw)
165+
166+
assert Nx.shape(actual) == {3, 4}
167+
assert_close(actual, expected, tol: 1.0e-3)
168+
end
169+
170+
test "transpose=false + bias: fused native lowering matches eager" do
171+
w =
172+
Nx.iota({2, 128}, backend: Emily.Backend, type: :f32)
173+
|> Nx.divide(2 * 128 / 2)
174+
|> Nx.subtract(1.0)
175+
176+
x =
177+
Nx.iota({3, 2}, backend: Emily.Backend, type: :f32)
178+
|> Nx.divide(2.0)
179+
|> Nx.subtract(0.5)
180+
181+
qw = QuantizedWeight.from_dense(w, group_size: 64, bits: 4, transpose: false)
182+
b = Nx.iota({128}, backend: Emily.Backend, type: :f32) |> Nx.divide(128.0)
183+
184+
fun = fn x, qw, b -> Layers.quantized_dense(x, qw, b) end
185+
actual = Nx.Defn.jit(fun, @native_raise).(x, qw, b)
186+
expected = Quantization.quantized_matmul(x, qw) |> Nx.add(b)
187+
188+
assert Nx.shape(actual) == {3, 128}
189+
assert_close(actual, expected, tol: 1.0e-3)
190+
end
191+
end
140192
end

0 commit comments

Comments
 (0)