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