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