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