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