Skip to main content

burn_ndarray/ops/
qtensor.rs

1use alloc::{vec, vec::Vec};
2
3use burn_backend::{
4    DType, ExecutionError, Shape, TensorData, TensorMetadata, TensorPrimitive, get_device_settings,
5    ops::{FloatTensorOps, QTensorOps},
6    quantization::{
7        QParams, QuantLevel, QuantMode, QuantPropagation, QuantScheme, QuantStore, QuantValue,
8        QuantizationParametersPrimitive, QuantizedBytes,
9    },
10    tensor::{FloatTensor, IntTensor, QuantizedTensor},
11};
12use burn_std::{FloatDType, IntDType};
13
14use crate::{
15    NdArray, NdArrayDevice, NdArrayQTensor, NdArrayTensor, SharedArray, element::QuantElement,
16    execute_with_dtype, execute_with_int_dtype, execute_with_int_out_dtype,
17    execute_with_numeric_dtype, slice,
18};
19
20use super::quantization::{QuantizationStrategy, SymmetricQuantization};
21use super::{NdArrayMathOps, NdArrayOps};
22
23impl QTensorOps<Self> for NdArray {
24    fn q_from_data(data: TensorData, _device: &NdArrayDevice) -> QuantizedTensor<Self> {
25        match data.dtype {
26            DType::QFloat(scheme) => {
27                let shape = data.shape.clone();
28                let num_elements = data.num_elements();
29                let q_bytes = QuantizedBytes {
30                    bytes: data.into_bytes(),
31                    scheme,
32                    num_elements,
33                };
34
35                match scheme {
36                    QuantScheme {
37                        level: QuantLevel::Tensor | QuantLevel::Block(_),
38                        mode: QuantMode::Symmetric,
39                        value: QuantValue::Q8F | QuantValue::Q8S,
40                        ..
41                    } => {
42                        // We can load QuantStore::U32 w/ QuantizedBytes impl
43                        let (values, qparams) = q_bytes.into_vec_i8();
44                        let data = TensorData::new(values, shape);
45                        // Overwrite storage
46                        let scheme = scheme.with_store(QuantStore::Native);
47
48                        let qparams = qparams
49                            .scales
50                            .into_iter()
51                            .map(|scales| QParams { scales })
52                            .collect();
53
54                        NdArrayQTensor {
55                            qtensor: NdArrayTensor::from_data(data),
56                            scheme,
57                            qparams,
58                        }
59                    }
60                    QuantScheme {
61                        value:
62                            QuantValue::Q4F
63                            | QuantValue::Q4S
64                            | QuantValue::Q2F
65                            | QuantValue::Q2S
66                            | QuantValue::E2M1
67                            | QuantValue::E4M3
68                            | QuantValue::E5M2,
69                        ..
70                    } => unimplemented!("from_data not supported for scheme {scheme:?}"),
71                }
72            }
73            _ => panic!(
74                "Invalid dtype (expected DType::QFloat, got {:?})",
75                data.dtype
76            ),
77        }
78    }
79
80    fn quantize(
81        tensor: FloatTensor<Self>,
82        scheme: &QuantScheme,
83        qparams: QuantizationParametersPrimitive<Self>,
84    ) -> QuantizedTensor<Self> {
85        let shape = tensor.shape();
86        let data_f = tensor.into_data();
87        let scales = qparams.scales.into_data().convert::<f32>();
88
89        // Implement with ndarray instead of QuantizationStrategy?
90        let (data, qparams) = match scheme {
91            QuantScheme {
92                level: QuantLevel::Tensor,
93                mode: QuantMode::Symmetric,
94                // `Q2S` is supported natively (stored as i8) — it feeds the multiply-free
95                // ternary matmul fast path in `q_matmul` (BitNet b1.58).
96                #[cfg(not(feature = "export_tests"))]
97                    value: QuantValue::Q8F | QuantValue::Q8S | QuantValue::Q2S,
98                // For tests, "native" sub-byte quant serves as a reference for value equality.
99                // Values are stored as i8 regardless.
100                #[cfg(feature = "export_tests")]
101                    value:
102                    QuantValue::Q8F
103                    | QuantValue::Q8S
104                    | QuantValue::Q4F
105                    | QuantValue::Q4S
106                    | QuantValue::Q2F
107                    | QuantValue::Q2S,
108                store: QuantStore::Native,
109                ..
110            } => {
111                let scales = scales.iter().next().unwrap();
112                let strategy = QuantizationStrategy::PerTensorSymmetric(
113                    SymmetricQuantization::init(scales, scheme.value),
114                );
115                let values = strategy.quantize(data_f.as_slice().unwrap());
116                (
117                    TensorData::quantized(values, shape.clone(), *scheme, &[scales]),
118                    vec![QParams { scales }],
119                )
120            }
121            QuantScheme {
122                level: QuantLevel::Block(block_size),
123                mode: QuantMode::Symmetric,
124                #[cfg(not(feature = "export_tests"))]
125                    value: QuantValue::Q8F | QuantValue::Q8S,
126                #[cfg(feature = "export_tests")]
127                    value:
128                    QuantValue::Q8F
129                    | QuantValue::Q8S
130                    | QuantValue::Q4F
131                    | QuantValue::Q4S
132                    | QuantValue::Q2F
133                    | QuantValue::Q2S,
134                store: QuantStore::Native,
135                ..
136            } => {
137                let scales = scales.as_slice().unwrap();
138                let (strategy, qparams) = scales
139                    .iter()
140                    .map(|&s| {
141                        (
142                            SymmetricQuantization::init(s, scheme.value),
143                            QParams { scales: s },
144                        )
145                    })
146                    .unzip();
147                let strategy = QuantizationStrategy::PerBlockSymmetric(strategy, *block_size);
148                let values = strategy.quantize(data_f.as_slice().unwrap());
149                (
150                    TensorData::quantized(values, shape.clone(), *scheme, scales),
151                    qparams,
152                )
153            }
154            scheme => unimplemented!("Quantization not supported for scheme {scheme:?}"),
155        };
156
157        let num_elements = data.num_elements();
158        let q_bytes = QuantizedBytes {
159            bytes: data.into_bytes(),
160            scheme: *scheme,
161            num_elements,
162        };
163        let (values, _) = q_bytes.into_vec_i8();
164        let data = TensorData::new(values, shape);
165
166        NdArrayQTensor {
167            qtensor: NdArrayTensor::from_data(data),
168            scheme: *scheme,
169            qparams,
170        }
171    }
172
173    fn dequantize(tensor: QuantizedTensor<Self>, dtype: FloatDType) -> FloatTensor<Self> {
174        let strategy = tensor.strategy();
175        let scheme = tensor.scheme;
176        let shape = tensor.shape();
177        let data = match tensor.qtensor {
178            NdArrayTensor::I8(storage) => {
179                let data = storage.into_shared().into_iter().collect();
180                dequantize(data, shape, scheme, &strategy, dtype.into())
181            }
182            _ => unreachable!(),
183        };
184        NdArrayTensor::from_data(data)
185    }
186
187    /// Matrix multiplication with at least one quantized operand.
188    ///
189    /// Fast path — BitNet b1.58 ternary weights: when `rhs` is a `Q2S` symmetric per-tensor weight
190    /// (values in `{-1, 0, +1}`) and `lhs` is an `f32` activation, the product is computed WITHOUT
191    /// dequantizing the weight and WITHOUT a single multiply in the inner loop: `+1 => add`,
192    /// `-1 => subtract`, `0 => skip`, then the per-tensor scale `γ` is applied once per output
193    /// element — the multiply-free compute path BitNet is built on. The result matches the
194    /// dequantize-then-`float_matmul` path to within f32 rounding.
195    ///
196    /// Every other case (Q8, per-block, non-f32 activation, batched weights, ...) falls through to
197    /// the regular `dequantize -> float_matmul` path — byte-for-byte the default behaviour.
198    fn q_matmul(lhs: TensorPrimitive<Self>, rhs: TensorPrimitive<Self>) -> TensorPrimitive<Self> {
199        if let (TensorPrimitive::Float(l), TensorPrimitive::QFloat(r)) = (&lhs, &rhs)
200            && let Some(out) = ternary_matmul(l, r)
201        {
202            return TensorPrimitive::Float(out);
203        }
204
205        // Fallback: identical to the default `QTensorOps::q_matmul` — dequantize any quantized
206        // operand, run the regular float matmul, and preserve quantization propagation.
207        let mut propagation = QuantPropagation::Inhibit;
208        let mut scheme = QuantScheme::default();
209        let target_dtype: Option<FloatDType> = match (&lhs, &rhs) {
210            (TensorPrimitive::Float(t), _) | (_, TensorPrimitive::Float(t)) => {
211                Some(t.dtype().into())
212            }
213            _ => None,
214        };
215        let lhs = match lhs {
216            TensorPrimitive::Float(lhs) => lhs,
217            TensorPrimitive::QFloat(lhs) => {
218                let settings = get_device_settings::<Self>(&lhs.device());
219                propagation = settings.quantization.propagation;
220                scheme = lhs.scheme;
221                let float_dtype = target_dtype.unwrap_or(settings.float_dtype);
222                Self::dequantize(lhs, float_dtype)
223            }
224        };
225        let rhs = match rhs {
226            TensorPrimitive::Float(rhs) => rhs,
227            TensorPrimitive::QFloat(rhs) => {
228                let settings = get_device_settings::<Self>(&rhs.device());
229                propagation = settings.quantization.propagation;
230                scheme = rhs.scheme;
231                let float_dtype = target_dtype.unwrap_or(settings.float_dtype);
232                Self::dequantize(rhs, float_dtype)
233            }
234        };
235        let out_f = <Self as FloatTensorOps<Self>>::float_matmul(lhs, rhs);
236        match propagation {
237            QuantPropagation::Propagate => {
238                TensorPrimitive::QFloat(Self::quantize_dynamic(out_f, &scheme))
239            }
240            QuantPropagation::Inhibit => TensorPrimitive::Float(out_f),
241        }
242    }
243
244    fn q_to_device(
245        tensor: QuantizedTensor<Self>,
246        _device: &NdArrayDevice,
247    ) -> QuantizedTensor<Self> {
248        tensor
249    }
250
251    fn q_reshape(tensor: QuantizedTensor<Self>, shape: Shape) -> QuantizedTensor<Self> {
252        NdArrayQTensor {
253            qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
254                NdArrayOps::reshape(array, shape)
255            }),
256            scheme: tensor.scheme,
257            qparams: tensor.qparams,
258        }
259    }
260
261    async fn q_into_data(tensor: QuantizedTensor<Self>) -> Result<TensorData, ExecutionError> {
262        let shape = tensor.qtensor.shape();
263        let scales = tensor.qparams.iter().map(|q| q.scales).collect::<Vec<_>>();
264        Ok(execute_with_numeric_dtype!(
265            tensor.qtensor,
266            E,
267            |array: SharedArray<E>| {
268                let values = array.into_iter().collect();
269                TensorData::quantized(values, shape, tensor.scheme, &scales)
270            }
271        ))
272    }
273
274    fn q_swap_dims(
275        tensor: QuantizedTensor<Self>,
276        dim1: usize,
277        dim2: usize,
278    ) -> QuantizedTensor<Self> {
279        NdArrayQTensor {
280            qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
281                NdArrayOps::swap_dims(array, dim1, dim2)
282            }),
283            scheme: tensor.scheme,
284            qparams: tensor.qparams,
285        }
286    }
287
288    fn q_permute(tensor: QuantizedTensor<Self>, axes: &[usize]) -> QuantizedTensor<Self> {
289        NdArrayQTensor {
290            qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
291                NdArrayOps::permute(array, axes)
292            }),
293            scheme: tensor.scheme,
294            qparams: tensor.qparams,
295        }
296    }
297
298    fn q_flip(tensor: QuantizedTensor<Self>, axes: &[usize]) -> QuantizedTensor<Self> {
299        NdArrayQTensor {
300            qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
301                NdArrayOps::flip(array, axes)
302            }),
303            scheme: tensor.scheme,
304            qparams: tensor.qparams,
305        }
306    }
307
308    fn q_gather(
309        dim: usize,
310        tensor: QuantizedTensor<Self>,
311        indices: IntTensor<Self>,
312    ) -> QuantizedTensor<Self> {
313        let qtensor = execute_with_int_dtype!(indices, IntElem, |idx_array: SharedArray<
314            IntElem,
315        >|
316         -> NdArrayTensor {
317            execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
318                NdArrayOps::gather(dim, array, idx_array)
319            })
320        });
321        NdArrayQTensor {
322            qtensor,
323            scheme: tensor.scheme,
324            qparams: tensor.qparams,
325        }
326    }
327
328    fn q_select(
329        tensor: QuantizedTensor<Self>,
330        dim: usize,
331        indices: IntTensor<Self>,
332    ) -> QuantizedTensor<Self> {
333        let qtensor = execute_with_int_dtype!(indices, IntElem, |idx_array: SharedArray<
334            IntElem,
335        >|
336         -> NdArrayTensor {
337            execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
338                NdArrayMathOps::select(array, dim, idx_array)
339            })
340        });
341        NdArrayQTensor {
342            qtensor,
343            scheme: tensor.scheme,
344            qparams: tensor.qparams,
345        }
346    }
347
348    fn q_slice(
349        tensor: QuantizedTensor<Self>,
350        slices: &[burn_backend::Slice],
351    ) -> QuantizedTensor<Self> {
352        NdArrayQTensor {
353            qtensor: slice!(tensor.qtensor, slices),
354            scheme: tensor.scheme,
355            qparams: tensor.qparams,
356        }
357    }
358
359    fn q_argmax(tensor: QuantizedTensor<Self>, dim: usize, out_dtype: IntDType) -> IntTensor<Self> {
360        execute_with_int_out_dtype!(out_dtype, I, {
361            execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
362                NdArrayMathOps::argmax::<I>(array, dim)
363            })
364        })
365    }
366
367    fn q_argmin(tensor: QuantizedTensor<Self>, dim: usize, out_dtype: IntDType) -> IntTensor<Self> {
368        execute_with_int_out_dtype!(out_dtype, I, {
369            execute_with_numeric_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
370                NdArrayMathOps::argmin::<I>(array, dim)
371            })
372        })
373    }
374
375    fn q_expand(tensor: QuantizedTensor<Self>, shape: Shape) -> QuantizedTensor<Self> {
376        NdArrayQTensor {
377            qtensor: execute_with_dtype!(tensor.qtensor, E, |array: SharedArray<E>| {
378                NdArrayOps::expand(array, shape)
379            }),
380            scheme: tensor.scheme,
381            qparams: tensor.qparams,
382        }
383    }
384}
385
386/// Native multiply-free ternary matmul (BitNet b1.58): an `f32` activation `lhs` times a `Q2S`
387/// symmetric per-tensor ternary weight `rhs` (values in `{-1, 0, +1}`).
388///
389/// Returns `None` — so the caller falls back to the regular dequantize path — unless every
390/// precondition holds: `rhs` is `Q2S` / `Symmetric` / per-tensor, `rhs` is a 2D weight `[K, N]`,
391/// and `lhs` is an `f32` tensor whose last dim is `K`. Leading dims of `lhs` are flattened into the
392/// row count `M`, so this covers the `Linear`-style `[.., K] x [K, N] -> [.., N]` case.
393///
394/// The inner loop contains no multiplies: `+1 => add`, `-1 => subtract`, `0 => skip`. The single
395/// per-tensor scale `γ` is applied once per output element at the end — `M·N` multiplies instead of
396/// the `M·N·K` of a dense matmul, with zeros never touched.
397fn ternary_matmul(
398    lhs: &FloatTensor<NdArray>,
399    rhs: &NdArrayQTensor,
400) -> Option<FloatTensor<NdArray>> {
401    // Canonical BitNet b1.58 weight quantization: Q2S, symmetric, per-tensor.
402    if !matches!(
403        rhs.scheme,
404        QuantScheme {
405            value: QuantValue::Q2S,
406            mode: QuantMode::Symmetric,
407            level: QuantLevel::Tensor,
408            ..
409        }
410    ) {
411        return None;
412    }
413    // Only an f32 activation (the reference float dtype) takes the fast path.
414    if !matches!(lhs, NdArrayTensor::F32(_)) {
415        return None;
416    }
417
418    // rhs is a 2D weight [K, N].
419    let wdims = rhs.qtensor.shape().to_vec();
420    if wdims.len() != 2 {
421        return None;
422    }
423    let (k, n) = (wdims[0], wdims[1]);
424
425    // lhs is [.., M, K] with a matching K; flatten the leading dims into M.
426    let ldims = lhs.shape().to_vec();
427    if ldims.len() < 2 || *ldims.last().unwrap() != k {
428        return None;
429    }
430    let m: usize = ldims[..ldims.len() - 1].iter().product();
431
432    // Per-tensor scale γ.
433    let gamma = rhs.qparams.first()?.scales;
434
435    // Canonical row-major values for both operands (`into_data` normalizes any strided layout).
436    let a_data = lhs.clone().into_data();
437    let a = a_data.as_slice::<f32>().ok()?;
438    let w_data = rhs.qtensor.clone().into_data();
439    let w = w_data.as_slice::<i8>().ok()?;
440    if a.len() != m * k || w.len() != k * n {
441        return None;
442    }
443
444    // Multiply-free accumulation; one scale per output element at the end.
445    let mut out = vec![0f32; m * n];
446    for i in 0..m {
447        let arow = &a[i * k..(i + 1) * k];
448        let orow = &mut out[i * n..(i + 1) * n];
449        for kk in 0..k {
450            let x = arow[kk];
451            let wrow = &w[kk * n..(kk + 1) * n];
452            for (o, &t) in orow.iter_mut().zip(wrow) {
453                match t {
454                    1 => *o += x,
455                    -1 => *o -= x,
456                    _ => {} // 0 -> skip
457                }
458            }
459        }
460        for o in orow.iter_mut() {
461            *o *= gamma;
462        }
463    }
464
465    let mut out_dims = ldims[..ldims.len() - 1].to_vec();
466    out_dims.push(n);
467    Some(NdArrayTensor::from_data(TensorData::new(
468        out,
469        Shape::from(out_dims),
470    )))
471}
472
473fn dequantize<Q: QuantElement>(
474    data: Vec<Q>,
475    shape: Shape,
476    scheme: QuantScheme,
477    strategy: &QuantizationStrategy,
478    dtype: DType,
479) -> TensorData {
480    let qparams = match strategy {
481        QuantizationStrategy::PerTensorSymmetric(quant) => vec![quant.scale],
482        QuantizationStrategy::PerBlockSymmetric(quant, _block_size) => {
483            quant.iter().map(|q| q.scale).collect()
484        }
485    };
486    let q_bytes = QuantizedBytes::new(data, scheme, &qparams);
487    let (values, _qparams) = q_bytes.into_vec_i8();
488    TensorData::new(strategy.dequantize(&values), shape).convert_dtype(dtype)
489}