@@ -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
99210end
0 commit comments