Skip to main content

ferrox_quant/repack/
common.rs

1//! Pieces more than one interleaved kind family needs: the f16 read,
2//! the 6-bit K-quant scale/min decode, the `×4` kernel choice, and the
3//! pre-interleaved activation quads the batch GEMMs consume.
4
5use crate::{Q8Activations, Q8KActivations, Q4_K_BLOCK_ELEMS, Q8_0_BLOCK_ELEMS};
6use half::f16;
7
8pub(crate) const KMASK1: u32 = 0x3f3f_3f3f;
9pub(crate) const KMASK2: u32 = 0x0f0f_0f0f;
10pub(crate) const KMASK3: u32 = 0x0303_0303;
11
12#[inline]
13pub(crate) fn f16_from_bytes(b: &[u8]) -> f32 {
14    f16::from_le_bytes([b[0], b[1]]).to_f32()
15}
16
17/// Which `×4` GEMM kernel this host runs, resolved once instead of once
18/// per call.
19///
20/// The `gemm_*_group_x4` entry points are called once per (row-group ×
21/// activation-quad) pair, which on a `pp512` projection is 10^4 to 10^5
22/// calls per GEMM. Each one used to re-run `is_aarch64_feature_detected!`,
23/// whose relaxed atomic load LLVM cannot hoist out of the caller's loop.
24/// Callers now probe once per matmul and pass the answer down through the
25/// `_on` variants; [`gemm_q4_kx8_group_x4`] and its siblings stay as
26/// probe-per-call wrappers so existing callers and tests are unchanged.
27///
28/// This is a dispatch decision only. Every arm computes the same values,
29/// to within f32 rounding order; the portable arm is bit-identical to the
30/// per-activation GEMV, which is what the
31/// `*_x4_portable_is_bit_exact_vs_scalar_gemv` tests assert. Forcing
32/// [`AccelX4::Portable`] on an accelerated host is therefore a valid
33/// (slow) way to run, and the tests use it that way.
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub enum AccelX4 {
36    /// ARM i8mm `SMMLA` kernels in `super::neon`.
37    NeonI8mm,
38    /// x86_64 AVX2 + FMA kernels in `super::avx2`.
39    Avx2,
40    /// The portable scalar reference.
41    Portable,
42}
43
44impl AccelX4 {
45    /// The fastest kernel available on this host.
46    #[inline]
47    pub fn detect() -> Self {
48        #[cfg(target_arch = "aarch64")]
49        {
50            if std::arch::is_aarch64_feature_detected!("i8mm") {
51                return AccelX4::NeonI8mm;
52            }
53        }
54        #[cfg(target_arch = "x86_64")]
55        {
56            if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
57                return AccelX4::Avx2;
58            }
59        }
60        AccelX4::Portable
61    }
62
63    /// Whether this is a real SIMD kernel rather than the scalar
64    /// reference.
65    #[inline]
66    pub fn is_simd(self) -> bool {
67        // Exhaustive on purpose, with no `_` arm: a new kernel family
68        // must answer this question rather than inherit an answer.
69        match self {
70            AccelX4::NeonI8mm | AccelX4::Avx2 => true,
71            AccelX4::Portable => false,
72        }
73    }
74}
75
76/// Whether the `×4` batch GEMMs run a SIMD kernel on this host for
77/// `interleave`-packed weights.
78///
79/// **One predicate.** The five `q*_gemm_uses_acts_x4` entry points are
80/// the same question asked with a different name, and they used to be
81/// five separately written `cfg` blocks — the exact shape (two or more
82/// structures that must agree, with nothing enforcing it) that this repo
83/// keeps paying for. They now all delegate here, so a host either has
84/// the whole `×4` tier or none of it, and no kind can be told "yes" while
85/// its kernel is missing.
86///
87/// The `×4` GEMMs exist only for the interleave-8 layout, on either
88/// architecture, which is why the width is part of the question.
89#[inline]
90pub fn interleaved_gemm_is_accelerated(interleave: usize) -> bool {
91    interleave == 8 && AccelX4::detect().is_simd()
92}
93
94/// The qs interleave width a matrix should be packed with on this host:
95/// 8 wherever the `×4` GEMMs have a SIMD kernel, 4 otherwise.
96///
97/// **One width, derived from the kernel choice.** The five
98/// `q*_interleave` entry points below used to answer this separately,
99/// and they had already drifted: the K-quants returned 8 on x86
100/// unconditionally while Q8_0 and Q4_0 returned 4 there, so packing an
101/// x86 host for the AVX2 GEMM would have given three kinds the layout
102/// its kernel reads and two kinds a layout with none. Asking
103/// [`AccelX4::detect`] once removes that possibility instead of fixing
104/// its instance.
105///
106/// The width is a pure function of the host, so it is constant for the
107/// life of the process — which is what lets the repack cache in
108/// `ferrox_core::weight_matrix::repack_cache` key on `(mapping, format,
109/// rows, cols)` without the width in the key. If this ever became a
110/// per-matrix choice, the width would have to join that key: two
111/// packings of one matrix at different widths are different bytes, and
112/// serving one where the other was asked for is a wrong answer rather
113/// than a panic.
114#[inline]
115pub fn preferred_interleave() -> usize {
116    if AccelX4::detect().is_simd() {
117        8
118    } else {
119        4
120    }
121}
122
123/// Decode one 12-byte packed scale/min group into 8 scales + 8 mins (u8).
124#[inline]
125pub(crate) fn decode_scales_mins(
126    scales12: &[u8],
127    scales_out: &mut [u8; 8],
128    mins_out: &mut [u8; 8],
129) {
130    debug_assert!(scales12.len() >= 12);
131    let mut utmp = [0u32; 4];
132    utmp[0] = u32::from_le_bytes(scales12[0..4].try_into().unwrap());
133    utmp[1] = u32::from_le_bytes(scales12[4..8].try_into().unwrap());
134    utmp[2] = u32::from_le_bytes(scales12[8..12].try_into().unwrap());
135    utmp[3] = ((utmp[2] >> 4) & KMASK2) | (((utmp[1] >> 6) & KMASK3) << 4);
136    let uaux_0 = utmp[1] & KMASK1;
137    utmp[1] = (utmp[2] & KMASK2) | (((utmp[0] >> 6) & KMASK3) << 4);
138    utmp[2] = uaux_0;
139    utmp[0] &= KMASK1;
140    let bytes = unsafe { std::slice::from_raw_parts(utmp.as_ptr() as *const u8, 16) };
141    scales_out.copy_from_slice(&bytes[0..8]);
142    mins_out.copy_from_slice(&bytes[8..16]);
143}
144
145/// A quad of up to [`Q8K_ACTS_X4_NC`] Q8_0 activations, pre-interleaved
146/// into the layout llama.cpp's `ggml_quantize_mat_q8_0_4x8` writes into
147/// `block_q8_0x4` (`arch/arm/repack.cpp`): every 32-element block's qs in
148/// 8-byte runs, plus the per-block per-row scales. Consumed by the i8mm
149/// `4x8` GEMMs; prepared once per matmul, same hoist as [`Q8KActsX4`].
150pub struct Q8ActsX4 {
151    /// Real activations in the quad (≤ 4); rows `na..4` are zero padding.
152    pub na: usize,
153    /// Q8_0 blocks per activation (`n_cols / 32`).
154    pub n_blocks: usize,
155    /// Interleaved quants, `n_blocks * 128` long. Block `b`, 8-element run
156    /// `c`, quad row `a`, lane `k` ↦
157    /// `qs[b*128 + c*32 + a*8 + k] = acts[a].q[b*32 + c*8 + k]`.
158    pub qs: Vec<i8>,
159    /// Activation scales, `n_blocks * 4` long: `d[b*4 + a] = acts[a].d[b]`.
160    pub d: Vec<f32>,
161}
162
163/// Interleave a quad of Q8_0 activations for the `4x8` i8mm GEMMs
164/// (llama.cpp `ggml_quantize_mat_q8_0_4x8`, minus the quantization we
165/// already did). Zero-pads when `acts.len() < 4`. Available on every
166/// target so the portable GEMMs — and the tests pinning the NEON kernels
167/// to them — run anywhere.
168pub fn prepare_q8_acts_x4(acts: &[Q8Activations], n_cols: usize) -> Q8ActsX4 {
169    assert!(acts.len() <= Q8K_ACTS_X4_NC);
170    assert!(n_cols.is_multiple_of(Q8_0_BLOCK_ELEMS));
171    let na = acts.len();
172    let nb = n_cols / Q8_0_BLOCK_ELEMS;
173    let mut qs = vec![0i8; nb * Q8_0_BLOCK_ELEMS * 4];
174    let mut d = vec![0f32; nb * 4];
175    for (a, act) in acts.iter().enumerate() {
176        debug_assert_eq!(act.d.len(), nb);
177        debug_assert!(
178            !act.q.contains(&i8::MIN),
179            "the AVX2 Q8_0 GEMM negates the activation with `_mm256_sign_epi8`, \
180             and -128 negates to itself; every ggml quantizer clamps to +-127"
181        );
182        for b in 0..nb {
183            let src = &act.q[b * Q8_0_BLOCK_ELEMS..(b + 1) * Q8_0_BLOCK_ELEMS];
184            let dst = &mut qs[b * Q8_0_BLOCK_ELEMS * 4..(b + 1) * Q8_0_BLOCK_ELEMS * 4];
185            for (c, run) in src.as_chunks::<8>().0.iter().enumerate() {
186                dst[c * 32 + a * 8..c * 32 + a * 8 + 8].copy_from_slice(run);
187            }
188            d[b * 4 + a] = act.d[b];
189        }
190    }
191    Q8ActsX4 {
192        na,
193        n_blocks: nb,
194        qs,
195        d,
196    }
197}
198
199/// A quad of up to [`Q8K_ACTS_X4_NC`] Q8_K activations, pre-interleaved into
200/// the layout llama.cpp's `ggml_quantize_mat_q8_K_4x8` writes into
201/// `block_q8_Kx4` (`ggml-cpu/repack.cpp`): every super-block's qs, the folded
202/// `bsums` pairs, and the per-block per-row scales.
203///
204/// The i8mm GEMM consumes activations in this shape. Interleaving them once
205/// per matmul — instead of once per (row-group, block) inside the kernel —
206/// is the point: the old in-kernel repack was a scalar pass over
207/// `rows/8 · batch · cols` bytes with a div and a mod per element, roughly
208/// 4× the instruction count of the `vmmlaq_s32` math it fed.
209/// Activations per [`Q8KActsX4`] quad (llama.cpp's `q8_k_blocklen`).
210pub const Q8K_ACTS_X4_NC: usize = 4;
211
212pub struct Q8KActsX4 {
213    /// Real activations in the quad (≤ 4); rows `na..4` are zero padding.
214    pub na: usize,
215    /// Q8_K super-blocks per activation (`n_cols / 256`).
216    pub n_blocks: usize,
217    /// Interleaved quants, `n_blocks * 1024` long. Block `b`, 8-element run
218    /// `c`, quad row `a`, lane `k` ↦
219    /// `qs[b*1024 + c*32 + a*8 + k] = acts[a].q[b*256 + c*8 + k]`.
220    pub qs: Vec<i8>,
221    /// Folded `bsums` pairs, `n_blocks * 4 * 8` long:
222    /// `bsums[(b*4 + a)*8 + i] = acts[a].bsums[b*16 + 2i] + acts[a].bsums[b*16 + 2i + 1]`.
223    pub bsums: Vec<i16>,
224    /// Activation scales, `n_blocks * 4` long: `d[b*4 + a] = acts[a].d[b]`.
225    pub d: Vec<f32>,
226}
227
228/// Interleave a quad of activations for [`gemm_q4_kx8_group_x4`]
229/// (llama.cpp `ggml_quantize_mat_q8_K_4x8`, minus the quantization we
230/// already did). Zero-pads when `acts.len() < 4`, matching what the kernel's
231/// in-loop repack used to emit. Available on every target so the portable
232/// GEMM below — and the tests pinning the NEON kernel to it — run anywhere.
233pub fn prepare_q8_k_acts_x4(acts: &[Q8KActivations], n_cols: usize) -> Q8KActsX4 {
234    assert!(acts.len() <= Q8K_ACTS_X4_NC);
235    assert!(n_cols.is_multiple_of(Q4_K_BLOCK_ELEMS));
236    let na = acts.len();
237    let nb = n_cols / Q4_K_BLOCK_ELEMS;
238    let mut qs = vec![0i8; nb * Q4_K_BLOCK_ELEMS * 4];
239    let mut bsums = vec![0i16; nb * 4 * 8];
240    let mut d = vec![0f32; nb * 4];
241    for (a, act) in acts.iter().enumerate() {
242        debug_assert_eq!(act.n_blocks(), nb);
243        for b in 0..nb {
244            let src = &act.q[b * Q4_K_BLOCK_ELEMS..(b + 1) * Q4_K_BLOCK_ELEMS];
245            let dst = &mut qs[b * Q4_K_BLOCK_ELEMS * 4..(b + 1) * Q4_K_BLOCK_ELEMS * 4];
246            for (c, run) in src.as_chunks::<8>().0.iter().enumerate() {
247                dst[c * 32 + a * 8..c * 32 + a * 8 + 8].copy_from_slice(run);
248            }
249            let src_bs = &act.bsums[b * 16..(b + 1) * 16];
250            let dst_bs = &mut bsums[(b * 4 + a) * 8..(b * 4 + a) * 8 + 8];
251            for (slot, pair) in dst_bs.iter_mut().zip(src_bs.as_chunks::<2>().0) {
252                *slot = pair[0] + pair[1];
253            }
254            d[b * 4 + a] = act.d[b];
255        }
256    }
257    Q8KActsX4 {
258        na,
259        n_blocks: nb,
260        qs,
261        bsums,
262        d,
263    }
264}