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