Skip to main content

ferrox_core/
weight_matrix.rs

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