spark_model/layers/ops/fp8_gemv_batch.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! FP8-weight dual-GEMV (batch=2) dispatch.
4//!
5//! `dense_gemv_fp8w_batch2` computes two output rows from one pass over the
6//! FP8 weight matrix — the batch=2 sibling of `dense_gemv_fp8w`. It halves
7//! FP8 weight bandwidth vs two M=1 GEMV launches and is bit-identical to
8//! running `dense_gemv_fp8w` twice (per-token reduction order unchanged).
9//! Used by the K=2 MTP verify path where the two verify positions share
10//! weights but have distinct activations (lm_head, attention Q/K/V/O, SSM
11//! out_proj).
12
13use anyhow::{Result, ensure};
14use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
15use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
16
17use crate::weight_map::Fp8DenseWeight;
18
19/// Register-tiled batched row-scaled FP8 GEMV (M<=8, T=2 outputs/thread) —
20/// the FP8 twin of `w4a16_gemv_batch8_rt2`, for the DFlash drafter PROPOSE
21/// path. `input` `[M, K]` BF16, `output` `[M, N]` BF16; per-row f32 scale
22/// applied at write-out inside the kernel. Replaces the prefill-class tile
23/// GEMMs (`fp8_gemm_t_row_scaled` M64-tile / `_m16`) that pad 87%/50% of
24/// their M-tile at M=8 (~100 GB/s measured vs 180+ for the rt family).
25/// Drafter-side numerics: correctness-free under strict-argmax accept.
26/// Kernel: `fp8_gemv_rowscale_batch8_rt2` (module `fp8_gemv_rt`).
27/// Grid: (ceil(N/8), 1, 1) Block: (256, 1, 1). Requires K % 16 == 0.
28#[allow(clippy::too_many_arguments)]
29pub fn fp8_gemv_rowscale_batch8_rt2(
30 gpu: &dyn GpuBackend,
31 kernel: KernelHandle,
32 input: DevicePtr,
33 weight: &Fp8DenseWeight,
34 output: DevicePtr,
35 m: u32,
36 n: u32,
37 k: u32,
38 stream: u64,
39) -> Result<()> {
40 ensure!(
41 (1..=8).contains(&m),
42 "fp8_gemv_rowscale_batch8_rt2: m={m} outside 1..=8 (kernel MAX_M)"
43 );
44 ensure!(
45 k.is_multiple_of(16),
46 "fp8_gemv_rowscale_batch8_rt2: K={k} not a multiple of 16"
47 );
48 KernelLaunch::new(gpu, kernel)
49 .grid([div_ceil(n, 8), 1, 1])
50 .block([256, 1, 1])
51 .arg_ptr(input)
52 .arg_ptr(weight.weight)
53 .arg_ptr(weight.row_scale)
54 .arg_ptr(output)
55 .arg_u32(m)
56 .arg_u32(n)
57 .arg_u32(k)
58 .launch(stream)
59}
60
61/// MAX_M=16 sibling of [`fp8_gemv_rowscale_batch8_rt2`] for the γ>8 DFlash
62/// propose window (flags 9..17). Same template, same launch geometry; added
63/// 2026-08-29 after STEP_TIMING measured propose 18.2ms (flag 8, rt2) vs
64/// 38.0ms (flag 9, tile fallback) — the whole γ>8 step tax.
65/// Kernel: `fp8_gemv_rowscale_batch16_rt2` (module `fp8_gemv_rt`).
66#[allow(clippy::too_many_arguments)]
67pub fn fp8_gemv_rowscale_batch16_rt2(
68 gpu: &dyn GpuBackend,
69 kernel: KernelHandle,
70 input: DevicePtr,
71 weight: &Fp8DenseWeight,
72 output: DevicePtr,
73 m: u32,
74 n: u32,
75 k: u32,
76 stream: u64,
77) -> Result<()> {
78 ensure!(
79 (1..=16).contains(&m),
80 "fp8_gemv_rowscale_batch16_rt2: m={m} outside 1..=16 (kernel MAX_M)"
81 );
82 ensure!(
83 k.is_multiple_of(16),
84 "fp8_gemv_rowscale_batch16_rt2: K={k} not a multiple of 16"
85 );
86 KernelLaunch::new(gpu, kernel)
87 .grid([div_ceil(n, 8), 1, 1])
88 .block([256, 1, 1])
89 .arg_ptr(input)
90 .arg_ptr(weight.weight)
91 .arg_ptr(weight.row_scale)
92 .arg_ptr(output)
93 .arg_u32(m)
94 .arg_u32(n)
95 .arg_u32(k)
96 .launch(stream)
97}
98
99/// FP8-weight dual-GEMV. `input` is `[2, K]` BF16, `output` is `[2, N]` BF16.
100/// Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
101pub fn dense_gemv_fp8w_batch2(
102 gpu: &dyn GpuBackend,
103 kernel: KernelHandle,
104 input: DevicePtr,
105 weight: &Fp8DenseWeight,
106 output: DevicePtr,
107 n: u32,
108 k: u32,
109 stream: u64,
110) -> Result<()> {
111 KernelLaunch::new(gpu, kernel)
112 .grid([div_ceil(n, 4), 1, 1])
113 .block([256, 1, 1])
114 .arg_ptr(input)
115 .arg_ptr(weight.weight)
116 .arg_ptr(weight.row_scale)
117 .arg_ptr(output)
118 .arg_u32(n)
119 .arg_u32(k)
120 .launch(stream)
121}
122
123/// The shared shape of `w8a16_gemv_batch4` / `w8a16_gemv_batch16` (contiguous
124/// A and C), so a caller that picks its MAX_M tier by row count can hold the
125/// wrapper and the handle as one pair instead of duplicating the call site.
126/// The `_strided` pair's sibling alias lives with its own callers.
127pub type ContiguousBatchGemv = fn(
128 &dyn GpuBackend,
129 KernelHandle,
130 DevicePtr,
131 DevicePtr,
132 DevicePtr,
133 DevicePtr,
134 u32,
135 u32,
136 u32,
137 u64,
138) -> Result<()>;
139
140/// Block-scaled FP8 batched GEMV (M<=4). `input` is `[M, K]` BF16, `output` is
141/// `[M, N]` BF16; `weight`/`block_scale` are the raw `w8a16_gemv` pointers (2D
142/// block-scaled FP8). One pass over the FP8 weight serves all M rows — the M=4
143/// sibling of `w8a16_gemv`, replacing `w8a16_gemm_pipelined` for n<=4 batched
144/// decode (which pads M to a 128-row MMA tile). Bit-identical per-row to
145/// `w8a16_gemv`. Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
146///
147/// REFUSES m>4. The kernel is `w8a16_gemv_batchm_impl<4>`: at M=5 it computes
148/// rows 0..3 and never writes rows 4.. — stale memory, not a launch failure.
149/// Callers with 5..=16 rows want [`w8a16_gemv_batch16`], which takes the same
150/// arguments and the same launch geometry (issue #927).
151#[allow(clippy::too_many_arguments)]
152pub fn w8a16_gemv_batch4(
153 gpu: &dyn GpuBackend,
154 kernel: KernelHandle,
155 input: DevicePtr,
156 weight: DevicePtr,
157 block_scale: DevicePtr,
158 output: DevicePtr,
159 m: u32,
160 n: u32,
161 k: u32,
162 stream: u64,
163) -> Result<()> {
164 ensure!(
165 (1..=4).contains(&m),
166 "w8a16_gemv_batch4: m={m} outside 1..=4 (kernel MAX_M; use w8a16_gemv_batch16)"
167 );
168 contiguous_batch_launch(
169 gpu,
170 kernel,
171 input,
172 weight,
173 block_scale,
174 output,
175 m,
176 n,
177 k,
178 stream,
179 )
180}
181
182/// MAX_M=16 sibling of [`w8a16_gemv_batch4`], for decode concurrency 5..=16.
183///
184/// WHY (#927). On 1xH100 with Qwen/Qwen3.8-27B-FP8 the decode step measured
185/// 44 ms at 4 active rows and 224 ms at 16 — C=16 aggregate FELL from 76 to
186/// 62 tok/s when the batch cap went 4 -> 16, because every native-FP8 site
187/// stopped at the M<=4 GEMV and handed 5..16 rows to the transposed /
188/// pipelined tile GEMMs (5-12 TFLOP/s class, M padded to a 128-row MMA tile).
189/// This kernel streams the weight ONCE for up to 16 rows instead.
190///
191/// Same template body, same K-iteration order and the same per-row reduction
192/// tree as `w8a16_gemv_batch4`, so each row is bit-identical to the scalar
193/// `w8a16_gemv` (H100 receipt on #932: M=8/16 `unequal_bf16=0`). The wider
194/// register array is the only difference.
195///
196/// Kernel: `w8a16_gemv_batch16` (module `w8a16_gemv_batch4`).
197/// Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
198#[allow(clippy::too_many_arguments)]
199pub fn w8a16_gemv_batch16(
200 gpu: &dyn GpuBackend,
201 kernel: KernelHandle,
202 input: DevicePtr,
203 weight: DevicePtr,
204 block_scale: DevicePtr,
205 output: DevicePtr,
206 m: u32,
207 n: u32,
208 k: u32,
209 stream: u64,
210) -> Result<()> {
211 ensure!(
212 (1..=16).contains(&m),
213 "w8a16_gemv_batch16: m={m} outside 1..=16 (kernel MAX_M)"
214 );
215 contiguous_batch_launch(
216 gpu,
217 kernel,
218 input,
219 weight,
220 block_scale,
221 output,
222 m,
223 n,
224 k,
225 stream,
226 )
227}
228
229/// Shared launch body for the two contiguous entry points. Identical argument
230/// order and geometry — the only thing that differs above is the MAX_M bound
231/// the caller must respect, exactly as for the `_strided` pair below.
232#[allow(clippy::too_many_arguments)]
233fn contiguous_batch_launch(
234 gpu: &dyn GpuBackend,
235 kernel: KernelHandle,
236 input: DevicePtr,
237 weight: DevicePtr,
238 block_scale: DevicePtr,
239 output: DevicePtr,
240 m: u32,
241 n: u32,
242 k: u32,
243 stream: u64,
244) -> Result<()> {
245 KernelLaunch::new(gpu, kernel)
246 .grid([div_ceil(n, 4), 1, 1])
247 .block([256, 1, 1])
248 .arg_ptr(input)
249 .arg_ptr(weight)
250 .arg_ptr(block_scale)
251 .arg_ptr(output)
252 .arg_u32(m)
253 .arg_u32(n)
254 .arg_u32(k)
255 .launch(stream)
256}
257
258/// Block-scaled FP8 dual-GEMV (batch=2). `input` is `[2, K]` BF16, `output` is
259/// `[2, N]` BF16; `weight`/`block_scale` are the raw `w8a16_gemv` pointers.
260/// Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
261#[allow(clippy::too_many_arguments)]
262pub fn w8a16_gemv_batch2(
263 gpu: &dyn GpuBackend,
264 kernel: KernelHandle,
265 input: DevicePtr,
266 weight: DevicePtr,
267 block_scale: DevicePtr,
268 output: DevicePtr,
269 n: u32,
270 k: u32,
271 stream: u64,
272) -> Result<()> {
273 KernelLaunch::new(gpu, kernel)
274 .grid([div_ceil(n, 4), 1, 1])
275 .block([256, 1, 1])
276 .arg_ptr(input)
277 .arg_ptr(weight)
278 .arg_ptr(block_scale)
279 .arg_ptr(output)
280 .arg_u32(n)
281 .arg_u32(k)
282 .launch(stream)
283}
284
285/// Strided sibling of [`w8a16_gemv_batch4`] (M<=4).
286///
287/// WHY: the multi-sequence decode Q/K/V buffer is `[n, per_seq_qkv]` with Q at
288/// offset 0, K after Q and V after K inside every row, so the contiguous
289/// `[M, N]` writer cannot address one projection across rows. Without a
290/// strided writer the native-FP8 attention projections fell back to three
291/// scalar `w8a16_gemv` launches PER ROW at decode concurrency 2..=8 — the
292/// third-largest bucket in the C=4 decode profile (issue #927). This writes one
293/// projection for all M rows in ONE launch.
294///
295/// LAYOUT: `input` `[M, a_row_stride]` BF16, only the first `k` elements of
296/// each row read; `weight`/`block_scale` are the raw `w8a16_gemv` pointers
297/// (`[N, K]` FP8 E4M3 and `[N/128, K/128]` FP32); `output`
298/// `[M, c_row_stride]` BF16, only the first `n` elements of each row written.
299/// Both strides are in ELEMENTS. `a_row_stride` must keep each activation row
300/// 16-byte aligned (multiple of 8) — the kernel's activation loads are `uint4`.
301///
302/// Bit-identical per row to `w8a16_gemv`: same template body, same K-iteration
303/// order and same reduction tree as [`w8a16_gemv_batch4`]; only the row pitches
304/// change. Verified by `examples/native_fp8_qkv_batch_microtest`.
305///
306/// Kernel: `w8a16_gemv_batch4_strided` (module `w8a16_gemv_batch4`).
307/// Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
308#[allow(clippy::too_many_arguments)]
309pub fn w8a16_gemv_batch4_strided(
310 gpu: &dyn GpuBackend,
311 kernel: KernelHandle,
312 input: DevicePtr,
313 weight: DevicePtr,
314 block_scale: DevicePtr,
315 output: DevicePtr,
316 m: u32,
317 n: u32,
318 k: u32,
319 a_row_stride: u32,
320 c_row_stride: u32,
321 stream: u64,
322) -> Result<()> {
323 ensure!(
324 (1..=4).contains(&m),
325 "w8a16_gemv_batch4_strided: m={m} outside 1..=4 (kernel MAX_M)"
326 );
327 strided_batch_launch(
328 gpu,
329 kernel,
330 input,
331 weight,
332 block_scale,
333 output,
334 m,
335 n,
336 k,
337 a_row_stride,
338 c_row_stride,
339 stream,
340 )
341}
342
343/// MAX_M=16 sibling of [`w8a16_gemv_batch4_strided`], for decode concurrency
344/// 5..=16. Same template, same launch geometry, same per-row accumulation
345/// order; the wider register array is the only difference.
346///
347/// Kernel: `w8a16_gemv_batch16_strided` (module `w8a16_gemv_batch4`).
348/// Grid: (ceil(N/4), 1, 1) Block: (256, 1, 1)
349#[allow(clippy::too_many_arguments)]
350pub fn w8a16_gemv_batch16_strided(
351 gpu: &dyn GpuBackend,
352 kernel: KernelHandle,
353 input: DevicePtr,
354 weight: DevicePtr,
355 block_scale: DevicePtr,
356 output: DevicePtr,
357 m: u32,
358 n: u32,
359 k: u32,
360 a_row_stride: u32,
361 c_row_stride: u32,
362 stream: u64,
363) -> Result<()> {
364 ensure!(
365 (1..=16).contains(&m),
366 "w8a16_gemv_batch16_strided: m={m} outside 1..=16 (kernel MAX_M)"
367 );
368 strided_batch_launch(
369 gpu,
370 kernel,
371 input,
372 weight,
373 block_scale,
374 output,
375 m,
376 n,
377 k,
378 a_row_stride,
379 c_row_stride,
380 stream,
381 )
382}
383
384/// Shared launch body for the two `_strided` entry points — identical argument
385/// order and geometry, so the only thing that differs above is the MAX_M bound
386/// the caller must respect.
387#[allow(clippy::too_many_arguments)]
388fn strided_batch_launch(
389 gpu: &dyn GpuBackend,
390 kernel: KernelHandle,
391 input: DevicePtr,
392 weight: DevicePtr,
393 block_scale: DevicePtr,
394 output: DevicePtr,
395 m: u32,
396 n: u32,
397 k: u32,
398 a_row_stride: u32,
399 c_row_stride: u32,
400 stream: u64,
401) -> Result<()> {
402 ensure!(
403 a_row_stride >= k && c_row_stride >= n,
404 "w8a16_gemv batch strided: row pitches (a={a_row_stride}, c={c_row_stride}) \
405 must cover the used extents (k={k}, n={n})"
406 );
407 ensure!(
408 a_row_stride.is_multiple_of(8),
409 "w8a16_gemv batch strided: a_row_stride={a_row_stride} must keep rows \
410 16B-aligned (uint4 activation loads)"
411 );
412 KernelLaunch::new(gpu, kernel)
413 .grid([div_ceil(n, 4), 1, 1])
414 .block([256, 1, 1])
415 .arg_ptr(input)
416 .arg_ptr(weight)
417 .arg_ptr(block_scale)
418 .arg_ptr(output)
419 .arg_u32(m)
420 .arg_u32(n)
421 .arg_u32(k)
422 .arg_u32(a_row_stride)
423 .arg_u32(c_row_stride)
424 .launch(stream)
425}