1use anyhow::Result;
13use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
14use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
15
16use crate::weight_map::QuantizedWeight;
17
18pub const W4A16_GEMV_OUTS_PER_BLOCK: u32 = 4;
21
22pub const W4A16_GEMV_SW_OUTS_PER_BLOCK: u32 = 8;
25
26pub fn w4a16_gemv_grid_x(n: u32) -> u32 {
27 div_ceil(n, W4A16_GEMV_OUTS_PER_BLOCK)
28}
29
30pub fn w4a16_gemv_sw_grid_x(n: u32) -> u32 {
31 div_ceil(n, W4A16_GEMV_SW_OUTS_PER_BLOCK)
32}
33
34pub fn gemv_sw_from(no_gemv_sw: Option<&str>) -> bool {
38 no_gemv_sw != Some("1")
39}
40
41pub fn use_gemv_sw(lever: bool, sw_handle: KernelHandle) -> bool {
43 lever && sw_handle.0 != 0
44}
45
46#[allow(clippy::too_many_arguments)]
48pub fn w4a16_gemv_sw(
49 gpu: &dyn GpuBackend,
50 kernel: KernelHandle,
51 input: DevicePtr,
52 weight: &QuantizedWeight,
53 output: DevicePtr,
54 n: u32,
55 k: u32,
56 stream: u64,
57) -> Result<()> {
58 KernelLaunch::new(gpu, kernel)
59 .grid([w4a16_gemv_sw_grid_x(n), 1, 1])
60 .block([256, 1, 1])
61 .arg_ptr(input)
62 .arg_ptr(weight.weight)
63 .arg_ptr(weight.weight_scale)
64 .arg_f32(weight.weight_scale_2)
65 .arg_ptr(output)
66 .arg_u32(n)
67 .arg_u32(k)
68 .launch(stream)
69}
70
71#[allow(clippy::too_many_arguments)]
77pub fn w4a16_gemv_sw_raw(
78 gpu: &dyn GpuBackend,
79 kernel: KernelHandle,
80 input: DevicePtr,
81 packed: DevicePtr,
82 scale: DevicePtr,
83 scale_2: f32,
84 output: DevicePtr,
85 n: u32,
86 k: u32,
87 stream: u64,
88) -> Result<()> {
89 KernelLaunch::new(gpu, kernel)
90 .grid([w4a16_gemv_sw_grid_x(n), 1, 1])
91 .block([256, 1, 1])
92 .arg_ptr(input)
93 .arg_ptr(packed)
94 .arg_ptr(scale)
95 .arg_f32(scale_2)
96 .arg_ptr(output)
97 .arg_u32(n)
98 .arg_u32(k)
99 .launch(stream)
100}
101
102#[allow(clippy::too_many_arguments)]
104pub fn w4a16_decode_gemv(
105 gpu: &dyn GpuBackend,
106 gemv: KernelHandle,
107 gemv_sw: KernelHandle,
108 use_sw: bool,
109 input: DevicePtr,
110 weight: &QuantizedWeight,
111 output: DevicePtr,
112 n: u32,
113 k: u32,
114 stream: u64,
115) -> Result<()> {
116 if use_gemv_sw(use_sw, gemv_sw) {
117 w4a16_gemv_sw(gpu, gemv_sw, input, weight, output, n, k, stream)
118 } else {
119 super::quant_dispatch::w4a16_gemv(gpu, gemv, input, weight, output, n, k, stream)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use spark_runtime::gpu::KernelHandle;
127 use spark_runtime::gpu::mock::MockGpuBackend;
128 use std::fs;
129 use std::path::{Path, PathBuf};
130
131 #[test]
132 fn gemv_sw_ships_on_and_only_the_one_value_kills() {
133 assert!(gemv_sw_from(None), "unset → ON");
134 assert!(gemv_sw_from(Some("0")), "`=0` is NOT off");
135 assert!(gemv_sw_from(Some("")), "empty is NOT off");
136 assert!(!gemv_sw_from(Some("1")), "`=1` is the kill");
137 }
138
139 #[test]
140 fn sw_requires_both_the_lever_and_a_live_handle() {
141 assert!(use_gemv_sw(true, KernelHandle(1)));
142 assert!(
143 !use_gemv_sw(true, KernelHandle(0)),
144 "missing kernel falls back"
145 );
146 assert!(!use_gemv_sw(false, KernelHandle(1)), "kill switch wins");
147 assert!(!use_gemv_sw(false, KernelHandle(0)));
148 }
149
150 #[test]
151 fn sw_grid_covers_every_output_and_is_half_base_when_n_divisible_by_8() {
152 for n in 1..=64 {
153 assert!(w4a16_gemv_sw_grid_x(n) * W4A16_GEMV_SW_OUTS_PER_BLOCK >= n);
154 assert!(w4a16_gemv_grid_x(n) * W4A16_GEMV_OUTS_PER_BLOCK >= n);
155 }
156 for n in [8u32, 16, 256, 5120, 14336] {
157 assert_eq!(
158 w4a16_gemv_sw_grid_x(n) * 2,
159 w4a16_gemv_grid_x(n),
160 "N={n}: SW is 8 outs/block, base is 4 — grid_x must be half"
161 );
162 }
163 }
164
165 #[test]
166 fn decode_dispatch_uses_the_selected_handle_and_matching_grid() {
167 for (lever, sw_handle, expected_handle, expected_grid_x) in [
168 (true, KernelHandle(22), 22, 2),
169 (false, KernelHandle(22), 11, 3),
170 (true, KernelHandle(0), 11, 3),
171 ] {
172 let gpu = MockGpuBackend::new();
173 w4a16_decode_gemv(
174 &gpu,
175 KernelHandle(11),
176 sw_handle,
177 lever,
178 DevicePtr::NULL,
179 &QuantizedWeight::null(),
180 DevicePtr::NULL,
181 9,
182 128,
183 0,
184 )
185 .unwrap();
186 let launches = gpu.launches_snapshot();
187 assert_eq!(launches.len(), 1);
188 assert_eq!(launches[0].func, expected_handle);
189 assert_eq!(launches[0].grid, [expected_grid_x, 1, 1]);
190 assert_eq!(launches[0].block, [256, 1, 1]);
191 }
192 }
193
194 fn kernel_root() -> PathBuf {
195 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../kernels")
196 }
197
198 fn named_cu(file_name: &str) -> Vec<PathBuf> {
199 fn visit(d: &Path, name: &str, out: &mut Vec<PathBuf>) {
200 let Ok(rd) = std::fs::read_dir(d) else { return };
201 for e in rd.flatten() {
202 let p = e.path();
203 if p.is_dir() {
204 visit(&p, name, out);
205 } else if p.file_name().is_some_and(|n| n == name) {
206 out.push(p);
207 }
208 }
209 }
210 let root = kernel_root();
211 let mut files = Vec::new();
212 visit(&root, file_name, &mut files);
213 files.sort();
214 files
215 }
216
217 #[test]
224 fn cuda_n_per_block_matches_rust_ssot() {
225 let gemv = named_cu("w4a16_gemv.cu");
226 assert!(
227 gemv.len() >= 3,
228 "expected gb10 + strix + strix-hip copies, got {gemv:?}"
229 );
230 let want_base = format!("#define N_PER_BLOCK {W4A16_GEMV_OUTS_PER_BLOCK}");
231 let want_sw = format!("#define N_PER_BLOCK_SW {W4A16_GEMV_SW_OUTS_PER_BLOCK}");
232 for p in &gemv {
233 let src = fs::read_to_string(p).unwrap();
234 assert!(
235 src.contains(&want_base),
236 "{} missing {want_base}",
237 p.display()
238 );
239 assert!(src.contains(&want_sw), "{} missing {want_sw}", p.display());
240 }
241 let fused = named_cu("w4a16_gemv_fused.cu");
242 assert!(
243 !fused.is_empty(),
244 "dual_sw / silu_input_sw live in w4a16_gemv_fused.cu"
245 );
246 for p in &fused {
247 let src = fs::read_to_string(p).unwrap();
248 assert!(src.contains(&want_sw), "{} missing {want_sw}", p.display());
249 }
250 }
251
252 #[test]
259 fn sw_partial_shares_pipelined_k16_loop() {
260 for p in named_cu("w4a16_gemv.cu") {
261 let src = fs::read_to_string(&p).unwrap();
262 assert!(
263 src.contains("orig_lane * 2u"),
264 "{}: w4a16_gemv_partial must start k16 at orig_lane*2",
265 p.display()
266 );
267 assert!(
268 src.contains("k16 < K16 + 1u"),
269 "{}: pipelined K16+1 bound missing",
270 p.display()
271 );
272 assert!(
273 !src.contains("k16 += 64u"),
274 "{}: stride-64 sequential loop drifted back in",
275 p.display()
276 );
277 }
278 for p in named_cu("w4a16_gemv_fused.cu") {
279 let src = fs::read_to_string(&p).unwrap();
280 assert!(
281 src.contains("w4a16_dual_partial"),
282 "{}: dual and dual_sw must share w4a16_dual_partial",
283 p.display()
284 );
285 assert!(
286 src.contains("orig_lane * 2u"),
287 "{}: dual_partial must start k16 at orig_lane*2",
288 p.display()
289 );
290 }
291 }
292
293 fn fn_signature_and_body<'a>(src: &'a str, sig: &str) -> (&'a str, &'a str) {
297 let start = src
298 .find(sig)
299 .unwrap_or_else(|| panic!("signature `{sig}` not found"));
300 let open = start
301 + src[start..]
302 .find('{')
303 .expect("no body brace after signature");
304 let mut depth = 0usize;
305 for (i, c) in src[open..].char_indices() {
306 match c {
307 '{' => depth += 1,
308 '}' => {
309 depth -= 1;
310 if depth == 0 {
311 return (&src[start..open], &src[open..=open + i]);
312 }
313 }
314 _ => {}
315 }
316 }
317 panic!("unbalanced braces after `{sig}`");
318 }
319
320 fn fn_body<'a>(src: &'a str, sig: &str) -> &'a str {
321 fn_signature_and_body(src, sig).1
322 }
323
324 const DECODE_PARTIALS: &[(&str, &str, &str, &[(&str, &str)])] = &[
327 (
328 "w4a16_gemv.cu",
329 "__device__ __forceinline__ float w4a16_gemv_partial(",
330 "E2M1_LUT",
331 &[("w4a16_gemv", "s_lut"), ("w4a16_gemv_sw", "warp_lut")],
332 ),
333 (
334 "w4a16_gemv_fused.cu",
335 "__device__ __forceinline__ float w4a16_dual_partial(",
336 "E2M1_LUT_FUSED_W4",
337 &[
338 ("w4a16_gemv_dual", "s_lut"),
339 ("w4a16_gemv_dual_sw", "warp_lut"),
340 ],
341 ),
342 (
343 "w4a16_gemv_fused.cu",
344 "__device__ __forceinline__ float w4a16_silu_partial(",
345 "E2M1_LUT_FUSED_W4",
346 &[("w4a16_gemv_silu_input_sw", "warp_lut")],
347 ),
348 ];
349
350 #[test]
369 fn decode_gemv_partials_index_a_shared_staged_lut() {
370 for &(file, sig, table, callers) in DECODE_PARTIALS {
371 let partial = sig
372 .strip_suffix('(')
373 .and_then(|sig| sig.split_whitespace().last())
374 .expect("partial signature ends in a function name");
375 let paths = named_cu(file);
376 assert!(paths.len() >= 3, "{file}: expected 3 backend copies");
377 for path in paths {
378 let src = fs::read_to_string(&path).unwrap();
379 let where_ = format!("{}::{sig}", path.display());
380 let (params, body) = fn_signature_and_body(&src, sig);
381 assert!(
382 !body.contains(&format!("{table}[byte_val")),
383 "{where_}: data-dependent index into __constant__ {table} \
384 serializes the warp — take the staged `lut` instead"
385 );
386 assert!(
387 body.contains("lut[byte_val"),
388 "{where_}: must dequant through the staged `lut` parameter"
389 );
390 assert!(
391 params.contains("const float* __restrict__ lut"),
392 "{where_}: must accept the staged table as `const float* __restrict__ lut`"
393 );
394 for &(caller, expected_lut) in callers {
395 let cb = fn_body(&src, &format!("void {caller}("));
396 assert!(
397 cb.contains("__shared__ float s_lut"),
398 "{}::{caller}: must stage the E2M1 table in shared memory",
399 path.display()
400 );
401 let calls: Vec<_> = cb
402 .lines()
403 .filter(|line| line.contains(&format!("{partial}(")))
404 .collect();
405 assert!(
406 !calls.is_empty(),
407 "{}::{caller}: no {partial} call",
408 path.display()
409 );
410 for call in calls {
411 assert!(
412 call.contains(&format!(", {expected_lut})")),
413 "{}::{caller}: `{partial}` must receive {expected_lut}, got `{}`",
414 path.display(),
415 call.trim()
416 );
417 assert!(
418 !call.contains(table),
419 "{}::{caller}: `{partial}` received constant-memory {table}",
420 path.display()
421 );
422 }
423 }
424 }
425 }
426 }
427
428 #[test]
447 fn sw_gemv_stages_the_lut_per_warp_without_a_block_barrier() {
448 let want_rows = "__shared__ float s_lut[N_PER_BLOCK_SW][16]";
449 for (file, kernels, helper) in [
450 (
451 "w4a16_gemv.cu",
452 &["w4a16_gemv_sw"][..],
453 "stage_e2m1_lut_warp",
454 ),
455 (
456 "w4a16_gemv_fused.cu",
457 &["w4a16_gemv_dual_sw", "w4a16_gemv_silu_input_sw"][..],
458 "stage_e2m1_lut_fused_warp",
459 ),
460 ] {
461 for path in named_cu(file) {
462 let src = fs::read_to_string(&path).unwrap();
463 let hb = fn_body(&src, &format!("void {helper}("));
464 assert!(
465 hb.contains("__syncwarp()"),
466 "{}::{helper}: warp-scoped staging must publish with __syncwarp()",
467 path.display()
468 );
469 for k in kernels {
470 let kb = fn_body(&src, &format!("void {k}("));
471 assert!(
472 kb.contains(want_rows),
473 "{}::{k}: per-warp LUT rows must be sized by N_PER_BLOCK_SW",
474 path.display()
475 );
476 assert!(
477 kb.contains(&format!("{helper}(s_lut[local_out], lane)")),
478 "{}::{k}: must stage its own warp row",
479 path.display()
480 );
481 assert!(
482 !kb.contains("__syncthreads()"),
483 "{}::{k}: block barrier after a warp-uniform early return is \
484 divergent UB — and it would undo the barrier-free reduction",
485 path.display()
486 );
487 }
488 }
489 }
490 }
491
492 #[test]
498 fn attention_decode_does_not_call_base_w4a16_gemv() {
499 let attn = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/layers/qwen3_attention");
500 let mut offenders = Vec::new();
501 for rel in [
502 "decode/attention_forward.rs",
503 "decode/attention_forward_v4.rs",
504 "decode/attention_forward_oproj.rs",
505 "decode/attention_forward_mla.rs",
506 "decode/attention_forward_kv.rs",
507 "trait_impl/multi_seq/qkv.rs",
508 "trait_impl/multi_seq/attn.rs",
509 "trait_impl/multi_seq/attn/o_proj.rs",
510 "trait_impl/multi_seq/mla.rs",
511 ] {
512 let src = fs::read_to_string(attn.join(rel)).unwrap();
513 if src.contains("w4a16_gemv(") {
514 offenders.push(rel);
515 }
516 }
517 assert!(
518 offenders.is_empty(),
519 "use nvfp4_decode_gemv (N/8 grid) not ops::w4a16_gemv: {offenders:?}"
520 );
521 }
522}