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