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