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