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