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}