ferrox_core/weight_matrix/gpu_backend.rs
1//! The seam every GPU backend goes through.
2//!
3//! Four things used to exist once per backend as free functions in
4//! `weight_matrix.rs`, with no shape holding them together:
5//!
6//! | Axis | Metal | CUDA |
7//! |---|---|---|
8//! | kind → matvec | `metal_matvec_kind_name` → `Option<&str>` | `cuda_matvec_kind_supported` → `bool` |
9//! | kind → GEMM | `metal_mul_mm_kind_supported` | `cuda_mul_mm_kind_supported` |
10//! | launch alias | `MetalMatvecLaunchFn`, 4 args | `CudaMatvecLaunchFn`, 5 args |
11//! | enable probe | `metal_dense_enabled` | `cuda_dense_enabled`, byte-identical body |
12//!
13//! and they were re-selected by hand at every dispatch site, so
14//! `apply_gpu` carried two near-identical `match kind` tables that could
15//! only be kept honest by a `debug_assert!`. A third backend would have
16//! copied all four. This module is the shape they now share.
17//!
18//! # What a third backend has to provide
19//!
20//! Exactly this, and nothing else:
21//!
22//! 1. A unit type (`pub struct Vulkan;`).
23//! 2. [`BackendCaps`] — the two capability tables plus an id and a
24//! display name. **Compiled unconditionally**, with no dependency on
25//! the backend crate, because the tables are a property of the kernel
26//! set rather than of the build, and gating them would make them
27//! untestable on the CPU builds that run `cargo test --workspace`.
28//! This is the rule that kept `metal_matvec_kind_name` un-`cfg`'d and
29//! it is load-bearing: `probe_kernels_for` asks what Metal *would*
30//! resolve from a build with no Metal.
31//! 3. [`BackendDispatch`] under `#[cfg(feature = "…")]` — the enable
32//! probe, a device-free launch table, and one launch entry point.
33//! 4. One line in [`gpu_backend_table`], which is **the** ordered list:
34//! enum variant, cargo feature / registry name, and seam type, once.
35//!
36//! `Vulkan` is what that recipe looks like when it is followed: a
37//! one-kernel backend (Q8_0 matvec, no GEMM, no batch path) added
38//! without touching `apply_gpu` or `active_backend`.
39//!
40//! # The one list, and its three consumers
41//!
42//! [`gpu_backend_table`] is expanded by exactly three things, so a
43//! backend cannot exist in one of them and not the others:
44//!
45//! - [`crate::kernel_registry::Backend`]'s variants and their names.
46//! This is verdict point 4 — "a backend cannot be dispatched to
47//! without being reportable" — and it is now structural rather than
48//! hand-kept. It works because the registry and this module are the
49//! same crate; a `macro_rules!` cannot generate an enum in a
50//! *different* crate, so a backend crate could never own its own
51//! variant.
52//! - [`with_gpu_backends`], the `#[cfg]`-gated dispatch order, which
53//! [`crate::weight_matrix::WeightMatrix::apply_gpu`] and
54//! [`crate::weight_matrix::active_backend`] both expand.
55//! - [`with_gpu_backend_caps`], the **ungated** one, which
56//! `probe_kernels_for` expands so a CPU-only build can still ask what
57//! Metal or Vulkan *would* resolve.
58//!
59//! # The launch signature, and the fifth argument
60//!
61//! `ferrox-cuda`'s `launch_*_matvec` takes
62//! `(weights, x, rows, row_bytes, n_blocks_per_row)`; `ferrox-metal`'s
63//! takes the first four. [`BackendDispatch::launch_matvec`] takes the
64//! four plus the [`QuantKind`], and every backend derives the rest.
65//!
66//! That is deliberate, and it is not the wider arity the beachhead
67//! verdict proposed. `n_blocks_per_row` is **redundant information**,
68//! not missing information: it is `row_bytes / block_bytes(kind)`, and
69//! Metal already recomputes exactly that inside
70//! `ferrox_metal::gpu::matvec_launch_meta`, which hands back the block
71//! size for the kind. Hoisting it into the shared signature would buy a
72//! backend nothing it cannot derive, and would cost a
73//! `block_bytes(kind)` that is total over all 21 `QuantKind`s — the
74//! existing one, `WeightMatrix::block_bytes_for_kind`, is deliberately
75//! partial and `unreachable!()`s outside the five CUDA kinds, and
76//! Metal's `IQ4_XS` is not one of them. So the seam passes the kind and
77//! lets each backend ask its own table.
78
79use crate::kernel_registry::Backend;
80use crate::weight_matrix::QuantKind;
81
82/// A backend launch failure, flattened to its rendered message.
83///
84/// `ferrox_metal::gpu::MetalError` and `ferrox_cuda::gpu::CudaError` are
85/// different types living behind different features, and the only thing
86/// any caller does with either is print it before falling back — so the
87/// seam carries the message rather than an enum that would have to grow
88/// a variant per backend crate.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BackendError(String);
91
92impl BackendError {
93 /// Renders any backend error into the one shape the seam carries.
94 pub fn new(e: impl std::fmt::Display) -> Self {
95 BackendError(e.to_string())
96 }
97}
98
99impl std::fmt::Display for BackendError {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(&self.0)
102 }
103}
104
105/// What a backend can run, asked without the backend crate present.
106///
107/// Every member is an associated function with no receiver, which is
108/// what the free functions this replaced already were, so implementing
109/// it is a lift rather than a redesign.
110pub trait BackendCaps {
111 /// How [`crate::kernel_registry`] reports this backend. Dispatch and
112 /// observability read the same constant, so a backend cannot be
113 /// dispatched to under one name and reported under another.
114 const ID: Backend;
115
116 /// Human-readable name, for the one message a dispatch failure
117 /// prints.
118 const NAME: &'static str;
119
120 /// What a batched prefill actually runs on for a kind this backend
121 /// has a matvec but no GEMM for — the string
122 /// `probe_kernels_for` records as the fallback.
123 ///
124 /// It lives here because it is a property of the backend and it was
125 /// previously a `match backend` arm inside `probe_kernels_for`: a
126 /// third `Backend` variant would have silently inherited Metal's
127 /// wording (`"Metal N x matvec batch"`) by falling through `_`, and
128 /// the registry's whole job is to name the path that will really
129 /// run. Ungated, like the rest of [`BackendCaps`], because the
130 /// probe is ungated.
131 const GEMM_FALLBACK: &'static str;
132
133 /// Which quant kinds have a **matvec** kernel (the decode path), as
134 /// the kernel name the backend's own launch-meta table is keyed by,
135 /// or `None` for a kind with no kernel.
136 ///
137 /// Returning the name rather than a `bool` is what let the two
138 /// backends share one member: CUDA only ever needed the `bool`
139 /// (`.is_some()`), Metal needs the name to look up
140 /// `ferrox_metal::gpu::matvec_launch_meta`, and a `bool` cannot be
141 /// widened after the fact without another table.
142 fn matvec_kernel(kind: QuantKind) -> Option<&'static str>;
143
144 /// Which quant kinds have a **batched GEMM** (the prefill path). A
145 /// kind with a matvec but no GEMM still runs on the accelerator — as
146 /// `batch` separate matvecs over the same weights, which is the
147 /// 13.7x shape, and which is why these are two predicates and not
148 /// one.
149 fn gemm_supported(kind: QuantKind) -> bool;
150}
151
152/// What a backend can actually do, which needs its crate compiled in.
153pub trait BackendDispatch: BackendCaps {
154 /// What [`crate::weight_matrix::WeightMatrix::apply_gpu`] will do
155 /// next if this backend's launch fails, named for the log line.
156 /// A property of this backend's position in
157 /// [`with_gpu_backends`], not of the backend itself.
158 const MATVEC_FALLBACK: &'static str;
159
160 /// Whether dense matmuls should try this backend in this process.
161 /// Decided once from the environment, then cached for the process
162 /// lifetime. See `env_or_probe` for the grammar, which is shared.
163 fn dense_enabled() -> bool;
164
165 /// Whether a launch **function** exists for `kind`, asked without a
166 /// device and without launching anything.
167 ///
168 /// This is the question [`BackendCaps::matvec_kernel`] claims to
169 /// answer, asked of the code that actually runs. They are two
170 /// structures that must agree about one thing, and they cannot be
171 /// merged: the kernel NAMES are needed on builds where the backend
172 /// crate is not a dependency and these function pointers do not
173 /// exist. So the agreement is a test —
174 /// `every_kind_a_compiled_backend_claims_can_actually_be_launched`,
175 /// over every backend and all 23 kinds — rather than the
176 /// `debug_assert!` that used to guard it, which fired only for
177 /// kinds a run actually reached and only in debug.
178 ///
179 /// `Q5_0` is why. It was in Metal's capability table with no launch
180 /// function behind it from the day it was added, so batched prefill
181 /// ran on the GPU while single-token decode silently fell to the
182 /// CPU, and a release build just ran slower.
183 fn has_launch(kind: QuantKind) -> bool;
184
185 /// One matvec. `None` means "this backend has no kernel for `kind`",
186 /// which is a different answer from `Some(Err(_))`, "the kernel
187 /// exists and the launch failed" — the caller logs only the second.
188 fn launch_matvec(
189 kind: QuantKind,
190 weights: &[u8],
191 x: &[f32],
192 rows: usize,
193 row_bytes: usize,
194 ) -> Option<Result<Vec<f32>, BackendError>>;
195}
196
197/// The per-kind Metal launch table, split out of
198/// [`BackendDispatch::launch_matvec`] so it can be checked for EVERY
199/// kind without a device.
200///
201/// It has to agree with [`Metal::matvec_kernel`], and it cannot be the
202/// same table: the kernel NAMES are needed on builds where
203/// `ferrox-metal` is not a dependency and these function pointers do not
204/// exist. So the agreement is asserted, and asserting it only inside
205/// `launch_matvec` was not enough -- that fires just for kinds a run
206/// actually reaches, in debug. `Q5_0` was in the capability table and
207/// missing here from the day it was added, and the symptom was
208/// single-token decode silently falling to the CPU while batched
209/// prefill ran on the GPU.
210#[cfg(feature = "metal")]
211fn metal_matvec_launch(kind: QuantKind) -> Option<MetalMatvecLaunchFn> {
212 match kind {
213 QuantKind::Q8_0 => Some(ferrox_metal::gpu::launch_q8_0_matvec),
214 QuantKind::Q4_0 => Some(ferrox_metal::gpu::launch_q4_0_matvec),
215 QuantKind::Q4K => Some(ferrox_metal::gpu::launch_q4_k_matvec),
216 QuantKind::Q5_0 => Some(ferrox_metal::gpu::launch_q5_0_matvec),
217 QuantKind::Q5K => Some(ferrox_metal::gpu::launch_q5_k_matvec),
218 QuantKind::Q6K => Some(ferrox_metal::gpu::launch_q6_k_matvec),
219 QuantKind::IQ4XS => Some(ferrox_metal::gpu::launch_iq4_xs_matvec),
220 QuantKind::Ptq1_0 => Some(ferrox_metal::gpu::launch_ptq1_0_matvec),
221 _ => None,
222 }
223}
224
225/// The per-kind CUDA launch table, split out of
226/// [`BackendDispatch::launch_matvec`] for the same reason
227/// [`metal_matvec_launch`] was: so
228/// [`BackendDispatch::has_launch`] can check it against
229/// [`Cuda::matvec_kernel`] for EVERY kind, without a GPU. It was inline
230/// in `launch_matvec` and therefore had no guard at all — the hole that
231/// cost Metal a Q5_0 decode path.
232#[cfg(feature = "cuda")]
233pub(crate) fn cuda_matvec_launch(kind: QuantKind) -> Option<CudaMatvecLaunchFn> {
234 match kind {
235 QuantKind::Q8_0 => Some(ferrox_cuda::gpu::launch_q8_0_matvec),
236 QuantKind::Q4_0 => Some(ferrox_cuda::gpu::launch_q4_0_matvec),
237 QuantKind::Q5_0 => Some(ferrox_cuda::gpu::launch_q5_0_matvec),
238 QuantKind::Q2K => Some(ferrox_cuda::gpu::launch_q2_k_matvec),
239 QuantKind::Q3K => Some(ferrox_cuda::gpu::launch_q3_k_matvec),
240 QuantKind::Q4K => Some(ferrox_cuda::gpu::launch_q4_k_matvec),
241 QuantKind::Q5K => Some(ferrox_cuda::gpu::launch_q5_k_matvec),
242 QuantKind::Q6K => Some(ferrox_cuda::gpu::launch_q6_k_matvec),
243 QuantKind::IQ4NL => Some(ferrox_cuda::gpu::launch_iq4_nl_matvec),
244 QuantKind::IQ4XS => Some(ferrox_cuda::gpu::launch_iq4_xs_matvec),
245 QuantKind::Mxfp4Gguf => Some(ferrox_cuda::gpu::launch_mxfp4_matvec),
246 _ => None,
247 }
248}
249
250/// The per-kind Vulkan launch table. One row, and the guard test is
251/// what keeps it one row: adding a kind to [`Vulkan::matvec_kernel`]
252/// without a shader here fails
253/// `every_kind_a_compiled_backend_claims_can_actually_be_launched`.
254#[cfg(feature = "vulkan")]
255fn vulkan_matvec_launch(kind: QuantKind) -> Option<VulkanMatvecLaunchFn> {
256 match kind {
257 QuantKind::Q8_0 => Some(ferrox_vulkan::dispatch::q8_0_matvec),
258 _ => None,
259 }
260}
261
262/// The Metal backend (`ferrox-metal`).
263pub struct Metal;
264
265/// The CUDA backend (`ferrox-cuda`).
266pub struct Cuda;
267
268/// The Vulkan backend (`ferrox-vulkan`) — **one kernel wide**.
269///
270/// `ferrox-vulkan` is the `vulkan-beachhead` GO/NO-GO slice, not a
271/// backend: a single hand-emitted SPIR-V Q8_0 matvec, checked against a
272/// scalar twin and run on a real device through MoltenVK. See
273/// `docs/plans/vulkan-beachhead-verdict.md`.
274///
275/// This impl is what wiring that slice into the seam costs, and it is
276/// deliberately not more than the slice supports:
277///
278/// - **Q8_0 and nothing else.** [`Vulkan::matvec_kernel`] names one
279/// kind; every other kind reports no kernel, which is the honest
280/// answer and is what makes the registry say "NO KERNEL … falls back
281/// to CPU apply_cpu" instead of quietly running slow.
282/// - **No GEMM at all.** [`Vulkan::gemm_supported`] is false for every
283/// kind. There is no `mul_mm` shader, and `apply_batch_with_acts` has
284/// no Vulkan arm, so a batched prefill runs on the host —
285/// [`Vulkan::GEMM_FALLBACK`] says exactly that.
286/// - **No performance claim.** `q8_0_matvec` rebuilds its entire
287/// pipeline per call. Nothing here may be reported as a measured
288/// capability; the verdict says so and this comment repeats it
289/// because the code is now reachable.
290pub struct Vulkan;
291
292impl BackendCaps for Metal {
293 const ID: Backend = Backend::Metal;
294 const NAME: &'static str = "Metal";
295 /// `apply_gpu_batch` re-reads the whole weight matrix once per
296 /// position, on the GPU. Still Metal, still the 13.7x shape.
297 const GEMM_FALLBACK: &'static str = "Metal N x matvec batch";
298
299 /// As the kernel name [`ferrox_metal::gpu::matvec_launch_meta`]
300 /// resolves.
301 ///
302 /// This is the single source of truth for that question. It is *not*
303 /// `#[cfg(feature = "metal")]`-gated deliberately: the table is a
304 /// property of the kernel set, and gating it would make it
305 /// untestable on the builds that run `cargo test --workspace`.
306 ///
307 /// Duplicating this list is how IQ4_XS batched prefill silently ran
308 /// on the CPU — `metal_kind_supported` and `apply_gpu_batch`'s kind
309 /// table disagreed by exactly one entry, and the only symptom was a
310 /// benchmark row 13.7x behind. Every Metal-kind question now routes
311 /// through here.
312 fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
313 match kind {
314 QuantKind::Q8_0
315 | QuantKind::Q4_0
316 | QuantKind::Q5_0
317 | QuantKind::Q4K
318 | QuantKind::Q5K
319 | QuantKind::Q6K
320 | QuantKind::IQ4XS
321 | QuantKind::Ptq1_0 => Some(kind.name()),
322 _ => None,
323 }
324 }
325
326 /// The `*_mul_mm_sg` simdgroup GEMMs.
327 ///
328 /// The invariant that this set equals [`Metal::matvec_kernel`]'s is
329 /// asserted by a test, so adding a matvec kernel without a GEMM
330 /// fails the suite instead of a benchmark.
331 fn gemm_supported(kind: QuantKind) -> bool {
332 // Q5_0 JOINED 2026-09-01, and the two-year-old comment this
333 // replaced named the exact condition: "the honest close is a
334 // `q5_0_matvec` plus a Q5_0 row in the bench suite, not a sixth
335 // entry in this list."
336 //
337 // The matvec now exists (`Q5_0_MATVEC_KERNEL_SRC`), so the split
338 // this list was protecting against is gone: Q5_0 was already
339 // getting GPU prefill through `mul_mm_sg_launch` and `mapped_sg`,
340 // which never consulted this table, while every decode step fell
341 // back to the CPU for want of the matvec. That is the mixed
342 // CPU/GPU path the old comment feared, and it was live rather
343 // than hypothetical.
344 //
345 // The bench row is still owed: there is no Q5_0 checkpoint in
346 // `benchmarks/suite.json`, so this path is
347 // CORRECT-BY-CONSTRUCTION and UNMEASURED.
348 // `Llama-3.2-1B-Instruct-Q5_K_M` is Q5_K, not Q5_0.
349 matches!(
350 kind,
351 QuantKind::Q8_0
352 | QuantKind::Q4_0
353 | QuantKind::Q5_0
354 | QuantKind::Q4K
355 | QuantKind::Q5K
356 | QuantKind::Q6K
357 | QuantKind::IQ4XS
358 | QuantKind::Ptq1_0
359 )
360 }
361}
362
363impl BackendCaps for Cuda {
364 const ID: Backend = Backend::Cuda;
365 const NAME: &'static str = "CUDA";
366 /// `apply_batch_with_acts` decomposes a CUDA prefill into one
367 /// matvec per position for every kind off [`Cuda::gemm_supported`].
368 const GEMM_FALLBACK: &'static str = "CUDA per-position matvec";
369
370 /// The decode path, and the arm that has actually run on a GPU --
371 /// for six of its nine kinds.
372 ///
373 /// **DERIVED from `ferrox_cuda::matvec_kinds::KINDS`, not restated.**
374 /// That table is compiled on every build (it is CUDA C text and
375 /// three strings per row; nothing in it needs `cudarc`), and
376 /// `ferrox-cuda` is an unconditional dependency for exactly this
377 /// reason. The set used to be written out here as a `matches!` and
378 /// checked against the kernel table by a test that only ran under
379 /// `--features cuda`; over-claiming there sends a decode to an
380 /// NVRTC module that does not exist, and under-claiming leaves a
381 /// kernel nothing ever calls. Both have happened.
382 ///
383 /// The name is returned only to share
384 /// [`BackendCaps::matvec_kernel`]'s shape with Metal; nothing on the
385 /// CUDA path reads it, because `ferrox-cuda`'s launchers are named
386 /// functions rather than entries in a string-keyed table.
387 fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
388 ferrox_cuda::matvec_kinds::kind_by_name(kind.name()).map(|_| kind.name())
389 }
390
391 /// The `mul_mm` prefill path.
392 ///
393 /// **DERIVED from `ferrox_cuda::mul_mm::KINDS`**, for the same
394 /// reason and by the same mechanism as [`Cuda::matvec_kernel`]
395 /// above. It equals that set, and
396 /// `the_matvec_table_and_the_mul_mm_table_name_the_same_kinds` in
397 /// `ferrox-cuda` is what keeps it equal -- a kind with one kernel
398 /// and not the other splits a forward pass across two devices.
399 ///
400 /// It did not until 2026-09-04: only Q8_0 and Q4_0 had a
401 /// matrix-matrix product, so a K-quant prefill decomposed into one
402 /// matvec launch per position. Measured on a GTX 1080, that cost
403 /// Llama-3.2-3B Q4_K_M 4.88 tok/s of pp512 against llama.cpp's
404 /// 1586.80, a 325x gap on the most common quantization in
405 /// circulation (#131). IQ4_XS is still absent: it is a codebook
406 /// lookup rather than an affine dequant.
407 ///
408 /// Q5_0 joined on 2026-09-05, matvec and GEMM together, because
409 /// `a_cuda_kind_with_a_matvec_also_has_a_gemm` makes half of it
410 /// fail the suite -- and because half of it is the shape that cost
411 /// Metal a Q5_0 decode path: GPU prefill with every decode step on
412 /// the host.
413 ///
414 /// IQ4_NL, IQ4_XS and MXFP4 joined on 2026-09-09, matvec and GEMM
415 /// together. They are CODEBOOK formats: the stored 4-bit code is an
416 /// index into a sixteen-entry table, not a magnitude, so the kernel
417 /// carries that table in `__constant__` memory. gpt-oss ships
418 /// MXFP4 and no GPU backend had it at all, so every expert decoded
419 /// on the host with the device idle.
420 ///
421 /// **UNRUN ON HARDWARE.** The kernel is checked against a scalar
422 /// twin and by executing the emitted CUDA C on the host, and has
423 /// never executed on a GPU. See `crates/ferrox-cuda/src/mul_mm.rs`.
424 fn gemm_supported(kind: QuantKind) -> bool {
425 ferrox_cuda::mul_mm::kind_by_name(kind.name()).is_some()
426 }
427}
428
429impl BackendCaps for Vulkan {
430 const ID: Backend = Backend::Vulkan;
431 const NAME: &'static str = "Vulkan";
432 /// There is no Vulkan batch entry point of any kind:
433 /// `apply_gpu_batch` is `#[cfg(feature = "metal")]` and
434 /// `apply_batch_with_acts` has a CUDA arm and a Metal arm. So a
435 /// prefill against a Vulkan-resident kind runs on the host, and
436 /// this names the host path rather than inventing a GPU one.
437 const GEMM_FALLBACK: &'static str = "CPU apply_batch";
438
439 /// Exactly one kind, because there is exactly one shader:
440 /// `ferrox_vulkan::q8_0_shader`.
441 ///
442 /// Everything else must report `None` rather than something
443 /// plausible. A capability table that over-claims is how a kind ends
444 /// up "supported" with no kernel behind it, which this repo has now
445 /// paid for twice (IQ4_XS prefill, Q5_0 decode). The guard test
446 /// checks this against [`vulkan_matvec_launch`] for all 23 kinds.
447 fn matvec_kernel(kind: QuantKind) -> Option<&'static str> {
448 match kind {
449 QuantKind::Q8_0 => Some(kind.name()),
450 _ => None,
451 }
452 }
453
454 /// No kind, for any kind. The beachhead emitted one matvec shader
455 /// and deliberately no `mul_mm`; the verdict puts a real GEMM in
456 /// `vulkan-prefill-gemm`, which is where the backend decision
457 /// actually lives.
458 ///
459 /// This is the one place the Metal invariant
460 /// (`every_metal_matvec_kind_also_has_a_metal_gemm`: matvec set ==
461 /// GEMM set) is knowingly not held, and it is held open rather than
462 /// papered over: Q8_0 decodes on Vulkan and prefills on the CPU,
463 /// the registry records the split by name, and `ferrox bench` would
464 /// show it.
465 fn gemm_supported(_kind: QuantKind) -> bool {
466 false
467 }
468}
469
470/// The `FERROX_METAL` / `FERROX_CUDA` grammar, which was written out
471/// twice in bodies that were byte-identical apart from the alias:
472///
473/// - `0|false|off|cpu` — force CPU
474/// - `1|true|on|<alias>` — force this backend
475/// - unset / anything else — whatever `probe` says
476///
477/// `probe` is only called when the environment did not decide, which is
478/// what keeps a forced-off build from opening a device.
479///
480/// Compiled when a backend needs it, and under `test` so the grammar
481/// stays checked on the CPU-only builds that run `cargo test`.
482#[cfg(any(feature = "metal", feature = "cuda", feature = "vulkan", test))]
483fn env_or_probe(value: Option<&str>, on_alias: &str, probe: impl FnOnce() -> bool) -> bool {
484 match value {
485 Some("0") | Some("false") | Some("off") | Some("cpu") => false,
486 Some("1") | Some("true") | Some("on") => true,
487 Some(v) if v == on_alias => true,
488 _ => probe(),
489 }
490}
491
492/// A `ferrox_metal::gpu::launch_*_matvec` function pointer's signature
493/// (`weights`/`x` borrowed; row block count is derived inside
494/// `ferrox_metal::gpu`).
495#[cfg(feature = "metal")]
496type MetalMatvecLaunchFn =
497 fn(&[u8], &[f32], usize, usize) -> Result<Vec<f32>, ferrox_metal::gpu::MetalError>;
498
499/// A `ferrox_cuda::gpu::launch_*_matvec` function pointer's signature
500/// (all five real kernels share it exactly).
501#[cfg(feature = "cuda")]
502type CudaMatvecLaunchFn =
503 fn(&[u8], &[f32], usize, usize, usize) -> Result<Vec<f32>, ferrox_cuda::gpu::CudaError>;
504
505/// A `ferrox_vulkan::dispatch` matvec's signature. Same five arguments
506/// as [`CudaMatvecLaunchFn`] -- `ferrox-vulkan` was written to this
507/// list on purpose -- plus the borrowed [`ferrox_vulkan::device::Context`]
508/// in front, because Vulkan keeps no process-global device inside its
509/// own crate the way `ferrox_metal::gpu` and `ferrox_cuda::gpu` do.
510/// [`vulkan_context`] is that global, and it lives here so the beachhead
511/// crate stays a beachhead.
512#[cfg(feature = "vulkan")]
513type VulkanMatvecLaunchFn = fn(
514 &ferrox_vulkan::device::Context,
515 &[u8],
516 &[f32],
517 usize,
518 usize,
519 usize,
520) -> Result<Vec<f32>, ferrox_vulkan::device::VulkanError>;
521
522#[cfg(feature = "metal")]
523impl BackendDispatch for Metal {
524 const MATVEC_FALLBACK: &'static str = "falling back to CPU";
525
526 fn has_launch(kind: QuantKind) -> bool {
527 metal_matvec_launch(kind).is_some()
528 }
529
530 fn dense_enabled() -> bool {
531 use std::sync::OnceLock;
532 // A `static` inside a generic function is shared across every
533 // monomorphization, so this cache cannot be hoisted into a
534 // default trait method: each backend needs its own cell.
535 static ENABLED: OnceLock<bool> = OnceLock::new();
536 *ENABLED.get_or_init(|| {
537 let v = std::env::var("FERROX_METAL").ok();
538 env_or_probe(v.as_deref(), "metal", || {
539 ferrox_metal::gpu::probe().is_some()
540 })
541 })
542 }
543
544 fn launch_matvec(
545 kind: QuantKind,
546 weights: &[u8],
547 x: &[f32],
548 rows: usize,
549 row_bytes: usize,
550 ) -> Option<Result<Vec<f32>, BackendError>> {
551 let launch = metal_matvec_launch(kind);
552 // This table and `Metal::matvec_kernel` answer the same question
553 // and must never diverge; when they did, IQ4_XS prefill silently
554 // moved to the CPU. They CANNOT be one table -- the names are
555 // needed on builds where `ferrox-metal` is not a dependency and
556 // these function pointers do not exist -- so the agreement stays
557 // asserted rather than structural.
558 debug_assert_eq!(
559 launch.is_some(),
560 Self::matvec_kernel(kind).is_some(),
561 "apply_gpu's Metal launch table disagrees with metal_matvec_kind_name for {:?}",
562 kind
563 );
564 let launch = launch?;
565 Some(launch(weights, x, rows, row_bytes).map_err(BackendError::new))
566 }
567}
568
569#[cfg(feature = "cuda")]
570impl BackendDispatch for Cuda {
571 const MATVEC_FALLBACK: &'static str = "trying next backend / CPU";
572
573 fn has_launch(kind: QuantKind) -> bool {
574 cuda_matvec_launch(kind).is_some()
575 }
576
577 fn dense_enabled() -> bool {
578 use std::sync::OnceLock;
579 // See the note on `Metal::dense_enabled` for why this cell is
580 // not shared through a default method.
581 static ENABLED: OnceLock<bool> = OnceLock::new();
582 *ENABLED.get_or_init(|| {
583 let v = std::env::var("FERROX_CUDA").ok();
584 env_or_probe(v.as_deref(), "cuda", || ferrox_cuda::gpu::probe().is_some())
585 })
586 }
587
588 fn launch_matvec(
589 kind: QuantKind,
590 weights: &[u8],
591 x: &[f32],
592 rows: usize,
593 row_bytes: usize,
594 ) -> Option<Result<Vec<f32>, BackendError>> {
595 let launch = cuda_matvec_launch(kind)?;
596
597 // `FERROX_CUDA=0` must mean the CPU, and a build with the
598 // `cuda` feature on a host with no driver must fall back, not
599 // die. Neither was true here until 2026-09-09.
600 //
601 // `Vulkan::launch_matvec` has carried this guard since it
602 // landed, and its comment said Metal and CUDA did not need one
603 // because "their launchers no-op into an error when their
604 // device is absent". That is true of Metal. It is NOT true of
605 // CUDA: `cudarc` resolves `libcuda` through a lazily loaded
606 // symbol table and PANICS (`cudarc-0.11.9/src/lib.rs:98`,
607 // "Unable to dynamically load the cuda shared library") rather
608 // than returning an error, so the `Result` this arm is written
609 // around never gets a chance to be `Err`. A panic in a rayon
610 // worker is not a fallback.
611 //
612 // It was latent rather than harmless: any quantized matvec on
613 // such a build aborted the process. Nothing in the suite
614 // reached it because the one test that dispatches a real kind
615 // through here is `#[ignore]`d, and the test that dispatches an
616 // unsupported one picked a kind that returned `None` above --
617 // until Q2_K gained a kernel and stopped being unsupported.
618 //
619 // `dense_enabled()` is a `OnceLock` over a probe that catches
620 // its own panics, so this costs one atomic load after the first
621 // call.
622 if !Self::dense_enabled() {
623 return None;
624 }
625
626 // Derived here rather than at the seam: `block_bytes_for_kind`
627 // is `unreachable!()` outside the CUDA-dispatchable kinds, and
628 // reaching it is gated on the match above having named one.
629 let n_blocks_per_row =
630 row_bytes / crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
631 Some(launch(weights, x, rows, row_bytes, n_blocks_per_row).map_err(BackendError::new))
632 }
633}
634
635/// The process-wide Vulkan device, opened at most once.
636///
637/// `ferrox_metal::gpu` and `ferrox_cuda::gpu` each keep their device
638/// inside their own crate, so their launch functions take no context.
639/// `ferrox-vulkan` deliberately does not: it is a beachhead whose
640/// `Context` is created and dropped by its own tests, and giving it a
641/// hidden global would have made the GO/NO-GO slice into infrastructure.
642/// So the global lives here, on the seam's side of the boundary.
643///
644/// `Mutex`, not a bare `Context`: a `vk::Queue` must be externally
645/// synchronized, and `apply_gpu` is called from rayon workers.
646/// Serializing them is correct and is not a regression, because
647/// `q8_0_matvec` rebuilds its entire pipeline per call and is not a
648/// performance path in the first place — see the verdict.
649///
650/// `None` means the device could not be opened. That is reported once,
651/// here, rather than once per matvec.
652#[cfg(feature = "vulkan")]
653fn vulkan_context() -> Option<&'static std::sync::Mutex<ferrox_vulkan::device::Context>> {
654 use std::sync::{Mutex, OnceLock};
655 static CTX: OnceLock<Option<Mutex<ferrox_vulkan::device::Context>>> = OnceLock::new();
656 CTX.get_or_init(|| match ferrox_vulkan::device::Context::new() {
657 Ok(ctx) => Some(Mutex::new(ctx)),
658 Err(e) => {
659 eprintln!(
660 "ferrox: Vulkan device unavailable, {}: {e}",
661 Vulkan::MATVEC_FALLBACK
662 );
663 None
664 }
665 })
666 .as_ref()
667}
668
669#[cfg(feature = "vulkan")]
670impl BackendDispatch for Vulkan {
671 /// Last in [`gpu_backend_table`], so there is nothing after it.
672 const MATVEC_FALLBACK: &'static str = "falling back to CPU";
673
674 fn has_launch(kind: QuantKind) -> bool {
675 vulkan_matvec_launch(kind).is_some()
676 }
677
678 fn dense_enabled() -> bool {
679 use std::sync::OnceLock;
680 // See the note on `Metal::dense_enabled` for why this cell is
681 // not shared through a default method.
682 static ENABLED: OnceLock<bool> = OnceLock::new();
683 *ENABLED.get_or_init(|| {
684 let v = std::env::var("FERROX_VULKAN").ok();
685 env_or_probe(v.as_deref(), "vulkan", || {
686 ferrox_vulkan::device::probe().is_ok()
687 })
688 })
689 }
690
691 fn launch_matvec(
692 kind: QuantKind,
693 weights: &[u8],
694 x: &[f32],
695 rows: usize,
696 row_bytes: usize,
697 ) -> Option<Result<Vec<f32>, BackendError>> {
698 let launch = vulkan_matvec_launch(kind)?;
699
700 // Unlike Metal, whose launcher no-ops into an error when its
701 // device is absent, `ferrox-vulkan` has no global to consult --
702 // so the env grammar is honoured here or not at all. (CUDA was
703 // in this sentence too, and should not have been: see
704 // `Cuda::launch_matvec`, which now carries the same guard.)
705 // `FERROX_VULKAN=0` must mean the CPU, not "open a device
706 // anyway". Returning `None` (rather than an error) is right:
707 // "this backend is not running here" is the same answer as "no
708 // kernel", and both mean try the next backend.
709 if !Self::dense_enabled() {
710 return None;
711 }
712 let ctx = vulkan_context()?;
713
714 // `ferrox_vulkan::dispatch::q8_0_matvec` asserts its shape
715 // invariants, which is right for a test-driven beachhead and
716 // wrong for a dispatch path: a panic in a rayon worker is not a
717 // fallback. Checked here so a mismatch is an error the caller
718 // logs and recovers from.
719 let block_bytes = crate::weight_matrix::WeightMatrix::block_bytes_for_kind(kind);
720 let n_blocks_per_row = row_bytes / block_bytes;
721 if weights.len() != rows * row_bytes
722 || row_bytes != n_blocks_per_row * block_bytes
723 || x.len() != n_blocks_per_row * ferrox_vulkan::q8_0_shader::BLOCK_ELEMS
724 || rows == 0
725 || n_blocks_per_row == 0
726 {
727 return Some(Err(BackendError::new(format!(
728 "Vulkan {} matvec shape rejected: {} weight bytes, {} activations, \
729 rows={rows} row_bytes={row_bytes}",
730 kind.name(),
731 weights.len(),
732 x.len(),
733 ))));
734 }
735
736 let guard = match ctx.lock() {
737 Ok(g) => g,
738 // A poisoned mutex means another thread panicked mid-
739 // dispatch; the device may be mid-submission, so refuse
740 // rather than reuse it.
741 Err(_) => {
742 return Some(Err(BackendError::new(
743 "Vulkan context poisoned by an earlier panic",
744 )))
745 }
746 };
747 Some(
748 launch(&guard, weights, x, rows, row_bytes, n_blocks_per_row)
749 .map_err(BackendError::new),
750 )
751 }
752}
753
754/// **The** backend table: one row per GPU backend, in dispatch
755/// precedence order — CUDA first, then Metal, then Vulkan, then the CPU
756/// fallthrough the caller supplies.
757///
758/// Vulkan is last on purpose. On the only machine that can run all
759/// three it reaches the GPU through MoltenVK, i.e. through Metal, and
760/// it has one kernel; a native backend must win over a translation
761/// layer wrapping it.
762///
763/// Each row is `(enum variant, feature / registry name, seam type)`.
764/// The three are the same string in three grammars, and that is exactly
765/// why they are written once: the cargo feature, the
766/// [`crate::kernel_registry::Backend`] variant and the type were three
767/// hand-kept lists, and "two structures that must agree about one
768/// thing" is the dominant bug shape in this repo.
769///
770/// It expands `$mac!` ONCE with every row, so a consumer can build a
771/// single item (an `enum`) from it and not just a sequence of
772/// statements. `$extra` is passed through in brackets ahead of the rows
773/// so a consumer that needs its own callback — [`with_gpu_backends`] —
774/// can forward one without re-listing the table.
775macro_rules! gpu_backend_table {
776 ($mac:path $(, $extra:tt)*) => {
777 $mac! {
778 [$($extra),*]
779 (Cuda, "cuda", Cuda),
780 (Metal, "metal", Metal),
781 (Vulkan, "vulkan", Vulkan),
782 }
783 };
784}
785pub(crate) use gpu_backend_table;
786
787/// Expands `$mac!(Backend)` once per **compiled-in** backend, in
788/// [`gpu_backend_table`] order.
789///
790/// This exists because the order was hand-copied at every dispatch site
791/// and in `active_backend`, and a macro is the only way to keep static
792/// dispatch, per-backend `#[cfg]`, and one written-down order at the
793/// same time. A third backend is one line in the table, not here.
794///
795/// `$mac` must tolerate being expanded zero times: on a CPU-only build
796/// this produces nothing, so define it `#[allow(unused_macros)]`.
797macro_rules! with_gpu_backends {
798 ($mac:ident) => {
799 $crate::weight_matrix::gpu_backend::gpu_backend_table!(
800 $crate::weight_matrix::gpu_backend::gpu_backend_dispatch_rows,
801 $mac
802 );
803 };
804}
805pub(crate) use with_gpu_backends;
806
807/// [`with_gpu_backends`]'s row expander. The `#[cfg(feature = …)]` is
808/// built from the table's own name column, so a backend cannot be in
809/// the list under one feature and gated on another.
810macro_rules! gpu_backend_dispatch_rows {
811 ([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
812 $(
813 #[cfg(feature = $feature)]
814 $mac!($crate::weight_matrix::gpu_backend::$ty);
815 )*
816 };
817}
818pub(crate) use gpu_backend_dispatch_rows;
819
820/// Expands `$mac!(Backend)` once per backend **whether or not it is
821/// compiled in**, in [`gpu_backend_table`] order.
822///
823/// The ungated twin of [`with_gpu_backends`], and the reason
824/// [`BackendCaps`] is ungated: `probe_kernels_for` has to answer "what
825/// would Metal resolve for this kind" on a build with no Metal, which
826/// is the only way the kernel-coverage tests run under a plain
827/// `cargo test`. A consumer of this must therefore stay inside
828/// [`BackendCaps`] — [`BackendDispatch`] does not exist for a backend
829/// whose feature is off.
830///
831/// Never expands zero times, so `$mac` needs no `unused_macros` cover.
832macro_rules! with_gpu_backend_caps {
833 ($mac:ident) => {
834 $crate::weight_matrix::gpu_backend::gpu_backend_table!(
835 $crate::weight_matrix::gpu_backend::gpu_backend_caps_rows,
836 $mac
837 );
838 };
839}
840pub(crate) use with_gpu_backend_caps;
841
842/// [`with_gpu_backend_caps`]'s row expander.
843macro_rules! gpu_backend_caps_rows {
844 ([$mac:ident] $(($variant:ident, $feature:literal, $ty:ident)),* $(,)?) => {
845 $(
846 $mac!($crate::weight_matrix::gpu_backend::$ty);
847 )*
848 };
849}
850pub(crate) use gpu_backend_caps_rows;
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 /// The env grammar both enable probes share. `probe` must not be
857 /// consulted when the environment already decided — a forced-off
858 /// build must never open a device.
859 #[test]
860 fn env_decides_before_the_probe_is_consulted() {
861 for forced_off in ["0", "false", "off", "cpu"] {
862 assert!(!env_or_probe(Some(forced_off), "metal", || panic!(
863 "probed after {forced_off}"
864 )));
865 }
866 for forced_on in ["1", "true", "on"] {
867 assert!(env_or_probe(Some(forced_on), "metal", || panic!(
868 "probed after {forced_on}"
869 )));
870 }
871 }
872
873 /// Each backend's own alias forces it on; the *other* backend's
874 /// alias is not a value it understands, so it falls through to the
875 /// probe rather than silently forcing.
876 #[test]
877 fn the_alias_is_per_backend() {
878 assert!(env_or_probe(Some("metal"), "metal", || false));
879 assert!(env_or_probe(Some("cuda"), "cuda", || false));
880 assert!(!env_or_probe(Some("cuda"), "metal", || false));
881 assert!(!env_or_probe(Some("metal"), "cuda", || false));
882 }
883
884 /// Unset, or a value the grammar does not name, defers to the probe.
885 #[test]
886 fn an_unrecognised_value_defers_to_the_probe() {
887 assert!(env_or_probe(None, "metal", || true));
888 assert!(!env_or_probe(None, "metal", || false));
889 assert!(env_or_probe(Some("auto"), "metal", || true));
890 assert!(!env_or_probe(Some("auto"), "metal", || false));
891 }
892
893 /// A backend cannot be dispatched to under one name and reported
894 /// under another: dispatch and the registry read the same constant.
895 ///
896 /// [`Backend`]'s variants are generated from the same table these
897 /// impls are listed in, so a *missing* variant is now impossible —
898 /// but `const ID` is still written by hand in each impl, so naming
899 /// another backend's variant is not. That is what the distinctness
900 /// check catches, and the count check catches a variant with no
901 /// backend behind it.
902 #[test]
903 fn every_backend_id_is_distinct_and_an_accelerator() {
904 let mut ids = Vec::new();
905 macro_rules! collect_id {
906 ($b:ty) => {
907 assert!(
908 <$b as BackendCaps>::ID.is_accelerator(),
909 "{} is reported as the CPU",
910 <$b as BackendCaps>::NAME
911 );
912 ids.push((<$b as BackendCaps>::ID, <$b as BackendCaps>::NAME));
913 };
914 }
915 with_gpu_backend_caps!(collect_id);
916
917 for (i, (id, name)) in ids.iter().enumerate() {
918 for (other_id, other_name) in &ids[i + 1..] {
919 assert_ne!(
920 id, other_id,
921 "{name} and {other_name} both report as {id} -- one of them is \
922 dispatched to under a registry identity that is not its own"
923 );
924 }
925 }
926 assert_eq!(
927 ids.len() + 1,
928 Backend::ALL.len(),
929 "the registry has a backend variant no BackendCaps impl claims: {:?} vs {ids:?}",
930 Backend::ALL
931 );
932 }
933
934 /// Vulkan is **one kernel wide** and the seam must keep saying so.
935 ///
936 /// `ferrox-vulkan` has exactly one shader, `q8_0_shader`. If this
937 /// table ever grows a kind, either a shader landed with it (and this
938 /// test is the place to say so) or the table now over-claims — which
939 /// is how IQ4_XS prefill and Q5_0 decode each silently moved to the
940 /// CPU while the capability report said "GPU".
941 ///
942 /// Ungated on purpose, like [`BackendCaps`] itself: this is a
943 /// property of the kernel set, so it is checked on every build,
944 /// including the CPU-only one that runs `cargo test --workspace`.
945 #[test]
946 fn vulkan_claims_exactly_one_matvec_kind_and_no_gemm() {
947 let claimed: Vec<QuantKind> = QuantKind::ALL
948 .iter()
949 .copied()
950 .filter(|&k| Vulkan::matvec_kernel(k).is_some())
951 .collect();
952 assert_eq!(
953 claimed,
954 vec![QuantKind::Q8_0],
955 "ferrox-vulkan has one shader (q8_0_shader); the capability table claims {claimed:?}"
956 );
957 for &k in QuantKind::ALL {
958 assert!(
959 !Vulkan::gemm_supported(k),
960 "{k:?}: there is no Vulkan mul_mm shader, so a claimed GEMM would send \
961 prefill to a kernel that does not exist"
962 );
963 }
964 }
965
966 /// The GEMM twin of the test below: `Metal::gemm_supported` must
967 /// answer exactly what `ferrox_metal::gpu::mul_mm_sg_meta` has a row
968 /// for, because `apply_gpu_batch` launches through that table now.
969 /// It used to launch through a per-kind match, and the match lacked
970 /// Q5_0 and PTQ1_0 while this table claimed both: prefill for those
971 /// kinds ran N matvecs with the kernel registry recording a GEMM hit.
972 #[cfg(feature = "metal")]
973 #[test]
974 fn metal_gemm_claims_are_exactly_the_gemm_table() {
975 for &kind in QuantKind::ALL {
976 let claimed = Metal::gemm_supported(kind);
977 let has_row = ferrox_metal::gpu::mul_mm_sg_meta(kind.name()).is_some();
978 assert_eq!(
979 claimed, has_row,
980 "{kind:?}: gemm_supported {claimed}, mul_mm_sg_meta row {has_row}"
981 );
982 }
983 }
984
985 /// Every kind a **compiled-in** backend's capability table CLAIMS
986 /// must have a launch function behind it, for all 23 kinds and
987 /// without a device.
988 ///
989 /// `Q5_0` did not, on Metal, from the day it was added. The kernel
990 /// source and the `matvec_launch_meta` row landed together and both
991 /// capability tables were widened on the strength of them — but
992 /// `apply_gpu`'s single-matvec decode path dispatches through a
993 /// per-kind `launch_*_matvec` FUNCTION, and there was no Q5_0 one.
994 /// So batched prefill ran on the GPU while single-token decode
995 /// silently fell to the CPU: exactly the mixed CPU/GPU split that
996 /// widening was supposed to close.
997 ///
998 /// The `debug_assert_eq!` in `Metal::launch_matvec` did guard this,
999 /// but only for kinds a run actually reaches, and only in debug. A
1000 /// release build just ran slower. This checks the whole table up
1001 /// front — and, since it expands over
1002 /// [`with_gpu_backends`] rather than naming Metal, CUDA and Vulkan
1003 /// each get it for free instead of Metal getting it three times.
1004 /// CUDA had no such guard at all before its launch table was split
1005 /// out of `launch_matvec`; Vulkan gets one on its first day.
1006 #[test]
1007 fn every_kind_a_compiled_backend_claims_can_actually_be_launched() {
1008 #[allow(unused_macros)]
1009 macro_rules! check_launch_table {
1010 ($b:ty) => {
1011 let mut claimed_without_launch = Vec::new();
1012 let mut launchable_unclaimed = Vec::new();
1013 for &kind in QuantKind::ALL {
1014 let claimed = <$b as BackendCaps>::matvec_kernel(kind).is_some();
1015 let launchable = <$b as BackendDispatch>::has_launch(kind);
1016 if claimed && !launchable {
1017 claimed_without_launch.push(kind);
1018 }
1019 if launchable && !claimed {
1020 launchable_unclaimed.push(kind);
1021 }
1022 }
1023 assert!(
1024 claimed_without_launch.is_empty(),
1025 "{} claims a matvec nothing can launch: {claimed_without_launch:?} -- \
1026 decode falls to the CPU for these while batched prefill runs on the GPU",
1027 <$b as BackendCaps>::NAME
1028 );
1029 assert!(
1030 launchable_unclaimed.is_empty(),
1031 "{} has a launch for {launchable_unclaimed:?} that its capability \
1032 table does not claim, so nothing will ever call it",
1033 <$b as BackendCaps>::NAME
1034 );
1035 };
1036 }
1037 with_gpu_backends!(check_launch_table);
1038 }
1039
1040 /// A kind that claims a matvec must name itself the way the
1041 /// backend's launch-meta table is keyed, for every backend and not
1042 /// just Metal. Expanded over the ungated table, so it holds on a
1043 /// CPU-only build and a third backend gets it for free — which it
1044 /// did not when the body hand-listed `[Metal, Cuda]`.
1045 #[test]
1046 fn a_claimed_matvec_kernel_is_named_after_its_kind() {
1047 macro_rules! check_names {
1048 ($b:ty) => {
1049 for &k in QuantKind::ALL {
1050 if let Some(name) = <$b as BackendCaps>::matvec_kernel(k) {
1051 assert_eq!(
1052 name,
1053 k.name(),
1054 "{} names {k:?} {name:?}",
1055 <$b as BackendCaps>::NAME
1056 );
1057 }
1058 }
1059 };
1060 }
1061 with_gpu_backend_caps!(check_names);
1062 }
1063
1064 /// The GPU matvec against the CPU one, on a real device.
1065 ///
1066 /// This is the only test here that opens a device, and it is the
1067 /// only one that can catch the seam wiring the right kernel to the
1068 /// wrong arguments — a `row_bytes` that is not `n_blocks * 34`, or
1069 /// an activation length derived from the wrong block size, are both
1070 /// legal calls that produce a wrong number. `ferrox-vulkan`'s own
1071 /// twin test proves the shader; this proves the *call*.
1072 ///
1073 /// Skips, loudly, when no device is reachable, so it is not a test
1074 /// that cannot fail: on a host with Vulkan it asserts, and it was
1075 /// checked by sabotage (feeding `rows + 1`) before being committed.
1076 #[cfg(feature = "vulkan")]
1077 #[test]
1078 fn the_vulkan_seam_matvec_matches_the_cpu_matvec() {
1079 use crate::weight_matrix::{WeightBytes, WeightMatrix};
1080
1081 if !Vulkan::dense_enabled() {
1082 eprintln!("no Vulkan device reachable; the seam matvec was NOT checked");
1083 return;
1084 }
1085
1086 // Three blocks per row: 3 * 34 = 102 bytes, deliberately not a
1087 // multiple of 4, which is the alignment case the shader's byte
1088 // extraction exists for.
1089 let (rows, blocks) = (9usize, 3usize);
1090 let cols = blocks * 32;
1091 let f32_weights: Vec<f32> = (0..rows * cols)
1092 .map(|i| ((i % 37) as f32 - 18.0) / 11.0)
1093 .collect();
1094 let mut data = Vec::new();
1095 for r in 0..rows {
1096 data.extend_from_slice(&ferrox_quant::quantize_q8_0(
1097 &f32_weights[r * cols..(r + 1) * cols],
1098 ));
1099 }
1100 let x: Vec<f32> = (0..cols).map(|i| ((i % 13) as f32 - 6.0) / 5.0).collect();
1101
1102 let m = WeightMatrix::Quantized {
1103 data: WeightBytes::Owned(data),
1104 rows,
1105 cols,
1106 kind: QuantKind::Q8_0,
1107 };
1108 let WeightMatrix::Quantized { data, .. } = &m else {
1109 unreachable!()
1110 };
1111 let got = Vulkan::launch_matvec(QuantKind::Q8_0, data.as_slice(), &x, rows, blocks * 34)
1112 .expect("Q8_0 has a Vulkan kernel")
1113 .expect("the launch must succeed once a device is open");
1114 let want = m.apply(&x);
1115
1116 assert_eq!(got.len(), want.len());
1117 for (r, (g, w)) in got.iter().zip(&want).enumerate() {
1118 // The same 1e-4 relative tolerance ferrox-cuda's hardware
1119 // test uses, and for the same reason: a GPU may contract
1120 // `acc + a * b` into an FMA.
1121 assert!(
1122 (g - w).abs() <= 1e-4 * w.abs().max(1.0),
1123 "row {r}: vulkan {g} vs cpu {w}"
1124 );
1125 }
1126 }
1127
1128 /// A shape the kernel cannot honour must come back as an ERROR the
1129 /// caller logs, never as a panic in a rayon worker.
1130 /// `ferrox_vulkan::dispatch::q8_0_matvec` asserts its invariants,
1131 /// which is right for a beachhead and fatal on a dispatch path, so
1132 /// the seam checks them first. Needs no device: the check runs
1133 /// before the context is used.
1134 #[cfg(feature = "vulkan")]
1135 #[test]
1136 fn a_mismatched_vulkan_shape_is_an_error_not_a_panic() {
1137 if !Vulkan::dense_enabled() {
1138 eprintln!("no Vulkan device reachable; the shape guard was NOT checked");
1139 return;
1140 }
1141 // 2 rows of 1 block each, but an activation sized for 2 blocks.
1142 let weights = vec![0u8; 2 * 34];
1143 let x = vec![0.0f32; 64];
1144 let out = Vulkan::launch_matvec(QuantKind::Q8_0, &weights, &x, 2, 34);
1145 assert!(
1146 matches!(out, Some(Err(_))),
1147 "a mismatched shape must be a reported error, got {out:?}"
1148 );
1149 }
1150}