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