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}