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 crate::activation_tap::observe(self, x, 1);
1119 #[cfg(feature = "cuda")]
1120 {
1121 if cuda_dense_enabled() {
1122 if let Some(out) = self.apply_gpu(x) {
1123 return out;
1124 }
1125 }
1126 }
1127 #[cfg(feature = "metal")]
1128 {
1129 if metal_dense_enabled() {
1130 if let Some(out) = self.apply_gpu(x) {
1131 return out;
1132 }
1133 }
1134 }
1135 self.apply_cpu(x)
1136 }
1137
1138 /// CPU-only matvec (NEON/AVX/scalar via `ferrox-quant`). Used by
1139 /// [`Self::apply`] after Metal miss/disable, and by GPU parity tests
1140 /// that must not recurse into [`Self::apply_gpu`].
1141 /// Applies three independent matrices to the same activation,
1142 /// overlapping their parallel regions instead of running them one
1143 /// after another.
1144 ///
1145 /// Decode opens one rayon fork-join per weight matrix -- roughly
1146 /// seven per layer -- and the measured CPU decode deficit is
1147 /// scheduling, not kernels (ferrox scales 1.40x/2.93x from 1 to 6
1148 /// threads where llama.cpp scales 1.99x/4.39x, while *beating* llama
1149 /// at one thread). q/k/v share an input and are independent, so
1150 /// their regions can coexist and let rayon's work-stealing fill
1151 /// threads that would otherwise idle at the tail of each one.
1152 ///
1153 /// Under [`crate::par::Backend::Spin`] the three run one after the
1154 /// other instead: each already spreads across the whole persistent
1155 /// pool, and the reason to overlap them was to hide a fork-join that
1156 /// the persistent pool does not pay. That choice lives in
1157 /// [`crate::par::join3`], not here, so it cannot drift from the one
1158 /// in `ferrox-moe`'s gate/up pair.
1159 ///
1160 /// CPU only. On a GPU backend each `apply` submits and waits on its
1161 /// own command buffer, and Metal decode is already at or ahead of
1162 /// parity -- there is nothing to win and a live path to disturb.
1163 pub fn apply_three(a: &Self, b: &Self, c: &Self, x: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
1164 #[cfg(feature = "metal")]
1165 let gpu = metal_dense_enabled();
1166 #[cfg(not(feature = "metal"))]
1167 let gpu = false;
1168 #[cfg(feature = "cuda")]
1169 let gpu = gpu || cuda_dense_enabled();
1170 if gpu {
1171 return (a.apply(x), b.apply(x), c.apply(x));
1172 }
1173 crate::par::join3(|| a.apply(x), || b.apply(x), || c.apply(x))
1174 }
1175
1176 pub fn apply_cpu(&self, x: &[f32]) -> Vec<f32> {
1177 assert_eq!(
1178 x.len(),
1179 self.cols(),
1180 "activation length must match matrix column count"
1181 );
1182 // Decode: one activation, so this operation is `rows x cols`
1183 // MACs and a task's share of it is (rows in task) x cols.
1184 // Publishing the shape is what lets both the scheduler choice
1185 // and the task floor be work-aware rather than row-count-aware.
1186 crate::par::with_op_work(self.rows(), x.len(), || self.apply_cpu_inner(x))
1187 }
1188
1189 fn apply_cpu_inner(&self, x: &[f32]) -> Vec<f32> {
1190 match self {
1191 WeightMatrix::F32(t) => {
1192 let xt = Tensor::new(x.to_vec(), vec![1, x.len()]);
1193 crate::matmul::matmul_f32(&xt, t).data
1194 }
1195 WeightMatrix::Quantized {
1196 data,
1197 rows,
1198 cols,
1199 kind,
1200 } => {
1201 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1202 let mut out = vec![0f32; *rows];
1203 // FERROX_CPU_INT_DOT=1: quantize the shared activation once,
1204 // then every row dot is int8×int8 → i32 (llama.cpp CPU matmul).
1205 // Q8_0/Q4_0 use 32-elem Q8_0 acts; Q4_K/Q5_K/Q6_K use Q8_K.
1206 if cpu_int_dot_for(IntDotShape::Matvec) {
1207 match *kind {
1208 QuantKind::Q8_0 if x.len().is_multiple_of(32) => {
1209 let act = ferrox_quant::quantize_activations_q8(x);
1210 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1211 let serial = Self::prefer_serial_matvec(*rows, *cols);
1212 // Probed once per matvec, not once per row-group:
1213 // `q*_interleave` reads a CPU feature bit, and LLVM
1214 // cannot hoist that relaxed atomic load out of the
1215 // caller's loop. `is_aarch64_feature_detected!` ran
1216 // 131k times in one Mistral-7B projection before the
1217 // last one of these was hoisted.
1218 let interleave = ferrox_quant::q8_0x4_interleave();
1219 if n_groups > 0 {
1220 let packed = get_or_repack_q8x4(data, *rows, *cols);
1221 if serial {
1222 for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1223 .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1224 .enumerate()
1225 {
1226 ferrox_quant::gemv_q8_0x4_group(
1227 &packed, g, &act, *cols, interleave, chunk,
1228 );
1229 }
1230 } else {
1231 crate::par::chunks_mut(
1232 &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1233 ferrox_quant::Q8_0X4_NROWS,
1234 Self::min_rows_per_task(n_groups).max(1),
1235 |g, chunk| {
1236 ferrox_quant::gemv_q8_0x4_group(
1237 &packed, g, &act, *cols, interleave, chunk,
1238 );
1239 },
1240 );
1241 }
1242 let data_slice = data.as_slice();
1243 let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1244 if tail_len > 0 {
1245 let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1246 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1247 for (i, o) in tail.iter_mut().enumerate() {
1248 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1249 let row =
1250 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1251 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1252 }
1253 } else {
1254 let min_len = Self::min_rows_per_task(tail_len);
1255 crate::par::items_mut(tail, min_len, |i, o| {
1256 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1257 let row =
1258 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1259 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1260 });
1261 }
1262 }
1263 return out;
1264 }
1265 if serial {
1266 for (r, o) in out.iter_mut().enumerate() {
1267 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1268 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1269 }
1270 } else {
1271 crate::par::items_mut(
1272 &mut out,
1273 Self::min_rows_per_task(*rows),
1274 |r, o| {
1275 let row =
1276 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1277 *o = ferrox_quant::dot_q8_0_q8(row, &act);
1278 },
1279 );
1280 }
1281 return out;
1282 }
1283 QuantKind::Q4_0 if x.len().is_multiple_of(32) => {
1284 let act = ferrox_quant::quantize_activations_q8(x);
1285 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1286 let serial = Self::prefer_serial_matvec(*rows, *cols);
1287 // Probed once per matvec, not once per row-group:
1288 // `q*_interleave` reads a CPU feature bit, and LLVM
1289 // cannot hoist that relaxed atomic load out of the
1290 // caller's loop. `is_aarch64_feature_detected!` ran
1291 // 131k times in one Mistral-7B projection before the
1292 // last one of these was hoisted.
1293 let interleave = ferrox_quant::q4_0x4_interleave();
1294 if n_groups > 0 {
1295 let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1296 if serial {
1297 for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1298 .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1299 .enumerate()
1300 {
1301 ferrox_quant::gemv_q4_0x4_group(
1302 &packed, g, &act, *cols, interleave, chunk,
1303 );
1304 }
1305 } else {
1306 crate::par::chunks_mut(
1307 &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1308 ferrox_quant::Q4_0X4_NROWS,
1309 Self::min_rows_per_task(n_groups).max(1),
1310 |g, chunk| {
1311 ferrox_quant::gemv_q4_0x4_group(
1312 &packed, g, &act, *cols, interleave, chunk,
1313 );
1314 },
1315 );
1316 }
1317 let data_slice = data.as_slice();
1318 let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1319 if tail_len > 0 {
1320 let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1321 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1322 for (i, o) in tail.iter_mut().enumerate() {
1323 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1324 let row =
1325 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1326 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1327 }
1328 } else {
1329 let min_len = Self::min_rows_per_task(tail_len);
1330 crate::par::items_mut(tail, min_len, |i, o| {
1331 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1332 let row =
1333 &data_slice[r * row_bytes..(r + 1) * row_bytes];
1334 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1335 });
1336 }
1337 }
1338 return out;
1339 }
1340 if serial {
1341 for (r, o) in out.iter_mut().enumerate() {
1342 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1343 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1344 }
1345 } else {
1346 crate::par::items_mut(
1347 &mut out,
1348 Self::min_rows_per_task(*rows),
1349 |r, o| {
1350 let row =
1351 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1352 *o = ferrox_quant::dot_q4_0_q8(row, &act);
1353 },
1354 );
1355 }
1356 return out;
1357 }
1358 QuantKind::Q4K if x.len().is_multiple_of(256) => {
1359 let act = ferrox_quant::quantize_activations_q8_k(x);
1360 let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
1361 if n_groups > 0 {
1362 let interleave = ferrox_quant::q4_kx8_interleave();
1363 let packed = get_or_repack_q4k(data, *rows, *cols);
1364 crate::par::chunks_mut(
1365 &mut out[..n_groups * ferrox_quant::Q4_KX8_NROWS],
1366 ferrox_quant::Q4_KX8_NROWS,
1367 Self::min_rows_per_task(n_groups).max(1),
1368 |g, chunk| {
1369 ferrox_quant::gemv_q4_kx8_group(
1370 &packed, g, &act, *cols, interleave, chunk,
1371 );
1372 },
1373 );
1374 let data_slice = data.as_slice();
1375 crate::par::items_mut(
1376 &mut out[n_groups * ferrox_quant::Q4_KX8_NROWS..],
1377 Self::min_rows_per_task(
1378 *rows - n_groups * ferrox_quant::Q4_KX8_NROWS,
1379 ),
1380 |i, o| {
1381 let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
1382 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1383 *o = ferrox_quant::dot_q4_k_q8(row, &act);
1384 },
1385 );
1386 return out;
1387 }
1388 crate::par::items_mut(
1389 &mut out,
1390 Self::min_rows_per_task(*rows),
1391 |r, o| {
1392 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1393 *o = ferrox_quant::dot_q4_k_q8(row, &act);
1394 },
1395 );
1396 return out;
1397 }
1398 QuantKind::Q5K if x.len().is_multiple_of(256) => {
1399 let act = ferrox_quant::quantize_activations_q8_k(x);
1400 let n_groups = *rows / ferrox_quant::Q5_KX8_NROWS;
1401 if n_groups > 0 {
1402 let interleave = ferrox_quant::q5_kx8_interleave();
1403 let packed = get_or_repack_q5k(data, *rows, *cols);
1404 crate::par::chunks_mut(
1405 &mut out[..n_groups * ferrox_quant::Q5_KX8_NROWS],
1406 ferrox_quant::Q5_KX8_NROWS,
1407 Self::min_rows_per_task(n_groups).max(1),
1408 |g, chunk| {
1409 ferrox_quant::gemv_q5_kx8_group(
1410 &packed, g, &act, *cols, interleave, chunk,
1411 );
1412 },
1413 );
1414 let data_slice = data.as_slice();
1415 crate::par::items_mut(
1416 &mut out[n_groups * ferrox_quant::Q5_KX8_NROWS..],
1417 Self::min_rows_per_task(
1418 *rows - n_groups * ferrox_quant::Q5_KX8_NROWS,
1419 ),
1420 |i, o| {
1421 let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
1422 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1423 *o = ferrox_quant::dot_q5_k_q8(row, &act);
1424 },
1425 );
1426 return out;
1427 }
1428 crate::par::items_mut(
1429 &mut out,
1430 Self::min_rows_per_task(*rows),
1431 |r, o| {
1432 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1433 *o = ferrox_quant::dot_q5_k_q8(row, &act);
1434 },
1435 );
1436 return out;
1437 }
1438 QuantKind::Q6K if x.len().is_multiple_of(256) => {
1439 let act = ferrox_quant::quantize_activations_q8_k(x);
1440 let n_groups = *rows / ferrox_quant::Q6_KX8_NROWS;
1441 if n_groups > 0 {
1442 let interleave = ferrox_quant::q6_kx8_interleave();
1443 let packed = get_or_repack_q6k(data, *rows, *cols);
1444 crate::par::chunks_mut(
1445 &mut out[..n_groups * ferrox_quant::Q6_KX8_NROWS],
1446 ferrox_quant::Q6_KX8_NROWS,
1447 Self::min_rows_per_task(n_groups).max(1),
1448 |g, out8| {
1449 ferrox_quant::gemv_q6_kx8_group(
1450 &packed, g, &act, *cols, interleave, out8,
1451 );
1452 },
1453 );
1454 crate::par::items_mut(
1455 &mut out[n_groups * ferrox_quant::Q6_KX8_NROWS..],
1456 Self::min_rows_per_task(
1457 *rows - n_groups * ferrox_quant::Q6_KX8_NROWS,
1458 ),
1459 |i, o| {
1460 let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
1461 let row =
1462 &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1463 *o = ferrox_quant::dot_q6_k_q8(row, &act);
1464 },
1465 );
1466 return out;
1467 }
1468 crate::par::items_mut(
1469 &mut out,
1470 Self::min_rows_per_task(*rows),
1471 |r, o| {
1472 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1473 *o = ferrox_quant::dot_q6_k_q8(row, &act);
1474 },
1475 );
1476 return out;
1477 }
1478 _ => {}
1479 }
1480 }
1481 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1482 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1483 *o = Self::dot(*kind, row, x);
1484 });
1485 out
1486 }
1487 WeightMatrix::Mxfp4 {
1488 packed,
1489 scale,
1490 rows,
1491 cols,
1492 } => {
1493 let packed_row_bytes = cols / 2;
1494 let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
1495 let mut out = vec![0f32; *rows];
1496 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1497 let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
1498 let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
1499 *o = ferrox_quant::dot_mxfp4_row_f32(prow, srow, x);
1500 });
1501 out
1502 }
1503 }
1504 }
1505
1506 /// INT_DOT matvec against a pre-quantized Q8_0 activation (shared gate/up).
1507 ///
1508 /// Publishes this operation's shape for exactly the same reason
1509 /// [`Self::apply_cpu`] does, and it matters more here: the dense FFN
1510 /// gate and up projections are the widest matvecs in a decode step,
1511 /// so they are the ones the scheduler rule is deciding about.
1512 pub fn apply_cpu_q8(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1513 crate::par::with_op_work(self.rows(), self.cols(), || self.apply_cpu_q8_inner(act))
1514 }
1515
1516 /// [`Self::apply_cpu_q8`] with the operation's shape already
1517 /// published. Split only so the publish wraps every return path.
1518 fn apply_cpu_q8_inner(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1519 let WeightMatrix::Quantized {
1520 data,
1521 rows,
1522 cols,
1523 kind,
1524 } = self
1525 else {
1526 return None;
1527 };
1528 if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1529 || !cpu_int_dot_for(IntDotShape::Matvec)
1530 {
1531 return None;
1532 }
1533 if act.q.len() != *cols || !cols.is_multiple_of(32) {
1534 return None;
1535 }
1536 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1537 let mut out = vec![0f32; *rows];
1538 let kind = *kind;
1539 let bytes = data.as_slice();
1540 // Q8_0×4 / Q4_0×4 interleaved GEMV — same paths as `apply_cpu` so
1541 // dense FFN gate+up hit the fast kernels, not per-row int dots.
1542 if matches!(kind, QuantKind::Q8_0) {
1543 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1544 if n_groups > 0 {
1545 let packed = get_or_repack_q8x4(data, *rows, *cols);
1546 let serial = Self::prefer_serial_matvec(*rows, *cols);
1547 // Probed once per matvec, not once per row-group:
1548 // `q*_interleave` reads a CPU feature bit, and LLVM
1549 // cannot hoist that relaxed atomic load out of the
1550 // caller's loop. `is_aarch64_feature_detected!` ran
1551 // 131k times in one Mistral-7B projection before the
1552 // last one of these was hoisted.
1553 let interleave = ferrox_quant::q8_0x4_interleave();
1554 let body = |g: usize, chunk: &mut [f32]| {
1555 ferrox_quant::gemv_q8_0x4_group(&packed, g, act, *cols, interleave, chunk);
1556 };
1557 if serial {
1558 for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1559 .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1560 .enumerate()
1561 {
1562 body(g, chunk);
1563 }
1564 } else {
1565 crate::par::chunks_mut(
1566 &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1567 ferrox_quant::Q8_0X4_NROWS,
1568 Self::min_rows_per_task(n_groups).max(1),
1569 |g, chunk| body(g, chunk),
1570 );
1571 }
1572 let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1573 if tail_len > 0 {
1574 let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1575 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1576 for (i, o) in tail.iter_mut().enumerate() {
1577 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1578 *o = ferrox_quant::dot_q8_0_q8(
1579 &bytes[r * row_bytes..(r + 1) * row_bytes],
1580 act,
1581 );
1582 }
1583 } else {
1584 let min_len = Self::min_rows_per_task(tail_len);
1585 crate::par::items_mut(tail, min_len, |i, o| {
1586 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1587 *o = ferrox_quant::dot_q8_0_q8(
1588 &bytes[r * row_bytes..(r + 1) * row_bytes],
1589 act,
1590 );
1591 });
1592 }
1593 }
1594 return Some(out);
1595 }
1596 }
1597 if matches!(kind, QuantKind::Q4_0) {
1598 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1599 if n_groups > 0 {
1600 let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1601 let serial = Self::prefer_serial_matvec(*rows, *cols);
1602 // Probed once per matvec, not once per row-group:
1603 // `q*_interleave` reads a CPU feature bit, and LLVM
1604 // cannot hoist that relaxed atomic load out of the
1605 // caller's loop. `is_aarch64_feature_detected!` ran
1606 // 131k times in one Mistral-7B projection before the
1607 // last one of these was hoisted.
1608 let interleave = ferrox_quant::q4_0x4_interleave();
1609 let body = |g: usize, chunk: &mut [f32]| {
1610 ferrox_quant::gemv_q4_0x4_group(&packed, g, act, *cols, interleave, chunk);
1611 };
1612 if serial {
1613 for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1614 .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1615 .enumerate()
1616 {
1617 body(g, chunk);
1618 }
1619 } else {
1620 crate::par::chunks_mut(
1621 &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1622 ferrox_quant::Q4_0X4_NROWS,
1623 Self::min_rows_per_task(n_groups).max(1),
1624 |g, chunk| body(g, chunk),
1625 );
1626 }
1627 let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1628 if tail_len > 0 {
1629 let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1630 if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1631 for (i, o) in tail.iter_mut().enumerate() {
1632 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1633 *o = ferrox_quant::dot_q4_0_q8(
1634 &bytes[r * row_bytes..(r + 1) * row_bytes],
1635 act,
1636 );
1637 }
1638 } else {
1639 let min_len = Self::min_rows_per_task(tail_len);
1640 crate::par::items_mut(tail, min_len, |i, o| {
1641 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1642 *o = ferrox_quant::dot_q4_0_q8(
1643 &bytes[r * row_bytes..(r + 1) * row_bytes],
1644 act,
1645 );
1646 });
1647 }
1648 }
1649 return Some(out);
1650 }
1651 }
1652 if Self::prefer_serial_matvec(*rows, *cols) {
1653 for (r, o) in out.iter_mut().enumerate() {
1654 let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
1655 *o = match kind {
1656 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
1657 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
1658 _ => unreachable!(),
1659 };
1660 }
1661 return Some(out);
1662 }
1663 crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1664 let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
1665 *o = match kind {
1666 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
1667 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
1668 _ => unreachable!(),
1669 };
1670 });
1671 Some(out)
1672 }
1673
1674 /// Two contiguous rows × one Q8 act (shared act loads). Q4_0 uses
1675 /// [`ferrox_quant::dot_q4_0_q8_2row`]; Q8_0 falls back to two singles.
1676 pub fn dot_pair_cpu_q8(
1677 &self,
1678 row: usize,
1679 act: &ferrox_quant::Q8Activations,
1680 ) -> Option<(f32, f32)> {
1681 let WeightMatrix::Quantized {
1682 data,
1683 rows,
1684 cols,
1685 kind,
1686 } = self
1687 else {
1688 return None;
1689 };
1690 if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1691 || !cpu_int_dot_for(IntDotShape::Matvec)
1692 {
1693 return None;
1694 }
1695 if act.q.len() != *cols || !cols.is_multiple_of(32) || row + 1 >= *rows {
1696 return None;
1697 }
1698 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1699 let bytes = data.as_slice();
1700 let r0 = &bytes[row * row_bytes..(row + 1) * row_bytes];
1701 let r1 = &bytes[(row + 1) * row_bytes..(row + 2) * row_bytes];
1702 Some(match *kind {
1703 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8_2row(r0, r1, act),
1704 QuantKind::Q8_0 => (
1705 ferrox_quant::dot_q8_0_q8(r0, act),
1706 ferrox_quant::dot_q8_0_q8(r1, act),
1707 ),
1708 _ => unreachable!(),
1709 })
1710 }
1711
1712 /// Single-row INT_DOT against pre-quantized Q8_0 acts (llama `mul_mat_id`
1713 /// inner loop). Returns `None` if this matrix is not Q4_0/Q8_0 INT_DOT.
1714 pub fn dot_row_cpu_q8(&self, row: usize, act: &ferrox_quant::Q8Activations) -> Option<f32> {
1715 let WeightMatrix::Quantized {
1716 data,
1717 rows,
1718 cols,
1719 kind,
1720 } = self
1721 else {
1722 return None;
1723 };
1724 if row >= *rows
1725 || !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1726 || !cpu_int_dot_for(IntDotShape::Matvec)
1727 || act.q.len() != *cols
1728 || !cols.is_multiple_of(32)
1729 {
1730 return None;
1731 }
1732 let row_bytes = self.block_bytes_per_row(*kind, *cols);
1733 let bytes = &data.as_slice()[row * row_bytes..(row + 1) * row_bytes];
1734 Some(match *kind {
1735 QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(bytes, act),
1736 QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(bytes, act),
1737 _ => unreachable!(),
1738 })
1739 }
1740
1741 /// Computes `W @ X` for a *batch* of activation vectors at once:
1742 /// `x_batch` is `batch_size` rows of `self.cols()` elements each,
1743 /// flattened row-major; returns `batch_size` rows of
1744 /// `self.rows()` elements each, flattened row-major (`[batch,
1745 /// rows]`, matching the layout `Tensor`/`Decoder` expect for
1746 /// chaining into further matmuls).
1747 ///
1748 /// This is not just a convenience wrapper: for a quantized matrix,
1749 /// each weight row's bytes are read from memory *once* and dotted
1750 /// against every activation in the batch, instead of once per
1751 /// `apply` call. For a memory-bandwidth-bound quantized matmul --
1752 /// which fused Q8_0/Q4_0 dot products are, since the whole point of
1753 /// keeping weights quantized is that reading them is the
1754 /// bottleneck, not the arithmetic -- processing `batch_size`
1755 /// positions this way costs roughly the same *memory traffic* as
1756 /// processing one position, not `batch_size` times as much. This
1757 /// is the same reason speculative-decoding verification and batched
1758 /// prefill are faster per-token than sequential single-token decode
1759 /// on real hardware: it turns `batch_size` separate reads of the
1760 /// same weights into one.
1761 ///
1762 /// With Metal dense enabled, dispatches a single batched Metal
1763 /// command buffer — Q4_0/Q4_K/Q6_K/Q8_0 reuse the weights through a
1764 /// simdgroup `mul_mm` at `batch_size >= 4`; every other kind, and
1765 /// every smaller batch, uses
1766 /// [`ferrox_metal::gpu::launch_matvec_batch`]. Falls back to
1767 /// per-row [`Self::apply`] if the batch launch fails.
1768 pub fn apply_batch(&self, x_batch: &[f32], batch_size: usize) -> Vec<f32> {
1769 self.apply_batch_with_acts(x_batch, batch_size, None)
1770 }
1771
1772 /// Quantize `x_batch` once, in the activation format this matrix's
1773 /// INT_DOT batch path consumes, for sharing across every projection
1774 /// that reads the same input (q/k/v on one normed batch; gate/up on
1775 /// another). Returns `None` when [`Self::apply_batch`] would not use
1776 /// quantized activations for this matrix — GPU dispatch, INT_DOT off,
1777 /// unsupported kind or width — so callers can pass the result straight
1778 /// to [`Self::apply_batch_with_acts`] unconditionally.
1779 pub fn quantize_batch_acts(&self, x_batch: &[f32], batch_size: usize) -> Option<BatchActs> {
1780 #[cfg(feature = "metal")]
1781 {
1782 if metal_dense_enabled()
1783 && matches!(
1784 self,
1785 WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
1786 )
1787 {
1788 return None;
1789 }
1790 }
1791 #[cfg(feature = "cuda")]
1792 {
1793 if cuda_dense_enabled() && matches!(self, WeightMatrix::Quantized { .. }) {
1794 return None;
1795 }
1796 }
1797 let WeightMatrix::Quantized { cols, kind, .. } = self else {
1798 return None;
1799 };
1800 if !cpu_int_dot_for(IntDotShape::BatchGemm) || x_batch.len() != batch_size * cols {
1801 return None;
1802 }
1803 let cols = *cols;
1804 match kind {
1805 QuantKind::Q8_0 | QuantKind::Q4_0 if cols.is_multiple_of(32) => {
1806 let acts: Vec<_> = (0..batch_size)
1807 .into_par_iter()
1808 .map(|b| {
1809 ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols])
1810 })
1811 .collect();
1812 // Q8_0 and Q4_0 agree on both the interleave width and the
1813 // predicate, so one tile set serves either consumer.
1814 let tiles =
1815 if ferrox_quant::q8_0x4_gemm_uses_acts_x4(ferrox_quant::q8_0x4_interleave()) {
1816 acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
1817 .map(|chunk| ferrox_quant::prepare_q8_acts_x4(chunk, cols))
1818 .collect()
1819 } else {
1820 Vec::new()
1821 };
1822 Some(BatchActs::Q8 { acts, tiles, cols })
1823 }
1824 QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K if cols.is_multiple_of(256) => {
1825 let acts: Vec<_> = (0..batch_size)
1826 .into_par_iter()
1827 .map(|b| {
1828 ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols])
1829 })
1830 .collect();
1831 // All three K-quants share the predicate and the quad
1832 // width, so the set a Q4_K gate builds is exactly what a
1833 // Q5_K or Q6_K sibling would have built for itself.
1834 let tiles =
1835 if ferrox_quant::q4_kx8_gemm_uses_acts_x4(ferrox_quant::q4_kx8_interleave()) {
1836 acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
1837 .map(|chunk| ferrox_quant::prepare_q8_k_acts_x4(chunk, cols))
1838 .collect()
1839 } else {
1840 Vec::new()
1841 };
1842 Some(BatchActs::Q8K { acts, tiles, cols })
1843 }
1844 _ => None,
1845 }
1846 }
1847
1848 /// [`Self::apply_batch`], optionally reusing a shared pre-quantized
1849 /// activation batch from [`Self::quantize_batch_acts`]. A `shared`
1850 /// value whose format or length does not match this matrix is simply
1851 /// ignored (the activations are re-quantized locally), so mixed-kind
1852 /// projection groups stay correct.
1853 pub fn apply_batch_with_acts(
1854 &self,
1855 x_batch: &[f32],
1856 batch_size: usize,
1857 shared: Option<&BatchActs>,
1858 ) -> Vec<f32> {
1859 let cols = self.cols();
1860 assert_eq!(
1861 x_batch.len(),
1862 batch_size * cols,
1863 "x_batch length must be batch_size * cols"
1864 );
1865 if batch_size == 0 {
1866 return Vec::new();
1867 }
1868 crate::activation_tap::observe(self, x_batch, batch_size);
1869
1870 /// Raw pointer to this function's `[batch][rows]` output, shared
1871 /// across rayon tasks.
1872 ///
1873 /// Parallelism is over weight rows, but a row's `batch_size` output
1874 /// slots (`out[b * rows + r]` for every `b`) interleave with every
1875 /// other row's, so they cannot be handed out as disjoint `&mut`
1876 /// chunks. Each task writes only the rows it owns, which keeps the
1877 /// writes race-free; this wrapper just carries the pointer across
1878 /// the `Send`/`Sync` boundary. Writing straight into the final
1879 /// layout kills what used to be here: a `[rows][batch]` staging vec
1880 /// (zeroed every call) plus a serial rows × batch transpose after
1881 /// the parallel section had already finished.
1882 #[derive(Clone, Copy)]
1883 struct BatchOut(*mut f32);
1884 unsafe impl Send for BatchOut {}
1885 unsafe impl Sync for BatchOut {}
1886 impl BatchOut {
1887 /// Safety: `idx` in bounds, and concurrent tasks never pass
1888 /// the same `idx` (they own disjoint row sets).
1889 #[inline]
1890 unsafe fn set(self, idx: usize, v: f32) {
1891 *self.0.add(idx) = v;
1892 }
1893 }
1894
1895 #[cfg(feature = "metal")]
1896 {
1897 if metal_dense_enabled()
1898 && matches!(
1899 self,
1900 WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
1901 )
1902 {
1903 if let Some(out) = self.apply_gpu_batch(x_batch, batch_size) {
1904 return out;
1905 }
1906 // The kind is Metal-supported, so reaching here means a
1907 // launch failed and the batch degrades to `batch_size`
1908 // separate `apply` calls -- each its own command buffer,
1909 // commit and wait.
1910 crate::kernel_registry::miss(
1911 crate::kernel_registry::Lookup::new(
1912 crate::kernel_registry::Backend::Metal,
1913 crate::kernel_registry::op::GEMM_PREFILL,
1914 self.quant_kind(),
1915 ),
1916 "N x apply (one command buffer each)",
1917 );
1918 let rows = self.rows();
1919 let mut out = vec![0f32; batch_size * rows];
1920 for b in 0..batch_size {
1921 let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
1922 out[b * rows..(b + 1) * rows].copy_from_slice(&y);
1923 }
1924 return out;
1925 } else if metal_dense_enabled() {
1926 // Metal is on but this matrix has no Metal kernel at
1927 // all, so the whole GEMM runs on the CPU. For a
1928 // quantized weight that is the IQ4_XS shape exactly; for
1929 // an F32 one it is the documented host GEMM.
1930 let look = crate::kernel_registry::Lookup::new(
1931 crate::kernel_registry::Backend::Metal,
1932 crate::kernel_registry::op::GEMM_PREFILL,
1933 self.quant_kind(),
1934 );
1935 if self.quant_kind().is_some() {
1936 crate::kernel_registry::miss(look, "CPU apply_batch");
1937 } else {
1938 crate::kernel_registry::miss_by_design(look, "CPU f32 GEMM");
1939 }
1940 }
1941 }
1942
1943 // CUDA has a batched GEMM for every kind in
1944 // `ferrox_cuda::mul_mm::KINDS` (`cuda_mul_mm_kind_supported`),
1945 // and NO PART OF IT HAS RUN ON A GPU. Every other kind still
1946 // takes the per-position matvec loop below, which is the arm
1947 // that has -- for the six kinds that predate 2026-09-09.
1948 //
1949 // That loop is why this arm exists at all: without it a batched
1950 // prefill fell through to the CPU branch and never touched the
1951 // GPU -- measured on an RTX 4090, SmolLM2 `pp512` ran at 28
1952 // tok/s against llama.cpp's 57466. Per-position matvec is still
1953 // the wrong shape for a wide prefill, but it is the GPU rather
1954 // than 26 idle SMs, and the fallback now records a
1955 // `GEMM_PREFILL` miss instead of degrading silently.
1956 #[cfg(feature = "cuda")]
1957 {
1958 // The batched GEMM first, when the kind has one and the
1959 // batch is wide enough to pay for it. Below that threshold a
1960 // single token stays on the matvec kernels, which are the
1961 // arm that has actually run on a GPU.
1962 if cuda_dense_enabled() {
1963 if let WeightMatrix::Quantized { data, kind, .. } = self {
1964 if cuda_mul_mm_kind_supported(*kind)
1965 && ferrox_cuda::mul_mm::worth_a_gemm(batch_size)
1966 {
1967 let mm_kind = ferrox_cuda::mul_mm::kind_by_name(kind.name())
1968 .expect("cuda_mul_mm_kind_supported agreed");
1969 let row_bytes = self.block_bytes_per_row(*kind, cols);
1970 match ferrox_cuda::mul_mm_launch::launch_mul_mm(
1971 mm_kind,
1972 data.as_slice(),
1973 x_batch,
1974 self.rows(),
1975 cols,
1976 batch_size,
1977 row_bytes,
1978 ) {
1979 Ok(out) => return out,
1980 Err(_) => {
1981 // The kind HAS a GEMM, so reaching here is a
1982 // launch failure rather than an unsupported
1983 // kind, and the batch degrades to per-position
1984 // matvecs. This call site used to be the one
1985 // SILENT fallback in the registry's table.
1986 crate::kernel_registry::miss(
1987 crate::kernel_registry::Lookup::new(
1988 crate::kernel_registry::Backend::Cuda,
1989 crate::kernel_registry::op::GEMM_PREFILL,
1990 self.quant_kind(),
1991 ),
1992 "N x matvec (the GEMM launch failed)",
1993 );
1994 }
1995 }
1996 }
1997 }
1998 }
1999 if cuda_dense_enabled()
2000 && matches!(self, WeightMatrix::Quantized { .. })
2001 && self.apply_gpu(&x_batch[..cols]).is_some()
2002 {
2003 let rows = self.rows();
2004 let mut out = vec![0f32; batch_size * rows];
2005 for b in 0..batch_size {
2006 match self.apply_gpu(&x_batch[b * cols..(b + 1) * cols]) {
2007 Some(y) => out[b * rows..(b + 1) * rows].copy_from_slice(&y),
2008 None => {
2009 let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2010 out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2011 }
2012 }
2013 }
2014 return out;
2015 }
2016 }
2017
2018 match self {
2019 WeightMatrix::F32(t) => {
2020 let xt = Tensor::new(x_batch.to_vec(), vec![batch_size, cols]);
2021 crate::matmul::matmul_f32(&xt, t).data
2022 }
2023 WeightMatrix::Quantized {
2024 data,
2025 rows,
2026 cols: _,
2027 kind,
2028 } => {
2029 let row_bytes = self.block_bytes_per_row(*kind, cols);
2030 // Written directly in the [batch, rows] layout the function
2031 // returns: each parallel task owns a disjoint set of rows
2032 // `r` and scatters `out[b * rows + r]` for every `b`
2033 // through `BatchOut`.
2034 let mut out = vec![0f32; batch_size * rows];
2035 let out_w = BatchOut(out.as_mut_ptr());
2036
2037 // Prefill INT_DOT: quantize each activation once, then
2038 // reuse Q8 packs across all weight rows (llama CPU path).
2039 if cpu_int_dot_for(IntDotShape::BatchGemm) {
2040 match *kind {
2041 QuantKind::Q8_0 if cols.is_multiple_of(32) => {
2042 let mut acts_owned = Vec::new();
2043 let (acts, shared_tiles) =
2044 Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2045 let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
2046 if n_groups > 0 {
2047 let packed = get_or_repack_q8x4(data, *rows, cols);
2048 let nrows_g = ferrox_quant::Q8_0X4_NROWS;
2049 let interleave = ferrox_quant::q8_0x4_interleave();
2050 if ferrox_quant::q8_0x4_gemm_uses_acts_x4(interleave) {
2051 // i8mm: interleave each quad of
2052 // activations once per matmul (llama.cpp
2053 // `ggml_quantize_mat_q8_0_4x8` into
2054 // `wdata`); every row-group reuses it.
2055 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2056 let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2057 let act_tiles: &[ferrox_quant::Q8ActsX4] =
2058 if shared_tiles.is_empty() {
2059 tiles_owned = acts
2060 .par_chunks(nc)
2061 .map(|chunk| {
2062 ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2063 })
2064 .collect();
2065 &tiles_owned
2066 } else {
2067 shared_tiles
2068 };
2069 // One runtime i8mm probe per matmul, not
2070 // one per (row-group x quad); see
2071 // `ferrox_quant::AccelX4`.
2072 let accel = ferrox_quant::AccelX4::detect();
2073 Self::par_chunked_groups(
2074 n_groups,
2075 nrows_g,
2076 act_tiles.len(),
2077 nc,
2078 |g, t0, t1| {
2079 let mut tmp = [0f32;
2080 ferrox_quant::Q8_0X4_NROWS
2081 * ferrox_quant::Q8K_ACTS_X4_NC];
2082 for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2083 let t = t0 + t;
2084 let n = tile.na;
2085 let tmp = &mut tmp[..nrows_g * n];
2086 ferrox_quant::gemm_q8_0x4_group_x4_on(
2087 &packed, g, tile, cols, interleave, accel, tmp,
2088 );
2089 for j in 0..n {
2090 let col = (t * nc + j) * rows + g * nrows_g;
2091 for r in 0..nrows_g {
2092 unsafe {
2093 out_w.set(col + r, tmp[r * n + j]);
2094 }
2095 }
2096 }
2097 }
2098 },
2099 );
2100 } else {
2101 // GEMM, not a GEMV per position: the
2102 // batched kernel writes a `[row][batch]`
2103 // span, and the group's weight vectors
2104 // stay in registers across a tile of
2105 // activations. The span is then scattered
2106 // into the [batch][rows] output right
2107 // here, in parallel.
2108 let span = ferrox_quant::Q8_0X4_GEMM_NC;
2109 let n_tiles = batch_size.div_ceil(span);
2110 Self::par_chunked_groups(
2111 n_groups,
2112 nrows_g,
2113 n_tiles,
2114 span,
2115 |g, t0, t1| {
2116 let b0 = t0 * span;
2117 let b1 = (t1 * span).min(batch_size);
2118 let n = b1 - b0;
2119 let mut group = vec![0f32; nrows_g * n];
2120 ferrox_quant::gemm_q8_0x4_group(
2121 &packed,
2122 g,
2123 &acts[b0..b1],
2124 cols,
2125 interleave,
2126 &mut group,
2127 );
2128 for (bi, b) in (b0..b1).enumerate() {
2129 for r in 0..nrows_g {
2130 unsafe {
2131 out_w.set(
2132 b * rows + g * nrows_g + r,
2133 group[r * n + bi],
2134 );
2135 }
2136 }
2137 }
2138 },
2139 );
2140 }
2141 let data_slice = data.as_slice();
2142 let tail = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
2143 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2144 let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
2145 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2146 for (b, act) in acts.iter().enumerate() {
2147 unsafe {
2148 out_w.set(
2149 b * rows + r,
2150 ferrox_quant::dot_q8_0_q8(row, act),
2151 );
2152 }
2153 }
2154 });
2155 } else {
2156 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2157 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2158 for (b, act) in acts.iter().enumerate() {
2159 unsafe {
2160 out_w.set(
2161 b * rows + r,
2162 ferrox_quant::dot_q8_0_q8(row, act),
2163 );
2164 }
2165 }
2166 });
2167 }
2168 return out;
2169 }
2170 QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2171 let mut acts_owned = Vec::new();
2172 let (acts, shared_tiles) =
2173 Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2174 let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
2175 if n_groups > 0 {
2176 let packed = get_or_repack_q4_0x4(data, *rows, cols);
2177 let nrows_g = ferrox_quant::Q4_0X4_NROWS;
2178 let interleave = ferrox_quant::q4_0x4_interleave();
2179 if ferrox_quant::q4_0x4_gemm_uses_acts_x4(interleave) {
2180 // i8mm: same once-per-matmul activation
2181 // quad hoist as the Q8_0 arm above.
2182 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2183 let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2184 let act_tiles: &[ferrox_quant::Q8ActsX4] =
2185 if shared_tiles.is_empty() {
2186 tiles_owned = acts
2187 .par_chunks(nc)
2188 .map(|chunk| {
2189 ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2190 })
2191 .collect();
2192 &tiles_owned
2193 } else {
2194 shared_tiles
2195 };
2196 let accel = ferrox_quant::AccelX4::detect();
2197 Self::par_chunked_groups(
2198 n_groups,
2199 nrows_g,
2200 act_tiles.len(),
2201 nc,
2202 |g, t0, t1| {
2203 let mut tmp = [0f32;
2204 ferrox_quant::Q4_0X4_NROWS
2205 * ferrox_quant::Q8K_ACTS_X4_NC];
2206 for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2207 let t = t0 + t;
2208 let n = tile.na;
2209 let tmp = &mut tmp[..nrows_g * n];
2210 ferrox_quant::gemm_q4_0x4_group_x4_on(
2211 &packed, g, tile, cols, interleave, accel, tmp,
2212 );
2213 for j in 0..n {
2214 let col = (t * nc + j) * rows + g * nrows_g;
2215 for r in 0..nrows_g {
2216 unsafe {
2217 out_w.set(col + r, tmp[r * n + j]);
2218 }
2219 }
2220 }
2221 }
2222 },
2223 );
2224 } else {
2225 // GEMM, not a GEMV per position: the
2226 // batched kernel writes a `[row][batch]`
2227 // span, and the group's weight vectors
2228 // stay in registers across a tile of
2229 // activations. The span is then scattered
2230 // into the [batch][rows] output right
2231 // here, in parallel.
2232 let span = ferrox_quant::Q8_0X4_GEMM_NC;
2233 let n_tiles = batch_size.div_ceil(span);
2234 Self::par_chunked_groups(
2235 n_groups,
2236 nrows_g,
2237 n_tiles,
2238 span,
2239 |g, t0, t1| {
2240 let b0 = t0 * span;
2241 let b1 = (t1 * span).min(batch_size);
2242 let n = b1 - b0;
2243 let mut group = vec![0f32; nrows_g * n];
2244 ferrox_quant::gemm_q4_0x4_group(
2245 &packed,
2246 g,
2247 &acts[b0..b1],
2248 cols,
2249 interleave,
2250 &mut group,
2251 );
2252 for (bi, b) in (b0..b1).enumerate() {
2253 for r in 0..nrows_g {
2254 unsafe {
2255 out_w.set(
2256 b * rows + g * nrows_g + r,
2257 group[r * n + bi],
2258 );
2259 }
2260 }
2261 }
2262 },
2263 );
2264 }
2265 let data_slice = data.as_slice();
2266 let tail = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
2267 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2268 let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
2269 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2270 for (b, act) in acts.iter().enumerate() {
2271 unsafe {
2272 out_w.set(
2273 b * rows + r,
2274 ferrox_quant::dot_q4_0_q8(row, act),
2275 );
2276 }
2277 }
2278 });
2279 } else {
2280 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2281 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2282 for (b, act) in acts.iter().enumerate() {
2283 unsafe {
2284 out_w.set(
2285 b * rows + r,
2286 ferrox_quant::dot_q4_0_q8(row, act),
2287 );
2288 }
2289 }
2290 });
2291 }
2292 return out;
2293 }
2294 QuantKind::Q4K if cols.is_multiple_of(256) => {
2295 let mut acts_owned = Vec::new();
2296 let (acts, shared_tiles) =
2297 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2298 let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
2299 if n_groups > 0 {
2300 let interleave = ferrox_quant::q4_kx8_interleave();
2301 let packed = get_or_repack_q4k(data, *rows, cols);
2302 let nc = ferrox_quant::Q4_KX8_GEMM_NC;
2303 // On the i8mm path, interleave each quad of
2304 // activations once per matmul (llama.cpp
2305 // `ggml_quantize_mat_q8_K_4x8` into `wdata`);
2306 // the kernel used to redo it per row-group.
2307 // A `shared` batch has already paid for this
2308 // on behalf of every sibling projection. The
2309 // predicate is asked first either way: it,
2310 // not the donor, decides whether this matrix
2311 // has an x4 kernel at all.
2312 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2313 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2314 if !ferrox_quant::q4_kx8_gemm_uses_acts_x4(interleave) {
2315 &[]
2316 } else if !shared_tiles.is_empty() {
2317 shared_tiles
2318 } else {
2319 tiles_owned = acts
2320 .par_chunks(nc)
2321 .map(|chunk| {
2322 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2323 })
2324 .collect();
2325 &tiles_owned
2326 };
2327 let accel = ferrox_quant::AccelX4::detect();
2328 let n_tiles = batch_size.div_ceil(nc);
2329 Self::par_chunked_groups(
2330 n_groups,
2331 ferrox_quant::Q4_KX8_NROWS,
2332 n_tiles,
2333 nc,
2334 |g, t0, t1| {
2335 let mut tile = [0f32;
2336 ferrox_quant::Q4_KX8_NROWS
2337 * ferrox_quant::Q4_KX8_GEMM_NC];
2338 for t in t0..t1 {
2339 let chunk =
2340 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2341 let n = chunk.len();
2342 let tile = &mut tile[..ferrox_quant::Q4_KX8_NROWS * n];
2343 if act_tiles.is_empty() {
2344 ferrox_quant::gemm_q4_kx8_group(
2345 &packed, g, chunk, cols, interleave, tile,
2346 );
2347 } else {
2348 ferrox_quant::gemm_q4_kx8_group_x4_on(
2349 &packed,
2350 g,
2351 &act_tiles[t],
2352 cols,
2353 interleave,
2354 accel,
2355 tile,
2356 );
2357 }
2358 for j in 0..n {
2359 let col = (t * nc + j) * rows
2360 + g * ferrox_quant::Q4_KX8_NROWS;
2361 for r in 0..ferrox_quant::Q4_KX8_NROWS {
2362 unsafe {
2363 out_w.set(col + r, tile[r * n + j]);
2364 }
2365 }
2366 }
2367 }
2368 },
2369 );
2370 let data_slice = data.as_slice();
2371 let tail = *rows - n_groups * ferrox_quant::Q4_KX8_NROWS;
2372 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2373 let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
2374 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2375 for (b, act) in acts.iter().enumerate() {
2376 unsafe {
2377 out_w.set(
2378 b * rows + r,
2379 ferrox_quant::dot_q4_k_q8(row, act),
2380 );
2381 }
2382 }
2383 });
2384 } else {
2385 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2386 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2387 for (b, act) in acts.iter().enumerate() {
2388 unsafe {
2389 out_w.set(
2390 b * rows + r,
2391 ferrox_quant::dot_q4_k_q8(row, act),
2392 );
2393 }
2394 }
2395 });
2396 }
2397 return out;
2398 }
2399 QuantKind::Q5K if cols.is_multiple_of(256) => {
2400 let mut acts_owned = Vec::new();
2401 let (acts, shared_tiles) =
2402 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2403 // Q5_Kx8 multi-act NEON GEMM amortizes weight unpack.
2404 let use_kx8 = cfg!(target_arch = "aarch64");
2405 let n_groups = if use_kx8 {
2406 *rows / ferrox_quant::Q5_KX8_NROWS
2407 } else {
2408 0
2409 };
2410 if n_groups > 0 {
2411 let interleave = ferrox_quant::q5_kx8_interleave();
2412 let packed = get_or_repack_q5k(data, *rows, cols);
2413 let nc = ferrox_quant::Q5_KX8_GEMM_NC;
2414 // On the i8mm path, interleave each quad of
2415 // activations once per matmul; the kernel
2416 // consumes it for every row-group. A `shared`
2417 // batch has already paid for it. Predicate
2418 // first, as in the Q4_K arm.
2419 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2420 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2421 if !ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
2422 &[]
2423 } else if !shared_tiles.is_empty() {
2424 shared_tiles
2425 } else {
2426 tiles_owned = acts
2427 .par_chunks(nc)
2428 .map(|chunk| {
2429 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2430 })
2431 .collect();
2432 &tiles_owned
2433 };
2434 let accel = ferrox_quant::AccelX4::detect();
2435 let n_tiles = batch_size.div_ceil(nc);
2436 Self::par_chunked_groups(
2437 n_groups,
2438 ferrox_quant::Q5_KX8_NROWS,
2439 n_tiles,
2440 nc,
2441 |g, t0, t1| {
2442 let mut tile = [0f32;
2443 ferrox_quant::Q5_KX8_NROWS
2444 * ferrox_quant::Q5_KX8_GEMM_NC];
2445 for t in t0..t1 {
2446 let chunk =
2447 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2448 let n = chunk.len();
2449 let tile = &mut tile[..ferrox_quant::Q5_KX8_NROWS * n];
2450 if act_tiles.is_empty() {
2451 ferrox_quant::gemm_q5_kx8_group(
2452 &packed, g, chunk, cols, interleave, tile,
2453 );
2454 } else {
2455 ferrox_quant::gemm_q5_kx8_group_x4_on(
2456 &packed,
2457 g,
2458 &act_tiles[t],
2459 cols,
2460 interleave,
2461 accel,
2462 tile,
2463 );
2464 }
2465 for j in 0..n {
2466 let col = (t * nc + j) * rows
2467 + g * ferrox_quant::Q5_KX8_NROWS;
2468 for r in 0..ferrox_quant::Q5_KX8_NROWS {
2469 unsafe {
2470 out_w.set(col + r, tile[r * n + j]);
2471 }
2472 }
2473 }
2474 }
2475 },
2476 );
2477 let data_slice = data.as_slice();
2478 let tail = *rows - n_groups * ferrox_quant::Q5_KX8_NROWS;
2479 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2480 let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
2481 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2482 for (b, act) in acts.iter().enumerate() {
2483 unsafe {
2484 out_w.set(
2485 b * rows + r,
2486 ferrox_quant::dot_q5_k_q8(row, act),
2487 );
2488 }
2489 }
2490 });
2491 } else {
2492 let data_slice = data.as_slice();
2493 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2494 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2495 let nc = ferrox_quant::Q5_K_GEMM_NC;
2496 for (t, chunk) in acts.chunks(nc).enumerate() {
2497 let n = chunk.len();
2498 let mut tmp = [0f32; ferrox_quant::Q5_K_GEMM_NC];
2499 ferrox_quant::gemm_q5_k_q8_row(row, chunk, &mut tmp[..n]);
2500 for (j, v) in tmp[..n].iter().enumerate() {
2501 unsafe {
2502 out_w.set((t * nc + j) * rows + r, *v);
2503 }
2504 }
2505 }
2506 });
2507 }
2508 return out;
2509 }
2510 QuantKind::Q6K if cols.is_multiple_of(256) => {
2511 let mut acts_owned = Vec::new();
2512 let (acts, shared_tiles) =
2513 Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2514 // Kx8 batch path only where the i8mm GEMM
2515 // exists (the scalar Kx8 GEMM measured slower
2516 // than the per-row NEON dot on Phi ffn_down,
2517 // so everything else keeps the row path).
2518 let interleave = ferrox_quant::q6_kx8_interleave();
2519 let use_kx8 = ferrox_quant::q6_kx8_gemm_uses_acts_x4(interleave);
2520 let n_groups = if use_kx8 {
2521 *rows / ferrox_quant::Q6_KX8_NROWS
2522 } else {
2523 0
2524 };
2525 if n_groups > 0 {
2526 let packed = get_or_repack_q6k(data, *rows, cols);
2527 // Quads of 4 (the i8mm tile shape), not
2528 // [`Q6_KX8_GEMM_NC`].
2529 let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2530 let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2531 let act_tiles: &[ferrox_quant::Q8KActsX4] =
2532 if shared_tiles.is_empty() {
2533 tiles_owned = acts
2534 .par_chunks(nc)
2535 .map(|chunk| {
2536 ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2537 })
2538 .collect();
2539 &tiles_owned
2540 } else {
2541 shared_tiles
2542 };
2543 let accel = ferrox_quant::AccelX4::detect();
2544 let n_tiles = batch_size.div_ceil(nc);
2545 Self::par_chunked_groups(
2546 n_groups,
2547 ferrox_quant::Q6_KX8_NROWS,
2548 n_tiles,
2549 nc,
2550 |g, t0, t1| {
2551 let mut tile = [0f32;
2552 ferrox_quant::Q6_KX8_NROWS
2553 * ferrox_quant::Q8K_ACTS_X4_NC];
2554 for t in t0..t1 {
2555 let chunk =
2556 &acts[t * nc..((t + 1) * nc).min(batch_size)];
2557 let n = chunk.len();
2558 let tile = &mut tile[..ferrox_quant::Q6_KX8_NROWS * n];
2559 ferrox_quant::gemm_q6_kx8_group_x4_on(
2560 &packed,
2561 g,
2562 &act_tiles[t],
2563 cols,
2564 interleave,
2565 accel,
2566 tile,
2567 );
2568 for j in 0..n {
2569 let col = (t * nc + j) * rows
2570 + g * ferrox_quant::Q6_KX8_NROWS;
2571 for r in 0..ferrox_quant::Q6_KX8_NROWS {
2572 unsafe {
2573 out_w.set(col + r, tile[r * n + j]);
2574 }
2575 }
2576 }
2577 }
2578 },
2579 );
2580 let data_slice = data.as_slice();
2581 let tail = *rows - n_groups * ferrox_quant::Q6_KX8_NROWS;
2582 crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2583 let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
2584 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2585 for (b, act) in acts.iter().enumerate() {
2586 unsafe {
2587 out_w.set(
2588 b * rows + r,
2589 ferrox_quant::dot_q6_k_q8(row, act),
2590 );
2591 }
2592 }
2593 });
2594 } else {
2595 let data_slice = data.as_slice();
2596 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2597 let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2598 let nc = ferrox_quant::Q6_K_GEMM_NC;
2599 for (t, chunk) in acts.chunks(nc).enumerate() {
2600 let mut tmp = [0f32; ferrox_quant::Q6_K_GEMM_NC];
2601 let n = chunk.len();
2602 ferrox_quant::gemm_q6_k_q8_row(row, chunk, &mut tmp[..n]);
2603 for (j, v) in tmp[..n].iter().enumerate() {
2604 unsafe {
2605 out_w.set((t * nc + j) * rows + r, *v);
2606 }
2607 }
2608 }
2609 });
2610 }
2611 return out;
2612 }
2613 QuantKind::Q5K | QuantKind::Q6K => {}
2614 _ => {}
2615 }
2616 }
2617
2618 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2619 let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2620 for b in 0..batch_size {
2621 let x = &x_batch[b * cols..(b + 1) * cols];
2622 unsafe {
2623 out_w.set(b * rows + r, Self::dot(*kind, row, x));
2624 }
2625 }
2626 });
2627 out
2628 }
2629 WeightMatrix::Mxfp4 {
2630 packed,
2631 scale,
2632 rows,
2633 cols: _,
2634 } => {
2635 let packed_row_bytes = cols / 2;
2636 let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
2637 let mut out = vec![0f32; batch_size * rows];
2638 let out_w = BatchOut(out.as_mut_ptr());
2639 crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2640 let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
2641 let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
2642 for b in 0..batch_size {
2643 let x = &x_batch[b * cols..(b + 1) * cols];
2644 unsafe {
2645 out_w.set(b * rows + r, ferrox_quant::dot_mxfp4_row_f32(prow, srow, x));
2646 }
2647 }
2648 });
2649 out
2650 }
2651 }
2652 }
2653
2654 /// Bytes actually resident in memory for this matrix -- the number
2655 /// that matters for "can this model's weights fit in RAM/VRAM at
2656 /// all," as opposed to the always-4x-larger f32-expanded size.
2657 pub fn resident_bytes(&self) -> usize {
2658 match self {
2659 WeightMatrix::F32(t) => t.len() * 4,
2660 WeightMatrix::Quantized { data, .. } => data.len(),
2661 WeightMatrix::Mxfp4 { packed, scale, .. } => packed.len() + scale.len(),
2662 }
2663 }
2664
2665 /// Dispatches a single matvec through a real GPU kernel when a GPU
2666 /// feature is compiled in (`cuda` and/or `metal`) and this matrix
2667 /// is one of the five GPU-accelerated quant kinds (Q8_0, Q4_0,
2668 /// Q4_K, Q5_K, Q6_K). Returns `None` for every other case (no GPU
2669 /// feature, `F32`/`Mxfp4`/`Mxfp4Gguf`, or a `Quantized` kind other
2670 /// than the five below), so the caller falls back to `apply()` on
2671 /// the CPU -- this is a real dispatch decision
2672 /// (`ferrox_moe::run_expert_placed` uses it exactly this way), not
2673 /// a stub. Metal weight buffers are process-resident after the first
2674 /// upload (`ferrox_metal::gpu` weight cache); activations still
2675 /// upload per call. When both `cuda` and `metal` are enabled, CUDA
2676 /// is tried first and Metal is the fallback.
2677 #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
2678 pub fn apply_gpu(&self, x: &[f32]) -> Option<Vec<f32>> {
2679 assert_eq!(
2680 x.len(),
2681 self.cols(),
2682 "activation length must match matrix column count"
2683 );
2684
2685 // F32 stays on CPU in apply_gpu: a lone small router matvec is
2686 // faster as host GEMV than a Metal sync. F32 Metal launches are
2687 // used when fused into MoE resident decode (encode_matvec).
2688 let WeightMatrix::Quantized {
2689 data,
2690 rows,
2691 cols,
2692 kind,
2693 } = self
2694 else {
2695 // Deliberate, and recorded rather than hidden: an MoE
2696 // router is a lone small F32 matvec that costs more to ship
2697 // to the GPU than to compute on the host.
2698 let backend = active_backend();
2699 if backend.is_accelerator() {
2700 crate::kernel_registry::miss_by_design(
2701 crate::kernel_registry::Lookup::new(
2702 backend,
2703 crate::kernel_registry::op::MATVEC,
2704 None,
2705 ),
2706 "host GEMV",
2707 );
2708 }
2709 return None;
2710 };
2711 let row_bytes = self.block_bytes_per_row(*kind, *cols);
2712
2713 // One body per backend, expanded over the one ordered list, in
2714 // place of the two hand-kept `match kind` tables this used to
2715 // hold -- which differed in arity, in error type, and (silently)
2716 // by one entry. A third backend adds no code here.
2717 #[allow(unused_macros)]
2718 macro_rules! try_matvec {
2719 ($b:ty) => {
2720 if let Some(result) = <$b as BackendDispatch>::launch_matvec(
2721 *kind,
2722 data.as_slice(),
2723 x,
2724 *rows,
2725 row_bytes,
2726 ) {
2727 match result {
2728 Ok(out) => return Some(out),
2729 Err(e) => {
2730 eprintln!(
2731 "ferrox: {} matvec dispatch failed, {}: {e}",
2732 <$b as BackendCaps>::NAME,
2733 <$b as BackendDispatch>::MATVEC_FALLBACK
2734 );
2735 }
2736 }
2737 }
2738 };
2739 }
2740 with_gpu_backends!(try_matvec);
2741
2742 // Reached only on a miss or a launch error, i.e. only when the
2743 // caller is about to run the whole matvec on the host anyway --
2744 // so recording it here costs nothing measurable and is the only
2745 // signal that a GPU run is quietly not one.
2746 let backend = active_backend();
2747 if backend.is_accelerator() {
2748 crate::kernel_registry::miss(
2749 crate::kernel_registry::Lookup::new(
2750 backend,
2751 crate::kernel_registry::op::MATVEC,
2752 Some(*kind),
2753 ),
2754 "CPU apply_cpu",
2755 );
2756 }
2757 None
2758 }
2759
2760 /// Runs several independent matvecs that share the same activation
2761 /// `x` in one GPU dispatch (one upload of `x`, one wait). Tries
2762 /// CUDA first (when `cuda_dense_enabled()`), then Metal (when
2763 /// `metal_dense_enabled()`). Intended for Q/K/V (and similar)
2764 /// projections. Returns `None` if no GPU backend is enabled, any
2765 /// matrix lacks a GPU kernel, or all fused launches fail — caller
2766 /// should fall back to sequential [`Self::apply`].
2767 #[cfg(any(feature = "cuda", feature = "metal"))]
2768 pub fn apply_gpu_multi(mats: &[&WeightMatrix], x: &[f32]) -> Option<Vec<Vec<f32>>> {
2769 if mats.is_empty() {
2770 return None;
2771 }
2772 assert_eq!(
2773 x.len(),
2774 mats[0].cols(),
2775 "activation length must match matrix column count"
2776 );
2777
2778 // Try CUDA first if enabled.
2779 #[cfg(feature = "cuda")]
2780 if cuda_dense_enabled() {
2781 let mut launches = Vec::with_capacity(mats.len());
2782 for m in mats {
2783 assert_eq!(m.cols(), mats[0].cols());
2784 let WeightMatrix::Quantized {
2785 data,
2786 rows,
2787 cols,
2788 kind,
2789 } = m
2790 else {
2791 return None;
2792 };
2793 // One table, in `ferrox-cuda`, exactly as the Metal arm
2794 // below asks `matvec_launch_meta`. This match was
2795 // written out here and again in
2796 // `apply_gpu_dense_ffn_swiglu`, three copies of one
2797 // five-row list with nothing holding them together --
2798 // and a kind added to the capability table but not to a
2799 // copy loses its fused launch silently, which is the
2800 // failure this file has paid for twice.
2801 let (kernel_src, module_name, fn_name) =
2802 ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
2803 let row_bytes = m.block_bytes_per_row(*kind, *cols);
2804 let n_blocks_per_row = row_bytes / Self::block_bytes_for_kind(*kind);
2805 launches.push(ferrox_cuda::gpu::MatvecLaunch {
2806 kernel_src,
2807 module_name,
2808 fn_name,
2809 // Borrow mmap/owned storage — never to_vec() (breaks
2810 // resident_cuda_weights pointer cache; re-uploads GB).
2811 weights: data.as_slice(),
2812 rows: *rows,
2813 row_bytes,
2814 n_blocks_per_row,
2815 });
2816 }
2817 match ferrox_cuda::gpu::launch_matvec_multi(x, &launches) {
2818 Ok(outs) => return Some(outs),
2819 Err(e) => {
2820 eprintln!("ferrox: CUDA multi-matvec failed, trying next backend: {e}");
2821 }
2822 }
2823 }
2824
2825 // Try Metal if CUDA didn't return or failed.
2826 #[cfg(feature = "metal")]
2827 if metal_dense_enabled() {
2828 let mut launches = Vec::with_capacity(mats.len());
2829 let mut held: Vec<(&[u8], usize, usize, &'static str)> = Vec::with_capacity(mats.len());
2830 for m in mats {
2831 assert_eq!(m.cols(), mats[0].cols());
2832 let WeightMatrix::Quantized {
2833 data,
2834 rows,
2835 cols,
2836 kind,
2837 } = m
2838 else {
2839 return None;
2840 };
2841 let kind_name = match kind {
2842 QuantKind::Q8_0 => "Q8_0",
2843 QuantKind::Q4_0 => "Q4_0",
2844 QuantKind::Q4K => "Q4_K",
2845 QuantKind::Q5K => "Q5_K",
2846 QuantKind::Q6K => "Q6_K",
2847 QuantKind::IQ4XS => "IQ4_XS",
2848 _ => return None,
2849 };
2850 let row_bytes = m.block_bytes_per_row(*kind, *cols);
2851 held.push((data.as_slice(), *rows, row_bytes, kind_name));
2852 }
2853 for (weights, rows, row_bytes, kind_name) in &held {
2854 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
2855 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
2856 launches.push(ferrox_metal::gpu::MatvecLaunch {
2857 kernel_src: src,
2858 fn_name,
2859 block_bytes,
2860 block_elems,
2861 weights,
2862 rows: *rows,
2863 row_bytes: *row_bytes,
2864 rows_per_tg,
2865 });
2866 }
2867 match ferrox_metal::gpu::launch_matvec_fused(x, &launches) {
2868 Ok(outs) => return Some(outs),
2869 Err(e) => {
2870 eprintln!("ferrox: Metal fused matvec failed, falling back to CPU: {e}");
2871 }
2872 }
2873 }
2874
2875 None
2876 }
2877
2878 /// Dense SwiGLU FFN on GPU with device-resident activations:
2879 /// one upload of `x`, gate+up+silu×up+down on device, one download.
2880 /// Tries CUDA first when enabled, then Metal. Returns `None` if
2881 /// no GPU path applies — caller falls back to [`Self::apply`] /
2882 /// multi-matvec.
2883 #[cfg(any(feature = "cuda", feature = "metal"))]
2884 pub fn apply_gpu_dense_ffn_swiglu(
2885 gate: &WeightMatrix,
2886 up: &WeightMatrix,
2887 down: &WeightMatrix,
2888 x: &[f32],
2889 ) -> Option<Vec<f32>> {
2890 #[cfg(feature = "cuda")]
2891 {
2892 if cuda_dense_enabled() {
2893 fn cuda_launch(m: &WeightMatrix) -> Option<ferrox_cuda::gpu::MatvecLaunch<'_>> {
2894 let WeightMatrix::Quantized {
2895 data,
2896 rows,
2897 cols,
2898 kind,
2899 } = m
2900 else {
2901 return None;
2902 };
2903 // The second of the two copies this used to hold.
2904 // See the note in `apply_gpu_multi`.
2905 let (kernel_src, module_name, fn_name) =
2906 ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
2907 let row_bytes = m.block_bytes_per_row(*kind, *cols);
2908 let n_blocks_per_row = row_bytes / WeightMatrix::block_bytes_for_kind(*kind);
2909 Some(ferrox_cuda::gpu::MatvecLaunch {
2910 kernel_src,
2911 module_name,
2912 fn_name,
2913 weights: data.as_slice(),
2914 rows: *rows,
2915 row_bytes,
2916 n_blocks_per_row,
2917 })
2918 }
2919 if let (Some(g), Some(u), Some(d)) =
2920 (cuda_launch(gate), cuda_launch(up), cuda_launch(down))
2921 {
2922 assert_eq!(gate.cols(), x.len());
2923 assert_eq!(up.cols(), x.len());
2924 assert_eq!(down.cols(), gate.rows());
2925 match ferrox_cuda::gpu::launch_dense_ffn_swiglu(&g, &u, &d, x) {
2926 Ok(out) => return Some(out),
2927 Err(e) => {
2928 eprintln!("ferrox: CUDA dense FFN fuse failed, trying next: {e}");
2929 }
2930 }
2931 }
2932 }
2933 }
2934 #[cfg(feature = "metal")]
2935 {
2936 if metal_dense_enabled() {
2937 fn metal_launch(m: &WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'_>> {
2938 let WeightMatrix::Quantized {
2939 data,
2940 rows,
2941 cols: _,
2942 kind,
2943 } = m
2944 else {
2945 return None;
2946 };
2947 let kind_name = match kind {
2948 QuantKind::Q8_0 => "Q8_0",
2949 QuantKind::Q4_0 => "Q4_0",
2950 QuantKind::Q4K => "Q4_K",
2951 QuantKind::Q5K => "Q5_K",
2952 QuantKind::Q6K => "Q6_K",
2953 QuantKind::IQ4XS => "IQ4_XS",
2954 _ => return None,
2955 };
2956 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
2957 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
2958 // A zero-row matrix has no rows to stride over, so
2959 // there is no meaningful row size; `checked_div`
2960 // says that once instead of splitting it across a
2961 // guard and a bare division.
2962 let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
2963 Some(ferrox_metal::gpu::MatvecLaunch {
2964 kernel_src: src,
2965 fn_name,
2966 block_bytes,
2967 block_elems,
2968 weights: data.as_slice(),
2969 rows: *rows,
2970 row_bytes,
2971 rows_per_tg,
2972 })
2973 }
2974 if let (Some(g), Some(u), Some(d)) =
2975 (metal_launch(gate), metal_launch(up), metal_launch(down))
2976 {
2977 assert_eq!(gate.cols(), x.len());
2978 assert_eq!(up.cols(), x.len());
2979 assert_eq!(down.cols(), gate.rows());
2980 match ferrox_metal::gpu::launch_dense_ffn_swiglu(&g, &u, &d, x) {
2981 Ok(out) => return Some(out),
2982 Err(e) => {
2983 eprintln!("ferrox: Metal dense FFN fuse failed, falling back: {e}");
2984 }
2985 }
2986 }
2987 }
2988 }
2989 None
2990 }
2991
2992 /// Runs one weight matrix against `batch_size` activations in a
2993 /// single Metal command buffer (shared resident weights, one
2994 /// upload of `x_batch`, one GPU wait). `x_batch` / return layout
2995 /// match [`Self::apply_batch`]: `[batch, cols]` → `[batch, rows]`.
2996 /// Returns `None` if Metal dense is off, the kind lacks a Metal
2997 /// kernel, or the launch fails.
2998 ///
2999 /// `batch_size >= 4` takes the weight-reuse `mul_mm` path where the
3000 /// kind has one; everything else falls through to
3001 /// [`ferrox_metal::gpu::launch_matvec_batch`].
3002 #[cfg(feature = "metal")]
3003 pub fn apply_gpu_batch(&self, x_batch: &[f32], batch_size: usize) -> Option<Vec<f32>> {
3004 if !metal_dense_enabled() || batch_size == 0 {
3005 return None;
3006 }
3007 let WeightMatrix::Quantized {
3008 data,
3009 rows,
3010 cols,
3011 kind,
3012 } = self
3013 else {
3014 return None;
3015 };
3016 let Some(kind_name) = metal_matvec_kind_name(*kind) else {
3017 crate::kernel_registry::miss(
3018 crate::kernel_registry::Lookup::new(
3019 crate::kernel_registry::Backend::Metal,
3020 crate::kernel_registry::op::GEMM_PREFILL,
3021 Some(*kind),
3022 ),
3023 "CPU apply_batch",
3024 );
3025 return None;
3026 };
3027 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3028 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3029 let row_bytes = self.block_bytes_per_row(*kind, *cols);
3030 // Weight-reuse mul_mm for prefill batch >= 4 (Q4_0 / Q4_K / Q6_K).
3031 // Threshold 4 (was 8) covers shorter prompts without changing the
3032 // decode path (batch_size == 1 still uses matvec).
3033 let use_mul_mm = batch_size >= 4;
3034 if use_mul_mm {
3035 // Observation only: a kind with a matvec kernel but no
3036 // simdgroup GEMM still runs on Metal, as `batch` separate
3037 // matvecs over the same weights. That is the shape that cost
3038 // IQ4_XS 13.7x, and it is invisible in the output.
3039 if !metal_mul_mm_kind_supported(*kind) {
3040 crate::kernel_registry::miss(
3041 crate::kernel_registry::Lookup::new(
3042 crate::kernel_registry::Backend::Metal,
3043 crate::kernel_registry::op::GEMM_PREFILL,
3044 Some(*kind),
3045 ),
3046 "Metal N x matvec batch",
3047 );
3048 }
3049 match kind {
3050 QuantKind::Q4_0 => {
3051 match ferrox_metal::gpu::launch_q4_0_mul_mm_sg(
3052 data.as_slice(),
3053 x_batch,
3054 *rows,
3055 row_bytes,
3056 batch_size,
3057 ) {
3058 Ok(out) => return Some(out),
3059 Err(e) => {
3060 eprintln!(
3061 "ferrox: Metal Q4_0 simdgroup mul_mm failed, batched fallback: {e}"
3062 );
3063 }
3064 }
3065 match ferrox_metal::gpu::launch_q4_0_mul_mm(
3066 data.as_slice(),
3067 x_batch,
3068 *rows,
3069 row_bytes,
3070 batch_size,
3071 ) {
3072 Ok(out) => return Some(out),
3073 Err(e) => {
3074 eprintln!("ferrox: Metal Q4_0 mul_mm failed, matvec fallback: {e}");
3075 }
3076 }
3077 }
3078 // Q8_0 had no batched GPU kernel at all, so a 512-token
3079 // prefill ran 512 independent matvecs over the same
3080 // weights. Those are the 14-30x `pp512` rows.
3081 QuantKind::Q8_0 => {
3082 match ferrox_metal::gpu::launch_q8_0_mul_mm_sg(
3083 data.as_slice(),
3084 x_batch,
3085 *rows,
3086 row_bytes,
3087 batch_size,
3088 ) {
3089 Ok(out) => return Some(out),
3090 Err(e) => {
3091 eprintln!(
3092 "ferrox: Metal Q8_0 simdgroup mul_mm failed, matvec fallback: {e}"
3093 );
3094 }
3095 }
3096 }
3097 QuantKind::Q5K => {
3098 match ferrox_metal::gpu::launch_q5_k_mul_mm_sg(
3099 data.as_slice(),
3100 x_batch,
3101 *rows,
3102 row_bytes,
3103 batch_size,
3104 ) {
3105 Ok(out) => return Some(out),
3106 Err(e) => {
3107 eprintln!(
3108 "ferrox: Metal Q5_K simdgroup mul_mm failed, matvec fallback: {e}"
3109 );
3110 }
3111 }
3112 }
3113 QuantKind::IQ4XS => {
3114 match ferrox_metal::gpu::launch_iq4_xs_mul_mm_sg(
3115 data.as_slice(),
3116 x_batch,
3117 *rows,
3118 row_bytes,
3119 batch_size,
3120 ) {
3121 Ok(out) => return Some(out),
3122 Err(e) => {
3123 eprintln!(
3124 "ferrox: Metal IQ4_XS simdgroup mul_mm failed, matvec fallback: {e}"
3125 );
3126 }
3127 }
3128 }
3129 QuantKind::Q4K => {
3130 // True simdgroup GEMM: each 64x32 output tile reads its
3131 // weight slice once into threadgroup memory instead of
3132 // once per token. `launch_q4_k_mul_mm` below is the
3133 // batched-matvec fallback it replaces -- correct, but it
3134 // re-reads the whole matrix for every token, which is why
3135 // Metal `pp512` was 14-99x behind llama.cpp.
3136 match ferrox_metal::gpu::launch_q4_k_mul_mm_sg(
3137 data.as_slice(),
3138 x_batch,
3139 *rows,
3140 row_bytes,
3141 batch_size,
3142 ) {
3143 Ok(out) => return Some(out),
3144 Err(e) => {
3145 eprintln!(
3146 "ferrox: Metal Q4_K simdgroup mul_mm failed, batched-matvec fallback: {e}"
3147 );
3148 }
3149 }
3150 match ferrox_metal::gpu::launch_q4_k_mul_mm(
3151 data.as_slice(),
3152 x_batch,
3153 *rows,
3154 row_bytes,
3155 batch_size,
3156 ) {
3157 Ok(out) => return Some(out),
3158 Err(e) => {
3159 eprintln!(
3160 "ferrox: Metal Q4_K mul_mm (MUL_MM path) failed, matvec fallback: {e}"
3161 );
3162 }
3163 }
3164 }
3165 QuantKind::Q6K => {
3166 // Same simdgroup GEMM as Q4_K. `ffn_down` and `attn_v`
3167 // are Q6_K in every Q4_K_M checkpoint, so without this
3168 // a third of the FFN stayed on the batched-matvec path
3169 // and capped what the Q4_K GEMM could deliver.
3170 match ferrox_metal::gpu::launch_q6_k_mul_mm_sg(
3171 data.as_slice(),
3172 x_batch,
3173 *rows,
3174 row_bytes,
3175 batch_size,
3176 ) {
3177 Ok(out) => return Some(out),
3178 Err(e) => {
3179 eprintln!(
3180 "ferrox: Metal Q6_K simdgroup mul_mm failed, matvec fallback: {e}"
3181 );
3182 }
3183 }
3184 }
3185 _ => {}
3186 }
3187 }
3188 let launch = ferrox_metal::gpu::MatvecLaunch {
3189 kernel_src: src,
3190 fn_name,
3191 block_bytes,
3192 block_elems,
3193 weights: data.as_slice(),
3194 rows: *rows,
3195 row_bytes,
3196 rows_per_tg,
3197 };
3198 match ferrox_metal::gpu::launch_matvec_batch(&launch, x_batch, batch_size) {
3199 Ok(out) => Some(out),
3200 Err(e) => {
3201 eprintln!("ferrox: Metal batch matvec failed, falling back: {e}");
3202 None
3203 }
3204 }
3205 }
3206
3207 /// Delegates to [`metal_matvec_kind_name`]. Kept as a method because
3208 /// the call sites read better, but it must never grow a list of its
3209 /// own again — a second copy of this list is what sent IQ4_XS
3210 /// batched prefill to the CPU.
3211 #[cfg(feature = "metal")]
3212 fn metal_kind_supported(kind: QuantKind) -> bool {
3213 metal_matvec_kind_name(kind).is_some()
3214 }
3215
3216 /// Eagerly resolve, and record, every kernel lookup this matrix's
3217 /// dispatch paths will make later, without dispatching anything.
3218 ///
3219 /// Call once per weight while the model is being built, with `role`
3220 /// naming the tensor (`"attn_q"`, `"ffn_down"`, ...). The predicates
3221 /// consulted here are the *same functions* the hot path consults, so
3222 /// the recorded prediction cannot drift from the decision. See
3223 /// [`crate::kernel_registry`] for why this exists and
3224 /// [`crate::kernel_registry::seal`] for what is done with it.
3225 ///
3226 /// Observation only: nothing here influences a later dispatch.
3227 #[track_caller]
3228 pub fn probe_kernels(&self, role: &'static str) {
3229 if !crate::kernel_registry::enabled() {
3230 return;
3231 }
3232 self.probe_kernels_into(
3233 crate::kernel_registry::global(),
3234 role,
3235 std::panic::Location::caller(),
3236 );
3237 }
3238
3239 /// [`Self::probe_kernels`] against an explicit registry and call
3240 /// site, so tests can probe into an instance of their own instead of
3241 /// the process-wide one.
3242 pub fn probe_kernels_into(
3243 &self,
3244 reg: &crate::kernel_registry::Registry,
3245 role: &'static str,
3246 loc: &'static std::panic::Location<'static>,
3247 ) {
3248 self.probe_kernels_for(reg, active_backend(), role, loc)
3249 }
3250
3251 /// [`Self::probe_kernels_into`] against an explicit backend rather
3252 /// than [`active_backend`]. Lets a test on a CPU-only build ask what
3253 /// a Metal or CUDA run would resolve -- which is the only way the
3254 /// kernel-coverage tests can run under plain
3255 /// `cargo test --workspace`, where every GPU feature is off.
3256 pub fn probe_kernels_for(
3257 &self,
3258 reg: &crate::kernel_registry::Registry,
3259 backend: crate::kernel_registry::Backend,
3260 role: &'static str,
3261 loc: &'static std::panic::Location<'static>,
3262 ) {
3263 use crate::kernel_registry::{op, Backend, Lookup, Outcome};
3264
3265 let kind = self.quant_kind();
3266 let cols = self.cols();
3267 let look = |op: &'static str| Lookup {
3268 backend,
3269 op,
3270 role,
3271 kind,
3272 };
3273
3274 // Whether the accelerator, if one is selected, can run this
3275 // matrix at all -- and if so, whether prefill gets a real GEMM
3276 // or `batch` matvecs over the same weights.
3277 //
3278 // Read off `BackendCaps` over the ungated backend table rather
3279 // than from a `match backend` written out here. The two are
3280 // NOT interchangeable: the hand-written match had a `Backend::
3281 // Cpu => (false, false)` arm and no `_`, so it was exhaustive
3282 // by luck -- a third variant broke it, which is the good case.
3283 // A fourth backend added while a `_` arm existed would have
3284 // silently reported "no kernels" for a backend that had them.
3285 //
3286 // Ungated on purpose: a CPU-only build must be able to ask what
3287 // CUDA would resolve, which is what every kernel-coverage test
3288 // below does. `BackendDispatch` is unavailable here for exactly
3289 // that reason.
3290 //
3291 // The predicate sets are per backend and genuinely different:
3292 // CUDA has a batched GEMM for a SUBSET of the kinds it has
3293 // matvecs for (Q8_0, Q4_0) and decomposes the rest into
3294 // per-position matvecs; Vulkan has one matvec and no GEMM at
3295 // all. `GEMM_FALLBACK` is what each of those decompositions is
3296 // actually called.
3297 let (matvec, gemm, gemm_fallback) = {
3298 let mut found = (false, false, "");
3299 macro_rules! caps_of {
3300 ($b:ty) => {
3301 if backend == <$b as BackendCaps>::ID {
3302 found = (
3303 kind.is_some_and(|k| <$b as BackendCaps>::matvec_kernel(k).is_some()),
3304 kind.is_some_and(<$b as BackendCaps>::gemm_supported),
3305 <$b as BackendCaps>::GEMM_FALLBACK,
3306 );
3307 }
3308 };
3309 }
3310 with_gpu_backend_caps!(caps_of);
3311 found
3312 };
3313
3314 if backend.is_accelerator() {
3315 reg.record_build_at(
3316 loc,
3317 look(op::MATVEC),
3318 match kind {
3319 // An accelerator kernel exists for this format.
3320 _ if matvec => Outcome::Hit,
3321 // No kernel: the whole matvec runs on the host.
3322 Some(_) => Outcome::slow_path("CPU apply_cpu"),
3323 // F32 has no quantized kernel by construction, and a
3324 // lone small F32 matvec (an MoE router) is host work
3325 // on purpose -- see `apply_gpu`.
3326 None => Outcome::by_design("host GEMV"),
3327 },
3328 );
3329 reg.record_build_at(
3330 loc,
3331 look(op::GEMM_PREFILL),
3332 match (gemm, matvec, kind) {
3333 (true, ..) => Outcome::Hit,
3334 // A matvec but no GEMM. What that costs is per
3335 // backend -- Metal re-reads the whole weight matrix
3336 // once per position but stays on the GPU (the 13.7x
3337 // shape), CUDA does the same through a different
3338 // entry point, and Vulkan has no batch path at all
3339 // so the prefill lands on the host -- so the name
3340 // comes from the backend instead of from an arm
3341 // here that a new variant would fall through.
3342 (false, true, _) => Outcome::slow_path(gemm_fallback),
3343 (false, false, Some(_)) => Outcome::slow_path("CPU apply_batch"),
3344 (false, false, None) => Outcome::by_design("CPU f32 GEMM"),
3345 },
3346 );
3347 }
3348
3349 // The host path is what every accelerator miss lands on, so
3350 // record its tier too: integer vec_dot, or the much slower f32
3351 // dequant-dot.
3352 if !matvec || !gemm {
3353 let int_dot = cpu_int_dot_for(IntDotShape::Matvec)
3354 && kind.is_some_and(|k| cpu_int_dot_kind_supported(k, cols));
3355 reg.record_build_at(
3356 loc,
3357 Lookup {
3358 backend: Backend::Cpu,
3359 op: op::MATVEC,
3360 role,
3361 kind,
3362 },
3363 match kind {
3364 _ if int_dot => Outcome::Hit,
3365 // A quantized weight with no integer vec_dot kernel
3366 // dequantizes to f32 first: a much slower engine,
3367 // and invisible in the output.
3368 Some(_) => Outcome::slow_path("f32 dequant-dot"),
3369 None => Outcome::by_design("f32 GEMM"),
3370 },
3371 );
3372 }
3373 }
3374
3375 /// The block size (in bytes) for exactly the quant kinds
3376 /// `apply_gpu` dispatches to a real CUDA or Vulkan kernel for -- a
3377 /// small, deliberately partial mirror of `block_bytes_per_row`'s
3378 /// per-kind match.
3379 ///
3380 /// Partial means this `unreachable!()` is reachable by a mistake:
3381 /// widening `Cuda::matvec_kernel` without adding the row here
3382 /// turns a decode into a panic in a rayon worker rather than a
3383 /// fallback. `every_cuda_or_vulkan_matvec_kind_has_a_block_size`
3384 /// calls it for every claimed kind so that lands as a red test
3385 /// instead.
3386 #[cfg(any(feature = "cuda", feature = "vulkan"))]
3387 pub(crate) fn block_bytes_for_kind(kind: QuantKind) -> usize {
3388 match kind {
3389 QuantKind::Q8_0 => ferrox_quant::Q8_0_BLOCK_BYTES,
3390 QuantKind::Q4_0 => ferrox_quant::Q4_0_BLOCK_BYTES,
3391 QuantKind::Q5_0 => ferrox_quant::Q5_0_BLOCK_BYTES,
3392 QuantKind::Q4K => ferrox_quant::Q4_K_BLOCK_BYTES,
3393 QuantKind::Q5K => ferrox_quant::Q5_K_BLOCK_BYTES,
3394 QuantKind::Q6K => ferrox_quant::Q6_K_BLOCK_BYTES,
3395 QuantKind::Q2K => ferrox_quant::Q2_K_BLOCK_BYTES,
3396 QuantKind::Q3K => ferrox_quant::Q3_K_BLOCK_BYTES,
3397 QuantKind::IQ4NL => ferrox_quant::IQ4_NL_BLOCK_BYTES,
3398 QuantKind::IQ4XS => ferrox_quant::IQ4_XS_BLOCK_BYTES,
3399 QuantKind::Mxfp4Gguf => ferrox_quant::MXFP4_GGUF_BLOCK_BYTES,
3400 _ => unreachable!(
3401 "apply_gpu only calls this for the CUDA/Vulkan-dispatchable kinds, not {kind:?}"
3402 ),
3403 }
3404 }
3405}
3406#[cfg(test)]
3407mod tests {
3408
3409 /// The task floor is **work-aware**, which is the whole reason
3410 /// [`crate::par::with_op_work`] exists: a row count alone cannot
3411 /// tell a 64-wide matrix from a 256-wide one, and rayon splitting
3412 /// the narrow one by rows alone is the measured 13-16x small-model
3413 /// regression.
3414 ///
3415 /// Both shapes here sit under [`crate::par::policy::SPIN_MIN_OP_MACS`]
3416 /// so both are decided by the fork-join arm, which is the only arm
3417 /// that reads a `min_len` at all.
3418 ///
3419 /// Sabotage: drop the `MIN_TASK_MACS` term from `min_rows_per_task`
3420 /// and this goes red, because both shapes then collapse onto the
3421 /// same row-count floor.
3422 #[test]
3423 fn the_task_floor_demands_more_rows_of_a_narrower_matrix() {
3424 if crate::par::policy::pinned().is_some() {
3425 return; // pinned: not the arm this floor belongs to
3426 }
3427 let rows = 4096usize;
3428 let narrow = crate::par::with_op_work(rows, 64, || WeightMatrix::min_rows_per_task(rows));
3429 let wider = crate::par::with_op_work(rows, 256, || WeightMatrix::min_rows_per_task(rows));
3430 assert_eq!(narrow, MIN_TASK_MACS.div_ceil(64));
3431 assert!(
3432 narrow > wider,
3433 "a 64-wide row carries a quarter of a 256-wide row's work, so a \
3434 task must hold four times as many of them: {narrow} vs {wider}"
3435 );
3436 }
3437
3438 /// The four dtypes the drifted copies were missing.
3439 ///
3440 /// Three of the six loaders stopped at IQ1_M, so `IQ1_S`,
3441 /// `IQ2_XXS`, `IQ3_XXS` and `MXFP4` mapped to `None` there -- and a
3442 /// `None` is `LoadError::UnsupportedDtype`, not a slower path. A
3443 /// DeepSeek-MLA checkpoint at `IQ2_XXS`, an ordinary quant for a
3444 /// model that size, was refused outright while the same quant
3445 /// loaded on the generic path. One table is what stops that
3446 /// recurring.
3447 #[test]
3448 fn the_four_dtypes_the_duplicated_tables_disagreed_about_all_map() {
3449 assert_eq!(quant_kind_for(GgmlType::IQ1S), Some(QuantKind::IQ1S));
3450 assert_eq!(quant_kind_for(GgmlType::IQ2XXS), Some(QuantKind::IQ2XXS));
3451 assert_eq!(quant_kind_for(GgmlType::IQ3XXS), Some(QuantKind::IQ3XXS));
3452 assert_eq!(quant_kind_for(GgmlType::MXFP4), Some(QuantKind::Mxfp4Gguf));
3453 }
3454
3455 /// Every dtype with a CPU dequant kernel must be reachable through
3456 /// this map, or the kernel exists and no loader can ever hand it a
3457 /// tensor. Checked against the two backend tables rather than a
3458 /// hand-written list, so adding a kernel without a mapping fails
3459 /// here instead of at a user's load.
3460 #[test]
3461 fn every_dtype_with_a_gpu_kernel_is_reachable_through_the_map() {
3462 let mapped: Vec<QuantKind> = [
3463 GgmlType::Q8_0,
3464 GgmlType::Q4_0,
3465 GgmlType::Q4K,
3466 GgmlType::Q5K,
3467 GgmlType::Q6K,
3468 GgmlType::IQ4XS,
3469 ]
3470 .into_iter()
3471 .map(|d| quant_kind_for(d).expect("a dtype with a GPU kernel must map"))
3472 .collect();
3473 for kind in mapped {
3474 assert!(
3475 metal_mul_mm_kind_supported(kind) || cuda_matvec_kind_supported(kind),
3476 "{kind:?} was listed as having a GPU kernel"
3477 );
3478 }
3479 }
3480
3481 /// The CUDA capability predicates and the *launch* table must name
3482 /// the same set, for every kind.
3483 ///
3484 /// `Cuda::matvec_kernel` and `Cuda::gemm_supported` are DERIVED
3485 /// from `ferrox-cuda`'s own kernel tables now, so the two pairs
3486 /// that used to be checked here cannot disagree -- those tests were
3487 /// deleted rather than left comparing a table to itself, which
3488 /// reads as coverage and is not.
3489 ///
3490 /// This one still matters. [`cuda_matvec_launch`] is a table of
3491 /// FUNCTION POINTERS, which only exist under `--features cuda`, so
3492 /// it cannot be derived from a table of strings. Over-claiming in
3493 /// the capability predicate sends a decode to a launcher that does
3494 /// not exist; under-claiming leaves a kernel nothing calls. The
3495 /// dispatch seam only `debug_assert!`s the agreement at the moment
3496 /// a matmul happens to run, which in release is no check at all.
3497 #[cfg(feature = "cuda")]
3498 #[test]
3499 fn every_cuda_matvec_kind_has_a_launcher() {
3500 use super::gpu_backend::cuda_matvec_launch;
3501 for &kind in QuantKind::ALL {
3502 assert_eq!(
3503 cuda_matvec_kind_supported(kind),
3504 cuda_matvec_launch(kind).is_some(),
3505 "{kind:?}: the capability table and the launch table disagree"
3506 );
3507 }
3508 }
3509
3510 /// `block_bytes_for_kind` is deliberately partial, so every kind
3511 /// CUDA or Vulkan claims a matvec for has to be one of its arms.
3512 ///
3513 /// Calling it IS the assertion: the arm it lacks is an
3514 /// `unreachable!()`, and reaching that in a rayon worker is a panic
3515 /// rather than the fallback the seam promises. `Metal` is
3516 /// deliberately not checked -- it claims IQ4_XS, asks
3517 /// `ferrox_metal::gpu::matvec_launch_meta` for its block size, and
3518 /// never touches this function.
3519 #[cfg(any(feature = "cuda", feature = "vulkan"))]
3520 #[test]
3521 fn every_cuda_or_vulkan_matvec_kind_has_a_block_size() {
3522 use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
3523 for &kind in QuantKind::ALL {
3524 if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
3525 continue;
3526 }
3527 let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
3528 assert!(
3529 block_bytes > 0,
3530 "{kind:?}: a claimed matvec kind needs a real block size"
3531 );
3532 }
3533 }
3534
3535 /// `block_bytes_for_kind` and `block_bytes_per_row` are two
3536 /// functions that must agree about one format's geometry, and the
3537 /// matvec seam DIVIDES one by the other.
3538 ///
3539 /// `Cuda::launch_matvec` derives `n_blocks_per_row` as
3540 /// `block_bytes_per_row(kind, cols) / block_bytes_for_kind(kind)`
3541 /// and hands it to a kernel that strides the row by a byte count
3542 /// written as a literal in CUDA C. If the two disagreed by so much
3543 /// as one byte the division would silently truncate, the kernel
3544 /// would read fewer blocks than the row holds, and every output
3545 /// would be a partial dot product -- plausible numbers, no error,
3546 /// no panic, and nothing in the suite red.
3547 ///
3548 /// Both are also held to `ferrox-cuda`'s own `MulMmKind` row, which
3549 /// is where that CUDA C literal comes from, so all three agree or
3550 /// this fails.
3551 ///
3552 /// Nothing checked any of it. That was survivable while the two
3553 /// tables were edited together by one person on one day; five kinds
3554 /// joined on 2026-09-09 and each needed a row in both.
3555 ///
3556 /// Sabotage: give any kind the wrong constant in either function
3557 /// and this names it.
3558 ///
3559 /// Gated like its neighbour: `block_bytes_for_kind` itself only
3560 /// exists when a backend that calls it is compiled in.
3561 #[cfg(any(feature = "cuda", feature = "vulkan"))]
3562 #[test]
3563 fn the_two_block_size_functions_agree_for_every_gpu_kind() {
3564 use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
3565 for &kind in QuantKind::ALL {
3566 if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
3567 continue;
3568 }
3569 let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
3570 let mm = ferrox_cuda::mul_mm::kind_by_name(kind.name())
3571 .unwrap_or_else(|| panic!("{kind:?}: claims a GPU matvec with no mul_mm row"));
3572 assert_eq!(
3573 block_bytes, mm.block_bytes,
3574 "{kind:?}: ferrox-core's block size is not the one the kernel strides by"
3575 );
3576
3577 // `block_bytes_per_row` takes `&self` but reads only its
3578 // arguments, so any matrix of the right kind will do.
3579 let probe = WeightMatrix::Quantized {
3580 data: WeightBytes::Owned(Vec::new()),
3581 rows: 1,
3582 cols: mm.block_elems,
3583 kind,
3584 };
3585 // Three, four and five whole blocks: a per-row function
3586 // that had dropped the multiply would still pass at one.
3587 for blocks in 3..=5usize {
3588 let cols = mm.block_elems * blocks;
3589 let row_bytes = probe.block_bytes_per_row(kind, cols);
3590 assert_eq!(
3591 row_bytes,
3592 blocks * block_bytes,
3593 "{kind:?}: block_bytes_per_row({cols}) is not {blocks} x {block_bytes}"
3594 );
3595 assert_eq!(
3596 row_bytes / block_bytes,
3597 blocks,
3598 "{kind:?}: the n_blocks_per_row the matvec seam derives is wrong"
3599 );
3600 }
3601 }
3602 }
3603
3604 /// F32 and F16 are not quantized, so `None` is the right answer and
3605 /// not a gap: the loader builds a plain `WeightMatrix::F32` for
3606 /// them rather than reporting an unsupported dtype.
3607 #[test]
3608 fn an_unquantized_dtype_maps_to_nothing() {
3609 assert_eq!(quant_kind_for(GgmlType::F32), None);
3610 assert_eq!(quant_kind_for(GgmlType::F16), None);
3611 }
3612 use super::*;
3613
3614 /// Forces [`cpu_int_dot_enabled`] for the lifetime of the guard, so a
3615 /// test can drive the quantized-activation batch kernels (the
3616 /// interleaved `block_q*_Kx8` / `block_q*_0x4` repack tier and the
3617 /// NEON i8mm GEMMs behind it) that every shipped binary turns on via
3618 /// `default_cpu_int_dot_on` but `cargo test` otherwise leaves off.
3619 ///
3620 /// The override is process-global, so the guard serializes on a
3621 /// mutex: two tests forcing opposite values concurrently would
3622 /// otherwise see each other's setting.
3623 pub(super) struct ForceIntDot {
3624 _lock: std::sync::MutexGuard<'static, ()>,
3625 }
3626
3627 impl ForceIntDot {
3628 pub(super) fn new(on: bool) -> Self {
3629 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3630 let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
3631 INT_DOT_TEST_OVERRIDE.store(i8::from(on), std::sync::atomic::Ordering::Release);
3632 Self { _lock: lock }
3633 }
3634 }
3635
3636 impl Drop for ForceIntDot {
3637 fn drop(&mut self) {
3638 INT_DOT_TEST_OVERRIDE.store(-1, std::sync::atomic::Ordering::Release);
3639 }
3640 }
3641
3642 /// The guard has to actually move the getter, in both directions --
3643 /// otherwise every test built on it silently exercises one path
3644 /// twice, which is exactly the hole it exists to close.
3645 #[test]
3646 fn force_int_dot_moves_the_getter_and_restores_it() {
3647 {
3648 let _g = ForceIntDot::new(true);
3649 assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
3650 }
3651 {
3652 let _g = ForceIntDot::new(false);
3653 assert!(!cpu_int_dot_enabled(), "forcing off must disable int dot");
3654 }
3655 assert_eq!(
3656 INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire),
3657 -1,
3658 "the guard must clear the override on drop"
3659 );
3660 }
3661
3662 /// `dequant_row` must reproduce exactly the values a full-buffer
3663 /// dequantization of the same row produces, for every storage
3664 /// variant -- and read only that row's bytes (each row here has
3665 /// distinct values, so an off-by-one-row slice fails loudly).
3666 #[test]
3667 fn dequant_row_matches_full_dequant_per_row() {
3668 // F32 variant.
3669 let rows = 3;
3670 let cols = 64;
3671 let f32_data: Vec<f32> = (0..rows * cols).map(|i| (i as f32) * 0.1 - 5.0).collect();
3672 let m = WeightMatrix::F32(Tensor::new(f32_data.clone(), vec![rows, cols]));
3673 for r in 0..rows {
3674 assert_eq!(m.dequant_row(r), &f32_data[r * cols..(r + 1) * cols]);
3675 }
3676
3677 // Quantized (Q8_0) variant: quantize each row independently and
3678 // compare dequant_row against dequantizing that row's bytes.
3679 let mut packed = Vec::new();
3680 for r in 0..rows {
3681 packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
3682 }
3683 let row_bytes = packed.len() / rows;
3684 let q = WeightMatrix::Quantized {
3685 data: WeightBytes::Owned(packed.clone()),
3686 rows,
3687 cols,
3688 kind: QuantKind::Q8_0,
3689 };
3690 for r in 0..rows {
3691 let expected =
3692 ferrox_quant::dequant_q8_0(&packed[r * row_bytes..(r + 1) * row_bytes]).unwrap();
3693 assert_eq!(q.dequant_row(r), expected, "Q8_0 row {r}");
3694 }
3695
3696 // Mxfp4 (two-buffer) variant: arbitrary valid bytes, compare
3697 // against the row-level reference dequantizer directly.
3698 let cols = 64;
3699 let packed: Vec<u8> = pseudo_bytes(7, rows * cols / 2);
3700 let scales: Vec<u8> = pseudo_bytes(11, rows * cols / 32);
3701 let m = WeightMatrix::Mxfp4 {
3702 packed: WeightBytes::Owned(packed.clone()),
3703 scale: WeightBytes::Owned(scales.clone()),
3704 rows,
3705 cols,
3706 };
3707 for r in 0..rows {
3708 let expected = ferrox_quant::dequant_mxfp4_row(
3709 &packed[r * cols / 2..(r + 1) * cols / 2],
3710 &scales[r * cols / 32..(r + 1) * cols / 32],
3711 )
3712 .unwrap();
3713 assert_eq!(m.dequant_row(r), expected, "Mxfp4 row {r}");
3714 }
3715 }
3716
3717 /// A quantized matrix used as an embedding table: `dequant_row`
3718 /// then a dot product must agree with `apply` against a one-hot...
3719 /// no -- more directly, with the fused `dot` of that row, proving
3720 /// row lookup and matmul read identical bytes.
3721 #[test]
3722 fn dequant_row_agrees_with_fused_dot_on_the_same_row() {
3723 let rows = 4;
3724 let cols = 64;
3725 let f32_data: Vec<f32> = (0..rows * cols)
3726 .map(|i| ((i as f32) * 0.13).sin())
3727 .collect();
3728 let mut packed = Vec::new();
3729 for r in 0..rows {
3730 packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
3731 }
3732 let q = WeightMatrix::Quantized {
3733 data: WeightBytes::Owned(packed),
3734 rows,
3735 cols,
3736 kind: QuantKind::Q8_0,
3737 };
3738 let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.031).cos()).collect();
3739 let applied = q.apply(&x);
3740 // With `FERROX_CPU_INT_DOT` on, `apply` quantizes the ACTIVATION to
3741 // int8 as well, so the two sides no longer differ only by float
3742 // summation order and a fixed 1e-4 is not the right bar -- it fired
3743 // at 6.5e-3 on a result of 5.25, which is the activation error, not
3744 // a byte disagreement. The worst case is derivable rather than
3745 // guessed: `quantize_activations_q8` rounds to `d = amax/127`, so
3746 // each element moves by at most `d/2`, and the dot's error is
3747 // bounded by that times the row's L1 norm.
3748 let bound = |row: &[f32]| {
3749 if !cpu_int_dot_for(IntDotShape::Matvec) {
3750 return 1e-4;
3751 }
3752 let amax = x.iter().fold(0f32, |m, v| m.max(v.abs()));
3753 let l1: f32 = row.iter().map(|w| w.abs()).sum();
3754 (amax / 127.0 / 2.0) * l1
3755 };
3756 for (r, &got) in applied.iter().enumerate() {
3757 let row = q.dequant_row(r);
3758 let via_row: f32 = row.iter().zip(&x).map(|(a, b)| a * b).sum();
3759 let bound = bound(&row);
3760 assert!(
3761 (got - via_row).abs() < bound,
3762 "row {r}: apply={got} via dequant_row={via_row} (bound {bound:e})"
3763 );
3764 }
3765 }
3766
3767 fn make_q8_0_row(values: &[f32]) -> Vec<u8> {
3768 ferrox_quant::quantize_q8_0(values)
3769 }
3770
3771 /// Deterministic byte generator for MXFP4 test fixtures (no
3772 /// quantizer exists in `ferrox_quant` -- MXFP4 is only ever a
3773 /// real, already-quantized checkpoint format, never produced by
3774 /// ferrox -- so tests build arbitrary-but-valid-shaped bytes
3775 /// directly, same convention as `ferrox-models::kimi_loader`'s
3776 /// tests).
3777 fn pseudo_bytes(seed: u32, len: usize) -> Vec<u8> {
3778 let mut state = seed.wrapping_mul(2654435761).wrapping_add(1);
3779 (0..len)
3780 .map(|_| {
3781 state = state.wrapping_mul(1103515245).wrapping_add(12345);
3782 (state >> 16) as u8
3783 })
3784 .collect()
3785 }
3786
3787 /// Clamped to a realistic E8M0 scale range -- see
3788 /// `ferrox-models::kimi_loader`'s identical helper for why (byte
3789 /// 255 is OCP-spec-reserved for NaN, and bytes above ~252 can
3790 /// legitimately overflow f32::MAX when combined with E2M1's max
3791 /// magnitude; neither is representative of a real trained weight).
3792 fn pseudo_mxfp4_scale_bytes(seed: u32, len: usize) -> Vec<u8> {
3793 pseudo_bytes(seed, len)
3794 .into_iter()
3795 .map(|b| b % 180)
3796 .collect()
3797 }
3798
3799 #[test]
3800 fn f32_and_mxfp4_paths_agree() {
3801 let rows = 2;
3802 let cols = 64; // 2 MXFP4 groups of 32 per row
3803 let packed = pseudo_bytes(1, rows * (cols / 2));
3804 let scale = pseudo_mxfp4_scale_bytes(2, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
3805 let x: Vec<f32> = (0..cols).map(|i| (i as f32) * 0.01 - 0.3).collect();
3806
3807 // Independent reference: dequantize each row to plain f32 (the
3808 // already-tested `dequant_mxfp4_row`), then use the ordinary
3809 // F32 matmul path.
3810 let mut f32_weights = Vec::with_capacity(rows * cols);
3811 for r in 0..rows {
3812 let prow = &packed[r * (cols / 2)..(r + 1) * (cols / 2)];
3813 let srow = &scale[r * (cols / ferrox_quant::MXFP4_GROUP_SIZE)
3814 ..(r + 1) * (cols / ferrox_quant::MXFP4_GROUP_SIZE)];
3815 f32_weights.extend(ferrox_quant::dequant_mxfp4_row(prow, srow).unwrap());
3816 }
3817 let f32_matrix = WeightMatrix::F32(Tensor::new(f32_weights, vec![rows, cols]));
3818 let f32_out = f32_matrix.apply(&x);
3819
3820 let mxfp4_matrix = WeightMatrix::Mxfp4 {
3821 packed: WeightBytes::Owned(packed),
3822 scale: WeightBytes::Owned(scale),
3823 rows,
3824 cols,
3825 };
3826 let mxfp4_out = mxfp4_matrix.apply(&x);
3827
3828 assert_eq!(f32_out.len(), rows);
3829 assert_eq!(mxfp4_out.len(), rows);
3830 for (f, m) in f32_out.iter().zip(mxfp4_out.iter()) {
3831 assert!((f - m).abs() < 1e-3, "f32={f} mxfp4={m}");
3832 }
3833 }
3834
3835 #[test]
3836 fn mxfp4_apply_batch_matches_sequential_apply_calls() {
3837 let rows = 3;
3838 let cols = 64;
3839 let packed = pseudo_bytes(3, rows * (cols / 2));
3840 let scale = pseudo_mxfp4_scale_bytes(4, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
3841 let matrix = WeightMatrix::Mxfp4 {
3842 packed: WeightBytes::Owned(packed),
3843 scale: WeightBytes::Owned(scale),
3844 rows,
3845 cols,
3846 };
3847
3848 let batch_size = 4;
3849 let x_batch: Vec<f32> = (0..batch_size * cols)
3850 .map(|i| ((i % 13) as f32) * 0.02 - 0.15)
3851 .collect();
3852
3853 let batched = matrix.apply_batch(&x_batch, batch_size);
3854 assert_eq!(batched.len(), batch_size * rows);
3855
3856 for b in 0..batch_size {
3857 let x = &x_batch[b * cols..(b + 1) * cols];
3858 let sequential = matrix.apply(x);
3859 let from_batch = &batched[b * rows..(b + 1) * rows];
3860 assert_eq!(
3861 sequential, from_batch,
3862 "batch row {b} disagrees with sequential apply()"
3863 );
3864 }
3865 }
3866
3867 #[test]
3868 fn mxfp4_resident_bytes_matches_the_packed_plus_scale_byte_count_not_eager_f32() {
3869 let rows = 2;
3870 let cols = 64;
3871 let packed = pseudo_bytes(5, rows * (cols / 2));
3872 let scale = pseudo_mxfp4_scale_bytes(6, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
3873 let packed_len = packed.len();
3874 let scale_len = scale.len();
3875 let matrix = WeightMatrix::Mxfp4 {
3876 packed: WeightBytes::Owned(packed),
3877 scale: WeightBytes::Owned(scale),
3878 rows,
3879 cols,
3880 };
3881
3882 assert_eq!(matrix.resident_bytes(), packed_len + scale_len);
3883 // Real MXFP4 packs 2 values/byte plus 1 scale byte per 32
3884 // values -- resident_bytes should be far below the 4-bytes-
3885 // per-value eager-f32 footprint.
3886 let eager_f32_bytes = rows * cols * 4;
3887 assert!(
3888 matrix.resident_bytes() * 4 < eager_f32_bytes,
3889 "expected MXFP4 resident bytes well under 1/4 of eager f32: got {} vs {}",
3890 matrix.resident_bytes(),
3891 eager_f32_bytes
3892 );
3893 }
3894
3895 #[test]
3896 fn f32_and_quantized_paths_agree_within_quant_error() {
3897 // 1 row, 32 cols, values chosen to keep Q8_0 error small.
3898 let weights: Vec<f32> = (0..32).map(|i| ((i as f32) - 16.0) * 0.2).collect();
3899 let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.05 - 0.8).collect();
3900
3901 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
3902 let f32_out = f32_matrix.apply(&x);
3903
3904 let packed = make_q8_0_row(&weights);
3905 let quant_matrix = WeightMatrix::Quantized {
3906 data: WeightBytes::Owned(packed),
3907 rows: 1,
3908 cols: 32,
3909 kind: QuantKind::Q8_0,
3910 };
3911 let quant_out = quant_matrix.apply(&x);
3912
3913 assert_eq!(f32_out.len(), 1);
3914 assert_eq!(quant_out.len(), 1);
3915 assert!(
3916 (f32_out[0] - quant_out[0]).abs() < 0.05,
3917 "f32={} quant={}",
3918 f32_out[0],
3919 quant_out[0]
3920 );
3921 }
3922
3923 #[test]
3924 fn quantized_resident_bytes_is_smaller_than_f32() {
3925 let weights = vec![0.1f32; 64]; // 2 rows x 32 cols
3926 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![2, 32]));
3927
3928 let mut packed = Vec::new();
3929 for chunk in weights.chunks(32) {
3930 packed.extend(ferrox_quant::quantize_q8_0(chunk));
3931 }
3932 let quant_matrix = WeightMatrix::Quantized {
3933 data: WeightBytes::Owned(packed),
3934 rows: 2,
3935 cols: 32,
3936 kind: QuantKind::Q8_0,
3937 };
3938
3939 assert_eq!(f32_matrix.resident_bytes(), 64 * 4); // 256 bytes
3940 assert_eq!(quant_matrix.resident_bytes(), 2 * 34); // 68 bytes
3941 assert!(quant_matrix.resident_bytes() < f32_matrix.resident_bytes());
3942 // Q8_0 should be close to the theoretical ~4x reduction vs f32.
3943 let ratio = f32_matrix.resident_bytes() as f32 / quant_matrix.resident_bytes() as f32;
3944 assert!(ratio > 3.5, "expected ~4x reduction, got {ratio}x");
3945 }
3946
3947 #[test]
3948 fn rows_and_cols_report_correctly_for_both_variants() {
3949 let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
3950 assert_eq!(f32_matrix.rows(), 2);
3951 assert_eq!(f32_matrix.cols(), 3);
3952
3953 let quant_matrix = WeightMatrix::Quantized {
3954 data: WeightBytes::Owned(vec![0u8; 34]),
3955 rows: 1,
3956 cols: 32,
3957 kind: QuantKind::Q8_0,
3958 };
3959 assert_eq!(quant_matrix.rows(), 1);
3960 assert_eq!(quant_matrix.cols(), 32);
3961 }
3962
3963 #[test]
3964 #[should_panic]
3965 fn apply_panics_on_activation_length_mismatch() {
3966 let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
3967 f32_matrix.apply(&[1.0, 2.0]); // wrong length (needs 3)
3968 }
3969
3970 #[test]
3971 fn apply_batch_with_batch_size_one_matches_apply() {
3972 // Pinned, not inherited. This asserts `apply` and `apply_batch`
3973 // are BIT-identical, which is only true while both take the same
3974 // kernel -- and since #152 they do not on x86, where the batch
3975 // half of the int-dot tier is taken and the matvec half is not.
3976 // The override is process-global, so without the guard a
3977 // concurrent test holding it on decides this one's result.
3978 let _int_dot = ForceIntDot::new(false);
3979 let weights: Vec<f32> = (0..32).map(|i| (i as f32 - 16.0) * 0.13).collect();
3980 let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.02 - 0.3).collect();
3981
3982 let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
3983 let single = f32_matrix.apply(&x);
3984 let batched = f32_matrix.apply_batch(&x, 1);
3985 assert_eq!(single, batched);
3986
3987 let packed = ferrox_quant::quantize_q8_0(&weights);
3988 let quant_matrix = WeightMatrix::Quantized {
3989 data: WeightBytes::Owned(packed),
3990 rows: 1,
3991 cols: 32,
3992 kind: QuantKind::Q8_0,
3993 };
3994 let single_q = quant_matrix.apply(&x);
3995 let batched_q = quant_matrix.apply_batch(&x, 1);
3996 assert_eq!(single_q, batched_q);
3997 }
3998
3999 #[test]
4000 fn apply_batch_matches_sequential_apply_calls_for_each_row_f32() {
4001 let rows = 3;
4002 let cols = 32;
4003 let weights: Vec<f32> = (0..rows * cols)
4004 .map(|i| ((i % 17) as f32 - 8.0) * 0.05)
4005 .collect();
4006 let matrix = WeightMatrix::F32(Tensor::new(weights, vec![rows, cols]));
4007
4008 let batch_size = 4;
4009 let x_batch: Vec<f32> = (0..batch_size * cols)
4010 .map(|i| ((i % 13) as f32) * 0.03 - 0.2)
4011 .collect();
4012
4013 let batched = matrix.apply_batch(&x_batch, batch_size);
4014 assert_eq!(batched.len(), batch_size * rows);
4015
4016 for b in 0..batch_size {
4017 let x = &x_batch[b * cols..(b + 1) * cols];
4018 let sequential = matrix.apply(x);
4019 let from_batch = &batched[b * rows..(b + 1) * rows];
4020 assert_eq!(
4021 sequential, from_batch,
4022 "batch row {b} disagrees with sequential apply()"
4023 );
4024 }
4025 }
4026
4027 #[test]
4028 fn apply_batch_matches_sequential_apply_calls_for_each_row_quantized() {
4029 let rows = 3;
4030 let cols = 32;
4031 let weights: Vec<f32> = (0..rows * cols)
4032 .map(|i| ((i % 19) as f32 - 9.0) * 0.07)
4033 .collect();
4034 let mut packed = Vec::new();
4035 for row in weights.chunks(cols) {
4036 packed.extend(ferrox_quant::quantize_q8_0(row));
4037 }
4038 let matrix = WeightMatrix::Quantized {
4039 data: WeightBytes::Owned(packed),
4040 rows,
4041 cols,
4042 kind: QuantKind::Q8_0,
4043 };
4044
4045 let batch_size = 5;
4046 let x_batch: Vec<f32> = (0..batch_size * cols)
4047 .map(|i| ((i % 11) as f32) * 0.04 - 0.25)
4048 .collect();
4049
4050 let batched = matrix.apply_batch(&x_batch, batch_size);
4051 assert_eq!(batched.len(), batch_size * rows);
4052
4053 for b in 0..batch_size {
4054 let x = &x_batch[b * cols..(b + 1) * cols];
4055 let sequential = matrix.apply(x);
4056 let from_batch = &batched[b * rows..(b + 1) * rows];
4057 assert_batch_row_matches(QuantKind::Q8_0, "", b, &sequential, from_batch);
4058 }
4059 }
4060
4061 /// Minimal f16 encode for small positive normals (test fixtures only).
4062 pub(super) fn f16_le(x: f32) -> [u8; 2] {
4063 let bits = x.to_bits();
4064 let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
4065 let mant = (bits >> 13) & 0x3ff;
4066 (((exp as u16) << 10) | mant as u16).to_le_bytes()
4067 }
4068
4069 /// Deterministic pseudo-random quantized matrix: every byte pattern is
4070 /// a valid weight block, only the f16 scale fields need sane values.
4071 /// Compare one row of `apply_batch` against `apply`, scaled by the
4072 /// magnitude of the row rather than of each element.
4073 ///
4074 /// The element-wise denominator (`err / s.abs().max(1.0)`) is wrong
4075 /// for a dot product over random data: the sums cancel, so a result
4076 /// that lands near zero turns a normal rounding difference into a
4077 /// relative error of 30%. Measured on Metal, the divergence is a
4078 /// uniform 5.5e-4 of the row's own scale across every quant kind
4079 /// and batch index, and up to 2.9e-1 of the individual result. The
4080 /// first number describes the arithmetic; the second describes
4081 /// which results happened to cancel.
4082 ///
4083 /// This matters because `apply_batch` is not `apply` on a GPU
4084 /// build: `apply_batch` dispatches to Metal while `apply` stays on
4085 /// the CPU, so this compares two backends. The bound stays tight on
4086 /// CPU, where both sides are the same code and must agree closely.
4087 fn assert_batch_row_matches(
4088 kind: QuantKind,
4089 ctx: &str,
4090 b: usize,
4091 sequential: &[f32],
4092 from_batch: &[f32],
4093 ) {
4094 let scale = sequential
4095 .iter()
4096 .fold(0.0f32, |a, v| a.max(v.abs()))
4097 .max(1.0);
4098 // A GPU build compares Metal against the CPU; a CPU build
4099 // compares the CPU against itself -- UNLESS this host takes only
4100 // one half of the int-dot tier, in which case `apply` and
4101 // `apply_batch` are not the same arithmetic at all.
4102 //
4103 // That is x86 since #152: the batch half runs the AVX2
4104 // interleaved GEMM over an int8-quantized activation while the
4105 // matvec half stays on the f32 AVX2 dot, because the int8 matvec
4106 // measured 4x to 8.8x slower there. The gap between the two
4107 // sides is then the ACTIVATION quantization floor -- each element
4108 // of `x` moves by up to `d/2` at `d = amax/127` -- not float
4109 // summation order, and a 1e-4 bar describes the wrong thing.
4110 //
4111 // Measured across every shape in these tests on a linux/amd64
4112 // container with real AVX2 (2026-09-09): worst 7.9e-3 of the row
4113 // scale. 6e-2 keeps a 7.6x margin, the same discipline as
4114 // `int_dot_batch_matches_dequant_dot_reference`, and is still far
4115 // inside a mis-pack, which decorrelates the two outputs entirely.
4116 let mixed = cpu_int_dot_for(IntDotShape::Matvec) != cpu_int_dot_for(IntDotShape::BatchGemm);
4117 let bound = if cfg!(any(feature = "metal", feature = "cuda")) {
4118 5e-3
4119 } else if mixed {
4120 6e-2
4121 } else {
4122 1e-4
4123 };
4124 for (r, (s, got)) in sequential.iter().zip(from_batch.iter()).enumerate() {
4125 let err = (s - got).abs() / scale;
4126 assert!(
4127 err < bound,
4128 "{kind:?} {ctx} batch {b} row {r}: apply()={s} apply_batch={got} \
4129 (err {err:e} of row scale {scale}, bound {bound:e})"
4130 );
4131 }
4132 }
4133
4134 fn synth_quant_matrix(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
4135 let mut state = 0x1234_5678u32;
4136 let mut next = move || {
4137 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
4138 (state >> 24) as u8
4139 };
4140 let mut data = Vec::new();
4141 match kind {
4142 QuantKind::Q8_0 | QuantKind::Q4_0 => {
4143 let qs = if kind == QuantKind::Q8_0 { 32 } else { 16 };
4144 for _ in 0..rows * (cols / 32) {
4145 data.extend_from_slice(&f16_le(0.02 + f32::from(next()) * 0.0004));
4146 for _ in 0..qs {
4147 data.push(next());
4148 }
4149 }
4150 }
4151 QuantKind::Q4K | QuantKind::Q5K => {
4152 let body = if kind == QuantKind::Q4K {
4153 12 + 128
4154 } else {
4155 12 + 32 + 128
4156 };
4157 for _ in 0..rows * (cols / 256) {
4158 data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4159 data.extend_from_slice(&f16_le(0.005 + f32::from(next()) * 0.0001));
4160 for _ in 0..body {
4161 data.push(next());
4162 }
4163 }
4164 }
4165 QuantKind::Q6K => {
4166 for _ in 0..rows * (cols / 256) {
4167 for _ in 0..128 + 64 + 16 {
4168 data.push(next());
4169 }
4170 data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4171 }
4172 }
4173 _ => unreachable!("synth_quant_matrix: unsupported kind"),
4174 }
4175 WeightMatrix::Quantized {
4176 data: WeightBytes::Owned(data),
4177 rows,
4178 cols,
4179 kind,
4180 }
4181 }
4182 /// One `apply_batch` vs per-row `apply` sweep, parameterized by shape
4183 /// so the shape tests below differ only in the numbers they pass.
4184 fn assert_apply_batch_matches_apply(
4185 kind: QuantKind,
4186 rows: usize,
4187 cols: usize,
4188 batch_size: usize,
4189 seed: usize,
4190 ) {
4191 let x_batch: Vec<f32> = (0..batch_size * cols)
4192 .map(|i| (((i * 31 + seed) % 97) as f32) * 0.021 - 1.0)
4193 .collect();
4194 let matrix = synth_quant_matrix(kind, rows, cols);
4195 let batched = matrix.apply_batch(&x_batch, batch_size);
4196 assert_eq!(batched.len(), batch_size * rows);
4197 let ctx = format!(
4198 "rows {rows} cols {cols} batch_size {batch_size} int_dot {}",
4199 cpu_int_dot_for(IntDotShape::BatchGemm)
4200 );
4201 for b in 0..batch_size {
4202 let x = &x_batch[b * cols..(b + 1) * cols];
4203 let sequential = matrix.apply(x);
4204 let from_batch = &batched[b * rows..(b + 1) * rows];
4205 // Delegates rather than restating the bound. The first
4206 // version of this helper compared each element against
4207 // `s.abs().max(1.0)`, which is a bare 1e-4 ABSOLUTE bound
4208 // for any row whose value is small -- and a dot product of
4209 // 512 terms that cancels to -0.76 carries the rounding of
4210 // the terms, not of the result. It passed on aarch64 and
4211 // failed on x86_64 CI at 1.07e-4, on one row out of 17094.
4212 // `assert_batch_row_matches` already divides by the row
4213 // vector's own scale, which is the invariant that makes the
4214 // comparison meaningful, and it is now the only place the
4215 // tolerance is written down.
4216 assert_batch_row_matches(kind, &ctx, b, &sequential, from_batch);
4217 }
4218 }
4219
4220 const BATCH_SHAPE_KINDS: [QuantKind; 5] = [
4221 QuantKind::Q8_0,
4222 QuantKind::Q4_0,
4223 QuantKind::Q4K,
4224 QuantKind::Q5K,
4225 QuantKind::Q6K,
4226 ];
4227
4228 /// `apply_batch` writes straight into the `[batch][rows]` output from
4229 /// parallel tasks (no staging transpose); the shapes here force every
4230 /// write pattern: full row-groups, a tail of leftover rows, and both
4231 /// full and partial activation tiles.
4232 ///
4233 /// Run under both settings of [`cpu_int_dot_enabled`]. With int-dot
4234 /// off, `apply_batch` dequantizes and the repack tier is skipped
4235 /// entirely; with it on -- which is what every shipped binary does,
4236 /// via `default_cpu_int_dot_on` -- the interleaved `block_q*_Kx8` /
4237 /// `block_q*_0x4` kernels and, on an i8mm host, the SMMLA GEMMs are
4238 /// the code under test. `cargo test` leaves the env var unset, so
4239 /// without [`ForceIntDot`] only the first of those two ever ran.
4240 #[test]
4241 fn apply_batch_matches_apply_across_kinds_with_groups_and_tail() {
4242 for int_dot in [false, true] {
4243 let _g = ForceIntDot::new(int_dot);
4244 for kind in BATCH_SHAPE_KINDS {
4245 // 19 rows = 2x8-row groups + 3 tail (4x4-row groups + 3
4246 // for Q8_0/Q4_0); 6 activations = one full 4-tile + a
4247 // partial one.
4248 assert_apply_batch_matches_apply(kind, 19, 512, 6, 7);
4249 }
4250 }
4251 }
4252
4253 /// Shapes too small to fill one interleaved row-group, which the
4254 /// tests around this one never reach: they use `rows` big enough that
4255 /// `n_groups > 0` for every kind. At `rows < 8` the K-quant arms take
4256 /// their `else` branch (per-row `gemm_q*_k_q8_row`) with the repack
4257 /// path completely bypassed, and at `rows < 4` the Q8_0/Q4_0 arms do
4258 /// the same with `dot_q*_q8`. `rows = 5` is the mixed case: one full
4259 /// `block_q*_0x4` group plus a 1-row tail for Q8_0/Q4_0, zero groups
4260 /// for the Kx8 kinds. `rows = 1` is the single-row case.
4261 ///
4262 /// `cols = 256` is also the minimum K for a K-quant -- a single
4263 /// super-block, so every kernel's block loop runs exactly one trip.
4264 /// `batch_size = 1` is the single-column case: one activation in the
4265 /// quad, `na = 1` with three zero-padded lanes in `Q8KActsX4` /
4266 /// `Q8ActsX4`.
4267 #[test]
4268 fn apply_batch_matches_apply_for_sub_tile_shapes() {
4269 for int_dot in [false, true] {
4270 let _g = ForceIntDot::new(int_dot);
4271 for kind in BATCH_SHAPE_KINDS {
4272 for rows in [1, 2, 3, 5, 7] {
4273 for batch_size in [1, 2, 5] {
4274 assert_apply_batch_matches_apply(kind, rows, 256, batch_size, 13);
4275 }
4276 }
4277 }
4278 }
4279 }
4280
4281 /// `apply_batch` under int-dot against an f32 dequantize-and-dot
4282 /// reference that never touches the packed buffer.
4283 ///
4284 /// Every other batch test compares `apply_batch` against `apply`,
4285 /// which under int-dot is the packed **GEMV** against the packed
4286 /// **GEMM** -- two kernels reading the *same* interleaved bytes. That
4287 /// catches a bad kernel but is structurally blind to a bad
4288 /// `pack_q*_matrix_x*`: both sides read the same wrong bytes and
4289 /// agree. `dequant_row` is the only reference in the tree that
4290 /// re-derives the weights from the canonical GGUF blocks, so it is
4291 /// the only one that can see a mis-interleave.
4292 ///
4293 /// The bound is the Q8/Q8_K *activation* quantization floor, not the
4294 /// kernel's, and it is scaled by the RMS of the reference outputs
4295 /// rather than per element: these synthetic weights are uniform
4296 /// random bytes, so individual dots cancel to near zero and a
4297 /// per-element relative bound would be meaningless. Worst deviation
4298 /// measured across every shape below, on an M2 Pro (i8mm), is 0.016 x
4299 /// RMS; 0.12 keeps a 7x margin. Coarse on purpose -- a mis-pack
4300 /// decorrelates the output from the reference entirely (measured at
4301 /// 2.07 x RMS for a one-row shift in the Q5_K `qh` interleave), an
4302 /// order of magnitude past this bound.
4303 #[test]
4304 fn int_dot_batch_matches_dequant_dot_reference() {
4305 let _g = ForceIntDot::new(true);
4306 // The batch half needs a SIMD `x4` GEMM, so a host without
4307 // one (an x86 box with no AVX2, Rosetta included) has no
4308 // packed path to test. Skipping is honest; asserting would
4309 // make the suite red for a host that is behaving correctly.
4310 assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4311 if !cpu_int_dot_for(IntDotShape::BatchGemm) {
4312 return;
4313 }
4314 for kind in BATCH_SHAPE_KINDS {
4315 // Rows straddle both tile widths: below the tile, one short
4316 // of it, exactly it, one past it, and multi-group with a
4317 // tail. Batch straddles the 4-wide activation quad. cols 256
4318 // is the minimum K for a K-quant (one super-block).
4319 for rows in [1, 3, 5, 7, 8, 9, 19] {
4320 for cols in [256, 512] {
4321 for batch_size in [1, 3, 4, 9] {
4322 assert_int_dot_matches_dequant_dot(kind, rows, cols, batch_size, 23);
4323 }
4324 }
4325 }
4326 }
4327 // Q8_0/Q4_0 alone can go down to a single 32-element block.
4328 for kind in [QuantKind::Q8_0, QuantKind::Q4_0] {
4329 for rows in [1, 3, 4, 5, 11] {
4330 for batch_size in [1, 3, 4, 9] {
4331 assert_int_dot_matches_dequant_dot(kind, rows, 32, batch_size, 29);
4332 }
4333 }
4334 }
4335 }
4336
4337 fn assert_int_dot_matches_dequant_dot(
4338 kind: QuantKind,
4339 rows: usize,
4340 cols: usize,
4341 batch_size: usize,
4342 seed: usize,
4343 ) {
4344 let x_batch: Vec<f32> = (0..batch_size * cols)
4345 .map(|i| (((i * 37 + seed) % 89) as f32) * 0.019 - 0.8)
4346 .collect();
4347 let matrix = synth_quant_matrix(kind, rows, cols);
4348 let got = matrix.apply_batch(&x_batch, batch_size);
4349 assert_eq!(got.len(), batch_size * rows);
4350
4351 let mut want = vec![0f32; batch_size * rows];
4352 for r in 0..rows {
4353 let w = matrix.dequant_row(r);
4354 assert_eq!(w.len(), cols);
4355 for b in 0..batch_size {
4356 let x = &x_batch[b * cols..(b + 1) * cols];
4357 want[b * rows + r] = w.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4358 }
4359 }
4360 let rms = (want.iter().map(|v| v * v).sum::<f32>() / want.len() as f32).sqrt();
4361 for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
4362 let err = (g - w).abs();
4363 assert!(
4364 err < 0.12 * rms.max(1e-3),
4365 "{kind:?} rows {rows} cols {cols} batch_size {batch_size} [flat {i}]: \
4366 int-dot={g} dequant-dot={w} (err {err}, rms {rms})"
4367 );
4368 }
4369 }
4370
4371 /// Large enough that `par_chunked_groups` builds a real 2D chunk grid
4372 /// (32 row-groups × 17 activation tiles) instead of falling back to
4373 /// one-chunk-per-thread — every (group, tile-range) seam in the
4374 /// chunked scatter is crossed. The smaller cross-kind test above
4375 /// covers the fallback path. Both int-dot settings, for the same
4376 /// reason as that test.
4377 #[test]
4378 fn apply_batch_chunked_grid_matches_apply() {
4379 for int_dot in [false, true] {
4380 let _g = ForceIntDot::new(int_dot);
4381 for kind in BATCH_SHAPE_KINDS {
4382 // 259 rows = 32 groups of 8 + 3 tail (64 of 4 + 3 for
4383 // Q8_0/Q4_0); 66 activations = 16 full 4-tiles + a
4384 // partial one.
4385 assert_apply_batch_matches_apply(kind, 259, 512, 66, 5);
4386 }
4387 }
4388 }
4389
4390 /// Sharing one quantized activation batch across projections must be
4391 /// invisible in the results: a matching `BatchActs` produces exactly
4392 /// what `apply_batch` produces (same quantization, same interleaved
4393 /// quads, same kernels), and a mismatched variant is ignored rather
4394 /// than misused.
4395 #[test]
4396 fn apply_batch_with_shared_acts_matches_apply_batch() {
4397 // Shared quads are built under one setting and consumed under
4398 // another if a concurrent test flips the global mid-run; pin it
4399 // on, which is also the setting that gives this test something
4400 // to compare.
4401 let _int_dot = ForceIntDot::new(true);
4402 let rows = 19;
4403 let cols = 512;
4404 let batch_size = 6;
4405 let x_batch: Vec<f32> = (0..batch_size * cols)
4406 .map(|i| (((i * 29 + 11) % 89) as f32) * 0.023 - 1.0)
4407 .collect();
4408 for kind in [
4409 QuantKind::Q8_0,
4410 QuantKind::Q4_0,
4411 QuantKind::Q4K,
4412 QuantKind::Q6K,
4413 ] {
4414 let matrix = synth_quant_matrix(kind, rows, cols);
4415 let baseline = matrix.apply_batch(&x_batch, batch_size);
4416
4417 let shared = matrix.quantize_batch_acts(&x_batch, batch_size);
4418 let with_shared = matrix.apply_batch_with_acts(&x_batch, batch_size, shared.as_ref());
4419 assert_eq!(
4420 baseline, with_shared,
4421 "{kind:?}: shared acts changed the result"
4422 );
4423
4424 let wrong = match kind {
4425 QuantKind::Q8_0 | QuantKind::Q4_0 => BatchActs::Q8K {
4426 acts: Vec::new(),
4427 tiles: Vec::new(),
4428 cols,
4429 },
4430 _ => BatchActs::Q8 {
4431 acts: Vec::new(),
4432 tiles: Vec::new(),
4433 cols,
4434 },
4435 };
4436 let with_wrong = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&wrong));
4437 assert_eq!(
4438 baseline, with_wrong,
4439 "{kind:?}: mismatched shared acts were not ignored"
4440 );
4441 }
4442 }
4443
4444 /// The interleaved quads now ride along with the activations, so the
4445 /// guard that decides whether a `shared` batch is usable has to cover
4446 /// them too -- and that guard is the one thing here that is not gated
4447 /// on `FERROX_CPU_INT_DOT`, so it is tested directly.
4448 ///
4449 /// A stale set is not a panic. The quads are indexed by super-block, so
4450 /// a batch prepared at another width either reads past its own end or
4451 /// silently dots the wrong columns; both surface as a wrong answer.
4452 /// What must happen instead is a local re-quantization with no quads,
4453 /// which is what the fresh-fallback assertions below pin.
4454 #[test]
4455 fn shared_acts_are_reused_only_at_the_matching_length_and_width() {
4456 let cols = 512;
4457 let batch_size = 7;
4458 let x_batch: Vec<f32> = (0..batch_size * cols)
4459 .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
4460 .collect();
4461
4462 let acts: Vec<_> = (0..batch_size)
4463 .map(|b| ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
4464 .collect();
4465 let tiles: Vec<_> = acts
4466 .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4467 .map(|c| ferrox_quant::prepare_q8_k_acts_x4(c, cols))
4468 .collect();
4469 let n_tiles = tiles.len();
4470 let shared = BatchActs::Q8K { acts, tiles, cols };
4471
4472 let mut owned = Vec::new();
4473 let (got, quads) =
4474 WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4475 assert_eq!(got.len(), batch_size);
4476 assert_eq!(
4477 quads.len(),
4478 n_tiles,
4479 "matching batch did not reuse its quads"
4480 );
4481 assert!(owned.is_empty(), "matching batch was re-quantized anyway");
4482
4483 // Same positions, another width: refuse and re-quantize.
4484 let mut owned = Vec::new();
4485 let (got, quads) =
4486 WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4487 assert!(quads.is_empty(), "quads from another width were accepted");
4488 assert_eq!(got.len(), batch_size);
4489 assert_eq!(got[0].n_blocks(), 1, "fallback did not quantize at 256");
4490
4491 // Same width, another position count: refuse and re-quantize.
4492 let mut owned = Vec::new();
4493 let (got, quads) =
4494 WeightMatrix::q8k_acts(Some(&shared), &x_batch[..cols], 1, cols, &mut owned);
4495 assert!(quads.is_empty(), "quads for another batch were accepted");
4496 assert_eq!(got.len(), 1);
4497
4498 // The Q8_0 half of the same guard.
4499 let acts: Vec<_> = (0..batch_size)
4500 .map(|b| ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
4501 .collect();
4502 let tiles: Vec<_> = acts
4503 .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4504 .map(|c| ferrox_quant::prepare_q8_acts_x4(c, cols))
4505 .collect();
4506 let n_tiles = tiles.len();
4507 let shared = BatchActs::Q8 { acts, tiles, cols };
4508
4509 let mut owned = Vec::new();
4510 let (got, quads) =
4511 WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4512 assert_eq!(got.len(), batch_size);
4513 assert_eq!(
4514 quads.len(),
4515 n_tiles,
4516 "matching batch did not reuse its quads"
4517 );
4518
4519 let mut owned = Vec::new();
4520 let (got, quads) =
4521 WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4522 assert!(quads.is_empty(), "quads from another width were accepted");
4523 assert_eq!(got[0].n_blocks(), 8, "fallback did not quantize at 256");
4524 }
4525
4526 /// Whatever a projection would have built for itself, a sibling's
4527 /// shared batch must hand it the same thing. Q4_K, Q5_K and Q6_K read
4528 /// one Q8_K quad set between them, and Q8_0 and Q4_0 one Q8_0 set, so
4529 /// the donor's kind must not show through.
4530 ///
4531 /// Gated the same way the path itself is: with `FERROX_CPU_INT_DOT`
4532 /// off (the library default) `quantize_batch_acts` returns `None` and
4533 /// no projection consumes quads at all, so this asserts against the
4534 /// INT_DOT build. Run the suite both ways.
4535 #[test]
4536 fn shared_quads_are_what_each_consumer_would_have_built_itself() {
4537 // The early return below reads a process-global, so it has to be
4538 // pinned or a neighbour can turn the tier off between the check
4539 // and the assertions it guards.
4540 let _int_dot = ForceIntDot::new(true);
4541 if !cpu_int_dot_for(IntDotShape::BatchGemm) {
4542 return;
4543 }
4544 let rows = 24;
4545 let cols = 512;
4546 let batch_size = 7;
4547 let x_batch: Vec<f32> = (0..batch_size * cols)
4548 .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
4549 .collect();
4550
4551 for (donor, consumers) in [
4552 (QuantKind::Q4K, &[QuantKind::Q5K, QuantKind::Q6K][..]),
4553 (QuantKind::Q8_0, &[QuantKind::Q4_0][..]),
4554 ] {
4555 let shared = synth_quant_matrix(donor, rows, cols)
4556 .quantize_batch_acts(&x_batch, batch_size)
4557 .expect("INT_DOT is on and this kind/width is eligible");
4558 for kind in consumers {
4559 let matrix = synth_quant_matrix(*kind, rows, cols);
4560 let baseline = matrix.apply_batch(&x_batch, batch_size);
4561 let shared_out = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&shared));
4562 assert_eq!(
4563 baseline, shared_out,
4564 "{kind:?} consuming {donor:?} quads changed the result"
4565 );
4566 }
4567 }
4568 }
4569
4570 #[test]
4571 fn apply_batch_with_zero_batch_size_returns_empty() {
4572 let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4573 let out = matrix.apply_batch(&[], 0);
4574 assert!(out.is_empty());
4575 }
4576
4577 #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
4578 mod gpu_dispatch {
4579 use super::*;
4580
4581 /// `apply_gpu` must return `None` for `F32` -- and, crucially,
4582 /// without ever touching the CUDA driver at all (this runs on
4583 /// every CI machine, none of which have a GPU): the `let ...
4584 /// else { return None }` pattern match happens before any
4585 /// `ferrox_cuda` call, so this is a real, meaningful assertion
4586 /// about dispatch behavior, not a stub.
4587 #[test]
4588 fn apply_gpu_returns_none_for_f32() {
4589 let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4590 assert!(matrix.apply_gpu(&[0.0, 0.0, 0.0]).is_none());
4591 }
4592
4593 #[test]
4594 fn apply_gpu_returns_none_for_mxfp4() {
4595 let matrix = WeightMatrix::Mxfp4 {
4596 packed: WeightBytes::Owned(vec![0u8; 32]),
4597 scale: WeightBytes::Owned(vec![0u8; 2]),
4598 rows: 1,
4599 cols: 64,
4600 };
4601 assert!(matrix.apply_gpu(&vec![0.0; 64]).is_none());
4602 }
4603
4604 /// A `Quantized` matrix whose `kind` has no GPU kernel on any
4605 /// compiled backend must fall back to `None`, not panic on the
4606 /// `unreachable!()` in `block_bytes_for_kind` -- proving the
4607 /// two match arms (`apply_gpu`'s launch table,
4608 /// `block_bytes_for_kind`'s partial one) stay in sync.
4609 ///
4610 /// The probe was `Q2_K` until 2026-09-09, when Q2_K gained a
4611 /// CUDA matvec and a GEMM and stopped being unsupported. `Q4_1`
4612 /// has neither on any backend and is the hole now. Moving it
4613 /// found a real defect rather than being bookkeeping: with the
4614 /// `cuda` feature on and no driver present, the first real
4615 /// dispatch through `Cuda::launch_matvec` aborted the process
4616 /// inside `cudarc`'s library loader, which that arm's
4617 /// `Result` could never have reported.
4618 #[test]
4619 fn apply_gpu_returns_none_for_an_unsupported_quant_kind() {
4620 let matrix = WeightMatrix::Quantized {
4621 data: WeightBytes::Owned(vec![0u8; ferrox_quant::Q4_1_BLOCK_BYTES]),
4622 rows: 1,
4623 cols: ferrox_quant::Q4_1_BLOCK_ELEMS,
4624 kind: QuantKind::Q4_1,
4625 };
4626 assert!(matrix
4627 .apply_gpu(&[0.0; ferrox_quant::Q4_1_BLOCK_ELEMS])
4628 .is_none());
4629 }
4630
4631 #[test]
4632 #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
4633 fn apply_gpu_matches_apply_for_q8_0_on_real_hardware() {
4634 let weights: Vec<f32> = (0..64).map(|i| ((i as f32) - 32.0) * 0.05).collect();
4635 let x: Vec<f32> = (0..64).map(|i| (i as f32) * 0.01 - 0.3).collect();
4636 let packed = ferrox_quant::quantize_q8_0(&weights);
4637 let matrix = WeightMatrix::Quantized {
4638 data: WeightBytes::Owned(packed),
4639 rows: 1,
4640 cols: 64,
4641 kind: QuantKind::Q8_0,
4642 };
4643
4644 let cpu = matrix.apply_cpu(&x);
4645 let gpu = matrix
4646 .apply_gpu(&x)
4647 .expect("Q8_0 must dispatch to a real GPU kernel");
4648 assert_eq!(cpu.len(), gpu.len());
4649 for (c, g) in cpu.iter().zip(gpu.iter()) {
4650 assert!((c - g).abs() < 1e-2, "cpu={c} gpu={g}");
4651 }
4652 }
4653 }
4654
4655 // ---- kernel-lookup registry coverage -------------------------------
4656 //
4657 // These are the tests that would have caught the IQ4_XS silent CPU
4658 // prefill at `cargo test` time instead of via a 13.7x benchmark row.
4659
4660 /// A quantized matrix of `kind` with `cols` columns, filled with
4661 /// arbitrary bytes -- the probe reads only shape and kind, never the
4662 /// weights, so the contents are irrelevant.
4663 fn shaped(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
4664 let per_row = match kind {
4665 QuantKind::Q8_0 => cols / 32 * 34,
4666 _ => cols,
4667 };
4668 WeightMatrix::Quantized {
4669 data: WeightBytes::Owned(vec![0u8; rows * per_row.max(1)]),
4670 rows,
4671 cols,
4672 kind,
4673 }
4674 }
4675
4676 /// `QuantKind::ALL` must actually list every variant. `name()` is
4677 /// exhaustive by the compiler, so distinct names prove distinct
4678 /// variants; the count pins that none was dropped from the list.
4679 #[test]
4680 fn quant_kind_all_lists_every_variant_exactly_once() {
4681 let mut names: Vec<&str> = QuantKind::ALL.iter().map(|k| k.name()).collect();
4682 let total = names.len();
4683 names.sort_unstable();
4684 names.dedup();
4685 assert_eq!(names.len(), total, "QuantKind::ALL has a duplicate");
4686 assert_eq!(
4687 total, 21,
4688 "a QuantKind variant was added without updating ALL"
4689 );
4690 }
4691
4692 /// The invariant that keeps prefill honest: every kind with a Metal
4693 /// matvec also has a Metal batched GEMM. Break it and the kind still
4694 /// "runs on Metal" -- as `batch` separate matvecs over the same
4695 /// weights, which is exactly the shape that put IQ4_XS 13.7x behind
4696 /// with no symptom other than a slow benchmark.
4697 #[test]
4698 fn every_metal_matvec_kind_also_has_a_metal_gemm() {
4699 for &k in QuantKind::ALL {
4700 assert_eq!(
4701 metal_matvec_kind_name(k).is_some(),
4702 metal_mul_mm_kind_supported(k),
4703 "{}: matvec and mul_mm kernel tables disagree -- one of the two \
4704 is a silent slow path",
4705 k.name()
4706 );
4707 }
4708 }
4709
4710 /// The kind tables are pure lookups over the name, so a kind that
4711 /// claims a kernel must name itself the way the Metal launch meta
4712 /// table is keyed.
4713 #[test]
4714 fn metal_kind_names_match_the_quant_kind_names() {
4715 for &k in QuantKind::ALL {
4716 if let Some(name) = metal_matvec_kind_name(k) {
4717 assert_eq!(name, k.name());
4718 }
4719 }
4720 }
4721
4722 /// THE registry test: a kind with no accelerator kernel, probed
4723 /// while the model is built, must be recorded as a miss and must be
4724 /// a seal-time violation -- not silently absorbed by a fallback.
4725 ///
4726 /// Runs on any build: the backend is passed explicitly, so it does
4727 /// not need `--features metal` to ask what Metal would resolve.
4728 #[test]
4729 fn a_deliberately_unsupported_kind_trips_the_registry() {
4730 use crate::kernel_registry::{Backend, Outcome};
4731
4732 let reg = crate::kernel_registry::Registry::new();
4733 let loc = std::panic::Location::caller();
4734
4735 // Supported: Q4_K has both a Metal matvec and a Metal GEMM.
4736 shaped(QuantKind::Q4K, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_down", loc);
4737 // Unsupported: no Metal kernel of any kind for IQ2_XXS.
4738 shaped(QuantKind::IQ2XXS, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_up", loc);
4739
4740 let report = reg.seal();
4741 let violations = &report.violations;
4742 assert_eq!(
4743 violations.len(),
4744 2,
4745 "expected matvec + gemm misses for IQ2_XXS only, got: {:?}",
4746 report
4747 .entries
4748 .iter()
4749 .map(|e| e.to_string())
4750 .collect::<Vec<_>>()
4751 );
4752 assert!(
4753 violations
4754 .iter()
4755 .all(|v| v.key.kind == Some(QuantKind::IQ2XXS)),
4756 "Q4_K must not be flagged"
4757 );
4758 assert!(
4759 violations.iter().any(|v| matches!(
4760 v.outcome,
4761 Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
4762 )),
4763 "the report must name the fallback that will actually run"
4764 );
4765 let rendered = report.render_violations();
4766 assert!(rendered.contains("IQ2_XXS"), "{rendered}");
4767 assert!(rendered.contains("weight_matrix.rs"), "{rendered}");
4768
4769 // And the host tier it lands on is recorded too: IQ2_XXS has no
4770 // integer vec_dot either, so it is f32 dequant-dot.
4771 assert!(
4772 report.entries.iter().any(|e| e.key.backend == Backend::Cpu
4773 && e.key.kind == Some(QuantKind::IQ2XXS)
4774 && matches!(e.outcome, Outcome::Miss { fallback, .. } if fallback == "f32 dequant-dot")),
4775 "{:?}",
4776 report.entries.iter().map(|e| e.to_string()).collect::<Vec<_>>()
4777 );
4778 }
4779
4780 /// A supported kind on a selected accelerator produces no violation
4781 /// at all -- otherwise the signal is noise and gets ignored.
4782 #[test]
4783 fn a_fully_supported_model_seals_clean() {
4784 use crate::kernel_registry::Backend;
4785
4786 let reg = crate::kernel_registry::Registry::new();
4787 let loc = std::panic::Location::caller();
4788 for kind in [QuantKind::Q4K, QuantKind::Q6K, QuantKind::Q8_0] {
4789 shaped(kind, 64, 256).probe_kernels_for(®, Backend::Metal, "ffn_down", loc);
4790 }
4791 let report = reg.seal();
4792 assert!(report.violations.is_empty(), "{}", report.render());
4793 }
4794
4795 /// A kind CUDA cannot run at all must be RECORDED as leaving the
4796 /// GPU, by name, rather than left to a comment in
4797 /// `apply_batch_with_acts`.
4798 ///
4799 /// This test used to probe `Q4K` and expect the fallback
4800 /// `"CUDA per-position matvec"`, which is what a kind gets when it
4801 /// has a matvec but no GEMM. **That combination no longer exists on
4802 /// CUDA.** The K-quants gained a GEMM on 2026-09-04, motivated by
4803 /// Llama-3.2-3B Q4_K_M running pp512 at 4.88 tok/s against
4804 /// llama.cpp's 1586.80, and the invariant below now forbids the
4805 /// combination from coming back.
4806 ///
4807 /// So the probe moved to a kind with neither kernel, and the
4808 /// expected fallback moved with it: with no matvec to loop over
4809 /// there is no per-position loop, and the whole matmul leaves for
4810 /// the host.
4811 ///
4812 /// It has moved three times. `Q5_0` was that kind until
4813 /// 2026-09-05, when it gained both; `Q2_K` was until 2026-09-09,
4814 /// when it and Q3_K did. `Q4_1` is the hole now, and the next row
4815 /// of the coverage table in `docs/plans/cpu-cuda-parity.md` §6 --
4816 /// which is the point: the test names a real hole and stops
4817 /// compiling a comment. When Q4_1 lands, this probe moves again.
4818 #[test]
4819 fn a_kind_cuda_cannot_run_is_recorded_as_leaving_the_gpu() {
4820 use crate::kernel_registry::{op, Backend, Outcome};
4821
4822 let reg = crate::kernel_registry::Registry::new();
4823 let loc = std::panic::Location::caller();
4824 shaped(QuantKind::Q4_1, 64, 256).probe_kernels_for(®, Backend::Cuda, "ffn_down", loc);
4825 let report = reg.seal();
4826 assert!(
4827 report.entries.iter().any(|e| e.key.backend == Backend::Cuda
4828 && e.key.op == op::GEMM_PREFILL
4829 && matches!(
4830 e.outcome,
4831 Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
4832 )),
4833 "{}",
4834 report.render()
4835 );
4836 }
4837
4838 /// CUDA's matvec set and its GEMM set are now the same, and that is
4839 /// worth pinning: a kind that can be decoded on the GPU but not
4840 /// prefilled there is the shape that cost 325x, and it went
4841 /// unnoticed because a fallback still answers correctly.
4842 ///
4843 /// If a future kind gains a matvec without a GEMM, this fails and
4844 /// names it, rather than a benchmark noticing months later.
4845 #[test]
4846 fn a_cuda_kind_with_a_matvec_also_has_a_gemm() {
4847 for kind in QuantKind::ALL {
4848 if cuda_matvec_kind_supported(*kind) {
4849 assert!(
4850 cuda_mul_mm_kind_supported(*kind),
4851 "{kind:?} can be decoded on CUDA but not prefilled there, \
4852 which decomposes a prefill into one matvec launch per position"
4853 );
4854 }
4855 }
4856 }
4857
4858 /// An F32 weight has no quantized kernel by construction; the probe
4859 /// records the host GEMV but must not call it a violation, or every
4860 /// MoE router would fail a strict run.
4861 #[test]
4862 fn an_f32_weight_is_recorded_without_being_a_violation() {
4863 use crate::kernel_registry::Backend;
4864
4865 let reg = crate::kernel_registry::Registry::new();
4866 let m = WeightMatrix::F32(Tensor::new(vec![0.0; 64 * 32], vec![64, 32]));
4867 m.probe_kernels_for(
4868 ®,
4869 Backend::Metal,
4870 "moe_router",
4871 std::panic::Location::caller(),
4872 );
4873 let report = reg.seal();
4874 assert!(!report.misses.is_empty());
4875 assert!(report.violations.is_empty(), "{}", report.render());
4876 }
4877}
4878
4879#[cfg(test)]
4880mod int_dot_default_tests {
4881 use super::{IntDotShape, IntDotTier};
4882
4883 /// The int-dot rule follows the kernels that exist, per workload,
4884 /// not the wish that every architecture had every kernel.
4885 ///
4886 /// Taking the MATVEC half where the interleaved kernels do not exist
4887 /// selects a scalar integer loop and skips the AVX2 f32 dot that
4888 /// does, which measured 4x to 8.8x of x86 decode (#127). Adding AVX2
4889 /// GEMMs (#152) does not change that: they are batch kernels, and
4890 /// the matvec half of x86 is still the f32 dot's.
4891 #[test]
4892 fn the_matvec_half_is_taken_only_where_its_kernels_are() {
4893 assert_eq!(
4894 super::int_dot_tier_here().matvec,
4895 cfg!(target_arch = "aarch64"),
4896 "the matvec half is aarch64's (i8mm, interleave-8 NEON) and nowhere else; \
4897 x86 measured 4x to 8.8x slower with it on"
4898 );
4899 }
4900
4901 /// The BATCH half is not a `cfg!` claim: it asks the kernels.
4902 ///
4903 /// A host may only be told the batch tier is a win if
4904 /// `ferrox_quant` reports a SIMD `×4` GEMM at the width this host
4905 /// packs with. That is what stops the two structures — the list of
4906 /// architectures believed to have kernels, and the kernels — from
4907 /// drifting apart, which is how the 4x-to-8.8x regression happened
4908 /// in the first place.
4909 #[test]
4910 fn the_batch_half_is_taken_only_where_a_simd_gemm_answers_for_it() {
4911 assert_eq!(
4912 super::int_dot_tier_here().batch_gemm,
4913 ferrox_quant::interleaved_gemm_is_accelerated(ferrox_quant::preferred_interleave())
4914 && cfg!(any(target_arch = "aarch64", target_arch = "x86_64")),
4915 "the batch half must agree with the kernel probe, not with a written-down list"
4916 );
4917 }
4918
4919 /// `int_dot_is_a_win_here` — the thing `default_cpu_int_dot_on`
4920 /// consults — is the OR of the two halves, so a host with only the
4921 /// batch half still gets the env default it needs to reach it.
4922 #[test]
4923 fn the_default_is_on_when_either_half_is_a_win() {
4924 let tier = super::int_dot_tier_here();
4925 assert_eq!(
4926 super::int_dot_is_a_win_here(),
4927 tier.matvec || tier.batch_gemm
4928 );
4929 }
4930
4931 /// `covers` must actually separate the two shapes, in both
4932 /// directions — otherwise every call site below asks a question with
4933 /// one answer and the split is decoration.
4934 #[test]
4935 fn covers_answers_per_shape_rather_than_per_host() {
4936 let matvec_only = IntDotTier {
4937 matvec: true,
4938 batch_gemm: false,
4939 };
4940 let batch_only = IntDotTier {
4941 matvec: false,
4942 batch_gemm: true,
4943 };
4944 assert!(matvec_only.covers(IntDotShape::Matvec));
4945 assert!(!matvec_only.covers(IntDotShape::BatchGemm));
4946 assert!(!batch_only.covers(IntDotShape::Matvec));
4947 assert!(batch_only.covers(IntDotShape::BatchGemm));
4948 }
4949}