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