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