spark_model/layers/ops/gemm_fp8_prefill.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! FP8 prefill launchers: NVFP4->FP8 weight pre-dequant, BF16->FP8 activation
4//! cast, and the FP8-weight GEMMs. Split from `gemm_dense.rs` (500-LoC cap).
5
6#![allow(unused_imports)]
7
8use anyhow::Result;
9use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
10use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
11
12use crate::weight_map::{Fp8DenseWeight, QuantizedWeight};
13
14use super::*;
15
16/// Pre-dequanted FP8 GEMM (prefill): C = A @ B_fp8.
17///
18/// A: [M, K] BF16, B_fp8: [N, K] FP8 E4M3 (pre-dequanted from NVFP4), C: [M, N] BF16.
19/// Eliminates runtime NVFP4→FP8 dequant — only LOAD + FP8 MMA per K step.
20///
21/// Grid: (ceil(N/128), ceil(M/64), 1) Block: (128, 1, 1)
22#[allow(clippy::too_many_arguments)]
23pub fn fp8_gemm_n128(
24 gpu: &dyn GpuBackend,
25 kernel: KernelHandle,
26 input: DevicePtr,
27 b_fp8: DevicePtr,
28 output: DevicePtr,
29 m: u32,
30 n: u32,
31 k: u32,
32 stream: u64,
33) -> Result<()> {
34 // DEFAULT-ON: route the GDN-projection prefill GEMM through the ldmatrix.x4
35 // A+B kernel (fp8_fp8_gemm_ldmab). ncu-proven 2.1x over the scalar-load
36 // fp8_gemm_t, cosine 1.000000 vs fp8_fp8_gemm_t, and a confirmed e2e warm-TTFT
37 // win. Quantizes the bf16 activation to e4m3 once into a persistent scratch,
38 // then launches the ldmatrix GEMM. K must be a multiple of 32 (the ldmab
39 // K-tile). Opt-OUT with ATLAS_FP8_LDMAB=0 (falls through to the scalar path).
40 if k.is_multiple_of(32) && std::env::var("ATLAS_FP8_LDMAB").as_deref() != Ok("0") {
41 // Handles and scratch live on the backend, not in statics: the
42 // handles point into this model's registry modules (unloaded when it
43 // drops) and the scratch is an allocation in this model's context.
44 // Cached process-wide, the next model would launch a kernel from an
45 // unloaded module and write activations through a freed pointer.
46 let cache = gpu.op_cache();
47 let qk = cache.kernel(gpu, "w4a16", "bf16_to_fp8")?;
48 let lk = cache.kernel(gpu, "w4a16_fp8_ldmab", "fp8_fp8_gemm_ldmab")?;
49 let need = (m as usize) * (k as usize); // e4m3 bytes
50 let a8 = cache.scratch(gpu, "fp8_prefill_activation", need)?;
51 bf16_to_fp8(gpu, qk, input, a8, m * k, stream)?;
52 return KernelLaunch::new(gpu, lk)
53 .grid([div_ceil(n, 128), div_ceil(m, 128), 1])
54 .block([256, 1, 1])
55 .arg_ptr(a8)
56 .arg_ptr(b_fp8)
57 .arg_ptr(output)
58 .arg_u32(m)
59 .arg_u32(n)
60 .arg_u32(k)
61 .launch(stream);
62 }
63 KernelLaunch::new(gpu, kernel)
64 .grid([div_ceil(n, 128), div_ceil(m, 64), 1])
65 .block([128, 1, 1])
66 .arg_ptr(input)
67 .arg_ptr(b_fp8)
68 .arg_ptr(output)
69 .arg_u32(m)
70 .arg_u32(n)
71 .arg_u32(k)
72 .launch(stream)
73}
74
75/// Pre-dequant NVFP4 → FP8 E4M3. One-time conversion at model load.
76///
77/// Reads B_packed[N, K/2] + B_scale[N, K/GROUP_SIZE] + scale2 → B_fp8[N, K].
78///
79/// Grid: (ceil(N*K/2 / 256), 1, 1) Block: (256, 1, 1)
80#[allow(clippy::too_many_arguments)]
81/// `fp8_gemm_t_mfast`: same GEMM as [`fp8_gemm_n128`] with the CTA grid axes
82/// swapped so M is the fast axis. The M-blocks that share a B panel then run
83/// co-resident and read it from L2 instead of DRAM; see the kernel comment.
84pub fn fp8_gemm_n128_mfast(
85 gpu: &dyn GpuBackend,
86 kernel: KernelHandle,
87 input: DevicePtr,
88 b_fp8: DevicePtr,
89 output: DevicePtr,
90 m: u32,
91 n: u32,
92 k: u32,
93 stream: u64,
94) -> Result<()> {
95 KernelLaunch::new(gpu, kernel)
96 .grid([div_ceil(m, 64), div_ceil(n, 128), 1])
97 .block([128, 1, 1])
98 .arg_ptr(input)
99 .arg_ptr(b_fp8)
100 .arg_ptr(output)
101 .arg_u32(m)
102 .arg_u32(n)
103 .arg_u32(k)
104 .launch(stream)
105}
106
107/// `fp8_gemm_t_m128_mfast`: 128-row M tile (2 chunks/CTA), m on the fast axis.
108/// Halves the B panel passes relative to [`fp8_gemm_n128_mfast`].
109pub fn fp8_gemm_m128_mfast(
110 gpu: &dyn GpuBackend,
111 kernel: KernelHandle,
112 input: DevicePtr,
113 b_fp8: DevicePtr,
114 output: DevicePtr,
115 m: u32,
116 n: u32,
117 k: u32,
118 stream: u64,
119) -> Result<()> {
120 KernelLaunch::new(gpu, kernel)
121 .grid([div_ceil(m, 128), div_ceil(n, 128), 1])
122 .block([128, 1, 1])
123 .arg_ptr(input)
124 .arg_ptr(b_fp8)
125 .arg_ptr(output)
126 .arg_u32(m)
127 .arg_u32(n)
128 .arg_u32(k)
129 .launch(stream)
130}
131
132/// `fp8_fp8_gemm_t_m128_mfast`: FP8 A x FP8 B, 128-row M tile, m on the fast
133/// axis. A must already be E4M3 (see `bf16_to_fp8`); the MMA consumed E4M3
134/// either way, so pre-casting A is numerically identical to the BF16-A kernel.
135pub fn fp8_fp8_gemm_m128_mfast(
136 gpu: &dyn GpuBackend,
137 kernel: KernelHandle,
138 input: DevicePtr,
139 b_fp8: DevicePtr,
140 output: DevicePtr,
141 m: u32,
142 n: u32,
143 k: u32,
144 stream: u64,
145) -> Result<()> {
146 KernelLaunch::new(gpu, kernel)
147 .grid([div_ceil(m, 128), div_ceil(n, 128), 1])
148 .block([128, 1, 1])
149 .arg_ptr(input)
150 .arg_ptr(b_fp8)
151 .arg_ptr(output)
152 .arg_u32(m)
153 .arg_u32(n)
154 .arg_u32(k)
155 .launch(stream)
156}
157
158pub fn predequant_nvfp4_to_fp8(
159 gpu: &dyn GpuBackend,
160 kernel: KernelHandle,
161 b_packed: DevicePtr,
162 b_scale: DevicePtr,
163 scale2: f32,
164 b_fp8: DevicePtr,
165 n: u32,
166 k: u32,
167 stream: u64,
168) -> Result<()> {
169 let total = n * k / 2;
170 KernelLaunch::new(gpu, kernel)
171 .grid([div_ceil(total, 256), 1, 1])
172 .block([256, 1, 1])
173 .arg_ptr(b_packed)
174 .arg_ptr(b_scale)
175 .arg_f32(scale2)
176 .arg_ptr(b_fp8)
177 .arg_u32(n)
178 .arg_u32(k)
179 .launch(stream)
180}
181
182/// Convert BF16 activations to FP8 E4M3 for FP8×FP8 GEMM.
183///
184/// Grid: (ceil(total_elements/2 / 256), 1, 1) Block: (256, 1, 1)
185pub fn bf16_to_fp8(
186 gpu: &dyn GpuBackend,
187 kernel: KernelHandle,
188 src: DevicePtr,
189 dst: DevicePtr,
190 total_elements: u32,
191 stream: u64,
192) -> Result<()> {
193 let threads_needed = total_elements / 2;
194 KernelLaunch::new(gpu, kernel)
195 .grid([div_ceil(threads_needed, 256), 1, 1])
196 .block([256, 1, 1])
197 .arg_ptr(src)
198 .arg_ptr(dst)
199 .arg_u32(total_elements)
200 .launch(stream)
201}
202
203/// Quantize a BF16 weight matrix `[N, K]` to FP8 E4M3 `[N, K]` with per-row
204/// f32 scales `[N]`. One CTA per row, 256 threads — parallel absmax
205/// reduction over K, then per-element saturating cast to E4M3.
206///
207/// Called **once at model load time**, never on the decode hot path.
208///
209/// Phase G (DFlash drafter FP8): converts each BF16 q/k/v/o/gate/up/down
210/// weight at load time. Decode path then consumes the resulting
211/// `Fp8DenseWeight` via `fp8_gemm_n128`.
212///
213/// Kernel: `quantize_bf16_to_fp8(input, output, row_scales, N, K)` —
214/// `kernels/gb10/common/dense_gemv_fp8w.cu:36`.
215/// Grid: (N, 1, 1) Block: (256, 1, 1)
216#[allow(clippy::too_many_arguments)]
217pub fn quantize_bf16_to_fp8(
218 gpu: &dyn GpuBackend,
219 kernel: KernelHandle,
220 input: DevicePtr,
221 output: DevicePtr,
222 row_scales: DevicePtr,
223 n: u32,
224 k: u32,
225 stream: u64,
226) -> Result<()> {
227 KernelLaunch::new(gpu, kernel)
228 .grid([n, 1, 1])
229 .block([256, 1, 1])
230 .arg_ptr(input)
231 .arg_ptr(output)
232 .arg_ptr(row_scales)
233 .arg_u32(n)
234 .arg_u32(k)
235 .launch(stream)
236}
237
238/// Small-M row-scaled FP8 GEMM (M ≤ 16) — single warp per CTA variant.
239///
240/// Same math as [`fp8_gemm_n128_row_scaled`] but M_TILE=16 instead of 64,
241/// so all M rows are valid (no wasted MMA cycles on bounds-checked rows).
242/// Uses 32 threads per CTA (1 warp) instead of 128, so 4× fewer threads
243/// for the same useful work. Critical for the DFlash drafter lm_head
244/// where M=γ=16 vs N=vocab_size=248320.
245///
246/// Kernel: `fp8_gemm_t_row_scaled_m16(A, B_fp8, row_scale, C, M, N, K)`.
247/// Grid: (ceil(N/128), 1, 1) Block: (32, 1, 1)
248#[allow(clippy::too_many_arguments)]
249pub fn fp8_gemm_n128_row_scaled_m16(
250 gpu: &dyn GpuBackend,
251 kernel: KernelHandle,
252 input: DevicePtr,
253 weight: &Fp8DenseWeight,
254 output: DevicePtr,
255 m: u32,
256 n: u32,
257 k: u32,
258 stream: u64,
259) -> Result<()> {
260 KernelLaunch::new(gpu, kernel)
261 .grid([div_ceil(n, 128), 1, 1])
262 .block([32, 1, 1])
263 .arg_ptr(input)
264 .arg_ptr(weight.weight)
265 .arg_ptr(weight.row_scale)
266 .arg_ptr(output)
267 .arg_u32(m)
268 .arg_u32(n)
269 .arg_u32(k)
270 .launch(stream)
271}
272
273/// Row-scaled FP8 GEMM: `C[M, N] = A[M, K] @ (dequant(B_fp8[N, K]) * row_scale[N])`.
274///
275/// Same tiling and FP8 MMA as `fp8_gemm_n128` (BF16 × FP8 → BF16), with a
276/// per-column scale multiply before the BF16 write-out. Consumes the
277/// `Fp8DenseWeight` produced by [`crate::weight_map::DenseWeight::quantize_to_fp8`]
278/// — the per-row scale on `Fp8DenseWeight` matches the kernel's
279/// `row_scale` parameter.
280///
281/// Phase G (DFlash drafter FP8) hot-path GEMM. Replaces `dense_gemm` on
282/// the seven dense-GEMM call sites in `forward_block_layer_pre_attn` /
283/// `_post_attn` when `self.quant == DflashQuantization::Fp8Weights`.
284///
285/// Kernel: `fp8_gemm_t_row_scaled(A, B_fp8, row_scale, C, M, N, K)` —
286/// `kernels/gb10/qwen3.6-27b/nvfp4/w4a16_gemm.cu`.
287/// Grid: (ceil(N/128), ceil(M/64), 1) Block: (128, 1, 1)
288#[allow(clippy::too_many_arguments)]
289pub fn fp8_gemm_n128_row_scaled(
290 gpu: &dyn GpuBackend,
291 kernel: KernelHandle,
292 input: DevicePtr,
293 weight: &Fp8DenseWeight,
294 output: DevicePtr,
295 m: u32,
296 n: u32,
297 k: u32,
298 stream: u64,
299) -> Result<()> {
300 KernelLaunch::new(gpu, kernel)
301 .grid([div_ceil(n, 128), div_ceil(m, 64), 1])
302 .block([128, 1, 1])
303 .arg_ptr(input)
304 .arg_ptr(weight.weight)
305 .arg_ptr(weight.row_scale)
306 .arg_ptr(output)
307 .arg_u32(m)
308 .arg_u32(n)
309 .arg_u32(k)
310 .launch(stream)
311}