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