Skip to main content

rten_gemm/
block_quant.rs

1//! Matrix multiplication with block-quantized inputs.
2
3use std::mem::MaybeUninit;
4use std::ops::Range;
5
6use rayon::prelude::*;
7use rten_base::iter::range_chunks;
8use rten_base::num::AsUsize;
9use rten_parallel::par_iter::ParIter;
10use rten_simd::ops::{BitOps, Extend, IntOps, Interleave, NumOps, ToFloat};
11use rten_simd::{Isa, Simd, SimdOp};
12use rten_tensor::{AsView, AssumeInit, Contiguous, Layout, NdTensor, NdTensorView};
13
14use crate::GemmResult;
15use crate::errors::{BlockQuantizedError, GemmError};
16use crate::i8dot::{Int8DotIsa, SimdInt8DotOp};
17
18/// Specifies whether to quantize the LHS / "A" input to block-quantized matrix
19/// multiplication.
20///
21/// Quantizing the LHS input can significantly improve performance but may
22/// impact accuracy.
23#[derive(Copy, Clone, Debug, PartialEq)]
24pub enum ComputeMode {
25    /// Quantize LHS / "A" input to 8-bits.
26    Int8,
27    /// Do not quantize the LHS.
28    Float,
29}
30
31/// Performs matrix-multiplication between an un-quantized LHS / "A" matrix
32/// and a block-quantized RHS / "B" matrix.
33pub struct BlockQuantizedGemm {
34    mode: ComputeMode,
35}
36
37impl BlockQuantizedGemm {
38    pub fn new() -> Self {
39        BlockQuantizedGemm {
40            mode: ComputeMode::Float,
41        }
42    }
43
44    /// Set the compute mode controls the accuracy/performance balance.
45    pub fn with_compute(mut self, mode: ComputeMode) -> Self {
46        self.mode = mode;
47        self
48    }
49
50    /// Return true if an optimized implementation for the given compute mode
51    /// if available on the current platform.
52    pub fn is_compute_optimized(mode: ComputeMode) -> bool {
53        match mode {
54            ComputeMode::Float => true,
55            ComputeMode::Int8 => is_int8_compute_optimized(),
56        }
57    }
58
59    /// Multiply `lhs` by the dequantized `rhs` matrix.
60    pub fn batched_gemm_uninit<'a>(
61        &self,
62        out: &'a mut [MaybeUninit<f32>],
63        lhs: NdTensorView<f32, 3>,
64        rhs: BlockQuantizedMatrix<f32>,
65    ) -> GemmResult<&'a mut [f32]> {
66        let [batch, m, lhs_k] = lhs.shape();
67        let n = rhs.cols();
68
69        if out.len() != n * m * batch {
70            return Err(GemmError::OutputSizeMismatch);
71        }
72        if lhs_k != rhs.rows() {
73            return Err(GemmError::KSizeMismatch);
74        }
75
76        if rhs.bits != 4 {
77            return Err(GemmError::QuantBitsNotSupported);
78        }
79
80        // Handle K=0 case here so we can rely on K > 0 in the kernels.
81        if lhs_k == 0 {
82            out.fill(MaybeUninit::new(0.));
83            return Ok(unsafe { out.assume_init() });
84        }
85
86        enum LhsRow<'a> {
87            Float(&'a [f32]),
88            Quant {
89                data: Contiguous<NdTensorView<'a, i8, 2>>,
90                scales: &'a [f32],
91            },
92        }
93
94        let lhs = lhs.to_contiguous();
95
96        let lhs_quant: Option<(NdTensor<i8, 4>, NdTensor<f32, 3>)> =
97            if matches!(self.mode, ComputeMode::Int8) && m == 1 {
98                Some(quantize(lhs.view(), rhs.elements_per_block()))
99            } else {
100                None
101            };
102
103        let col_block = 16;
104        for (b, out_mat) in out.chunks_mut(n * m).enumerate() {
105            // The handling of multiple rows here is inefficient. This is
106            // because the initial focus is on efficient vector-matrix products.
107            for (row, out_row) in out_mat.chunks_mut(n).enumerate() {
108                let lhs_row = if let Some((lhs_data, lhs_scales)) = &lhs_quant {
109                    LhsRow::Quant {
110                        data: Contiguous::new(lhs_data.slice((b, row))).unwrap(),
111                        scales: lhs_scales.slice((b, row)).data().unwrap(),
112                    }
113                } else {
114                    LhsRow::Float(lhs.slice((b, row)).data().unwrap())
115                };
116
117                ParIter::from(range_chunks(0..n, col_block))
118                    .zip(out_row.par_chunks_mut(col_block))
119                    .for_each(|(col_range, out_row_chunk)| match lhs_row {
120                        LhsRow::Quant { data, scales } => {
121                            let op = VecDotMatrixQuant {
122                                lhs_data: data,
123                                lhs_scales: scales,
124                                rhs: rhs.slice(col_range),
125                                out: out_row_chunk,
126                            };
127                            op.dispatch();
128                        }
129                        LhsRow::Float(lhs) => {
130                            let op = VecDotMatrix {
131                                lhs,
132                                rhs: rhs.slice(col_range),
133                                out: out_row_chunk,
134                            };
135                            op.dispatch();
136                        }
137                    });
138            }
139        }
140
141        Ok(unsafe { out.assume_init() })
142    }
143}
144
145impl Default for BlockQuantizedGemm {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// Return true if a SIMD-optimized int8 dot product implementation is
152/// available.
153fn is_int8_compute_optimized() -> bool {
154    struct HasInt8Simd;
155    impl SimdInt8DotOp for HasInt8Simd {
156        type Output = bool;
157
158        fn eval<I: Int8DotIsa>(self, _isa: I) -> Self::Output {
159            I::SIMD
160        }
161    }
162    HasInt8Simd.dispatch()
163}
164
165/// SIMD operation which computes the product between an f32 vector and a 4-bit
166/// quantized matrix.
167struct VecDotMatrix<'a> {
168    lhs: &'a [f32],
169    rhs: BlockQuantizedMatrix<'a, f32>,
170    out: &'a mut [MaybeUninit<f32>],
171}
172
173impl<'a> VecDotMatrix<'a> {
174    #[inline(always)]
175    fn eval_impl<I: Isa, const SCALES_PER_VBLOCK: usize>(self, isa: I) -> &'a mut [f32] {
176        let ops = isa.f32();
177        let i16_ops = isa.i16();
178        let i32_ops = isa.i32();
179        let i8_ops = isa.i8();
180        let u8_ops = isa.u8();
181
182        // Columns are processed in "vblocks" whose size is the number of
183        // 4-bit elements that can be loaded into a SIMD vector. This can be
184        // larger or smaller than the block size of the RHS.
185        let elements_per_vec = u8_ops.len() * 2;
186
187        let VecDotMatrix { lhs, rhs, out } = self;
188        let vecs_per_block = rhs.elements_per_block() / elements_per_vec;
189
190        // Convert division into shift.
191        let vecs_per_block_log2 = if vecs_per_block != 0 {
192            debug_assert!(vecs_per_block.is_power_of_two());
193            vecs_per_block.ilog2()
194        } else {
195            0
196        };
197
198        // Max supported vector width is 512 bits. This is because the smallest
199        // supported block size is 16 and we require f32 vector width >= block
200        // size.
201        assert!(ops.len() <= 16);
202
203        let n_tail_scales = rhs.blocks_per_column() % SCALES_PER_VBLOCK;
204
205        let rhs_data = rhs.quant.data();
206        let rhs_cols = rhs_data.chunks_exact(rhs.blocks_per_column() * rhs.bytes_per_block());
207        let scale_blocks = rhs.scales.data().chunks_exact(rhs.blocks_per_column());
208
209        for ((col, col_scales), out) in rhs_cols.zip(scale_blocks).zip(out.iter_mut()) {
210            let mut acc = [ops.zero(); 4];
211
212            let mut row_vblocks = lhs.chunks_exact(elements_per_vec);
213            let mut col_vblocks = col.chunks_exact(u8_ops.len());
214
215            let zero_point = i8_ops.splat(8);
216            let lo_mask = u8_ops.splat(0x0F);
217
218            // Vectorized loop over "vblock" elements
219            for (vblock_idx, (row_vblock, col_vblock)) in
220                row_vblocks.by_ref().zip(col_vblocks.by_ref()).enumerate()
221            {
222                // Load packed u4 values.
223                let rhs_vblock = u8_ops.load(col_vblock);
224
225                // Unpack to u8.
226                let lo = u8_ops.and(rhs_vblock, lo_mask);
227                let hi = u8_ops.shift_right::<4>(rhs_vblock);
228                let (lo, hi) = (
229                    u8_ops.interleave_low(lo, hi),
230                    u8_ops.interleave_high(lo, hi),
231                );
232
233                // Re-interpret as i8
234                let lo = i8_ops.from_bits(lo.to_bits());
235                let hi = i8_ops.from_bits(hi.to_bits());
236
237                // Subtract zero point
238                let lo = i8_ops.sub(lo, zero_point);
239                let hi = i8_ops.sub(hi, zero_point);
240
241                // Widen to i32
242                let a_i16 = i8_ops.extend_low(lo);
243                let b_i16 = i8_ops.extend_high(lo);
244                let c_i16 = i8_ops.extend_low(hi);
245                let d_i16 = i8_ops.extend_high(hi);
246                let a_i32 = i16_ops.extend_low(a_i16);
247                let b_i32 = i16_ops.extend_high(a_i16);
248                let c_i32 = i16_ops.extend_low(b_i16);
249                let d_i32 = i16_ops.extend_high(b_i16);
250                let e_i32 = i16_ops.extend_low(c_i16);
251                let f_i32 = i16_ops.extend_high(c_i16);
252                let g_i32 = i16_ops.extend_low(d_i16);
253                let h_i32 = i16_ops.extend_high(d_i16);
254                let rhs_i32 = [a_i32, b_i32, c_i32, d_i32, e_i32, f_i32, g_i32, h_i32];
255
256                // Convert to f32, apply scale and multiply with LHS.
257                let vlen = ops.len();
258                match SCALES_PER_VBLOCK {
259                    1 => {
260                        let scale = ops.splat(col_scales[vblock_idx >> vecs_per_block_log2]);
261                        for i in 0..8 {
262                            let rhs_f32 = i32_ops.to_float(rhs_i32[i]);
263                            let rhs_scaled = ops.mul(rhs_f32, scale);
264                            let lhs = ops.load(&row_vblock[i * vlen..][..vlen]);
265                            acc[i % acc.len()] = ops.mul_add(lhs, rhs_scaled, acc[i % acc.len()]);
266                        }
267                    }
268                    2 => {
269                        let block_idx = vblock_idx * 2;
270                        let scales = [
271                            ops.splat(col_scales[block_idx]),
272                            ops.splat(col_scales[block_idx + 1]),
273                        ];
274                        for i in 0..8 {
275                            let rhs_f32 = i32_ops.to_float(rhs_i32[i]);
276                            let rhs_scaled = ops.mul(rhs_f32, scales[i / 4]);
277                            let lhs = ops.load(&row_vblock[i * vlen..][..vlen]);
278                            acc[i % acc.len()] = ops.mul_add(lhs, rhs_scaled, acc[i % acc.len()]);
279                        }
280                    }
281                    4 => {
282                        let block_idx = vblock_idx * 4;
283                        let scales = [
284                            ops.splat(col_scales[block_idx]),
285                            ops.splat(col_scales[block_idx + 1]),
286                            ops.splat(col_scales[block_idx + 2]),
287                            ops.splat(col_scales[block_idx + 3]),
288                        ];
289                        for i in 0..8 {
290                            let rhs_f32 = i32_ops.to_float(rhs_i32[i]);
291                            let rhs_scaled = ops.mul(rhs_f32, scales[i / 2]);
292                            let lhs = ops.load(&row_vblock[i * vlen..][..vlen]);
293                            acc[i % acc.len()] = ops.mul_add(lhs, rhs_scaled, acc[i % acc.len()]);
294                        }
295                    }
296                    8 => {
297                        let block_idx = vblock_idx * 8;
298                        for i in 0..8 {
299                            let scale = ops.splat(col_scales[block_idx + i]);
300                            let rhs_f32 = i32_ops.to_float(rhs_i32[i]);
301                            let rhs_scaled = ops.mul(rhs_f32, scale);
302                            let lhs = ops.load(&row_vblock[i * vlen..][..vlen]);
303                            acc[i % acc.len()] = ops.mul_add(lhs, rhs_scaled, acc[i % acc.len()]);
304                        }
305                    }
306                    _ => unreachable!(),
307                }
308            }
309
310            // Sum accumulators
311            let acc_01 = ops.add(acc[0], acc[1]);
312            let acc_23 = ops.add(acc[2], acc[3]);
313            let acc = ops.add(acc_01, acc_23);
314            let mut acc = ops.sum(acc);
315
316            // Scalar tail loop
317            if !row_vblocks.remainder().is_empty() {
318                let mut tail_acc = 0.;
319
320                let lhs_tail_pairs = row_vblocks.remainder().as_chunks::<2>().0;
321                let tail_scales = &col_scales[col_scales.len() - n_tail_scales..];
322                let elements_per_scale = lhs_tail_pairs.len() / tail_scales.len();
323
324                debug_assert_eq!(lhs_tail_pairs.len(), col_vblocks.remainder().len());
325                for (i, (lhs, rhs)) in lhs_tail_pairs
326                    .iter()
327                    .zip(col_vblocks.remainder())
328                    .enumerate()
329                {
330                    let zero_point = 8;
331                    let rhs_lo = (rhs & 0x0F) as i32 - zero_point;
332                    let rhs_hi = (rhs >> 4) as i32 - zero_point;
333
334                    let scale = tail_scales[i / elements_per_scale];
335                    let rhs_lo_scaled = (rhs_lo as f32) * scale;
336                    let rhs_hi_scaled = (rhs_hi as f32) * scale;
337
338                    tail_acc += lhs[0] * rhs_lo_scaled + lhs[1] * rhs_hi_scaled;
339                }
340
341                acc += tail_acc;
342            }
343
344            out.write(acc);
345        }
346
347        unsafe { out.assume_init() }
348    }
349}
350
351impl<'a> SimdOp for VecDotMatrix<'a> {
352    type Output = &'a mut [f32];
353
354    #[inline(always)]
355    fn eval<I: Isa>(self, isa: I) -> Self::Output {
356        let u8_ops = isa.u8();
357
358        // Columns are processed in "vblocks" whose size is the number of
359        // 4-bit elements that can be loaded into a SIMD vector. This can be
360        // larger or smaller than the block size of the RHS.
361        let elements_per_vec = u8_ops.len() * 2;
362
363        // Number of scale values and zero points we will use for each vblock.
364        // The maximum supported SIMD width is 512 bits (128 x u4) and the
365        // minimum block size is 16, so the maximum value is 128/16 = 8.
366        let scales_per_vblock = (elements_per_vec / self.rhs.elements_per_block()).max(1);
367
368        match scales_per_vblock {
369            1 => self.eval_impl::<I, 1>(isa),
370            2 => self.eval_impl::<I, 2>(isa),
371            4 => self.eval_impl::<I, 4>(isa),
372            8 => self.eval_impl::<I, 8>(isa),
373            _ => unreachable!("unsupported scales_per_vblock"),
374        }
375    }
376}
377
378/// Quantize blocks of `data` to i8 values.
379///
380/// `data` has shape (batch, row, col) and `block_size` is a power of 2 >= 16.
381///
382/// Returns a tuple of (quantized_data, scales) where `quantized_data` has shape
383/// (batch, row, block, element) and `scales` has shape (batch, row, block).
384/// Elements can be dequantized via `x as f32 * block_scale`.
385fn quantize(
386    data: Contiguous<NdTensorView<f32, 3>>,
387    block_size: usize,
388) -> (NdTensor<i8, 4>, NdTensor<f32, 3>) {
389    let [batch, rows, k] = data.shape();
390
391    assert!(block_size >= 16 && block_size.is_power_of_two());
392    assert!(k.is_multiple_of(block_size));
393
394    let n_blocks = k / block_size;
395    let mut output = Vec::with_capacity(n_blocks * block_size);
396    let mut scales = Vec::with_capacity(n_blocks);
397
398    for block in data.data().chunks_exact(block_size) {
399        let abs_max = block.iter().fold(0., |max, x| x.abs().max(max));
400        let inv_scale = i8::MAX as f32 / abs_max;
401
402        for &x in block {
403            let qx = (x * inv_scale).round() as i8;
404            output.push(qx);
405        }
406
407        scales.push(1. / inv_scale);
408    }
409
410    let quant_data = NdTensor::from_data([batch, rows, n_blocks, block_size], output);
411    let scales = NdTensor::from_data([batch, rows, n_blocks], scales);
412    (quant_data, scales)
413}
414
415/// Multiply an int8-quantized LHS by an int4-quantized RHS.
416struct VecDotMatrixQuant<'a> {
417    lhs_data: Contiguous<NdTensorView<'a, i8, 2>>,
418    lhs_scales: &'a [f32],
419    rhs: BlockQuantizedMatrix<'a, f32>,
420    out: &'a mut [MaybeUninit<f32>],
421}
422
423impl<'a> VecDotMatrixQuant<'a> {
424    #[inline(always)]
425    fn eval_impl<I: Int8DotIsa, const SCALES_PER_VBLOCK: usize>(self, isa: I) -> &'a mut [f32] {
426        let ops = isa.isa().f32();
427        let i8_ops = isa.isa().i8();
428        let u8_ops = isa.isa().u8();
429        let i32_ops = isa.isa().i32();
430
431        let VecDotMatrixQuant {
432            lhs_data,
433            lhs_scales,
434            rhs,
435            out,
436        } = self;
437
438        let rhs_data = rhs.quant.data();
439        let rhs_cols = rhs_data.chunks_exact(rhs.blocks_per_column() * rhs.bytes_per_block());
440        let scale_blocks = rhs.scales.data().chunks_exact(rhs.blocks_per_column());
441
442        // Columns are processed in "vblocks" whose size is the number of
443        // 4-bit elements that can be loaded into a SIMD vector. This can be
444        // larger or smaller than the block size of the RHS.
445        let elements_per_vec = u8_ops.len() * 2;
446        let elements_per_block = rhs.elements_per_block();
447        let vecs_per_block = elements_per_block / elements_per_vec;
448        let blocks_per_vec = elements_per_vec.div_ceil(elements_per_block);
449        let n_tail_blocks = rhs.blocks_per_column() % blocks_per_vec;
450
451        // Convert division into shift.
452        let vecs_per_block_log2 = if vecs_per_block != 0 {
453            debug_assert!(vecs_per_block.is_power_of_two());
454            vecs_per_block.ilog2()
455        } else {
456            0
457        };
458
459        // Masks used to select scales if we're using 4 or 8 scales per vblock.
460        let lo_half_mask = ops.first_n_mask(ops.len() / 2);
461        let lo_quad_mask = ops.first_n_mask(ops.len() / 4);
462        let lo_three_quads_mask = ops.first_n_mask(3 * ops.len() / 4);
463
464        for ((col, col_scales), out) in rhs_cols.zip(scale_blocks).zip(out.iter_mut()) {
465            let mut acc = [ops.zero(); 2];
466
467            let zero_point = i8_ops.splat(8);
468            let lo_mask = u8_ops.splat(0x0F);
469            let vlen = u8_ops.len();
470            let zero_i32 = i32_ops.zero();
471
472            let mut col_vblocks = col.chunks_exact(vlen);
473            let mut row_vblocks = lhs_data.data().chunks_exact(vlen * 2);
474
475            for (vblock_idx, vblocks) in row_vblocks.by_ref().zip(col_vblocks.by_ref()).enumerate()
476            {
477                let (row_vblock, col_vblock) = vblocks;
478
479                // Load packed u4 values.
480                let rhs_vblock = u8_ops.load(col_vblock);
481
482                // Unpack to u8.
483                let lo = u8_ops.and(rhs_vblock, lo_mask);
484                let hi = u8_ops.shift_right::<4>(rhs_vblock);
485                let (lo, hi) = (
486                    u8_ops.interleave_low(lo, hi),
487                    u8_ops.interleave_high(lo, hi),
488                );
489
490                // Re-interpret as i8
491                let mut lo = i8_ops.from_bits(lo.to_bits());
492                let mut hi = i8_ops.from_bits(hi.to_bits());
493
494                // Subtract zero point if using i8 x i8 dot product.
495                if !I::LHS_UNSIGNED {
496                    lo = i8_ops.sub(lo, zero_point);
497                    hi = i8_ops.sub(hi, zero_point);
498                }
499
500                // Load vblock elements from LHS
501                let [lhs_lo, lhs_hi] = i8_ops.load_many::<2>(row_vblock);
502
503                // Compute i8 x i8 -> i32 or u8 x i8 -> i32 dot product.
504                let mut dot_lo = isa.dot(lo, lhs_lo, zero_i32);
505                let mut dot_hi = isa.dot(hi, lhs_hi, zero_i32);
506
507                // If using u8 x i8 dot product, compensate for not subtracting
508                // the zero point before the dot product.
509                if I::LHS_UNSIGNED {
510                    let zero_point_i32 = i32_ops.splat(8);
511                    let lhs_lo_sum = isa.dot(i8_ops.splat(1), lhs_lo, zero_i32);
512                    let lhs_hi_sum = isa.dot(i8_ops.splat(1), lhs_hi, zero_i32);
513                    dot_lo = i32_ops.sub(dot_lo, i32_ops.mul(zero_point_i32, lhs_lo_sum));
514                    dot_hi = i32_ops.sub(dot_hi, i32_ops.mul(zero_point_i32, lhs_hi_sum));
515                }
516
517                let float_lo = i32_ops.to_float(dot_lo);
518                let float_hi = i32_ops.to_float(dot_hi);
519
520                match SCALES_PER_VBLOCK {
521                    1 => {
522                        let col_scale = col_scales[vblock_idx >> vecs_per_block_log2];
523                        let row_scale = lhs_scales[vblock_idx >> vecs_per_block_log2];
524
525                        let scale = ops.splat(col_scale * row_scale);
526
527                        acc[0] = ops.mul_add(float_lo, scale, acc[0]);
528                        acc[1] = ops.mul_add(float_hi, scale, acc[1]);
529                    }
530                    2 => {
531                        let block_idx = vblock_idx * 2;
532
533                        let col_scale_lo = col_scales[block_idx];
534                        let col_scale_hi = col_scales[block_idx + 1];
535
536                        let row_scale_lo = lhs_scales[block_idx];
537                        let row_scale_hi = lhs_scales[block_idx + 1];
538
539                        let scale_lo = ops.splat(col_scale_lo * row_scale_lo);
540                        let scale_hi = ops.splat(col_scale_hi * row_scale_hi);
541
542                        acc[0] = ops.mul_add(float_lo, scale_lo, acc[0]);
543                        acc[1] = ops.mul_add(float_hi, scale_hi, acc[1]);
544                    }
545                    4 => {
546                        let block_idx = vblock_idx * 4;
547
548                        let col_scale_a = col_scales[block_idx];
549                        let col_scale_b = col_scales[block_idx + 1];
550                        let col_scale_c = col_scales[block_idx + 2];
551                        let col_scale_d = col_scales[block_idx + 3];
552
553                        let row_scale_a = lhs_scales[block_idx];
554                        let row_scale_b = lhs_scales[block_idx + 1];
555                        let row_scale_c = lhs_scales[block_idx + 2];
556                        let row_scale_d = lhs_scales[block_idx + 3];
557
558                        let scale_a = ops.splat(col_scale_a * row_scale_a);
559                        let scale_b = ops.splat(col_scale_b * row_scale_b);
560                        let scale_c = ops.splat(col_scale_c * row_scale_c);
561                        let scale_d = ops.splat(col_scale_d * row_scale_d);
562
563                        let scale_ab = ops.select(scale_a, scale_b, lo_half_mask);
564                        let scale_cd = ops.select(scale_c, scale_d, lo_half_mask);
565
566                        acc[0] = ops.mul_add(float_lo, scale_ab, acc[0]);
567                        acc[1] = ops.mul_add(float_hi, scale_cd, acc[1]);
568                    }
569                    8 => {
570                        let block_idx = vblock_idx * 8;
571
572                        let scales: [<I::Isa as Isa>::F32; 8] = std::array::from_fn(|i| {
573                            ops.splat(col_scales[block_idx + i] * lhs_scales[block_idx + i])
574                        });
575
576                        // Replicate eight f32 scales to fill two SIMD vectors.
577                        // For a 512-bit vector we have:
578                        // [0000 1111 2222 3333] [4444 5555 6666 7777].
579                        let scales_01 = ops.select(scales[0], scales[1], lo_quad_mask);
580                        let scales_23 = ops.select(scales[2], scales[3], lo_three_quads_mask);
581                        let scales_0123 = ops.select(scales_01, scales_23, lo_half_mask);
582                        let scales_45 = ops.select(scales[4], scales[5], lo_quad_mask);
583                        let scales_67 = ops.select(scales[6], scales[7], lo_three_quads_mask);
584                        let scales_4567 = ops.select(scales_45, scales_67, lo_half_mask);
585
586                        acc[0] = ops.mul_add(float_lo, scales_0123, acc[0]);
587                        acc[1] = ops.mul_add(float_hi, scales_4567, acc[1]);
588                    }
589                    _ => unreachable!(),
590                }
591            }
592
593            let acc = ops.add(acc[0], acc[1]);
594            let mut acc = ops.sum(acc);
595
596            // Handle tail blocks in column. This contains < 128 elements
597            // (512 / 4).
598            if n_tail_blocks > 0 {
599                let row_tail_blocks = row_vblocks.remainder().chunks_exact(elements_per_block);
600                let col_tail_blocks = col_vblocks.remainder().chunks_exact(elements_per_block / 2);
601                debug_assert_eq!(row_tail_blocks.len(), n_tail_blocks);
602
603                let mut tail_acc = 0.;
604                for (i, (lhs_block, rhs_block)) in row_tail_blocks.zip(col_tail_blocks).enumerate()
605                {
606                    let col_scale = col_scales[col_scales.len() - n_tail_blocks + i];
607                    let row_scale = lhs_scales[lhs_scales.len() - n_tail_blocks + i];
608                    let scale = col_scale * row_scale;
609
610                    let mut acc = 0.;
611                    for ([x_lo, x_hi], y) in lhs_block.as_chunks::<2>().0.iter().zip(rhs_block) {
612                        let y_lo = (y & 0x0F) as i32 - 8;
613                        let y_hi = (y >> 4) as i32 - 8;
614                        acc += (*x_lo as i32 * y_lo + *x_hi as i32 * y_hi) as f32 * scale;
615                    }
616                    tail_acc += acc;
617                }
618                acc += tail_acc;
619            }
620
621            out.write(acc);
622        }
623
624        unsafe { out.assume_init() }
625    }
626}
627
628impl<'a> SimdInt8DotOp for VecDotMatrixQuant<'a> {
629    type Output = &'a mut [f32];
630
631    #[inline(always)]
632    fn eval<I: Int8DotIsa>(self, isa: I) -> Self::Output {
633        let u8_ops = isa.isa().u8();
634
635        // Columns are processed in "vblocks" whose size is the number of
636        // 4-bit elements that can be loaded into a SIMD vector. This can be
637        // larger or smaller than the block size of the RHS.
638        let elements_per_vec = u8_ops.len() * 2;
639
640        // Number of scale values and zero points we will use for each vblock.
641        // The maximum supported SIMD width is 512 bits (128 x u4) and the
642        // minimum block size is 16, so the maximum value is 128/16 = 8.
643        let scales_per_vblock = (elements_per_vec / self.rhs.elements_per_block()).max(1);
644
645        match scales_per_vblock {
646            1 => self.eval_impl::<I, 1>(isa),
647            2 => self.eval_impl::<I, 2>(isa),
648            4 => self.eval_impl::<I, 4>(isa),
649            8 => self.eval_impl::<I, 8>(isa),
650            _ => unreachable!("unsupported scales_per_vblock"),
651        }
652    }
653}
654
655/// Matrix which is quantized into blocks along the K dimension.
656///
657/// The data layout and supported bit/block sizes follow ONNX Runtime's MatMulNBits
658/// operator. See <https://github.com/microsoft/onnxruntime/blob/main/docs/ContribOperators.md>.
659#[derive(Copy, Clone)]
660pub struct BlockQuantizedMatrix<'a, T> {
661    /// Quantized data of shape (N, k_blocks, block_size).
662    quant: Contiguous<NdTensorView<'a, u8, 3>>,
663
664    /// Scales of shape (N, k_blocks)
665    scales: Contiguous<NdTensorView<'a, T, 2>>,
666
667    /// Bits per quantized element.
668    ///
669    /// Must be a divisor of `block_size * 8`.
670    bits: u8,
671}
672
673impl<'a, T: Copy> BlockQuantizedMatrix<'a, T> {
674    /// Minimum supported number of elements per block.
675    pub const MIN_BLOCK_SIZE: usize = 16;
676
677    /// Create a block-quantized RHS matrix input.
678    ///
679    /// `quant` is the block-quantized input with shape (cols, k_blocks,
680    /// block_size). `scales` are the per-block scales with shape (cols,
681    /// k_blocks). `bits` specifies the number of bits per element in each
682    /// block.
683    ///
684    /// The number of elements per quantized block must be a power of 2 of at
685    /// least `MIN_BLOCK_SIZE`.
686    pub fn new(
687        quant: Contiguous<NdTensorView<'a, u8, 3>>,
688        scales: Contiguous<NdTensorView<'a, T, 2>>,
689        bits: u8,
690    ) -> Result<Self, BlockQuantizedError> {
691        // ONNX Runtime currently supports 2, 4 or 8 bits per element. These
692        // values have the convenient property that a byte is a whole number
693        // of elements. We only support 4 bits for the moment.
694        if !matches!(bits, 4 | 8) {
695            return Err(BlockQuantizedError::UnsupportedElementSize);
696        }
697        let n_elem = 8 / bits;
698
699        let [_batch, _k_blocks, block_bytes] = quant.shape();
700
701        let block_size = block_bytes * n_elem.as_usize();
702        if !block_size.is_power_of_two() || block_size < Self::MIN_BLOCK_SIZE {
703            return Err(BlockQuantizedError::UnsupportedBlockSize);
704        }
705
706        Ok(Self {
707            quant,
708            scales,
709            bits,
710        })
711    }
712
713    /// Return the number of rows in the dequantized matrix.
714    pub fn rows(&self) -> usize {
715        self.blocks_per_column() * self.elements_per_block()
716    }
717
718    /// Return the number of columns in the dequantized matrix.
719    pub fn cols(&self) -> usize {
720        self.quant.size(0)
721    }
722
723    /// Extract a slice of columns from the matrix.
724    pub(crate) fn slice(&self, col_range: Range<usize>) -> BlockQuantizedMatrix<'a, T> {
725        BlockQuantizedMatrix {
726            quant: Contiguous::new(self.quant.slice(col_range.clone())).unwrap(),
727            scales: Contiguous::new(self.scales.slice(col_range)).unwrap(),
728            bits: self.bits,
729        }
730    }
731
732    /// Return the number of bits per element
733    pub fn n_bits(&self) -> u8 {
734        self.bits
735    }
736
737    /// Return the number of blocks in each column.
738    pub(crate) fn blocks_per_column(&self) -> usize {
739        self.quant.size(1)
740    }
741
742    pub(crate) fn elements_per_block(&self) -> usize {
743        (self.bytes_per_block() * 8) / self.bits.as_usize()
744    }
745
746    pub(crate) fn bytes_per_block(&self) -> usize {
747        self.quant.size(2)
748    }
749
750    /// Return the packed data for a range of blocks in a column.
751    pub(crate) fn column_data(
752        &self,
753        col: usize,
754        start_block: usize,
755        n_blocks: usize,
756    ) -> Option<&[u8]> {
757        if self.quant.size(1) < start_block + n_blocks {
758            return None;
759        }
760        let offset = self.quant.offset([col, start_block, 0])?;
761        let len = self.quant.size(2);
762        Some(&self.quant.data()[offset..offset + len * n_blocks])
763    }
764
765    /// Return the scale factors for a range of blocks in a column.
766    pub(crate) fn column_scales(
767        &self,
768        col: usize,
769        start_block: usize,
770        n_blocks: usize,
771    ) -> Option<&[T]> {
772        if self.scales.size(1) < start_block + n_blocks {
773            return None;
774        }
775        let offset = self.scales.offset([col, start_block])?;
776        Some(&self.scales.data()[offset..offset + n_blocks])
777    }
778}
779
780/// Return the default zero point for n-bit quantization.
781///
782/// This is an i16 because the maximum value is 128 (when n_bits=8) and the
783/// value is used in signed subtractions.
784///
785/// See docs for `zero_points` input to MatMulNBits in
786/// <https://github.com/microsoft/onnxruntime/blob/main/docs/ContribOperators.md>.
787pub const fn nbit_zero_point(n_bits: u8) -> i16 {
788    assert!(n_bits >= 2 && n_bits <= 8);
789    1 << (n_bits - 1)
790}
791
792#[cfg(test)]
793pub fn pack_4bit_elements(vals: &[i8], zero_point: i8) -> Vec<u8> {
794    let (chunks, tail) = vals.as_chunks::<2>();
795    assert!(tail.is_empty());
796    chunks
797        .iter()
798        .copied()
799        .map(|[even, odd]| {
800            let lo = (even + zero_point) as u8;
801            let hi = (odd + zero_point) as u8;
802            (lo & 0x0F) | (hi << 4)
803        })
804        .collect()
805}
806
807#[cfg(test)]
808mod tests {
809    use std::mem::MaybeUninit;
810
811    use rten_tensor::rng::XorShiftRng;
812    use rten_tensor::test_util::{expect_equal, expect_equal_with_tolerance};
813    use rten_tensor::{AsView, Contiguous, Layout, NdTensor, NdTensorView};
814    use rten_testing::TestCases;
815
816    use super::{
817        BlockQuantizedGemm, BlockQuantizedMatrix, ComputeMode, nbit_zero_point, pack_4bit_elements,
818        quantize,
819    };
820
821    fn reference_gemm_f32_with_block_quantized_rhs(
822        lhs: NdTensorView<f32, 2>,
823        rhs: NdTensorView<u8, 3>,
824        rhs_scales: NdTensorView<f32, 2>,
825    ) -> NdTensor<f32, 2> {
826        let [m, k] = lhs.shape();
827        let [n, _k_blocks, block_size] = rhs.shape();
828        let elems_per_block = block_size * 2;
829        let zero_point = 8;
830
831        let mut out = NdTensor::zeros([m, n]);
832
833        for row in 0..m {
834            for col in 0..n {
835                let mut acc = 0.;
836                for ki in 0..k {
837                    let k_block = ki / elems_per_block;
838                    let block_idx = ki % elems_per_block;
839                    let scale = rhs_scales[[col, k_block]];
840
841                    let byte = rhs[[col, k_block, block_idx / 2]];
842                    let elem = if ki % 2 == 0 { byte & 0x0F } else { byte >> 4 };
843                    let dequantized_elem = (elem as i32 - zero_point) as f32 * scale;
844
845                    acc += lhs[[row, ki]] * dequantized_elem;
846                }
847                out[[row, col]] = acc;
848            }
849        }
850
851        out
852    }
853
854    #[test]
855    fn test_block_quantized_matrix() {
856        let zero_point = nbit_zero_point(4) as i8;
857        let elems: Vec<i8> = (-8..8).cycle().take(256).collect();
858        let packed = pack_4bit_elements(&elems, zero_point);
859
860        let block_bytes = 16;
861        let cols = 4;
862        let k_blocks = 2;
863        let n_bits = 4;
864
865        let quants = NdTensor::from_data([cols, k_blocks, block_bytes], packed);
866        let scales = NdTensorView::from_data([cols, k_blocks], &[1., 2., 3., 4., 5., 6., 7., 8.]);
867        let mat = BlockQuantizedMatrix::new(
868            Contiguous::new(quants.view()).unwrap(),
869            Contiguous::new(scales.view()).unwrap(),
870            n_bits,
871        )
872        .unwrap();
873
874        assert_eq!(mat.rows(), 64);
875        assert_eq!(mat.cols(), cols);
876        assert_eq!(mat.elements_per_block(), 32);
877        assert_eq!(mat.blocks_per_column(), k_blocks);
878
879        assert_eq!(
880            mat.column_data(0, 0, 1).unwrap(),
881            pack_4bit_elements(&elems[..32], zero_point)
882        );
883        assert_eq!(mat.column_scales(0, 0, 2), Some([1.0, 2.0].as_slice()));
884        assert_eq!(
885            mat.column_data(3, 1, 1).unwrap(),
886            pack_4bit_elements(&elems[256 - 32..], zero_point)
887        );
888        assert_eq!(mat.column_scales(3, 1, 1), Some([8.0].as_slice()));
889
890        // Out of bounds column.
891        assert_eq!(mat.column_data(4, 1, 1), None);
892        // Out of bounds K block.
893        assert_eq!(mat.column_data(3, 2, 1), None);
894    }
895
896    // The ONNX Runtime definition of MatMulNBits specifies that the block
897    // size must be a power of 2 and >= 16. The ORT implementation supports
898    // block sizes from 16 to 256. The implementation in this crate is more
899    // general and supports larger block sizes. 256 is large enough to test
900    // all code paths on all architectures.
901    const BLOCK_SIZES: [usize; 5] = [16, 32, 64, 128, 256];
902
903    #[test]
904    fn test_quantize() {
905        let mut rng = XorShiftRng::new(1234);
906        for block_size in BLOCK_SIZES {
907            let batch = 2;
908            let rows = 3;
909            let n_blocks = 2;
910            let k = block_size * n_blocks;
911
912            let mut data = NdTensor::rand([batch, rows, k], &mut rng);
913            // Shift range from [0, 1] to [-1, -1]
914            data.apply(|x| (x - 0.5) * 2.);
915
916            let (quantized, scales) = quantize(Contiguous::new(data.view()).unwrap(), block_size);
917
918            let dequantized: Vec<f32> = quantized
919                .inner_iter::<1>()
920                .zip(scales.iter())
921                .flat_map(|(block, scale)| block.iter().map(move |x| *x as f32 * scale))
922                .collect();
923
924            let max_err = data
925                .iter()
926                .zip(dequantized)
927                .map(|(x, y)| (x - y).abs())
928                .fold(f32::MIN, |max, x| x.max(max));
929            let threshold = 0.004;
930
931            assert!(
932                max_err <= threshold,
933                "max_err {max_err} exceeds {threshold}"
934            );
935        }
936    }
937
938    #[test]
939    fn test_block_quantized_gemm() {
940        #[derive(Clone, Debug)]
941        struct Case {
942            block_size: usize,
943            n_rows: usize,
944            n_cols: usize,
945            n_blocks: usize,
946            compute: ComputeMode,
947            tolerance: Option<f32>,
948        }
949
950        // Max u4 elements in a SIMD vector.
951        let max_vblock_size = 128;
952
953        let mut cases = Vec::new();
954
955        for block_size in BLOCK_SIZES {
956            cases.push(Case {
957                n_rows: 1,
958                n_cols: 3,
959                n_blocks: (max_vblock_size / block_size).max(1),
960                block_size,
961                compute: ComputeMode::Float,
962                tolerance: None,
963            });
964        }
965
966        // Add a case that will exercise both the main and tail loops.
967        cases.push(Case {
968            n_rows: 1,
969            n_cols: 1,
970            // 16 x u4 = 64 bits, smaller than vector length.
971            block_size: 16,
972            // (max_vblock_size / 16) to use main loop once, plus one for a tail.
973            n_blocks: (max_vblock_size / 16) + 1,
974            compute: ComputeMode::Float,
975            tolerance: None,
976        });
977
978        // Add cases that use int8 quantization of the LHS.
979        for (block_size, atol) in [(16, 0.1), (32, 0.1), (64, 0.2), (128, 0.2), (256, 0.3)] {
980            cases.push(Case {
981                n_rows: 1,
982                n_cols: 3,
983                block_size,
984                n_blocks: (max_vblock_size / block_size).max(1),
985                compute: ComputeMode::Int8,
986                tolerance: Some(atol),
987            });
988        }
989
990        // Add a case that will exercise both the main and tail loops using int8.
991        cases.push(Case {
992            n_rows: 1,
993            n_cols: 1,
994            // 16 x u4 = 64 bits, smaller than vector length.
995            block_size: 16,
996            // (max_vblock_size / 16) to use main loop once, plus one for a tail.
997            n_blocks: (max_vblock_size / 16) + 1,
998            compute: ComputeMode::Int8,
999            tolerance: Some(0.1),
1000        });
1001
1002        // Test K=0. The output must still be initialized even though there
1003        // are no computations to do.
1004        cases.push(Case {
1005            n_rows: 1,
1006            n_cols: 1,
1007            block_size: 32,
1008            n_blocks: 0,
1009            compute: ComputeMode::Int8,
1010            tolerance: None,
1011        });
1012        cases.push(Case {
1013            n_rows: 1,
1014            n_cols: 1,
1015            block_size: 32,
1016            n_blocks: 0,
1017            compute: ComputeMode::Float,
1018            tolerance: None,
1019        });
1020
1021        cases.test_each_clone(|case| {
1022            let Case {
1023                n_rows,
1024                n_cols,
1025                n_blocks,
1026                block_size,
1027                compute,
1028                tolerance,
1029            } = case;
1030
1031            let mut rng = XorShiftRng::new(1234);
1032
1033            let gemm = BlockQuantizedGemm::new().with_compute(compute);
1034            let lhs = NdTensor::<f32, 2>::rand([n_rows, n_blocks * block_size], &mut rng);
1035            let rhs_data = NdTensor::<u8, 3>::rand([n_cols, n_blocks, block_size / 2], &mut rng);
1036            let rhs_scales = NdTensor::<f32, 2>::rand([n_cols, n_blocks], &mut rng);
1037            let bqm = BlockQuantizedMatrix::new(
1038                Contiguous::new(rhs_data.view()).unwrap(),
1039                Contiguous::new(rhs_scales.view()).unwrap(),
1040                4,
1041            )
1042            .unwrap();
1043
1044            let expected = reference_gemm_f32_with_block_quantized_rhs(
1045                lhs.view(),
1046                rhs_data.view(),
1047                rhs_scales.view(),
1048            );
1049
1050            let mut out = Vec::with_capacity(n_cols);
1051
1052            // Some platforms will zero-initialize new allocations. Manually
1053            // fill with invalid data to make sure we don't rely on this.
1054            out.spare_capacity_mut().fill(MaybeUninit::new(f32::NAN));
1055
1056            let result = gemm
1057                .batched_gemm_uninit(
1058                    out.spare_capacity_mut(),
1059                    lhs.reshaped([1, lhs.size(0), lhs.size(1)]).view(),
1060                    bqm,
1061                )
1062                .unwrap();
1063            let result_matrix = NdTensorView::from_data([1, result.len()], result.as_ref());
1064
1065            if let Some(atol) = tolerance {
1066                let rtol = 0.;
1067                expect_equal_with_tolerance(&result_matrix, &expected.view(), atol, rtol).unwrap();
1068            } else {
1069                expect_equal(&result_matrix, &expected.view()).unwrap();
1070            }
1071        });
1072    }
1073}