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    /// Any number of projections of ONE activation, in one GPU
1468    /// submission where the backend allows it.
1469    ///
1470    /// [`Self::apply_pair`] and [`Self::apply_three`] are this for the
1471    /// two arities that read better at their call sites; a gated
1472    /// delta-net layer projects `qkv`, `z` and its gate logits from the
1473    /// same normed vector and so takes this one, which is one command
1474    /// buffer per layer rather than two.
1475    pub fn apply_many(mats: &[&Self], x: &[f32]) -> Vec<Vec<f32>> {
1476        if mats.len() < 2 {
1477            return mats.iter().map(|m| m.apply(x)).collect();
1478        }
1479        if Self::gpu_dense_active() {
1480            #[cfg(any(feature = "cuda", feature = "metal"))]
1481            if let Some(outs) = Self::apply_gpu_multi(mats, x) {
1482                return outs;
1483            }
1484            return mats.iter().map(|m| m.apply(x)).collect();
1485        }
1486        // The CPU pool spreads ONE matvec across every core already, so
1487        // the arms run in sequence there for the reason `apply_three`
1488        // records: overlapping them buys a fork-join the persistent
1489        // pool does not pay.
1490        mats.iter().map(|m| m.apply(x)).collect()
1491    }
1492
1493    /// Whether a dense matvec would go to an accelerator right now.
1494    fn gpu_dense_active() -> bool {
1495        #[cfg(feature = "metal")]
1496        let gpu = metal_dense_enabled();
1497        #[cfg(not(feature = "metal"))]
1498        let gpu = false;
1499        #[cfg(feature = "cuda")]
1500        let gpu = gpu || cuda_dense_enabled();
1501        gpu
1502    }
1503
1504    pub fn apply_cpu(&self, x: &[f32]) -> Vec<f32> {
1505        assert_eq!(
1506            x.len(),
1507            self.cols(),
1508            "activation length must match matrix column count"
1509        );
1510        // Decode: one activation, so this operation is `rows x cols`
1511        // MACs and a task's share of it is (rows in task) x cols.
1512        // Publishing the shape is what lets both the scheduler choice
1513        // and the task floor be work-aware rather than row-count-aware.
1514        crate::par::with_op_work(self.rows(), x.len(), || self.apply_cpu_inner(x))
1515    }
1516
1517    fn apply_cpu_inner(&self, x: &[f32]) -> Vec<f32> {
1518        match self {
1519            WeightMatrix::F32(t) => {
1520                let xt = Tensor::new(x.to_vec(), vec![1, x.len()]);
1521                crate::matmul::matmul_f32(&xt, t).data
1522            }
1523            WeightMatrix::Quantized {
1524                data,
1525                rows,
1526                cols,
1527                kind,
1528            } => {
1529                let row_bytes = self.block_bytes_per_row(*kind, *cols);
1530                let mut out = vec![0f32; *rows];
1531                // FERROX_CPU_INT_DOT=1: quantize the shared activation once,
1532                // then every row dot is int8×int8 → i32 (llama.cpp CPU matmul).
1533                // Q8_0/Q4_0 use 32-elem Q8_0 acts; Q4_K/Q5_K/Q6_K use Q8_K.
1534                if cpu_int_dot_for(IntDotShape::Matvec) {
1535                    match *kind {
1536                        QuantKind::Q8_0 if x.len().is_multiple_of(32) => {
1537                            let act = ferrox_quant::quantize_activations_q8(x);
1538                            let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1539                            let serial = Self::prefer_serial_matvec(*rows, *cols);
1540                            // Probed once per matvec, not once per row-group:
1541                            // `q*_interleave` reads a CPU feature bit, and LLVM
1542                            // cannot hoist that relaxed atomic load out of the
1543                            // caller's loop. `is_aarch64_feature_detected!` ran
1544                            // 131k times in one Mistral-7B projection before the
1545                            // last one of these was hoisted.
1546                            let interleave = ferrox_quant::q8_0x4_interleave();
1547                            if n_groups > 0 {
1548                                let packed = get_or_repack_q8x4(data, *rows, *cols);
1549                                if serial {
1550                                    for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1551                                        .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1552                                        .enumerate()
1553                                    {
1554                                        ferrox_quant::gemv_q8_0x4_group(
1555                                            &packed, g, &act, *cols, interleave, chunk,
1556                                        );
1557                                    }
1558                                } else {
1559                                    crate::par::chunks_mut(
1560                                        &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1561                                        ferrox_quant::Q8_0X4_NROWS,
1562                                        Self::min_rows_per_task(n_groups).max(1),
1563                                        |g, chunk| {
1564                                            ferrox_quant::gemv_q8_0x4_group(
1565                                                &packed, g, &act, *cols, interleave, chunk,
1566                                            );
1567                                        },
1568                                    );
1569                                }
1570                                let data_slice = data.as_slice();
1571                                let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1572                                if tail_len > 0 {
1573                                    let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1574                                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1575                                        for (i, o) in tail.iter_mut().enumerate() {
1576                                            let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1577                                            let row =
1578                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1579                                            *o = ferrox_quant::dot_q8_0_q8(row, &act);
1580                                        }
1581                                    } else {
1582                                        let min_len = Self::min_rows_per_task(tail_len);
1583                                        crate::par::items_mut(tail, min_len, |i, o| {
1584                                            let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1585                                            let row =
1586                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1587                                            *o = ferrox_quant::dot_q8_0_q8(row, &act);
1588                                        });
1589                                    }
1590                                }
1591                                return out;
1592                            }
1593                            if serial {
1594                                for (r, o) in out.iter_mut().enumerate() {
1595                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1596                                    *o = ferrox_quant::dot_q8_0_q8(row, &act);
1597                                }
1598                            } else {
1599                                crate::par::items_mut(
1600                                    &mut out,
1601                                    Self::min_rows_per_task(*rows),
1602                                    |r, o| {
1603                                        let row =
1604                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1605                                        *o = ferrox_quant::dot_q8_0_q8(row, &act);
1606                                    },
1607                                );
1608                            }
1609                            return out;
1610                        }
1611                        QuantKind::Q4_0 if x.len().is_multiple_of(32) => {
1612                            let act = ferrox_quant::quantize_activations_q8(x);
1613                            let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1614                            let serial = Self::prefer_serial_matvec(*rows, *cols);
1615                            // Probed once per matvec, not once per row-group:
1616                            // `q*_interleave` reads a CPU feature bit, and LLVM
1617                            // cannot hoist that relaxed atomic load out of the
1618                            // caller's loop. `is_aarch64_feature_detected!` ran
1619                            // 131k times in one Mistral-7B projection before the
1620                            // last one of these was hoisted.
1621                            let interleave = ferrox_quant::q4_0x4_interleave();
1622                            if n_groups > 0 {
1623                                let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1624                                if serial {
1625                                    for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1626                                        .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1627                                        .enumerate()
1628                                    {
1629                                        ferrox_quant::gemv_q4_0x4_group(
1630                                            &packed, g, &act, *cols, interleave, chunk,
1631                                        );
1632                                    }
1633                                } else {
1634                                    crate::par::chunks_mut(
1635                                        &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1636                                        ferrox_quant::Q4_0X4_NROWS,
1637                                        Self::min_rows_per_task(n_groups).max(1),
1638                                        |g, chunk| {
1639                                            ferrox_quant::gemv_q4_0x4_group(
1640                                                &packed, g, &act, *cols, interleave, chunk,
1641                                            );
1642                                        },
1643                                    );
1644                                }
1645                                let data_slice = data.as_slice();
1646                                let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1647                                if tail_len > 0 {
1648                                    let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1649                                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1650                                        for (i, o) in tail.iter_mut().enumerate() {
1651                                            let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1652                                            let row =
1653                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1654                                            *o = ferrox_quant::dot_q4_0_q8(row, &act);
1655                                        }
1656                                    } else {
1657                                        let min_len = Self::min_rows_per_task(tail_len);
1658                                        crate::par::items_mut(tail, min_len, |i, o| {
1659                                            let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1660                                            let row =
1661                                                &data_slice[r * row_bytes..(r + 1) * row_bytes];
1662                                            *o = ferrox_quant::dot_q4_0_q8(row, &act);
1663                                        });
1664                                    }
1665                                }
1666                                return out;
1667                            }
1668                            if serial {
1669                                for (r, o) in out.iter_mut().enumerate() {
1670                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1671                                    *o = ferrox_quant::dot_q4_0_q8(row, &act);
1672                                }
1673                            } else {
1674                                crate::par::items_mut(
1675                                    &mut out,
1676                                    Self::min_rows_per_task(*rows),
1677                                    |r, o| {
1678                                        let row =
1679                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1680                                        *o = ferrox_quant::dot_q4_0_q8(row, &act);
1681                                    },
1682                                );
1683                            }
1684                            return out;
1685                        }
1686                        QuantKind::Q4K if x.len().is_multiple_of(256) => {
1687                            let act = ferrox_quant::quantize_activations_q8_k(x);
1688                            let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
1689                            if n_groups > 0 {
1690                                let interleave = ferrox_quant::q4_kx8_interleave();
1691                                let packed = get_or_repack_q4k(data, *rows, *cols);
1692                                crate::par::chunks_mut(
1693                                    &mut out[..n_groups * ferrox_quant::Q4_KX8_NROWS],
1694                                    ferrox_quant::Q4_KX8_NROWS,
1695                                    Self::min_rows_per_task(n_groups).max(1),
1696                                    |g, chunk| {
1697                                        ferrox_quant::gemv_q4_kx8_group(
1698                                            &packed, g, &act, *cols, interleave, chunk,
1699                                        );
1700                                    },
1701                                );
1702                                let data_slice = data.as_slice();
1703                                crate::par::items_mut(
1704                                    &mut out[n_groups * ferrox_quant::Q4_KX8_NROWS..],
1705                                    Self::min_rows_per_task(
1706                                        *rows - n_groups * ferrox_quant::Q4_KX8_NROWS,
1707                                    ),
1708                                    |i, o| {
1709                                        let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
1710                                        let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1711                                        *o = ferrox_quant::dot_q4_k_q8(row, &act);
1712                                    },
1713                                );
1714                                return out;
1715                            }
1716                            crate::par::items_mut(
1717                                &mut out,
1718                                Self::min_rows_per_task(*rows),
1719                                |r, o| {
1720                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1721                                    *o = ferrox_quant::dot_q4_k_q8(row, &act);
1722                                },
1723                            );
1724                            return out;
1725                        }
1726                        QuantKind::Q5K if x.len().is_multiple_of(256) => {
1727                            let act = ferrox_quant::quantize_activations_q8_k(x);
1728                            let n_groups = *rows / ferrox_quant::Q5_KX8_NROWS;
1729                            if n_groups > 0 {
1730                                let interleave = ferrox_quant::q5_kx8_interleave();
1731                                let packed = get_or_repack_q5k(data, *rows, *cols);
1732                                crate::par::chunks_mut(
1733                                    &mut out[..n_groups * ferrox_quant::Q5_KX8_NROWS],
1734                                    ferrox_quant::Q5_KX8_NROWS,
1735                                    Self::min_rows_per_task(n_groups).max(1),
1736                                    |g, chunk| {
1737                                        ferrox_quant::gemv_q5_kx8_group(
1738                                            &packed, g, &act, *cols, interleave, chunk,
1739                                        );
1740                                    },
1741                                );
1742                                let data_slice = data.as_slice();
1743                                crate::par::items_mut(
1744                                    &mut out[n_groups * ferrox_quant::Q5_KX8_NROWS..],
1745                                    Self::min_rows_per_task(
1746                                        *rows - n_groups * ferrox_quant::Q5_KX8_NROWS,
1747                                    ),
1748                                    |i, o| {
1749                                        let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
1750                                        let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
1751                                        *o = ferrox_quant::dot_q5_k_q8(row, &act);
1752                                    },
1753                                );
1754                                return out;
1755                            }
1756                            crate::par::items_mut(
1757                                &mut out,
1758                                Self::min_rows_per_task(*rows),
1759                                |r, o| {
1760                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1761                                    *o = ferrox_quant::dot_q5_k_q8(row, &act);
1762                                },
1763                            );
1764                            return out;
1765                        }
1766                        // IQ4_XS over Q8_K activations, llama.cpp's
1767                        // `ggml_vec_dot_iq4_xs_q8_K` (`ferrox_quant::
1768                        // iq4_xs_q8`): the same int8 lane the K-quants
1769                        // take, so decode and prefill agree on the
1770                        // activation quantization.
1771                        QuantKind::IQ4XS if x.len().is_multiple_of(256) => {
1772                            let act = ferrox_quant::quantize_activations_q8_k(x);
1773                            crate::par::items_mut(
1774                                &mut out,
1775                                Self::min_rows_per_task(*rows),
1776                                |r, o| {
1777                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1778                                    *o = ferrox_quant::dot_iq4_xs_q8_k(row, &act);
1779                                },
1780                            );
1781                            return out;
1782                        }
1783                        QuantKind::Q6K if x.len().is_multiple_of(256) => {
1784                            let act = ferrox_quant::quantize_activations_q8_k(x);
1785                            let n_groups = *rows / ferrox_quant::Q6_KX8_NROWS;
1786                            if n_groups > 0 {
1787                                let interleave = ferrox_quant::q6_kx8_interleave();
1788                                let packed = get_or_repack_q6k(data, *rows, *cols);
1789                                crate::par::chunks_mut(
1790                                    &mut out[..n_groups * ferrox_quant::Q6_KX8_NROWS],
1791                                    ferrox_quant::Q6_KX8_NROWS,
1792                                    Self::min_rows_per_task(n_groups).max(1),
1793                                    |g, out8| {
1794                                        ferrox_quant::gemv_q6_kx8_group(
1795                                            &packed, g, &act, *cols, interleave, out8,
1796                                        );
1797                                    },
1798                                );
1799                                crate::par::items_mut(
1800                                    &mut out[n_groups * ferrox_quant::Q6_KX8_NROWS..],
1801                                    Self::min_rows_per_task(
1802                                        *rows - n_groups * ferrox_quant::Q6_KX8_NROWS,
1803                                    ),
1804                                    |i, o| {
1805                                        let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
1806                                        let row =
1807                                            &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1808                                        *o = ferrox_quant::dot_q6_k_q8(row, &act);
1809                                    },
1810                                );
1811                                return out;
1812                            }
1813                            crate::par::items_mut(
1814                                &mut out,
1815                                Self::min_rows_per_task(*rows),
1816                                |r, o| {
1817                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1818                                    *o = ferrox_quant::dot_q6_k_q8(row, &act);
1819                                },
1820                            );
1821                            return out;
1822                        }
1823                        _ => {}
1824                    }
1825                }
1826                crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1827                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
1828                    *o = Self::dot(*kind, row, x);
1829                });
1830                out
1831            }
1832            WeightMatrix::Mxfp4 {
1833                packed,
1834                scale,
1835                rows,
1836                cols,
1837            } => {
1838                let packed_row_bytes = cols / 2;
1839                let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
1840                let mut out = vec![0f32; *rows];
1841                crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
1842                    let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
1843                    let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
1844                    *o = ferrox_quant::dot_mxfp4_row_f32(prow, srow, x);
1845                });
1846                out
1847            }
1848            WeightMatrix::Adapted { base, lora } => {
1849                let mut out = base.apply_cpu_inner(x);
1850                lora.add_to(x, &mut out);
1851                out
1852            }
1853            WeightMatrix::Folded { base, fold } => base.apply_cpu_inner(&fold.transform_input(x)),
1854        }
1855    }
1856
1857    /// INT_DOT matvec against a pre-quantized Q8_0 activation (shared gate/up).
1858    ///
1859    /// Publishes this operation's shape for exactly the same reason
1860    /// [`Self::apply_cpu`] does, and it matters more here: the dense FFN
1861    /// gate and up projections are the widest matvecs in a decode step,
1862    /// so they are the ones the scheduler rule is deciding about.
1863    pub fn apply_cpu_q8(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1864        crate::par::with_op_work(self.rows(), self.cols(), || self.apply_cpu_q8_inner(act))
1865    }
1866
1867    /// [`Self::apply_cpu_q8`] with the operation's shape already
1868    /// published. Split only so the publish wraps every return path.
1869    fn apply_cpu_q8_inner(&self, act: &ferrox_quant::Q8Activations) -> Option<Vec<f32>> {
1870        let WeightMatrix::Quantized {
1871            data,
1872            rows,
1873            cols,
1874            kind,
1875        } = self
1876        else {
1877            return None;
1878        };
1879        if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
1880            || !cpu_int_dot_for(IntDotShape::Matvec)
1881        {
1882            return None;
1883        }
1884        if act.q.len() != *cols || !cols.is_multiple_of(32) {
1885            return None;
1886        }
1887        let row_bytes = self.block_bytes_per_row(*kind, *cols);
1888        let mut out = vec![0f32; *rows];
1889        let kind = *kind;
1890        let bytes = data.as_slice();
1891        // Q8_0×4 / Q4_0×4 interleaved GEMV — same paths as `apply_cpu` so
1892        // dense FFN gate+up hit the fast kernels, not per-row int dots.
1893        if matches!(kind, QuantKind::Q8_0) {
1894            let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
1895            if n_groups > 0 {
1896                let packed = get_or_repack_q8x4(data, *rows, *cols);
1897                let serial = Self::prefer_serial_matvec(*rows, *cols);
1898                // Probed once per matvec, not once per row-group:
1899                // `q*_interleave` reads a CPU feature bit, and LLVM
1900                // cannot hoist that relaxed atomic load out of the
1901                // caller's loop. `is_aarch64_feature_detected!` ran
1902                // 131k times in one Mistral-7B projection before the
1903                // last one of these was hoisted.
1904                let interleave = ferrox_quant::q8_0x4_interleave();
1905                let body = |g: usize, chunk: &mut [f32]| {
1906                    ferrox_quant::gemv_q8_0x4_group(&packed, g, act, *cols, interleave, chunk);
1907                };
1908                if serial {
1909                    for (g, chunk) in out[..n_groups * ferrox_quant::Q8_0X4_NROWS]
1910                        .chunks_mut(ferrox_quant::Q8_0X4_NROWS)
1911                        .enumerate()
1912                    {
1913                        body(g, chunk);
1914                    }
1915                } else {
1916                    crate::par::chunks_mut(
1917                        &mut out[..n_groups * ferrox_quant::Q8_0X4_NROWS],
1918                        ferrox_quant::Q8_0X4_NROWS,
1919                        Self::min_rows_per_task(n_groups).max(1),
1920                        |g, chunk| body(g, chunk),
1921                    );
1922                }
1923                let tail_len = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
1924                if tail_len > 0 {
1925                    let tail = &mut out[n_groups * ferrox_quant::Q8_0X4_NROWS..];
1926                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1927                        for (i, o) in tail.iter_mut().enumerate() {
1928                            let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1929                            *o = ferrox_quant::dot_q8_0_q8(
1930                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1931                                act,
1932                            );
1933                        }
1934                    } else {
1935                        let min_len = Self::min_rows_per_task(tail_len);
1936                        crate::par::items_mut(tail, min_len, |i, o| {
1937                            let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
1938                            *o = ferrox_quant::dot_q8_0_q8(
1939                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1940                                act,
1941                            );
1942                        });
1943                    }
1944                }
1945                return Some(out);
1946            }
1947        }
1948        if matches!(kind, QuantKind::Q4_0) {
1949            let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
1950            if n_groups > 0 {
1951                let packed = get_or_repack_q4_0x4(data, *rows, *cols);
1952                let serial = Self::prefer_serial_matvec(*rows, *cols);
1953                // Probed once per matvec, not once per row-group:
1954                // `q*_interleave` reads a CPU feature bit, and LLVM
1955                // cannot hoist that relaxed atomic load out of the
1956                // caller's loop. `is_aarch64_feature_detected!` ran
1957                // 131k times in one Mistral-7B projection before the
1958                // last one of these was hoisted.
1959                let interleave = ferrox_quant::q4_0x4_interleave();
1960                let body = |g: usize, chunk: &mut [f32]| {
1961                    ferrox_quant::gemv_q4_0x4_group(&packed, g, act, *cols, interleave, chunk);
1962                };
1963                if serial {
1964                    for (g, chunk) in out[..n_groups * ferrox_quant::Q4_0X4_NROWS]
1965                        .chunks_mut(ferrox_quant::Q4_0X4_NROWS)
1966                        .enumerate()
1967                    {
1968                        body(g, chunk);
1969                    }
1970                } else {
1971                    crate::par::chunks_mut(
1972                        &mut out[..n_groups * ferrox_quant::Q4_0X4_NROWS],
1973                        ferrox_quant::Q4_0X4_NROWS,
1974                        Self::min_rows_per_task(n_groups).max(1),
1975                        |g, chunk| body(g, chunk),
1976                    );
1977                }
1978                let tail_len = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
1979                if tail_len > 0 {
1980                    let tail = &mut out[n_groups * ferrox_quant::Q4_0X4_NROWS..];
1981                    if serial || Self::prefer_serial_matvec(tail_len, *cols) {
1982                        for (i, o) in tail.iter_mut().enumerate() {
1983                            let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1984                            *o = ferrox_quant::dot_q4_0_q8(
1985                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1986                                act,
1987                            );
1988                        }
1989                    } else {
1990                        let min_len = Self::min_rows_per_task(tail_len);
1991                        crate::par::items_mut(tail, min_len, |i, o| {
1992                            let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
1993                            *o = ferrox_quant::dot_q4_0_q8(
1994                                &bytes[r * row_bytes..(r + 1) * row_bytes],
1995                                act,
1996                            );
1997                        });
1998                    }
1999                }
2000                return Some(out);
2001            }
2002        }
2003        if Self::prefer_serial_matvec(*rows, *cols) {
2004            for (r, o) in out.iter_mut().enumerate() {
2005                let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2006                *o = match kind {
2007                    QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
2008                    QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
2009                    _ => unreachable!(),
2010                };
2011            }
2012            return Some(out);
2013        }
2014        crate::par::items_mut(&mut out, Self::min_rows_per_task(*rows), |r, o| {
2015            let row = &bytes[r * row_bytes..(r + 1) * row_bytes];
2016            *o = match kind {
2017                QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(row, act),
2018                QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(row, act),
2019                _ => unreachable!(),
2020            };
2021        });
2022        Some(out)
2023    }
2024
2025    /// Two contiguous rows × one Q8 act (shared act loads). Q4_0 uses
2026    /// [`ferrox_quant::dot_q4_0_q8_2row`]; Q8_0 falls back to two singles.
2027    pub fn dot_pair_cpu_q8(
2028        &self,
2029        row: usize,
2030        act: &ferrox_quant::Q8Activations,
2031    ) -> Option<(f32, f32)> {
2032        let WeightMatrix::Quantized {
2033            data,
2034            rows,
2035            cols,
2036            kind,
2037        } = self
2038        else {
2039            return None;
2040        };
2041        if !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2042            || !cpu_int_dot_for(IntDotShape::Matvec)
2043        {
2044            return None;
2045        }
2046        if act.q.len() != *cols || !cols.is_multiple_of(32) || row + 1 >= *rows {
2047            return None;
2048        }
2049        let row_bytes = self.block_bytes_per_row(*kind, *cols);
2050        let bytes = data.as_slice();
2051        let r0 = &bytes[row * row_bytes..(row + 1) * row_bytes];
2052        let r1 = &bytes[(row + 1) * row_bytes..(row + 2) * row_bytes];
2053        Some(match *kind {
2054            QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8_2row(r0, r1, act),
2055            QuantKind::Q8_0 => (
2056                ferrox_quant::dot_q8_0_q8(r0, act),
2057                ferrox_quant::dot_q8_0_q8(r1, act),
2058            ),
2059            _ => unreachable!(),
2060        })
2061    }
2062
2063    /// Single-row INT_DOT against pre-quantized Q8_0 acts (llama `mul_mat_id`
2064    /// inner loop). Returns `None` if this matrix is not Q4_0/Q8_0 INT_DOT.
2065    pub fn dot_row_cpu_q8(&self, row: usize, act: &ferrox_quant::Q8Activations) -> Option<f32> {
2066        let WeightMatrix::Quantized {
2067            data,
2068            rows,
2069            cols,
2070            kind,
2071        } = self
2072        else {
2073            return None;
2074        };
2075        if row >= *rows
2076            || !matches!(*kind, QuantKind::Q8_0 | QuantKind::Q4_0)
2077            || !cpu_int_dot_for(IntDotShape::Matvec)
2078            || act.q.len() != *cols
2079            || !cols.is_multiple_of(32)
2080        {
2081            return None;
2082        }
2083        let row_bytes = self.block_bytes_per_row(*kind, *cols);
2084        let bytes = &data.as_slice()[row * row_bytes..(row + 1) * row_bytes];
2085        Some(match *kind {
2086            QuantKind::Q8_0 => ferrox_quant::dot_q8_0_q8(bytes, act),
2087            QuantKind::Q4_0 => ferrox_quant::dot_q4_0_q8(bytes, act),
2088            _ => unreachable!(),
2089        })
2090    }
2091
2092    /// Computes `W @ X` for a *batch* of activation vectors at once:
2093    /// `x_batch` is `batch_size` rows of `self.cols()` elements each,
2094    /// flattened row-major; returns `batch_size` rows of
2095    /// `self.rows()` elements each, flattened row-major (`[batch,
2096    /// rows]`, matching the layout `Tensor`/`Decoder` expect for
2097    /// chaining into further matmuls).
2098    ///
2099    /// This is not just a convenience wrapper: for a quantized matrix,
2100    /// each weight row's bytes are read from memory *once* and dotted
2101    /// against every activation in the batch, instead of once per
2102    /// `apply` call. For a memory-bandwidth-bound quantized matmul --
2103    /// which fused Q8_0/Q4_0 dot products are, since the whole point of
2104    /// keeping weights quantized is that reading them is the
2105    /// bottleneck, not the arithmetic -- processing `batch_size`
2106    /// positions this way costs roughly the same *memory traffic* as
2107    /// processing one position, not `batch_size` times as much. This
2108    /// is the same reason speculative-decoding verification and batched
2109    /// prefill are faster per-token than sequential single-token decode
2110    /// on real hardware: it turns `batch_size` separate reads of the
2111    /// same weights into one.
2112    ///
2113    /// With Metal dense enabled, dispatches a single batched Metal
2114    /// command buffer — Q4_0/Q4_K/Q6_K/Q8_0 reuse the weights through a
2115    /// simdgroup `mul_mm` at `batch_size >= 4`; every other kind, and
2116    /// every smaller batch, uses
2117    /// [`ferrox_metal::gpu::launch_matvec_batch`]. Falls back to
2118    /// per-row [`Self::apply`] if the batch launch fails.
2119    pub fn apply_batch(&self, x_batch: &[f32], batch_size: usize) -> Vec<f32> {
2120        self.apply_batch_with_acts(x_batch, batch_size, None)
2121    }
2122
2123    /// Quantize `x_batch` once, in the activation format this matrix's
2124    /// INT_DOT batch path consumes, for sharing across every projection
2125    /// that reads the same input (q/k/v on one normed batch; gate/up on
2126    /// another). Returns `None` when [`Self::apply_batch`] would not use
2127    /// quantized activations for this matrix — GPU dispatch, INT_DOT off,
2128    /// unsupported kind or width — so callers can pass the result straight
2129    /// to [`Self::apply_batch_with_acts`] unconditionally.
2130    pub fn quantize_batch_acts(&self, x_batch: &[f32], batch_size: usize) -> Option<BatchActs> {
2131        if let WeightMatrix::Adapted { base, .. } = self {
2132            // The activations the BASE consumes; the delta reads the
2133            // f32 batch itself.
2134            return base.quantize_batch_acts(x_batch, batch_size);
2135        }
2136        if let WeightMatrix::Folded { .. } = self {
2137            // The base consumes the TRANSFORMED batch, which no sibling
2138            // matrix shares unless it shares the fold; `apply_batch`
2139            // quantizes its own.
2140            return None;
2141        }
2142        #[cfg(feature = "metal")]
2143        {
2144            if metal_dense_enabled()
2145                && matches!(
2146                    self,
2147                    WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2148                )
2149            {
2150                return None;
2151            }
2152        }
2153        #[cfg(feature = "cuda")]
2154        {
2155            if cuda_dense_enabled() && matches!(self, WeightMatrix::Quantized { .. }) {
2156                return None;
2157            }
2158        }
2159        let WeightMatrix::Quantized { cols, kind, .. } = self else {
2160            return None;
2161        };
2162        if !cpu_int_dot_for(IntDotShape::BatchGemm) || x_batch.len() != batch_size * cols {
2163            return None;
2164        }
2165        let cols = *cols;
2166        match kind {
2167            QuantKind::Q8_0 | QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2168                let acts: Vec<_> = (0..batch_size)
2169                    .into_par_iter()
2170                    .map(|b| {
2171                        ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols])
2172                    })
2173                    .collect();
2174                // Q8_0 and Q4_0 agree on both the interleave width and the
2175                // predicate, so one tile set serves either consumer.
2176                let tiles =
2177                    if ferrox_quant::q8_0x4_gemm_uses_acts_x4(ferrox_quant::q8_0x4_interleave()) {
2178                        acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
2179                            .map(|chunk| ferrox_quant::prepare_q8_acts_x4(chunk, cols))
2180                            .collect()
2181                    } else {
2182                        Vec::new()
2183                    };
2184                Some(BatchActs::Q8 { acts, tiles, cols })
2185            }
2186            QuantKind::Q4K | QuantKind::Q5K | QuantKind::Q6K if cols.is_multiple_of(256) => {
2187                let acts: Vec<_> = (0..batch_size)
2188                    .into_par_iter()
2189                    .map(|b| {
2190                        ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols])
2191                    })
2192                    .collect();
2193                // All three K-quants share the predicate and the quad
2194                // width, so the set a Q4_K gate builds is exactly what a
2195                // Q5_K or Q6_K sibling would have built for itself.
2196                let tiles =
2197                    if ferrox_quant::q4_kx8_gemm_uses_acts_x4(ferrox_quant::q4_kx8_interleave()) {
2198                        acts.par_chunks(ferrox_quant::Q8K_ACTS_X4_NC)
2199                            .map(|chunk| ferrox_quant::prepare_q8_k_acts_x4(chunk, cols))
2200                            .collect()
2201                    } else {
2202                        Vec::new()
2203                    };
2204                Some(BatchActs::Q8K { acts, tiles, cols })
2205            }
2206            _ => None,
2207        }
2208    }
2209
2210    /// [`Self::apply_batch`], optionally reusing a shared pre-quantized
2211    /// activation batch from [`Self::quantize_batch_acts`]. A `shared`
2212    /// value whose format or length does not match this matrix is simply
2213    /// ignored (the activations are re-quantized locally), so mixed-kind
2214    /// projection groups stay correct.
2215    pub fn apply_batch_with_acts(
2216        &self,
2217        x_batch: &[f32],
2218        batch_size: usize,
2219        shared: Option<&BatchActs>,
2220    ) -> Vec<f32> {
2221        let cols = self.cols();
2222        assert_eq!(
2223            x_batch.len(),
2224            batch_size * cols,
2225            "x_batch length must be batch_size * cols"
2226        );
2227        if batch_size == 0 {
2228            return Vec::new();
2229        }
2230        crate::activation_tap::observe(self, x_batch, batch_size);
2231        if let WeightMatrix::Adapted { base, lora } = self {
2232            let mut out = base.apply_batch_with_acts(x_batch, batch_size, shared);
2233            lora.add_batch_to(x_batch, batch_size, &mut out);
2234            return out;
2235        }
2236        if let WeightMatrix::Folded { base, fold } = self {
2237            let transformed = fold.transform_rows(x_batch, batch_size);
2238            return base.apply_batch_with_acts(&transformed, batch_size, None);
2239        }
2240
2241        /// Raw pointer to this function's `[batch][rows]` output, shared
2242        /// across rayon tasks.
2243        ///
2244        /// Parallelism is over weight rows, but a row's `batch_size` output
2245        /// slots (`out[b * rows + r]` for every `b`) interleave with every
2246        /// other row's, so they cannot be handed out as disjoint `&mut`
2247        /// chunks. Each task writes only the rows it owns, which keeps the
2248        /// writes race-free; this wrapper just carries the pointer across
2249        /// the `Send`/`Sync` boundary. Writing straight into the final
2250        /// layout kills what used to be here: a `[rows][batch]` staging vec
2251        /// (zeroed every call) plus a serial rows × batch transpose after
2252        /// the parallel section had already finished.
2253        #[derive(Clone, Copy)]
2254        struct BatchOut(*mut f32);
2255        unsafe impl Send for BatchOut {}
2256        unsafe impl Sync for BatchOut {}
2257        impl BatchOut {
2258            /// Safety: `idx` in bounds, and concurrent tasks never pass
2259            /// the same `idx` (they own disjoint row sets).
2260            #[inline]
2261            unsafe fn set(self, idx: usize, v: f32) {
2262                *self.0.add(idx) = v;
2263            }
2264        }
2265
2266        #[cfg(feature = "metal")]
2267        {
2268            if metal_dense_enabled()
2269                && matches!(
2270                    self,
2271                    WeightMatrix::Quantized { kind, .. } if Self::metal_kind_supported(*kind)
2272                )
2273            {
2274                if let Some(out) = self.apply_gpu_batch(x_batch, batch_size) {
2275                    return out;
2276                }
2277                // The kind is Metal-supported, so reaching here means a
2278                // launch failed and the batch degrades to `batch_size`
2279                // separate `apply` calls -- each its own command buffer,
2280                // commit and wait.
2281                crate::kernel_registry::miss(
2282                    crate::kernel_registry::Lookup::new(
2283                        crate::kernel_registry::Backend::Metal,
2284                        crate::kernel_registry::op::GEMM_PREFILL,
2285                        self.quant_kind(),
2286                    ),
2287                    "N x apply (one command buffer each)",
2288                );
2289                let rows = self.rows();
2290                let mut out = vec![0f32; batch_size * rows];
2291                for b in 0..batch_size {
2292                    let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2293                    out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2294                }
2295                return out;
2296            } else if metal_dense_enabled() {
2297                // Metal is on but this matrix has no Metal kernel at
2298                // all, so the whole GEMM runs on the CPU. For a
2299                // quantized weight that is the IQ4_XS shape exactly; for
2300                // an F32 one it is the documented host GEMM.
2301                let look = crate::kernel_registry::Lookup::new(
2302                    crate::kernel_registry::Backend::Metal,
2303                    crate::kernel_registry::op::GEMM_PREFILL,
2304                    self.quant_kind(),
2305                );
2306                if self.quant_kind().is_some() {
2307                    crate::kernel_registry::miss(look, "CPU apply_batch");
2308                } else {
2309                    crate::kernel_registry::miss_by_design(look, "CPU f32 GEMM");
2310                }
2311            }
2312        }
2313
2314        // CUDA has a batched GEMM for every kind in
2315        // `ferrox_cuda::mul_mm::KINDS` (`cuda_mul_mm_kind_supported`),
2316        // and NO PART OF IT HAS RUN ON A GPU. Every other kind still
2317        // takes the per-position matvec loop below, which is the arm
2318        // that has -- for the six kinds that predate 2026-09-09.
2319        //
2320        // That loop is why this arm exists at all: without it a batched
2321        // prefill fell through to the CPU branch and never touched the
2322        // GPU -- measured on an RTX 4090, SmolLM2 `pp512` ran at 28
2323        // tok/s against llama.cpp's 57466. Per-position matvec is still
2324        // the wrong shape for a wide prefill, but it is the GPU rather
2325        // than 26 idle SMs, and the fallback now records a
2326        // `GEMM_PREFILL` miss instead of degrading silently.
2327        #[cfg(feature = "cuda")]
2328        {
2329            // The batched GEMM first, when the kind has one and the
2330            // batch is wide enough to pay for it. Below that threshold a
2331            // single token stays on the matvec kernels, which are the
2332            // arm that has actually run on a GPU.
2333            if cuda_dense_enabled() {
2334                if let WeightMatrix::Quantized { data, kind, .. } = self {
2335                    if cuda_mul_mm_kind_supported(*kind)
2336                        && ferrox_cuda::mul_mm::worth_a_gemm(batch_size)
2337                    {
2338                        let mm_kind = ferrox_cuda::mul_mm::kind_by_name(kind.name())
2339                            .expect("cuda_mul_mm_kind_supported agreed");
2340                        let row_bytes = self.block_bytes_per_row(*kind, cols);
2341                        match ferrox_cuda::mul_mm_launch::launch_mul_mm(
2342                            mm_kind,
2343                            data.as_slice(),
2344                            x_batch,
2345                            self.rows(),
2346                            cols,
2347                            batch_size,
2348                            row_bytes,
2349                        ) {
2350                            Ok(out) => return out,
2351                            Err(_) => {
2352                                // The kind HAS a GEMM, so reaching here is a
2353                                // launch failure rather than an unsupported
2354                                // kind, and the batch degrades to per-position
2355                                // matvecs. This call site used to be the one
2356                                // SILENT fallback in the registry's table.
2357                                crate::kernel_registry::miss(
2358                                    crate::kernel_registry::Lookup::new(
2359                                        crate::kernel_registry::Backend::Cuda,
2360                                        crate::kernel_registry::op::GEMM_PREFILL,
2361                                        self.quant_kind(),
2362                                    ),
2363                                    "N x matvec (the GEMM launch failed)",
2364                                );
2365                            }
2366                        }
2367                    }
2368                }
2369            }
2370            if cuda_dense_enabled()
2371                && matches!(self, WeightMatrix::Quantized { .. })
2372                && self.apply_gpu(&x_batch[..cols]).is_some()
2373            {
2374                let rows = self.rows();
2375                let mut out = vec![0f32; batch_size * rows];
2376                for b in 0..batch_size {
2377                    match self.apply_gpu(&x_batch[b * cols..(b + 1) * cols]) {
2378                        Some(y) => out[b * rows..(b + 1) * rows].copy_from_slice(&y),
2379                        None => {
2380                            let y = self.apply(&x_batch[b * cols..(b + 1) * cols]);
2381                            out[b * rows..(b + 1) * rows].copy_from_slice(&y);
2382                        }
2383                    }
2384                }
2385                return out;
2386            }
2387        }
2388
2389        match self {
2390            WeightMatrix::F32(t) => {
2391                let xt = Tensor::new(x_batch.to_vec(), vec![batch_size, cols]);
2392                crate::matmul::matmul_f32(&xt, t).data
2393            }
2394            WeightMatrix::Quantized {
2395                data,
2396                rows,
2397                cols: _,
2398                kind,
2399            } => {
2400                let row_bytes = self.block_bytes_per_row(*kind, cols);
2401                // Written directly in the [batch, rows] layout the function
2402                // returns: each parallel task owns a disjoint set of rows
2403                // `r` and scatters `out[b * rows + r]` for every `b`
2404                // through `BatchOut`.
2405                let mut out = vec![0f32; batch_size * rows];
2406                let out_w = BatchOut(out.as_mut_ptr());
2407
2408                // Prefill INT_DOT: quantize each activation once, then
2409                // reuse Q8 packs across all weight rows (llama CPU path).
2410                if cpu_int_dot_for(IntDotShape::BatchGemm) {
2411                    match *kind {
2412                        QuantKind::Q8_0 if cols.is_multiple_of(32) => {
2413                            let mut acts_owned = Vec::new();
2414                            let (acts, shared_tiles) =
2415                                Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2416                            let n_groups = *rows / ferrox_quant::Q8_0X4_NROWS;
2417                            if n_groups > 0 {
2418                                let packed = get_or_repack_q8x4(data, *rows, cols);
2419                                let nrows_g = ferrox_quant::Q8_0X4_NROWS;
2420                                let interleave = ferrox_quant::q8_0x4_interleave();
2421                                if ferrox_quant::q8_0x4_gemm_uses_acts_x4(interleave) {
2422                                    // i8mm: interleave each quad of
2423                                    // activations once per matmul (llama.cpp
2424                                    // `ggml_quantize_mat_q8_0_4x8` into
2425                                    // `wdata`); every row-group reuses it.
2426                                    let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2427                                    let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2428                                    let act_tiles: &[ferrox_quant::Q8ActsX4] =
2429                                        if shared_tiles.is_empty() {
2430                                            tiles_owned = acts
2431                                                .par_chunks(nc)
2432                                                .map(|chunk| {
2433                                                    ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2434                                                })
2435                                                .collect();
2436                                            &tiles_owned
2437                                        } else {
2438                                            shared_tiles
2439                                        };
2440                                    // One runtime i8mm probe per matmul, not
2441                                    // one per (row-group x quad); see
2442                                    // `ferrox_quant::AccelX4`.
2443                                    let accel = ferrox_quant::AccelX4::detect();
2444                                    Self::par_chunked_groups(
2445                                        n_groups,
2446                                        nrows_g,
2447                                        act_tiles.len(),
2448                                        nc,
2449                                        |g, t0, t1| {
2450                                            let mut tmp = [0f32;
2451                                                ferrox_quant::Q8_0X4_NROWS
2452                                                    * ferrox_quant::Q8K_ACTS_X4_NC];
2453                                            for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2454                                                let t = t0 + t;
2455                                                let n = tile.na;
2456                                                let tmp = &mut tmp[..nrows_g * n];
2457                                                ferrox_quant::gemm_q8_0x4_group_x4_on(
2458                                                    &packed, g, tile, cols, interleave, accel, tmp,
2459                                                );
2460                                                for j in 0..n {
2461                                                    let col = (t * nc + j) * rows + g * nrows_g;
2462                                                    for r in 0..nrows_g {
2463                                                        unsafe {
2464                                                            out_w.set(col + r, tmp[r * n + j]);
2465                                                        }
2466                                                    }
2467                                                }
2468                                            }
2469                                        },
2470                                    );
2471                                } else {
2472                                    // GEMM, not a GEMV per position: the
2473                                    // batched kernel writes a `[row][batch]`
2474                                    // span, and the group's weight vectors
2475                                    // stay in registers across a tile of
2476                                    // activations. The span is then scattered
2477                                    // into the [batch][rows] output right
2478                                    // here, in parallel.
2479                                    let span = ferrox_quant::Q8_0X4_GEMM_NC;
2480                                    let n_tiles = batch_size.div_ceil(span);
2481                                    Self::par_chunked_groups(
2482                                        n_groups,
2483                                        nrows_g,
2484                                        n_tiles,
2485                                        span,
2486                                        |g, t0, t1| {
2487                                            let b0 = t0 * span;
2488                                            let b1 = (t1 * span).min(batch_size);
2489                                            let n = b1 - b0;
2490                                            let mut group = vec![0f32; nrows_g * n];
2491                                            ferrox_quant::gemm_q8_0x4_group(
2492                                                &packed,
2493                                                g,
2494                                                &acts[b0..b1],
2495                                                cols,
2496                                                interleave,
2497                                                &mut group,
2498                                            );
2499                                            for (bi, b) in (b0..b1).enumerate() {
2500                                                for r in 0..nrows_g {
2501                                                    unsafe {
2502                                                        out_w.set(
2503                                                            b * rows + g * nrows_g + r,
2504                                                            group[r * n + bi],
2505                                                        );
2506                                                    }
2507                                                }
2508                                            }
2509                                        },
2510                                    );
2511                                }
2512                                let data_slice = data.as_slice();
2513                                let tail = *rows - n_groups * ferrox_quant::Q8_0X4_NROWS;
2514                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2515                                    let r = n_groups * ferrox_quant::Q8_0X4_NROWS + i;
2516                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2517                                    for (b, act) in acts.iter().enumerate() {
2518                                        unsafe {
2519                                            out_w.set(
2520                                                b * rows + r,
2521                                                ferrox_quant::dot_q8_0_q8(row, act),
2522                                            );
2523                                        }
2524                                    }
2525                                });
2526                            } else {
2527                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2528                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2529                                    for (b, act) in acts.iter().enumerate() {
2530                                        unsafe {
2531                                            out_w.set(
2532                                                b * rows + r,
2533                                                ferrox_quant::dot_q8_0_q8(row, act),
2534                                            );
2535                                        }
2536                                    }
2537                                });
2538                            }
2539                            return out;
2540                        }
2541                        QuantKind::Q4_0 if cols.is_multiple_of(32) => {
2542                            let mut acts_owned = Vec::new();
2543                            let (acts, shared_tiles) =
2544                                Self::q8_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2545                            let n_groups = *rows / ferrox_quant::Q4_0X4_NROWS;
2546                            if n_groups > 0 {
2547                                let packed = get_or_repack_q4_0x4(data, *rows, cols);
2548                                let nrows_g = ferrox_quant::Q4_0X4_NROWS;
2549                                let interleave = ferrox_quant::q4_0x4_interleave();
2550                                if ferrox_quant::q4_0x4_gemm_uses_acts_x4(interleave) {
2551                                    // i8mm: same once-per-matmul activation
2552                                    // quad hoist as the Q8_0 arm above.
2553                                    let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2554                                    let tiles_owned: Vec<ferrox_quant::Q8ActsX4>;
2555                                    let act_tiles: &[ferrox_quant::Q8ActsX4] =
2556                                        if shared_tiles.is_empty() {
2557                                            tiles_owned = acts
2558                                                .par_chunks(nc)
2559                                                .map(|chunk| {
2560                                                    ferrox_quant::prepare_q8_acts_x4(chunk, cols)
2561                                                })
2562                                                .collect();
2563                                            &tiles_owned
2564                                        } else {
2565                                            shared_tiles
2566                                        };
2567                                    let accel = ferrox_quant::AccelX4::detect();
2568                                    Self::par_chunked_groups(
2569                                        n_groups,
2570                                        nrows_g,
2571                                        act_tiles.len(),
2572                                        nc,
2573                                        |g, t0, t1| {
2574                                            let mut tmp = [0f32;
2575                                                ferrox_quant::Q4_0X4_NROWS
2576                                                    * ferrox_quant::Q8K_ACTS_X4_NC];
2577                                            for (t, tile) in act_tiles[t0..t1].iter().enumerate() {
2578                                                let t = t0 + t;
2579                                                let n = tile.na;
2580                                                let tmp = &mut tmp[..nrows_g * n];
2581                                                ferrox_quant::gemm_q4_0x4_group_x4_on(
2582                                                    &packed, g, tile, cols, interleave, accel, tmp,
2583                                                );
2584                                                for j in 0..n {
2585                                                    let col = (t * nc + j) * rows + g * nrows_g;
2586                                                    for r in 0..nrows_g {
2587                                                        unsafe {
2588                                                            out_w.set(col + r, tmp[r * n + j]);
2589                                                        }
2590                                                    }
2591                                                }
2592                                            }
2593                                        },
2594                                    );
2595                                } else {
2596                                    // GEMM, not a GEMV per position: the
2597                                    // batched kernel writes a `[row][batch]`
2598                                    // span, and the group's weight vectors
2599                                    // stay in registers across a tile of
2600                                    // activations. The span is then scattered
2601                                    // into the [batch][rows] output right
2602                                    // here, in parallel.
2603                                    let span = ferrox_quant::Q8_0X4_GEMM_NC;
2604                                    let n_tiles = batch_size.div_ceil(span);
2605                                    Self::par_chunked_groups(
2606                                        n_groups,
2607                                        nrows_g,
2608                                        n_tiles,
2609                                        span,
2610                                        |g, t0, t1| {
2611                                            let b0 = t0 * span;
2612                                            let b1 = (t1 * span).min(batch_size);
2613                                            let n = b1 - b0;
2614                                            let mut group = vec![0f32; nrows_g * n];
2615                                            ferrox_quant::gemm_q4_0x4_group(
2616                                                &packed,
2617                                                g,
2618                                                &acts[b0..b1],
2619                                                cols,
2620                                                interleave,
2621                                                &mut group,
2622                                            );
2623                                            for (bi, b) in (b0..b1).enumerate() {
2624                                                for r in 0..nrows_g {
2625                                                    unsafe {
2626                                                        out_w.set(
2627                                                            b * rows + g * nrows_g + r,
2628                                                            group[r * n + bi],
2629                                                        );
2630                                                    }
2631                                                }
2632                                            }
2633                                        },
2634                                    );
2635                                }
2636                                let data_slice = data.as_slice();
2637                                let tail = *rows - n_groups * ferrox_quant::Q4_0X4_NROWS;
2638                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2639                                    let r = n_groups * ferrox_quant::Q4_0X4_NROWS + i;
2640                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2641                                    for (b, act) in acts.iter().enumerate() {
2642                                        unsafe {
2643                                            out_w.set(
2644                                                b * rows + r,
2645                                                ferrox_quant::dot_q4_0_q8(row, act),
2646                                            );
2647                                        }
2648                                    }
2649                                });
2650                            } else {
2651                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2652                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2653                                    for (b, act) in acts.iter().enumerate() {
2654                                        unsafe {
2655                                            out_w.set(
2656                                                b * rows + r,
2657                                                ferrox_quant::dot_q4_0_q8(row, act),
2658                                            );
2659                                        }
2660                                    }
2661                                });
2662                            }
2663                            return out;
2664                        }
2665                        QuantKind::Q4K if cols.is_multiple_of(256) => {
2666                            let mut acts_owned = Vec::new();
2667                            let (acts, shared_tiles) =
2668                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2669                            let n_groups = *rows / ferrox_quant::Q4_KX8_NROWS;
2670                            if n_groups > 0 {
2671                                let interleave = ferrox_quant::q4_kx8_interleave();
2672                                let packed = get_or_repack_q4k(data, *rows, cols);
2673                                let nc = ferrox_quant::Q4_KX8_GEMM_NC;
2674                                // On the i8mm path, interleave each quad of
2675                                // activations once per matmul (llama.cpp
2676                                // `ggml_quantize_mat_q8_K_4x8` into `wdata`);
2677                                // the kernel used to redo it per row-group.
2678                                // A `shared` batch has already paid for this
2679                                // on behalf of every sibling projection. The
2680                                // predicate is asked first either way: it,
2681                                // not the donor, decides whether this matrix
2682                                // has an x4 kernel at all.
2683                                let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2684                                let act_tiles: &[ferrox_quant::Q8KActsX4] =
2685                                    if !ferrox_quant::q4_kx8_gemm_uses_acts_x4(interleave) {
2686                                        &[]
2687                                    } else if !shared_tiles.is_empty() {
2688                                        shared_tiles
2689                                    } else {
2690                                        tiles_owned = acts
2691                                            .par_chunks(nc)
2692                                            .map(|chunk| {
2693                                                ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2694                                            })
2695                                            .collect();
2696                                        &tiles_owned
2697                                    };
2698                                let accel = ferrox_quant::AccelX4::detect();
2699                                let n_tiles = batch_size.div_ceil(nc);
2700                                Self::par_chunked_groups(
2701                                    n_groups,
2702                                    ferrox_quant::Q4_KX8_NROWS,
2703                                    n_tiles,
2704                                    nc,
2705                                    |g, t0, t1| {
2706                                        let mut tile = [0f32;
2707                                            ferrox_quant::Q4_KX8_NROWS
2708                                                * ferrox_quant::Q4_KX8_GEMM_NC];
2709                                        for t in t0..t1 {
2710                                            let chunk =
2711                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
2712                                            let n = chunk.len();
2713                                            let tile = &mut tile[..ferrox_quant::Q4_KX8_NROWS * n];
2714                                            if act_tiles.is_empty() {
2715                                                ferrox_quant::gemm_q4_kx8_group(
2716                                                    &packed, g, chunk, cols, interleave, tile,
2717                                                );
2718                                            } else {
2719                                                ferrox_quant::gemm_q4_kx8_group_x4_on(
2720                                                    &packed,
2721                                                    g,
2722                                                    &act_tiles[t],
2723                                                    cols,
2724                                                    interleave,
2725                                                    accel,
2726                                                    tile,
2727                                                );
2728                                            }
2729                                            for j in 0..n {
2730                                                let col = (t * nc + j) * rows
2731                                                    + g * ferrox_quant::Q4_KX8_NROWS;
2732                                                for r in 0..ferrox_quant::Q4_KX8_NROWS {
2733                                                    unsafe {
2734                                                        out_w.set(col + r, tile[r * n + j]);
2735                                                    }
2736                                                }
2737                                            }
2738                                        }
2739                                    },
2740                                );
2741                                let data_slice = data.as_slice();
2742                                let tail = *rows - n_groups * ferrox_quant::Q4_KX8_NROWS;
2743                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2744                                    let r = n_groups * ferrox_quant::Q4_KX8_NROWS + i;
2745                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2746                                    for (b, act) in acts.iter().enumerate() {
2747                                        unsafe {
2748                                            out_w.set(
2749                                                b * rows + r,
2750                                                ferrox_quant::dot_q4_k_q8(row, act),
2751                                            );
2752                                        }
2753                                    }
2754                                });
2755                            } else {
2756                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2757                                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
2758                                    for (b, act) in acts.iter().enumerate() {
2759                                        unsafe {
2760                                            out_w.set(
2761                                                b * rows + r,
2762                                                ferrox_quant::dot_q4_k_q8(row, act),
2763                                            );
2764                                        }
2765                                    }
2766                                });
2767                            }
2768                            return out;
2769                        }
2770                        QuantKind::Q5K if cols.is_multiple_of(256) => {
2771                            let mut acts_owned = Vec::new();
2772                            let (acts, shared_tiles) =
2773                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2774                            // The Kx8 batch path: every aarch64 host (i8mm,
2775                            // dotprod, or the scalar Kx8 body, as the Q4_K arm
2776                            // takes it), and any other host whose `x4` GEMM
2777                            // has a SIMD kernel at this width (AVX2 since
2778                            // #159). This read `cfg!(target_arch = "aarch64")`
2779                            // alone until 2026-09-15, which sent every x86
2780                            // Q5_K prefill through the per-row GEMM below:
2781                            // 44.2 against llama.cpp's 378.7 tok/s on
2782                            // Llama-3.2-1B Q5_K_M (8.56x) on a Ryzen 5950X,
2783                            // beside Q4_K at 1.19x and Q6_K at 1.19x on the
2784                            // same host, because those two arms asked the
2785                            // kernels and this one asked the architecture.
2786                            let interleave = ferrox_quant::q5_kx8_interleave();
2787                            let use_kx8 = q5k_batch_takes_kx8(interleave);
2788                            let n_groups = if use_kx8 {
2789                                *rows / ferrox_quant::Q5_KX8_NROWS
2790                            } else {
2791                                0
2792                            };
2793                            if n_groups > 0 {
2794                                let packed = get_or_repack_q5k(data, *rows, cols);
2795                                let nc = ferrox_quant::Q5_KX8_GEMM_NC;
2796                                // On the i8mm path, interleave each quad of
2797                                // activations once per matmul; the kernel
2798                                // consumes it for every row-group. A `shared`
2799                                // batch has already paid for it. Predicate
2800                                // first, as in the Q4_K arm.
2801                                let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2802                                let act_tiles: &[ferrox_quant::Q8KActsX4] =
2803                                    if !ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
2804                                        &[]
2805                                    } else if !shared_tiles.is_empty() {
2806                                        shared_tiles
2807                                    } else {
2808                                        tiles_owned = acts
2809                                            .par_chunks(nc)
2810                                            .map(|chunk| {
2811                                                ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2812                                            })
2813                                            .collect();
2814                                        &tiles_owned
2815                                    };
2816                                let accel = ferrox_quant::AccelX4::detect();
2817                                let n_tiles = batch_size.div_ceil(nc);
2818                                Self::par_chunked_groups(
2819                                    n_groups,
2820                                    ferrox_quant::Q5_KX8_NROWS,
2821                                    n_tiles,
2822                                    nc,
2823                                    |g, t0, t1| {
2824                                        let mut tile = [0f32;
2825                                            ferrox_quant::Q5_KX8_NROWS
2826                                                * ferrox_quant::Q5_KX8_GEMM_NC];
2827                                        for t in t0..t1 {
2828                                            let chunk =
2829                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
2830                                            let n = chunk.len();
2831                                            let tile = &mut tile[..ferrox_quant::Q5_KX8_NROWS * n];
2832                                            if act_tiles.is_empty() {
2833                                                ferrox_quant::gemm_q5_kx8_group(
2834                                                    &packed, g, chunk, cols, interleave, tile,
2835                                                );
2836                                            } else {
2837                                                ferrox_quant::gemm_q5_kx8_group_x4_on(
2838                                                    &packed,
2839                                                    g,
2840                                                    &act_tiles[t],
2841                                                    cols,
2842                                                    interleave,
2843                                                    accel,
2844                                                    tile,
2845                                                );
2846                                            }
2847                                            for j in 0..n {
2848                                                let col = (t * nc + j) * rows
2849                                                    + g * ferrox_quant::Q5_KX8_NROWS;
2850                                                for r in 0..ferrox_quant::Q5_KX8_NROWS {
2851                                                    unsafe {
2852                                                        out_w.set(col + r, tile[r * n + j]);
2853                                                    }
2854                                                }
2855                                            }
2856                                        }
2857                                    },
2858                                );
2859                                let data_slice = data.as_slice();
2860                                let tail = *rows - n_groups * ferrox_quant::Q5_KX8_NROWS;
2861                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2862                                    let r = n_groups * ferrox_quant::Q5_KX8_NROWS + i;
2863                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2864                                    for (b, act) in acts.iter().enumerate() {
2865                                        unsafe {
2866                                            out_w.set(
2867                                                b * rows + r,
2868                                                ferrox_quant::dot_q5_k_q8(row, act),
2869                                            );
2870                                        }
2871                                    }
2872                                });
2873                            } else {
2874                                let data_slice = data.as_slice();
2875                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2876                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2877                                    let nc = ferrox_quant::Q5_K_GEMM_NC;
2878                                    for (t, chunk) in acts.chunks(nc).enumerate() {
2879                                        let n = chunk.len();
2880                                        let mut tmp = [0f32; ferrox_quant::Q5_K_GEMM_NC];
2881                                        ferrox_quant::gemm_q5_k_q8_row(row, chunk, &mut tmp[..n]);
2882                                        for (j, v) in tmp[..n].iter().enumerate() {
2883                                            unsafe {
2884                                                out_w.set((t * nc + j) * rows + r, *v);
2885                                            }
2886                                        }
2887                                    }
2888                                });
2889                            }
2890                            return out;
2891                        }
2892                        QuantKind::Q6K if cols.is_multiple_of(256) => {
2893                            let mut acts_owned = Vec::new();
2894                            let (acts, shared_tiles) =
2895                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
2896                            // Kx8 batch path only where the i8mm GEMM
2897                            // exists (the scalar Kx8 GEMM measured slower
2898                            // than the per-row NEON dot on Phi ffn_down,
2899                            // so everything else keeps the row path).
2900                            let interleave = ferrox_quant::q6_kx8_interleave();
2901                            let use_kx8 = ferrox_quant::q6_kx8_gemm_uses_acts_x4(interleave);
2902                            let n_groups = if use_kx8 {
2903                                *rows / ferrox_quant::Q6_KX8_NROWS
2904                            } else {
2905                                0
2906                            };
2907                            if n_groups > 0 {
2908                                let packed = get_or_repack_q6k(data, *rows, cols);
2909                                // Quads of 4 (the i8mm tile shape), not
2910                                // [`Q6_KX8_GEMM_NC`].
2911                                let nc = ferrox_quant::Q8K_ACTS_X4_NC;
2912                                let tiles_owned: Vec<ferrox_quant::Q8KActsX4>;
2913                                let act_tiles: &[ferrox_quant::Q8KActsX4] =
2914                                    if shared_tiles.is_empty() {
2915                                        tiles_owned = acts
2916                                            .par_chunks(nc)
2917                                            .map(|chunk| {
2918                                                ferrox_quant::prepare_q8_k_acts_x4(chunk, cols)
2919                                            })
2920                                            .collect();
2921                                        &tiles_owned
2922                                    } else {
2923                                        shared_tiles
2924                                    };
2925                                let accel = ferrox_quant::AccelX4::detect();
2926                                let n_tiles = batch_size.div_ceil(nc);
2927                                Self::par_chunked_groups(
2928                                    n_groups,
2929                                    ferrox_quant::Q6_KX8_NROWS,
2930                                    n_tiles,
2931                                    nc,
2932                                    |g, t0, t1| {
2933                                        let mut tile = [0f32;
2934                                            ferrox_quant::Q6_KX8_NROWS
2935                                                * ferrox_quant::Q8K_ACTS_X4_NC];
2936                                        for t in t0..t1 {
2937                                            let chunk =
2938                                                &acts[t * nc..((t + 1) * nc).min(batch_size)];
2939                                            let n = chunk.len();
2940                                            let tile = &mut tile[..ferrox_quant::Q6_KX8_NROWS * n];
2941                                            ferrox_quant::gemm_q6_kx8_group_x4_on(
2942                                                &packed,
2943                                                g,
2944                                                &act_tiles[t],
2945                                                cols,
2946                                                interleave,
2947                                                accel,
2948                                                tile,
2949                                            );
2950                                            for j in 0..n {
2951                                                let col = (t * nc + j) * rows
2952                                                    + g * ferrox_quant::Q6_KX8_NROWS;
2953                                                for r in 0..ferrox_quant::Q6_KX8_NROWS {
2954                                                    unsafe {
2955                                                        out_w.set(col + r, tile[r * n + j]);
2956                                                    }
2957                                                }
2958                                            }
2959                                        }
2960                                    },
2961                                );
2962                                let data_slice = data.as_slice();
2963                                let tail = *rows - n_groups * ferrox_quant::Q6_KX8_NROWS;
2964                                crate::par::indices(tail, Self::min_rows_per_task(tail), |i| {
2965                                    let r = n_groups * ferrox_quant::Q6_KX8_NROWS + i;
2966                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2967                                    for (b, act) in acts.iter().enumerate() {
2968                                        unsafe {
2969                                            out_w.set(
2970                                                b * rows + r,
2971                                                ferrox_quant::dot_q6_k_q8(row, act),
2972                                            );
2973                                        }
2974                                    }
2975                                });
2976                            } else {
2977                                let data_slice = data.as_slice();
2978                                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
2979                                    let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
2980                                    let nc = ferrox_quant::Q6_K_GEMM_NC;
2981                                    for (t, chunk) in acts.chunks(nc).enumerate() {
2982                                        let mut tmp = [0f32; ferrox_quant::Q6_K_GEMM_NC];
2983                                        let n = chunk.len();
2984                                        ferrox_quant::gemm_q6_k_q8_row(row, chunk, &mut tmp[..n]);
2985                                        for (j, v) in tmp[..n].iter().enumerate() {
2986                                            unsafe {
2987                                                out_w.set((t * nc + j) * rows + r, *v);
2988                                            }
2989                                        }
2990                                    }
2991                                });
2992                            }
2993                            return out;
2994                        }
2995                        // IQ4_XS: quantize the activations to Q8_K ONCE
2996                        // per matmul and run the int8 dot per (row,
2997                        // activation). No Kx8 tier, so the row's nibbles
2998                        // are still decoded per activation, as llama.cpp's
2999                        // own IQ4_XS prefill decodes them; what the f32
3000                        // fallback below paid on top was an f32 FMA per
3001                        // element and a per-activation f32 read of the
3002                        // row, measured 4.45x behind llama.cpp on a Ryzen
3003                        // 9 3900X (2026-09-15) where every K-quant on the
3004                        // same host was 1.0x to 1.4x.
3005                        QuantKind::IQ4XS if cols.is_multiple_of(256) => {
3006                            let mut acts_owned = Vec::new();
3007                            let (acts, _) =
3008                                Self::q8k_acts(shared, x_batch, batch_size, cols, &mut acts_owned);
3009                            let data_slice = data.as_slice();
3010                            crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3011                                let row = &data_slice[r * row_bytes..(r + 1) * row_bytes];
3012                                for (b, act) in acts.iter().enumerate() {
3013                                    unsafe {
3014                                        out_w.set(
3015                                            b * rows + r,
3016                                            ferrox_quant::dot_iq4_xs_q8_k(row, act),
3017                                        );
3018                                    }
3019                                }
3020                            });
3021                            return out;
3022                        }
3023                        QuantKind::Q5K | QuantKind::Q6K => {}
3024                        _ => {}
3025                    }
3026                }
3027
3028                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3029                    let row = &data.as_slice()[r * row_bytes..(r + 1) * row_bytes];
3030                    for b in 0..batch_size {
3031                        let x = &x_batch[b * cols..(b + 1) * cols];
3032                        unsafe {
3033                            out_w.set(b * rows + r, Self::dot(*kind, row, x));
3034                        }
3035                    }
3036                });
3037                out
3038            }
3039            WeightMatrix::Mxfp4 {
3040                packed,
3041                scale,
3042                rows,
3043                cols: _,
3044            } => {
3045                let packed_row_bytes = cols / 2;
3046                let scale_row_bytes = cols / ferrox_quant::MXFP4_GROUP_SIZE;
3047                let mut out = vec![0f32; batch_size * rows];
3048                let out_w = BatchOut(out.as_mut_ptr());
3049                crate::par::indices(*rows, Self::min_rows_per_task(*rows), |r| {
3050                    let prow = &packed.as_slice()[r * packed_row_bytes..(r + 1) * packed_row_bytes];
3051                    let srow = &scale.as_slice()[r * scale_row_bytes..(r + 1) * scale_row_bytes];
3052                    for b in 0..batch_size {
3053                        let x = &x_batch[b * cols..(b + 1) * cols];
3054                        unsafe {
3055                            out_w.set(b * rows + r, ferrox_quant::dot_mxfp4_row_f32(prow, srow, x));
3056                        }
3057                    }
3058                });
3059                out
3060            }
3061            WeightMatrix::Adapted { .. } | WeightMatrix::Folded { .. } => {
3062                unreachable!("handled before dispatch")
3063            }
3064        }
3065    }
3066
3067    /// Bytes actually resident in memory for this matrix -- the number
3068    /// that matters for "can this model's weights fit in RAM/VRAM at
3069    /// all," as opposed to the always-4x-larger f32-expanded size.
3070    pub fn resident_bytes(&self) -> usize {
3071        match self {
3072            WeightMatrix::F32(t) => t.len() * 4,
3073            WeightMatrix::Quantized { data, .. } => data.len(),
3074            WeightMatrix::Mxfp4 { packed, scale, .. } => packed.len() + scale.len(),
3075            WeightMatrix::Adapted { base, lora } => base.resident_bytes() + lora.resident_bytes(),
3076            WeightMatrix::Folded { base, .. } => base.resident_bytes(),
3077        }
3078    }
3079
3080    /// Dispatches a single matvec through a real GPU kernel when a GPU
3081    /// feature is compiled in (`cuda` and/or `metal`) and this matrix
3082    /// is one of the five GPU-accelerated quant kinds (Q8_0, Q4_0,
3083    /// Q4_K, Q5_K, Q6_K). Returns `None` for every other case (no GPU
3084    /// feature, `F32`/`Mxfp4`/`Mxfp4Gguf`, or a `Quantized` kind other
3085    /// than the five below), so the caller falls back to `apply()` on
3086    /// the CPU -- this is a real dispatch decision
3087    /// (`ferrox_moe::run_expert_placed` uses it exactly this way), not
3088    /// a stub. Metal weight buffers are process-resident after the first
3089    /// upload (`ferrox_metal::gpu` weight cache); activations still
3090    /// upload per call. When both `cuda` and `metal` are enabled, CUDA
3091    /// is tried first and Metal is the fallback.
3092    #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
3093    pub fn apply_gpu(&self, x: &[f32]) -> Option<Vec<f32>> {
3094        assert_eq!(
3095            x.len(),
3096            self.cols(),
3097            "activation length must match matrix column count"
3098        );
3099        if let WeightMatrix::Adapted { base, lora } = self {
3100            let mut out = base.apply_gpu(x)?;
3101            lora.add_to(x, &mut out);
3102            return Some(out);
3103        }
3104        if let WeightMatrix::Folded { base, fold } = self {
3105            // The rotation goes into the matvec's own command buffer
3106            // when Metal can serve it, so the host neither runs the
3107            // butterfly nor ships a second vector. Every other backend
3108            // takes the host transform, which is the definition both
3109            // sides are checked against.
3110            #[cfg(feature = "metal")]
3111            if metal_dense_enabled() {
3112                if let Some(plan) = fold.metal_plan(x.len()) {
3113                    if let Some(out) = base.apply_gpu_folded(x, Some(&plan)) {
3114                        return Some(out);
3115                    }
3116                }
3117            }
3118            return base.apply_gpu(&fold.transform_input(x));
3119        }
3120
3121        // F32 stays on CPU in apply_gpu: a lone small router matvec is
3122        // faster as host GEMV than a Metal sync. F32 Metal launches are
3123        // used when fused into MoE resident decode (encode_matvec).
3124        let WeightMatrix::Quantized {
3125            data,
3126            rows,
3127            cols,
3128            kind,
3129        } = self
3130        else {
3131            // Deliberate, and recorded rather than hidden: an MoE
3132            // router is a lone small F32 matvec that costs more to ship
3133            // to the GPU than to compute on the host.
3134            let backend = active_backend();
3135            if backend.is_accelerator() {
3136                crate::kernel_registry::miss_by_design(
3137                    crate::kernel_registry::Lookup::new(
3138                        backend,
3139                        crate::kernel_registry::op::MATVEC,
3140                        None,
3141                    ),
3142                    "host GEMV",
3143                );
3144            }
3145            return None;
3146        };
3147        let row_bytes = self.block_bytes_per_row(*kind, *cols);
3148
3149        // One body per backend, expanded over the one ordered list, in
3150        // place of the two hand-kept `match kind` tables this used to
3151        // hold -- which differed in arity, in error type, and (silently)
3152        // by one entry. A third backend adds no code here.
3153        #[allow(unused_macros)]
3154        macro_rules! try_matvec {
3155            ($b:ty) => {
3156                if let Some(result) = <$b as BackendDispatch>::launch_matvec(
3157                    *kind,
3158                    data.as_slice(),
3159                    x,
3160                    *rows,
3161                    row_bytes,
3162                ) {
3163                    match result {
3164                        Ok(out) => return Some(out),
3165                        Err(e) => {
3166                            eprintln!(
3167                                "ferrox: {} matvec dispatch failed, {}: {e}",
3168                                <$b as BackendCaps>::NAME,
3169                                <$b as BackendDispatch>::MATVEC_FALLBACK
3170                            );
3171                        }
3172                    }
3173                }
3174            };
3175        }
3176        with_gpu_backends!(try_matvec);
3177
3178        // Reached only on a miss or a launch error, i.e. only when the
3179        // caller is about to run the whole matvec on the host anyway --
3180        // so recording it here costs nothing measurable and is the only
3181        // signal that a GPU run is quietly not one.
3182        let backend = active_backend();
3183        if backend.is_accelerator() {
3184            crate::kernel_registry::miss(
3185                crate::kernel_registry::Lookup::new(
3186                    backend,
3187                    crate::kernel_registry::op::MATVEC,
3188                    Some(*kind),
3189                ),
3190                "CPU apply_cpu",
3191            );
3192        }
3193        None
3194    }
3195
3196    /// Runs several independent matvecs that share the same activation
3197    /// `x` in one GPU dispatch (one upload of `x`, one wait). Tries
3198    /// CUDA first (when `cuda_dense_enabled()`), then Metal (when
3199    /// `metal_dense_enabled()`). Intended for Q/K/V (and similar)
3200    /// projections. Returns `None` if no GPU backend is enabled, any
3201    /// matrix lacks a GPU kernel, or all fused launches fail — caller
3202    /// should fall back to sequential [`Self::apply`].
3203    #[cfg(any(feature = "cuda", feature = "metal"))]
3204    pub fn apply_gpu_multi(mats: &[&WeightMatrix], x: &[f32]) -> Option<Vec<Vec<f32>>> {
3205        Self::apply_gpu_multi_folded(mats, x, None)
3206    }
3207
3208    /// One matrix through the fused path, with an optional device-side
3209    /// Hadamard prologue. The single-matrix caller of
3210    /// [`Self::apply_gpu_multi_folded`], so a folded projection and a
3211    /// folded q/k/v triple take the same launch.
3212    #[cfg(feature = "metal")]
3213    pub(crate) fn apply_gpu_folded(
3214        &self,
3215        x: &[f32],
3216        fold: Option<&ferrox_metal::hadamard::FoldPlan<'_>>,
3217    ) -> Option<Vec<f32>> {
3218        Self::apply_gpu_multi_folded(&[self], x, fold).map(|mut outs| outs.pop().unwrap())
3219    }
3220
3221    /// [`Self::apply_gpu_multi`] with PrismML's folded rotation applied
3222    /// to `x` on the device before the matvecs read it.
3223    ///
3224    /// `fold` is Metal-only: the rotation kernel is
3225    /// `ferrox_metal::hadamard`, and a CUDA or Vulkan caller takes the
3226    /// host butterfly rather than a rotation nothing there implements.
3227    #[cfg(any(feature = "cuda", feature = "metal"))]
3228    pub(crate) fn apply_gpu_multi_folded(
3229        mats: &[&WeightMatrix],
3230        x: &[f32],
3231        #[cfg(feature = "metal")] fold: Option<&ferrox_metal::hadamard::FoldPlan<'_>>,
3232        #[cfg(not(feature = "metal"))] fold: Option<&()>,
3233    ) -> Option<Vec<Vec<f32>>> {
3234        // Off Metal there is no rotation kernel, so `None` is the only
3235        // value this can take and nothing below reads it. Bound here
3236        // rather than renamed, because the parameter is part of one
3237        // signature both builds call.
3238        #[cfg(not(feature = "metal"))]
3239        let _ = &fold;
3240        if mats.is_empty() {
3241            return None;
3242        }
3243        assert_eq!(
3244            x.len(),
3245            mats[0].cols(),
3246            "activation length must match matrix column count"
3247        );
3248        if mats.iter().any(|m| m.lora().is_some()) {
3249            // The fused launch runs over the bases; each adapter's
3250            // delta is added to its own output on the host.
3251            let bases: Vec<&WeightMatrix> = mats.iter().map(|m| m.base()).collect();
3252            let mut outs = Self::apply_gpu_multi(&bases, x)?;
3253            for (m, out) in mats.iter().zip(outs.iter_mut()) {
3254                if let Some(lora) = m.lora() {
3255                    lora.add_to(x, out);
3256                }
3257            }
3258            return Some(outs);
3259        }
3260        if let Some(fold) = mats[0].hadamard_fold() {
3261            // One fold shared by every matrix (q/k/v split from one folded
3262            // fused projection): transform once, launch the bases.
3263            // Different folds, or a fold on only some, is not a shared
3264            // activation any more, and the caller runs them one by one.
3265            if !mats.iter().all(|m| {
3266                m.hadamard_fold()
3267                    .is_some_and(|f| std::sync::Arc::ptr_eq(f, fold))
3268            }) {
3269                return None;
3270            }
3271            let bases: Vec<&WeightMatrix> = mats
3272                .iter()
3273                .map(|m| match m {
3274                    WeightMatrix::Folded { base, .. } => base.as_ref(),
3275                    _ => unreachable!("every matrix carries the fold"),
3276                })
3277                .collect();
3278            let xt = fold.transform_input(x);
3279            return Self::apply_gpu_multi(&bases, &xt);
3280        }
3281        if mats.iter().any(|m| m.hadamard_fold().is_some()) {
3282            return None;
3283        }
3284
3285        // Try CUDA first if enabled.
3286        #[cfg(feature = "cuda")]
3287        if cuda_dense_enabled() {
3288            let mut launches = Vec::with_capacity(mats.len());
3289            for m in mats {
3290                assert_eq!(m.cols(), mats[0].cols());
3291                let WeightMatrix::Quantized {
3292                    data,
3293                    rows,
3294                    cols,
3295                    kind,
3296                } = m
3297                else {
3298                    return None;
3299                };
3300                // One table, in `ferrox-cuda`, exactly as the Metal arm
3301                // below asks `matvec_launch_meta`. This match was
3302                // written out here and again in
3303                // `apply_gpu_dense_ffn_swiglu`, three copies of one
3304                // five-row list with nothing holding them together --
3305                // and a kind added to the capability table but not to a
3306                // copy loses its fused launch silently, which is the
3307                // failure this file has paid for twice.
3308                let (kernel_src, module_name, fn_name) =
3309                    ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
3310                let row_bytes = m.block_bytes_per_row(*kind, *cols);
3311                let n_blocks_per_row = row_bytes / Self::block_bytes_for_kind(*kind);
3312                launches.push(ferrox_cuda::gpu::MatvecLaunch {
3313                    kernel_src,
3314                    module_name,
3315                    fn_name,
3316                    // Borrow mmap/owned storage — never to_vec() (breaks
3317                    // resident_cuda_weights pointer cache; re-uploads GB).
3318                    weights: data.as_slice(),
3319                    rows: *rows,
3320                    row_bytes,
3321                    n_blocks_per_row,
3322                });
3323            }
3324            match ferrox_cuda::gpu::launch_matvec_multi(x, &launches) {
3325                Ok(outs) => return Some(outs),
3326                Err(e) => {
3327                    eprintln!("ferrox: CUDA multi-matvec failed, trying next backend: {e}");
3328                }
3329            }
3330        }
3331
3332        // Try Metal if CUDA didn't return or failed.
3333        #[cfg(feature = "metal")]
3334        if metal_dense_enabled() {
3335            let mut launches = Vec::with_capacity(mats.len());
3336            let mut held: Vec<(&[u8], usize, usize, &'static str)> = Vec::with_capacity(mats.len());
3337            for m in mats {
3338                assert_eq!(m.cols(), mats[0].cols());
3339                let WeightMatrix::Quantized {
3340                    data,
3341                    rows,
3342                    cols,
3343                    kind,
3344                } = m
3345                else {
3346                    return None;
3347                };
3348                // The one Metal kind table (`Metal::matvec_kernel`).
3349                // This was a hand-written six-row match that lacked
3350                // Q5_0 and PTQ1_0, so every gate/up and q/k/v pair of
3351                // those kinds ran as separate launches: correct, and
3352                // one GPU round trip per layer slower than the single
3353                // path for the kinds the copy happened to name.
3354                let kind_name = Metal::matvec_kernel(*kind)?;
3355                let row_bytes = m.block_bytes_per_row(*kind, *cols);
3356                held.push((data.as_slice(), *rows, row_bytes, kind_name));
3357            }
3358            for (weights, rows, row_bytes, kind_name) in &held {
3359                let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3360                    ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3361                launches.push(ferrox_metal::gpu::MatvecLaunch {
3362                    kernel_src: src,
3363                    fn_name,
3364                    block_bytes,
3365                    block_elems,
3366                    weights,
3367                    rows: *rows,
3368                    row_bytes: *row_bytes,
3369                    rows_per_tg,
3370                });
3371            }
3372            match ferrox_metal::gpu::launch_matvec_fused_folded(
3373                x,
3374                &launches,
3375                ferrox_metal::gpu::MatvecEpilogue::default(),
3376                fold,
3377            ) {
3378                Ok(outs) => return Some(outs),
3379                Err(e) => {
3380                    eprintln!("ferrox: Metal fused matvec failed, falling back to CPU: {e}");
3381                }
3382            }
3383        }
3384
3385        None
3386    }
3387
3388    /// Dense SwiGLU FFN on GPU with device-resident activations:
3389    /// one upload of `x`, gate+up+silu×up+down on device, one download.
3390    /// Tries CUDA first when enabled, then Metal. Returns `None` if
3391    /// no GPU path applies — caller falls back to [`Self::apply`] /
3392    /// multi-matvec.
3393    #[cfg(any(feature = "cuda", feature = "metal"))]
3394    pub fn apply_gpu_dense_ffn_swiglu(
3395        gate: &WeightMatrix,
3396        up: &WeightMatrix,
3397        down: &WeightMatrix,
3398        x: &[f32],
3399    ) -> Option<Vec<f32>> {
3400        #[cfg(feature = "cuda")]
3401        {
3402            if cuda_dense_enabled() {
3403                fn cuda_launch(m: &WeightMatrix) -> Option<ferrox_cuda::gpu::MatvecLaunch<'_>> {
3404                    let WeightMatrix::Quantized {
3405                        data,
3406                        rows,
3407                        cols,
3408                        kind,
3409                    } = m
3410                    else {
3411                        return None;
3412                    };
3413                    // The second of the two copies this used to hold.
3414                    // See the note in `apply_gpu_multi`.
3415                    let (kernel_src, module_name, fn_name) =
3416                        ferrox_cuda::gpu::matvec_launch_meta(kind.name())?;
3417                    let row_bytes = m.block_bytes_per_row(*kind, *cols);
3418                    let n_blocks_per_row = row_bytes / WeightMatrix::block_bytes_for_kind(*kind);
3419                    Some(ferrox_cuda::gpu::MatvecLaunch {
3420                        kernel_src,
3421                        module_name,
3422                        fn_name,
3423                        weights: data.as_slice(),
3424                        rows: *rows,
3425                        row_bytes,
3426                        n_blocks_per_row,
3427                    })
3428                }
3429                if let (Some(g), Some(u), Some(d)) =
3430                    (cuda_launch(gate), cuda_launch(up), cuda_launch(down))
3431                {
3432                    assert_eq!(gate.cols(), x.len());
3433                    assert_eq!(up.cols(), x.len());
3434                    assert_eq!(down.cols(), gate.rows());
3435                    match ferrox_cuda::gpu::launch_dense_ffn_swiglu(&g, &u, &d, x) {
3436                        Ok(out) => return Some(out),
3437                        Err(e) => {
3438                            eprintln!("ferrox: CUDA dense FFN fuse failed, trying next: {e}");
3439                        }
3440                    }
3441                }
3442            }
3443        }
3444        #[cfg(feature = "metal")]
3445        {
3446            if metal_dense_enabled() {
3447                fn metal_launch(m: &WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'_>> {
3448                    let WeightMatrix::Quantized {
3449                        data,
3450                        rows,
3451                        cols: _,
3452                        kind,
3453                        // Through the fold, which travels as a plan beside
3454                        // the launch rather than as a transformed vector.
3455                        // `base()` unwraps a LoRA only, deliberately: an
3456                        // adapter is arithmetic this kernel cannot do.
3457                    } = (match m {
3458                        WeightMatrix::Folded { base, .. } => base.as_ref(),
3459                        other => other,
3460                    })
3461                    else {
3462                        return None;
3463                    };
3464                    // The one Metal kind table again: this was the
3465                    // THIRD hand-written copy of it, and it lacked
3466                    // Q5_0 and PTQ1_0, so those kinds ran their FFN as
3467                    // three submissions with a host SwiGLU between.
3468                    let kind_name = Metal::matvec_kernel(*kind)?;
3469                    let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3470                        ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3471                    // A zero-row matrix has no rows to stride over, so
3472                    // there is no meaningful row size; `checked_div`
3473                    // says that once instead of splitting it across a
3474                    // guard and a bare division.
3475                    let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
3476                    Some(ferrox_metal::gpu::MatvecLaunch {
3477                        kernel_src: src,
3478                        fn_name,
3479                        block_bytes,
3480                        block_elems,
3481                        weights: data.as_slice(),
3482                        rows: *rows,
3483                        row_bytes,
3484                        rows_per_tg,
3485                    })
3486                }
3487                // A folded FFN reads TWO rotated activations: `x`
3488                // before gate/up, and the SwiGLU output before `down`.
3489                // They are different widths and therefore different
3490                // folds, so both travel with the launch rather than
3491                // one standing in for the other.
3492                let fold_x = gate.hadamard_fold().and_then(|f| f.metal_plan(x.len()));
3493                let fold_act = down.hadamard_fold().and_then(|f| f.metal_plan(gate.rows()));
3494                let folds_agree = match (gate.hadamard_fold(), up.hadamard_fold()) {
3495                    (Some(g), Some(u)) => std::sync::Arc::ptr_eq(g, u) && fold_x.is_some(),
3496                    (None, None) => true,
3497                    _ => false,
3498                } && (down.hadamard_fold().is_none() == fold_act.is_none());
3499                if let (Some(g), Some(u), Some(d), true) = (
3500                    metal_launch(gate),
3501                    metal_launch(up),
3502                    metal_launch(down),
3503                    folds_agree,
3504                ) {
3505                    assert_eq!(gate.cols(), x.len());
3506                    assert_eq!(up.cols(), x.len());
3507                    assert_eq!(down.cols(), gate.rows());
3508                    match ferrox_metal::gpu::launch_dense_ffn_swiglu_folded(
3509                        &g,
3510                        &u,
3511                        &d,
3512                        x,
3513                        fold_x.as_ref(),
3514                        fold_act.as_ref(),
3515                    ) {
3516                        Ok(out) => return Some(out),
3517                        Err(e) => {
3518                            eprintln!("ferrox: Metal dense FFN fuse failed, falling back: {e}");
3519                        }
3520                    }
3521                }
3522            }
3523        }
3524        None
3525    }
3526
3527    /// Runs one weight matrix against `batch_size` activations in a
3528    /// single Metal command buffer (shared resident weights, one
3529    /// upload of `x_batch`, one GPU wait). `x_batch` / return layout
3530    /// match [`Self::apply_batch`]: `[batch, cols]` → `[batch, rows]`.
3531    /// Returns `None` if Metal dense is off, the kind lacks a Metal
3532    /// kernel, or the launch fails.
3533    ///
3534    /// `batch_size >= 4` takes the weight-reuse `mul_mm` path where the
3535    /// kind has one; everything else falls through to
3536    /// [`ferrox_metal::gpu::launch_matvec_batch`].
3537    #[cfg(feature = "metal")]
3538    pub fn apply_gpu_batch(&self, x_batch: &[f32], batch_size: usize) -> Option<Vec<f32>> {
3539        if !metal_dense_enabled() || batch_size == 0 {
3540            return None;
3541        }
3542        if let WeightMatrix::Adapted { base, lora } = self {
3543            let mut out = base.apply_gpu_batch(x_batch, batch_size)?;
3544            lora.add_batch_to(x_batch, batch_size, &mut out);
3545            return Some(out);
3546        }
3547        if let WeightMatrix::Folded { base, fold } = self {
3548            // A BATCH keeps the host rotation, and that is a
3549            // measurement rather than an omission: the same device
3550            // prologue that pays for itself on a decode token
3551            // (`apply_gpu`) cost 6% of Bonsai prefill, because
3552            // `transform_rows` is already parallel across cores and the
3553            // kernel serialises into the GEMM's own command buffer
3554            // instead. `pp128` 33.96 -> 32.04 tok/s on an M2 Pro, three
3555            // reps each, which is why `apply_gpu_batch` takes no fold.
3556            let transformed = fold.transform_rows(x_batch, batch_size);
3557            return base.apply_gpu_batch(&transformed, batch_size);
3558        }
3559        let WeightMatrix::Quantized {
3560            data,
3561            rows,
3562            cols,
3563            kind,
3564        } = self
3565        else {
3566            return None;
3567        };
3568        let Some(kind_name) = metal_matvec_kind_name(*kind) else {
3569            crate::kernel_registry::miss(
3570                crate::kernel_registry::Lookup::new(
3571                    crate::kernel_registry::Backend::Metal,
3572                    crate::kernel_registry::op::GEMM_PREFILL,
3573                    Some(*kind),
3574                ),
3575                "CPU apply_batch",
3576            );
3577            return None;
3578        };
3579        let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
3580            ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
3581        let row_bytes = self.block_bytes_per_row(*kind, *cols);
3582        // Weight-reuse simdgroup GEMM for prefill batch >= 4: each 64x32
3583        // output tile reads its weight slice once into threadgroup
3584        // memory instead of once per token. The batched matvec below is
3585        // the fallback it replaces -- correct, but it re-reads the whole
3586        // matrix for every token, which is why Metal `pp512` was 14-99x
3587        // behind llama.cpp. Threshold 4 (was 8) covers shorter prompts
3588        // without changing the decode path (batch_size == 1 is matvec).
3589        //
3590        // ONE launcher keyed by the kind's name, read off the same table
3591        // `gemm_supported` is derived from. This was a seven-arm match
3592        // naming each kind's wrapper, and the arms lagged the table:
3593        // Q5_0 and PTQ1_0 had a GEMM the table admitted and the match
3594        // did not reach, so their prefill ran N matvecs while the kernel
3595        // registry recorded a GEMM hit.
3596        if batch_size >= 4 {
3597            if !metal_mul_mm_kind_supported(*kind) {
3598                // Observation only: a kind with a matvec kernel but no
3599                // simdgroup GEMM still runs on Metal, as `batch`
3600                // separate matvecs over the same weights. That is the
3601                // shape that cost IQ4_XS 13.7x, and it is invisible in
3602                // the output.
3603                crate::kernel_registry::miss(
3604                    crate::kernel_registry::Lookup::new(
3605                        crate::kernel_registry::Backend::Metal,
3606                        crate::kernel_registry::op::GEMM_PREFILL,
3607                        Some(*kind),
3608                    ),
3609                    "Metal N x matvec batch",
3610                );
3611            }
3612            match ferrox_metal::gpu::launch_mul_mm_sg(
3613                kind.name(),
3614                data.as_slice(),
3615                x_batch,
3616                *rows,
3617                row_bytes,
3618                batch_size,
3619            ) {
3620                Ok(Some(out)) => return Some(out),
3621                Ok(None) => {}
3622                Err(e) => {
3623                    eprintln!(
3624                        "ferrox: Metal {} simdgroup mul_mm failed, batched fallback: {e}",
3625                        kind.name()
3626                    );
3627                }
3628            }
3629            // The older non-simdgroup GEMMs, kept for the two kinds that
3630            // have one, between the simdgroup kernel and the matvec.
3631            let older = match kind {
3632                QuantKind::Q4_0 => Some(ferrox_metal::gpu::launch_q4_0_mul_mm(
3633                    data.as_slice(),
3634                    x_batch,
3635                    *rows,
3636                    row_bytes,
3637                    batch_size,
3638                )),
3639                QuantKind::Q4K => Some(ferrox_metal::gpu::launch_q4_k_mul_mm(
3640                    data.as_slice(),
3641                    x_batch,
3642                    *rows,
3643                    row_bytes,
3644                    batch_size,
3645                )),
3646                _ => None,
3647            };
3648            match older {
3649                Some(Ok(out)) => return Some(out),
3650                Some(Err(e)) => {
3651                    eprintln!(
3652                        "ferrox: Metal {} mul_mm failed, matvec fallback: {e}",
3653                        kind.name()
3654                    );
3655                }
3656                None => {}
3657            }
3658        }
3659        let launch = ferrox_metal::gpu::MatvecLaunch {
3660            kernel_src: src,
3661            fn_name,
3662            block_bytes,
3663            block_elems,
3664            weights: data.as_slice(),
3665            rows: *rows,
3666            row_bytes,
3667            rows_per_tg,
3668        };
3669        match ferrox_metal::gpu::launch_matvec_batch(&launch, x_batch, batch_size) {
3670            Ok(out) => Some(out),
3671            Err(e) => {
3672                eprintln!("ferrox: Metal batch matvec failed, falling back: {e}");
3673                None
3674            }
3675        }
3676    }
3677
3678    /// Delegates to [`metal_matvec_kind_name`]. Kept as a method because
3679    /// the call sites read better, but it must never grow a list of its
3680    /// own again — a second copy of this list is what sent IQ4_XS
3681    /// batched prefill to the CPU.
3682    #[cfg(feature = "metal")]
3683    fn metal_kind_supported(kind: QuantKind) -> bool {
3684        metal_matvec_kind_name(kind).is_some()
3685    }
3686
3687    /// Eagerly resolve, and record, every kernel lookup this matrix's
3688    /// dispatch paths will make later, without dispatching anything.
3689    ///
3690    /// Call once per weight while the model is being built, with `role`
3691    /// naming the tensor (`"attn_q"`, `"ffn_down"`, ...). The predicates
3692    /// consulted here are the *same functions* the hot path consults, so
3693    /// the recorded prediction cannot drift from the decision. See
3694    /// [`crate::kernel_registry`] for why this exists and
3695    /// [`crate::kernel_registry::seal`] for what is done with it.
3696    ///
3697    /// Observation only: nothing here influences a later dispatch.
3698    #[track_caller]
3699    pub fn probe_kernels(&self, role: &'static str) {
3700        if !crate::kernel_registry::enabled() {
3701            return;
3702        }
3703        self.probe_kernels_into(
3704            crate::kernel_registry::global(),
3705            role,
3706            std::panic::Location::caller(),
3707        );
3708    }
3709
3710    /// [`Self::probe_kernels`] against an explicit registry and call
3711    /// site, so tests can probe into an instance of their own instead of
3712    /// the process-wide one.
3713    pub fn probe_kernels_into(
3714        &self,
3715        reg: &crate::kernel_registry::Registry,
3716        role: &'static str,
3717        loc: &'static std::panic::Location<'static>,
3718    ) {
3719        self.probe_kernels_for(reg, active_backend(), role, loc)
3720    }
3721
3722    /// [`Self::probe_kernels_into`] against an explicit backend rather
3723    /// than [`active_backend`]. Lets a test on a CPU-only build ask what
3724    /// a Metal or CUDA run would resolve -- which is the only way the
3725    /// kernel-coverage tests can run under plain
3726    /// `cargo test --workspace`, where every GPU feature is off.
3727    pub fn probe_kernels_for(
3728        &self,
3729        reg: &crate::kernel_registry::Registry,
3730        backend: crate::kernel_registry::Backend,
3731        role: &'static str,
3732        loc: &'static std::panic::Location<'static>,
3733    ) {
3734        use crate::kernel_registry::{op, Backend, Lookup, Outcome};
3735
3736        let kind = self.quant_kind();
3737        let cols = self.cols();
3738        let look = |op: &'static str| Lookup {
3739            backend,
3740            op,
3741            role,
3742            kind,
3743        };
3744
3745        // Whether the accelerator, if one is selected, can run this
3746        // matrix at all -- and if so, whether prefill gets a real GEMM
3747        // or `batch` matvecs over the same weights.
3748        //
3749        // Read off `BackendCaps` over the ungated backend table rather
3750        // than from a `match backend` written out here. The two are
3751        // NOT interchangeable: the hand-written match had a `Backend::
3752        // Cpu => (false, false)` arm and no `_`, so it was exhaustive
3753        // by luck -- a third variant broke it, which is the good case.
3754        // A fourth backend added while a `_` arm existed would have
3755        // silently reported "no kernels" for a backend that had them.
3756        //
3757        // Ungated on purpose: a CPU-only build must be able to ask what
3758        // CUDA would resolve, which is what every kernel-coverage test
3759        // below does. `BackendDispatch` is unavailable here for exactly
3760        // that reason.
3761        //
3762        // The predicate sets are per backend and genuinely different:
3763        // CUDA has a batched GEMM for a SUBSET of the kinds it has
3764        // matvecs for (Q8_0, Q4_0) and decomposes the rest into
3765        // per-position matvecs; Vulkan has one matvec and no GEMM at
3766        // all. `GEMM_FALLBACK` is what each of those decompositions is
3767        // actually called.
3768        let (matvec, gemm, gemm_fallback) = {
3769            let mut found = (false, false, "");
3770            macro_rules! caps_of {
3771                ($b:ty) => {
3772                    if backend == <$b as BackendCaps>::ID {
3773                        found = (
3774                            kind.is_some_and(|k| <$b as BackendCaps>::matvec_kernel(k).is_some()),
3775                            kind.is_some_and(<$b as BackendCaps>::gemm_supported),
3776                            <$b as BackendCaps>::GEMM_FALLBACK,
3777                        );
3778                    }
3779                };
3780            }
3781            with_gpu_backend_caps!(caps_of);
3782            found
3783        };
3784
3785        if backend.is_accelerator() {
3786            reg.record_build_at(
3787                loc,
3788                look(op::MATVEC),
3789                match kind {
3790                    // An accelerator kernel exists for this format.
3791                    _ if matvec => Outcome::Hit,
3792                    // No kernel: the whole matvec runs on the host.
3793                    Some(_) => Outcome::slow_path("CPU apply_cpu"),
3794                    // F32 has no quantized kernel by construction, and a
3795                    // lone small F32 matvec (an MoE router) is host work
3796                    // on purpose -- see `apply_gpu`.
3797                    None => Outcome::by_design("host GEMV"),
3798                },
3799            );
3800            reg.record_build_at(
3801                loc,
3802                look(op::GEMM_PREFILL),
3803                match (gemm, matvec, kind) {
3804                    (true, ..) => Outcome::Hit,
3805                    // A matvec but no GEMM. What that costs is per
3806                    // backend -- Metal re-reads the whole weight matrix
3807                    // once per position but stays on the GPU (the 13.7x
3808                    // shape), CUDA does the same through a different
3809                    // entry point, and Vulkan has no batch path at all
3810                    // so the prefill lands on the host -- so the name
3811                    // comes from the backend instead of from an arm
3812                    // here that a new variant would fall through.
3813                    (false, true, _) => Outcome::slow_path(gemm_fallback),
3814                    (false, false, Some(_)) => Outcome::slow_path("CPU apply_batch"),
3815                    (false, false, None) => Outcome::by_design("CPU f32 GEMM"),
3816                },
3817            );
3818        }
3819
3820        // The host path is what every accelerator miss lands on, so
3821        // record its tier too: integer vec_dot, or the much slower f32
3822        // dequant-dot.
3823        if !matvec || !gemm {
3824            let int_dot = cpu_int_dot_for(IntDotShape::Matvec)
3825                && kind.is_some_and(|k| cpu_int_dot_kind_supported(k, cols));
3826            reg.record_build_at(
3827                loc,
3828                Lookup {
3829                    backend: Backend::Cpu,
3830                    op: op::MATVEC,
3831                    role,
3832                    kind,
3833                },
3834                match kind {
3835                    _ if int_dot => Outcome::Hit,
3836                    // A quantized weight with no integer vec_dot kernel
3837                    // dequantizes to f32 first: a much slower engine,
3838                    // and invisible in the output.
3839                    Some(_) => Outcome::slow_path("f32 dequant-dot"),
3840                    None => Outcome::by_design("f32 GEMM"),
3841                },
3842            );
3843        }
3844    }
3845
3846    /// The block size (in bytes) for exactly the quant kinds
3847    /// `apply_gpu` dispatches to a real CUDA or Vulkan kernel for -- a
3848    /// small, deliberately partial mirror of `block_bytes_per_row`'s
3849    /// per-kind match.
3850    ///
3851    /// Partial means this `unreachable!()` is reachable by a mistake:
3852    /// widening `Cuda::matvec_kernel` without adding the row here
3853    /// turns a decode into a panic in a rayon worker rather than a
3854    /// fallback. `every_cuda_or_vulkan_matvec_kind_has_a_block_size`
3855    /// calls it for every claimed kind so that lands as a red test
3856    /// instead.
3857    #[cfg(any(feature = "cuda", feature = "vulkan"))]
3858    pub(crate) fn block_bytes_for_kind(kind: QuantKind) -> usize {
3859        match kind {
3860            QuantKind::Q8_0 => ferrox_quant::Q8_0_BLOCK_BYTES,
3861            QuantKind::Q4_0 => ferrox_quant::Q4_0_BLOCK_BYTES,
3862            QuantKind::Q5_0 => ferrox_quant::Q5_0_BLOCK_BYTES,
3863            QuantKind::Q4K => ferrox_quant::Q4_K_BLOCK_BYTES,
3864            QuantKind::Q5K => ferrox_quant::Q5_K_BLOCK_BYTES,
3865            QuantKind::Q6K => ferrox_quant::Q6_K_BLOCK_BYTES,
3866            QuantKind::Q2K => ferrox_quant::Q2_K_BLOCK_BYTES,
3867            QuantKind::Q3K => ferrox_quant::Q3_K_BLOCK_BYTES,
3868            QuantKind::IQ4NL => ferrox_quant::IQ4_NL_BLOCK_BYTES,
3869            QuantKind::IQ4XS => ferrox_quant::IQ4_XS_BLOCK_BYTES,
3870            QuantKind::Mxfp4Gguf => ferrox_quant::MXFP4_GGUF_BLOCK_BYTES,
3871            _ => unreachable!(
3872                "apply_gpu only calls this for the CUDA/Vulkan-dispatchable kinds, not {kind:?}"
3873            ),
3874        }
3875    }
3876}
3877#[cfg(test)]
3878mod tests {
3879
3880    /// The task floor is **work-aware**, which is the whole reason
3881    /// [`crate::par::with_op_work`] exists: a row count alone cannot
3882    /// tell a 64-wide matrix from a 256-wide one, and rayon splitting
3883    /// the narrow one by rows alone is the measured 13-16x small-model
3884    /// regression.
3885    ///
3886    /// Both shapes here sit under [`crate::par::policy::SPIN_MIN_OP_MACS`]
3887    /// so both are decided by the fork-join arm, which is the only arm
3888    /// that reads a `min_len` at all.
3889    ///
3890    /// Sabotage: drop the `MIN_TASK_MACS` term from `min_rows_per_task`
3891    /// and this goes red, because both shapes then collapse onto the
3892    /// same row-count floor.
3893    #[test]
3894    fn the_task_floor_demands_more_rows_of_a_narrower_matrix() {
3895        if crate::par::policy::pinned().is_some() {
3896            return; // pinned: not the arm this floor belongs to
3897        }
3898        let rows = 4096usize;
3899        let narrow = crate::par::with_op_work(rows, 64, || WeightMatrix::min_rows_per_task(rows));
3900        let wider = crate::par::with_op_work(rows, 256, || WeightMatrix::min_rows_per_task(rows));
3901        assert_eq!(narrow, MIN_TASK_MACS.div_ceil(64));
3902        assert!(
3903            narrow > wider,
3904            "a 64-wide row carries a quarter of a 256-wide row's work, so a \
3905             task must hold four times as many of them: {narrow} vs {wider}"
3906        );
3907    }
3908
3909    /// The four dtypes the drifted copies were missing.
3910    ///
3911    /// Three of the six loaders stopped at IQ1_M, so `IQ1_S`,
3912    /// `IQ2_XXS`, `IQ3_XXS` and `MXFP4` mapped to `None` there -- and a
3913    /// `None` is `LoadError::UnsupportedDtype`, not a slower path. A
3914    /// DeepSeek-MLA checkpoint at `IQ2_XXS`, an ordinary quant for a
3915    /// model that size, was refused outright while the same quant
3916    /// loaded on the generic path. One table is what stops that
3917    /// recurring.
3918    #[test]
3919    fn the_four_dtypes_the_duplicated_tables_disagreed_about_all_map() {
3920        assert_eq!(quant_kind_for(GgmlType::IQ1S), Some(QuantKind::IQ1S));
3921        assert_eq!(quant_kind_for(GgmlType::IQ2XXS), Some(QuantKind::IQ2XXS));
3922        assert_eq!(quant_kind_for(GgmlType::IQ3XXS), Some(QuantKind::IQ3XXS));
3923        assert_eq!(quant_kind_for(GgmlType::MXFP4), Some(QuantKind::Mxfp4Gguf));
3924    }
3925
3926    /// Every dtype with a CPU dequant kernel must be reachable through
3927    /// this map, or the kernel exists and no loader can ever hand it a
3928    /// tensor. Checked against the two backend tables rather than a
3929    /// hand-written list, so adding a kernel without a mapping fails
3930    /// here instead of at a user's load.
3931    #[test]
3932    fn every_dtype_with_a_gpu_kernel_is_reachable_through_the_map() {
3933        let mapped: Vec<QuantKind> = [
3934            GgmlType::Q8_0,
3935            GgmlType::Q4_0,
3936            GgmlType::Q4K,
3937            GgmlType::Q5K,
3938            GgmlType::Q6K,
3939            GgmlType::IQ4XS,
3940        ]
3941        .into_iter()
3942        .map(|d| quant_kind_for(d).expect("a dtype with a GPU kernel must map"))
3943        .collect();
3944        for kind in mapped {
3945            assert!(
3946                metal_mul_mm_kind_supported(kind) || cuda_matvec_kind_supported(kind),
3947                "{kind:?} was listed as having a GPU kernel"
3948            );
3949        }
3950    }
3951
3952    /// The CUDA capability predicates and the *launch* table must name
3953    /// the same set, for every kind.
3954    ///
3955    /// `Cuda::matvec_kernel` and `Cuda::gemm_supported` are DERIVED
3956    /// from `ferrox-cuda`'s own kernel tables now, so the two pairs
3957    /// that used to be checked here cannot disagree -- those tests were
3958    /// deleted rather than left comparing a table to itself, which
3959    /// reads as coverage and is not.
3960    ///
3961    /// This one still matters. [`cuda_matvec_launch`] is a table of
3962    /// FUNCTION POINTERS, which only exist under `--features cuda`, so
3963    /// it cannot be derived from a table of strings. Over-claiming in
3964    /// the capability predicate sends a decode to a launcher that does
3965    /// not exist; under-claiming leaves a kernel nothing calls. The
3966    /// dispatch seam only `debug_assert!`s the agreement at the moment
3967    /// a matmul happens to run, which in release is no check at all.
3968    #[cfg(feature = "cuda")]
3969    #[test]
3970    fn every_cuda_matvec_kind_has_a_launcher() {
3971        use super::gpu_backend::cuda_matvec_launch;
3972        for &kind in QuantKind::ALL {
3973            assert_eq!(
3974                cuda_matvec_kind_supported(kind),
3975                cuda_matvec_launch(kind).is_some(),
3976                "{kind:?}: the capability table and the launch table disagree"
3977            );
3978        }
3979    }
3980
3981    /// `block_bytes_for_kind` is deliberately partial, so every kind
3982    /// CUDA or Vulkan claims a matvec for has to be one of its arms.
3983    ///
3984    /// Calling it IS the assertion: the arm it lacks is an
3985    /// `unreachable!()`, and reaching that in a rayon worker is a panic
3986    /// rather than the fallback the seam promises. `Metal` is
3987    /// deliberately not checked -- it claims IQ4_XS, asks
3988    /// `ferrox_metal::gpu::matvec_launch_meta` for its block size, and
3989    /// never touches this function.
3990    #[cfg(any(feature = "cuda", feature = "vulkan"))]
3991    #[test]
3992    fn every_cuda_or_vulkan_matvec_kind_has_a_block_size() {
3993        use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
3994        for &kind in QuantKind::ALL {
3995            if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
3996                continue;
3997            }
3998            let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
3999            assert!(
4000                block_bytes > 0,
4001                "{kind:?}: a claimed matvec kind needs a real block size"
4002            );
4003        }
4004    }
4005
4006    /// `block_bytes_for_kind` and `block_bytes_per_row` are two
4007    /// functions that must agree about one format's geometry, and the
4008    /// matvec seam DIVIDES one by the other.
4009    ///
4010    /// `Cuda::launch_matvec` derives `n_blocks_per_row` as
4011    /// `block_bytes_per_row(kind, cols) / block_bytes_for_kind(kind)`
4012    /// and hands it to a kernel that strides the row by a byte count
4013    /// written as a literal in CUDA C. If the two disagreed by so much
4014    /// as one byte the division would silently truncate, the kernel
4015    /// would read fewer blocks than the row holds, and every output
4016    /// would be a partial dot product -- plausible numbers, no error,
4017    /// no panic, and nothing in the suite red.
4018    ///
4019    /// Both are also held to `ferrox-cuda`'s own `MulMmKind` row, which
4020    /// is where that CUDA C literal comes from, so all three agree or
4021    /// this fails.
4022    ///
4023    /// Nothing checked any of it. That was survivable while the two
4024    /// tables were edited together by one person on one day; five kinds
4025    /// joined on 2026-09-09 and each needed a row in both.
4026    ///
4027    /// Sabotage: give any kind the wrong constant in either function
4028    /// and this names it.
4029    ///
4030    /// Gated like its neighbour: `block_bytes_for_kind` itself only
4031    /// exists when a backend that calls it is compiled in.
4032    #[cfg(any(feature = "cuda", feature = "vulkan"))]
4033    #[test]
4034    fn the_two_block_size_functions_agree_for_every_gpu_kind() {
4035        use super::gpu_backend::{BackendCaps, Cuda, Vulkan};
4036        for &kind in QuantKind::ALL {
4037            if Cuda::matvec_kernel(kind).is_none() && Vulkan::matvec_kernel(kind).is_none() {
4038                continue;
4039            }
4040            let block_bytes = WeightMatrix::block_bytes_for_kind(kind);
4041            let mm = ferrox_cuda::mul_mm::kind_by_name(kind.name())
4042                .unwrap_or_else(|| panic!("{kind:?}: claims a GPU matvec with no mul_mm row"));
4043            assert_eq!(
4044                block_bytes, mm.block_bytes,
4045                "{kind:?}: ferrox-core's block size is not the one the kernel strides by"
4046            );
4047
4048            // `block_bytes_per_row` takes `&self` but reads only its
4049            // arguments, so any matrix of the right kind will do.
4050            let probe = WeightMatrix::Quantized {
4051                data: WeightBytes::Owned(Vec::new()),
4052                rows: 1,
4053                cols: mm.block_elems,
4054                kind,
4055            };
4056            // Three, four and five whole blocks: a per-row function
4057            // that had dropped the multiply would still pass at one.
4058            for blocks in 3..=5usize {
4059                let cols = mm.block_elems * blocks;
4060                let row_bytes = probe.block_bytes_per_row(kind, cols);
4061                assert_eq!(
4062                    row_bytes,
4063                    blocks * block_bytes,
4064                    "{kind:?}: block_bytes_per_row({cols}) is not {blocks} x {block_bytes}"
4065                );
4066                assert_eq!(
4067                    row_bytes / block_bytes,
4068                    blocks,
4069                    "{kind:?}: the n_blocks_per_row the matvec seam derives is wrong"
4070                );
4071            }
4072        }
4073    }
4074
4075    /// F32 and F16 are not quantized, so `None` is the right answer and
4076    /// not a gap: the loader builds a plain `WeightMatrix::F32` for
4077    /// them rather than reporting an unsupported dtype.
4078    #[test]
4079    fn an_unquantized_dtype_maps_to_nothing() {
4080        assert_eq!(quant_kind_for(GgmlType::F32), None);
4081        assert_eq!(quant_kind_for(GgmlType::F16), None);
4082    }
4083    use super::*;
4084
4085    /// Forces [`cpu_int_dot_enabled`] for the lifetime of the guard, so a
4086    /// test can drive the quantized-activation batch kernels (the
4087    /// interleaved `block_q*_Kx8` / `block_q*_0x4` repack tier and the
4088    /// NEON i8mm GEMMs behind it) that every shipped binary turns on via
4089    /// `default_cpu_int_dot_on` but `cargo test` otherwise leaves off.
4090    ///
4091    /// The override is process-global, so the guard serializes on a
4092    /// mutex: two tests forcing opposite values concurrently would
4093    /// otherwise see each other's setting.
4094    pub(super) struct ForceIntDot {
4095        _lock: std::sync::MutexGuard<'static, ()>,
4096    }
4097
4098    impl ForceIntDot {
4099        pub(super) fn new(on: bool) -> Self {
4100            static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4101            let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
4102            INT_DOT_TEST_OVERRIDE.store(i8::from(on), std::sync::atomic::Ordering::Release);
4103            Self { _lock: lock }
4104        }
4105    }
4106
4107    impl Drop for ForceIntDot {
4108        fn drop(&mut self) {
4109            INT_DOT_TEST_OVERRIDE.store(-1, std::sync::atomic::Ordering::Release);
4110        }
4111    }
4112
4113    /// The guard has to actually move the getter, in both directions --
4114    /// otherwise every test built on it silently exercises one path
4115    /// twice, which is exactly the hole it exists to close.
4116    #[test]
4117    fn force_int_dot_moves_the_getter_and_restores_it() {
4118        {
4119            let _g = ForceIntDot::new(true);
4120            assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4121        }
4122        {
4123            let _g = ForceIntDot::new(false);
4124            assert!(!cpu_int_dot_enabled(), "forcing off must disable int dot");
4125        }
4126        assert_eq!(
4127            INT_DOT_TEST_OVERRIDE.load(std::sync::atomic::Ordering::Acquire),
4128            -1,
4129            "the guard must clear the override on drop"
4130        );
4131    }
4132
4133    /// `dequant_row` must reproduce exactly the values a full-buffer
4134    /// dequantization of the same row produces, for every storage
4135    /// variant -- and read only that row's bytes (each row here has
4136    /// distinct values, so an off-by-one-row slice fails loudly).
4137    #[test]
4138    fn dequant_row_matches_full_dequant_per_row() {
4139        // F32 variant.
4140        let rows = 3;
4141        let cols = 64;
4142        let f32_data: Vec<f32> = (0..rows * cols).map(|i| (i as f32) * 0.1 - 5.0).collect();
4143        let m = WeightMatrix::F32(Tensor::new(f32_data.clone(), vec![rows, cols]));
4144        for r in 0..rows {
4145            assert_eq!(m.dequant_row(r), &f32_data[r * cols..(r + 1) * cols]);
4146        }
4147
4148        // Quantized (Q8_0) variant: quantize each row independently and
4149        // compare dequant_row against dequantizing that row's bytes.
4150        let mut packed = Vec::new();
4151        for r in 0..rows {
4152            packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4153        }
4154        let row_bytes = packed.len() / rows;
4155        let q = WeightMatrix::Quantized {
4156            data: WeightBytes::Owned(packed.clone()),
4157            rows,
4158            cols,
4159            kind: QuantKind::Q8_0,
4160        };
4161        for r in 0..rows {
4162            let expected =
4163                ferrox_quant::dequant_q8_0(&packed[r * row_bytes..(r + 1) * row_bytes]).unwrap();
4164            assert_eq!(q.dequant_row(r), expected, "Q8_0 row {r}");
4165        }
4166
4167        // Mxfp4 (two-buffer) variant: arbitrary valid bytes, compare
4168        // against the row-level reference dequantizer directly.
4169        let cols = 64;
4170        let packed: Vec<u8> = pseudo_bytes(7, rows * cols / 2);
4171        let scales: Vec<u8> = pseudo_bytes(11, rows * cols / 32);
4172        let m = WeightMatrix::Mxfp4 {
4173            packed: WeightBytes::Owned(packed.clone()),
4174            scale: WeightBytes::Owned(scales.clone()),
4175            rows,
4176            cols,
4177        };
4178        for r in 0..rows {
4179            let expected = ferrox_quant::dequant_mxfp4_row(
4180                &packed[r * cols / 2..(r + 1) * cols / 2],
4181                &scales[r * cols / 32..(r + 1) * cols / 32],
4182            )
4183            .unwrap();
4184            assert_eq!(m.dequant_row(r), expected, "Mxfp4 row {r}");
4185        }
4186    }
4187
4188    /// A quantized matrix used as an embedding table: `dequant_row`
4189    /// then a dot product must agree with `apply` against a one-hot...
4190    /// no -- more directly, with the fused `dot` of that row, proving
4191    /// row lookup and matmul read identical bytes.
4192    #[test]
4193    fn dequant_row_agrees_with_fused_dot_on_the_same_row() {
4194        let rows = 4;
4195        let cols = 64;
4196        let f32_data: Vec<f32> = (0..rows * cols)
4197            .map(|i| ((i as f32) * 0.13).sin())
4198            .collect();
4199        let mut packed = Vec::new();
4200        for r in 0..rows {
4201            packed.extend(make_q8_0_row(&f32_data[r * cols..(r + 1) * cols]));
4202        }
4203        let q = WeightMatrix::Quantized {
4204            data: WeightBytes::Owned(packed),
4205            rows,
4206            cols,
4207            kind: QuantKind::Q8_0,
4208        };
4209        let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.031).cos()).collect();
4210        let applied = q.apply(&x);
4211        // With `FERROX_CPU_INT_DOT` on, `apply` quantizes the ACTIVATION to
4212        // int8 as well, so the two sides no longer differ only by float
4213        // summation order and a fixed 1e-4 is not the right bar -- it fired
4214        // at 6.5e-3 on a result of 5.25, which is the activation error, not
4215        // a byte disagreement. The worst case is derivable rather than
4216        // guessed: `quantize_activations_q8` rounds to `d = amax/127`, so
4217        // each element moves by at most `d/2`, and the dot's error is
4218        // bounded by that times the row's L1 norm.
4219        let bound = |row: &[f32]| {
4220            if !cpu_int_dot_for(IntDotShape::Matvec) {
4221                return 1e-4;
4222            }
4223            let amax = x.iter().fold(0f32, |m, v| m.max(v.abs()));
4224            let l1: f32 = row.iter().map(|w| w.abs()).sum();
4225            (amax / 127.0 / 2.0) * l1
4226        };
4227        for (r, &got) in applied.iter().enumerate() {
4228            let row = q.dequant_row(r);
4229            let via_row: f32 = row.iter().zip(&x).map(|(a, b)| a * b).sum();
4230            let bound = bound(&row);
4231            assert!(
4232                (got - via_row).abs() < bound,
4233                "row {r}: apply={got} via dequant_row={via_row} (bound {bound:e})"
4234            );
4235        }
4236    }
4237
4238    fn make_q8_0_row(values: &[f32]) -> Vec<u8> {
4239        ferrox_quant::quantize_q8_0(values)
4240    }
4241
4242    /// Deterministic byte generator for MXFP4 test fixtures (no
4243    /// quantizer exists in `ferrox_quant` -- MXFP4 is only ever a
4244    /// real, already-quantized checkpoint format, never produced by
4245    /// ferrox -- so tests build arbitrary-but-valid-shaped bytes
4246    /// directly, same convention as `ferrox-models::kimi_loader`'s
4247    /// tests).
4248    fn pseudo_bytes(seed: u32, len: usize) -> Vec<u8> {
4249        let mut state = seed.wrapping_mul(2654435761).wrapping_add(1);
4250        (0..len)
4251            .map(|_| {
4252                state = state.wrapping_mul(1103515245).wrapping_add(12345);
4253                (state >> 16) as u8
4254            })
4255            .collect()
4256    }
4257
4258    /// Clamped to a realistic E8M0 scale range -- see
4259    /// `ferrox-models::kimi_loader`'s identical helper for why (byte
4260    /// 255 is OCP-spec-reserved for NaN, and bytes above ~252 can
4261    /// legitimately overflow f32::MAX when combined with E2M1's max
4262    /// magnitude; neither is representative of a real trained weight).
4263    fn pseudo_mxfp4_scale_bytes(seed: u32, len: usize) -> Vec<u8> {
4264        pseudo_bytes(seed, len)
4265            .into_iter()
4266            .map(|b| b % 180)
4267            .collect()
4268    }
4269
4270    #[test]
4271    fn f32_and_mxfp4_paths_agree() {
4272        let rows = 2;
4273        let cols = 64; // 2 MXFP4 groups of 32 per row
4274        let packed = pseudo_bytes(1, rows * (cols / 2));
4275        let scale = pseudo_mxfp4_scale_bytes(2, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4276        let x: Vec<f32> = (0..cols).map(|i| (i as f32) * 0.01 - 0.3).collect();
4277
4278        // Independent reference: dequantize each row to plain f32 (the
4279        // already-tested `dequant_mxfp4_row`), then use the ordinary
4280        // F32 matmul path.
4281        let mut f32_weights = Vec::with_capacity(rows * cols);
4282        for r in 0..rows {
4283            let prow = &packed[r * (cols / 2)..(r + 1) * (cols / 2)];
4284            let srow = &scale[r * (cols / ferrox_quant::MXFP4_GROUP_SIZE)
4285                ..(r + 1) * (cols / ferrox_quant::MXFP4_GROUP_SIZE)];
4286            f32_weights.extend(ferrox_quant::dequant_mxfp4_row(prow, srow).unwrap());
4287        }
4288        let f32_matrix = WeightMatrix::F32(Tensor::new(f32_weights, vec![rows, cols]));
4289        let f32_out = f32_matrix.apply(&x);
4290
4291        let mxfp4_matrix = WeightMatrix::Mxfp4 {
4292            packed: WeightBytes::Owned(packed),
4293            scale: WeightBytes::Owned(scale),
4294            rows,
4295            cols,
4296        };
4297        let mxfp4_out = mxfp4_matrix.apply(&x);
4298
4299        assert_eq!(f32_out.len(), rows);
4300        assert_eq!(mxfp4_out.len(), rows);
4301        for (f, m) in f32_out.iter().zip(mxfp4_out.iter()) {
4302            assert!((f - m).abs() < 1e-3, "f32={f} mxfp4={m}");
4303        }
4304    }
4305
4306    #[test]
4307    fn mxfp4_apply_batch_matches_sequential_apply_calls() {
4308        let rows = 3;
4309        let cols = 64;
4310        let packed = pseudo_bytes(3, rows * (cols / 2));
4311        let scale = pseudo_mxfp4_scale_bytes(4, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4312        let matrix = WeightMatrix::Mxfp4 {
4313            packed: WeightBytes::Owned(packed),
4314            scale: WeightBytes::Owned(scale),
4315            rows,
4316            cols,
4317        };
4318
4319        let batch_size = 4;
4320        let x_batch: Vec<f32> = (0..batch_size * cols)
4321            .map(|i| ((i % 13) as f32) * 0.02 - 0.15)
4322            .collect();
4323
4324        let batched = matrix.apply_batch(&x_batch, batch_size);
4325        assert_eq!(batched.len(), batch_size * rows);
4326
4327        for b in 0..batch_size {
4328            let x = &x_batch[b * cols..(b + 1) * cols];
4329            let sequential = matrix.apply(x);
4330            let from_batch = &batched[b * rows..(b + 1) * rows];
4331            assert_eq!(
4332                sequential, from_batch,
4333                "batch row {b} disagrees with sequential apply()"
4334            );
4335        }
4336    }
4337
4338    #[test]
4339    fn mxfp4_resident_bytes_matches_the_packed_plus_scale_byte_count_not_eager_f32() {
4340        let rows = 2;
4341        let cols = 64;
4342        let packed = pseudo_bytes(5, rows * (cols / 2));
4343        let scale = pseudo_mxfp4_scale_bytes(6, rows * (cols / ferrox_quant::MXFP4_GROUP_SIZE));
4344        let packed_len = packed.len();
4345        let scale_len = scale.len();
4346        let matrix = WeightMatrix::Mxfp4 {
4347            packed: WeightBytes::Owned(packed),
4348            scale: WeightBytes::Owned(scale),
4349            rows,
4350            cols,
4351        };
4352
4353        assert_eq!(matrix.resident_bytes(), packed_len + scale_len);
4354        // Real MXFP4 packs 2 values/byte plus 1 scale byte per 32
4355        // values -- resident_bytes should be far below the 4-bytes-
4356        // per-value eager-f32 footprint.
4357        let eager_f32_bytes = rows * cols * 4;
4358        assert!(
4359            matrix.resident_bytes() * 4 < eager_f32_bytes,
4360            "expected MXFP4 resident bytes well under 1/4 of eager f32: got {} vs {}",
4361            matrix.resident_bytes(),
4362            eager_f32_bytes
4363        );
4364    }
4365
4366    #[test]
4367    fn f32_and_quantized_paths_agree_within_quant_error() {
4368        // 1 row, 32 cols, values chosen to keep Q8_0 error small.
4369        let weights: Vec<f32> = (0..32).map(|i| ((i as f32) - 16.0) * 0.2).collect();
4370        let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.05 - 0.8).collect();
4371
4372        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4373        let f32_out = f32_matrix.apply(&x);
4374
4375        let packed = make_q8_0_row(&weights);
4376        let quant_matrix = WeightMatrix::Quantized {
4377            data: WeightBytes::Owned(packed),
4378            rows: 1,
4379            cols: 32,
4380            kind: QuantKind::Q8_0,
4381        };
4382        let quant_out = quant_matrix.apply(&x);
4383
4384        assert_eq!(f32_out.len(), 1);
4385        assert_eq!(quant_out.len(), 1);
4386        assert!(
4387            (f32_out[0] - quant_out[0]).abs() < 0.05,
4388            "f32={} quant={}",
4389            f32_out[0],
4390            quant_out[0]
4391        );
4392    }
4393
4394    #[test]
4395    fn quantized_resident_bytes_is_smaller_than_f32() {
4396        let weights = vec![0.1f32; 64]; // 2 rows x 32 cols
4397        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![2, 32]));
4398
4399        let mut packed = Vec::new();
4400        for chunk in weights.chunks(32) {
4401            packed.extend(ferrox_quant::quantize_q8_0(chunk));
4402        }
4403        let quant_matrix = WeightMatrix::Quantized {
4404            data: WeightBytes::Owned(packed),
4405            rows: 2,
4406            cols: 32,
4407            kind: QuantKind::Q8_0,
4408        };
4409
4410        assert_eq!(f32_matrix.resident_bytes(), 64 * 4); // 256 bytes
4411        assert_eq!(quant_matrix.resident_bytes(), 2 * 34); // 68 bytes
4412        assert!(quant_matrix.resident_bytes() < f32_matrix.resident_bytes());
4413        // Q8_0 should be close to the theoretical ~4x reduction vs f32.
4414        let ratio = f32_matrix.resident_bytes() as f32 / quant_matrix.resident_bytes() as f32;
4415        assert!(ratio > 3.5, "expected ~4x reduction, got {ratio}x");
4416    }
4417
4418    #[test]
4419    fn rows_and_cols_report_correctly_for_both_variants() {
4420        let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4421        assert_eq!(f32_matrix.rows(), 2);
4422        assert_eq!(f32_matrix.cols(), 3);
4423
4424        let quant_matrix = WeightMatrix::Quantized {
4425            data: WeightBytes::Owned(vec![0u8; 34]),
4426            rows: 1,
4427            cols: 32,
4428            kind: QuantKind::Q8_0,
4429        };
4430        assert_eq!(quant_matrix.rows(), 1);
4431        assert_eq!(quant_matrix.cols(), 32);
4432    }
4433
4434    #[test]
4435    #[should_panic]
4436    fn apply_panics_on_activation_length_mismatch() {
4437        let f32_matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
4438        f32_matrix.apply(&[1.0, 2.0]); // wrong length (needs 3)
4439    }
4440
4441    #[test]
4442    fn apply_batch_with_batch_size_one_matches_apply() {
4443        // Pinned, not inherited. This asserts `apply` and `apply_batch`
4444        // are BIT-identical, which is only true while both take the same
4445        // kernel -- and since #152 they do not on x86, where the batch
4446        // half of the int-dot tier is taken and the matvec half is not.
4447        // The override is process-global, so without the guard a
4448        // concurrent test holding it on decides this one's result.
4449        let _int_dot = ForceIntDot::new(false);
4450        let weights: Vec<f32> = (0..32).map(|i| (i as f32 - 16.0) * 0.13).collect();
4451        let x: Vec<f32> = (0..32).map(|i| (i as f32) * 0.02 - 0.3).collect();
4452
4453        let f32_matrix = WeightMatrix::F32(Tensor::new(weights.clone(), vec![1, 32]));
4454        let single = f32_matrix.apply(&x);
4455        let batched = f32_matrix.apply_batch(&x, 1);
4456        assert_eq!(single, batched);
4457
4458        let packed = ferrox_quant::quantize_q8_0(&weights);
4459        let quant_matrix = WeightMatrix::Quantized {
4460            data: WeightBytes::Owned(packed),
4461            rows: 1,
4462            cols: 32,
4463            kind: QuantKind::Q8_0,
4464        };
4465        let single_q = quant_matrix.apply(&x);
4466        let batched_q = quant_matrix.apply_batch(&x, 1);
4467        assert_eq!(single_q, batched_q);
4468    }
4469
4470    #[test]
4471    fn apply_batch_matches_sequential_apply_calls_for_each_row_f32() {
4472        let rows = 3;
4473        let cols = 32;
4474        let weights: Vec<f32> = (0..rows * cols)
4475            .map(|i| ((i % 17) as f32 - 8.0) * 0.05)
4476            .collect();
4477        let matrix = WeightMatrix::F32(Tensor::new(weights, vec![rows, cols]));
4478
4479        let batch_size = 4;
4480        let x_batch: Vec<f32> = (0..batch_size * cols)
4481            .map(|i| ((i % 13) as f32) * 0.03 - 0.2)
4482            .collect();
4483
4484        let batched = matrix.apply_batch(&x_batch, batch_size);
4485        assert_eq!(batched.len(), batch_size * rows);
4486
4487        for b in 0..batch_size {
4488            let x = &x_batch[b * cols..(b + 1) * cols];
4489            let sequential = matrix.apply(x);
4490            let from_batch = &batched[b * rows..(b + 1) * rows];
4491            assert_eq!(
4492                sequential, from_batch,
4493                "batch row {b} disagrees with sequential apply()"
4494            );
4495        }
4496    }
4497
4498    #[test]
4499    fn apply_batch_matches_sequential_apply_calls_for_each_row_quantized() {
4500        let rows = 3;
4501        let cols = 32;
4502        let weights: Vec<f32> = (0..rows * cols)
4503            .map(|i| ((i % 19) as f32 - 9.0) * 0.07)
4504            .collect();
4505        let mut packed = Vec::new();
4506        for row in weights.chunks(cols) {
4507            packed.extend(ferrox_quant::quantize_q8_0(row));
4508        }
4509        let matrix = WeightMatrix::Quantized {
4510            data: WeightBytes::Owned(packed),
4511            rows,
4512            cols,
4513            kind: QuantKind::Q8_0,
4514        };
4515
4516        let batch_size = 5;
4517        let x_batch: Vec<f32> = (0..batch_size * cols)
4518            .map(|i| ((i % 11) as f32) * 0.04 - 0.25)
4519            .collect();
4520
4521        let batched = matrix.apply_batch(&x_batch, batch_size);
4522        assert_eq!(batched.len(), batch_size * rows);
4523
4524        for b in 0..batch_size {
4525            let x = &x_batch[b * cols..(b + 1) * cols];
4526            let sequential = matrix.apply(x);
4527            let from_batch = &batched[b * rows..(b + 1) * rows];
4528            assert_batch_row_matches(QuantKind::Q8_0, "", b, &sequential, from_batch);
4529        }
4530    }
4531
4532    /// Minimal f16 encode for small positive normals (test fixtures only).
4533    pub(super) fn f16_le(x: f32) -> [u8; 2] {
4534        let bits = x.to_bits();
4535        let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
4536        let mant = (bits >> 13) & 0x3ff;
4537        (((exp as u16) << 10) | mant as u16).to_le_bytes()
4538    }
4539
4540    /// Deterministic pseudo-random quantized matrix: every byte pattern is
4541    /// a valid weight block, only the f16 scale fields need sane values.
4542    /// Compare one row of `apply_batch` against `apply`, scaled by the
4543    /// magnitude of the row rather than of each element.
4544    ///
4545    /// The element-wise denominator (`err / s.abs().max(1.0)`) is wrong
4546    /// for a dot product over random data: the sums cancel, so a result
4547    /// that lands near zero turns a normal rounding difference into a
4548    /// relative error of 30%. Measured on Metal, the divergence is a
4549    /// uniform 5.5e-4 of the row's own scale across every quant kind
4550    /// and batch index, and up to 2.9e-1 of the individual result. The
4551    /// first number describes the arithmetic; the second describes
4552    /// which results happened to cancel.
4553    ///
4554    /// This matters because `apply_batch` is not `apply` on a GPU
4555    /// build: `apply_batch` dispatches to Metal while `apply` stays on
4556    /// the CPU, so this compares two backends. The bound stays tight on
4557    /// CPU, where both sides are the same code and must agree closely.
4558    fn assert_batch_row_matches(
4559        kind: QuantKind,
4560        ctx: &str,
4561        b: usize,
4562        sequential: &[f32],
4563        from_batch: &[f32],
4564    ) {
4565        let scale = sequential
4566            .iter()
4567            .fold(0.0f32, |a, v| a.max(v.abs()))
4568            .max(1.0);
4569        // A GPU build compares Metal against the CPU; a CPU build
4570        // compares the CPU against itself -- UNLESS this host takes only
4571        // one half of the int-dot tier, in which case `apply` and
4572        // `apply_batch` are not the same arithmetic at all.
4573        //
4574        // That is x86 since #152: the batch half runs the AVX2
4575        // interleaved GEMM over an int8-quantized activation while the
4576        // matvec half stays on the f32 AVX2 dot, because the int8 matvec
4577        // measured 4x to 8.8x slower there. The gap between the two
4578        // sides is then the ACTIVATION quantization floor -- each element
4579        // of `x` moves by up to `d/2` at `d = amax/127` -- not float
4580        // summation order, and a 1e-4 bar describes the wrong thing.
4581        //
4582        // Measured across every shape in these tests on a linux/amd64
4583        // container with real AVX2 (2026-09-09): worst 7.9e-3 of the row
4584        // scale. 6e-2 keeps a 7.6x margin, the same discipline as
4585        // `int_dot_batch_matches_dequant_dot_reference`, and is still far
4586        // inside a mis-pack, which decorrelates the two outputs entirely.
4587        let mixed = cpu_int_dot_for(IntDotShape::Matvec) != cpu_int_dot_for(IntDotShape::BatchGemm);
4588        let bound = if cfg!(any(feature = "metal", feature = "cuda")) {
4589            5e-3
4590        } else if mixed {
4591            6e-2
4592        } else {
4593            1e-4
4594        };
4595        for (r, (s, got)) in sequential.iter().zip(from_batch.iter()).enumerate() {
4596            let err = (s - got).abs() / scale;
4597            assert!(
4598                err < bound,
4599                "{kind:?} {ctx} batch {b} row {r}: apply()={s} apply_batch={got} \
4600                 (err {err:e} of row scale {scale}, bound {bound:e})"
4601            );
4602        }
4603    }
4604
4605    fn synth_quant_matrix(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
4606        let mut state = 0x1234_5678u32;
4607        let mut next = move || {
4608            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
4609            (state >> 24) as u8
4610        };
4611        let mut data = Vec::new();
4612        match kind {
4613            QuantKind::Q8_0 | QuantKind::Q4_0 => {
4614                let qs = if kind == QuantKind::Q8_0 { 32 } else { 16 };
4615                for _ in 0..rows * (cols / 32) {
4616                    data.extend_from_slice(&f16_le(0.02 + f32::from(next()) * 0.0004));
4617                    for _ in 0..qs {
4618                        data.push(next());
4619                    }
4620                }
4621            }
4622            QuantKind::Q4K | QuantKind::Q5K => {
4623                let body = if kind == QuantKind::Q4K {
4624                    12 + 128
4625                } else {
4626                    12 + 32 + 128
4627                };
4628                for _ in 0..rows * (cols / 256) {
4629                    data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4630                    data.extend_from_slice(&f16_le(0.005 + f32::from(next()) * 0.0001));
4631                    for _ in 0..body {
4632                        data.push(next());
4633                    }
4634                }
4635            }
4636            QuantKind::Q6K => {
4637                for _ in 0..rows * (cols / 256) {
4638                    for _ in 0..128 + 64 + 16 {
4639                        data.push(next());
4640                    }
4641                    data.extend_from_slice(&f16_le(0.01 + f32::from(next()) * 0.0002));
4642                }
4643            }
4644            _ => unreachable!("synth_quant_matrix: unsupported kind"),
4645        }
4646        WeightMatrix::Quantized {
4647            data: WeightBytes::Owned(data),
4648            rows,
4649            cols,
4650            kind,
4651        }
4652    }
4653    /// One `apply_batch` vs per-row `apply` sweep, parameterized by shape
4654    /// so the shape tests below differ only in the numbers they pass.
4655    fn assert_apply_batch_matches_apply(
4656        kind: QuantKind,
4657        rows: usize,
4658        cols: usize,
4659        batch_size: usize,
4660        seed: usize,
4661    ) {
4662        let x_batch: Vec<f32> = (0..batch_size * cols)
4663            .map(|i| (((i * 31 + seed) % 97) as f32) * 0.021 - 1.0)
4664            .collect();
4665        let matrix = synth_quant_matrix(kind, rows, cols);
4666        let batched = matrix.apply_batch(&x_batch, batch_size);
4667        assert_eq!(batched.len(), batch_size * rows);
4668        let ctx = format!(
4669            "rows {rows} cols {cols} batch_size {batch_size} int_dot {}",
4670            cpu_int_dot_for(IntDotShape::BatchGemm)
4671        );
4672        for b in 0..batch_size {
4673            let x = &x_batch[b * cols..(b + 1) * cols];
4674            let sequential = matrix.apply(x);
4675            let from_batch = &batched[b * rows..(b + 1) * rows];
4676            // Delegates rather than restating the bound. The first
4677            // version of this helper compared each element against
4678            // `s.abs().max(1.0)`, which is a bare 1e-4 ABSOLUTE bound
4679            // for any row whose value is small -- and a dot product of
4680            // 512 terms that cancels to -0.76 carries the rounding of
4681            // the terms, not of the result. It passed on aarch64 and
4682            // failed on x86_64 CI at 1.07e-4, on one row out of 17094.
4683            // `assert_batch_row_matches` already divides by the row
4684            // vector's own scale, which is the invariant that makes the
4685            // comparison meaningful, and it is now the only place the
4686            // tolerance is written down.
4687            assert_batch_row_matches(kind, &ctx, b, &sequential, from_batch);
4688        }
4689    }
4690
4691    const BATCH_SHAPE_KINDS: [QuantKind; 5] = [
4692        QuantKind::Q8_0,
4693        QuantKind::Q4_0,
4694        QuantKind::Q4K,
4695        QuantKind::Q5K,
4696        QuantKind::Q6K,
4697    ];
4698
4699    /// `apply_batch` writes straight into the `[batch][rows]` output from
4700    /// parallel tasks (no staging transpose); the shapes here force every
4701    /// write pattern: full row-groups, a tail of leftover rows, and both
4702    /// full and partial activation tiles.
4703    ///
4704    /// Run under both settings of [`cpu_int_dot_enabled`]. With int-dot
4705    /// off, `apply_batch` dequantizes and the repack tier is skipped
4706    /// entirely; with it on -- which is what every shipped binary does,
4707    /// via `default_cpu_int_dot_on` -- the interleaved `block_q*_Kx8` /
4708    /// `block_q*_0x4` kernels and, on an i8mm host, the SMMLA GEMMs are
4709    /// the code under test. `cargo test` leaves the env var unset, so
4710    /// without [`ForceIntDot`] only the first of those two ever ran.
4711    #[test]
4712    fn apply_batch_matches_apply_across_kinds_with_groups_and_tail() {
4713        for int_dot in [false, true] {
4714            let _g = ForceIntDot::new(int_dot);
4715            for kind in BATCH_SHAPE_KINDS {
4716                // 19 rows = 2x8-row groups + 3 tail (4x4-row groups + 3
4717                // for Q8_0/Q4_0); 6 activations = one full 4-tile + a
4718                // partial one.
4719                assert_apply_batch_matches_apply(kind, 19, 512, 6, 7);
4720            }
4721        }
4722    }
4723
4724    /// Shapes too small to fill one interleaved row-group, which the
4725    /// tests around this one never reach: they use `rows` big enough that
4726    /// `n_groups > 0` for every kind. At `rows < 8` the K-quant arms take
4727    /// their `else` branch (per-row `gemm_q*_k_q8_row`) with the repack
4728    /// path completely bypassed, and at `rows < 4` the Q8_0/Q4_0 arms do
4729    /// the same with `dot_q*_q8`. `rows = 5` is the mixed case: one full
4730    /// `block_q*_0x4` group plus a 1-row tail for Q8_0/Q4_0, zero groups
4731    /// for the Kx8 kinds. `rows = 1` is the single-row case.
4732    ///
4733    /// `cols = 256` is also the minimum K for a K-quant -- a single
4734    /// super-block, so every kernel's block loop runs exactly one trip.
4735    /// `batch_size = 1` is the single-column case: one activation in the
4736    /// quad, `na = 1` with three zero-padded lanes in `Q8KActsX4` /
4737    /// `Q8ActsX4`.
4738    #[test]
4739    fn apply_batch_matches_apply_for_sub_tile_shapes() {
4740        for int_dot in [false, true] {
4741            let _g = ForceIntDot::new(int_dot);
4742            for kind in BATCH_SHAPE_KINDS {
4743                for rows in [1, 2, 3, 5, 7] {
4744                    for batch_size in [1, 2, 5] {
4745                        assert_apply_batch_matches_apply(kind, rows, 256, batch_size, 13);
4746                    }
4747                }
4748            }
4749        }
4750    }
4751
4752    /// `apply_batch` under int-dot against an f32 dequantize-and-dot
4753    /// reference that never touches the packed buffer.
4754    ///
4755    /// Every other batch test compares `apply_batch` against `apply`,
4756    /// which under int-dot is the packed **GEMV** against the packed
4757    /// **GEMM** -- two kernels reading the *same* interleaved bytes. That
4758    /// catches a bad kernel but is structurally blind to a bad
4759    /// `pack_q*_matrix_x*`: both sides read the same wrong bytes and
4760    /// agree. `dequant_row` is the only reference in the tree that
4761    /// re-derives the weights from the canonical GGUF blocks, so it is
4762    /// the only one that can see a mis-interleave.
4763    ///
4764    /// The bound is the Q8/Q8_K *activation* quantization floor, not the
4765    /// kernel's, and it is scaled by the RMS of the reference outputs
4766    /// rather than per element: these synthetic weights are uniform
4767    /// random bytes, so individual dots cancel to near zero and a
4768    /// per-element relative bound would be meaningless. Worst deviation
4769    /// measured across every shape below, on an M2 Pro (i8mm), is 0.016 x
4770    /// RMS; 0.12 keeps a 7x margin. Coarse on purpose -- a mis-pack
4771    /// decorrelates the output from the reference entirely (measured at
4772    /// 2.07 x RMS for a one-row shift in the Q5_K `qh` interleave), an
4773    /// order of magnitude past this bound.
4774    #[test]
4775    fn int_dot_batch_matches_dequant_dot_reference() {
4776        let _g = ForceIntDot::new(true);
4777        // The batch half needs a SIMD `x4` GEMM, so a host without
4778        // one (an x86 box with no AVX2, Rosetta included) has no
4779        // packed path to test. Skipping is honest; asserting would
4780        // make the suite red for a host that is behaving correctly.
4781        assert!(cpu_int_dot_enabled(), "forcing on must enable int dot");
4782        if !cpu_int_dot_for(IntDotShape::BatchGemm) {
4783            return;
4784        }
4785        for kind in BATCH_SHAPE_KINDS {
4786            // Rows straddle both tile widths: below the tile, one short
4787            // of it, exactly it, one past it, and multi-group with a
4788            // tail. Batch straddles the 4-wide activation quad. cols 256
4789            // is the minimum K for a K-quant (one super-block).
4790            for rows in [1, 3, 5, 7, 8, 9, 19] {
4791                for cols in [256, 512] {
4792                    for batch_size in [1, 3, 4, 9] {
4793                        assert_int_dot_matches_dequant_dot(kind, rows, cols, batch_size, 23);
4794                    }
4795                }
4796            }
4797        }
4798        // Q8_0/Q4_0 alone can go down to a single 32-element block.
4799        for kind in [QuantKind::Q8_0, QuantKind::Q4_0] {
4800            for rows in [1, 3, 4, 5, 11] {
4801                for batch_size in [1, 3, 4, 9] {
4802                    assert_int_dot_matches_dequant_dot(kind, rows, 32, batch_size, 29);
4803                }
4804            }
4805        }
4806    }
4807
4808    fn assert_int_dot_matches_dequant_dot(
4809        kind: QuantKind,
4810        rows: usize,
4811        cols: usize,
4812        batch_size: usize,
4813        seed: usize,
4814    ) {
4815        let x_batch: Vec<f32> = (0..batch_size * cols)
4816            .map(|i| (((i * 37 + seed) % 89) as f32) * 0.019 - 0.8)
4817            .collect();
4818        let matrix = synth_quant_matrix(kind, rows, cols);
4819        let got = matrix.apply_batch(&x_batch, batch_size);
4820        assert_eq!(got.len(), batch_size * rows);
4821
4822        let mut want = vec![0f32; batch_size * rows];
4823        for r in 0..rows {
4824            let w = matrix.dequant_row(r);
4825            assert_eq!(w.len(), cols);
4826            for b in 0..batch_size {
4827                let x = &x_batch[b * cols..(b + 1) * cols];
4828                want[b * rows + r] = w.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4829            }
4830        }
4831        let rms = (want.iter().map(|v| v * v).sum::<f32>() / want.len() as f32).sqrt();
4832        for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
4833            let err = (g - w).abs();
4834            assert!(
4835                err < 0.12 * rms.max(1e-3),
4836                "{kind:?} rows {rows} cols {cols} batch_size {batch_size} [flat {i}]: \
4837                 int-dot={g} dequant-dot={w} (err {err}, rms {rms})"
4838            );
4839        }
4840    }
4841
4842    /// Large enough that `par_chunked_groups` builds a real 2D chunk grid
4843    /// (32 row-groups × 17 activation tiles) instead of falling back to
4844    /// one-chunk-per-thread — every (group, tile-range) seam in the
4845    /// chunked scatter is crossed. The smaller cross-kind test above
4846    /// covers the fallback path. Both int-dot settings, for the same
4847    /// reason as that test.
4848    #[test]
4849    fn apply_batch_chunked_grid_matches_apply() {
4850        for int_dot in [false, true] {
4851            let _g = ForceIntDot::new(int_dot);
4852            for kind in BATCH_SHAPE_KINDS {
4853                // 259 rows = 32 groups of 8 + 3 tail (64 of 4 + 3 for
4854                // Q8_0/Q4_0); 66 activations = 16 full 4-tiles + a
4855                // partial one.
4856                assert_apply_batch_matches_apply(kind, 259, 512, 66, 5);
4857            }
4858        }
4859    }
4860
4861    /// Sharing one quantized activation batch across projections must be
4862    /// invisible in the results: a matching `BatchActs` produces exactly
4863    /// what `apply_batch` produces (same quantization, same interleaved
4864    /// quads, same kernels), and a mismatched variant is ignored rather
4865    /// than misused.
4866    #[test]
4867    fn apply_batch_with_shared_acts_matches_apply_batch() {
4868        // Shared quads are built under one setting and consumed under
4869        // another if a concurrent test flips the global mid-run; pin it
4870        // on, which is also the setting that gives this test something
4871        // to compare.
4872        let _int_dot = ForceIntDot::new(true);
4873        let rows = 19;
4874        let cols = 512;
4875        let batch_size = 6;
4876        let x_batch: Vec<f32> = (0..batch_size * cols)
4877            .map(|i| (((i * 29 + 11) % 89) as f32) * 0.023 - 1.0)
4878            .collect();
4879        for kind in [
4880            QuantKind::Q8_0,
4881            QuantKind::Q4_0,
4882            QuantKind::Q4K,
4883            QuantKind::Q6K,
4884        ] {
4885            let matrix = synth_quant_matrix(kind, rows, cols);
4886            let baseline = matrix.apply_batch(&x_batch, batch_size);
4887
4888            let shared = matrix.quantize_batch_acts(&x_batch, batch_size);
4889            let with_shared = matrix.apply_batch_with_acts(&x_batch, batch_size, shared.as_ref());
4890            assert_eq!(
4891                baseline, with_shared,
4892                "{kind:?}: shared acts changed the result"
4893            );
4894
4895            let wrong = match kind {
4896                QuantKind::Q8_0 | QuantKind::Q4_0 => BatchActs::Q8K {
4897                    acts: Vec::new(),
4898                    tiles: Vec::new(),
4899                    cols,
4900                },
4901                _ => BatchActs::Q8 {
4902                    acts: Vec::new(),
4903                    tiles: Vec::new(),
4904                    cols,
4905                },
4906            };
4907            let with_wrong = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&wrong));
4908            assert_eq!(
4909                baseline, with_wrong,
4910                "{kind:?}: mismatched shared acts were not ignored"
4911            );
4912        }
4913    }
4914
4915    /// The interleaved quads now ride along with the activations, so the
4916    /// guard that decides whether a `shared` batch is usable has to cover
4917    /// them too -- and that guard is the one thing here that is not gated
4918    /// on `FERROX_CPU_INT_DOT`, so it is tested directly.
4919    ///
4920    /// A stale set is not a panic. The quads are indexed by super-block, so
4921    /// a batch prepared at another width either reads past its own end or
4922    /// silently dots the wrong columns; both surface as a wrong answer.
4923    /// What must happen instead is a local re-quantization with no quads,
4924    /// which is what the fresh-fallback assertions below pin.
4925    #[test]
4926    fn shared_acts_are_reused_only_at_the_matching_length_and_width() {
4927        let cols = 512;
4928        let batch_size = 7;
4929        let x_batch: Vec<f32> = (0..batch_size * cols)
4930            .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
4931            .collect();
4932
4933        let acts: Vec<_> = (0..batch_size)
4934            .map(|b| ferrox_quant::quantize_activations_q8_k(&x_batch[b * cols..(b + 1) * cols]))
4935            .collect();
4936        let tiles: Vec<_> = acts
4937            .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4938            .map(|c| ferrox_quant::prepare_q8_k_acts_x4(c, cols))
4939            .collect();
4940        let n_tiles = tiles.len();
4941        let shared = BatchActs::Q8K { acts, tiles, cols };
4942
4943        let mut owned = Vec::new();
4944        let (got, quads) =
4945            WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4946        assert_eq!(got.len(), batch_size);
4947        assert_eq!(
4948            quads.len(),
4949            n_tiles,
4950            "matching batch did not reuse its quads"
4951        );
4952        assert!(owned.is_empty(), "matching batch was re-quantized anyway");
4953
4954        // Same positions, another width: refuse and re-quantize.
4955        let mut owned = Vec::new();
4956        let (got, quads) =
4957            WeightMatrix::q8k_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4958        assert!(quads.is_empty(), "quads from another width were accepted");
4959        assert_eq!(got.len(), batch_size);
4960        assert_eq!(got[0].n_blocks(), 1, "fallback did not quantize at 256");
4961
4962        // Same width, another position count: refuse and re-quantize.
4963        let mut owned = Vec::new();
4964        let (got, quads) =
4965            WeightMatrix::q8k_acts(Some(&shared), &x_batch[..cols], 1, cols, &mut owned);
4966        assert!(quads.is_empty(), "quads for another batch were accepted");
4967        assert_eq!(got.len(), 1);
4968
4969        // The Q8_0 half of the same guard.
4970        let acts: Vec<_> = (0..batch_size)
4971            .map(|b| ferrox_quant::quantize_activations_q8(&x_batch[b * cols..(b + 1) * cols]))
4972            .collect();
4973        let tiles: Vec<_> = acts
4974            .chunks(ferrox_quant::Q8K_ACTS_X4_NC)
4975            .map(|c| ferrox_quant::prepare_q8_acts_x4(c, cols))
4976            .collect();
4977        let n_tiles = tiles.len();
4978        let shared = BatchActs::Q8 { acts, tiles, cols };
4979
4980        let mut owned = Vec::new();
4981        let (got, quads) =
4982            WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, cols, &mut owned);
4983        assert_eq!(got.len(), batch_size);
4984        assert_eq!(
4985            quads.len(),
4986            n_tiles,
4987            "matching batch did not reuse its quads"
4988        );
4989
4990        let mut owned = Vec::new();
4991        let (got, quads) =
4992            WeightMatrix::q8_acts(Some(&shared), &x_batch, batch_size, 256, &mut owned);
4993        assert!(quads.is_empty(), "quads from another width were accepted");
4994        assert_eq!(got[0].n_blocks(), 8, "fallback did not quantize at 256");
4995    }
4996
4997    /// Whatever a projection would have built for itself, a sibling's
4998    /// shared batch must hand it the same thing. Q4_K, Q5_K and Q6_K read
4999    /// one Q8_K quad set between them, and Q8_0 and Q4_0 one Q8_0 set, so
5000    /// the donor's kind must not show through.
5001    ///
5002    /// Gated the same way the path itself is: with `FERROX_CPU_INT_DOT`
5003    /// off (the library default) `quantize_batch_acts` returns `None` and
5004    /// no projection consumes quads at all, so this asserts against the
5005    /// INT_DOT build. Run the suite both ways.
5006    #[test]
5007    fn shared_quads_are_what_each_consumer_would_have_built_itself() {
5008        // The early return below reads a process-global, so it has to be
5009        // pinned or a neighbour can turn the tier off between the check
5010        // and the assertions it guards.
5011        let _int_dot = ForceIntDot::new(true);
5012        if !cpu_int_dot_for(IntDotShape::BatchGemm) {
5013            return;
5014        }
5015        let rows = 24;
5016        let cols = 512;
5017        let batch_size = 7;
5018        let x_batch: Vec<f32> = (0..batch_size * cols)
5019            .map(|i| (((i * 37 + 5) % 83) as f32) * 0.019 - 0.9)
5020            .collect();
5021
5022        for (donor, consumers) in [
5023            (QuantKind::Q4K, &[QuantKind::Q5K, QuantKind::Q6K][..]),
5024            (QuantKind::Q8_0, &[QuantKind::Q4_0][..]),
5025        ] {
5026            let shared = synth_quant_matrix(donor, rows, cols)
5027                .quantize_batch_acts(&x_batch, batch_size)
5028                .expect("INT_DOT is on and this kind/width is eligible");
5029            for kind in consumers {
5030                let matrix = synth_quant_matrix(*kind, rows, cols);
5031                let baseline = matrix.apply_batch(&x_batch, batch_size);
5032                let shared_out = matrix.apply_batch_with_acts(&x_batch, batch_size, Some(&shared));
5033                assert_eq!(
5034                    baseline, shared_out,
5035                    "{kind:?} consuming {donor:?} quads changed the result"
5036                );
5037            }
5038        }
5039    }
5040
5041    #[test]
5042    fn apply_batch_with_zero_batch_size_returns_empty() {
5043        let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5044        let out = matrix.apply_batch(&[], 0);
5045        assert!(out.is_empty());
5046    }
5047
5048    #[cfg(any(feature = "cuda", feature = "metal", feature = "vulkan"))]
5049    mod gpu_dispatch {
5050        use super::*;
5051
5052        /// `apply_gpu` must return `None` for `F32` -- and, crucially,
5053        /// without ever touching the CUDA driver at all (this runs on
5054        /// every CI machine, none of which have a GPU): the `let ...
5055        /// else { return None }` pattern match happens before any
5056        /// `ferrox_cuda` call, so this is a real, meaningful assertion
5057        /// about dispatch behavior, not a stub.
5058        #[test]
5059        fn apply_gpu_returns_none_for_f32() {
5060            let matrix = WeightMatrix::F32(Tensor::new(vec![0.0; 6], vec![2, 3]));
5061            assert!(matrix.apply_gpu(&[0.0, 0.0, 0.0]).is_none());
5062        }
5063
5064        #[test]
5065        fn apply_gpu_returns_none_for_mxfp4() {
5066            let matrix = WeightMatrix::Mxfp4 {
5067                packed: WeightBytes::Owned(vec![0u8; 32]),
5068                scale: WeightBytes::Owned(vec![0u8; 2]),
5069                rows: 1,
5070                cols: 64,
5071            };
5072            assert!(matrix.apply_gpu(&vec![0.0; 64]).is_none());
5073        }
5074
5075        /// A `Quantized` matrix whose `kind` has no GPU kernel on any
5076        /// compiled backend must fall back to `None`, not panic on the
5077        /// `unreachable!()` in `block_bytes_for_kind` -- proving the
5078        /// two match arms (`apply_gpu`'s launch table,
5079        /// `block_bytes_for_kind`'s partial one) stay in sync.
5080        ///
5081        /// The probe was `Q2_K` until 2026-09-09, when Q2_K gained a
5082        /// CUDA matvec and a GEMM and stopped being unsupported. `Q4_1`
5083        /// has neither on any backend and is the hole now. Moving it
5084        /// found a real defect rather than being bookkeeping: with the
5085        /// `cuda` feature on and no driver present, the first real
5086        /// dispatch through `Cuda::launch_matvec` aborted the process
5087        /// inside `cudarc`'s library loader, which that arm's
5088        /// `Result` could never have reported.
5089        #[test]
5090        fn apply_gpu_returns_none_for_an_unsupported_quant_kind() {
5091            let matrix = WeightMatrix::Quantized {
5092                data: WeightBytes::Owned(vec![0u8; ferrox_quant::Q4_1_BLOCK_BYTES]),
5093                rows: 1,
5094                cols: ferrox_quant::Q4_1_BLOCK_ELEMS,
5095                kind: QuantKind::Q4_1,
5096            };
5097            assert!(matrix
5098                .apply_gpu(&[0.0; ferrox_quant::Q4_1_BLOCK_ELEMS])
5099                .is_none());
5100        }
5101
5102        #[test]
5103        #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
5104        fn apply_gpu_matches_apply_for_q8_0_on_real_hardware() {
5105            let weights: Vec<f32> = (0..64).map(|i| ((i as f32) - 32.0) * 0.05).collect();
5106            let x: Vec<f32> = (0..64).map(|i| (i as f32) * 0.01 - 0.3).collect();
5107            let packed = ferrox_quant::quantize_q8_0(&weights);
5108            let matrix = WeightMatrix::Quantized {
5109                data: WeightBytes::Owned(packed),
5110                rows: 1,
5111                cols: 64,
5112                kind: QuantKind::Q8_0,
5113            };
5114
5115            let cpu = matrix.apply_cpu(&x);
5116            let gpu = matrix
5117                .apply_gpu(&x)
5118                .expect("Q8_0 must dispatch to a real GPU kernel");
5119            assert_eq!(cpu.len(), gpu.len());
5120            for (c, g) in cpu.iter().zip(gpu.iter()) {
5121                assert!((c - g).abs() < 1e-2, "cpu={c} gpu={g}");
5122            }
5123        }
5124    }
5125
5126    // ---- kernel-lookup registry coverage -------------------------------
5127    //
5128    // These are the tests that would have caught the IQ4_XS silent CPU
5129    // prefill at `cargo test` time instead of via a 13.7x benchmark row.
5130
5131    /// A quantized matrix of `kind` with `cols` columns, filled with
5132    /// arbitrary bytes -- the probe reads only shape and kind, never the
5133    /// weights, so the contents are irrelevant.
5134    fn shaped(kind: QuantKind, rows: usize, cols: usize) -> WeightMatrix {
5135        let per_row = match kind {
5136            QuantKind::Q8_0 => cols / 32 * 34,
5137            _ => cols,
5138        };
5139        WeightMatrix::Quantized {
5140            data: WeightBytes::Owned(vec![0u8; rows * per_row.max(1)]),
5141            rows,
5142            cols,
5143            kind,
5144        }
5145    }
5146
5147    /// `QuantKind::ALL` must actually list every variant. `name()` is
5148    /// exhaustive by the compiler, so distinct names prove distinct
5149    /// variants; the count pins that none was dropped from the list.
5150    #[test]
5151    fn quant_kind_all_lists_every_variant_exactly_once() {
5152        let mut names: Vec<&str> = QuantKind::ALL.iter().map(|k| k.name()).collect();
5153        let total = names.len();
5154        names.sort_unstable();
5155        names.dedup();
5156        assert_eq!(names.len(), total, "QuantKind::ALL has a duplicate");
5157        assert_eq!(
5158            total, 23,
5159            "a QuantKind variant was added without updating ALL"
5160        );
5161    }
5162
5163    /// The invariant that keeps prefill honest: every kind with a Metal
5164    /// matvec also has a Metal batched GEMM. Break it and the kind still
5165    /// "runs on Metal" -- as `batch` separate matvecs over the same
5166    /// weights, which is exactly the shape that put IQ4_XS 13.7x behind
5167    /// with no symptom other than a slow benchmark.
5168    #[test]
5169    fn every_metal_matvec_kind_also_has_a_metal_gemm() {
5170        for &k in QuantKind::ALL {
5171            assert_eq!(
5172                metal_matvec_kind_name(k).is_some(),
5173                metal_mul_mm_kind_supported(k),
5174                "{}: matvec and mul_mm kernel tables disagree -- one of the two \
5175                 is a silent slow path",
5176                k.name()
5177            );
5178        }
5179    }
5180
5181    /// The kind tables are pure lookups over the name, so a kind that
5182    /// claims a kernel must name itself the way the Metal launch meta
5183    /// table is keyed.
5184    #[test]
5185    fn metal_kind_names_match_the_quant_kind_names() {
5186        for &k in QuantKind::ALL {
5187            if let Some(name) = metal_matvec_kind_name(k) {
5188                assert_eq!(name, k.name());
5189            }
5190        }
5191    }
5192
5193    /// THE registry test: a kind with no accelerator kernel, probed
5194    /// while the model is built, must be recorded as a miss and must be
5195    /// a seal-time violation -- not silently absorbed by a fallback.
5196    ///
5197    /// Runs on any build: the backend is passed explicitly, so it does
5198    /// not need `--features metal` to ask what Metal would resolve.
5199    #[test]
5200    fn a_deliberately_unsupported_kind_trips_the_registry() {
5201        use crate::kernel_registry::{Backend, Outcome};
5202
5203        let reg = crate::kernel_registry::Registry::new();
5204        let loc = std::panic::Location::caller();
5205
5206        // Supported: Q4_K has both a Metal matvec and a Metal GEMM.
5207        shaped(QuantKind::Q4K, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_down", loc);
5208        // Unsupported: no Metal kernel of any kind for IQ2_XXS.
5209        shaped(QuantKind::IQ2XXS, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_up", loc);
5210
5211        let report = reg.seal();
5212        let violations = &report.violations;
5213        assert_eq!(
5214            violations.len(),
5215            2,
5216            "expected matvec + gemm misses for IQ2_XXS only, got: {:?}",
5217            report
5218                .entries
5219                .iter()
5220                .map(|e| e.to_string())
5221                .collect::<Vec<_>>()
5222        );
5223        assert!(
5224            violations
5225                .iter()
5226                .all(|v| v.key.kind == Some(QuantKind::IQ2XXS)),
5227            "Q4_K must not be flagged"
5228        );
5229        assert!(
5230            violations.iter().any(|v| matches!(
5231                v.outcome,
5232                Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5233            )),
5234            "the report must name the fallback that will actually run"
5235        );
5236        let rendered = report.render_violations();
5237        assert!(rendered.contains("IQ2_XXS"), "{rendered}");
5238        assert!(rendered.contains("weight_matrix.rs"), "{rendered}");
5239
5240        // And the host tier it lands on is recorded too: IQ2_XXS has no
5241        // integer vec_dot either, so it is f32 dequant-dot.
5242        assert!(
5243            report.entries.iter().any(|e| e.key.backend == Backend::Cpu
5244                && e.key.kind == Some(QuantKind::IQ2XXS)
5245                && matches!(e.outcome, Outcome::Miss { fallback, .. } if fallback == "f32 dequant-dot")),
5246            "{:?}",
5247            report.entries.iter().map(|e| e.to_string()).collect::<Vec<_>>()
5248        );
5249    }
5250
5251    /// A supported kind on a selected accelerator produces no violation
5252    /// at all -- otherwise the signal is noise and gets ignored.
5253    #[test]
5254    fn a_fully_supported_model_seals_clean() {
5255        use crate::kernel_registry::Backend;
5256
5257        let reg = crate::kernel_registry::Registry::new();
5258        let loc = std::panic::Location::caller();
5259        for kind in [QuantKind::Q4K, QuantKind::Q6K, QuantKind::Q8_0] {
5260            shaped(kind, 64, 256).probe_kernels_for(&reg, Backend::Metal, "ffn_down", loc);
5261        }
5262        let report = reg.seal();
5263        assert!(report.violations.is_empty(), "{}", report.render());
5264    }
5265
5266    /// A kind CUDA cannot run at all must be RECORDED as leaving the
5267    /// GPU, by name, rather than left to a comment in
5268    /// `apply_batch_with_acts`.
5269    ///
5270    /// This test used to probe `Q4K` and expect the fallback
5271    /// `"CUDA per-position matvec"`, which is what a kind gets when it
5272    /// has a matvec but no GEMM. **That combination no longer exists on
5273    /// CUDA.** The K-quants gained a GEMM on 2026-09-04, motivated by
5274    /// Llama-3.2-3B Q4_K_M running pp512 at 4.88 tok/s against
5275    /// llama.cpp's 1586.80, and the invariant below now forbids the
5276    /// combination from coming back.
5277    ///
5278    /// So the probe moved to a kind with neither kernel, and the
5279    /// expected fallback moved with it: with no matvec to loop over
5280    /// there is no per-position loop, and the whole matmul leaves for
5281    /// the host.
5282    ///
5283    /// It has moved three times. `Q5_0` was that kind until
5284    /// 2026-09-05, when it gained both; `Q2_K` was until 2026-09-09,
5285    /// when it and Q3_K did. `Q4_1` is the hole now, and the next row
5286    /// of the coverage table in `docs/plans/cpu-cuda-parity.md` §6 --
5287    /// which is the point: the test names a real hole and stops
5288    /// compiling a comment. When Q4_1 lands, this probe moves again.
5289    #[test]
5290    fn a_kind_cuda_cannot_run_is_recorded_as_leaving_the_gpu() {
5291        use crate::kernel_registry::{op, Backend, Outcome};
5292
5293        let reg = crate::kernel_registry::Registry::new();
5294        let loc = std::panic::Location::caller();
5295        shaped(QuantKind::Q4_1, 64, 256).probe_kernels_for(&reg, Backend::Cuda, "ffn_down", loc);
5296        let report = reg.seal();
5297        assert!(
5298            report.entries.iter().any(|e| e.key.backend == Backend::Cuda
5299                && e.key.op == op::GEMM_PREFILL
5300                && matches!(
5301                    e.outcome,
5302                    Outcome::Miss { fallback, .. } if fallback == "CPU apply_batch"
5303                )),
5304            "{}",
5305            report.render()
5306        );
5307    }
5308
5309    /// CUDA's matvec set and its GEMM set are now the same, and that is
5310    /// worth pinning: a kind that can be decoded on the GPU but not
5311    /// prefilled there is the shape that cost 325x, and it went
5312    /// unnoticed because a fallback still answers correctly.
5313    ///
5314    /// If a future kind gains a matvec without a GEMM, this fails and
5315    /// names it, rather than a benchmark noticing months later.
5316    #[test]
5317    fn a_cuda_kind_with_a_matvec_also_has_a_gemm() {
5318        for kind in QuantKind::ALL {
5319            if cuda_matvec_kind_supported(*kind) {
5320                assert!(
5321                    cuda_mul_mm_kind_supported(*kind),
5322                    "{kind:?} can be decoded on CUDA but not prefilled there, \
5323                     which decomposes a prefill into one matvec launch per position"
5324                );
5325            }
5326        }
5327    }
5328
5329    /// An F32 weight has no quantized kernel by construction; the probe
5330    /// records the host GEMV but must not call it a violation, or every
5331    /// MoE router would fail a strict run.
5332    #[test]
5333    fn an_f32_weight_is_recorded_without_being_a_violation() {
5334        use crate::kernel_registry::Backend;
5335
5336        let reg = crate::kernel_registry::Registry::new();
5337        let m = WeightMatrix::F32(Tensor::new(vec![0.0; 64 * 32], vec![64, 32]));
5338        m.probe_kernels_for(
5339            &reg,
5340            Backend::Metal,
5341            "moe_router",
5342            std::panic::Location::caller(),
5343        );
5344        let report = reg.seal();
5345        assert!(!report.misses.is_empty());
5346        assert!(report.violations.is_empty(), "{}", report.render());
5347    }
5348}
5349
5350#[cfg(test)]
5351mod int_dot_default_tests {
5352    use super::{IntDotShape, IntDotTier};
5353
5354    /// The int-dot rule follows the kernels that exist, per workload,
5355    /// not the wish that every architecture had every kernel.
5356    ///
5357    /// Taking the MATVEC half where the interleaved kernels do not exist
5358    /// selects a scalar integer loop and skips the AVX2 f32 dot that
5359    /// does, which measured 4x to 8.8x of x86 decode (#127). Adding AVX2
5360    /// GEMMs (#152) does not change that: they are batch kernels, and
5361    /// the matvec half of x86 is still the f32 dot's.
5362    #[test]
5363    fn the_matvec_half_is_taken_only_where_its_kernels_are() {
5364        assert_eq!(
5365            super::int_dot_tier_here().matvec,
5366            cfg!(target_arch = "aarch64"),
5367            "the matvec half is aarch64's (i8mm, interleave-8 NEON) and nowhere else; \
5368             x86 measured 4x to 8.8x slower with it on"
5369        );
5370    }
5371
5372    /// The BATCH half is not a `cfg!` claim: it asks the kernels.
5373    ///
5374    /// A host may only be told the batch tier is a win if `ferrox_quant`
5375    /// reports a SIMD batch GEMM at the width this host packs with. That
5376    /// is what stops the two structures — the list of architectures
5377    /// believed to have kernels, and the kernels — from drifting apart,
5378    /// which is how the 4x-to-8.8x regression happened in the first
5379    /// place.
5380    ///
5381    /// The probe is [`ferrox_quant::batch_gemm_is_accelerated`] and not
5382    /// `interleaved_gemm_is_accelerated`, because those are different
5383    /// questions and this one asked the narrower of the two. The
5384    /// interleave-8 predicate is about the quad kernels; a pre-i8mm
5385    /// aarch64 host runs a width-4 `dotprod` GEMM for four of the five
5386    /// kinds, so it has a SIMD batch GEMM while the interleave-8
5387    /// predicate says it does not.
5388    #[test]
5389    fn the_batch_half_is_taken_only_where_a_simd_gemm_answers_for_it() {
5390        assert_eq!(
5391            super::int_dot_tier_here().batch_gemm,
5392            ferrox_quant::batch_gemm_is_accelerated(ferrox_quant::preferred_interleave())
5393                && cfg!(any(target_arch = "aarch64", target_arch = "x86_64")),
5394            "the batch half must agree with the kernel probe, not with a written-down list"
5395        );
5396    }
5397
5398    /// The Q5_K batch gate asks the kernels: wherever the Q5_K `x4`
5399    /// GEMM has a SIMD kernel the Kx8 path is taken, whatever the
5400    /// architecture. A `cfg!` alone here is the defect this test exists
5401    /// for (8.56x on x86 prefill, 2026-09-15).
5402    #[test]
5403    fn the_q5k_batch_path_is_taken_wherever_its_simd_gemm_exists() {
5404        let interleave = ferrox_quant::q5_kx8_interleave();
5405        if ferrox_quant::q5_kx8_gemm_uses_acts_x4(interleave) {
5406            assert!(super::q5k_batch_takes_kx8(interleave));
5407        }
5408        // And on a host with no such kernel, the gate agrees with the
5409        // Q4_K arm's rule, which is "aarch64 always".
5410        assert!(super::q5k_batch_takes_kx8(interleave) || !cfg!(target_arch = "aarch64"));
5411    }
5412
5413    /// The two probes are not interchangeable, and this pins the
5414    /// difference so neither can quietly be swapped for the other.
5415    ///
5416    /// On a host with a width-4 SIMD batch GEMM and no interleave-8 one
5417    /// — every pre-i8mm aarch64 host, which is every M1 Mac, every
5418    /// A14-and-earlier iPhone, and the Cortex-A55-class cores that are
5419    /// still most of the Android fleet — the wider predicate says yes
5420    /// and the narrower says no. Reading the narrower as "is the batch
5421    /// tier a win" is what made this test fail on an M1 while the code
5422    /// was running a SIMD GEMM the whole time.
5423    #[test]
5424    fn the_batch_probe_is_wider_than_the_interleave_8_one() {
5425        for width in [4usize, 8] {
5426            assert!(
5427                ferrox_quant::batch_gemm_is_accelerated(width)
5428                    || !ferrox_quant::interleaved_gemm_is_accelerated(width),
5429                "the batch probe must answer yes wherever the interleave-8 one does"
5430            );
5431        }
5432        #[cfg(target_arch = "aarch64")]
5433        if std::arch::is_aarch64_feature_detected!("dotprod") {
5434            assert!(
5435                ferrox_quant::batch_gemm_is_accelerated(4),
5436                "a dotprod host runs the width-4 sdot GEMM for Q4_K/Q5_K/Q8_0/Q4_0"
5437            );
5438            assert!(
5439                !ferrox_quant::interleaved_gemm_is_accelerated(4),
5440                "the interleave-8 predicate is about the quad kernels only"
5441            );
5442        }
5443    }
5444
5445    /// `int_dot_is_a_win_here` — the thing `default_cpu_int_dot_on`
5446    /// consults — is the OR of the two halves, so a host with only the
5447    /// batch half still gets the env default it needs to reach it.
5448    #[test]
5449    fn the_default_is_on_when_either_half_is_a_win() {
5450        let tier = super::int_dot_tier_here();
5451        assert_eq!(
5452            super::int_dot_is_a_win_here(),
5453            tier.matvec || tier.batch_gemm
5454        );
5455    }
5456
5457    /// `covers` must actually separate the two shapes, in both
5458    /// directions — otherwise every call site below asks a question with
5459    /// one answer and the split is decoration.
5460    #[test]
5461    fn covers_answers_per_shape_rather_than_per_host() {
5462        let matvec_only = IntDotTier {
5463            matvec: true,
5464            batch_gemm: false,
5465        };
5466        let batch_only = IntDotTier {
5467            matvec: false,
5468            batch_gemm: true,
5469        };
5470        assert!(matvec_only.covers(IntDotShape::Matvec));
5471        assert!(!matvec_only.covers(IntDotShape::BatchGemm));
5472        assert!(!batch_only.covers(IntDotShape::Matvec));
5473        assert!(batch_only.covers(IntDotShape::BatchGemm));
5474    }
5475}