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