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