ferrox_cuda/matvec_kinds/mod.rs
1//! The per-quant-kind matvec table: which formats CUDA can DECODE, and
2//! the three strings a launch needs for each.
3//!
4//! This is the decode counterpart of [`crate::mul_mm::KINDS`], and it
5//! is a table for the same reason. The same five rows were once written
6//! out three times -- here, inline in `ferrox-core`'s `apply_gpu_multi`
7//! and again in `apply_gpu_dense_ffn_swiglu` -- and a kind added to one
8//! and not the others silently lost the fused launch while the
9//! capability report kept saying GPU.
10//!
11//! # Always compiled
12//!
13//! Deliberately outside the `cuda` feature gate, like `mul_mm`. The
14//! rows are CUDA C *text* and three `&'static str`s; nothing here needs
15//! `cudarc`. That buys two things: `cargo test -p ferrox-cuda` on a
16//! GPU-less host still checks that every row names an entry point its
17//! source defines and that every embedded codebook agrees with the
18//! GEMM's, and `ferrox-core` can ask "does CUDA decode this kind?" on
19//! a build with no CUDA feature at all -- which is what lets
20//! `Cuda::matvec_kernel` DERIVE from this table instead of restating
21//! it. Restating it is how IQ4_XS ended up "supported" with no kernel.
22//!
23//! The launch itself ([`crate::gpu::matvec_launch_meta`] and the
24//! `launch_*_matvec` functions) stays feature-gated.
25
26pub mod codebook;
27pub mod kquant;
28pub mod legacy;
29
30/// One quantized weight format the CUDA matvec path can consume.
31///
32/// The `__global__` entry point named by [`Self::fn_name`] must have
33/// the signature every kernel in this directory shares:
34///
35/// ```text
36/// void <fn_name>(const unsigned char* weights, const float* x,
37/// float* out, int rows, int row_bytes,
38/// int n_blocks_per_row)
39/// ```
40///
41/// one threadblock per output row, 256 threads striding the row's
42/// blocks, a tree reduction into `out[row]`. `launch_matvec` supplies
43/// exactly that geometry, so a kernel written to a different one
44/// returns wrong numbers rather than failing.
45#[derive(Debug, Clone, Copy)]
46pub struct MatvecKind {
47 /// GGUF quant name -- the key `ferrox-core` looks a kind up by,
48 /// which is `QuantKind::name()`.
49 pub name: &'static str,
50 /// NVRTC module cache key. Must be unique per kind.
51 pub module_name: &'static str,
52 /// The `__global__` entry point inside that module.
53 pub fn_name: &'static str,
54 /// The complete translation unit defining it.
55 pub src: &'static str,
56}
57
58/// The dispatch table. A new format is one row here and nothing else.
59///
60/// Order is Q8_0, Q4_0, Q5_0, then the K-quants, then the codebook
61/// kinds, matching [`crate::mul_mm::KINDS`] so the two read as the same
62/// list -- which is what
63/// `ferrox_core`'s `a_cuda_kind_with_a_matvec_also_has_a_gemm` requires
64/// them to be.
65pub const KINDS: &[MatvecKind] = &[
66 MatvecKind {
67 name: "Q8_0",
68 module_name: "ferrox_q8_0",
69 fn_name: "q8_0_matvec",
70 src: legacy::Q8_0_MATVEC_KERNEL_SRC,
71 },
72 MatvecKind {
73 name: "Q4_0",
74 module_name: "ferrox_q4_0",
75 fn_name: "q4_0_matvec",
76 src: legacy::Q4_0_MATVEC_KERNEL_SRC,
77 },
78 MatvecKind {
79 name: "Q5_0",
80 module_name: "ferrox_q5_0",
81 fn_name: "q5_0_matvec",
82 src: legacy::Q5_0_MATVEC_KERNEL_SRC,
83 },
84 MatvecKind {
85 name: "Q2_K",
86 module_name: "ferrox_q2_k",
87 fn_name: "q2_k_matvec",
88 src: kquant::Q2_K_MATVEC_KERNEL_SRC,
89 },
90 MatvecKind {
91 name: "Q3_K",
92 module_name: "ferrox_q3_k",
93 fn_name: "q3_k_matvec",
94 src: kquant::Q3_K_MATVEC_KERNEL_SRC,
95 },
96 MatvecKind {
97 name: "Q4_K",
98 module_name: "ferrox_q4_k",
99 fn_name: "q4_k_matvec",
100 src: kquant::Q4_K_MATVEC_KERNEL_SRC,
101 },
102 MatvecKind {
103 name: "Q5_K",
104 module_name: "ferrox_q5_k",
105 fn_name: "q5_k_matvec",
106 src: kquant::Q5_K_MATVEC_KERNEL_SRC,
107 },
108 MatvecKind {
109 name: "Q6_K",
110 module_name: "ferrox_q6_k",
111 fn_name: "q6_k_matvec",
112 src: kquant::Q6_K_MATVEC_KERNEL_SRC,
113 },
114 MatvecKind {
115 name: "IQ4_NL",
116 module_name: "ferrox_iq4_nl",
117 fn_name: "iq4_nl_matvec",
118 src: codebook::IQ4_NL_MATVEC_KERNEL_SRC,
119 },
120 MatvecKind {
121 name: "IQ4_XS",
122 module_name: "ferrox_iq4_xs",
123 fn_name: "iq4_xs_matvec",
124 src: codebook::IQ4_XS_MATVEC_KERNEL_SRC,
125 },
126 MatvecKind {
127 name: "MXFP4",
128 module_name: "ferrox_mxfp4",
129 fn_name: "mxfp4_matvec",
130 src: codebook::MXFP4_MATVEC_KERNEL_SRC,
131 },
132];
133
134/// Looks up a kind by its GGUF quant name. `None` means CUDA has no
135/// matvec for that format and the caller must fall back and say so,
136/// never compute something else.
137pub fn kind_by_name(name: &str) -> Option<&'static MatvecKind> {
138 KINDS.iter().find(|k| k.name == name)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 /// A row whose `fn_name` its source does not define is a
146 /// `KernelCompile` error at a user's first token. Runnable without
147 /// a device, because that failure needs no GPU to see.
148 #[test]
149 fn every_row_names_an_entry_point_its_source_defines() {
150 for k in KINDS {
151 assert!(
152 k.src.contains(&format!("void {}(", k.fn_name)),
153 "{}: the source in {} does not define {}",
154 k.name,
155 k.module_name,
156 k.fn_name
157 );
158 assert!(
159 k.src.contains("int n_blocks_per_row"),
160 "{}: does not take the launch geometry every caller supplies",
161 k.name
162 );
163 }
164 for (i, a) in KINDS.iter().enumerate() {
165 for b in &KINDS[i + 1..] {
166 assert_ne!(
167 a.module_name, b.module_name,
168 "{} and {} collide in the process-wide NVRTC module cache",
169 a.name, b.name
170 );
171 assert_ne!(a.fn_name, b.fn_name, "{} vs {}", a.name, b.name);
172 }
173 }
174 }
175
176 /// A kind with no kernel must not resolve. Resolving sends a decode
177 /// to a module that cannot compile, and the caller has no way to
178 /// fall back honestly.
179 #[test]
180 fn a_kind_with_no_matvec_does_not_resolve() {
181 for absent in ["Q5_1", "Q4_1", "Q8_1", "IQ1_S", "IQ2_XXS", "IQ3_S"] {
182 assert!(
183 kind_by_name(absent).is_none(),
184 "{absent} resolved to a CUDA matvec that does not exist"
185 );
186 }
187 }
188
189 /// Every matvec kernel strides its row by a byte count written as
190 /// a LITERAL in CUDA C (`row_ptr + (size_t)b * 84`), and that
191 /// literal has to be the block size the format actually has.
192 ///
193 /// A wrong stride walks the row past the first block and every
194 /// value after it is garbage -- the failure Q5_0 got a bespoke test
195 /// for on 2026-09-05 (`the_q5_0_matvec_strides_by_the_real_block_
196 /// geometry`), one kind at a time. This is that test for the whole
197 /// table, driven from [`crate::mul_mm::KINDS`], which is where the
198 /// GEMM takes the same number from. Five kinds landed on
199 /// 2026-09-09 and none of them would have had one otherwise.
200 ///
201 /// The activation step is checked the same way: a kernel that steps
202 /// the INPUT by the wrong count pairs every quant after the first
203 /// block with the wrong activation.
204 ///
205 /// Matched per line rather than on one exact spelling, because the
206 /// six kernels that predate this test write the same offset three
207 /// ways (`b * 34`, `(size_t)b * 22`, `blk * 144`). Normalising them
208 /// is a separate change; a test that only accepted one spelling
209 /// would have to be weakened or would fail on code that is correct.
210 ///
211 /// Sabotage: change any stride or activation-step literal in a
212 /// kernel source and this names the kind.
213 #[test]
214 fn every_matvec_strides_by_the_real_block_geometry() {
215 for k in KINDS {
216 let mm = crate::mul_mm::kind_by_name(k.name)
217 .unwrap_or_else(|| panic!("{}: a matvec with no mul_mm row", k.name));
218
219 let row_step = k
220 .src
221 .lines()
222 .find(|l| l.contains("row_ptr +"))
223 .unwrap_or_else(|| panic!("{}: no row-pointer arithmetic", k.name));
224 assert!(
225 row_step.contains(&format!("* {};", mm.block_bytes)),
226 "{}: strides the row by something other than {} bytes: {}",
227 k.name,
228 mm.block_bytes,
229 row_step.trim()
230 );
231
232 let x_step = k
233 .src
234 .lines()
235 .find(|l| l.contains("base = ") && l.trim_end().ends_with(';'))
236 .unwrap_or_else(|| panic!("{}: no activation-base arithmetic", k.name));
237 assert!(
238 x_step.contains(&format!("* {};", mm.block_elems)),
239 "{}: steps the activation by something other than {} elements: {}",
240 k.name,
241 mm.block_elems,
242 x_step.trim()
243 );
244 }
245 }
246
247 /// The codebook a matvec kernel embeds as a literal has to be the
248 /// codebook the GEMM emits from its [`Codebook`] row.
249 ///
250 /// Two structures that must agree about sixteen arbitrary numbers,
251 /// with the emitted half generated and the literal half typed by
252 /// hand. Nothing else would catch a transposed pair: every value is
253 /// plausible, every tensor would still decode, and only the
254 /// arithmetic would be quietly wrong.
255 ///
256 /// The numbers are parsed back out of the kernel text rather than
257 /// compared to a second copy of the formatting, so this fails on a
258 /// bad literal and not merely on a bad `format!`.
259 ///
260 /// Sabotage: change one entry of either literal below and this
261 /// names the kind and the index.
262 #[test]
263 fn every_embedded_codebook_is_the_mul_mm_codebook() {
264 let mut checked = 0usize;
265 for mm in crate::mul_mm::KINDS {
266 let Some(cb) = mm.codebook else { continue };
267 let Some(mv) = kind_by_name(mm.name) else {
268 panic!(
269 "{}: has a mul_mm codebook kernel but no matvec, which is the \
270 split that decomposes a prefill into one launch per position",
271 mm.name
272 );
273 };
274 let decl = format!("__constant__ float {}[16] = {{", cb.c_name);
275 let at = mv.src.find(&decl).unwrap_or_else(|| {
276 panic!("{}: the matvec does not declare {}", mm.name, cb.c_name)
277 });
278 let body = &mv.src[at + decl.len()..];
279 let body = &body[..body.find('}').expect("unterminated codebook")];
280 let got: Vec<f32> = body
281 .split(',')
282 .map(|t| {
283 t.trim()
284 .trim_end_matches('f')
285 .parse::<f32>()
286 .unwrap_or_else(|e| panic!("{}: {t:?}: {e}", mm.name))
287 })
288 .collect();
289 assert_eq!(got.len(), 16, "{}: codebook is not 16 entries", mm.name);
290 for (i, (g, w)) in got.iter().zip(cb.values.iter()).enumerate() {
291 // Bit comparison: MXFP4's code 8 is negative zero, and
292 // `-0.0 == 0.0` would let a sign flip through.
293 assert_eq!(
294 g.to_bits(),
295 w.to_bits(),
296 "{}: codebook entry {i}: matvec has {g}, mul_mm emits {w}",
297 mm.name
298 );
299 }
300 checked += 1;
301 }
302 assert!(checked >= 3, "the codebook kinds stopped being checked");
303 }
304
305 /// A kind that can be prefilled on the GPU but not decoded there
306 /// (or the reverse) splits a forward pass across two devices, which
307 /// is the shape that cost Metal a Q5_0 decode path and CUDA a 325x
308 /// K-quant prefill.
309 ///
310 /// `ferrox-core` asserts the same thing over `QuantKind`; this
311 /// asserts it over the two kernel tables themselves, so it holds
312 /// even for a format `QuantKind` has no variant for.
313 #[test]
314 fn the_matvec_table_and_the_mul_mm_table_name_the_same_kinds() {
315 let mm: Vec<&str> = crate::mul_mm::KINDS.iter().map(|k| k.name).collect();
316 let mv: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
317 assert_eq!(mm, mv, "the CUDA decode and prefill tables have diverged");
318 }
319}
320
321/// The on-device check for every matvec kernel, in one loop.
322///
323/// Gated on `cuda` because it calls the launchers, and `#[ignore]`d
324/// because it needs a device. It is deliberately ONE test over
325/// [`KINDS`] rather than one per kernel: `gpu.rs` grew a hand-written
326/// hardware test per kind, and the kinds that arrived later
327/// (`Q5_0`, and everything added on 2026-09-09) each needed someone to
328/// remember. A kind added to the table is on this list the moment it
329/// exists.
330#[cfg(all(test, feature = "cuda"))]
331mod hardware_tests {
332 /// Every CUDA matvec against `ferrox_quant`'s fused dot, on weights
333 /// built by the shared `mul_mm` fixtures -- the same bytes the GEMM
334 /// twin and the GEMM hardware test use, so a disagreement between
335 /// decode and prefill shows up as one of them failing on data the
336 /// other passed.
337 ///
338 /// Run on a machine with an actual CUDA device:
339 /// cargo test -p ferrox-cuda --features cuda -- --ignored
340 #[test]
341 #[ignore = "requires real CUDA hardware. Q2_K, Q3_K, IQ4_NL, IQ4_XS and MXFP4 have NEVER executed on a GPU; Q5_0 has not either. Run with --ignored on a CUDA-capable machine and record the result before any doc claims those kinds decode on CUDA"]
342 fn every_cuda_matvec_matches_the_cpu_reference() {
343 type Dot = fn(&[u8], &[f32]) -> f32;
344 type Launch =
345 fn(&[u8], &[f32], usize, usize, usize) -> Result<Vec<f32>, crate::gpu::CudaError>;
346
347 // Keyed by name and checked to cover `KINDS`, so a kernel
348 // without a CPU oracle fails here instead of shipping unchecked.
349 let oracles: &[(&str, Dot, Launch)] = &[
350 (
351 "Q8_0",
352 ferrox_quant::dot_q8_0_f32_scalar,
353 crate::gpu::launch_q8_0_matvec,
354 ),
355 (
356 "Q4_0",
357 ferrox_quant::dot_q4_0_f32_scalar,
358 crate::gpu::launch_q4_0_matvec,
359 ),
360 (
361 "Q5_0",
362 ferrox_quant::dot_q5_0_f32_scalar,
363 crate::gpu::launch_q5_0_matvec,
364 ),
365 (
366 "Q2_K",
367 ferrox_quant::dot_q2_k_f32_scalar,
368 crate::gpu::launch_q2_k_matvec,
369 ),
370 (
371 "Q3_K",
372 ferrox_quant::dot_q3_k_f32_scalar,
373 crate::gpu::launch_q3_k_matvec,
374 ),
375 (
376 "Q4_K",
377 ferrox_quant::dot_q4_k_f32_scalar,
378 crate::gpu::launch_q4_k_matvec,
379 ),
380 (
381 "Q5_K",
382 ferrox_quant::dot_q5_k_f32_scalar,
383 crate::gpu::launch_q5_k_matvec,
384 ),
385 (
386 "Q6_K",
387 ferrox_quant::dot_q6_k_f32_scalar,
388 crate::gpu::launch_q6_k_matvec,
389 ),
390 (
391 "IQ4_NL",
392 ferrox_quant::dot_iq4_nl_f32_scalar,
393 crate::gpu::launch_iq4_nl_matvec,
394 ),
395 (
396 "IQ4_XS",
397 ferrox_quant::dot_iq4_xs_f32_scalar,
398 crate::gpu::launch_iq4_xs_matvec,
399 ),
400 (
401 "MXFP4",
402 ferrox_quant::dot_mxfp4_gguf_f32_scalar,
403 crate::gpu::launch_mxfp4_matvec,
404 ),
405 ];
406
407 for mm in crate::mul_mm::KINDS {
408 let (_, dot, launch) = oracles
409 .iter()
410 .find(|(name, _, _)| *name == mm.name)
411 .unwrap_or_else(|| panic!("{}: a CUDA matvec with no CPU oracle", mm.name));
412
413 let rows = 7;
414 // Two whole super-blocks, so a kernel that strides the row
415 // wrongly is visible rather than reading one block twice.
416 let cols = mm.block_elems * 2;
417 let blocks_per_row = cols / mm.block_elems;
418 let row_bytes = blocks_per_row * mm.block_bytes;
419 let weights = crate::mul_mm_ref::fixtures::weights(mm, rows, cols, 31337);
420 let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.09).sin()).collect();
421
422 let expected: Vec<f32> = (0..rows)
423 .map(|r| dot(&weights[r * row_bytes..(r + 1) * row_bytes], &x))
424 .collect();
425 let got = launch(&weights, &x, rows, row_bytes, blocks_per_row)
426 .expect("kernel launch must succeed on real CUDA hardware");
427
428 assert_eq!(got.len(), expected.len());
429 for (i, (g, w)) in got.iter().zip(expected.iter()).enumerate() {
430 // Relative, not absolute. Several of these kernels
431 // factor the scale out of the inner loop where
432 // `ferrox_quant`'s scalar dot multiplies it in per
433 // element, so the two reassociate differently over 256
434 // terms; an absolute 1e-2 would be a coin flip on a
435 // large scale.
436 let scale = w.abs().max(1.0);
437 assert!(
438 (g - w).abs() <= 1e-3 * scale,
439 "{} row {i}: GPU={g} CPU reference={w}",
440 mm.name
441 );
442 }
443 }
444 }
445}