ferrox_core/weight_matrix.rs
1//! `WeightMatrix`: a weight matrix that may live either as plain f32
2//! (small dims, embeddings, synthetic test weights) or as raw
3//! Q8_0/Q4_0 block bytes loaded straight from a GGUF file, with no f32
4//! expansion at load time. This is what lets ferrox load a
5//! multi-billion-parameter checkpoint without first blowing it up 4x
6//! in RAM: the loader (ferrox-models) hands tensors over still
7//! quantized, and every matmul call here dispatches to the fused
8//! dequant+dot kernels in ferrox-quant.
9
10use rayon::prelude::*;
11use std::ops::Range;
12use std::sync::Arc;
13
14use ferrox_gguf::GgmlType;
15
16use crate::tensor::Tensor;
17
18pub mod gpu_backend;
19pub mod hadamard;
20pub mod lora;
21mod repack_cache;
22
23#[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
24use gpu_backend::BackendDispatch;
25use gpu_backend::{with_gpu_backend_caps, with_gpu_backends, BackendCaps, Cuda, Metal};
26pub use lora::{LoraDelta, LoraScale, LoraShapeError, LoraStack};
27pub use repack_cache::MapId;
28use repack_cache::{
29 get_or_repack_q4_0x4, get_or_repack_q4k, get_or_repack_q5k, get_or_repack_q6k,
30 get_or_repack_q8x4,
31};
32
33/// Backing storage for a quantized weight matrix's raw bytes: either an
34/// owned buffer (synthetic/test weights, or any tensor that had to be
35/// copied for some other reason) or a zero-copy view into a shared
36/// memory-mapped GGUF file. This is the fix for the "loader read
37/// everything into a fresh Vec<u8>" inefficiency: a real checkpoint's
38/// resident memory should be the mmap itself, not a second copy of it,
39/// which is how llama.cpp's mmap-based loader both avoid
40/// doubling a multi-hundred-gigabyte checkpoint's memory footprint.
41pub enum WeightBytes {
42 Owned(Vec<u8>),
43 Mapped {
44 mmap: Arc<memmap2::Mmap>,
45 range: Range<usize>,
46 },
47 /// A sub-range of a shared, lease-style buffer (e.g. one matrix
48 /// inside an `ferrox_core::expert_store::ExpertLease`'s combined
49 /// gate/up/down bytes). Holding the `Arc` here is exactly what
50 /// makes the store's lease pinning structural: as long as any
51 /// `WeightMatrix` built over these bytes is alive, the cache entry's
52 /// strong count stays >1 and eviction cannot reuse it.
53 Shared {
54 buf: Arc<Vec<u8>>,
55 range: Range<usize>,
56 },
57}
58
59impl WeightBytes {
60 pub fn as_slice(&self) -> &[u8] {
61 match self {
62 WeightBytes::Owned(v) => v,
63 WeightBytes::Mapped { mmap, range } => &mmap[range.clone()],
64 WeightBytes::Shared { buf, range } => &buf[range.clone()],
65 }
66 }
67
68 pub fn len(&self) -> usize {
69 self.as_slice().len()
70 }
71
72 pub fn is_empty(&self) -> bool {
73 self.len() == 0
74 }
75
76 /// The identity a repack cache may key on, or `None` for bytes that
77 /// must never be cached by address.
78 ///
79 /// This *replaces* an `address_is_stable() -> bool`, and the boolean
80 /// was the bug: a yes/no answer cannot say whether the mapping that
81 /// made the address meaningful is still alive, so the cache went on
82 /// trusting an address after the mapping behind it was gone. See
83 /// [`MapId`] for the ABA that produces and how the `Weak` closes it.
84 ///
85 /// `Shared` stays `None`, and for a different reason that a `Weak`
86 /// would NOT fix: it is a lease over an expert store's *recycled*
87 /// buffer, so the allocation stays alive and keeps its address while
88 /// its CONTENTS are replaced by another expert's. Identity is stable
89 /// there and still means nothing. That produced fluent garbage on
90 /// OLMoE with expert streaming on, while the raw weight bytes
91 /// compared equal, because the corruption was in the CACHE and not
92 /// in the weights.
93 ///
94 /// `Owned` stays `None` too: a freed `Vec`'s address is reused, and
95 /// nothing holds a handle that could witness the free.
96 pub fn map_id(&self) -> Option<MapId> {
97 match self {
98 WeightBytes::Mapped { mmap, range } => Some(MapId::of(mmap, range.start)),
99 WeightBytes::Owned(_) | WeightBytes::Shared { .. } => None,
100 }
101 }
102
103 /// True if this is a zero-copy mmap view rather than an owned
104 /// heap allocation -- useful for tests/diagnostics asserting that
105 /// the loader actually took the zero-copy path.
106 pub fn is_mapped(&self) -> bool {
107 matches!(self, WeightBytes::Mapped { .. })
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub enum QuantKind {
113 Q8_0,
114 Q4_0,
115 /// The dominant real-world GGUF quantization formats (most
116 /// published checkpoints ship as Q4_K_M or similar K-quant mixes,
117 /// not the legacy Q4_0/Q8_0 formats above). See
118 /// `ferrox_quant`'s module docs for the block layout and
119 /// independent Python cross-validation.
120 Q4K,
121 Q5K,
122 Q6K,
123 /// The two more-aggressive K-quant tiers, used in Q2_K/Q3_K_M/
124 /// Q3_K_L-style quant mixes (the far more common Q4_K_M/Q5_K_M
125 /// mixes only combine with Q6_K, already covered above). See
126 /// `ferrox_quant`'s module docs and independent Python
127 /// cross-validation.
128 Q2K,
129 Q3K,
130 /// Legacy, largely-obsolete-for-new-releases formats, still
131 /// occasionally encountered. See `ferrox_quant`'s module docs;
132 /// byte layouts verified against real `ggml-common.h` source.
133 Q4_1,
134 Q5_0,
135 Q5_1,
136 Q8_1,
137 /// Non-linear ("codebook") quants: a 4-bit index maps through a
138 /// shared 16-entry signed lookup table instead of a linear
139 /// `nibble*scale+min` transform. See `ferrox_quant`'s module docs
140 /// and independent Python cross-validation.
141 IQ4NL,
142 IQ4XS,
143 /// The codebook-grid low-bit formats used throughout published
144 /// "Dynamic" low-bit GGUFs of large MoE models (grid-table
145 /// magnitudes + shared sign patterns; scalar kernels only so far).
146 /// See `ferrox_quant`'s module docs and the ggml-cross-validated
147 /// independent Python reference.
148 IQ1S,
149 IQ2XXS,
150 IQ3XXS,
151 /// The second codebook-grid tier (ggml tags 17/21/22/29), which the
152 /// published `UD-*` recipes reach for when the `_XXS` tier is too
153 /// lossy -- IQ3_S especially, since it is most of what an `IQ3_M`
154 /// mix contains. Scalar kernels only; goldens are the real compiled
155 /// ggml dequantizers' own output, asserted bit-exactly.
156 IQ2XS,
157 IQ2S,
158 IQ3S,
159 IQ1M,
160 /// GGUF *block*-MXFP4 (17-byte interleaved blocks, ggml tag 39) --
161 /// not the same layout as `WeightMatrix::Mxfp4`'s two-buffer
162 /// safetensors form, though the math is identical. Scalar kernel
163 /// only so far.
164 Mxfp4Gguf,
165 /// ggml `TQ1_0` and PrismML `PTQ1_0`: base-3 trits, 256 and 128 to
166 /// a block (`ferrox_quant::ternary`). Bonsai 2 27B is PTQ1_0.
167 Tq1_0,
168 Ptq1_0,
169}
170
171impl QuantKind {
172 /// Every variant, so exhaustiveness can be *tested* rather than
173 /// trusted. The kernel-coverage tests below iterate this; adding a
174 /// variant without adding it here fails to compile (the match in
175 /// [`Self::name`] is exhaustive and this list is checked against it).
176 pub const ALL: &'static [QuantKind] = &[
177 QuantKind::Q8_0,
178 QuantKind::Q4_0,
179 QuantKind::Q4K,
180 QuantKind::Q5K,
181 QuantKind::Q6K,
182 QuantKind::Q2K,
183 QuantKind::Q3K,
184 QuantKind::Q4_1,
185 QuantKind::Q5_0,
186 QuantKind::Q5_1,
187 QuantKind::Q8_1,
188 QuantKind::IQ4NL,
189 QuantKind::IQ4XS,
190 QuantKind::IQ1S,
191 QuantKind::IQ2XXS,
192 QuantKind::IQ3XXS,
193 QuantKind::IQ2XS,
194 QuantKind::IQ2S,
195 QuantKind::IQ3S,
196 QuantKind::IQ1M,
197 QuantKind::Mxfp4Gguf,
198 QuantKind::Tq1_0,
199 QuantKind::Ptq1_0,
200 ];
201
202 /// The GGUF-facing name. Also the key
203 /// [`ferrox_metal::gpu::matvec_launch_meta`] is looked up by, which
204 /// is why it is one function and not a `Debug` impl.
205 pub fn name(self) -> &'static str {
206 match self {
207 QuantKind::Q8_0 => "Q8_0",
208 QuantKind::Q4_0 => "Q4_0",
209 QuantKind::Q4K => "Q4_K",
210 QuantKind::Q5K => "Q5_K",
211 QuantKind::Q6K => "Q6_K",
212 QuantKind::Q2K => "Q2_K",
213 QuantKind::Q3K => "Q3_K",
214 QuantKind::Q4_1 => "Q4_1",
215 QuantKind::Q5_0 => "Q5_0",
216 QuantKind::Q5_1 => "Q5_1",
217 QuantKind::Q8_1 => "Q8_1",
218 QuantKind::IQ4NL => "IQ4_NL",
219 QuantKind::IQ4XS => "IQ4_XS",
220 QuantKind::IQ1S => "IQ1_S",
221 QuantKind::IQ2XXS => "IQ2_XXS",
222 QuantKind::IQ3XXS => "IQ3_XXS",
223 QuantKind::IQ2XS => "IQ2_XS",
224 QuantKind::IQ2S => "IQ2_S",
225 QuantKind::IQ3S => "IQ3_S",
226 QuantKind::IQ1M => "IQ1_M",
227 QuantKind::Mxfp4Gguf => "MXFP4",
228 QuantKind::Tq1_0 => "TQ1_0",
229 QuantKind::Ptq1_0 => "PTQ1_0",
230 }
231 }
232}
233
234/// Which quant kinds have a **Metal matvec** kernel, as the kernel name
235/// [`ferrox_metal::gpu::matvec_launch_meta`] resolves.
236///
237/// The table itself is [`Metal::matvec_kernel`]; this is the name the
238/// rest of the tree already imports, kept so the single source of truth
239/// moving did not become 30 edits in crates owned by someone else.
240pub fn metal_matvec_kind_name(kind: QuantKind) -> Option<&'static str> {
241 Metal::matvec_kernel(kind)
242}
243
244/// Which quant kinds have a **Metal batched simdgroup GEMM**
245/// (`*_mul_mm_sg`), the prefill path. Delegates to
246/// [`Metal::gemm_supported`].
247pub fn metal_mul_mm_kind_supported(kind: QuantKind) -> bool {
248 Metal::gemm_supported(kind)
249}
250
251/// Maps a GGUF tensor's on-disk dtype to the [`QuantKind`] a
252/// [`WeightMatrix`] uses to pick a fused dequant+dot kernel, or `None`
253/// for a dtype with no quantized kernel (F32, or one not implemented at
254/// all).
255///
256/// **The single source of truth for that question**, for the same
257/// reason [`metal_mul_mm_kind_supported`] is for its own: this table
258/// used to be copied into six GGUF loaders, and the copies drifted.
259/// Three of them (`loader`, `glm52_gguf_loader`, `kimi_gguf_loader`)
260/// listed 21 dtypes while the other three (`mla_gguf_loader`,
261/// `gemma4_gguf_loader`, `hybrid_gguf_loader`) listed 17 -- missing
262/// `IQ1_S`, `IQ2_XXS`, `IQ3_XXS` and `MXFP4`. A miss is not a slow
263/// path, it is `LoadError::UnsupportedDtype`, so a DeepSeek-MLA
264/// checkpoint quantized to `IQ2_XXS` -- an ordinary combination for a
265/// model that large -- was refused outright while the identical quant
266/// loaded fine on the generic path.
267pub fn quant_kind_for(dtype: GgmlType) -> Option<QuantKind> {
268 match dtype {
269 GgmlType::Q8_0 => Some(QuantKind::Q8_0),
270 GgmlType::Q4_0 => Some(QuantKind::Q4_0),
271 GgmlType::Q4K => Some(QuantKind::Q4K),
272 GgmlType::Q5K => Some(QuantKind::Q5K),
273 GgmlType::Q6K => Some(QuantKind::Q6K),
274 GgmlType::Q2K => Some(QuantKind::Q2K),
275 GgmlType::Q3K => Some(QuantKind::Q3K),
276 GgmlType::Q4_1 => Some(QuantKind::Q4_1),
277 GgmlType::Q5_0 => Some(QuantKind::Q5_0),
278 GgmlType::Q5_1 => Some(QuantKind::Q5_1),
279 GgmlType::Q8_1 => Some(QuantKind::Q8_1),
280 GgmlType::IQ4NL => Some(QuantKind::IQ4NL),
281 GgmlType::IQ4XS => Some(QuantKind::IQ4XS),
282 GgmlType::IQ2XS => Some(QuantKind::IQ2XS),
283 GgmlType::IQ2S => Some(QuantKind::IQ2S),
284 GgmlType::IQ3S => Some(QuantKind::IQ3S),
285 GgmlType::IQ1M => Some(QuantKind::IQ1M),
286 GgmlType::IQ1S => Some(QuantKind::IQ1S),
287 GgmlType::IQ2XXS => Some(QuantKind::IQ2XXS),
288 GgmlType::IQ3XXS => Some(QuantKind::IQ3XXS),
289 GgmlType::MXFP4 => Some(QuantKind::Mxfp4Gguf),
290 GgmlType::TQ1_0 => Some(QuantKind::Tq1_0),
291 GgmlType::PTQ1_0 => Some(QuantKind::Ptq1_0),
292 _ => None,
293 }
294}
295
296/// Which quant kinds have a **CUDA batched GEMM** (`mul_mm`), the
297/// prefill path. Delegates to [`Cuda::gemm_supported`], which is where
298/// the "UNRUN ON HARDWARE" caveat is written down.
299pub fn cuda_mul_mm_kind_supported(kind: QuantKind) -> bool {
300 Cuda::gemm_supported(kind)
301}
302
303/// Which quant kinds have a **CUDA matvec** kernel, the decode path.
304/// Delegates to [`Cuda::matvec_kernel`], whose `Option<&str>` is the
305/// shape Metal needs and CUDA does not — the `bool` is this wrapper.
306pub fn cuda_matvec_kind_supported(kind: QuantKind) -> bool {
307 Cuda::matvec_kernel(kind).is_some()
308}
309
310/// Which quant kinds take the CPU integer `vec_dot` path (activation
311/// quantized to Q8/Q8_K, int8xint8 dots) rather than the much slower f32
312/// dequant-dot. `cols` matters: the K-quant kernels need a whole number
313/// of 256-element super-blocks, the legacy ones 32-element blocks.
314pub fn cpu_int_dot_kind_supported(kind: QuantKind, cols: usize) -> bool {
315 match kind {
316 QuantKind::Q8_0 | QuantKind::Q4_0 => cols.is_multiple_of(32),
317 QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K => cols.is_multiple_of(256),
318 _ => false,
319 }
320}
321
322/// The backend dense matmuls will actually use in this process, decided
323/// by the same cached env/probe reads dispatch uses. CUDA wins when both
324/// are compiled in — and it wins here because it is first in
325/// [`gpu_backend::with_gpu_backends`], the single ordered list
326/// [`WeightMatrix::apply_gpu`] also expands, rather than because that
327/// order is written out a second time.
328pub fn active_backend() -> crate::kernel_registry::Backend {
329 #[allow(unused_macros)]
330 macro_rules! first_enabled {
331 ($b:ty) => {
332 if <$b as BackendDispatch>::dense_enabled() {
333 return <$b as BackendCaps>::ID;
334 }
335 };
336 }
337 with_gpu_backends!(first_enabled);
338 crate::kernel_registry::Backend::Cpu
339}
340
341/// Minimum multiply-accumulates a rayon task should carry before it is
342/// worth its own scheduling. Chosen by measurement, not derivation.
343///
344/// **This is a rayon-only mitigation and it is unreachable on
345/// [`crate::par::Backend::Spin`].** It exists to stop rayon splitting a
346/// matvec into tasks too small to repay a fork-join; the persistent pool
347/// has no fork-join to repay, so it chunks by pool width alone (see the
348/// `MIN_TASK_MACS` section of [`crate::par`]). Issue #27 asks for this
349/// constant to be deleted rather than retuned, and on the pool's path it
350/// is: [`WeightMatrix::min_rows_per_task`] returns before reading it
351/// whenever [`crate::par::backend`] picked the pool for this operation.
352/// It survives on the fork-join path, which is still every operation
353/// below [`crate::par::policy::SPIN_MIN_OP_MACS`], because removing it
354/// there re-opens the 13-16x small-model regression recorded on
355/// [`WeightMatrix::min_rows_per_task`].
356const MIN_TASK_MACS: usize = 1 << 16;
357
358/// Whether dense [`WeightMatrix::apply`] / [`WeightMatrix::apply_batch`]
359/// should try Metal first (when built with `--features metal`).
360///
361/// - `FERROX_METAL=0|false|off|cpu` — force CPU
362/// - `FERROX_METAL=1|true|on|metal` — force Metal attempt
363/// - unset / `auto` — Metal when [`ferrox_metal::gpu::probe`] finds a device
364///
365/// Decision is cached for the process lifetime (env read once).
366#[cfg(feature = "metal")]
367pub fn metal_dense_enabled() -> bool {
368 Metal::dense_enabled()
369}
370
371/// Whether dense [`WeightMatrix::apply`] should try CUDA first (when
372/// built with `--features cuda`).
373///
374/// - `FERROX_CUDA=0|false|off|cpu` — force skip CUDA dense
375/// - `FERROX_CUDA=1|true|on|cuda` — force CUDA attempt
376/// - unset / `auto` — CUDA when a device probe succeeds
377#[cfg(feature = "cuda")]
378pub fn cuda_dense_enabled() -> bool {
379 Cuda::dense_enabled()
380}
381
382/// Whether CPU Q8_0 / Q4_0 / Q4_K / Q5_K / Q6_K matvec should quantize the
383/// activation to int8 and use the integer `vec_dot` path. Q4_K
384/// additionally lazy-repacks into interleaved `block_q4_Kx8` for 8-wide
385/// GEMV; Q8_0 into `block_q8_0x4` and Q4_0 into `block_q4_0x4` for
386/// 4-wide GEMV.
387///
388/// Off by default *as a library*, and turned on by both binaries (see
389/// `ferrox_core::threads`'s siblings in `ferrox-cli`/`ferrox-server`,
390/// which set `FERROX_CPU_INT_DOT=1` unless the caller already chose).
391/// The split is deliberate: this is what llama.cpp's CPU backend does
392/// unconditionally -- quantize the activation to Q8, run integer
393/// `vec_dot` -- and it is worth 28% of CPU decode on Host B
394/// (Qwen2.5-0.5B Q8_0, `-ngl 0 -t 6`: 58.0 -> 80.5 tok/s). But it also
395/// perturbs results below the f32 reference's precision, and this
396/// crate's golden cross-validation against the independent NumPy
397/// reference asserts exact agreement. So the *inference product*
398/// defaults to fast and the *library default* stays reference-exact.
399///
400/// **This is the master switch, not the dispatch rule.** It says whether
401/// the tier is on at all; whether a given piece of work should take it
402/// is [`cpu_int_dot_for`], which also asks whether this host has the
403/// kernel for that workload's shape. Production dispatch calls that one.
404pub fn cpu_int_dot_enabled() -> bool {
405 #[cfg(test)]
406 {
407 // The env var is read once into a `OnceLock`, so a test cannot
408 // flip it after any other test has already observed it. Without
409 // an override, `cargo test` runs with int-dot *off* and every
410 // interleaved/i8mm batch kernel below is dead code in CI --
411 // which is how the whole repack tier went untested end to end.
412 // See [`tests::ForceIntDot`].
413 match INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire) {
414 0 => return false,
415 1 => return true,
416 _ => {}
417 }
418 }
419 use std::sync::OnceLock;
420 static ENABLED: OnceLock<bool> = OnceLock::new();
421 *ENABLED.get_or_init(|| {
422 matches!(
423 std::env::var("FERROX_CPU_INT_DOT").ok().as_deref(),
424 Some("1") | Some("true") | Some("on")
425 )
426 })
427}
428
429/// Test-only forcing of [`cpu_int_dot_enabled`]: `-1` unset, `0` off,
430/// `1` on. A global atomic rather than a thread-local because the paths
431/// it gates run on Rayon workers, which do not inherit thread-locals
432/// from the test thread.
433#[cfg(test)]
434static INT_DOT_TEST_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
435
436/// Sets `FERROX_CPU_INT_DOT=1` unless the caller already expressed a
437/// preference. Call from a binary's startup, before any worker threads
438/// exist. See [`cpu_int_dot_enabled`] for why the default lives here
439/// rather than in the getter.
440///
441/// # Safety
442/// Must be called while the process is still single-threaded, since it
443/// mutates the process environment.
444pub unsafe fn default_cpu_int_dot_on() {
445 if std::env::var_os("FERROX_CPU_INT_DOT").is_none() && int_dot_is_a_win_here() {
446 unsafe { std::env::set_var("FERROX_CPU_INT_DOT", "1") };
447 }
448}
449
450/// Whether the int-dot path is faster than the f32 one on THIS
451/// architecture.
452///
453/// It is not universally faster, and the default said it was. The
454/// interleaved int8 kernels this path selects were written for
455/// aarch64: `i8mm` SMMLA tiers, interleave-8 NEON GEMV, the Q8_K repack.
456/// x86_64 has none of that, so on x86 the switch selects a scalar
457/// integer loop AND bypasses the AVX2 f32 dot that does exist
458/// (`dot_q4_k_f32` and friends are gated on `avx2` + `fma`).
459///
460/// Measured 2026-09-04 on an idle 32-core Ryzen 9 7945HX (Zen 4, with
461/// `avx512_vnni` that nothing here uses), `tg64`, int-dot on against
462/// off:
463///
464/// | model | on (was the default) | off |
465/// |---|---|---|
466/// | Llama-3.2-1B Q4_K_M | 10.08 | **48.94** |
467/// | Llama-3.2-1B Q6_K | 4.11 | **36.23** |
468/// | Llama-3.2-3B Q4_K_M | 4.73 | **19.30** |
469///
470/// So the default cost x86 between 4x and 8.8x of decode, and it is
471/// most of why `benchmarks/RESULTS.md` had no x86 row worth showing
472/// (#127). Prefill is unaffected (95.3 against 89.4 on the 1B), which
473/// is consistent: prefill goes through the batched GEMM rather than
474/// this dot.
475///
476/// **That measurement is per WORKLOAD, and the flag was per process.**
477/// It says the matvec half of the tier loses on x86 and says nothing
478/// against the batch half; the batch half simply had no x86 kernel to
479/// try, which is #152. Now that it does, the rule is
480/// [`int_dot_tier_here`] and this function is only its "is any half
481/// worth turning on by default" summary.
482///
483/// This is a DEFAULT, not a gate: `FERROX_CPU_INT_DOT=1` still turns it
484/// on anywhere.
485fn int_dot_is_a_win_here() -> bool {
486 let tier = int_dot_tier_here();
487 tier.matvec || tier.batch_gemm
488}
489
490/// Which shape of work a call site is asking the repacked integer tier
491/// for. Not a hint: the two are different kernels and, on x86, different
492/// answers.
493#[derive(Clone, Copy, PartialEq, Eq, Debug)]
494pub enum IntDotShape {
495 /// One activation against the whole matrix — `apply`, `apply_cpu_q8`,
496 /// the MoE per-expert dots. Decode, and the `nrc == 1` GEMV kernels.
497 Matvec,
498 /// A batch of activations at once, through the interleaved `×4`
499 /// GEMMs. Prefill.
500 BatchGemm,
501}
502
503/// **The** predicate for "does this work take the repacked integer
504/// tier". Every call site asks this and none restates it.
505///
506/// Two things have to be true: `FERROX_CPU_INT_DOT` is on (the master
507/// switch, [`cpu_int_dot_enabled`]), and this host has kernels worth
508/// taking for `shape` ([`int_dot_tier_here`]).
509///
510/// Splitting by shape is the whole point. The tier used to be one
511/// process-wide flag over two unrelated kernel families, so x86 had to
512/// choose between a batched GEMM it wanted and a matvec that cost it 4x
513/// to 8.8x of decode — and chose neither.
514pub fn cpu_int_dot_for(shape: IntDotShape) -> bool {
515 cpu_int_dot_enabled() && int_dot_tier_here().covers(shape)
516}
517
518/// Which halves of the repacked integer tier are worth taking on this
519/// host.
520#[derive(Clone, Copy, PartialEq, Eq, Debug)]
521struct IntDotTier {
522 matvec: bool,
523 batch_gemm: bool,
524}
525
526impl IntDotTier {
527 /// Exhaustive on purpose, with no `_` arm: a third workload shape
528 /// must state its own answer rather than inherit one.
529 fn covers(self, shape: IntDotShape) -> bool {
530 match shape {
531 IntDotShape::Matvec => self.matvec,
532 IntDotShape::BatchGemm => self.batch_gemm,
533 }
534 }
535}
536
537/// The per-host, per-workload rule, in one place.
538///
539/// - **aarch64**: the matvec half always — the interleave-8 NEON GEMV
540/// and the i8mm SMMLA GEMMs are the kernels this tier was written for,
541/// worth ~28% of decode and 15x of prefill (`FERROX_CPU_INT_DOT=0`
542/// takes Llama-3.2-1B Q4_K_M pp512 from 420.34 to 27.81 tok/s, #152).
543/// The batch half asks the kernels, which on a host with `i8mm` is the
544/// quad GEMM and on one with only `dotprod` is the width-4 `sdot` GEMM.
545/// - **x86_64**: the batch half only, and only when the AVX2 `×4` GEMMs
546/// are actually present. The matvec half stays off because it was
547/// MEASURED to lose — see the table above — and nothing in this change
548/// touches the kernel it loses to.
549/// - anywhere else: neither, because neither has a kernel.
550///
551/// `batch_gemm` is not a written-down claim about any architecture; it
552/// asks `ferrox_quant` whether a SIMD batch GEMM exists at the width
553/// this host packs with. A host cannot be told the tier is a win while
554/// its kernel is missing, and an x86 host without AVX2 gets the same
555/// answer a RISC-V one does.
556///
557/// It asks [`ferrox_quant::batch_gemm_is_accelerated`] and NOT
558/// `interleaved_gemm_is_accelerated`: the latter is about the
559/// interleave-8 quad kernels, which is the right question for whether to
560/// PREPARE a quad and the wrong one for whether the tier buys anything.
561/// A pre-i8mm aarch64 host — every M1 Mac, every A14-and-earlier iPhone,
562/// and the Cortex-A55-class cores that are still most of the Android
563/// fleet — runs a width-4 `dotprod` GEMM for Q4_K, Q5_K, Q8_0 and Q4_0,
564/// so the narrower predicate reports "no SIMD GEMM" on a host that is
565/// running one. Q6_K has no width-4 GEMM on purpose (its scalar Kx8 GEMM
566/// measured slower than the per-row NEON dot), and the per-kind
567/// `q*_gemm_uses_acts_x4` entry points are what keep that distinction.
568///
569/// # On the `cfg!` in here
570///
571/// `par::policy` warns against exactly this shape — "an
572/// architecture-conditional default is what `FERROX_CPU_INT_DOT` was" —
573/// and it is right that an *unmeasured* one is how this went wrong.
574/// This one is the measurement: the x86 matvec row above is a real
575/// before/after on a quiet host, and the x86 batch row is gated on a
576/// runtime probe rather than a guess. The two predicates also answer
577/// different questions and must not be merged: `policy::backend` picks
578/// the SCHEDULER by work size; this picks the KERNEL by workload shape.
579/// Whether the Q5_K batched matmul takes the Kx8 path: every aarch64
580/// host (the i8mm quad GEMM, the width-4 `sdot` GEMM, or the scalar Kx8
581/// body, as the Q4_K arm takes it unconditionally), and any other host
582/// whose `x4` GEMM has a SIMD kernel at this width (AVX2 since #159).
583///
584/// Asked of the kernels rather than written beside them, the shape
585/// `int_dot_tier_here` has: this read `cfg!(target_arch = "aarch64")`
586/// alone until 2026-09-15, which sent every x86 Q5_K prefill through
587/// the per-row GEMM -- 44.2 against llama.cpp's 378.7 tok/s on
588/// Llama-3.2-1B Q5_K_M (8.56x) on a Ryzen 5950X, beside Q4_K and Q6_K
589/// at 1.19x on the same host, because those two arms asked the kernels
590/// and this one asked the architecture. With the predicate: 417.1
591/// tok/s, 0.91x. Phi-4-mini's `attn_qkv` is Q5_K too.
592#[inline]
593fn q5k_batch_takes_kx8(interleave: usize) -> bool {
594 cfg!(target_arch = "aarch64") || ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave)
595}
596
597fn int_dot_tier_here() -> IntDotTier {
598 #[cfg(target_arch = "aarch64")]
599 {
600 IntDotTier {
601 matvec: true,
602 batch_gemm: ferrox_quant::batch_gemm_is_accelerated(
603 ferrox_quant::preferred_interleave(),
604 ),
605 }
606 }
607 #[cfg(target_arch = "x86_64")]
608 {
609 IntDotTier {
610 matvec: false,
611 batch_gemm: ferrox_quant::batch_gemm_is_accelerated(
612 ferrox_quant::preferred_interleave(),
613 ),
614 }
615 }
616 #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
617 {
618 IntDotTier {
619 matvec: false,
620 batch_gemm: false,
621 }
622 }
623}
624
625/// A batch of activations quantized once for reuse across several
626/// [`WeightMatrix::apply_batch_with_acts`] calls that read the same input
627/// (q/k/v on one normed batch; gate/up on another). Build with
628/// [`WeightMatrix::quantize_batch_acts`]. Q8_0/Q4_0 matrices consume
629/// [`BatchActs::Q8`]; the K-quants consume [`BatchActs::Q8K`].
630///
631/// `tiles` carries the *interleaved* activation quads the i8mm GEMMs read
632/// (llama.cpp's `wdata` after `ggml_quantize_mat_q8_K_4x8`), not just the
633/// per-position quantization. Sharing stops at the same place the
634/// quantization does: q/k/v build one set between them instead of three,
635/// gate/up one instead of two. It is empty on hosts with no i8mm kernel,
636/// where preparing a quad buys nothing.
637///
638/// `cols` is recorded so a set built for one width can never be handed to
639/// a matrix of another. The tiles are chunked four positions wide for
640/// every kind (`Q8K_ACTS_X4_NC`, and `Q4_KX8_GEMM_NC` / `Q5_KX8_GEMM_NC`
641/// are the same 4), which is why one set serves Q4_K, Q5_K and Q6_K --
642/// and, in the [`BatchActs::Q8`] variant, both Q8_0 and Q4_0.
643pub enum BatchActs {
644 Q8 {
645 acts: Vec<ferrox_quant::Q8Activations>,
646 tiles: Vec<ferrox_quant::Q8ActsX4>,
647 cols: usize,
648 },
649 Q8K {
650 acts: Vec<ferrox_quant::Q8KActivations>,
651 tiles: Vec<ferrox_quant::Q8KActsX4>,
652 cols: usize,
653 },
654}
655
656// Sharing one quad set across kinds is only sound while every `x4`
657// consumer chunks the batch the same way. If one of these widths is ever
658// retuned on its own, the quads a Q4_K gate builds stop lining up with
659// what a Q5_K sibling indexes, and the failure is a wrong answer rather
660// than a panic -- so it fails the build instead.
661const _: () = {
662 assert!(ferrox_quant::Q4_KX8_GEMM_NC == ferrox_quant::Q8K_ACTS_X4_NC);
663 assert!(ferrox_quant::Q5_KX8_GEMM_NC == ferrox_quant::Q8K_ACTS_X4_NC);
664};
665
666pub enum WeightMatrix {
667 F32(Tensor),
668 Quantized {
669 data: WeightBytes,
670 rows: usize,
671 cols: usize,
672 kind: QuantKind,
673 },
674 /// MXFP4 (OCP Microscaling 4-bit float, Kimi K3's real routed-expert
675 /// format): unlike every `Quantized` kind above, which store one
676 /// interleaved block buffer per row, Kimi K3's real checkpoint
677 /// stores the packed 4-bit codes and per-group E8M0 scales as two
678 /// *separate* tensors (confirmed against a real shard header, see
679 /// `ferrox_quant`'s MXFP4 module docs) -- so this variant holds two
680 /// independently zero-copy-mappable buffers instead of `Quantized`'s
681 /// single `data` buffer. `apply`/`apply_batch` dispatch to
682 /// `ferrox_quant::dot_mxfp4_row_f32`, which reads directly from
683 /// these buffers without ever materializing a dequantized f32 copy
684 /// of the whole matrix -- the same zero-copy-mmap-plus-fused-dot
685 /// discipline as every `Quantized` kind, letting a real MXFP4
686 /// checkpoint's resident memory stay close to its on-disk size
687 /// instead of the ~8x larger eager-f32-dequant footprint.
688 Mxfp4 {
689 packed: WeightBytes,
690 scale: WeightBytes,
691 rows: usize,
692 cols: usize,
693 },
694 /// `base` with one or more LoRA adapters attached: every product
695 /// this matrix computes is `W x + Σ_i s_i · B_i (A_i x)`, the
696 /// low-rank term added inside the same method that computed `W x`
697 /// (see [`lora`]). `base` is never itself `Adapted`:
698 /// [`Self::attach_lora`] pushes onto the existing stack instead.
699 ///
700 /// A fourth variant rather than a field on the other three, so that
701 /// every place that reaches PAST the methods for raw bytes -- a
702 /// fused Metal stack, a simdgroup-GEMM descriptor, a Q8 row dot --
703 /// has to say what it does with an adapter, and the answer written
704 /// into each of them is `None`: those callers fall back to the
705 /// methods, which serve the delta, rather than run the base weights
706 /// and drop it.
707 Adapted {
708 base: Box<WeightMatrix>,
709 lora: LoraStack,
710 },
711 /// A weight PrismML folded a Hadamard rotation into
712 /// (`hadamard::HadamardFold`): the product is right only after the
713 /// activation has been transformed the same way, so every `apply`
714 /// here transforms and then runs `base`, and every fused launch
715 /// view answers `None` for it as it does for `Adapted`. The row
716 /// lookup (`FoldSite::RowLookup`) restores a stored row instead.
717 Folded {
718 base: Box<WeightMatrix>,
719 fold: std::sync::Arc<hadamard::HadamardFold>,
720 },
721}
722
723impl WeightMatrix {
724 /// Attaches one adapter's `(A, B)` pair. A second adapter on the
725 /// same weight joins the first's stack; the base is boxed exactly
726 /// once.
727 pub fn attach_lora(&mut self, delta: LoraDelta) {
728 assert_eq!(delta.rows(), self.rows(), "LoRA delta rows");
729 assert_eq!(delta.cols(), self.cols(), "LoRA delta cols");
730 if let WeightMatrix::Adapted { lora, .. } = self {
731 lora.push(delta);
732 return;
733 }
734 let placeholder = WeightMatrix::F32(Tensor::new(Vec::new(), vec![0, 0]));
735 let base = std::mem::replace(self, placeholder);
736 *self = WeightMatrix::Adapted {
737 base: Box::new(base),
738 lora: LoraStack::new(delta),
739 };
740 }
741
742 /// The adapters on this matrix, if any.
743 pub fn lora(&self) -> Option<&LoraStack> {
744 match self {
745 WeightMatrix::Adapted { lora, .. } => Some(lora),
746 _ => None,
747 }
748 }
749
750 /// Wraps `self` in a Hadamard fold (`hadamard::HadamardFold`).
751 /// The fold's width, when it has signs, must be this matrix's
752 /// column count.
753 pub fn fold_hadamard(&mut self, fold: std::sync::Arc<hadamard::HadamardFold>) {
754 if let Some(w) = fold.width() {
755 assert_eq!(
756 w,
757 self.cols(),
758 "Hadamard sign vector width vs matrix columns"
759 );
760 }
761 let placeholder = WeightMatrix::F32(Tensor::new(Vec::new(), vec![0, 0]));
762 let base = std::mem::replace(self, placeholder);
763 *self = WeightMatrix::Folded {
764 base: Box::new(base),
765 fold,
766 };
767 }
768
769 /// The fold on this matrix, if any.
770 pub fn hadamard_fold(&self) -> Option<&std::sync::Arc<hadamard::HadamardFold>> {
771 match self {
772 WeightMatrix::Folded { fold, .. } => Some(fold),
773 _ => None,
774 }
775 }
776
777 /// The weights under any adapter: `self` when there is none. NOT
778 /// under a fold: a folded matrix's base answers a different
779 /// product, so `base()` of a `Folded` is the `Folded` itself and a
780 /// caller that wants the bytes goes through `hadamard_fold()`.
781 pub fn base(&self) -> &WeightMatrix {
782 match self {
783 WeightMatrix::Adapted { base, .. } => base,
784 _ => self,
785 }
786 }
787
788 /// Raw quantized byte length, or 0 for a float matrix. For
789 /// comparing two backings of the same weight.
790 pub fn bytes_len(&self) -> usize {
791 match self {
792 WeightMatrix::Quantized { data, .. } => data.len(),
793 WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => {
794 base.bytes_len()
795 }
796 _ => 0,
797 }
798 }
799
800 /// Do two matrices hold the same quantized bytes?
801 ///
802 /// Exists to answer one question: when a streamed expert and a
803 /// resident one disagree about a model's output, is the difference
804 /// in the WEIGHTS or downstream of them?
805 pub fn bytes_eq(&self, other: &WeightMatrix) -> bool {
806 match (self.base(), other.base()) {
807 (WeightMatrix::Quantized { data: a, .. }, WeightMatrix::Quantized { data: b, .. }) => {
808 a.as_slice() == b.as_slice()
809 }
810 _ => false,
811 }
812 }
813
814 pub fn rows(&self) -> usize {
815 match self {
816 WeightMatrix::F32(t) => t.rows(),
817 WeightMatrix::Quantized { rows, .. } => *rows,
818 WeightMatrix::Mxfp4 { rows, .. } => *rows,
819 WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => base.rows(),
820 }
821 }
822
823 /// The block format, or `None` for the two non-block storages
824 /// (`F32`, safetensors-pair `Mxfp4`). This is the key every
825 /// kernel-availability table is indexed by.
826 pub fn quant_kind(&self) -> Option<QuantKind> {
827 match self {
828 WeightMatrix::Quantized { kind, .. } => Some(*kind),
829 WeightMatrix::F32(_) | WeightMatrix::Mxfp4 { .. } => None,
830 WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => {
831 base.quant_kind()
832 }
833 }
834 }
835
836 pub fn cols(&self) -> usize {
837 match self {
838 WeightMatrix::F32(t) => t.cols(),
839 WeightMatrix::Quantized { cols, .. } => *cols,
840 WeightMatrix::Mxfp4 { cols, .. } => *cols,
841 WeightMatrix::Adapted { base, .. } | WeightMatrix::Folded { base, .. } => base.cols(),
842 }
843 }
844
845 fn block_bytes_per_row(&self, kind: QuantKind, cols: usize) -> usize {
846 match kind {
847 QuantKind::Q8_0 => {
848 (cols / ferrox_quant::Q8_0_BLOCK_ELEMS) * ferrox_quant::Q8_0_BLOCK_BYTES
849 }
850 QuantKind::Q4_0 => {
851 (cols / ferrox_quant::Q4_0_BLOCK_ELEMS) * ferrox_quant::Q4_0_BLOCK_BYTES
852 }
853 QuantKind::Q4K => {
854 (cols / ferrox_quant::Q4_K_BLOCK_ELEMS) * ferrox_quant::Q4_K_BLOCK_BYTES
855 }
856 QuantKind::Q5K => {
857 (cols / ferrox_quant::Q5_K_BLOCK_ELEMS) * ferrox_quant::Q5_K_BLOCK_BYTES
858 }
859 QuantKind::Q6K => {
860 (cols / ferrox_quant::Q6_K_BLOCK_ELEMS) * ferrox_quant::Q6_K_BLOCK_BYTES
861 }
862 QuantKind::Q2K => {
863 (cols / ferrox_quant::Q2_K_BLOCK_ELEMS) * ferrox_quant::Q2_K_BLOCK_BYTES
864 }
865 QuantKind::Q3K => {
866 (cols / ferrox_quant::Q3_K_BLOCK_ELEMS) * ferrox_quant::Q3_K_BLOCK_BYTES
867 }
868 QuantKind::Q4_1 => {
869 (cols / ferrox_quant::Q4_1_BLOCK_ELEMS) * ferrox_quant::Q4_1_BLOCK_BYTES
870 }
871 QuantKind::Q5_0 => {
872 (cols / ferrox_quant::Q5_0_BLOCK_ELEMS) * ferrox_quant::Q5_0_BLOCK_BYTES
873 }
874 QuantKind::Q5_1 => {
875 (cols / ferrox_quant::Q5_1_BLOCK_ELEMS) * ferrox_quant::Q5_1_BLOCK_BYTES
876 }
877 QuantKind::Q8_1 => {
878 (cols / ferrox_quant::Q8_1_BLOCK_ELEMS) * ferrox_quant::Q8_1_BLOCK_BYTES
879 }
880 QuantKind::IQ4NL => {
881 (cols / ferrox_quant::IQ4_NL_BLOCK_ELEMS) * ferrox_quant::IQ4_NL_BLOCK_BYTES
882 }
883 QuantKind::IQ4XS => {
884 (cols / ferrox_quant::IQ4_XS_BLOCK_ELEMS) * ferrox_quant::IQ4_XS_BLOCK_BYTES
885 }
886 QuantKind::IQ1S => {
887 (cols / ferrox_quant::IQ1_S_BLOCK_ELEMS) * ferrox_quant::IQ1_S_BLOCK_BYTES
888 }
889 QuantKind::IQ2XXS => {
890 (cols / ferrox_quant::IQ2_XXS_BLOCK_ELEMS) * ferrox_quant::IQ2_XXS_BLOCK_BYTES
891 }
892 QuantKind::IQ3XXS => {
893 (cols / ferrox_quant::IQ3_XXS_BLOCK_ELEMS) * ferrox_quant::IQ3_XXS_BLOCK_BYTES
894 }
895 QuantKind::IQ2XS => {
896 (cols / ferrox_quant::IQ2_XS_BLOCK_ELEMS) * ferrox_quant::IQ2_XS_BLOCK_BYTES
897 }
898 QuantKind::IQ2S => {
899 (cols / ferrox_quant::IQ2_S_BLOCK_ELEMS) * ferrox_quant::IQ2_S_BLOCK_BYTES
900 }
901 QuantKind::IQ3S => {
902 (cols / ferrox_quant::IQ3_S_BLOCK_ELEMS) * ferrox_quant::IQ3_S_BLOCK_BYTES
903 }
904 QuantKind::IQ1M => {
905 (cols / ferrox_quant::IQ1_M_BLOCK_ELEMS) * ferrox_quant::IQ1_M_BLOCK_BYTES
906 }
907 QuantKind::Mxfp4Gguf => {
908 (cols / ferrox_quant::MXFP4_GGUF_BLOCK_ELEMS) * ferrox_quant::MXFP4_GGUF_BLOCK_BYTES
909 }
910 QuantKind::Tq1_0 => {
911 (cols / ferrox_quant::ternary::TQ1_0_BLOCK_ELEMS)
912 * ferrox_quant::ternary::TQ1_0_BLOCK_BYTES
913 }
914 QuantKind::Ptq1_0 => {
915 (cols / ferrox_quant::ternary::PTQ1_0_BLOCK_ELEMS)
916 * ferrox_quant::ternary::PTQ1_0_BLOCK_BYTES
917 }
918 }
919 }
920
921 /// A reasonable minimum number of rows for one rayon task to
922 /// process, to avoid rayon's work-stealing splitter fragmenting a
923 /// matmul into tasks so small that scheduling/synchronization
924 /// overhead dominates the real per-row work (a fused dequant+dot,
925 /// not free). This is a real, measured fix, not speculative
926 /// tuning: naive per-row splitting (rayon's default) caused a
927 /// 13-16x throughput regression on a host configured with far more
928 /// rayon threads than a small model's matrices have useful
929 /// parallelism for (observed directly on a shared-core rented
930 /// host, where auto-detected high thread counts collapsed
931 /// throughput ~13-16x on a small model). Aims for ~4 tasks per thread
932 /// -- enough that rayon's work-stealing can still load-balance
933 /// across threads that finish early, without going all the way
934 /// down to one task per row.
935 ///
936 /// Floor of 8 avoids Rayon thrash on tiny mats (SmolLM2 attn_kv
937 /// has 192 rows → without a floor, ~48 one-row tasks on 10 cores).
938 ///
939 /// The floor is also **work-aware**, which matters for decode. A row
940 /// count alone says nothing about how much arithmetic a task carries:
941 /// SmolLM2's 576-wide projections split into ~24 tasks of ~14K MACs
942 /// each, far too little to pay for a fork-join. Measured on this host
943 /// (both engines back to back, thread count as the only variable):
944 /// ferrox scales 1.40x / 2.93x from 1 to 6 threads on TinyLlama /
945 /// Mistral-7B where llama.cpp scales 1.99x / 4.39x, and the deficit
946 /// grows as the model shrinks -- the signature of tasks too small to
947 /// amortise their own scheduling, not of slow kernels (ferrox is
948 /// *ahead* of llama at one thread on Mistral-7B).
949 ///
950 /// [`crate::par::with_op_work`] supplies the elements-per-row so a
951 /// task can be required to carry at least [`MIN_TASK_MACS`]
952 /// multiply-accumulates. Zero (unset) keeps the old row-only
953 /// behaviour, so any call site that has not opted in is unchanged.
954 ///
955 /// Nothing here needs to ask which scheduler won this operation.
956 /// What this returns is a `min_len`, and `min_len` is read only by
957 /// the fork-join arm of [`crate::par`] -- the persistent pool's arm
958 /// chunks by width alone, which
959 /// `par::tests::the_spin_arm_chunks_by_pool_width_with_no_work_threshold`
960 /// asserts. A second `Backend::Spin` check here was written and
961 /// removed: deleting it changed no result, which is the definition
962 /// of a gate that cannot fire.
963 fn min_rows_per_task(rows: usize) -> usize {
964 let threads = crate::par::num_threads();
965 let by_threads = (rows / (threads * 4)).max(8.min(rows.max(1)));
966 let per_row = crate::par::macs_per_row();
967 if per_row == 0 {
968 return by_threads;
969 }
970 let need = MIN_TASK_MACS.div_ceil(per_row.max(1));
971 by_threads.max(need.min(rows.max(1)))
972 }
973
974 /// Run `body(g, t0, t1)` for every row-group `g` and activation-tile
975 /// range `[t0, t1)` of a llama-style 2D chunk grid over
976 /// (row-groups × batch tiles).
977 ///
978 /// This is the port of `ggml_compute_forward_mul_mat`'s chunking
979 /// (`ggml-cpu.c`): ~16 rows / 16 batch positions per chunk, and if
980 /// that grid is smaller than `4 × threads`, re-chunk by thread along
981 /// the larger dimension. llama walks the grid with an atomic
982 /// `current_chunk` because its threadpool has no scheduler; Rayon
983 /// already work-steals, so handing it the same chunks (`min_len 1`)
984 /// gets the same load balancing. The point is the *batch* dimension:
985 /// splitting only by rows leaves a 192-row projection with ~3 tasks
986 /// no matter how many positions are in flight.
987 fn par_chunked_groups(
988 n_groups: usize,
989 group_rows: usize,
990 n_tiles: usize,
991 tile_batch: usize,
992 body: impl Fn(usize, usize, usize) + Sync,
993 ) {
994 if n_groups == 0 || n_tiles == 0 {
995 return;
996 }
997 let nth = crate::par::num_threads();
998 const CHUNK_ELEMS: usize = 16;
999 let g_per_chunk = (CHUNK_ELEMS / group_rows).max(1);
1000 let t_per_chunk = (CHUNK_ELEMS / tile_batch).max(1);
1001 let mut nchunk_g = n_groups.div_ceil(g_per_chunk);
1002 let mut nchunk_t = n_tiles.div_ceil(t_per_chunk);
1003 if nchunk_g * nchunk_t < nth * 4 {
1004 // llama's fallback: one chunk per thread along the larger dim.
1005 if n_groups * group_rows > n_tiles * tile_batch {
1006 nchunk_g = nth.min(n_groups);
1007 nchunk_t = 1;
1008 } else {
1009 nchunk_g = 1;
1010 nchunk_t = nth.min(n_tiles);
1011 }
1012 }
1013 let dg = n_groups.div_ceil(nchunk_g);
1014 let dt = n_tiles.div_ceil(nchunk_t);
1015 crate::par::indices(nchunk_g * nchunk_t, 1, |chunk| {
1016 let g0 = (chunk % nchunk_g) * dg;
1017 let g1 = (g0 + dg).min(n_groups);
1018 let t0 = (chunk / nchunk_g) * dt;
1019 let t1 = (t0 + dt).min(n_tiles);
1020 for g in g0..g1 {
1021 body(g, t0, t1);
1022 }
1023 });
1024 }
1025
1026 /// Resolve the Q8_0-format activations (and the interleaved quads, if
1027 /// any) an [`Self::apply_batch_with_acts`] arm should read.
1028 ///
1029 /// Returns the shared batch when it matches this matrix -- same
1030 /// positions, same width -- and otherwise quantizes into `owned` and
1031 /// returns that with no quads, so the caller builds its own. A
1032 /// mismatched `shared` is silently ignored rather than trusted, which
1033 /// is what keeps a mixed-width projection group correct.
1034 ///
1035 /// The returned quads are only ever the *shared* ones. The empty slice
1036 /// therefore means "nobody prepared these for you", not "this host has
1037 /// no i8mm kernel" -- the caller still decides that with
1038 /// `q8_0x4_gemm_uses_acts_x4`.
1039 fn q8_acts<'a>(
1040 shared: Option<&'a BatchActs>,
1041 x_batch: &[f32],
1042 batch_size: usize,
1043 cols: usize,
1044 owned: &'a mut Vec<ferrox_quant::Q8Activations>,
1045 ) -> (
1046 &'a [ferrox_quant::Q8Activations],
1047 &'a [ferrox_quant::Q8ActsX4],
1048 ) {
1049 if let Some(BatchActs::Q8 {
1050 acts,
1051 tiles,
1052 cols: c,
1053 }) = shared
1054 {
1055 if acts.len() == batch_size && *c == cols {
1056 return (acts, tiles);
1057 }
1058 }
1059 *owned = (0..batch_size)
1060 .into_par_iter()
1061 .map(|b| ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
1062 .collect();
1063 (owned, &[])
1064 }
1065
1066 /// [`Self::q8_acts`] for the Q8_K format the K-quants consume.
1067 fn q8k_acts<'a>(
1068 shared: Option<&'a BatchActs>,
1069 x_batch: &[f32],
1070 batch_size: usize,
1071 cols: usize,
1072 owned: &'a mut Vec<ferrox_quant::Q8KActivations>,
1073 ) -> (
1074 &'a [ferrox_quant::Q8KActivations],
1075 &'a [ferrox_quant::Q8KActsX4],
1076 ) {
1077 if let Some(BatchActs::Q8K {
1078 acts,
1079 tiles,
1080 cols: c,
1081 }) = shared
1082 {
1083 if acts.len() == batch_size && *c == cols {
1084 return (acts, tiles);
1085 }
1086 }
1087 *owned = (0..batch_size)
1088 .into_par_iter()
1089 .map(|b| ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
1090 .collect();
1091 (owned, &[])
1092 }
1093
1094 /// Prefer serial when the mat is too small for fork-join to pay off.
1095 fn prefer_serial_matvec(rows: usize, cols: usize) -> bool {
1096 // ~256k f32-equivalent ops: below this, Rayon overhead dominates
1097 // on Host B-class cores for Q8/Q4 decode GEMVs.
1098 rows.saturating_mul(cols) < 256_000
1099 }
1100
1101 fn dot(kind: QuantKind, row: &[u8], x: &[f32]) -> f32 {
1102 match kind {
1103 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_f32(row, x),
1104 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_f32(row, x),
1105 QuantKind::Q4K => ferrox_quant::dot_q4_k_f32(row, x),
1106 QuantKind::Q5K => ferrox_quant::dot_q5_k_f32(row, x),
1107 QuantKind::Q6K => ferrox_quant::dot_q6_k_f32(row, x),
1108 QuantKind::Q2K => ferrox_quant::dot_q2_k_f32(row, x),
1109 QuantKind::Q3K => ferrox_quant::dot_q3_k_f32(row, x),
1110 QuantKind::Q4_1 => ferrox_quant::dot_q4_1_f32(row, x),
1111 QuantKind::Q5_0 => ferrox_quant::dot_q5_0_f32(row, x),
1112 QuantKind::Q5_1 => ferrox_quant::dot_q5_1_f32(row, x),
1113 QuantKind::Q8_1 => ferrox_quant::dot_q8_1_f32(row, x),
1114 QuantKind::IQ4NL => ferrox_quant::dot_iq4_nl_f32(row, x),
1115 QuantKind::IQ4XS => ferrox_quant::dot_iq4_xs_f32(row, x),
1116 QuantKind::IQ1S => ferrox_quant::dot_iq1_s_f32(row, x),
1117 QuantKind::IQ2XXS => ferrox_quant::dot_iq2_xxs_f32(row, x),
1118 QuantKind::IQ3XXS => ferrox_quant::dot_iq3_xxs_f32(row, x),
1119 QuantKind::IQ2XS => ferrox_quant::dot_iq2_xs_f32(row, x),
1120 QuantKind::IQ2S => ferrox_quant::dot_iq2_s_f32(row, x),
1121 QuantKind::IQ3S => ferrox_quant::dot_iq3_s_f32(row, x),
1122 QuantKind::IQ1M => ferrox_quant::dot_iq1_m_f32(row, x),
1123 QuantKind::Mxfp4Gguf => ferrox_quant::dot_mxfp4_gguf_f32(row, x),
1124 QuantKind::Tq1_0 => {
1125 ferrox_quant::ternary::dot_trits_f32(row, ferrox_quant::ternary::TQ1_0, x)
1126 }
1127 QuantKind::Ptq1_0 => {
1128 ferrox_quant::ternary::dot_trits_f32(row, ferrox_quant::ternary::PTQ1_0, x)
1129 }
1130 }
1131 }
1132
1133 /// Per-kind full-buffer dequantization -- the row-lookup counterpart
1134 /// of `dot`'s fused per-kind dispatch below.
1135 fn dequant(kind: QuantKind, bytes: &[u8]) -> Vec<f32> {
1136 let out = match kind {
1137 QuantKind::Q8_0 => ferrox_quant::dequant_q8_0(bytes),
1138 QuantKind::Q4_0 => ferrox_quant::dequant_q4_0(bytes),
1139 QuantKind::Q4K => ferrox_quant::dequant_q4_k(bytes),
1140 QuantKind::Q5K => ferrox_quant::dequant_q5_k(bytes),
1141 QuantKind::Q6K => ferrox_quant::dequant_q6_k(bytes),
1142 QuantKind::Q2K => ferrox_quant::dequant_q2_k(bytes),
1143 QuantKind::Q3K => ferrox_quant::dequant_q3_k(bytes),
1144 QuantKind::Q4_1 => ferrox_quant::dequant_q4_1(bytes),
1145 QuantKind::Q5_0 => ferrox_quant::dequant_q5_0(bytes),
1146 QuantKind::Q5_1 => ferrox_quant::dequant_q5_1(bytes),
1147 QuantKind::Q8_1 => ferrox_quant::dequant_q8_1(bytes),
1148 QuantKind::IQ4NL => ferrox_quant::dequant_iq4_nl(bytes),
1149 QuantKind::IQ4XS => ferrox_quant::dequant_iq4_xs(bytes),
1150 QuantKind::IQ1S => ferrox_quant::dequant_iq1_s(bytes),
1151 QuantKind::IQ2XXS => ferrox_quant::dequant_iq2_xxs(bytes),
1152 QuantKind::IQ3XXS => ferrox_quant::dequant_iq3_xxs(bytes),
1153 QuantKind::IQ2XS => ferrox_quant::dequant_iq2_xs(bytes),
1154 QuantKind::IQ2S => ferrox_quant::dequant_iq2_s(bytes),
1155 QuantKind::IQ3S => ferrox_quant::dequant_iq3_s(bytes),
1156 QuantKind::IQ1M => ferrox_quant::dequant_iq1_m(bytes),
1157 QuantKind::Mxfp4Gguf => ferrox_quant::dequant_mxfp4_gguf(bytes),
1158 QuantKind::Tq1_0 => {
1159 ferrox_quant::ternary::dequant_trits(bytes, ferrox_quant::ternary::TQ1_0)
1160 }
1161 QuantKind::Ptq1_0 => {
1162 ferrox_quant::ternary::dequant_trits(bytes, ferrox_quant::ternary::PTQ1_0)
1163 }
1164 };
1165 out.expect("row byte length is block-aligned by construction (block_bytes_per_row)")
1166 }
1167
1168 /// Dequantizes exactly one row to f32, without touching any other
1169 /// row's bytes. This is what makes a *quantized* embedding table
1170 /// usable directly: token lookup reads `row_bytes` bytes and
1171 /// dequantizes `cols` values, instead of the whole vocabulary
1172 /// tensor ever being widened to f32 (which for a large-vocab model
1173 /// is a multi-GB allocation that exists only to be indexed one row
1174 /// at a time).
1175 pub fn dequant_row(&self, r: usize) -> Vec<f32> {
1176 assert!(r < self.rows(), "row {r} out of range ({})", self.rows());
1177 match self {
1178 WeightMatrix::F32(t) => t.row(r).to_vec(),
1179 WeightMatrix::Quantized {
1180 data, cols, kind, ..
1181 } => {
1182 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1183 let bytes = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1184 let out = Self::dequant(*kind, bytes);
1185 debug_assert_eq!(out.len(), *cols);
1186 out
1187 }
1188 WeightMatrix::Mxfp4 {
1189 packed,
1190 scale,
1191 cols,
1192 ..
1193 } => {
1194 let packed_per_row = cols / 2;
1195 let scales_per_row = cols / ferrox_quant::MXFP4_GROUP_SIZE;
1196 let p = &packed.as_slice()[r * packed_per_row..(r + 1) * packed_per_row];
1197 let sc = &scale.as_slice()[r * scales_per_row..(r + 1) * scales_per_row];
1198 ferrox_quant::dequant_mxfp4_row(p, sc)
1199 .expect("row slices are group-aligned by construction")
1200 }
1201 WeightMatrix::Adapted { base, lora } => {
1202 let mut row = base.dequant_row(r);
1203 lora.add_row_to(r, &mut row);
1204 row
1205 }
1206 // A stored row of a lookup table comes back to the primal
1207 // basis; a row of an input-folded weight stays folded, which
1208 // is what a caller splitting or widening the matrix needs.
1209 WeightMatrix::Folded { base, fold } => {
1210 let mut row = base.dequant_row(r);
1211 if fold.site == hadamard::FoldSite::RowLookup {
1212 fold.restore_row(&mut row);
1213 }
1214 row
1215 }
1216 }
1217 }
1218
1219 /// Whether batching this matrix during prefill beats running the
1220 /// fused per-position dense-FFN launch once per token.
1221 ///
1222 /// Measured, not assumed. Every kind with a simdgroup GEMM
1223 /// (`*_mul_mm_sg`) batches: Q4_K, Q5_K, Q6_K, Q8_0, Q4_0, IQ4_XS.
1224 /// The remaining IQ codebook kinds have no GEMM, and their batched
1225 /// *matvec* loses to the fused per-position launch — IQ4_XS
1226 /// regressed 72.1 -> 33.2 on Llama-3.2-1B while it was in that
1227 /// state — so they keep the per-position path until a GEMM exists
1228 /// for them too.
1229 /// This matrix as a Metal simdgroup-GEMM descriptor, or `None` if
1230 /// its quant kind has no GEMM (so it must stay on the matvec path).
1231 /// Lets several matmuls be encoded into one command buffer instead
1232 /// of one launch each.
1233 #[cfg(feature = "metal")]
1234 pub fn mul_mm_sg_launch(&self) -> Option<ferrox_metal::gpu::MulMmSgLaunch<'_>> {
1235 let WeightMatrix::Quantized {
1236 data,
1237 rows,
1238 cols,
1239 kind,
1240 } = self
1241 else {
1242 return None;
1243 };
1244 let kind_name = match kind {
1245 QuantKind::Q8_0 => "Q8_0",
1246 QuantKind::Q4_0 => "Q4_0",
1247 QuantKind::Q5_0 => "Q5_0",
1248 QuantKind::Q4K => "Q4_K",
1249 QuantKind::Q5K => "Q5_K",
1250 QuantKind::Q6K => "Q6_K",
1251 QuantKind::IQ4XS => "IQ4_XS",
1252 QuantKind::Ptq1_0 => "PTQ1_0",
1253 _ => return None,
1254 };
1255 let (fn_name, block_bytes, block_elems) = ferrox_metal::gpu::mul_mm_sg_meta(kind_name)?;
1256 Some(ferrox_metal::gpu::MulMmSgLaunch {
1257 weights: data.as_slice(),
1258 rows: *rows,
1259 row_bytes: self.block_bytes_per_row(*kind, *cols),
1260 fn_name,
1261 block_bytes,
1262 block_elems,
1263 })
1264 }
1265
1266 /// This matrix as the CUDA batched GEMM takes it, or `None` for an
1267 /// adapted matrix (a LoRA delta lives in `WeightMatrix::Adapted` and
1268 /// no raw-bytes launch serves it) or a kind with no `mul_mm` row.
1269 /// The CUDA twin of [`Self::mul_mm_sg_launch`], and the ONE
1270 /// constructor of [`ferrox_cuda::prefill::MulMmWeights`]: the row
1271 /// byte count comes from the same `block_bytes_per_row` the matvec
1272 /// seam is held to, so a kind added there is a kind added here.
1273 #[cfg(feature = "cuda")]
1274 pub fn cuda_mul_mm_view(&self) -> Option<ferrox_cuda::prefill::MulMmWeights<'_>> {
1275 let WeightMatrix::Quantized {
1276 data,
1277 rows,
1278 cols,
1279 kind,
1280 } = self
1281 else {
1282 return None;
1283 };
1284 if !cuda_mul_mm_kind_supported(*kind) {
1285 return None;
1286 }
1287 let mm_kind = ferrox_cuda::mul_mm::kind_by_name(kind.name())?;
1288 Some(ferrox_cuda::prefill::MulMmWeights {
1289 kind: mm_kind,
1290 data: data.as_slice(),
1291 rows: *rows,
1292 cols: *cols,
1293 row_bytes: self.block_bytes_per_row(*kind, *cols),
1294 })
1295 }
1296
1297 #[cfg(any(feature = "metal", feature = "cuda"))]
1298 pub fn prefers_gpu_batch(&self) -> bool {
1299 !matches!(
1300 self.base(),
1301 WeightMatrix::Quantized {
1302 kind: QuantKind::IQ4NL
1303 | QuantKind::IQ1S
1304 | QuantKind::IQ2XXS
1305 | QuantKind::IQ3XXS
1306 | QuantKind::IQ2XS
1307 | QuantKind::IQ2S
1308 | QuantKind::IQ3S
1309 | QuantKind::IQ1M,
1310 ..
1311 }
1312 )
1313 }
1314
1315 /// Computes `W @ x` for a single activation vector `x` of length
1316 /// `self.cols()`, returning a vector of length `self.rows()`.
1317 /// Parallelized over output rows with rayon, same decomposition as
1318 /// `matmul_f32`.
1319 ///
1320 /// With `--features metal` / `--features cuda`, when the matching
1321 /// dense GPU env selects a device (see [`metal_dense_enabled`] /
1322 /// [`cuda_dense_enabled`]), quantized kinds that have a GPU kernel
1323 /// go through [`Self::apply_gpu`] first so dense Llama-class
1324 /// decode uses the GPU instead of only MoE expert placement.
1325 pub fn apply(&self, x: &[f32]) -> Vec<f32> {
1326 assert_eq!(
1327 x.len(),
1328 self.cols(),
1329 "activation length must match matrix column count"
1330 );
1331 crate::activation_tap::observe(self, x, 1);
1332 if let WeightMatrix::Adapted { base, lora } = self {
1333 let mut out = base.apply(x);
1334 lora.add_to(x, &mut out);
1335 return out;
1336 }
1337 if let WeightMatrix::Folded { base, fold } = self {
1338 return base.apply(&fold.transform_input(x));
1339 }
1340 #[cfg(feature = "cuda")]
1341 {
1342 if cuda_dense_enabled() {
1343 if let Some(out) = self.apply_gpu(x) {
1344 return out;
1345 }
1346 }
1347 }
1348 #[cfg(feature = "metal")]
1349 {
1350 if metal_dense_enabled() {
1351 if let Some(out) = self.apply_gpu(x) {
1352 return out;
1353 }
1354 }
1355 }
1356 self.apply_cpu(x)
1357 }
1358
1359 /// [`Self::apply`] followed by `softcap_inplace(.., softcap)`, as
1360 /// ONE operation: Gemma-2's lm_head with its `final_logit_softcap`.
1361 ///
1362 /// On Metal the cap runs as an epilogue in the matvec's own command
1363 /// buffer (`ferrox_metal::gpu::MatvecEpilogue`), so the host never
1364 /// walks the 256k logits before sampling them: that walk was
1365 /// 0.65 ms per token, more than the whole encode phase (PR #202).
1366 /// Everywhere else, and whenever the Metal launch is refused or
1367 /// fails, it is the host multiply-tanh it always was. Either way
1368 /// the caller gets capped logits and never has to remember the cap.
1369 pub fn apply_softcapped(&self, x: &[f32], softcap: f32) -> Vec<f32> {
1370 #[cfg(feature = "metal")]
1371 if metal_dense_enabled() {
1372 if let WeightMatrix::Quantized {
1373 data,
1374 rows,
1375 cols,
1376 kind,
1377 } = self
1378 {
1379 if let Some(kind_name) = Metal::matvec_kernel(*kind) {
1380 crate::activation_tap::observe(self, x, 1);
1381 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1382 let epilogue = ferrox_metal::gpu::MatvecEpilogue {
1383 softcap: Some(softcap),
1384 };
1385 match ferrox_metal::gpu::launch_matvec_kind_with(
1386 kind_name,
1387 data.as_slice(),
1388 x,
1389 *rows,
1390 row_bytes,
1391 epilogue,
1392 ) {
1393 Some(Ok(out)) => return out,
1394 Some(Err(e)) => {
1395 eprintln!(
1396 "ferrox: Metal softcapped matvec failed, falling back to CPU: {e}"
1397 );
1398 }
1399 None => {}
1400 }
1401 }
1402 }
1403 }
1404 let mut out = self.apply(x);
1405 crate::matmul::softcap_inplace(&mut out, softcap);
1406 out
1407 }
1408
1409 /// CPU-only matvec (NEON/AVX/scalar via `ferrox-quant`). Used by
1410 /// [`Self::apply`] after Metal miss/disable, and by GPU parity tests
1411 /// that must not recurse into [`Self::apply_gpu`].
1412 /// Applies three independent matrices to the same activation,
1413 /// overlapping their parallel regions instead of running them one
1414 /// after another.
1415 ///
1416 /// Decode opens one rayon fork-join per weight matrix -- roughly
1417 /// seven per layer -- and the measured CPU decode deficit is
1418 /// scheduling, not kernels (ferrox scales 1.40x/2.93x from 1 to 6
1419 /// threads where llama.cpp scales 1.99x/4.39x, while *beating* llama
1420 /// at one thread). q/k/v share an input and are independent, so
1421 /// their regions can coexist and let rayon's work-stealing fill
1422 /// threads that would otherwise idle at the tail of each one.
1423 ///
1424 /// Under [`crate::par::Backend::Spin`] the three run one after the
1425 /// other instead: each already spreads across the whole persistent
1426 /// pool, and the reason to overlap them was to hide a fork-join that
1427 /// the persistent pool does not pay. That choice lives in
1428 /// [`crate::par::join3`], not here, so it cannot drift from the one
1429 /// in `ferrox-moe`'s gate/up pair.
1430 ///
1431 /// On a GPU backend the three go into ONE command buffer
1432 /// ([`Self::apply_gpu_multi`]: one upload of `x`, one wait) and fall
1433 /// back to three sequential launches when the fused path declines
1434 /// (a kind without a kernel, folds that differ). Each round trip a
1435 /// per-matvec model pays is ~0.2 ms of latency beyond the kernel
1436 /// (measured on an M2 Pro with `FERROX_METAL_GPU_TIMING`), which
1437 /// is what this saves.
1438 pub fn apply_three(a: &Self, b: &Self, c: &Self, x: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
1439 if Self::gpu_dense_active() {
1440 #[cfg(any(feature = "cuda", feature = "metal"))]
1441 if let Some(mut outs) = Self::apply_gpu_multi(&[a, b, c], x) {
1442 let c = outs.pop().unwrap();
1443 let b = outs.pop().unwrap();
1444 let a = outs.pop().unwrap();
1445 return (a, b, c);
1446 }
1447 return (a.apply(x), b.apply(x), c.apply(x));
1448 }
1449 crate::par::join3(|| a.apply(x), || b.apply(x), || c.apply(x))
1450 }
1451
1452 /// Two projections of one activation: [`Self::apply_three`] for a
1453 /// pair (a gated delta-net's `qkv` and `z`, a GLU's gate and up).
1454 pub fn apply_pair(a: &Self, b: &Self, x: &[f32]) -> (Vec<f32>, Vec<f32>) {
1455 if Self::gpu_dense_active() {
1456 #[cfg(any(feature = "cuda", feature = "metal"))]
1457 if let Some(mut outs) = Self::apply_gpu_multi(&[a, b], x) {
1458 let b = outs.pop().unwrap();
1459 let a = outs.pop().unwrap();
1460 return (a, b);
1461 }
1462 return (a.apply(x), b.apply(x));
1463 }
1464 crate::par::join2(|| a.apply(x), || b.apply(x))
1465 }
1466
1467 /// Any number of projections of ONE activation, in one GPU
1468 /// submission where the backend allows it.
1469 ///
1470 /// [`Self::apply_pair`] and [`Self::apply_three`] are this for the
1471 /// two arities that read better at their call sites; a gated
1472 /// delta-net layer projects `qkv`, `z` and its gate logits from the
1473 /// same normed vector and so takes this one, which is one command
1474 /// buffer per layer rather than two.
1475 pub fn apply_many(mats: &[&Self], x: &[f32]) -> Vec<Vec<f32>> {
1476 if mats.len() < 2 {
1477 return mats.iter().map(|m| m.apply(x)).collect();
1478 }
1479 if Self::gpu_dense_active() {
1480 #[cfg(any(feature = "cuda", feature = "metal"))]
1481 if let Some(outs) = Self::apply_gpu_multi(mats, x) {
1482 return outs;
1483 }
1484 return mats.iter().map(|m| m.apply(x)).collect();
1485 }
1486 // The CPU pool spreads ONE matvec across every core already, so
1487 // the arms run in sequence there for the reason `apply_three`
1488 // records: overlapping them buys a fork-join the persistent
1489 // pool does not pay.
1490 mats.iter().map(|m| m.apply(x)).collect()
1491 }
1492
1493 /// Whether a dense matvec would go to an accelerator right now.
1494 fn gpu_dense_active() -> bool {
1495 #[cfg(feature = "metal")]
1496 let gpu = metal_dense_enabled();
1497 #[cfg(not(feature = "metal"))]
1498 let gpu = false;
1499 #[cfg(feature = "cuda")]
1500 let gpu = gpu || cuda_dense_enabled();
1501 gpu
1502 }
1503
1504 pub fn apply_cpu(&self, x: &[f32]) -> Vec<f32> {
1505 assert_eq!(
1506 x.len(),
1507 self.cols(),
1508 "activation length must match matrix column count"
1509 );
1510 // Decode: one activation, so this operation is `rows x cols`
1511 // MACs and a task's share of it is (rows in task) x cols.
1512 // Publishing the shape is what lets both the scheduler choice
1513 // and the task floor be work-aware rather than row-count-aware.
1514 crate::par::with_op_work(self.rows(), x.len(), || self.apply_cpu_inner(x))
1515 }
1516
1517 fn apply_cpu_inner(&self, x: &[f32]) -> Vec<f32> {
1518 match self {
1519 WeightMatrix::F32(t) => {
1520 let xt = Tensor::new(x.to_vec(), vec![1, x.len()]);
1521 crate::matmul::matmul_f32(&xt, t).data
1522 }
1523 WeightMatrix::Quantized {
1524 data,
1525 rows,
1526 cols,
1527 kind,
1528 } => {
1529 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1530 let mut out = vec![0f32; *rows];
1531 // FERROX_CPU_INT_DOT=1: quantize the shared activation once,
1532 // then every row dot is int8×int8 → i32 (llama.cpp CPU matmul).
1533 // Q8_0/Q4_0 use 32-elem Q8_0 acts; Q4_K/Q5_K/Q6_K use Q8_K.
1534 if cpu_int_dot_for(IntDotShape::Matvec) {
1535 match *kind {
1536 QuantKind::Q8_0 if x.len().is_multiple_of(32) => {
1537 let act = ferrox_quant::quantize_activations_q8(x);
1538 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1539 let serial = Self::prefer_serial_matvec(*rows, *cols);
1540 // Probed once per matvec, not once per row-group:
1541 // `q*_interleave` reads a CPU feature bit, and LLVM
1542 // cannot hoist that relaxed atomic load out of the
1543 // caller's loop. `is_aarch64_feature_detected!` ran
1544 // 131k times in one Mistral-7B projection before the
1545 // last one of these was hoisted.
1546 let interleave = ferrox_quant::q8_0x4_interleave();
1547 if n_groups > 0 {
1548 let packed = get_or_repack_q8x4(data, *rows, *cols);
1549 if serial {
1550 for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1551 .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1552 .enumerate()
1553 {
1554 ferrox_quant::gemv_q8_0x4_group(
1555 &packed, g, &act, *cols, interleave, chunk,
1556 );
1557 }
1558 } else {
1559 crate::par::chunks_mut(
1560 &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1561 ferrox_quant::Q8_0X4_NROWS,
1562 Self::min_rows_per_task(n_groups).max(1),
1563 |g, chunk| {
1564 ferrox_quant::gemv_q8_0x4_group(
1565 &packed, g, &act, *cols, interleave, chunk,
1566 );
1567 },
1568 );
1569 }
1570 let data_slice = data.as_slice();
1571 let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1572 if tail_len > 0 {
1573 let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1574 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1575 for (i, o) in tail.iter_mut().enumerate() {
1576 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1577 let row =
1578 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1579 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1580 }
1581 } else {
1582 let min_len = Self::min_rows_per_task(tail_len);
1583 crate::par::items_mut(tail, min_len, |i, o| {
1584 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1585 let row =
1586 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1587 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1588 });
1589 }
1590 }
1591 return out;
1592 }
1593 if serial {
1594 for (r, o) in out.iter_mut().enumerate() {
1595 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1596 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1597 }
1598 } else {
1599 crate::par::items_mut(
1600 &mut out,
1601 Self::min_rows_per_task(*rows),
1602 |r, o| {
1603 let row =
1604 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1605 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1606 },
1607 );
1608 }
1609 return out;
1610 }
1611 QuantKind::Q4_0 if x.len().is_multiple_of(32) => {
1612 let act = ferrox_quant::quantize_activations_q8(x);
1613 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1614 let serial = Self::prefer_serial_matvec(*rows, *cols);
1615 // Probed once per matvec, not once per row-group:
1616 // `q*_interleave` reads a CPU feature bit, and LLVM
1617 // cannot hoist that relaxed atomic load out of the
1618 // caller's loop. `is_aarch64_feature_detected!` ran
1619 // 131k times in one Mistral-7B projection before the
1620 // last one of these was hoisted.
1621 let interleave = ferrox_quant::q4_0x4_interleave();
1622 if n_groups > 0 {
1623 let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1624 if serial {
1625 for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1626 .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1627 .enumerate()
1628 {
1629 ferrox_quant::gemv_q4_0x4_group(
1630 &packed, g, &act, *cols, interleave, chunk,
1631 );
1632 }
1633 } else {
1634 crate::par::chunks_mut(
1635 &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1636 ferrox_quant::Q4_0X4_NROWS,
1637 Self::min_rows_per_task(n_groups).max(1),
1638 |g, chunk| {
1639 ferrox_quant::gemv_q4_0x4_group(
1640 &packed, g, &act, *cols, interleave, chunk,
1641 );
1642 },
1643 );
1644 }
1645 let data_slice = data.as_slice();
1646 let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1647 if tail_len > 0 {
1648 let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1649 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1650 for (i, o) in tail.iter_mut().enumerate() {
1651 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1652 let row =
1653 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1654 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1655 }
1656 } else {
1657 let min_len = Self::min_rows_per_task(tail_len);
1658 crate::par::items_mut(tail, min_len, |i, o| {
1659 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1660 let row =
1661 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1662 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1663 });
1664 }
1665 }
1666 return out;
1667 }
1668 if serial {
1669 for (r, o) in out.iter_mut().enumerate() {
1670 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1671 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1672 }
1673 } else {
1674 crate::par::items_mut(
1675 &mut out,
1676 Self::min_rows_per_task(*rows),
1677 |r, o| {
1678 let row =
1679 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1680 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1681 },
1682 );
1683 }
1684 return out;
1685 }
1686 QuantKind::Q4K if x.len().is_multiple_of(256) => {
1687 let act = ferrox_quant::quantize_activations_q8_k(x);
1688 let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
1689 if n_groups > 0 {
1690 let interleave = ferrox_quant::q4_kx8_interleave();
1691 let packed = get_or_repack_q4k(data, *rows, *cols);
1692 crate::par::chunks_mut(
1693 &mut out[..n_groups * ferrox_quant::Q4_KX8_NROWS],
1694 ferrox_quant::Q4_KX8_NROWS,
1695 Self::min_rows_per_task(n_groups).max(1),
1696 |g, chunk| {
1697 ferrox_quant::gemv_q4_kx8_group(
1698 &packed, g, &act, *cols, interleave, chunk,
1699 );
1700 },
1701 );
1702 let data_slice = data.as_slice();
1703 crate::par::items_mut(
1704 &mut out[n_groups * ferrox_quant::Q4_KX8_NROWS..],
1705 Self::min_rows_per_task(
1706 *rows - n_groups * ferrox_quant::Q4_KX8_NROWS,
1707 ),
1708 |i, o| {
1709 let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
1710 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1711 *o = ferrox_quant::dot_q4_k_q8(row, &act);
1712 },
1713 );
1714 return out;
1715 }
1716 crate::par::items_mut(
1717 &mut out,
1718 Self::min_rows_per_task(*rows),
1719 |r, o| {
1720 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1721 *o = ferrox_quant::dot_q4_k_q8(row, &act);
1722 },
1723 );
1724 return out;
1725 }
1726 QuantKind::Q5K if x.len().is_multiple_of(256) => {
1727 let act = ferrox_quant::quantize_activations_q8_k(x);
1728 let n_groups = *rows / ferrox_quant::Q5_KX8_NROWS;
1729 if n_groups > 0 {
1730 let interleave = ferrox_quant::q5_kx8_interleave();
1731 let packed = get_or_repack_q5k(data, *rows, *cols);
1732 crate::par::chunks_mut(
1733 &mut out[..n_groups * ferrox_quant::Q5_KX8_NROWS],
1734 ferrox_quant::Q5_KX8_NROWS,
1735 Self::min_rows_per_task(n_groups).max(1),
1736 |g, chunk| {
1737 ferrox_quant::gemv_q5_kx8_group(
1738 &packed, g, &act, *cols, interleave, chunk,
1739 );
1740 },
1741 );
1742 let data_slice = data.as_slice();
1743 crate::par::items_mut(
1744 &mut out[n_groups * ferrox_quant::Q5_KX8_NROWS..],
1745 Self::min_rows_per_task(
1746 *rows - n_groups * ferrox_quant::Q5_KX8_NROWS,
1747 ),
1748 |i, o| {
1749 let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
1750 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1751 *o = ferrox_quant::dot_q5_k_q8(row, &act);
1752 },
1753 );
1754 return out;
1755 }
1756 crate::par::items_mut(
1757 &mut out,
1758 Self::min_rows_per_task(*rows),
1759 |r, o| {
1760 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1761 *o = ferrox_quant::dot_q5_k_q8(row, &act);
1762 },
1763 );
1764 return out;
1765 }
1766 // IQ4_XS over Q8_K activations, llama.cpp's
1767 // `ggml_vec_dot_iq4_xs_q8_K` (`ferrox_quant::
1768 // iq4_xs_q8`): the same int8 lane the K-quants
1769 // take, so decode and prefill agree on the
1770 // activation quantization.
1771 QuantKind::IQ4XS if x.len().is_multiple_of(256) => {
1772 let act = ferrox_quant::quantize_activations_q8_k(x);
1773 crate::par::items_mut(
1774 &mut out,
1775 Self::min_rows_per_task(*rows),
1776 |r, o| {
1777 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1778 *o = ferrox_quant::dot_iq4_xs_q8_k(row, &act);
1779 },
1780 );
1781 return out;
1782 }
1783 QuantKind::Q6K if x.len().is_multiple_of(256) => {
1784 let act = ferrox_quant::quantize_activations_q8_k(x);
1785 let n_groups = *rows / ferrox_quant::Q6_KX8_NROWS;
1786 if n_groups > 0 {
1787 let interleave = ferrox_quant::q6_kx8_interleave();
1788 let packed = get_or_repack_q6k(data, *rows, *cols);
1789 crate::par::chunks_mut(
1790 &mut out[..n_groups * ferrox_quant::Q6_KX8_NROWS],
1791 ferrox_quant::Q6_KX8_NROWS,
1792 Self::min_rows_per_task(n_groups).max(1),
1793 |g, out8| {
1794 ferrox_quant::gemv_q6_kx8_group(
1795 &packed, g, &act, *cols, interleave, out8,
1796 );
1797 },
1798 );
1799 crate::par::items_mut(
1800 &mut out[n_groups * ferrox_quant::Q6_KX8_NROWS..],
1801 Self::min_rows_per_task(
1802 *rows - n_groups * ferrox_quant::Q6_KX8_NROWS,
1803 ),
1804 |i, o| {
1805 let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
1806 let row =
1807 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1808 *o = ferrox_quant::dot_q6_k_q8(row, &act);
1809 },
1810 );
1811 return out;
1812 }
1813 crate::par::items_mut(
1814 &mut out,
1815 Self::min_rows_per_task(*rows),
1816 |r, o| {
1817 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1818 *o = ferrox_quant::dot_q6_k_q8(row, &act);
1819 },
1820 );
1821 return out;
1822 }
1823 _ => {}
1824 }
1825 }
1826 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1827 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1828 *o = Self::dot(*kind, row, x);
1829 });
1830 out
1831 }
1832 WeightMatrix::Mxfp4 {
1833 packed,
1834 scale,
1835 rows,
1836 cols,
1837 } => {
1838 let packed_row_bytes = cols / 2;
1839 let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
1840 let mut out = vec![0f32; *rows];
1841 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1842 let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
1843 let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
1844 *o = ferrox_quant::dot_mxfp4_row_f32(prow, srow, x);
1845 });
1846 out
1847 }
1848 WeightMatrix::Adapted { base, lora } => {
1849 let mut out = base.apply_cpu_inner(x);
1850 lora.add_to(x, &mut out);
1851 out
1852 }
1853 WeightMatrix::Folded { base, fold } => base.apply_cpu_inner(&fold.transform_input(x)),
1854 }
1855 }
1856
1857 /// INT_DOT matvec against a pre-quantized Q8_0 activation (shared gate/up).
1858 ///
1859 /// Publishes this operation's shape for exactly the same reason
1860 /// [`Self::apply_cpu`] does, and it matters more here: the dense FFN
1861 /// gate and up projections are the widest matvecs in a decode step,
1862 /// so they are the ones the scheduler rule is deciding about.
1863 pub fn apply_cpu_q8(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1864 crate::par::with_op_work(self.rows(), self.cols(), || self.apply_cpu_q8_inner(act))
1865 }
1866
1867 /// [`Self::apply_cpu_q8`] with the operation's shape already
1868 /// published. Split only so the publish wraps every return path.
1869 fn apply_cpu_q8_inner(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1870 let WeightMatrix::Quantized {
1871 data,
1872 rows,
1873 cols,
1874 kind,
1875 } = self
1876 else {
1877 return None;
1878 };
1879 if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1880 || !cpu_int_dot_for(IntDotShape::Matvec)
1881 {
1882 return None;
1883 }
1884 if act.q.len() != *cols || !cols.is_multiple_of(32) {
1885 return None;
1886 }
1887 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1888 let mut out = vec![0f32; *rows];
1889 let kind = *kind;
1890 let bytes = data.as_slice();
1891 // Q8_0×4 / Q4_0×4 interleaved GEMV — same paths as `apply_cpu` so
1892 // dense FFN gate+up hit the fast kernels, not per-row int dots.
1893 if matches!(kind, QuantKind::Q8_0) {
1894 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1895 if n_groups > 0 {
1896 let packed = get_or_repack_q8x4(data, *rows, *cols);
1897 let serial = Self::prefer_serial_matvec(*rows, *cols);
1898 // Probed once per matvec, not once per row-group:
1899 // `q*_interleave` reads a CPU feature bit, and LLVM
1900 // cannot hoist that relaxed atomic load out of the
1901 // caller's loop. `is_aarch64_feature_detected!` ran
1902 // 131k times in one Mistral-7B projection before the
1903 // last one of these was hoisted.
1904 let interleave = ferrox_quant::q8_0x4_interleave();
1905 let body = |g: usize, chunk: &mut [f32]| {
1906 ferrox_quant::gemv_q8_0x4_group(&packed, g, act, *cols, interleave, chunk);
1907 };
1908 if serial {
1909 for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1910 .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1911 .enumerate()
1912 {
1913 body(g, chunk);
1914 }
1915 } else {
1916 crate::par::chunks_mut(
1917 &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1918 ferrox_quant::Q8_0X4_NROWS,
1919 Self::min_rows_per_task(n_groups).max(1),
1920 |g, chunk| body(g, chunk),
1921 );
1922 }
1923 let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1924 if tail_len > 0 {
1925 let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1926 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1927 for (i, o) in tail.iter_mut().enumerate() {
1928 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1929 *o = ferrox_quant::dot_q8_0_q8(
1930 &bytes[r * row_bytes..(r + 1) * row_bytes],
1931 act,
1932 );
1933 }
1934 } else {
1935 let min_len = Self::min_rows_per_task(tail_len);
1936 crate::par::items_mut(tail, min_len, |i, o| {
1937 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1938 *o = ferrox_quant::dot_q8_0_q8(
1939 &bytes[r * row_bytes..(r + 1) * row_bytes],
1940 act,
1941 );
1942 });
1943 }
1944 }
1945 return Some(out);
1946 }
1947 }
1948 if matches!(kind, QuantKind::Q4_0) {
1949 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1950 if n_groups > 0 {
1951 let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1952 let serial = Self::prefer_serial_matvec(*rows, *cols);
1953 // Probed once per matvec, not once per row-group:
1954 // `q*_interleave` reads a CPU feature bit, and LLVM
1955 // cannot hoist that relaxed atomic load out of the
1956 // caller's loop. `is_aarch64_feature_detected!` ran
1957 // 131k times in one Mistral-7B projection before the
1958 // last one of these was hoisted.
1959 let interleave = ferrox_quant::q4_0x4_interleave();
1960 let body = |g: usize, chunk: &mut [f32]| {
1961 ferrox_quant::gemv_q4_0x4_group(&packed, g, act, *cols, interleave, chunk);
1962 };
1963 if serial {
1964 for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1965 .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1966 .enumerate()
1967 {
1968 body(g, chunk);
1969 }
1970 } else {
1971 crate::par::chunks_mut(
1972 &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1973 ferrox_quant::Q4_0X4_NROWS,
1974 Self::min_rows_per_task(n_groups).max(1),
1975 |g, chunk| body(g, chunk),
1976 );
1977 }
1978 let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1979 if tail_len > 0 {
1980 let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1981 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1982 for (i, o) in tail.iter_mut().enumerate() {
1983 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1984 *o = ferrox_quant::dot_q4_0_q8(
1985 &bytes[r * row_bytes..(r + 1) * row_bytes],
1986 act,
1987 );
1988 }
1989 } else {
1990 let min_len = Self::min_rows_per_task(tail_len);
1991 crate::par::items_mut(tail, min_len, |i, o| {
1992 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1993 *o = ferrox_quant::dot_q4_0_q8(
1994 &bytes[r * row_bytes..(r + 1) * row_bytes],
1995 act,
1996 );
1997 });
1998 }
1999 }
2000 return Some(out);
2001 }
2002 }
2003 if Self::prefer_serial_matvec(*rows, *cols) {
2004 for (r, o) in out.iter_mut().enumerate() {
2005 let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2006 *o = match kind {
2007 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
2008 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
2009 _ => unreachable!(),
2010 };
2011 }
2012 return Some(out);
2013 }
2014 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
2015 let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2016 *o = match kind {
2017 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
2018 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
2019 _ => unreachable!(),
2020 };
2021 });
2022 Some(out)
2023 }
2024
2025 /// Two contiguous rows × one Q8 act (shared act loads). Q4_0 uses
2026 /// [`ferrox_quant::dot_q4_0_q8_2row`]; Q8_0 falls back to two singles.
2027 pub fn dot_pair_cpu_q8(
2028 &self,
2029 row: usize,
2030 act: &ferrox_quant::Q8Activations,
2031 ) -> Option<(f32, f32)> {
2032 let WeightMatrix::Quantized {
2033 data,
2034 rows,
2035 cols,
2036 kind,
2037 } = self
2038 else {
2039 return None;
2040 };
2041 if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2042 || !cpu_int_dot_for(IntDotShape::Matvec)
2043 {
2044 return None;
2045 }
2046 if act.q.len() != *cols || !cols.is_multiple_of(32) || row + 1 >= *rows {
2047 return None;
2048 }
2049 let row_bytes = self.block_bytes_per_row(*kind, *cols);
2050 let bytes = data.as_slice();
2051 let r0 = &bytes[row * row_bytes..(row + 1) * row_bytes];
2052 let r1 = &bytes[(row + 1) * row_bytes..(row + 2) * row_bytes];
2053 Some(match *kind {
2054 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8_2row(r0, r1, act),
2055 QuantKind::Q8_0 => (
2056 ferrox_quant::dot_q8_0_q8(r0, act),
2057 ferrox_quant::dot_q8_0_q8(r1, act),
2058 ),
2059 _ => unreachable!(),
2060 })
2061 }
2062
2063 /// Single-row INT_DOT against pre-quantized Q8_0 acts (llama `mul_mat_id`
2064 /// inner loop). Returns `None` if this matrix is not Q4_0/Q8_0 INT_DOT.
2065 pub fn dot_row_cpu_q8(&self, row: usize, act: &ferrox_quant::Q8Activations) -> Option<f32> {
2066 let WeightMatrix::Quantized {
2067 data,
2068 rows,
2069 cols,
2070 kind,
2071 } = self
2072 else {
2073 return None;
2074 };
2075 if row >= *rows
2076 || !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2077 || !cpu_int_dot_for(IntDotShape::Matvec)
2078 || act.q.len() != *cols
2079 || !cols.is_multiple_of(32)
2080 {
2081 return None;
2082 }
2083 let row_bytes = self.block_bytes_per_row(*kind, *cols);
2084 let bytes = &data.as_slice()[row * row_bytes..(row + 1) * row_bytes];
2085 Some(match *kind {
2086 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(bytes, act),
2087 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(bytes, act),
2088 _ => unreachable!(),
2089 })
2090 }
2091
2092 /// Computes `W @ X` for a *batch* of activation vectors at once:
2093 /// `x_batch` is `batch_size` rows of `self.cols()` elements each,
2094 /// flattened row-major; returns `batch_size` rows of
2095 /// `self.rows()` elements each, flattened row-major (`[batch,
2096 /// rows]`, matching the layout `Tensor`/`Decoder` expect for
2097 /// chaining into further matmuls).
2098 ///
2099 /// This is not just a convenience wrapper: for a quantized matrix,
2100 /// each weight row's bytes are read from memory *once* and dotted
2101 /// against every activation in the batch, instead of once per
2102 /// `apply` call. For a memory-bandwidth-bound quantized matmul --
2103 /// which fused Q8_0/Q4_0 dot products are, since the whole point of
2104 /// keeping weights quantized is that reading them is the
2105 /// bottleneck, not the arithmetic -- processing `batch_size`
2106 /// positions this way costs roughly the same *memory traffic* as
2107 /// processing one position, not `batch_size` times as much. This
2108 /// is the same reason speculative-decoding verification and batched
2109 /// prefill are faster per-token than sequential single-token decode
2110 /// on real hardware: it turns `batch_size` separate reads of the
2111 /// same weights into one.
2112 ///
2113 /// With Metal dense enabled, dispatches a single batched Metal
2114 /// command buffer — Q4_0/Q4_K/Q6_K/Q8_0 reuse the weights through a
2115 /// simdgroup `mul_mm` at `batch_size >= 4`; every other kind, and
2116 /// every smaller batch, uses
2117 /// [`ferrox_metal::gpu::launch_matvec_batch`]. Falls back to
2118 /// per-row [`Self::apply`] if the batch launch fails.
2119 pub fn apply_batch(&self, x_batch: &[f32], batch_size: usize) -> Vec<f32> {
2120 self.apply_batch_with_acts(x_batch, batch_size, None)
2121 }
2122
2123 /// Quantize `x_batch` once, in the activation format this matrix's
2124 /// INT_DOT batch path consumes, for sharing across every projection
2125 /// that reads the same input (q/k/v on one normed batch; gate/up on
2126 /// another). Returns `None` when [`Self::apply_batch`] would not use
2127 /// quantized activations for this matrix — GPU dispatch, INT_DOT off,
2128 /// unsupported kind or width — so callers can pass the result straight
2129 /// to [`Self::apply_batch_with_acts`] unconditionally.
2130 pub fn quantize_batch_acts(&self, x_batch: &[f32], batch_size: usize) -> Option<BatchActs> {
2131 if let WeightMatrix::Adapted { base, .. } = self {
2132 // The activations the BASE consumes; the delta reads the
2133 // f32 batch itself.
2134 return base.quantize_batch_acts(x_batch, batch_size);
2135 }
2136 if let WeightMatrix::Folded { .. } = self {
2137 // The base consumes the TRANSFORMED batch, which no sibling
2138 // matrix shares unless it shares the fold; `apply_batch`
2139 // quantizes its own.
2140 return None;
2141 }
2142 #[cfg(feature = "metal")]
2143 {
2144 if metal_dense_enabled()
2145 && matches!(
2146 self,
2147 WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2148 )
2149 {
2150 return None;
2151 }
2152 }
2153 #[cfg(feature = "cuda")]
2154 {
2155 if cuda_dense_enabled() && matches!(self, WeightMatrix::Quantized { .. }) {
2156 return None;
2157 }
2158 }
2159 let WeightMatrix::Quantized { cols, kind, .. } = self else {
2160 return None;
2161 };
2162 if !cpu_int_dot_for(IntDotShape::BatchGemm) || x_batch.len() != batch_size * cols {
2163 return None;
2164 }
2165 let cols = *cols;
2166 match kind {
2167 QuantKind::Q8_0 | QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2168 let acts: Vec<_> = (0..batch_size)
2169 .into_par_iter()
2170 .map(|b| {
2171 ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols])
2172 })
2173 .collect();
2174 // Q8_0 and Q4_0 agree on both the interleave width and the
2175 // predicate, so one tile set serves either consumer.
2176 let tiles =
2177 if ferrox_quant::q8_0x4_gemm_uses_acts_x4(ferrox_quant::q8_0x4_interleave()) {
2178 acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
2179 .map(|chunk| ferrox_quant::prepare_q8_acts_x4(chunk, cols))
2180 .collect()
2181 } else {
2182 Vec::new()
2183 };
2184 Some(BatchActs::Q8 { acts, tiles, cols })
2185 }
2186 QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K if cols.is_multiple_of(256) => {
2187 let acts: Vec<_> = (0..batch_size)
2188 .into_par_iter()
2189 .map(|b| {
2190 ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols])
2191 })
2192 .collect();
2193 // All three K-quants share the predicate and the quad
2194 // width, so the set a Q4_K gate builds is exactly what a
2195 // Q5_K or Q6_K sibling would have built for itself.
2196 let tiles =
2197 if ferrox_quant::q4_kx8_gemm_uses_acts_x4(ferrox_quant::q4_kx8_interleave()) {
2198 acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
2199 .map(|chunk| ferrox_quant::prepare_q8_k_acts_x4(chunk, cols))
2200 .collect()
2201 } else {
2202 Vec::new()
2203 };
2204 Some(BatchActs::Q8K { acts, tiles, cols })
2205 }
2206 _ => None,
2207 }
2208 }
2209
2210 /// [`Self::apply_batch`], optionally reusing a shared pre-quantized
2211 /// activation batch from [`Self::quantize_batch_acts`]. A `shared`
2212 /// value whose format or length does not match this matrix is simply
2213 /// ignored (the activations are re-quantized locally), so mixed-kind
2214 /// projection groups stay correct.
2215 pub fn apply_batch_with_acts(
2216 &self,
2217 x_batch: &[f32],
2218 batch_size: usize,
2219 shared: Option<&BatchActs>,
2220 ) -> Vec<f32> {
2221 let cols = self.cols();
2222 assert_eq!(
2223 x_batch.len(),
2224 batch_size * cols,
2225 "x_batch length must be batch_size * cols"
2226 );
2227 if batch_size == 0 {
2228 return Vec::new();
2229 }
2230 crate::activation_tap::observe(self, x_batch, batch_size);
2231 if let WeightMatrix::Adapted { base, lora } = self {
2232 let mut out = base.apply_batch_with_acts(x_batch, batch_size, shared);
2233 lora.add_batch_to(x_batch, batch_size, &mut out);
2234 return out;
2235 }
2236 if let WeightMatrix::Folded { base, fold } = self {
2237 let transformed = fold.transform_rows(x_batch, batch_size);
2238 return base.apply_batch_with_acts(&transformed, batch_size, None);
2239 }
2240
2241 /// Raw pointer to this function's `[batch][rows]` output, shared
2242 /// across rayon tasks.
2243 ///
2244 /// Parallelism is over weight rows, but a row's `batch_size` output
2245 /// slots (`out[b * rows + r]` for every `b`) interleave with every
2246 /// other row's, so they cannot be handed out as disjoint `&mut`
2247 /// chunks. Each task writes only the rows it owns, which keeps the
2248 /// writes race-free; this wrapper just carries the pointer across
2249 /// the `Send`/`Sync` boundary. Writing straight into the final
2250 /// layout kills what used to be here: a `[rows][batch]` staging vec
2251 /// (zeroed every call) plus a serial rows × batch transpose after
2252 /// the parallel section had already finished.
2253 #[derive(Clone, Copy)]
2254 struct BatchOut(*mut f32);
2255 unsafe impl Send for BatchOut {}
2256 unsafe impl Sync for BatchOut {}
2257 impl BatchOut {
2258 /// Safety: `idx` in bounds, and concurrent tasks never pass
2259 /// the same `idx` (they own disjoint row sets).
2260 #[inline]
2261 unsafe fn set(self, idx: usize, v: f32) {
2262 *self.0.add(idx) = v;
2263 }
2264 }
2265
2266 #[cfg(feature = "metal")]
2267 {
2268 if metal_dense_enabled()
2269 && matches!(
2270 self,
2271 WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2272 )
2273 {
2274 if let Some(out) = self.apply_gpu_batch(x_batch, batch_size) {
2275 return out;
2276 }
2277 // The kind is Metal-supported, so reaching here means a
2278 // launch failed and the batch degrades to `batch_size`
2279 // separate `apply` calls -- each its own command buffer,
2280 // commit and wait.
2281 crate::kernel_registry::miss(
2282 crate::kernel_registry::Lookup::new(
2283 crate::kernel_registry::Backend::Metal,
2284 crate::kernel_registry::op::GEMM_PREFILL,
2285 self.quant_kind(),
2286 ),
2287 "N x apply (one command buffer each)",
2288 );
2289 let rows = self.rows();
2290 let mut out = vec![0f32; batch_size * rows];
2291 for b in 0..batch_size {
2292 let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2293 out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2294 }
2295 return out;
2296 } else if metal_dense_enabled() {
2297 // Metal is on but this matrix has no Metal kernel at
2298 // all, so the whole GEMM runs on the CPU. For a
2299 // quantized weight that is the IQ4_XS shape exactly; for
2300 // an F32 one it is the documented host GEMM.
2301 let look = crate::kernel_registry::Lookup::new(
2302 crate::kernel_registry::Backend::Metal,
2303 crate::kernel_registry::op::GEMM_PREFILL,
2304 self.quant_kind(),
2305 );
2306 if self.quant_kind().is_some() {
2307 crate::kernel_registry::miss(look, "CPU apply_batch");
2308 } else {
2309 crate::kernel_registry::miss_by_design(look, "CPU f32 GEMM");
2310 }
2311 }
2312 }
2313
2314 // CUDA has a batched GEMM for every kind in
2315 // `ferrox_cuda::mul_mm::KINDS` (`cuda_mul_mm_kind_supported`),
2316 // and NO PART OF IT HAS RUN ON A GPU. Every other kind still
2317 // takes the per-position matvec loop below, which is the arm
2318 // that has -- for the six kinds that predate 2026-09-09.
2319 //
2320 // That loop is why this arm exists at all: without it a batched
2321 // prefill fell through to the CPU branch and never touched the
2322 // GPU -- measured on an RTX 4090, SmolLM2 `pp512` ran at 28
2323 // tok/s against llama.cpp's 57466. Per-position matvec is still
2324 // the wrong shape for a wide prefill, but it is the GPU rather
2325 // than 26 idle SMs, and the fallback now records a
2326 // `GEMM_PREFILL` miss instead of degrading silently.
2327 #[cfg(feature = "cuda")]
2328 {
2329 // The batched GEMM first, when the kind has one and the
2330 // batch is wide enough to pay for it. Below that threshold a
2331 // single token stays on the matvec kernels, which are the
2332 // arm that has actually run on a GPU.
2333 if cuda_dense_enabled() {
2334 if let WeightMatrix::Quantized { data, kind, .. } = self {
2335 if cuda_mul_mm_kind_supported(*kind)
2336 && ferrox_cuda::mul_mm::worth_a_gemm(batch_size)
2337 {
2338 let mm_kind = ferrox_cuda::mul_mm::kind_by_name(kind.name())
2339 .expect("cuda_mul_mm_kind_supported agreed");
2340 let row_bytes = self.block_bytes_per_row(*kind, cols);
2341 match ferrox_cuda::mul_mm_launch::launch_mul_mm(
2342 mm_kind,
2343 data.as_slice(),
2344 x_batch,
2345 self.rows(),
2346 cols,
2347 batch_size,
2348 row_bytes,
2349 ) {
2350 Ok(out) => return out,
2351 Err(_) => {
2352 // The kind HAS a GEMM, so reaching here is a
2353 // launch failure rather than an unsupported
2354 // kind, and the batch degrades to per-position
2355 // matvecs. This call site used to be the one
2356 // SILENT fallback in the registry's table.
2357 crate::kernel_registry::miss(
2358 crate::kernel_registry::Lookup::new(
2359 crate::kernel_registry::Backend::Cuda,
2360 crate::kernel_registry::op::GEMM_PREFILL,
2361 self.quant_kind(),
2362 ),
2363 "N x matvec (the GEMM launch failed)",
2364 );
2365 }
2366 }
2367 }
2368 }
2369 }
2370 if cuda_dense_enabled()
2371 && matches!(self, WeightMatrix::Quantized { .. })
2372 && self.apply_gpu(&x_batch[..cols]).is_some()
2373 {
2374 let rows = self.rows();
2375 let mut out = vec![0f32; batch_size * rows];
2376 for b in 0..batch_size {
2377 match self.apply_gpu(&x_batch[b * cols..(b + 1) * cols]) {
2378 Some(y) => out[b * rows..(b + 1) * rows].copy_from_slice(&y),
2379 None => {
2380 let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2381 out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2382 }
2383 }
2384 }
2385 return out;
2386 }
2387 }
2388
2389 match self {
2390 WeightMatrix::F32(t) => {
2391 let xt = Tensor::new(x_batch.to_vec(), vec![batch_size, cols]);
2392 crate::matmul::matmul_f32(&xt, t).data
2393 }
2394 WeightMatrix::Quantized {
2395 data,
2396 rows,
2397 cols: _,
2398 kind,
2399 } => {
2400 let row_bytes = self.block_bytes_per_row(*kind, cols);
2401 // Written directly in the [batch, rows] layout the function
2402 // returns: each parallel task owns a disjoint set of rows
2403 // `r` and scatters `out[b * rows + r]` for every `b`
2404 // through `BatchOut`.
2405 let mut out = vec![0f32; batch_size * rows];
2406 let out_w = BatchOut(out.as_mut_ptr());
2407
2408 // Prefill INT_DOT: quantize each activation once, then
2409 // reuse Q8 packs across all weight rows (llama CPU path).
2410 if cpu_int_dot_for(IntDotShape::BatchGemm) {
2411 match *kind {
2412 QuantKind::Q8_0 if cols.is_multiple_of(32) => {
2413 let mut acts_owned = Vec::new();
2414 let (acts, shared_tiles) =
2415 Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2416 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
2417 if n_groups > 0 {
2418 let packed = get_or_repack_q8x4(data, *rows, cols);
2419 let nrows_g = ferrox_quant::Q8_0X4_NROWS;
2420 let interleave = ferrox_quant::q8_0x4_interleave();
2421 if ferrox_quant::q8_0x4_gemm_uses_acts_x4(interleave) {
2422 // i8mm: interleave each quad of
2423 // activations once per matmul (llama.cpp
2424 // `ggml_quantize_mat_q8_0_4x8` into
2425 // `wdata`); every row-group reuses it.
2426 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2427 let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2428 let act_tiles: &[ferrox_quant::Q8ActsX4] =
2429 if shared_tiles.is_empty() {
2430 tiles_owned = acts
2431 .par_chunks(nc)
2432 .map(|chunk| {
2433 ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2434 })
2435 .collect();
2436 &tiles_owned
2437 } else {
2438 shared_tiles
2439 };
2440 // One runtime i8mm probe per matmul, not
2441 // one per (row-group x quad); see
2442 // `ferrox_quant::AccelX4`.
2443 let accel = ferrox_quant::AccelX4::detect();
2444 Self::par_chunked_groups(
2445 n_groups,
2446 nrows_g,
2447 act_tiles.len(),
2448 nc,
2449 |g, t0, t1| {
2450 let mut tmp = [0f32;
2451 ferrox_quant::Q8_0X4_NROWS
2452 * ferrox_quant::Q8K_ACTS_X4_NC];
2453 for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2454 let t = t0 + t;
2455 let n = tile.na;
2456 let tmp = &mut tmp[..nrows_g * n];
2457 ferrox_quant::gemm_q8_0x4_group_x4_on(
2458 &packed, g, tile, cols, interleave, accel, tmp,
2459 );
2460 for j in 0..n {
2461 let col = (t * nc + j) * rows + g * nrows_g;
2462 for r in 0..nrows_g {
2463 unsafe {
2464 out_w.set(col + r, tmp[r * n + j]);
2465 }
2466 }
2467 }
2468 }
2469 },
2470 );
2471 } else {
2472 // GEMM, not a GEMV per position: the
2473 // batched kernel writes a `[row][batch]`
2474 // span, and the group's weight vectors
2475 // stay in registers across a tile of
2476 // activations. The span is then scattered
2477 // into the [batch][rows] output right
2478 // here, in parallel.
2479 let span = ferrox_quant::Q8_0X4_GEMM_NC;
2480 let n_tiles = batch_size.div_ceil(span);
2481 Self::par_chunked_groups(
2482 n_groups,
2483 nrows_g,
2484 n_tiles,
2485 span,
2486 |g, t0, t1| {
2487 let b0 = t0 * span;
2488 let b1 = (t1 * span).min(batch_size);
2489 let n = b1 - b0;
2490 let mut group = vec![0f32; nrows_g * n];
2491 ferrox_quant::gemm_q8_0x4_group(
2492 &packed,
2493 g,
2494 &acts[b0..b1],
2495 cols,
2496 interleave,
2497 &mut group,
2498 );
2499 for (bi, b) in (b0..b1).enumerate() {
2500 for r in 0..nrows_g {
2501 unsafe {
2502 out_w.set(
2503 b * rows + g * nrows_g + r,
2504 group[r * n + bi],
2505 );
2506 }
2507 }
2508 }
2509 },
2510 );
2511 }
2512 let data_slice = data.as_slice();
2513 let tail = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
2514 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2515 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
2516 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2517 for (b, act) in acts.iter().enumerate() {
2518 unsafe {
2519 out_w.set(
2520 b * rows + r,
2521 ferrox_quant::dot_q8_0_q8(row, act),
2522 );
2523 }
2524 }
2525 });
2526 } else {
2527 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2528 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2529 for (b, act) in acts.iter().enumerate() {
2530 unsafe {
2531 out_w.set(
2532 b * rows + r,
2533 ferrox_quant::dot_q8_0_q8(row, act),
2534 );
2535 }
2536 }
2537 });
2538 }
2539 return out;
2540 }
2541 QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2542 let mut acts_owned = Vec::new();
2543 let (acts, shared_tiles) =
2544 Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2545 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
2546 if n_groups > 0 {
2547 let packed = get_or_repack_q4_0x4(data, *rows, cols);
2548 let nrows_g = ferrox_quant::Q4_0X4_NROWS;
2549 let interleave = ferrox_quant::q4_0x4_interleave();
2550 if ferrox_quant::q4_0x4_gemm_uses_acts_x4(interleave) {
2551 // i8mm: same once-per-matmul activation
2552 // quad hoist as the Q8_0 arm above.
2553 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2554 let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2555 let act_tiles: &[ferrox_quant::Q8ActsX4] =
2556 if shared_tiles.is_empty() {
2557 tiles_owned = acts
2558 .par_chunks(nc)
2559 .map(|chunk| {
2560 ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2561 })
2562 .collect();
2563 &tiles_owned
2564 } else {
2565 shared_tiles
2566 };
2567 let accel = ferrox_quant::AccelX4::detect();
2568 Self::par_chunked_groups(
2569 n_groups,
2570 nrows_g,
2571 act_tiles.len(),
2572 nc,
2573 |g, t0, t1| {
2574 let mut tmp = [0f32;
2575 ferrox_quant::Q4_0X4_NROWS
2576 * ferrox_quant::Q8K_ACTS_X4_NC];
2577 for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2578 let t = t0 + t;
2579 let n = tile.na;
2580 let tmp = &mut tmp[..nrows_g * n];
2581 ferrox_quant::gemm_q4_0x4_group_x4_on(
2582 &packed, g, tile, cols, interleave, accel, tmp,
2583 );
2584 for j in 0..n {
2585 let col = (t * nc + j) * rows + g * nrows_g;
2586 for r in 0..nrows_g {
2587 unsafe {
2588 out_w.set(col + r, tmp[r * n + j]);
2589 }
2590 }
2591 }
2592 }
2593 },
2594 );
2595 } else {
2596 // GEMM, not a GEMV per position: the
2597 // batched kernel writes a `[row][batch]`
2598 // span, and the group's weight vectors
2599 // stay in registers across a tile of
2600 // activations. The span is then scattered
2601 // into the [batch][rows] output right
2602 // here, in parallel.
2603 let span = ferrox_quant::Q8_0X4_GEMM_NC;
2604 let n_tiles = batch_size.div_ceil(span);
2605 Self::par_chunked_groups(
2606 n_groups,
2607 nrows_g,
2608 n_tiles,
2609 span,
2610 |g, t0, t1| {
2611 let b0 = t0 * span;
2612 let b1 = (t1 * span).min(batch_size);
2613 let n = b1 - b0;
2614 let mut group = vec![0f32; nrows_g * n];
2615 ferrox_quant::gemm_q4_0x4_group(
2616 &packed,
2617 g,
2618 &acts[b0..b1],
2619 cols,
2620 interleave,
2621 &mut group,
2622 );
2623 for (bi, b) in (b0..b1).enumerate() {
2624 for r in 0..nrows_g {
2625 unsafe {
2626 out_w.set(
2627 b * rows + g * nrows_g + r,
2628 group[r * n + bi],
2629 );
2630 }
2631 }
2632 }
2633 },
2634 );
2635 }
2636 let data_slice = data.as_slice();
2637 let tail = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
2638 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2639 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
2640 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2641 for (b, act) in acts.iter().enumerate() {
2642 unsafe {
2643 out_w.set(
2644 b * rows + r,
2645 ferrox_quant::dot_q4_0_q8(row, act),
2646 );
2647 }
2648 }
2649 });
2650 } else {
2651 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2652 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2653 for (b, act) in acts.iter().enumerate() {
2654 unsafe {
2655 out_w.set(
2656 b * rows + r,
2657 ferrox_quant::dot_q4_0_q8(row, act),
2658 );
2659 }
2660 }
2661 });
2662 }
2663 return out;
2664 }
2665 QuantKind::Q4K if cols.is_multiple_of(256) => {
2666 let mut acts_owned = Vec::new();
2667 let (acts, shared_tiles) =
2668 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2669 let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
2670 if n_groups > 0 {
2671 let interleave = ferrox_quant::q4_kx8_interleave();
2672 let packed = get_or_repack_q4k(data, *rows, cols);
2673 let nc = ferrox_quant::Q4_KX8_GEMM_NC;
2674 // On the i8mm path, interleave each quad of
2675 // activations once per matmul (llama.cpp
2676 // `ggml_quantize_mat_q8_K_4x8` into `wdata`);
2677 // the kernel used to redo it per row-group.
2678 // A `shared` batch has already paid for this
2679 // on behalf of every sibling projection. The
2680 // predicate is asked first either way: it,
2681 // not the donor, decides whether this matrix
2682 // has an x4 kernel at all.
2683 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2684 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2685 if !ferrox_quant::q4_kx8_gemm_uses_acts_x4(interleave) {
2686 &[]
2687 } else if !shared_tiles.is_empty() {
2688 shared_tiles
2689 } else {
2690 tiles_owned = acts
2691 .par_chunks(nc)
2692 .map(|chunk| {
2693 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2694 })
2695 .collect();
2696 &tiles_owned
2697 };
2698 let accel = ferrox_quant::AccelX4::detect();
2699 let n_tiles = batch_size.div_ceil(nc);
2700 Self::par_chunked_groups(
2701 n_groups,
2702 ferrox_quant::Q4_KX8_NROWS,
2703 n_tiles,
2704 nc,
2705 |g, t0, t1| {
2706 let mut tile = [0f32;
2707 ferrox_quant::Q4_KX8_NROWS
2708 * ferrox_quant::Q4_KX8_GEMM_NC];
2709 for t in t0..t1 {
2710 let chunk =
2711 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2712 let n = chunk.len();
2713 let tile = &mut tile[..ferrox_quant::Q4_KX8_NROWS * n];
2714 if act_tiles.is_empty() {
2715 ferrox_quant::gemm_q4_kx8_group(
2716 &packed, g, chunk, cols, interleave, tile,
2717 );
2718 } else {
2719 ferrox_quant::gemm_q4_kx8_group_x4_on(
2720 &packed,
2721 g,
2722 &act_tiles[t],
2723 cols,
2724 interleave,
2725 accel,
2726 tile,
2727 );
2728 }
2729 for j in 0..n {
2730 let col = (t * nc + j) * rows
2731 + g * ferrox_quant::Q4_KX8_NROWS;
2732 for r in 0..ferrox_quant::Q4_KX8_NROWS {
2733 unsafe {
2734 out_w.set(col + r, tile[r * n + j]);
2735 }
2736 }
2737 }
2738 }
2739 },
2740 );
2741 let data_slice = data.as_slice();
2742 let tail = *rows - n_groups * ferrox_quant::Q4_KX8_NROWS;
2743 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2744 let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
2745 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2746 for (b, act) in acts.iter().enumerate() {
2747 unsafe {
2748 out_w.set(
2749 b * rows + r,
2750 ferrox_quant::dot_q4_k_q8(row, act),
2751 );
2752 }
2753 }
2754 });
2755 } else {
2756 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2757 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2758 for (b, act) in acts.iter().enumerate() {
2759 unsafe {
2760 out_w.set(
2761 b * rows + r,
2762 ferrox_quant::dot_q4_k_q8(row, act),
2763 );
2764 }
2765 }
2766 });
2767 }
2768 return out;
2769 }
2770 QuantKind::Q5K if cols.is_multiple_of(256) => {
2771 let mut acts_owned = Vec::new();
2772 let (acts, shared_tiles) =
2773 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2774 // The Kx8 batch path: every aarch64 host (i8mm,
2775 // dotprod, or the scalar Kx8 body, as the Q4_K arm
2776 // takes it), and any other host whose `x4` GEMM
2777 // has a SIMD kernel at this width (AVX2 since
2778 // #159). This read `cfg!(target_arch = "aarch64")`
2779 // alone until 2026-09-15, which sent every x86
2780 // Q5_K prefill through the per-row GEMM below:
2781 // 44.2 against llama.cpp's 378.7 tok/s on
2782 // Llama-3.2-1B Q5_K_M (8.56x) on a Ryzen 5950X,
2783 // beside Q4_K at 1.19x and Q6_K at 1.19x on the
2784 // same host, because those two arms asked the
2785 // kernels and this one asked the architecture.
2786 let interleave = ferrox_quant::q5_kx8_interleave();
2787 let use_kx8 = q5k_batch_takes_kx8(interleave);
2788 let n_groups = if use_kx8 {
2789 *rows / ferrox_quant::Q5_KX8_NROWS
2790 } else {
2791 0
2792 };
2793 if n_groups > 0 {
2794 let packed = get_or_repack_q5k(data, *rows, cols);
2795 let nc = ferrox_quant::Q5_KX8_GEMM_NC;
2796 // On the i8mm path, interleave each quad of
2797 // activations once per matmul; the kernel
2798 // consumes it for every row-group. A `shared`
2799 // batch has already paid for it. Predicate
2800 // first, as in the Q4_K arm.
2801 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2802 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2803 if !ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
2804 &[]
2805 } else if !shared_tiles.is_empty() {
2806 shared_tiles
2807 } else {
2808 tiles_owned = acts
2809 .par_chunks(nc)
2810 .map(|chunk| {
2811 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2812 })
2813 .collect();
2814 &tiles_owned
2815 };
2816 let accel = ferrox_quant::AccelX4::detect();
2817 let n_tiles = batch_size.div_ceil(nc);
2818 Self::par_chunked_groups(
2819 n_groups,
2820 ferrox_quant::Q5_KX8_NROWS,
2821 n_tiles,
2822 nc,
2823 |g, t0, t1| {
2824 let mut tile = [0f32;
2825 ferrox_quant::Q5_KX8_NROWS
2826 * ferrox_quant::Q5_KX8_GEMM_NC];
2827 for t in t0..t1 {
2828 let chunk =
2829 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2830 let n = chunk.len();
2831 let tile = &mut tile[..ferrox_quant::Q5_KX8_NROWS * n];
2832 if act_tiles.is_empty() {
2833 ferrox_quant::gemm_q5_kx8_group(
2834 &packed, g, chunk, cols, interleave, tile,
2835 );
2836 } else {
2837 ferrox_quant::gemm_q5_kx8_group_x4_on(
2838 &packed,
2839 g,
2840 &act_tiles[t],
2841 cols,
2842 interleave,
2843 accel,
2844 tile,
2845 );
2846 }
2847 for j in 0..n {
2848 let col = (t * nc + j) * rows
2849 + g * ferrox_quant::Q5_KX8_NROWS;
2850 for r in 0..ferrox_quant::Q5_KX8_NROWS {
2851 unsafe {
2852 out_w.set(col + r, tile[r * n + j]);
2853 }
2854 }
2855 }
2856 }
2857 },
2858 );
2859 let data_slice = data.as_slice();
2860 let tail = *rows - n_groups * ferrox_quant::Q5_KX8_NROWS;
2861 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2862 let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
2863 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2864 for (b, act) in acts.iter().enumerate() {
2865 unsafe {
2866 out_w.set(
2867 b * rows + r,
2868 ferrox_quant::dot_q5_k_q8(row, act),
2869 );
2870 }
2871 }
2872 });
2873 } else {
2874 let data_slice = data.as_slice();
2875 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2876 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2877 let nc = ferrox_quant::Q5_K_GEMM_NC;
2878 for (t, chunk) in acts.chunks(nc).enumerate() {
2879 let n = chunk.len();
2880 let mut tmp = [0f32; ferrox_quant::Q5_K_GEMM_NC];
2881 ferrox_quant::gemm_q5_k_q8_row(row, chunk, &mut tmp[..n]);
2882 for (j, v) in tmp[..n].iter().enumerate() {
2883 unsafe {
2884 out_w.set((t * nc + j) * rows + r, *v);
2885 }
2886 }
2887 }
2888 });
2889 }
2890 return out;
2891 }
2892 QuantKind::Q6K if cols.is_multiple_of(256) => {
2893 let mut acts_owned = Vec::new();
2894 let (acts, shared_tiles) =
2895 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2896 // Kx8 batch path only where the i8mm GEMM
2897 // exists (the scalar Kx8 GEMM measured slower
2898 // than the per-row NEON dot on Phi ffn_down,
2899 // so everything else keeps the row path).
2900 let interleave = ferrox_quant::q6_kx8_interleave();
2901 let use_kx8 = ferrox_quant::q6_kx8_gemm_uses_acts_x4(interleave);
2902 let n_groups = if use_kx8 {
2903 *rows / ferrox_quant::Q6_KX8_NROWS
2904 } else {
2905 0
2906 };
2907 if n_groups > 0 {
2908 let packed = get_or_repack_q6k(data, *rows, cols);
2909 // Quads of 4 (the i8mm tile shape), not
2910 // [`Q6_KX8_GEMM_NC`].
2911 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2912 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2913 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2914 if shared_tiles.is_empty() {
2915 tiles_owned = acts
2916 .par_chunks(nc)
2917 .map(|chunk| {
2918 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2919 })
2920 .collect();
2921 &tiles_owned
2922 } else {
2923 shared_tiles
2924 };
2925 let accel = ferrox_quant::AccelX4::detect();
2926 let n_tiles = batch_size.div_ceil(nc);
2927 Self::par_chunked_groups(
2928 n_groups,
2929 ferrox_quant::Q6_KX8_NROWS,
2930 n_tiles,
2931 nc,
2932 |g, t0, t1| {
2933 let mut tile = [0f32;
2934 ferrox_quant::Q6_KX8_NROWS
2935 * ferrox_quant::Q8K_ACTS_X4_NC];
2936 for t in t0..t1 {
2937 let chunk =
2938 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2939 let n = chunk.len();
2940 let tile = &mut tile[..ferrox_quant::Q6_KX8_NROWS * n];
2941 ferrox_quant::gemm_q6_kx8_group_x4_on(
2942 &packed,
2943 g,
2944 &act_tiles[t],
2945 cols,
2946 interleave,
2947 accel,
2948 tile,
2949 );
2950 for j in 0..n {
2951 let col = (t * nc + j) * rows
2952 + g * ferrox_quant::Q6_KX8_NROWS;
2953 for r in 0..ferrox_quant::Q6_KX8_NROWS {
2954 unsafe {
2955 out_w.set(col + r, tile[r * n + j]);
2956 }
2957 }
2958 }
2959 }
2960 },
2961 );
2962 let data_slice = data.as_slice();
2963 let tail = *rows - n_groups * ferrox_quant::Q6_KX8_NROWS;
2964 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2965 let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
2966 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2967 for (b, act) in acts.iter().enumerate() {
2968 unsafe {
2969 out_w.set(
2970 b * rows + r,
2971 ferrox_quant::dot_q6_k_q8(row, act),
2972 );
2973 }
2974 }
2975 });
2976 } else {
2977 let data_slice = data.as_slice();
2978 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2979 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2980 let nc = ferrox_quant::Q6_K_GEMM_NC;
2981 for (t, chunk) in acts.chunks(nc).enumerate() {
2982 let mut tmp = [0f32; ferrox_quant::Q6_K_GEMM_NC];
2983 let n = chunk.len();
2984 ferrox_quant::gemm_q6_k_q8_row(row, chunk, &mut tmp[..n]);
2985 for (j, v) in tmp[..n].iter().enumerate() {
2986 unsafe {
2987 out_w.set((t * nc + j) * rows + r, *v);
2988 }
2989 }
2990 }
2991 });
2992 }
2993 return out;
2994 }
2995 // IQ4_XS: quantize the activations to Q8_K ONCE
2996 // per matmul and run the int8 dot per (row,
2997 // activation). No Kx8 tier, so the row's nibbles
2998 // are still decoded per activation, as llama.cpp's
2999 // own IQ4_XS prefill decodes them; what the f32
3000 // fallback below paid on top was an f32 FMA per
3001 // element and a per-activation f32 read of the
3002 // row, measured 4.45x behind llama.cpp on a Ryzen
3003 // 9 3900X (2026-09-15) where every K-quant on the
3004 // same host was 1.0x to 1.4x.
3005 QuantKind::IQ4XS if cols.is_multiple_of(256) => {
3006 let mut acts_owned = Vec::new();
3007 let (acts, _) =
3008 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
3009 let data_slice = data.as_slice();
3010 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3011 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
3012 for (b, act) in acts.iter().enumerate() {
3013 unsafe {
3014 out_w.set(
3015 b * rows + r,
3016 ferrox_quant::dot_iq4_xs_q8_k(row, act),
3017 );
3018 }
3019 }
3020 });
3021 return out;
3022 }
3023 QuantKind::Q5K | QuantKind::Q6K => {}
3024 _ => {}
3025 }
3026 }
3027
3028 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3029 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
3030 for b in 0..batch_size {
3031 let x = &x_batch[b * cols..(b + 1) * cols];
3032 unsafe {
3033 out_w.set(b * rows + r, Self::dot(*kind, row, x));
3034 }
3035 }
3036 });
3037 out
3038 }
3039 WeightMatrix::Mxfp4 {
3040 packed,
3041 scale,
3042 rows,
3043 cols: _,
3044 } => {
3045 let packed_row_bytes = cols / 2;
3046 let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
3047 let mut out = vec![0f32; batch_size * rows];
3048 let out_w = BatchOut(out.as_mut_ptr());
3049 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3050 let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
3051 let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
3052 for b in 0..batch_size {
3053 let x = &x_batch[b * cols..(b + 1) * cols];
3054 unsafe {
3055 out_w.set(b * rows + r, ferrox_quant::dot_mxfp4_row_f32(prow, srow, x));
3056 }
3057 }
3058 });
3059 out
3060 }
3061 WeightMatrix::Adapted { .. } | WeightMatrix::Folded { .. } => {
3062 unreachable!("handled before dispatch")
3063 }
3064 }
3065 }
3066
3067 /// Bytes actually resident in memory for this matrix -- the number
3068 /// that matters for "can this model's weights fit in RAM/VRAM at
3069 /// all," as opposed to the always-4x-larger f32-expanded size.
3070 pub fn resident_bytes(&self) -> usize {
3071 match self {
3072 WeightMatrix::F32(t) => t.len() * 4,
3073 WeightMatrix::Quantized { data, .. } => data.len(),
3074 WeightMatrix::Mxfp4 { packed, scale, .. } => packed.len() + scale.len(),
3075 WeightMatrix::Adapted { base, lora } => base.resident_bytes() + lora.resident_bytes(),
3076 WeightMatrix::Folded { base, .. } => base.resident_bytes(),
3077 }
3078 }
3079
3080 /// Dispatches a single matvec through a real GPU kernel when a GPU
3081 /// feature is compiled in (`cuda` and/or `metal`) and this matrix
3082 /// is one of the five GPU-accelerated quant kinds (Q8_0, Q4_0,
3083 /// Q4_K, Q5_K, Q6_K). Returns `None` for every other case (no GPU
3084 /// feature, `F32`/`Mxfp4`/`Mxfp4Gguf`, or a `Quantized` kind other
3085 /// than the five below), so the caller falls back to `apply()` on
3086 /// the CPU -- this is a real dispatch decision
3087 /// (`ferrox_moe::run_expert_placed` uses it exactly this way), not
3088 /// a stub. Metal weight buffers are process-resident after the first
3089 /// upload (`ferrox_metal::gpu` weight cache); activations still
3090 /// upload per call. When both `cuda` and `metal` are enabled, CUDA
3091 /// is tried first and Metal is the fallback.
3092 #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
3093 pub fn apply_gpu(&self, x: &[f32]) -> Option<Vec<f32>> {
3094 assert_eq!(
3095 x.len(),
3096 self.cols(),
3097 "activation length must match matrix column count"
3098 );
3099 if let WeightMatrix::Adapted { base, lora } = self {
3100 let mut out = base.apply_gpu(x)?;
3101 lora.add_to(x, &mut out);
3102 return Some(out);
3103 }
3104 if let WeightMatrix::Folded { base, fold } = self {
3105 // The rotation goes into the matvec's own command buffer
3106 // when Metal can serve it, so the host neither runs the
3107 // butterfly nor ships a second vector. Every other backend
3108 // takes the host transform, which is the definition both
3109 // sides are checked against.
3110 #[cfg(feature = "metal")]
3111 if metal_dense_enabled() {
3112 if let Some(plan) = fold.metal_plan(x.len()) {
3113 if let Some(out) = base.apply_gpu_folded(x, Some(&plan)) {
3114 return Some(out);
3115 }
3116 }
3117 }
3118 return base.apply_gpu(&fold.transform_input(x));
3119 }
3120
3121 // F32 stays on CPU in apply_gpu: a lone small router matvec is
3122 // faster as host GEMV than a Metal sync. F32 Metal launches are
3123 // used when fused into MoE resident decode (encode_matvec).
3124 let WeightMatrix::Quantized {
3125 data,
3126 rows,
3127 cols,
3128 kind,
3129 } = self
3130 else {
3131 // Deliberate, and recorded rather than hidden: an MoE
3132 // router is a lone small F32 matvec that costs more to ship
3133 // to the GPU than to compute on the host.
3134 let backend = active_backend();
3135 if backend.is_accelerator() {
3136 crate::kernel_registry::miss_by_design(
3137 crate::kernel_registry::Lookup::new(
3138 backend,
3139 crate::kernel_registry::op::MATVEC,
3140 None,
3141 ),
3142 "host GEMV",
3143 );
3144 }
3145 return None;
3146 };
3147 let row_bytes = self.block_bytes_per_row(*kind, *cols);
3148
3149 // One body per backend, expanded over the one ordered list, in
3150 // place of the two hand-kept `match kind` tables this used to
3151 // hold -- which differed in arity, in error type, and (silently)
3152 // by one entry. A third backend adds no code here.
3153 #[allow(unused_macros)]
3154 macro_rules! try_matvec {
3155 ($b:ty) => {
3156 if let Some(result) = <$b as BackendDispatch>::launch_matvec(
3157 *kind,
3158 data.as_slice(),
3159 x,
3160 *rows,
3161 row_bytes,
3162 ) {
3163 match result {
3164 Ok(out) => return Some(out),
3165 Err(e) => {
3166 eprintln!(
3167 "ferrox: {} matvec dispatch failed, {}: {e}",
3168 <$b as BackendCaps>::NAME,
3169 <$b as BackendDispatch>::MATVEC_FALLBACK
3170 );
3171 }
3172 }
3173 }
3174 };
3175 }
3176 with_gpu_backends!(try_matvec);
3177
3178 // Reached only on a miss or a launch error, i.e. only when the
3179 // caller is about to run the whole matvec on the host anyway --
3180 // so recording it here costs nothing measurable and is the only
3181 // signal that a GPU run is quietly not one.
3182 let backend = active_backend();
3183 if backend.is_accelerator() {
3184 crate::kernel_registry::miss(
3185 crate::kernel_registry::Lookup::new(
3186 backend,
3187 crate::kernel_registry::op::MATVEC,
3188 Some(*kind),
3189 ),
3190 "CPU apply_cpu",
3191 );
3192 }
3193 None
3194 }
3195
3196 /// Runs several independent matvecs that share the same activation
3197 /// `x` in one GPU dispatch (one upload of `x`, one wait). Tries
3198 /// CUDA first (when `cuda_dense_enabled()`), then Metal (when
3199 /// `metal_dense_enabled()`). Intended for Q/K/V (and similar)
3200 /// projections. Returns `None` if no GPU backend is enabled, any
3201 /// matrix lacks a GPU kernel, or all fused launches fail — caller
3202 /// should fall back to sequential [`Self::apply`].
3203 #[cfg(any(feature = "cuda", feature = "metal"))]
3204 pub fn apply_gpu_multi(mats: &[&WeightMatrix], x: &[f32]) -> Option<Vec<Vec<f32>>> {
3205 Self::apply_gpu_multi_folded(mats, x, None)
3206 }
3207
3208 /// One matrix through the fused path, with an optional device-side
3209 /// Hadamard prologue. The single-matrix caller of
3210 /// [`Self::apply_gpu_multi_folded`], so a folded projection and a
3211 /// folded q/k/v triple take the same launch.
3212 #[cfg(feature = "metal")]
3213 pub(crate) fn apply_gpu_folded(
3214 &self,
3215 x: &[f32],
3216 fold: Option<&ferrox_metal::hadamard::FoldPlan<'_>>,
3217 ) -> Option<Vec<f32>> {
3218 Self::apply_gpu_multi_folded(&[self], x, fold).map(|mut outs| outs.pop().unwrap())
3219 }
3220
3221 /// [`Self::apply_gpu_multi`] with PrismML's folded rotation applied
3222 /// to `x` on the device before the matvecs read it.
3223 ///
3224 /// `fold` is Metal-only: the rotation kernel is
3225 /// `ferrox_metal::hadamard`, and a CUDA or Vulkan caller takes the
3226 /// host butterfly rather than a rotation nothing there implements.
3227 #[cfg(any(feature = "cuda", feature = "metal"))]
3228 pub(crate) fn apply_gpu_multi_folded(
3229 mats: &[&WeightMatrix],
3230 x: &[f32],
3231 #[cfg(feature = "metal")] fold: Option<&ferrox_metal::hadamard::FoldPlan<'_>>,
3232 #[cfg(not(feature = "metal"))] fold: Option<&()>,
3233 ) -> Option<Vec<Vec<f32>>> {
3234 // Off Metal there is no rotation kernel, so `None` is the only
3235 // value this can take and nothing below reads it. Bound here
3236 // rather than renamed, because the parameter is part of one
3237 // signature both builds call.
3238 #[cfg(not(feature = "metal"))]
3239 let _ = &fold;
3240 if mats.is_empty() {
3241 return None;
3242 }
3243 assert_eq!(
3244 x.len(),
3245 mats[0].cols(),
3246 "activation length must match matrix column count"
3247 );
3248 if mats.iter().any(|m| m.lora().is_some()) {
3249 // The fused launch runs over the bases; each adapter's
3250 // delta is added to its own output on the host.
3251 let bases: Vec<&WeightMatrix> = mats.iter().map(|m| m.base()).collect();
3252 let mut outs = Self::apply_gpu_multi(&bases, x)?;
3253 for (m, out) in mats.iter().zip(outs.iter_mut()) {
3254 if let Some(lora) = m.lora() {
3255 lora.add_to(x, out);
3256 }
3257 }
3258 return Some(outs);
3259 }
3260 if let Some(fold) = mats[0].hadamard_fold() {
3261 // One fold shared by every matrix (q/k/v split from one folded
3262 // fused projection): transform once, launch the bases.
3263 // Different folds, or a fold on only some, is not a shared
3264 // activation any more, and the caller runs them one by one.
3265 if !mats.iter().all(|m| {
3266 m.hadamard_fold()
3267 .is_some_and(|f| std::sync::Arc::ptr_eq(f, fold))
3268 }) {
3269 return None;
3270 }
3271 let bases: Vec<&WeightMatrix> = mats
3272 .iter()
3273 .map(|m| match m {
3274 WeightMatrix::Folded { base, .. } => base.as_ref(),
3275 _ => unreachable!("every matrix carries the fold"),
3276 })
3277 .collect();
3278 let xt = fold.transform_input(x);
3279 return Self::apply_gpu_multi(&bases, &xt);
3280 }
3281 if mats.iter().any(|m| m.hadamard_fold().is_some()) {
3282 return None;
3283 }
3284
3285 // Try CUDA first if enabled.
3286 #[cfg(feature = "cuda")]
3287 if cuda_dense_enabled() {
3288 let mut launches = Vec::with_capacity(mats.len());
3289 for m in mats {
3290 assert_eq!(m.cols(), mats[0].cols());
3291 let WeightMatrix::Quantized {
3292 data,
3293 rows,
3294 cols,
3295 kind,
3296 } = m
3297 else {
3298 return None;
3299 };
3300 // One table, in `ferrox-cuda`, exactly as the Metal arm
3301 // below asks `matvec_launch_meta`. This match was
3302 // written out here and again in
3303 // `apply_gpu_dense_ffn_swiglu`, three copies of one
3304 // five-row list with nothing holding them together --
3305 // and a kind added to the capability table but not to a
3306 // copy loses its fused launch silently, which is the
3307 // failure this file has paid for twice.
3308 let (kernel_src, module_name, fn_name) =
3309 ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
3310 let row_bytes = m.block_bytes_per_row(*kind, *cols);
3311 let n_blocks_per_row = row_bytes / Self::block_bytes_for_kind(*kind);
3312 launches.push(ferrox_cuda::gpu::MatvecLaunch {
3313 kernel_src,
3314 module_name,
3315 fn_name,
3316 // Borrow mmap/owned storage — never to_vec() (breaks
3317 // resident_cuda_weights pointer cache; re-uploads GB).
3318 weights: data.as_slice(),
3319 rows: *rows,
3320 row_bytes,
3321 n_blocks_per_row,
3322 });
3323 }
3324 match ferrox_cuda::gpu::launch_matvec_multi(x, &launches) {
3325 Ok(outs) => return Some(outs),
3326 Err(e) => {
3327 eprintln!("ferrox: CUDA multi-matvec failed, trying next backend: {e}");
3328 }
3329 }
3330 }
3331
3332 // Try Metal if CUDA didn't return or failed.
3333 #[cfg(feature = "metal")]
3334 if metal_dense_enabled() {
3335 let mut launches = Vec::with_capacity(mats.len());
3336 let mut held: Vec<(&[u8], usize, usize, &'static str)> = Vec::with_capacity(mats.len());
3337 for m in mats {
3338 assert_eq!(m.cols(), mats[0].cols());
3339 let WeightMatrix::Quantized {
3340 data,
3341 rows,
3342 cols,
3343 kind,
3344 } = m
3345 else {
3346 return None;
3347 };
3348 // The one Metal kind table (`Metal::matvec_kernel`).
3349 // This was a hand-written six-row match that lacked
3350 // Q5_0 and PTQ1_0, so every gate/up and q/k/v pair of
3351 // those kinds ran as separate launches: correct, and
3352 // one GPU round trip per layer slower than the single
3353 // path for the kinds the copy happened to name.
3354 let kind_name = Metal::matvec_kernel(*kind)?;
3355 let row_bytes = m.block_bytes_per_row(*kind, *cols);
3356 held.push((data.as_slice(), *rows, row_bytes, kind_name));
3357 }
3358 for (weights, rows, row_bytes, kind_name) in &held {
3359 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3360 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3361 launches.push(ferrox_metal::gpu::MatvecLaunch {
3362 kernel_src: src,
3363 fn_name,
3364 block_bytes,
3365 block_elems,
3366 weights,
3367 rows: *rows,
3368 row_bytes: *row_bytes,
3369 rows_per_tg,
3370 });
3371 }
3372 match ferrox_metal::gpu::launch_matvec_fused_folded(
3373 x,
3374 &launches,
3375 ferrox_metal::gpu::MatvecEpilogue::default(),
3376 fold,
3377 ) {
3378 Ok(outs) => return Some(outs),
3379 Err(e) => {
3380 eprintln!("ferrox: Metal fused matvec failed, falling back to CPU: {e}");
3381 }
3382 }
3383 }
3384
3385 None
3386 }
3387
3388 /// Dense SwiGLU FFN on GPU with device-resident activations:
3389 /// one upload of `x`, gate+up+silu×up+down on device, one download.
3390 /// Tries CUDA first when enabled, then Metal. Returns `None` if
3391 /// no GPU path applies — caller falls back to [`Self::apply`] /
3392 /// multi-matvec.
3393 #[cfg(any(feature = "cuda", feature = "metal"))]
3394 pub fn apply_gpu_dense_ffn_swiglu(
3395 gate: &WeightMatrix,
3396 up: &WeightMatrix,
3397 down: &WeightMatrix,
3398 x: &[f32],
3399 ) -> Option<Vec<f32>> {
3400 #[cfg(feature = "cuda")]
3401 {
3402 if cuda_dense_enabled() {
3403 fn cuda_launch(m: &WeightMatrix) -> Option<ferrox_cuda::gpu::MatvecLaunch<'_>> {
3404 let WeightMatrix::Quantized {
3405 data,
3406 rows,
3407 cols,
3408 kind,
3409 } = m
3410 else {
3411 return None;
3412 };
3413 // The second of the two copies this used to hold.
3414 // See the note in `apply_gpu_multi`.
3415 let (kernel_src, module_name, fn_name) =
3416 ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
3417 let row_bytes = m.block_bytes_per_row(*kind, *cols);
3418 let n_blocks_per_row = row_bytes / WeightMatrix::block_bytes_for_kind(*kind);
3419 Some(ferrox_cuda::gpu::MatvecLaunch {
3420 kernel_src,
3421 module_name,
3422 fn_name,
3423 weights: data.as_slice(),
3424 rows: *rows,
3425 row_bytes,
3426 n_blocks_per_row,
3427 })
3428 }
3429 if let (Some(g), Some(u), Some(d)) =
3430 (cuda_launch(gate), cuda_launch(up), cuda_launch(down))
3431 {
3432 assert_eq!(gate.cols(), x.len());
3433 assert_eq!(up.cols(), x.len());
3434 assert_eq!(down.cols(), gate.rows());
3435 match ferrox_cuda::gpu::launch_dense_ffn_swiglu(&g, &u, &d, x) {
3436 Ok(out) => return Some(out),
3437 Err(e) => {
3438 eprintln!("ferrox: CUDA dense FFN fuse failed, trying next: {e}");
3439 }
3440 }
3441 }
3442 }
3443 }
3444 #[cfg(feature = "metal")]
3445 {
3446 if metal_dense_enabled() {
3447 fn metal_launch(m: &WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'_>> {
3448 let WeightMatrix::Quantized {
3449 data,
3450 rows,
3451 cols: _,
3452 kind,
3453 // Through the fold, which travels as a plan beside
3454 // the launch rather than as a transformed vector.
3455 // `base()` unwraps a LoRA only, deliberately: an
3456 // adapter is arithmetic this kernel cannot do.
3457 } = (match m {
3458 WeightMatrix::Folded { base, .. } => base.as_ref(),
3459 other => other,
3460 })
3461 else {
3462 return None;
3463 };
3464 // The one Metal kind table again: this was the
3465 // THIRD hand-written copy of it, and it lacked
3466 // Q5_0 and PTQ1_0, so those kinds ran their FFN as
3467 // three submissions with a host SwiGLU between.
3468 let kind_name = Metal::matvec_kernel(*kind)?;
3469 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3470 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3471 // A zero-row matrix has no rows to stride over, so
3472 // there is no meaningful row size; `checked_div`
3473 // says that once instead of splitting it across a
3474 // guard and a bare division.
3475 let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
3476 Some(ferrox_metal::gpu::MatvecLaunch {
3477 kernel_src: src,
3478 fn_name,
3479 block_bytes,
3480 block_elems,
3481 weights: data.as_slice(),
3482 rows: *rows,
3483 row_bytes,
3484 rows_per_tg,
3485 })
3486 }
3487 // A folded FFN reads TWO rotated activations: `x`
3488 // before gate/up, and the SwiGLU output before `down`.
3489 // They are different widths and therefore different
3490 // folds, so both travel with the launch rather than
3491 // one standing in for the other.
3492 let fold_x = gate.hadamard_fold().and_then(|f| f.metal_plan(x.len()));
3493 let fold_act = down.hadamard_fold().and_then(|f| f.metal_plan(gate.rows()));
3494 let folds_agree = match (gate.hadamard_fold(), up.hadamard_fold()) {
3495 (Some(g), Some(u)) => std::sync::Arc::ptr_eq(g, u) && fold_x.is_some(),
3496 (None, None) => true,
3497 _ => false,
3498 } && (down.hadamard_fold().is_none() == fold_act.is_none());
3499 if let (Some(g), Some(u), Some(d), true) = (
3500 metal_launch(gate),
3501 metal_launch(up),
3502 metal_launch(down),
3503 folds_agree,
3504 ) {
3505 assert_eq!(gate.cols(), x.len());
3506 assert_eq!(up.cols(), x.len());
3507 assert_eq!(down.cols(), gate.rows());
3508 match ferrox_metal::gpu::launch_dense_ffn_swiglu_folded(
3509 &g,
3510 &u,
3511 &d,
3512 x,
3513 fold_x.as_ref(),
3514 fold_act.as_ref(),
3515 ) {
3516 Ok(out) => return Some(out),
3517 Err(e) => {
3518 eprintln!("ferrox: Metal dense FFN fuse failed, falling back: {e}");
3519 }
3520 }
3521 }
3522 }
3523 }
3524 None
3525 }
3526
3527 /// Runs one weight matrix against `batch_size` activations in a
3528 /// single Metal command buffer (shared resident weights, one
3529 /// upload of `x_batch`, one GPU wait). `x_batch` / return layout
3530 /// match [`Self::apply_batch`]: `[batch, cols]` → `[batch, rows]`.
3531 /// Returns `None` if Metal dense is off, the kind lacks a Metal
3532 /// kernel, or the launch fails.
3533 ///
3534 /// `batch_size >= 4` takes the weight-reuse `mul_mm` path where the
3535 /// kind has one; everything else falls through to
3536 /// [`ferrox_metal::gpu::launch_matvec_batch`].
3537 #[cfg(feature = "metal")]
3538 pub fn apply_gpu_batch(&self, x_batch: &[f32], batch_size: usize) -> Option<Vec<f32>> {
3539 if !metal_dense_enabled() || batch_size == 0 {
3540 return None;
3541 }
3542 if let WeightMatrix::Adapted { base, lora } = self {
3543 let mut out = base.apply_gpu_batch(x_batch, batch_size)?;
3544 lora.add_batch_to(x_batch, batch_size, &mut out);
3545 return Some(out);
3546 }
3547 if let WeightMatrix::Folded { base, fold } = self {
3548 // A BATCH keeps the host rotation, and that is a
3549 // measurement rather than an omission: the same device
3550 // prologue that pays for itself on a decode token
3551 // (`apply_gpu`) cost 6% of Bonsai prefill, because
3552 // `transform_rows` is already parallel across cores and the
3553 // kernel serialises into the GEMM's own command buffer
3554 // instead. `pp128` 33.96 -> 32.04 tok/s on an M2 Pro, three
3555 // reps each, which is why `apply_gpu_batch` takes no fold.
3556 let transformed = fold.transform_rows(x_batch, batch_size);
3557 return base.apply_gpu_batch(&transformed, batch_size);
3558 }
3559 let WeightMatrix::Quantized {
3560 data,
3561 rows,
3562 cols,
3563 kind,
3564 } = self
3565 else {
3566 return None;
3567 };
3568 let Some(kind_name) = metal_matvec_kind_name(*kind) else {
3569 crate::kernel_registry::miss(
3570 crate::kernel_registry::Lookup::new(
3571 crate::kernel_registry::Backend::Metal,
3572 crate::kernel_registry::op::GEMM_PREFILL,
3573 Some(*kind),
3574 ),
3575 "CPU apply_batch",
3576 );
3577 return None;
3578 };
3579 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3580 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3581 let row_bytes = self.block_bytes_per_row(*kind, *cols);
3582 // Weight-reuse simdgroup GEMM for prefill batch >= 4: each 64x32
3583 // output tile reads its weight slice once into threadgroup
3584 // memory instead of once per token. The batched matvec below is
3585 // the fallback it replaces -- correct, but it re-reads the whole
3586 // matrix for every token, which is why Metal `pp512` was 14-99x
3587 // behind llama.cpp. Threshold 4 (was 8) covers shorter prompts
3588 // without changing the decode path (batch_size == 1 is matvec).
3589 //
3590 // ONE launcher keyed by the kind's name, read off the same table
3591 // `gemm_supported` is derived from. This was a seven-arm match
3592 // naming each kind's wrapper, and the arms lagged the table:
3593 // Q5_0 and PTQ1_0 had a GEMM the table admitted and the match
3594 // did not reach, so their prefill ran N matvecs while the kernel
3595 // registry recorded a GEMM hit.
3596 if batch_size >= 4 {
3597 if !metal_mul_mm_kind_supported(*kind) {
3598 // Observation only: a kind with a matvec kernel but no
3599 // simdgroup GEMM still runs on Metal, as `batch`
3600 // separate matvecs over the same weights. That is the
3601 // shape that cost IQ4_XS 13.7x, and it is invisible in
3602 // the output.
3603 crate::kernel_registry::miss(
3604 crate::kernel_registry::Lookup::new(
3605 crate::kernel_registry::Backend::Metal,
3606 crate::kernel_registry::op::GEMM_PREFILL,
3607 Some(*kind),
3608 ),
3609 "Metal N x matvec batch",
3610 );
3611 }
3612 match ferrox_metal::gpu::launch_mul_mm_sg(
3613 kind.name(),
3614 data.as_slice(),
3615 x_batch,
3616 *rows,
3617 row_bytes,
3618 batch_size,
3619 ) {
3620 Ok(Some(out)) => return Some(out),
3621 Ok(None) => {}
3622 Err(e) => {
3623 eprintln!(
3624 "ferrox: Metal {} simdgroup mul_mm failed, batched fallback: {e}",
3625 kind.name()
3626 );
3627 }
3628 }
3629 // The older non-simdgroup GEMMs, kept for the two kinds that
3630 // have one, between the simdgroup kernel and the matvec.
3631 let older = match kind {
3632 QuantKind::Q4_0 => Some(ferrox_metal::gpu::launch_q4_0_mul_mm(
3633 data.as_slice(),
3634 x_batch,
3635 *rows,
3636 row_bytes,
3637 batch_size,
3638 )),
3639 QuantKind::Q4K => Some(ferrox_metal::gpu::launch_q4_k_mul_mm(
3640 data.as_slice(),
3641 x_batch,
3642 *rows,
3643 row_bytes,
3644 batch_size,
3645 )),
3646 _ => None,
3647 };
3648 match older {
3649 Some(Ok(out)) => return Some(out),
3650 Some(Err(e)) => {
3651 eprintln!(
3652 "ferrox: Metal {} mul_mm failed, matvec fallback: {e}",
3653 kind.name()
3654 );
3655 }
3656 None => {}
3657 }
3658 }
3659 let launch = ferrox_metal::gpu::MatvecLaunch {
3660 kernel_src: src,
3661 fn_name,
3662 block_bytes,
3663 block_elems,
3664 weights: data.as_slice(),
3665 rows: *rows,
3666 row_bytes,
3667 rows_per_tg,
3668 };
3669 match ferrox_metal::gpu::launch_matvec_batch(&launch, x_batch, batch_size) {
3670 Ok(out) => Some(out),
3671 Err(e) => {
3672 eprintln!("ferrox: Metal batch matvec failed, falling back: {e}");
3673 None
3674 }
3675 }
3676 }
3677
3678 /// Delegates to [`metal_matvec_kind_name`]. Kept as a method because
3679 /// the call sites read better, but it must never grow a list of its
3680 /// own again — a second copy of this list is what sent IQ4_XS
3681 /// batched prefill to the CPU.
3682 #[cfg(feature = "metal")]
3683 fn metal_kind_supported(kind: QuantKind) -> bool {
3684 metal_matvec_kind_name(kind).is_some()
3685 }
3686
3687 /// Eagerly resolve, and record, every kernel lookup this matrix's
3688 /// dispatch paths will make later, without dispatching anything.
3689 ///
3690 /// Call once per weight while the model is being built, with `role`
3691 /// naming the tensor (`"attn_q"`, `"ffn_down"`, ...). The predicates
3692 /// consulted here are the *same functions* the hot path consults, so
3693 /// the recorded prediction cannot drift from the decision. See
3694 /// [`crate::kernel_registry`] for why this exists and
3695 /// [`crate::kernel_registry::seal`] for what is done with it.
3696 ///
3697 /// Observation only: nothing here influences a later dispatch.
3698 #[track_caller]
3699 pub fn probe_kernels(&self, role: &'static str) {
3700 if !crate::kernel_registry::enabled() {
3701 return;
3702 }
3703 self.probe_kernels_into(
3704 crate::kernel_registry::global(),
3705 role,
3706 std::panic::Location::caller(),
3707 );
3708 }
3709
3710 /// [`Self::probe_kernels`] against an explicit registry and call
3711 /// site, so tests can probe into an instance of their own instead of
3712 /// the process-wide one.
3713 pub fn probe_kernels_into(
3714 &self,
3715 reg: &crate::kernel_registry::Registry,
3716 role: &'static str,
3717 loc: &'static std::panic::Location<'static>,
3718 ) {
3719 self.probe_kernels_for(reg, active_backend(), role, loc)
3720 }
3721
3722 /// [`Self::probe_kernels_into`] against an explicit backend rather
3723 /// than [`active_backend`]. Lets a test on a CPU-only build ask what
3724 /// a Metal or CUDA run would resolve -- which is the only way the
3725 /// kernel-coverage tests can run under plain
3726 /// `cargo test --workspace`, where every GPU feature is off.
3727 pub fn probe_kernels_for(
3728 &self,
3729 reg: &crate::kernel_registry::Registry,
3730 backend: crate::kernel_registry::Backend,
3731 role: &'static str,
3732 loc: &'static std::panic::Location<'static>,
3733 ) {
3734 use crate::kernel_registry::{op, Backend, Lookup, Outcome};
3735
3736 let kind = self.quant_kind();
3737 let cols = self.cols();
3738 let look = |op: &'static str| Lookup {
3739 backend,
3740 op,
3741 role,
3742 kind,
3743 };
3744
3745 // Whether the accelerator, if one is selected, can run this
3746 // matrix at all -- and if so, whether prefill gets a real GEMM
3747 // or `batch` matvecs over the same weights.
3748 //
3749 // Read off `BackendCaps` over the ungated backend table rather
3750 // than from a `match backend` written out here. The two are
3751 // NOT interchangeable: the hand-written match had a `Backend::
3752 // Cpu => (false, false)` arm and no `_`, so it was exhaustive
3753 // by luck -- a third variant broke it, which is the good case.
3754 // A fourth backend added while a `_` arm existed would have
3755 // silently reported "no kernels" for a backend that had them.
3756 //
3757 // Ungated on purpose: a CPU-only build must be able to ask what
3758 // CUDA would resolve, which is what every kernel-coverage test
3759 // below does. `BackendDispatch` is unavailable here for exactly
3760 // that reason.
3761 //
3762 // The predicate sets are per backend and genuinely different:
3763 // CUDA has a batched GEMM for a SUBSET of the kinds it has
3764 // matvecs for (Q8_0, Q4_0) and decomposes the rest into
3765 // per-position matvecs; Vulkan has one matvec and no GEMM at
3766 // all. `GEMM_FALLBACK` is what each of those decompositions is
3767 // actually called.
3768 let (matvec, gemm, gemm_fallback) = {
3769 let mut found = (false, false, "");
3770 macro_rules! caps_of {
3771 ($b:ty) => {
3772 if backend == <$b as BackendCaps>::ID {
3773 found = (
3774 kind.is_some_and(|k| <$b as BackendCaps>::matvec_kernel(k).is_some()),
3775 kind.is_some_and(<$b as BackendCaps>::gemm_supported),
3776 <$b as BackendCaps>::GEMM_FALLBACK,
3777 );
3778 }
3779 };
3780 }
3781 with_gpu_backend_caps!(caps_of);
3782 found
3783 };
3784
3785 if backend.is_accelerator() {
3786 reg.record_build_at(
3787 loc,
3788 look(op::MATVEC),
3789 match kind {
3790 // An accelerator kernel exists for this format.
3791 _ if matvec => Outcome::Hit,
3792 // No kernel: the whole matvec runs on the host.
3793 Some(_) => Outcome::slow_path("CPU apply_cpu"),
3794 // F32 has no quantized kernel by construction, and a
3795 // lone small F32 matvec (an MoE router) is host work
3796 // on purpose -- see `apply_gpu`.
3797 None => Outcome::by_design("host GEMV"),
3798 },
3799 );
3800 reg.record_build_at(
3801 loc,
3802 look(op::GEMM_PREFILL),
3803 match (gemm, matvec, kind) {
3804 (true, ..) => Outcome::Hit,
3805 // A matvec but no GEMM. What that costs is per
3806 // backend -- Metal re-reads the whole weight matrix
3807 // once per position but stays on the GPU (the 13.7x
3808 // shape), CUDA does the same through a different
3809 // entry point, and Vulkan has no batch path at all
3810 // so the prefill lands on the host -- so the name
3811 // comes from the backend instead of from an arm
3812 // here that a new variant would fall through.
3813 (false, true, _) => Outcome::slow_path(gemm_fallback),
3814 (false, false, Some(_)) => Outcome::slow_path("CPU apply_batch"),
3815 (false, false, None) => Outcome::by_design("CPU f32 GEMM"),
3816 },
3817 );
3818 }
3819
3820 // The host path is what every accelerator miss lands on, so
3821 // record its tier too: integer vec_dot, or the much slower f32
3822 // dequant-dot.
3823 if !matvec || !gemm {
3824 let int_dot = cpu_int_dot_for(IntDotShape::Matvec)
3825 && kind.is_some_and(|k| cpu_int_dot_kind_supported(k, cols));
3826 reg.record_build_at(
3827 loc,
3828 Lookup {
3829 backend: Backend::Cpu,
3830 op: op::MATVEC,
3831 role,
3832 kind,
3833 },
3834 match kind {
3835 _ if int_dot => Outcome::Hit,
3836 // A quantized weight with no integer vec_dot kernel
3837 // dequantizes to f32 first: a much slower engine,
3838 // and invisible in the output.
3839 Some(_) => Outcome::slow_path("f32 dequant-dot"),
3840 None => Outcome::by_design("f32 GEMM"),
3841 },
3842 );
3843 }
3844 }
3845
3846 /// The block size (in bytes) for exactly the quant kinds
3847 /// `apply_gpu` dispatches to a real CUDA or Vulkan kernel for -- a
3848 /// small, deliberately partial mirror of `block_bytes_per_row`'s
3849 /// per-kind match.
3850 ///
3851 /// Partial means this `unreachable!()` is reachable by a mistake:
3852 /// widening `Cuda::matvec_kernel` without adding the row here
3853 /// turns a decode into a panic in a rayon worker rather than a
3854 /// fallback. `every_cuda_or_vulkan_matvec_kind_has_a_block_size`
3855 /// calls it for every claimed kind so that lands as a red test
3856 /// instead.
3857 #[cfg(any(feature = "cuda", feature = "vulkan"))]
3858 pub(crate) fn block_bytes_for_kind(kind: QuantKind) -> usize {
3859 match kind {
3860 QuantKind::Q8_0 => ferrox_quant::Q8_0_BLOCK_BYTES,
3861 QuantKind::Q4_0 => ferrox_quant::Q4_0_BLOCK_BYTES,
3862 QuantKind::Q5_0 => ferrox_quant::Q5_0_BLOCK_BYTES,
3863 QuantKind::Q4K => ferrox_quant::Q4_K_BLOCK_BYTES,
3864 QuantKind::Q5K => ferrox_quant::Q5_K_BLOCK_BYTES,
3865 QuantKind::Q6K => ferrox_quant::Q6_K_BLOCK_BYTES,
3866 QuantKind::Q2K => ferrox_quant::Q2_K_BLOCK_BYTES,
3867 QuantKind::Q3K => ferrox_quant::Q3_K_BLOCK_BYTES,
3868 QuantKind::IQ4NL => ferrox_quant::IQ4_NL_BLOCK_BYTES,
3869 QuantKind::IQ4XS => ferrox_quant::IQ4_XS_BLOCK_BYTES,
3870 QuantKind::Mxfp4Gguf => ferrox_quant::MXFP4_GGUF_BLOCK_BYTES,
3871 _ => unreachable!(
3872 "apply_gpu only calls this for the CUDA/Vulkan-dispatchable kinds, not {kind:?}"
3873 ),
3874 }
3875 }
3876}
3877#[cfg(test)]
3878mod tests {
3879
3880 /// The task floor is **work-aware**, which is the whole reason
3881 /// [`crate::par::with_op_work`] exists: a row count alone cannot
3882 /// tell a 64-wide matrix from a 256-wide one, and rayon splitting
3883 /// the narrow one by rows alone is the measured 13-16x small-model
3884 /// regression.
3885 ///
3886 /// Both shapes here sit under [`crate::par::policy::SPIN_MIN_OP_MACS`]
3887 /// so both are decided by the fork-join arm, which is the only arm
3888 /// that reads a `min_len` at all.
3889 ///
3890 /// Sabotage: drop the `MIN_TASK_MACS` term from `min_rows_per_task`
3891 /// and this goes red, because both shapes then collapse onto the
3892 /// same row-count floor.
3893 #[test]
3894 fn the_task_floor_demands_more_rows_of_a_narrower_matrix() {
3895 if crate::par::policy::pinned().is_some() {
3896 return; // pinned: not the arm this floor belongs to
3897 }
3898 let rows = 4096usize;
3899 let narrow = crate::par::with_op_work(rows, 64, || WeightMatrix::min_rows_per_task(rows));
3900 let wider = crate::par::with_op_work(rows, 256, || WeightMatrix::min_rows_per_task(rows));
3901 assert_eq!(narrow, MIN_TASK_MACS.div_ceil(64));
3902 assert!(
3903 narrow > wider,
3904 "a 64-wide row carries a quarter of a 256-wide row's work, so a \
3905 task must hold four times as many of them: {narrow} vs {wider}"
3906 );
3907 }
3908
3909 /// The four dtypes the drifted copies were missing.
3910 ///
3911 /// Three of the six loaders stopped at IQ1_M, so `IQ1_S`,
3912 /// `IQ2_XXS`, `IQ3_XXS` and `MXFP4` mapped to `None` there -- and a
3913 /// `None` is `LoadError::UnsupportedDtype`, not a slower path. A
3914 /// DeepSeek-MLA checkpoint at `IQ2_XXS`, an ordinary quant for a
3915 /// model that size, was refused outright while the same quant
3916 /// loaded on the generic path. One table is what stops that
3917 /// recurring.
3918 #[test]
3919 fn the_four_dtypes_the_duplicated_tables_disagreed_about_all_map() {
3920 assert_eq!(quant_kind_for(GgmlType::IQ1S), Some(QuantKind::IQ1S));
3921 assert_eq!(quant_kind_for(GgmlType::IQ2XXS), Some(QuantKind::IQ2XXS));
3922 assert_eq!(quant_kind_for(GgmlType::IQ3XXS), Some(QuantKind::IQ3XXS));
3923 assert_eq!(quant_kind_for(GgmlType::MXFP4), Some(QuantKind::Mxfp4Gguf));
3924 }
3925
3926 /// Every dtype with a CPU dequant kernel must be reachable through
3927 /// this map, or the kernel exists and no loader can ever hand it a
3928 /// tensor. Checked against the two backend tables rather than a
3929 /// hand-written list, so adding a kernel without a mapping fails
3930 /// here instead of at a user's load.
3931 #[test]
3932 fn every_dtype_with_a_gpu_kernel_is_reachable_through_the_map() {
3933 let mapped: Vec<QuantKind> = [
3934 GgmlType::Q8_0,
3935 GgmlType::Q4_0,
3936 GgmlType::Q4K,
3937 GgmlType::Q5K,
3938 GgmlType::Q6K,
3939 GgmlType::IQ4XS,
3940 ]
3941 .into_iter()
3942 .map(|d| quant_kind_for(d).expect("a dtype with a GPU kernel must map"))
3943 .collect();
3944 for kind in mapped {
3945 assert!(
3946 metal_mul_mm_kind_supported(kind) || cuda_matvec_kind_supported(kind),
3947 "{kind:?} was listed as having a GPU kernel"
3948 );
3949 }
3950 }
3951
3952 /// The CUDA capability predicates and the *launch* table must name
3953 /// the same set, for every kind.
3954 ///
3955 /// `Cuda::matvec_kernel` and `Cuda::gemm_supported` are DERIVED
3956 /// from `ferrox-cuda`'s own kernel tables now, so the two pairs
3957 /// that used to be checked here cannot disagree -- those tests were
3958 /// deleted rather than left comparing a table to itself, which
3959 /// reads as coverage and is not.
3960 ///
3961 /// This one still matters. [`cuda_matvec_launch`] is a table of
3962 /// FUNCTION POINTERS, which only exist under `--features cuda`, so
3963 /// it cannot be derived from a table of strings. Over-claiming in
3964 /// the capability predicate sends a decode to a launcher that does
3965 /// not exist; under-claiming leaves a kernel nothing calls. The
3966 /// dispatch seam only `debug_assert!`s the agreement at the moment
3967 /// a matmul happens to run, which in release is no check at all.
3968 #[cfg(feature = "cuda")]
3969 #[test]
3970 fn every_cuda_matvec_kind_has_a_launcher() {
3971 use super::gpu_backend::cuda_matvec_launch;
3972 for &kind in QuantKind::ALL {
3973 assert_eq!(
3974 cuda_matvec_kind_supported(kind),
3975 cuda_matvec_launch(kind).is_some(),
3976 "{kind:?}: the capability table and the launch table disagree"
3977 );
3978 }
3979 }
3980
3981 /// `block_bytes_for_kind` is deliberately partial, so every kind
3982 /// CUDA or Vulkan claims a matvec for has to be one of its arms.
3983 ///
3984 /// Calling it IS the assertion: the arm it lacks is an
3985 /// `unreachable!()`, and reaching that in a rayon worker is a panic
3986 /// rather than the fallback the seam promises. `Metal` is
3987 /// deliberately not checked -- it claims IQ4_XS, asks
3988 /// `ferrox_metal::gpu::matvec_launch_meta` for its block size, and
3989 /// never touches this function.
3990 #[cfg(any(feature = "cuda", feature = "vulkan"))]
3991 #[test]
3992 fn every_cuda_or_vulkan_matvec_kind_has_a_block_size() {
3993 use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
3994 for &kind in QuantKind::ALL {
3995 if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
3996 continue;
3997 }
3998 let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
3999 assert!(
4000 block_bytes > 0,
4001 "{kind:?}: a claimed matvec kind needs a real block size"
4002 );
4003 }
4004 }
4005
4006 /// `block_bytes_for_kind` and `block_bytes_per_row` are two
4007 /// functions that must agree about one format's geometry, and the
4008 /// matvec seam DIVIDES one by the other.
4009 ///
4010 /// `Cuda::launch_matvec` derives `n_blocks_per_row` as
4011 /// `block_bytes_per_row(kind, cols) / block_bytes_for_kind(kind)`
4012 /// and hands it to a kernel that strides the row by a byte count
4013 /// written as a literal in CUDA C. If the two disagreed by so much
4014 /// as one byte the division would silently truncate, the kernel
4015 /// would read fewer blocks than the row holds, and every output
4016 /// would be a partial dot product -- plausible numbers, no error,
4017 /// no panic, and nothing in the suite red.
4018 ///
4019 /// Both are also held to `ferrox-cuda`'s own `MulMmKind` row, which
4020 /// is where that CUDA C literal comes from, so all three agree or
4021 /// this fails.
4022 ///
4023 /// Nothing checked any of it. That was survivable while the two
4024 /// tables were edited together by one person on one day; five kinds
4025 /// joined on 2026-09-09 and each needed a row in both.
4026 ///
4027 /// Sabotage: give any kind the wrong constant in either function
4028 /// and this names it.
4029 ///
4030 /// Gated like its neighbour: `block_bytes_for_kind` itself only
4031 /// exists when a backend that calls it is compiled in.
4032 #[cfg(any(feature = "cuda", feature = "vulkan"))]
4033 #[test]
4034 fn the_two_block_size_functions_agree_for_every_gpu_kind() {
4035 use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
4036 for &kind in QuantKind::ALL {
4037 if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
4038 continue;
4039 }
4040 let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
4041 let mm = ferrox_cuda::mul_mm::kind_by_name(kind.name())
4042 .unwrap_or_else(|| panic!("{kind:?}: claims a GPU matvec with no mul_mm row"));
4043 assert_eq!(
4044 block_bytes, mm.block_bytes,
4045 "{kind:?}: ferrox-core's block size is not the one the kernel strides by"
4046 );
4047
4048 // `block_bytes_per_row` takes `&self` but reads only its
4049 // arguments, so any matrix of the right kind will do.
4050 let probe = WeightMatrix::Quantized {
4051 data: WeightBytes::Owned(Vec::new()),
4052 rows: 1,
4053 cols: mm.block_elems,
4054 kind,
4055 };
4056 // Three, four and five whole blocks: a per-row function
4057 // that had dropped the multiply would still pass at one.
4058 for blocks in 3..=5usize {
4059 let cols = mm.block_elems * blocks;
4060 let row_bytes = probe.block_bytes_per_row(kind, cols);
4061 assert_eq!(
4062 row_bytes,
4063 blocks * block_bytes,
4064 "{kind:?}: block_bytes_per_row({cols}) is not {blocks} x {block_bytes}"
4065 );
4066 assert_eq!(
4067 row_bytes / block_bytes,
4068 blocks,
4069 "{kind:?}: the n_blocks_per_row the matvec seam derives is wrong"
4070 );
4071 }
4072 }
4073 }
4074
4075 /// F32 and F16 are not quantized, so `None` is the right answer and
4076 /// not a gap: the loader builds a plain `WeightMatrix::F32` for
4077 /// them rather than reporting an unsupported dtype.
4078 #[test]
4079 fn an_unquantized_dtype_maps_to_nothing() {
4080 assert_eq!(quant_kind_for(GgmlType::F32), None);
4081 assert_eq!(quant_kind_for(GgmlType::F16), None);
4082 }
4083 use super::*;
4084
4085 /// Forces [`cpu_int_dot_enabled`] for the lifetime of the guard, so a
4086 /// test can drive the quantized-activation batch kernels (the
4087 /// interleaved `block_q*_Kx8` / `block_q*_0x4` repack tier and the
4088 /// NEON i8mm GEMMs behind it) that every shipped binary turns on via
4089 /// `default_cpu_int_dot_on` but `cargo test` otherwise leaves off.
4090 ///
4091 /// The override is process-global, so the guard serializes on a
4092 /// mutex: two tests forcing opposite values concurrently would
4093 /// otherwise see each other's setting.
4094 pub(super) struct ForceIntDot {
4095 _lock: std::sync::MutexGuard<'static, ()>,
4096 }
4097
4098 impl ForceIntDot {
4099 pub(super) fn new(on: bool) -> Self {
4100 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4101 let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4102 INT_DOT_TEST_OVERRIDE.store(i8::from(on), std::sync::atomic::Ordering::Release);
4103 Self { _lock: lock }
4104 }
4105 }
4106
4107 impl Drop for ForceIntDot {
4108 fn drop(&mut self) {
4109 INT_DOT_TEST_OVERRIDE.store(-1, std::sync::atomic::Ordering::Release);
4110 }
4111 }
4112
4113 /// The guard has to actually move the getter, in both directions --
4114 /// otherwise every test built on it silently exercises one path
4115 /// twice, which is exactly the hole it exists to close.
4116 #[test]
4117 fn force_int_dot_moves_the_getter_and_restores_it() {
4118 {
4119 let _g = ForceIntDot::new(true);
4120 assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4121 }
4122 {
4123 let _g = ForceIntDot::new(false);
4124 assert!(!cpu_int_dot_enabled(), "forcing off must disable int dot");
4125 }
4126 assert_eq!(
4127 INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire),
4128 -1,
4129 "the guard must clear the override on drop"
4130 );
4131 }
4132
4133 /// `dequant_row` must reproduce exactly the values a full-buffer
4134 /// dequantization of the same row produces, for every storage
4135 /// variant -- and read only that row's bytes (each row here has
4136 /// distinct values, so an off-by-one-row slice fails loudly).
4137 #[test]
4138 fn dequant_row_matches_full_dequant_per_row() {
4139 // F32 variant.
4140 let rows = 3;
4141 let cols = 64;
4142 let f32_data: Vec<f32> = (0..rows * cols).map(|i| (i as f32) * 0.1 - 5.0).collect();
4143 let m = WeightMatrix::F32(Tensor::new(f32_data.clone(), vec![rows, cols]));
4144 for r in 0..rows {
4145 assert_eq!(m.dequant_row(r), &f32_data[r * cols..(r + 1) * cols]);
4146 }
4147
4148 // Quantized (Q8_0) variant: quantize each row independently and
4149 // compare dequant_row against dequantizing that row's bytes.
4150 let mut packed = Vec::new();
4151 for r in 0..rows {
4152 packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4153 }
4154 let row_bytes = packed.len() / rows;
4155 let q = WeightMatrix::Quantized {
4156 data: WeightBytes::Owned(packed.clone()),
4157 rows,
4158 cols,
4159 kind: QuantKind::Q8_0,
4160 };
4161 for r in 0..rows {
4162 let expected =
4163 ferrox_quant::dequant_q8_0(&packed[r * row_bytes..(r + 1) * row_bytes]).unwrap();
4164 assert_eq!(q.dequant_row(r), expected, "Q8_0 row {r}");
4165 }
4166
4167 // Mxfp4 (two-buffer) variant: arbitrary valid bytes, compare
4168 // against the row-level reference dequantizer directly.
4169 let cols = 64;
4170 let packed: Vec<u8> = pseudo_bytes(7, rows * cols / 2);
4171 let scales: Vec<u8> = pseudo_bytes(11, rows * cols / 32);
4172 let m = WeightMatrix::Mxfp4 {
4173 packed: WeightBytes::Owned(packed.clone()),
4174 scale: WeightBytes::Owned(scales.clone()),
4175 rows,
4176 cols,
4177 };
4178 for r in 0..rows {
4179 let expected = ferrox_quant::dequant_mxfp4_row(
4180 &packed[r * cols / 2..(r + 1) * cols / 2],
4181 &scales[r * cols / 32..(r + 1) * cols / 32],
4182 )
4183 .unwrap();
4184 assert_eq!(m.dequant_row(r), expected, "Mxfp4 row {r}");
4185 }
4186 }
4187
4188 /// A quantized matrix used as an embedding table: `dequant_row`
4189 /// then a dot product must agree with `apply` against a one-hot...
4190 /// no -- more directly, with the fused `dot` of that row, proving
4191 /// row lookup and matmul read identical bytes.
4192 #[test]
4193 fn dequant_row_agrees_with_fused_dot_on_the_same_row() {
4194 let rows = 4;
4195 let cols = 64;
4196 let f32_data: Vec<f32> = (0..rows * cols)
4197 .map(|i| ((i as f32) * 0.13).sin())
4198 .collect();
4199 let mut packed = Vec::new();
4200 for r in 0..rows {
4201 packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4202 }
4203 let q = WeightMatrix::Quantized {
4204 data: WeightBytes::Owned(packed),
4205 rows,
4206 cols,
4207 kind: QuantKind::Q8_0,
4208 };
4209 let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.031).cos()).collect();
4210 let applied = q.apply(&x);
4211 // With `FERROX_CPU_INT_DOT` on, `apply` quantizes the ACTIVATION to
4212 // int8 as well, so the two sides no longer differ only by float
4213 // summation order and a fixed 1e-4 is not the right bar -- it fired
4214 // at 6.5e-3 on a result of 5.25, which is the activation error, not
4215 // a byte disagreement. The worst case is derivable rather than
4216 // guessed: `quantize_activations_q8` rounds to `d = amax/127`, so
4217 // each element moves by at most `d/2`, and the dot's error is
4218 // bounded by that times the row's L1 norm.
4219 let bound = |row: &[f32]| {
4220 if !cpu_int_dot_for(IntDotShape::Matvec) {
4221 return 1e-4;
4222 }
4223 let amax = x.iter().fold(0f32, |m, v| m.max(v.abs()));
4224 let l1: f32 = row.iter().map(|w| w.abs()).sum();
4225 (amax / 127.0 / 2.0) * l1
4226 };
4227 for (r, &got) in applied.iter().enumerate() {
4228 let row = q.dequant_row(r);
4229 let via_row: f32 = row.iter().zip(&x).map(|(a, b)| a * b).sum();
4230 let bound = bound(&row);
4231 assert!(
4232 (got - via_row).abs() < bound,
4233 "row {r}: apply={got} via dequant_row={via_row} (bound {bound:e})"
4234 );
4235 }
4236 }
4237
4238 fn make_q8_0_row(values: &[f32]) -> Vec<u8> {
4239 ferrox_quant::quantize_q8_0(values)
4240 }
4241
4242 /// Deterministic byte generator for MXFP4 test fixtures (no
4243 /// quantizer exists in `ferrox_quant` -- MXFP4 is only ever a
4244 /// real, already-quantized checkpoint format, never produced by
4245 /// ferrox -- so tests build arbitrary-but-valid-shaped bytes
4246 /// directly, same convention as `ferrox-models::kimi_loader`'s
4247 /// tests).
4248 fn pseudo_bytes(seed: u32, len: usize) -> Vec<u8> {
4249 let mut state = seed.wrapping_mul(2654435761).wrapping_add(1);
4250 (0..len)
4251 .map(|_| {
4252 state = state.wrapping_mul(1103515245).wrapping_add(12345);
4253 (state >> 16) as u8
4254 })
4255 .collect()
4256 }
4257
4258 /// Clamped to a realistic E8M0 scale range -- see
4259 /// `ferrox-models::kimi_loader`'s identical helper for why (byte
4260 /// 255 is OCP-spec-reserved for NaN, and bytes above ~252 can
4261 /// legitimately overflow f32::MAX when combined with E2M1's max
4262 /// magnitude; neither is representative of a real trained weight).
4263 fn pseudo_mxfp4_scale_bytes(seed: u32, len: usize) -> Vec<u8> {
4264 pseudo_bytes(seed, len)
4265 .into_iter()
4266 .map(|b| b % 180)
4267 .collect()
4268 }
4269
4270 #[test]
4271 fn f32_and_mxfp4_paths_agree() {
4272 let rows = 2;
4273 let cols = 64; // 2 MXFP4 groups of 32 per row
4274 let packed = pseudo_bytes(1, rows * (cols / 2));
4275 let scale = pseudo_mxfp4_scale_bytes(2, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4276 let x: Vec<f32> = (0..cols).map(|i| (i as f32) * 0.01 - 0.3).collect();
4277
4278 // Independent reference: dequantize each row to plain f32 (the
4279 // already-tested `dequant_mxfp4_row`), then use the ordinary
4280 // F32 matmul path.
4281 let mut f32_weights = Vec::with_capacity(rows * cols);
4282 for r in 0..rows {
4283 let prow = &packed[r * (cols / 2)..(r + 1) * (cols / 2)];
4284 let srow = &scale[r * (cols / ferrox_quant::MXFP4_GROUP_SIZE)
4285 ..(r + 1) * (cols / ferrox_quant::MXFP4_GROUP_SIZE)];
4286 f32_weights.extend(ferrox_quant::dequant_mxfp4_row(prow, srow).unwrap());
4287 }
4288 let f32_matrix = WeightMatrix::F32(Tensor::new(f32_weights, vec![rows, cols]));
4289 let f32_out = f32_matrix.apply(&x);
4290
4291 let mxfp4_matrix = WeightMatrix::Mxfp4 {
4292 packed: WeightBytes::Owned(packed),
4293 scale: WeightBytes::Owned(scale),
4294 rows,
4295 cols,
4296 };
4297 let mxfp4_out = mxfp4_matrix.apply(&x);
4298
4299 assert_eq!(f32_out.len(), rows);
4300 assert_eq!(mxfp4_out.len(), rows);
4301 for (f, m) in f32_out.iter().zip(mxfp4_out.iter()) {
4302 assert!((f - m).abs() < 1e-3, "f32={f} mxfp4={m}");
4303 }
4304 }
4305
4306 #[test]
4307 fn mxfp4_apply_batch_matches_sequential_apply_calls() {
4308 let rows = 3;
4309 let cols = 64;
4310 let packed = pseudo_bytes(3, rows * (cols / 2));
4311 let scale = pseudo_mxfp4_scale_bytes(4, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4312 let matrix = WeightMatrix::Mxfp4 {
4313 packed: WeightBytes::Owned(packed),
4314 scale: WeightBytes::Owned(scale),
4315 rows,
4316 cols,
4317 };
4318
4319 let batch_size = 4;
4320 let x_batch: Vec<f32> = (0..batch_size * cols)
4321 .map(|i| ((i % 13) as f32) * 0.02 - 0.15)
4322 .collect();
4323
4324 let batched = matrix.apply_batch(&x_batch, batch_size);
4325 assert_eq!(batched.len(), batch_size * rows);
4326
4327 for b in 0..batch_size {
4328 let x = &x_batch[b * cols..(b + 1) * cols];
4329 let sequential = matrix.apply(x);
4330 let from_batch = &batched[b * rows..(b + 1) * rows];
4331 assert_eq!(
4332 sequential, from_batch,
4333 "batch row {b} disagrees with sequential apply()"
4334 );
4335 }
4336 }
4337
4338 #[test]
4339 fn mxfp4_resident_bytes_matches_the_packed_plus_scale_byte_count_not_eager_f32() {
4340 let rows = 2;
4341 let cols = 64;
4342 let packed = pseudo_bytes(5, rows * (cols / 2));
4343 let scale = pseudo_mxfp4_scale_bytes(6, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4344 let packed_len = packed.len();
4345 let scale_len = scale.len();
4346 let matrix = WeightMatrix::Mxfp4 {
4347 packed: WeightBytes::Owned(packed),
4348 scale: WeightBytes::Owned(scale),
4349 rows,
4350 cols,
4351 };
4352
4353 assert_eq!(matrix.resident_bytes(), packed_len + scale_len);
4354 // Real MXFP4 packs 2 values/byte plus 1 scale byte per 32
4355 // values -- resident_bytes should be far below the 4-bytes-
4356 // per-value eager-f32 footprint.
4357 let eager_f32_bytes = rows * cols * 4;
4358 assert!(
4359 matrix.resident_bytes() * 4 < eager_f32_bytes,
4360 "expected MXFP4 resident bytes well under 1/4 of eager f32: got {} vs {}",
4361 matrix.resident_bytes(),
4362 eager_f32_bytes
4363 );
4364 }
4365
4366 #[test]
4367 fn f32_and_quantized_paths_agree_within_quant_error() {
4368 // 1 row, 32 cols, values chosen to keep Q8_0 error small.
4369 let weights: Vec<f32> = (0..32).map(|i| ((i as f32) - 16.0) * 0.2).collect();
4370 let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.05 - 0.8).collect();
4371
4372 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4373 let f32_out = f32_matrix.apply(&x);
4374
4375 let packed = make_q8_0_row(&weights);
4376 let quant_matrix = WeightMatrix::Quantized {
4377 data: WeightBytes::Owned(packed),
4378 rows: 1,
4379 cols: 32,
4380 kind: QuantKind::Q8_0,
4381 };
4382 let quant_out = quant_matrix.apply(&x);
4383
4384 assert_eq!(f32_out.len(), 1);
4385 assert_eq!(quant_out.len(), 1);
4386 assert!(
4387 (f32_out[0] - quant_out[0]).abs() < 0.05,
4388 "f32={} quant={}",
4389 f32_out[0],
4390 quant_out[0]
4391 );
4392 }
4393
4394 #[test]
4395 fn quantized_resident_bytes_is_smaller_than_f32() {
4396 let weights = vec![0.1f32; 64]; // 2 rows x 32 cols
4397 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![2, 32]));
4398
4399 let mut packed = Vec::new();
4400 for chunk in weights.chunks(32) {
4401 packed.extend(ferrox_quant::quantize_q8_0(chunk));
4402 }
4403 let quant_matrix = WeightMatrix::Quantized {
4404 data: WeightBytes::Owned(packed),
4405 rows: 2,
4406 cols: 32,
4407 kind: QuantKind::Q8_0,
4408 };
4409
4410 assert_eq!(f32_matrix.resident_bytes(), 64 * 4); // 256 bytes
4411 assert_eq!(quant_matrix.resident_bytes(), 2 * 34); // 68 bytes
4412 assert!(quant_matrix.resident_bytes() < f32_matrix.resident_bytes());
4413 // Q8_0 should be close to the theoretical ~4x reduction vs f32.
4414 let ratio = f32_matrix.resident_bytes() as f32 / quant_matrix.resident_bytes() as f32;
4415 assert!(ratio > 3.5, "expected ~4x reduction, got {ratio}x");
4416 }
4417
4418 #[test]
4419 fn rows_and_cols_report_correctly_for_both_variants() {
4420 let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4421 assert_eq!(f32_matrix.rows(), 2);
4422 assert_eq!(f32_matrix.cols(), 3);
4423
4424 let quant_matrix = WeightMatrix::Quantized {
4425 data: WeightBytes::Owned(vec![0u8; 34]),
4426 rows: 1,
4427 cols: 32,
4428 kind: QuantKind::Q8_0,
4429 };
4430 assert_eq!(quant_matrix.rows(), 1);
4431 assert_eq!(quant_matrix.cols(), 32);
4432 }
4433
4434 #[test]
4435 #[should_panic]
4436 fn apply_panics_on_activation_length_mismatch() {
4437 let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4438 f32_matrix.apply(&[1.0, 2.0]); // wrong length (needs 3)
4439 }
4440
4441 #[test]
4442 fn apply_batch_with_batch_size_one_matches_apply() {
4443 // Pinned, not inherited. This asserts `apply` and `apply_batch`
4444 // are BIT-identical, which is only true while both take the same
4445 // kernel -- and since #152 they do not on x86, where the batch
4446 // half of the int-dot tier is taken and the matvec half is not.
4447 // The override is process-global, so without the guard a
4448 // concurrent test holding it on decides this one's result.
4449 let _int_dot = ForceIntDot::new(false);
4450 let weights: Vec<f32> = (0..32).map(|i| (i as f32 - 16.0) * 0.13).collect();
4451 let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.02 - 0.3).collect();
4452
4453 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4454 let single = f32_matrix.apply(&x);
4455 let batched = f32_matrix.apply_batch(&x, 1);
4456 assert_eq!(single, batched);
4457
4458 let packed = ferrox_quant::quantize_q8_0(&weights);
4459 let quant_matrix = WeightMatrix::Quantized {
4460 data: WeightBytes::Owned(packed),
4461 rows: 1,
4462 cols: 32,
4463 kind: QuantKind::Q8_0,
4464 };
4465 let single_q = quant_matrix.apply(&x);
4466 let batched_q = quant_matrix.apply_batch(&x, 1);
4467 assert_eq!(single_q, batched_q);
4468 }
4469
4470 #[test]
4471 fn apply_batch_matches_sequential_apply_calls_for_each_row_f32() {
4472 let rows = 3;
4473 let cols = 32;
4474 let weights: Vec<f32> = (0..rows * cols)
4475 .map(|i| ((i % 17) as f32 - 8.0) * 0.05)
4476 .collect();
4477 let matrix = WeightMatrix::F32(Tensor::new(weights, vec![rows, cols]));
4478
4479 let batch_size = 4;
4480 let x_batch: Vec<f32> = (0..batch_size * cols)
4481 .map(|i| ((i % 13) as f32) * 0.03 - 0.2)
4482 .collect();
4483
4484 let batched = matrix.apply_batch(&x_batch, batch_size);
4485 assert_eq!(batched.len(), batch_size * rows);
4486
4487 for b in 0..batch_size {
4488 let x = &x_batch[b * cols..(b + 1) * cols];
4489 let sequential = matrix.apply(x);
4490 let from_batch = &batched[b * rows..(b + 1) * rows];
4491 assert_eq!(
4492 sequential, from_batch,
4493 "batch row {b} disagrees with sequential apply()"
4494 );
4495 }
4496 }
4497
4498 #[test]
4499 fn apply_batch_matches_sequential_apply_calls_for_each_row_quantized() {
4500 let rows = 3;
4501 let cols = 32;
4502 let weights: Vec<f32> = (0..rows * cols)
4503 .map(|i| ((i % 19) as f32 - 9.0) * 0.07)
4504 .collect();
4505 let mut packed = Vec::new();
4506 for row in weights.chunks(cols) {
4507 packed.extend(ferrox_quant::quantize_q8_0(row));
4508 }
4509 let matrix = WeightMatrix::Quantized {
4510 data: WeightBytes::Owned(packed),
4511 rows,
4512 cols,
4513 kind: QuantKind::Q8_0,
4514 };
4515
4516 let batch_size = 5;
4517 let x_batch: Vec<f32> = (0..batch_size * cols)
4518 .map(|i| ((i % 11) as f32) * 0.04 - 0.25)
4519 .collect();
4520
4521 let batched = matrix.apply_batch(&x_batch, batch_size);
4522 assert_eq!(batched.len(), batch_size * rows);
4523
4524 for b in 0..batch_size {
4525 let x = &x_batch[b * cols..(b + 1) * cols];
4526 let sequential = matrix.apply(x);
4527 let from_batch = &batched[b * rows..(b + 1) * rows];
4528 assert_batch_row_matches(QuantKind::Q8_0, "", b, &sequential, from_batch);
4529 }
4530 }
4531
4532 /// Minimal f16 encode for small positive normals (test fixtures only).
4533 pub(super) fn f16_le(x: f32) -> [u8; 2] {
4534 let bits = x.to_bits();
4535 let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
4536 let mant = (bits >> 13) & 0x3ff;
4537 (((exp as u16) << 10) | mant as u16).to_le_bytes()
4538 }
4539
4540 /// Deterministic pseudo-random quantized matrix: every byte pattern is
4541 /// a valid weight block, only the f16 scale fields need sane values.
4542 /// Compare one row of `apply_batch` against `apply`, scaled by the
4543 /// magnitude of the row rather than of each element.
4544 ///
4545 /// The element-wise denominator (`err / s.abs().max(1.0)`) is wrong
4546 /// for a dot product over random data: the sums cancel, so a result
4547 /// that lands near zero turns a normal rounding difference into a
4548 /// relative error of 30%. Measured on Metal, the divergence is a
4549 /// uniform 5.5e-4 of the row's own scale across every quant kind
4550 /// and batch index, and up to 2.9e-1 of the individual result. The
4551 /// first number describes the arithmetic; the second describes
4552 /// which results happened to cancel.
4553 ///
4554 /// This matters because `apply_batch` is not `apply` on a GPU
4555 /// build: `apply_batch` dispatches to Metal while `apply` stays on
4556 /// the CPU, so this compares two backends. The bound stays tight on
4557 /// CPU, where both sides are the same code and must agree closely.
4558 fn assert_batch_row_matches(
4559 kind: QuantKind,
4560 ctx: &str,
4561 b: usize,
4562 sequential: &[f32],
4563 from_batch: &[f32],
4564 ) {
4565 let scale = sequential
4566 .iter()
4567 .fold(0.0f32, |a, v| a.max(v.abs()))
4568 .max(1.0);
4569 // A GPU build compares Metal against the CPU; a CPU build
4570 // compares the CPU against itself -- UNLESS this host takes only
4571 // one half of the int-dot tier, in which case `apply` and
4572 // `apply_batch` are not the same arithmetic at all.
4573 //
4574 // That is x86 since #152: the batch half runs the AVX2
4575 // interleaved GEMM over an int8-quantized activation while the
4576 // matvec half stays on the f32 AVX2 dot, because the int8 matvec
4577 // measured 4x to 8.8x slower there. The gap between the two
4578 // sides is then the ACTIVATION quantization floor -- each element
4579 // of `x` moves by up to `d/2` at `d = amax/127` -- not float
4580 // summation order, and a 1e-4 bar describes the wrong thing.
4581 //
4582 // Measured across every shape in these tests on a linux/amd64
4583 // container with real AVX2 (2026-09-09): worst 7.9e-3 of the row
4584 // scale. 6e-2 keeps a 7.6x margin, the same discipline as
4585 // `int_dot_batch_matches_dequant_dot_reference`, and is still far
4586 // inside a mis-pack, which decorrelates the two outputs entirely.
4587 let mixed = cpu_int_dot_for(IntDotShape::Matvec) != cpu_int_dot_for(IntDotShape::BatchGemm);
4588 let bound = if cfg!(any(feature = "metal", feature = "cuda")) {
4589 5e-3
4590 } else if mixed {
4591 6e-2
4592 } else {
4593 1e-4
4594 };
4595 for (r, (s, got)) in sequential.iter().zip(from_batch.iter()).enumerate() {
4596 let err = (s - got).abs() / scale;
4597 assert!(
4598 err < bound,
4599 "{kind:?} {ctx} batch {b} row {r}: apply()={s} apply_batch={got} \
4600 (err {err:e} of row scale {scale}, bound {bound:e})"
4601 );
4602 }
4603 }
4604
4605 fn synth_quant_matrix(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
4606 let mut state = 0x1234_5678u32;
4607 let mut next = move || {
4608 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
4609 (state >> 24) as u8
4610 };
4611 let mut data = Vec::new();
4612 match kind {
4613 QuantKind::Q8_0 | QuantKind::Q4_0 => {
4614 let qs = if kind == QuantKind::Q8_0 { 32 } else { 16 };
4615 for _ in 0..rows * (cols / 32) {
4616 data.extend_from_slice(&f16_le(0.02 + f32::from(next()) * 0.0004));
4617 for _ in 0..qs {
4618 data.push(next());
4619 }
4620 }
4621 }
4622 QuantKind::Q4K | QuantKind::Q5K => {
4623 let body = if kind == QuantKind::Q4K {
4624 12 + 128
4625 } else {
4626 12 + 32 + 128
4627 };
4628 for _ in 0..rows * (cols / 256) {
4629 data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4630 data.extend_from_slice(&f16_le(0.005 + f32::from(next()) * 0.0001));
4631 for _ in 0..body {
4632 data.push(next());
4633 }
4634 }
4635 }
4636 QuantKind::Q6K => {
4637 for _ in 0..rows * (cols / 256) {
4638 for _ in 0..128 + 64 + 16 {
4639 data.push(next());
4640 }
4641 data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4642 }
4643 }
4644 _ => unreachable!("synth_quant_matrix: unsupported kind"),
4645 }
4646 WeightMatrix::Quantized {
4647 data: WeightBytes::Owned(data),
4648 rows,
4649 cols,
4650 kind,
4651 }
4652 }
4653 /// One `apply_batch` vs per-row `apply` sweep, parameterized by shape
4654 /// so the shape tests below differ only in the numbers they pass.
4655 fn assert_apply_batch_matches_apply(
4656 kind: QuantKind,
4657 rows: usize,
4658 cols: usize,
4659 batch_size: usize,
4660 seed: usize,
4661 ) {
4662 let x_batch: Vec<f32> = (0..batch_size * cols)
4663 .map(|i| (((i * 31 + seed) % 97) as f32) * 0.021 - 1.0)
4664 .collect();
4665 let matrix = synth_quant_matrix(kind, rows, cols);
4666 let batched = matrix.apply_batch(&x_batch, batch_size);
4667 assert_eq!(batched.len(), batch_size * rows);
4668 let ctx = format!(
4669 "rows {rows} cols {cols} batch_size {batch_size} int_dot {}",
4670 cpu_int_dot_for(IntDotShape::BatchGemm)
4671 );
4672 for b in 0..batch_size {
4673 let x = &x_batch[b * cols..(b + 1) * cols];
4674 let sequential = matrix.apply(x);
4675 let from_batch = &batched[b * rows..(b + 1) * rows];
4676 // Delegates rather than restating the bound. The first
4677 // version of this helper compared each element against
4678 // `s.abs().max(1.0)`, which is a bare 1e-4 ABSOLUTE bound
4679 // for any row whose value is small -- and a dot product of
4680 // 512 terms that cancels to -0.76 carries the rounding of
4681 // the terms, not of the result. It passed on aarch64 and
4682 // failed on x86_64 CI at 1.07e-4, on one row out of 17094.
4683 // `assert_batch_row_matches` already divides by the row
4684 // vector's own scale, which is the invariant that makes the
4685 // comparison meaningful, and it is now the only place the
4686 // tolerance is written down.
4687 assert_batch_row_matches(kind, &ctx, b, &sequential, from_batch);
4688 }
4689 }
4690
4691 const BATCH_SHAPE_KINDS: [QuantKind; 5] = [
4692 QuantKind::Q8_0,
4693 QuantKind::Q4_0,
4694 QuantKind::Q4K,
4695 QuantKind::Q5K,
4696 QuantKind::Q6K,
4697 ];
4698
4699 /// `apply_batch` writes straight into the `[batch][rows]` output from
4700 /// parallel tasks (no staging transpose); the shapes here force every
4701 /// write pattern: full row-groups, a tail of leftover rows, and both
4702 /// full and partial activation tiles.
4703 ///
4704 /// Run under both settings of [`cpu_int_dot_enabled`]. With int-dot
4705 /// off, `apply_batch` dequantizes and the repack tier is skipped
4706 /// entirely; with it on -- which is what every shipped binary does,
4707 /// via `default_cpu_int_dot_on` -- the interleaved `block_q*_Kx8` /
4708 /// `block_q*_0x4` kernels and, on an i8mm host, the SMMLA GEMMs are
4709 /// the code under test. `cargo test` leaves the env var unset, so
4710 /// without [`ForceIntDot`] only the first of those two ever ran.
4711 #[test]
4712 fn apply_batch_matches_apply_across_kinds_with_groups_and_tail() {
4713 for int_dot in [false, true] {
4714 let _g = ForceIntDot::new(int_dot);
4715 for kind in BATCH_SHAPE_KINDS {
4716 // 19 rows = 2x8-row groups + 3 tail (4x4-row groups + 3
4717 // for Q8_0/Q4_0); 6 activations = one full 4-tile + a
4718 // partial one.
4719 assert_apply_batch_matches_apply(kind, 19, 512, 6, 7);
4720 }
4721 }
4722 }
4723
4724 /// Shapes too small to fill one interleaved row-group, which the
4725 /// tests around this one never reach: they use `rows` big enough that
4726 /// `n_groups > 0` for every kind. At `rows < 8` the K-quant arms take
4727 /// their `else` branch (per-row `gemm_q*_k_q8_row`) with the repack
4728 /// path completely bypassed, and at `rows < 4` the Q8_0/Q4_0 arms do
4729 /// the same with `dot_q*_q8`. `rows = 5` is the mixed case: one full
4730 /// `block_q*_0x4` group plus a 1-row tail for Q8_0/Q4_0, zero groups
4731 /// for the Kx8 kinds. `rows = 1` is the single-row case.
4732 ///
4733 /// `cols = 256` is also the minimum K for a K-quant -- a single
4734 /// super-block, so every kernel's block loop runs exactly one trip.
4735 /// `batch_size = 1` is the single-column case: one activation in the
4736 /// quad, `na = 1` with three zero-padded lanes in `Q8KActsX4` /
4737 /// `Q8ActsX4`.
4738 #[test]
4739 fn apply_batch_matches_apply_for_sub_tile_shapes() {
4740 for int_dot in [false, true] {
4741 let _g = ForceIntDot::new(int_dot);
4742 for kind in BATCH_SHAPE_KINDS {
4743 for rows in [1, 2, 3, 5, 7] {
4744 for batch_size in [1, 2, 5] {
4745 assert_apply_batch_matches_apply(kind, rows, 256, batch_size, 13);
4746 }
4747 }
4748 }
4749 }
4750 }
4751
4752 /// `apply_batch` under int-dot against an f32 dequantize-and-dot
4753 /// reference that never touches the packed buffer.
4754 ///
4755 /// Every other batch test compares `apply_batch` against `apply`,
4756 /// which under int-dot is the packed **GEMV** against the packed
4757 /// **GEMM** -- two kernels reading the *same* interleaved bytes. That
4758 /// catches a bad kernel but is structurally blind to a bad
4759 /// `pack_q*_matrix_x*`: both sides read the same wrong bytes and
4760 /// agree. `dequant_row` is the only reference in the tree that
4761 /// re-derives the weights from the canonical GGUF blocks, so it is
4762 /// the only one that can see a mis-interleave.
4763 ///
4764 /// The bound is the Q8/Q8_K *activation* quantization floor, not the
4765 /// kernel's, and it is scaled by the RMS of the reference outputs
4766 /// rather than per element: these synthetic weights are uniform
4767 /// random bytes, so individual dots cancel to near zero and a
4768 /// per-element relative bound would be meaningless. Worst deviation
4769 /// measured across every shape below, on an M2 Pro (i8mm), is 0.016 x
4770 /// RMS; 0.12 keeps a 7x margin. Coarse on purpose -- a mis-pack
4771 /// decorrelates the output from the reference entirely (measured at
4772 /// 2.07 x RMS for a one-row shift in the Q5_K `qh` interleave), an
4773 /// order of magnitude past this bound.
4774 #[test]
4775 fn int_dot_batch_matches_dequant_dot_reference() {
4776 let _g = ForceIntDot::new(true);
4777 // The batch half needs a SIMD `x4` GEMM, so a host without
4778 // one (an x86 box with no AVX2, Rosetta included) has no
4779 // packed path to test. Skipping is honest; asserting would
4780 // make the suite red for a host that is behaving correctly.
4781 assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4782 if !cpu_int_dot_for(IntDotShape::BatchGemm) {
4783 return;
4784 }
4785 for kind in BATCH_SHAPE_KINDS {
4786 // Rows straddle both tile widths: below the tile, one short
4787 // of it, exactly it, one past it, and multi-group with a
4788 // tail. Batch straddles the 4-wide activation quad. cols 256
4789 // is the minimum K for a K-quant (one super-block).
4790 for rows in [1, 3, 5, 7, 8, 9, 19] {
4791 for cols in [256, 512] {
4792 for batch_size in [1, 3, 4, 9] {
4793 assert_int_dot_matches_dequant_dot(kind, rows, cols, batch_size, 23);
4794 }
4795 }
4796 }
4797 }
4798 // Q8_0/Q4_0 alone can go down to a single 32-element block.
4799 for kind in [QuantKind::Q8_0, QuantKind::Q4_0] {
4800 for rows in [1, 3, 4, 5, 11] {
4801 for batch_size in [1, 3, 4, 9] {
4802 assert_int_dot_matches_dequant_dot(kind, rows, 32, batch_size, 29);
4803 }
4804 }
4805 }
4806 }
4807
4808 fn assert_int_dot_matches_dequant_dot(
4809 kind: QuantKind,
4810 rows: usize,
4811 cols: usize,
4812 batch_size: usize,
4813 seed: usize,
4814 ) {
4815 let x_batch: Vec<f32> = (0..batch_size * cols)
4816 .map(|i| (((i * 37 + seed) % 89) as f32) * 0.019 - 0.8)
4817 .collect();
4818 let matrix = synth_quant_matrix(kind, rows, cols);
4819 let got = matrix.apply_batch(&x_batch, batch_size);
4820 assert_eq!(got.len(), batch_size * rows);
4821
4822 let mut want = vec![0f32; batch_size * rows];
4823 for r in 0..rows {
4824 let w = matrix.dequant_row(r);
4825 assert_eq!(w.len(), cols);
4826 for b in 0..batch_size {
4827 let x = &x_batch[b * cols..(b + 1) * cols];
4828 want[b * rows + r] = w.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4829 }
4830 }
4831 let rms = (want.iter().map(|v| v * v).sum::<f32>() / want.len() as f32).sqrt();
4832 for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
4833 let err = (g - w).abs();
4834 assert!(
4835 err < 0.12 * rms.max(1e-3),
4836 "{kind:?} rows {rows} cols {cols} batch_size {batch_size} [flat {i}]: \
4837 int-dot={g} dequant-dot={w} (err {err}, rms {rms})"
4838 );
4839 }
4840 }
4841
4842 /// Large enough that `par_chunked_groups` builds a real 2D chunk grid
4843 /// (32 row-groups × 17 activation tiles) instead of falling back to
4844 /// one-chunk-per-thread — every (group, tile-range) seam in the
4845 /// chunked scatter is crossed. The smaller cross-kind test above
4846 /// covers the fallback path. Both int-dot settings, for the same
4847 /// reason as that test.
4848 #[test]
4849 fn apply_batch_chunked_grid_matches_apply() {
4850 for int_dot in [false, true] {
4851 let _g = ForceIntDot::new(int_dot);
4852 for kind in BATCH_SHAPE_KINDS {
4853 // 259 rows = 32 groups of 8 + 3 tail (64 of 4 + 3 for
4854 // Q8_0/Q4_0); 66 activations = 16 full 4-tiles + a
4855 // partial one.
4856 assert_apply_batch_matches_apply(kind, 259, 512, 66, 5);
4857 }
4858 }
4859 }
4860
4861 /// Sharing one quantized activation batch across projections must be
4862 /// invisible in the results: a matching `BatchActs` produces exactly
4863 /// what `apply_batch` produces (same quantization, same interleaved
4864 /// quads, same kernels), and a mismatched variant is ignored rather
4865 /// than misused.
4866 #[test]
4867 fn apply_batch_with_shared_acts_matches_apply_batch() {
4868 // Shared quads are built under one setting and consumed under
4869 // another if a concurrent test flips the global mid-run; pin it
4870 // on, which is also the setting that gives this test something
4871 // to compare.
4872 let _int_dot = ForceIntDot::new(true);
4873 let rows = 19;
4874 let cols = 512;
4875 let batch_size = 6;
4876 let x_batch: Vec<f32> = (0..batch_size * cols)
4877 .map(|i| (((i * 29 + 11) % 89) as f32) * 0.023 - 1.0)
4878 .collect();
4879 for kind in [
4880 QuantKind::Q8_0,
4881 QuantKind::Q4_0,
4882 QuantKind::Q4K,
4883 QuantKind::Q6K,
4884 ] {
4885 let matrix = synth_quant_matrix(kind, rows, cols);
4886 let baseline = matrix.apply_batch(&x_batch, batch_size);
4887
4888 let shared = matrix.quantize_batch_acts(&x_batch, batch_size);
4889 let with_shared = matrix.apply_batch_with_acts(&x_batch, batch_size, shared.as_ref());
4890 assert_eq!(
4891 baseline, with_shared,
4892 "{kind:?}: shared acts changed the result"
4893 );
4894
4895 let wrong = match kind {
4896 QuantKind::Q8_0 | QuantKind::Q4_0 => BatchActs::Q8K {
4897 acts: Vec::new(),
4898 tiles: Vec::new(),
4899 cols,
4900 },
4901 _ => BatchActs::Q8 {
4902 acts: Vec::new(),
4903 tiles: Vec::new(),
4904 cols,
4905 },
4906 };
4907 let with_wrong = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&wrong));
4908 assert_eq!(
4909 baseline, with_wrong,
4910 "{kind:?}: mismatched shared acts were not ignored"
4911 );
4912 }
4913 }
4914
4915 /// The interleaved quads now ride along with the activations, so the
4916 /// guard that decides whether a `shared` batch is usable has to cover
4917 /// them too -- and that guard is the one thing here that is not gated
4918 /// on `FERROX_CPU_INT_DOT`, so it is tested directly.
4919 ///
4920 /// A stale set is not a panic. The quads are indexed by super-block, so
4921 /// a batch prepared at another width either reads past its own end or
4922 /// silently dots the wrong columns; both surface as a wrong answer.
4923 /// What must happen instead is a local re-quantization with no quads,
4924 /// which is what the fresh-fallback assertions below pin.
4925 #[test]
4926 fn shared_acts_are_reused_only_at_the_matching_length_and_width() {
4927 let cols = 512;
4928 let batch_size = 7;
4929 let x_batch: Vec<f32> = (0..batch_size * cols)
4930 .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
4931 .collect();
4932
4933 let acts: Vec<_> = (0..batch_size)
4934 .map(|b| ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
4935 .collect();
4936 let tiles: Vec<_> = acts
4937 .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4938 .map(|c| ferrox_quant::prepare_q8_k_acts_x4(c, cols))
4939 .collect();
4940 let n_tiles = tiles.len();
4941 let shared = BatchActs::Q8K { acts, tiles, cols };
4942
4943 let mut owned = Vec::new();
4944 let (got, quads) =
4945 WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4946 assert_eq!(got.len(), batch_size);
4947 assert_eq!(
4948 quads.len(),
4949 n_tiles,
4950 "matching batch did not reuse its quads"
4951 );
4952 assert!(owned.is_empty(), "matching batch was re-quantized anyway");
4953
4954 // Same positions, another width: refuse and re-quantize.
4955 let mut owned = Vec::new();
4956 let (got, quads) =
4957 WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4958 assert!(quads.is_empty(), "quads from another width were accepted");
4959 assert_eq!(got.len(), batch_size);
4960 assert_eq!(got[0].n_blocks(), 1, "fallback did not quantize at 256");
4961
4962 // Same width, another position count: refuse and re-quantize.
4963 let mut owned = Vec::new();
4964 let (got, quads) =
4965 WeightMatrix::q8k_acts(Some(&shared), &x_batch[..cols], 1, cols, &mut owned);
4966 assert!(quads.is_empty(), "quads for another batch were accepted");
4967 assert_eq!(got.len(), 1);
4968
4969 // The Q8_0 half of the same guard.
4970 let acts: Vec<_> = (0..batch_size)
4971 .map(|b| ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
4972 .collect();
4973 let tiles: Vec<_> = acts
4974 .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4975 .map(|c| ferrox_quant::prepare_q8_acts_x4(c, cols))
4976 .collect();
4977 let n_tiles = tiles.len();
4978 let shared = BatchActs::Q8 { acts, tiles, cols };
4979
4980 let mut owned = Vec::new();
4981 let (got, quads) =
4982 WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4983 assert_eq!(got.len(), batch_size);
4984 assert_eq!(
4985 quads.len(),
4986 n_tiles,
4987 "matching batch did not reuse its quads"
4988 );
4989
4990 let mut owned = Vec::new();
4991 let (got, quads) =
4992 WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4993 assert!(quads.is_empty(), "quads from another width were accepted");
4994 assert_eq!(got[0].n_blocks(), 8, "fallback did not quantize at 256");
4995 }
4996
4997 /// Whatever a projection would have built for itself, a sibling's
4998 /// shared batch must hand it the same thing. Q4_K, Q5_K and Q6_K read
4999 /// one Q8_K quad set between them, and Q8_0 and Q4_0 one Q8_0 set, so
5000 /// the donor's kind must not show through.
5001 ///
5002 /// Gated the same way the path itself is: with `FERROX_CPU_INT_DOT`
5003 /// off (the library default) `quantize_batch_acts` returns `None` and
5004 /// no projection consumes quads at all, so this asserts against the
5005 /// INT_DOT build. Run the suite both ways.
5006 #[test]
5007 fn shared_quads_are_what_each_consumer_would_have_built_itself() {
5008 // The early return below reads a process-global, so it has to be
5009 // pinned or a neighbour can turn the tier off between the check
5010 // and the assertions it guards.
5011 let _int_dot = ForceIntDot::new(true);
5012 if !cpu_int_dot_for(IntDotShape::BatchGemm) {
5013 return;
5014 }
5015 let rows = 24;
5016 let cols = 512;
5017 let batch_size = 7;
5018 let x_batch: Vec<f32> = (0..batch_size * cols)
5019 .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
5020 .collect();
5021
5022 for (donor, consumers) in [
5023 (QuantKind::Q4K, &[QuantKind::Q5K, QuantKind::Q6K][..]),
5024 (QuantKind::Q8_0, &[QuantKind::Q4_0][..]),
5025 ] {
5026 let shared = synth_quant_matrix(donor, rows, cols)
5027 .quantize_batch_acts(&x_batch, batch_size)
5028 .expect("INT_DOT is on and this kind/width is eligible");
5029 for kind in consumers {
5030 let matrix = synth_quant_matrix(*kind, rows, cols);
5031 let baseline = matrix.apply_batch(&x_batch, batch_size);
5032 let shared_out = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&shared));
5033 assert_eq!(
5034 baseline, shared_out,
5035 "{kind:?} consuming {donor:?} quads changed the result"
5036 );
5037 }
5038 }
5039 }
5040
5041 #[test]
5042 fn apply_batch_with_zero_batch_size_returns_empty() {
5043 let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5044 let out = matrix.apply_batch(&[], 0);
5045 assert!(out.is_empty());
5046 }
5047
5048 #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
5049 mod gpu_dispatch {
5050 use super::*;
5051
5052 /// `apply_gpu` must return `None` for `F32` -- and, crucially,
5053 /// without ever touching the CUDA driver at all (this runs on
5054 /// every CI machine, none of which have a GPU): the `let ...
5055 /// else { return None }` pattern match happens before any
5056 /// `ferrox_cuda` call, so this is a real, meaningful assertion
5057 /// about dispatch behavior, not a stub.
5058 #[test]
5059 fn apply_gpu_returns_none_for_f32() {
5060 let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5061 assert!(matrix.apply_gpu(&[0.0, 0.0, 0.0]).is_none());
5062 }
5063
5064 #[test]
5065 fn apply_gpu_returns_none_for_mxfp4() {
5066 let matrix = WeightMatrix::Mxfp4 {
5067 packed: WeightBytes::Owned(vec![0u8; 32]),
5068 scale: WeightBytes::Owned(vec![0u8; 2]),
5069 rows: 1,
5070 cols: 64,
5071 };
5072 assert!(matrix.apply_gpu(&vec![0.0; 64]).is_none());
5073 }
5074
5075 /// A `Quantized` matrix whose `kind` has no GPU kernel on any
5076 /// compiled backend must fall back to `None`, not panic on the
5077 /// `unreachable!()` in `block_bytes_for_kind` -- proving the
5078 /// two match arms (`apply_gpu`'s launch table,
5079 /// `block_bytes_for_kind`'s partial one) stay in sync.
5080 ///
5081 /// The probe was `Q2_K` until 2026-09-09, when Q2_K gained a
5082 /// CUDA matvec and a GEMM and stopped being unsupported. `Q4_1`
5083 /// has neither on any backend and is the hole now. Moving it
5084 /// found a real defect rather than being bookkeeping: with the
5085 /// `cuda` feature on and no driver present, the first real
5086 /// dispatch through `Cuda::launch_matvec` aborted the process
5087 /// inside `cudarc`'s library loader, which that arm's
5088 /// `Result` could never have reported.
5089 #[test]
5090 fn apply_gpu_returns_none_for_an_unsupported_quant_kind() {
5091 let matrix = WeightMatrix::Quantized {
5092 data: WeightBytes::Owned(vec![0u8; ferrox_quant::Q4_1_BLOCK_BYTES]),
5093 rows: 1,
5094 cols: ferrox_quant::Q4_1_BLOCK_ELEMS,
5095 kind: QuantKind::Q4_1,
5096 };
5097 assert!(matrix
5098 .apply_gpu(&[0.0; ferrox_quant::Q4_1_BLOCK_ELEMS])
5099 .is_none());
5100 }
5101
5102 #[test]
5103 #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
5104 fn apply_gpu_matches_apply_for_q8_0_on_real_hardware() {
5105 let weights: Vec<f32> = (0..64).map(|i| ((i as f32) - 32.0) * 0.05).collect();
5106 let x: Vec<f32> = (0..64).map(|i| (i as f32) * 0.01 - 0.3).collect();
5107 let packed = ferrox_quant::quantize_q8_0(&weights);
5108 let matrix = WeightMatrix::Quantized {
5109 data: WeightBytes::Owned(packed),
5110 rows: 1,
5111 cols: 64,
5112 kind: QuantKind::Q8_0,
5113 };
5114
5115 let cpu = matrix.apply_cpu(&x);
5116 let gpu = matrix
5117 .apply_gpu(&x)
5118 .expect("Q8_0 must dispatch to a real GPU kernel");
5119 assert_eq!(cpu.len(), gpu.len());
5120 for (c, g) in cpu.iter().zip(gpu.iter()) {
5121 assert!((c - g).abs() < 1e-2, "cpu={c} gpu={g}");
5122 }
5123 }
5124 }
5125
5126 // ---- kernel-lookup registry coverage -------------------------------
5127 //
5128 // These are the tests that would have caught the IQ4_XS silent CPU
5129 // prefill at `cargo test` time instead of via a 13.7x benchmark row.
5130
5131 /// A quantized matrix of `kind` with `cols` columns, filled with
5132 /// arbitrary bytes -- the probe reads only shape and kind, never the
5133 /// weights, so the contents are irrelevant.
5134 fn shaped(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
5135 let per_row = match kind {
5136 QuantKind::Q8_0 => cols / 32 * 34,
5137 _ => cols,
5138 };
5139 WeightMatrix::Quantized {
5140 data: WeightBytes::Owned(vec![0u8; rows * per_row.max(1)]),
5141 rows,
5142 cols,
5143 kind,
5144 }
5145 }
5146
5147 /// `QuantKind::ALL` must actually list every variant. `name()` is
5148 /// exhaustive by the compiler, so distinct names prove distinct
5149 /// variants; the count pins that none was dropped from the list.
5150 #[test]
5151 fn quant_kind_all_lists_every_variant_exactly_once() {
5152 let mut names: Vec<&str> = QuantKind::ALL.iter().map(|k| k.name()).collect();
5153 let total = names.len();
5154 names.sort_unstable();
5155 names.dedup();
5156 assert_eq!(names.len(), total, "QuantKind::ALL has a duplicate");
5157 assert_eq!(
5158 total, 23,
5159 "a QuantKind variant was added without updating ALL"
5160 );
5161 }
5162
5163 /// The invariant that keeps prefill honest: every kind with a Metal
5164 /// matvec also has a Metal batched GEMM. Break it and the kind still
5165 /// "runs on Metal" -- as `batch` separate matvecs over the same
5166 /// weights, which is exactly the shape that put IQ4_XS 13.7x behind
5167 /// with no symptom other than a slow benchmark.
5168 #[test]
5169 fn every_metal_matvec_kind_also_has_a_metal_gemm() {
5170 for &k in QuantKind::ALL {
5171 assert_eq!(
5172 metal_matvec_kind_name(k).is_some(),
5173 metal_mul_mm_kind_supported(k),
5174 "{}: matvec and mul_mm kernel tables disagree -- one of the two \
5175 is a silent slow path",
5176 k.name()
5177 );
5178 }
5179 }
5180
5181 /// The kind tables are pure lookups over the name, so a kind that
5182 /// claims a kernel must name itself the way the Metal launch meta
5183 /// table is keyed.
5184 #[test]
5185 fn metal_kind_names_match_the_quant_kind_names() {
5186 for &k in QuantKind::ALL {
5187 if let Some(name) = metal_matvec_kind_name(k) {
5188 assert_eq!(name, k.name());
5189 }
5190 }
5191 }
5192
5193 /// THE registry test: a kind with no accelerator kernel, probed
5194 /// while the model is built, must be recorded as a miss and must be
5195 /// a seal-time violation -- not silently absorbed by a fallback.
5196 ///
5197 /// Runs on any build: the backend is passed explicitly, so it does
5198 /// not need `--features metal` to ask what Metal would resolve.
5199 #[test]
5200 fn a_deliberately_unsupported_kind_trips_the_registry() {
5201 use crate::kernel_registry::{Backend, Outcome};
5202
5203 let reg = crate::kernel_registry::Registry::new();
5204 let loc = std::panic::Location::caller();
5205
5206 // Supported: Q4_K has both a Metal matvec and a Metal GEMM.
5207 shaped(QuantKind::Q4K, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_down", loc);
5208 // Unsupported: no Metal kernel of any kind for IQ2_XXS.
5209 shaped(QuantKind::IQ2XXS, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_up", loc);
5210
5211 let report = reg.seal();
5212 let violations = &report.violations;
5213 assert_eq!(
5214 violations.len(),
5215 2,
5216 "expected matvec + gemm misses for IQ2_XXS only, got: {:?}",
5217 report
5218 .entries
5219 .iter()
5220 .map(|e| e.to_string())
5221 .collect::<Vec<_>>()
5222 );
5223 assert!(
5224 violations
5225 .iter()
5226 .all(|v| v.key.kind == Some(QuantKind::IQ2XXS)),
5227 "Q4_K must not be flagged"
5228 );
5229 assert!(
5230 violations.iter().any(|v| matches!(
5231 v.outcome,
5232 Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5233 )),
5234 "the report must name the fallback that will actually run"
5235 );
5236 let rendered = report.render_violations();
5237 assert!(rendered.contains("IQ2_XXS"), "{rendered}");
5238 assert!(rendered.contains("weight_matrix.rs"), "{rendered}");
5239
5240 // And the host tier it lands on is recorded too: IQ2_XXS has no
5241 // integer vec_dot either, so it is f32 dequant-dot.
5242 assert!(
5243 report.entries.iter().any(|e| e.key.backend == Backend::Cpu
5244 && e.key.kind == Some(QuantKind::IQ2XXS)
5245 && matches!(e.outcome, Outcome::Miss { fallback, .. } if fallback == "f32 dequant-dot")),
5246 "{:?}",
5247 report.entries.iter().map(|e| e.to_string()).collect::<Vec<_>>()
5248 );
5249 }
5250
5251 /// A supported kind on a selected accelerator produces no violation
5252 /// at all -- otherwise the signal is noise and gets ignored.
5253 #[test]
5254 fn a_fully_supported_model_seals_clean() {
5255 use crate::kernel_registry::Backend;
5256
5257 let reg = crate::kernel_registry::Registry::new();
5258 let loc = std::panic::Location::caller();
5259 for kind in [QuantKind::Q4K, QuantKind::Q6K, QuantKind::Q8_0] {
5260 shaped(kind, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_down", loc);
5261 }
5262 let report = reg.seal();
5263 assert!(report.violations.is_empty(), "{}", report.render());
5264 }
5265
5266 /// A kind CUDA cannot run at all must be RECORDED as leaving the
5267 /// GPU, by name, rather than left to a comment in
5268 /// `apply_batch_with_acts`.
5269 ///
5270 /// This test used to probe `Q4K` and expect the fallback
5271 /// `"CUDA per-position matvec"`, which is what a kind gets when it
5272 /// has a matvec but no GEMM. **That combination no longer exists on
5273 /// CUDA.** The K-quants gained a GEMM on 2026-09-04, motivated by
5274 /// Llama-3.2-3B Q4_K_M running pp512 at 4.88 tok/s against
5275 /// llama.cpp's 1586.80, and the invariant below now forbids the
5276 /// combination from coming back.
5277 ///
5278 /// So the probe moved to a kind with neither kernel, and the
5279 /// expected fallback moved with it: with no matvec to loop over
5280 /// there is no per-position loop, and the whole matmul leaves for
5281 /// the host.
5282 ///
5283 /// It has moved three times. `Q5_0` was that kind until
5284 /// 2026-09-05, when it gained both; `Q2_K` was until 2026-09-09,
5285 /// when it and Q3_K did. `Q4_1` is the hole now, and the next row
5286 /// of the coverage table in `docs/plans/cpu-cuda-parity.md` §6 --
5287 /// which is the point: the test names a real hole and stops
5288 /// compiling a comment. When Q4_1 lands, this probe moves again.
5289 #[test]
5290 fn a_kind_cuda_cannot_run_is_recorded_as_leaving_the_gpu() {
5291 use crate::kernel_registry::{op, Backend, Outcome};
5292
5293 let reg = crate::kernel_registry::Registry::new();
5294 let loc = std::panic::Location::caller();
5295 shaped(QuantKind::Q4_1, 64, 256).probe_kernels_for(®, Backend::Cuda, "ffn_down", loc);
5296 let report = reg.seal();
5297 assert!(
5298 report.entries.iter().any(|e| e.key.backend == Backend::Cuda
5299 && e.key.op == op::GEMM_PREFILL
5300 && matches!(
5301 e.outcome,
5302 Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5303 )),
5304 "{}",
5305 report.render()
5306 );
5307 }
5308
5309 /// CUDA's matvec set and its GEMM set are now the same, and that is
5310 /// worth pinning: a kind that can be decoded on the GPU but not
5311 /// prefilled there is the shape that cost 325x, and it went
5312 /// unnoticed because a fallback still answers correctly.
5313 ///
5314 /// If a future kind gains a matvec without a GEMM, this fails and
5315 /// names it, rather than a benchmark noticing months later.
5316 #[test]
5317 fn a_cuda_kind_with_a_matvec_also_has_a_gemm() {
5318 for kind in QuantKind::ALL {
5319 if cuda_matvec_kind_supported(*kind) {
5320 assert!(
5321 cuda_mul_mm_kind_supported(*kind),
5322 "{kind:?} can be decoded on CUDA but not prefilled there, \
5323 which decomposes a prefill into one matvec launch per position"
5324 );
5325 }
5326 }
5327 }
5328
5329 /// An F32 weight has no quantized kernel by construction; the probe
5330 /// records the host GEMV but must not call it a violation, or every
5331 /// MoE router would fail a strict run.
5332 #[test]
5333 fn an_f32_weight_is_recorded_without_being_a_violation() {
5334 use crate::kernel_registry::Backend;
5335
5336 let reg = crate::kernel_registry::Registry::new();
5337 let m = WeightMatrix::F32(Tensor::new(vec![0.0; 64 * 32], vec![64, 32]));
5338 m.probe_kernels_for(
5339 ®,
5340 Backend::Metal,
5341 "moe_router",
5342 std::panic::Location::caller(),
5343 );
5344 let report = reg.seal();
5345 assert!(!report.misses.is_empty());
5346 assert!(report.violations.is_empty(), "{}", report.render());
5347 }
5348}
5349
5350#[cfg(test)]
5351mod int_dot_default_tests {
5352 use super::{IntDotShape, IntDotTier};
5353
5354 /// The int-dot rule follows the kernels that exist, per workload,
5355 /// not the wish that every architecture had every kernel.
5356 ///
5357 /// Taking the MATVEC half where the interleaved kernels do not exist
5358 /// selects a scalar integer loop and skips the AVX2 f32 dot that
5359 /// does, which measured 4x to 8.8x of x86 decode (#127). Adding AVX2
5360 /// GEMMs (#152) does not change that: they are batch kernels, and
5361 /// the matvec half of x86 is still the f32 dot's.
5362 #[test]
5363 fn the_matvec_half_is_taken_only_where_its_kernels_are() {
5364 assert_eq!(
5365 super::int_dot_tier_here().matvec,
5366 cfg!(target_arch = "aarch64"),
5367 "the matvec half is aarch64's (i8mm, interleave-8 NEON) and nowhere else; \
5368 x86 measured 4x to 8.8x slower with it on"
5369 );
5370 }
5371
5372 /// The BATCH half is not a `cfg!` claim: it asks the kernels.
5373 ///
5374 /// A host may only be told the batch tier is a win if `ferrox_quant`
5375 /// reports a SIMD batch GEMM at the width this host packs with. That
5376 /// is what stops the two structures — the list of architectures
5377 /// believed to have kernels, and the kernels — from drifting apart,
5378 /// which is how the 4x-to-8.8x regression happened in the first
5379 /// place.
5380 ///
5381 /// The probe is [`ferrox_quant::batch_gemm_is_accelerated`] and not
5382 /// `interleaved_gemm_is_accelerated`, because those are different
5383 /// questions and this one asked the narrower of the two. The
5384 /// interleave-8 predicate is about the quad kernels; a pre-i8mm
5385 /// aarch64 host runs a width-4 `dotprod` GEMM for four of the five
5386 /// kinds, so it has a SIMD batch GEMM while the interleave-8
5387 /// predicate says it does not.
5388 #[test]
5389 fn the_batch_half_is_taken_only_where_a_simd_gemm_answers_for_it() {
5390 assert_eq!(
5391 super::int_dot_tier_here().batch_gemm,
5392 ferrox_quant::batch_gemm_is_accelerated(ferrox_quant::preferred_interleave())
5393 && cfg!(any(target_arch = "aarch64", target_arch = "x86_64")),
5394 "the batch half must agree with the kernel probe, not with a written-down list"
5395 );
5396 }
5397
5398 /// The Q5_K batch gate asks the kernels: wherever the Q5_K `x4`
5399 /// GEMM has a SIMD kernel the Kx8 path is taken, whatever the
5400 /// architecture. A `cfg!` alone here is the defect this test exists
5401 /// for (8.56x on x86 prefill, 2026-09-15).
5402 #[test]
5403 fn the_q5k_batch_path_is_taken_wherever_its_simd_gemm_exists() {
5404 let interleave = ferrox_quant::q5_kx8_interleave();
5405 if ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
5406 assert!(super::q5k_batch_takes_kx8(interleave));
5407 }
5408 // And on a host with no such kernel, the gate agrees with the
5409 // Q4_K arm's rule, which is "aarch64 always".
5410 assert!(super::q5k_batch_takes_kx8(interleave) || !cfg!(target_arch = "aarch64"));
5411 }
5412
5413 /// The two probes are not interchangeable, and this pins the
5414 /// difference so neither can quietly be swapped for the other.
5415 ///
5416 /// On a host with a width-4 SIMD batch GEMM and no interleave-8 one
5417 /// — every pre-i8mm aarch64 host, which is every M1 Mac, every
5418 /// A14-and-earlier iPhone, and the Cortex-A55-class cores that are
5419 /// still most of the Android fleet — the wider predicate says yes
5420 /// and the narrower says no. Reading the narrower as "is the batch
5421 /// tier a win" is what made this test fail on an M1 while the code
5422 /// was running a SIMD GEMM the whole time.
5423 #[test]
5424 fn the_batch_probe_is_wider_than_the_interleave_8_one() {
5425 for width in [4usize, 8] {
5426 assert!(
5427 ferrox_quant::batch_gemm_is_accelerated(width)
5428 || !ferrox_quant::interleaved_gemm_is_accelerated(width),
5429 "the batch probe must answer yes wherever the interleave-8 one does"
5430 );
5431 }
5432 #[cfg(target_arch = "aarch64")]
5433 if std::arch::is_aarch64_feature_detected!("dotprod") {
5434 assert!(
5435 ferrox_quant::batch_gemm_is_accelerated(4),
5436 "a dotprod host runs the width-4 sdot GEMM for Q4_K/Q5_K/Q8_0/Q4_0"
5437 );
5438 assert!(
5439 !ferrox_quant::interleaved_gemm_is_accelerated(4),
5440 "the interleave-8 predicate is about the quad kernels only"
5441 );
5442 }
5443 }
5444
5445 /// `int_dot_is_a_win_here` — the thing `default_cpu_int_dot_on`
5446 /// consults — is the OR of the two halves, so a host with only the
5447 /// batch half still gets the env default it needs to reach it.
5448 #[test]
5449 fn the_default_is_on_when_either_half_is_a_win() {
5450 let tier = super::int_dot_tier_here();
5451 assert_eq!(
5452 super::int_dot_is_a_win_here(),
5453 tier.matvec || tier.batch_gemm
5454 );
5455 }
5456
5457 /// `covers` must actually separate the two shapes, in both
5458 /// directions — otherwise every call site below asks a question with
5459 /// one answer and the split is decoration.
5460 #[test]
5461 fn covers_answers_per_shape_rather_than_per_host() {
5462 let matvec_only = IntDotTier {
5463 matvec: true,
5464 batch_gemm: false,
5465 };
5466 let batch_only = IntDotTier {
5467 matvec: false,
5468 batch_gemm: true,
5469 };
5470 assert!(matvec_only.covers(IntDotShape::Matvec));
5471 assert!(!matvec_only.covers(IntDotShape::BatchGemm));
5472 assert!(!batch_only.covers(IntDotShape::Matvec));
5473 assert!(batch_only.covers(IntDotShape::BatchGemm));
5474 }
5475}