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