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