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    ///
47    /// Resolved once per process. The feature probe underneath is a
48    /// `sysctlbyname` walk on Apple platforms, and below macOS 15 /
49    /// iOS 18 there is no `hw.optional.arm.caps` fast path, so it is
50    /// ~20 sysctls rather than one bit test. That is cheap once and not
51    /// cheap per matmul, and [`preferred_interleave`] and
52    /// [`interleaved_gemm_is_accelerated`] both call this on paths that
53    /// run per matrix.
54    #[inline]
55    pub fn detect() -> Self {
56        static CACHED: std::sync::OnceLock<AccelX4> = std::sync::OnceLock::new();
57        *CACHED.get_or_init(Self::probe)
58    }
59
60    fn probe() -> Self {
61        #[cfg(target_arch = "aarch64")]
62        {
63            if std::arch::is_aarch64_feature_detected!("i8mm") {
64                return AccelX4::NeonI8mm;
65            }
66        }
67        #[cfg(target_arch = "x86_64")]
68        {
69            if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
70                return AccelX4::Avx2;
71            }
72        }
73        AccelX4::Portable
74    }
75
76    /// Whether this is a real SIMD kernel rather than the scalar
77    /// reference.
78    #[inline]
79    pub fn is_simd(self) -> bool {
80        // Exhaustive on purpose, with no `_` arm: a new kernel family
81        // must answer this question rather than inherit an answer.
82        match self {
83            AccelX4::NeonI8mm | AccelX4::Avx2 => true,
84            AccelX4::Portable => false,
85        }
86    }
87}
88
89/// Whether the `×4` batch GEMMs run a SIMD kernel on this host for
90/// `interleave`-packed weights.
91///
92/// **One predicate.** The five `q*_gemm_uses_acts_x4` entry points are
93/// the same question asked with a different name, and they used to be
94/// five separately written `cfg` blocks — the exact shape (two or more
95/// structures that must agree, with nothing enforcing it) that this repo
96/// keeps paying for. They now all delegate here, so a host either has
97/// the whole `×4` tier or none of it, and no kind can be told "yes" while
98/// its kernel is missing.
99///
100/// The interleave-8 `×4` GEMMs -- the ones reading a pre-interleaved
101/// activation quad -- exist only for that layout, on either
102/// architecture, which is why the width is part of the question. This
103/// is NOT the same question as "does a SIMD batch GEMM exist at all";
104/// see [`batch_gemm_is_accelerated`].
105#[inline]
106pub fn interleaved_gemm_is_accelerated(interleave: usize) -> bool {
107    interleave == 8 && AccelX4::detect().is_simd()
108}
109
110/// Whether a SIMD batch GEMM -- of ANY layout -- runs on this host for
111/// `interleave`-packed weights.
112///
113/// [`interleaved_gemm_is_accelerated`] answers a narrower question: it
114/// is about the interleave-8 quad kernels specifically, which is what
115/// the five `q*_gemm_uses_acts_x4` entry points need, because that quad
116/// is the thing they decide whether to prepare.
117///
118/// It is not the right question for "is the batch tier worth taking on
119/// this host", and reading it as though it were is a real drift. Four
120/// of the five kinds -- Q4_K, Q5_K, Q8_0, Q4_0 -- also have a width-4
121/// `dotprod` GEMM in `gemm_*_group` (`gemm_*_neon_sdot`), which runs on
122/// every aarch64 host with `dotprod` and no `i8mm`: every Apple M1, every
123/// A14-and-earlier iPhone, and the Cortex-A55-class cores that are still
124/// most of the Android fleet. On those hosts the interleave-8 predicate
125/// says "no SIMD GEMM" while a SIMD GEMM is what actually runs.
126///
127/// Q6_K is the exception and deliberately so: it has no width-4 GEMM
128/// because the scalar Kx8 GEMM measured SLOWER than the per-row NEON dot
129/// on ARM (see [`q6_kx8_gemm_uses_acts_x4`]). It falls to the per-row
130/// path there, which is the intended behaviour and not a missing kernel.
131/// So this predicate answers "does the tier buy anything here", and the
132/// per-kind entry points still decide what each kind does with it.
133#[inline]
134pub fn batch_gemm_is_accelerated(interleave: usize) -> bool {
135    if interleaved_gemm_is_accelerated(interleave) {
136        return true;
137    }
138    // The `gemm_*_neon_sdot` arm in `gemm_*_group`, for the four kinds
139    // that have one.
140    #[cfg(target_arch = "aarch64")]
141    let width_4_simd = interleave == 4 && std::arch::is_aarch64_feature_detected!("dotprod");
142    #[cfg(not(target_arch = "aarch64"))]
143    let width_4_simd = {
144        let _ = interleave;
145        false
146    };
147    width_4_simd
148}
149
150/// The qs interleave width a matrix should be packed with on this host:
151/// 8 wherever the `×4` GEMMs have a SIMD kernel, 4 otherwise.
152///
153/// **One width, derived from the kernel choice.** The five
154/// `q*_interleave` entry points below used to answer this separately,
155/// and they had already drifted: the K-quants returned 8 on x86
156/// unconditionally while Q8_0 and Q4_0 returned 4 there, so packing an
157/// x86 host for the AVX2 GEMM would have given three kinds the layout
158/// its kernel reads and two kinds a layout with none. Asking
159/// [`AccelX4::detect`] once removes that possibility instead of fixing
160/// its instance.
161///
162/// The width is a pure function of the host, so it is constant for the
163/// life of the process — which is what lets the repack cache in
164/// `ferrox_core::weight_matrix::repack_cache` key on `(mapping, format,
165/// rows, cols)` without the width in the key. If this ever became a
166/// per-matrix choice, the width would have to join that key: two
167/// packings of one matrix at different widths are different bytes, and
168/// serving one where the other was asked for is a wrong answer rather
169/// than a panic.
170#[inline]
171pub fn preferred_interleave() -> usize {
172    if AccelX4::detect().is_simd() {
173        8
174    } else {
175        4
176    }
177}
178
179/// Decode one 12-byte packed scale/min group into 8 scales + 8 mins (u8).
180#[inline]
181pub(crate) fn decode_scales_mins(
182    scales12: &[u8],
183    scales_out: &mut [u8; 8],
184    mins_out: &mut [u8; 8],
185) {
186    debug_assert!(scales12.len() >= 12);
187    let mut utmp = [0u32; 4];
188    utmp[0] = u32::from_le_bytes(scales12[0..4].try_into().unwrap());
189    utmp[1] = u32::from_le_bytes(scales12[4..8].try_into().unwrap());
190    utmp[2] = u32::from_le_bytes(scales12[8..12].try_into().unwrap());
191    utmp[3] = ((utmp[2] >> 4) & KMASK2) | (((utmp[1] >> 6) & KMASK3) << 4);
192    let uaux_0 = utmp[1] & KMASK1;
193    utmp[1] = (utmp[2] & KMASK2) | (((utmp[0] >> 6) & KMASK3) << 4);
194    utmp[2] = uaux_0;
195    utmp[0] &= KMASK1;
196    let bytes = unsafe { std::slice::from_raw_parts(utmp.as_ptr() as *const u8, 16) };
197    scales_out.copy_from_slice(&bytes[0..8]);
198    mins_out.copy_from_slice(&bytes[8..16]);
199}
200
201/// A quad of up to [`Q8K_ACTS_X4_NC`] Q8_0 activations, pre-interleaved
202/// into the layout llama.cpp's `ggml_quantize_mat_q8_0_4x8` writes into
203/// `block_q8_0x4` (`arch/arm/repack.cpp`): every 32-element block's qs in
204/// 8-byte runs, plus the per-block per-row scales. Consumed by the i8mm
205/// `4x8` GEMMs; prepared once per matmul, same hoist as [`Q8KActsX4`].
206pub struct Q8ActsX4 {
207    /// Real activations in the quad (≤ 4); rows `na..4` are zero padding.
208    pub na: usize,
209    /// Q8_0 blocks per activation (`n_cols / 32`).
210    pub n_blocks: usize,
211    /// Interleaved quants, `n_blocks * 128` long. Block `b`, 8-element run
212    /// `c`, quad row `a`, lane `k` ↦
213    /// `qs[b*128 + c*32 + a*8 + k] = acts[a].q[b*32 + c*8 + k]`.
214    pub qs: Vec<i8>,
215    /// Activation scales, `n_blocks * 4` long: `d[b*4 + a] = acts[a].d[b]`.
216    pub d: Vec<f32>,
217}
218
219/// Interleave a quad of Q8_0 activations for the `4x8` i8mm GEMMs
220/// (llama.cpp `ggml_quantize_mat_q8_0_4x8`, minus the quantization we
221/// already did). Zero-pads when `acts.len() < 4`. Available on every
222/// target so the portable GEMMs — and the tests pinning the NEON kernels
223/// to them — run anywhere.
224pub fn prepare_q8_acts_x4(acts: &[Q8Activations], n_cols: usize) -> Q8ActsX4 {
225    assert!(acts.len() <= Q8K_ACTS_X4_NC);
226    assert!(n_cols.is_multiple_of(Q8_0_BLOCK_ELEMS));
227    let na = acts.len();
228    let nb = n_cols / Q8_0_BLOCK_ELEMS;
229    let mut qs = vec![0i8; nb * Q8_0_BLOCK_ELEMS * 4];
230    let mut d = vec![0f32; nb * 4];
231    for (a, act) in acts.iter().enumerate() {
232        debug_assert_eq!(act.d.len(), nb);
233        debug_assert!(
234            !act.q.contains(&i8::MIN),
235            "the AVX2 Q8_0 GEMM negates the activation with `_mm256_sign_epi8`, \
236             and -128 negates to itself; every ggml quantizer clamps to +-127"
237        );
238        for b in 0..nb {
239            let src = &act.q[b * Q8_0_BLOCK_ELEMS..(b + 1) * Q8_0_BLOCK_ELEMS];
240            let dst = &mut qs[b * Q8_0_BLOCK_ELEMS * 4..(b + 1) * Q8_0_BLOCK_ELEMS * 4];
241            for (c, run) in src.as_chunks::<8>().0.iter().enumerate() {
242                dst[c * 32 + a * 8..c * 32 + a * 8 + 8].copy_from_slice(run);
243            }
244            d[b * 4 + a] = act.d[b];
245        }
246    }
247    Q8ActsX4 {
248        na,
249        n_blocks: nb,
250        qs,
251        d,
252    }
253}
254
255/// A quad of up to [`Q8K_ACTS_X4_NC`] Q8_K activations, pre-interleaved into
256/// the layout llama.cpp's `ggml_quantize_mat_q8_K_4x8` writes into
257/// `block_q8_Kx4` (`ggml-cpu/repack.cpp`): every super-block's qs, the folded
258/// `bsums` pairs, and the per-block per-row scales.
259///
260/// The i8mm GEMM consumes activations in this shape. Interleaving them once
261/// per matmul — instead of once per (row-group, block) inside the kernel —
262/// is the point: the old in-kernel repack was a scalar pass over
263/// `rows/8 · batch · cols` bytes with a div and a mod per element, roughly
264/// 4× the instruction count of the `vmmlaq_s32` math it fed.
265/// Activations per [`Q8KActsX4`] quad (llama.cpp's `q8_k_blocklen`).
266pub const Q8K_ACTS_X4_NC: usize = 4;
267
268pub struct Q8KActsX4 {
269    /// Real activations in the quad (≤ 4); rows `na..4` are zero padding.
270    pub na: usize,
271    /// Q8_K super-blocks per activation (`n_cols / 256`).
272    pub n_blocks: usize,
273    /// Interleaved quants, `n_blocks * 1024` long. Block `b`, 8-element run
274    /// `c`, quad row `a`, lane `k` ↦
275    /// `qs[b*1024 + c*32 + a*8 + k] = acts[a].q[b*256 + c*8 + k]`.
276    pub qs: Vec<i8>,
277    /// Folded `bsums` pairs, `n_blocks * 4 * 8` long:
278    /// `bsums[(b*4 + a)*8 + i] = acts[a].bsums[b*16 + 2i] + acts[a].bsums[b*16 + 2i + 1]`.
279    pub bsums: Vec<i16>,
280    /// Activation scales, `n_blocks * 4` long: `d[b*4 + a] = acts[a].d[b]`.
281    pub d: Vec<f32>,
282}
283
284/// Interleave a quad of activations for [`gemm_q4_kx8_group_x4`]
285/// (llama.cpp `ggml_quantize_mat_q8_K_4x8`, minus the quantization we
286/// already did). Zero-pads when `acts.len() < 4`, matching what the kernel's
287/// in-loop repack used to emit. Available on every target so the portable
288/// GEMM below — and the tests pinning the NEON kernel to it — run anywhere.
289pub fn prepare_q8_k_acts_x4(acts: &[Q8KActivations], n_cols: usize) -> Q8KActsX4 {
290    assert!(acts.len() <= Q8K_ACTS_X4_NC);
291    assert!(n_cols.is_multiple_of(Q4_K_BLOCK_ELEMS));
292    let na = acts.len();
293    let nb = n_cols / Q4_K_BLOCK_ELEMS;
294    let mut qs = vec![0i8; nb * Q4_K_BLOCK_ELEMS * 4];
295    let mut bsums = vec![0i16; nb * 4 * 8];
296    let mut d = vec![0f32; nb * 4];
297    for (a, act) in acts.iter().enumerate() {
298        debug_assert_eq!(act.n_blocks(), nb);
299        for b in 0..nb {
300            let src = &act.q[b * Q4_K_BLOCK_ELEMS..(b + 1) * Q4_K_BLOCK_ELEMS];
301            let dst = &mut qs[b * Q4_K_BLOCK_ELEMS * 4..(b + 1) * Q4_K_BLOCK_ELEMS * 4];
302            for (c, run) in src.as_chunks::<8>().0.iter().enumerate() {
303                dst[c * 32 + a * 8..c * 32 + a * 8 + 8].copy_from_slice(run);
304            }
305            let src_bs = &act.bsums[b * 16..(b + 1) * 16];
306            let dst_bs = &mut bsums[(b * 4 + a) * 8..(b * 4 + a) * 8 + 8];
307            for (slot, pair) in dst_bs.iter_mut().zip(src_bs.as_chunks::<2>().0) {
308                *slot = pair[0] + pair[1];
309            }
310            d[b * 4 + a] = act.d[b];
311        }
312    }
313    Q8KActsX4 {
314        na,
315        n_blocks: nb,
316        qs,
317        bsums,
318        d,
319    }
320}