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