Skip to main content

ferrox_core/
weight_matrix.rs

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