spark_model/layers/ops/nvfp4_mmq.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// Launchers for the vendored llama NVFP4 W4A4 MMQ FFN prefill GEMM (ATLAS_FFN_NVFP4_MMQ).
4// Kernels in kernels/gb10/qwen3.6-27b/nvfp4/nvfp4_mmq.cu (Blackwell block-scale MMA
5// kind::mxf4nvf4.m16n8k64, e2m1×e2m1, ue4m3 group-16 scales).
6// Microbench (GB10, M=4096): gate/up 80.2 TFLOP/s, down 79.7 — vs w4a16 t_m128 ~51 (1.57x).
7// Correctness: rel_err 1.6e-3 vs same-quant CPU ref (= bf16-output rounding); the hardware
8// decodes ue4m3 scales as STANDARD e4m3 on both operands, so the checkpoint's per-16 scale
9// bytes are byte-copy correct and the only missing factor is the per-tensor FP32 scale2 —
10// folded by the caller in atlas_nvfp4_silu_mul_scaled (empirical ratio 0.99 ≈ 1.0, see
11// scratchpad nvfp4_mmq_bench.cu).
12// Pipeline: weights repacked ONCE at load (raw bit shuffle, checkpoint layout →
13// block_nvfp4); per prefill: activations bf16 → block_fp4_mmq (shared ffn_act_q8 scratch),
14// then MMQ → bf16 out; scale2 folded in the SiLU-mul.
15use anyhow::Result;
16use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
17use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
18
19/// NVFP4 block: 64 weights -> 36-byte block_nvfp4 {4×ue4m3 scales, 32B e2m1 nibbles}.
20pub const QK_NVFP4: u32 = 64;
21pub const NVFP4_BLOCK_BYTES: usize = 36;
22/// block_fp4_mmq (activation y): 256 values -> 144 bytes (== block_q8_1_mmq).
23const FP4_MMQ_Y_BLOCK_VALS: u32 = 256;
24const FP4_MMQ_Y_BLOCK_BYTES: usize = 144;
25/// Dynamic shared memory: ids(512) + x-tile(128*MMQ_MMA_TILE_X_K_FP4=76*4) + y-tile(128*144).
26pub const NVFP4_MMQ_SMEM: u32 = nvfp4_mmq_smem(128);
27
28/// Dynamic shared memory for a given M-tile, from the vendor's layout:
29/// ids_dst\[mmq_x\] + y-tile\[mmq_x * MMQ_TILE_Y_K(=36) ints, padded to 256\] +
30/// x-tile\[128 * MMQ_MMA_TILE_X_K_FP4(=76) ints\], all 4-byte.
31/// Reproduces the previously-hardcoded 57856 at mmq_x=128, which is the check that
32/// this derivation matches the kernel's actual layout.
33pub const fn nvfp4_mmq_smem(mmq_x: u32) -> u32 {
34 let y = mmq_x * 36;
35 let y_padded = y.div_ceil(256) * 256;
36 4 * (mmq_x + y_padded + 128 * 76)
37}
38const QUANT_BLOCK_THREADS: u32 = 128;
39
40/// Bytes for the block_nvfp4 form of an [n, k] weight (k % 64 == 0).
41pub fn nvfp4_mmq_weight_bytes(n: u32, k: u32) -> usize {
42 (n as usize) * (k as usize / QK_NVFP4 as usize) * NVFP4_BLOCK_BYTES
43}
44
45/// block_fp4_mmq activation scratch bytes for [m, k]. +1MB slack: the kernel's smem copy
46/// loop rounds the last y-slice read up to warp granularity (same convention as
47/// q8_1_scratch_bytes). Always ≤ q8_1_scratch_bytes(m, k) → fits the shared ffn_act_q8.
48pub fn fp4_act_scratch_bytes(m: u32, k: u32) -> usize {
49 let bpc = div_ceil(k, FP4_MMQ_Y_BLOCK_VALS) as usize;
50 (m as usize) * bpc * FP4_MMQ_Y_BLOCK_BYTES + (1 << 20)
51}
52
53/// Repack a checkpoint NVFP4 weight (packed E2M1 [n, k/2] low=even/high=odd + E4M3
54/// [n, k/16] scales) into llama block_nvfp4 rows \[n\]\[k/64\]. Raw bit shuffle — the e2m1
55/// codes and e4m3 scale bytes are reused verbatim (scale2 folded downstream).
56pub fn nvfp4_mmq_repack(
57 gpu: &dyn GpuBackend,
58 kernel: KernelHandle, // atlas_nvfp4_repack
59 packed: DevicePtr,
60 scales: DevicePtr,
61 out_blocks: DevicePtr,
62 n: u32,
63 k: u32,
64 stream: u64,
65) -> Result<()> {
66 let nblocks = (n as u64) * (k as u64 / QK_NVFP4 as u64);
67 KernelLaunch::new(gpu, kernel)
68 .grid([div_ceil(nblocks as u32, 256), 1, 1])
69 .block([256, 1, 1])
70 .arg_ptr(packed)
71 .arg_ptr(scales)
72 .arg_ptr(out_blocks)
73 .arg_u32(n)
74 .arg_u32(k)
75 .launch(stream)
76}
77
78/// Quantize bf16 activations [m, k] -> block_fp4_mmq (e2m1 + ue4m3 group-16, ±2 scale
79/// search) into `out_y`. One thread per 16-value group; ne0 padded to 256.
80pub fn nvfp4_mmq_quantize_act(
81 gpu: &dyn GpuBackend,
82 kernel: KernelHandle, // atlas_nvfp4_quantize_bf16
83 input_bf16: DevicePtr,
84 out_y: DevicePtr,
85 m: u32,
86 k: u32,
87 stream: u64,
88) -> Result<()> {
89 let kpad = div_ceil(k, FP4_MMQ_Y_BLOCK_VALS) * FP4_MMQ_Y_BLOCK_VALS;
90 let grid_y = div_ceil(kpad, 16 * QUANT_BLOCK_THREADS);
91 KernelLaunch::new(gpu, kernel)
92 .grid([m, grid_y, 1])
93 .block([QUANT_BLOCK_THREADS, 1, 1])
94 .arg_ptr(input_bf16)
95 .arg_ptr(out_y)
96 .arg_u64(k as u64) // ne00
97 .arg_u64(k as u64) // s01 (contiguous rows)
98 .arg_u64(kpad as u64) // ne0
99 .arg_u32(m) // ne1
100 .launch(stream)
101}
102
103/// NVFP4 W4A4 MMQ GEMM: C\[m,n\] (bf16, missing ×scale2) = A_fp4\[m,k\] x W_nvfp4\[n,k\].
104pub fn nvfp4_mmq_gemm(
105 gpu: &dyn GpuBackend,
106 kernel_nc: KernelHandle, // atlas_nvfp4_mmq128_nc
107 kernel_wc: KernelHandle, // atlas_nvfp4_mmq128_wc
108 a_fp4: DevicePtr, // block_fp4_mmq activations
109 w_nvfp4: DevicePtr, // block_nvfp4 weights [n, k]
110 out_bf16: DevicePtr,
111 m: u32,
112 n: u32,
113 k: u32,
114 stream: u64,
115) -> Result<()> {
116 let kernel = if !n.is_multiple_of(128) {
117 kernel_wc
118 } else {
119 kernel_nc
120 };
121 KernelLaunch::new(gpu, kernel)
122 .grid([div_ceil(n, 128), div_ceil(m, 128), 1])
123 .block([32, 8, 1])
124 .shared_mem(NVFP4_MMQ_SMEM)
125 .arg_ptr(w_nvfp4) // x = weights
126 .arg_ptr(a_fp4) // y = fp4 activations
127 .arg_ptr(out_bf16) // dst
128 .arg_u32(n) // nrows_x
129 .arg_u32(m) // ncols_dst
130 .arg_u32(k) // ncols_x
131 .arg_u32(k / QK_NVFP4) // stride_row_x = K/64
132 .arg_u32(m) // ncols_y
133 .arg_u32(n) // stride_col_dst
134 .launch(stream)
135}
136
137/// NVFP4 W4A4 MMQ GEMM with an M-SIZED TILE.
138///
139/// Same kernel family as [`nvfp4_mmq_gemm`], but the caller picks the M tile. The
140/// 128-wide tile issues MMAs for all 128 tile columns regardless of `m` and discards
141/// the surplus in the write-back predicate, so decode at m=16 wasted 87.5% of its MMA
142/// slots. `mmq_x` must be one of {16, 32, 128} — the instantiated entries.
143///
144/// PREFILL MUST KEEP 128: `grid.y = ceil(m / mmq_x)`, so a small tile re-streams the
145/// whole weight matrix once per M-tile. This is a decode-shape optimisation only.
146#[allow(clippy::too_many_arguments)]
147pub fn nvfp4_mmq_gemm_tiled(
148 gpu: &dyn GpuBackend,
149 kernel_nc: KernelHandle,
150 kernel_wc: KernelHandle,
151 mmq_x: u32,
152 a_fp4: DevicePtr,
153 w_nvfp4: DevicePtr,
154 out_bf16: DevicePtr,
155 m: u32,
156 n: u32,
157 k: u32,
158 stream: u64,
159) -> Result<()> {
160 debug_assert!(
161 matches!(mmq_x, 16 | 32 | 64 | 128),
162 "mmq_x must be an instantiated tile"
163 );
164 debug_assert!(
165 m <= mmq_x,
166 "grid.y>1 would re-stream the weights per M-tile"
167 );
168 let kernel = if !n.is_multiple_of(128) {
169 kernel_wc
170 } else {
171 kernel_nc
172 };
173 KernelLaunch::new(gpu, kernel)
174 .grid([div_ceil(n, 128), div_ceil(m, mmq_x), 1])
175 .block([32, 8, 1])
176 .shared_mem(nvfp4_mmq_smem(mmq_x))
177 .arg_ptr(w_nvfp4)
178 .arg_ptr(a_fp4)
179 .arg_ptr(out_bf16)
180 .arg_u32(n)
181 .arg_u32(m)
182 .arg_u32(k)
183 .arg_u32(k / QK_NVFP4)
184 .arg_u32(m)
185 .arg_u32(n)
186 .launch(stream)
187}
188/// Fused SiLU-mul + block_fp4_mmq quantize for the down-MMQ path: reads RAW gate/up MMQ
189/// outputs, applies the scale2 folds + swiglu clamp + SiLU-mul, and quantizes straight
190/// into the down GEMM's y-format — the intermediate bf16 activation tensor is never
191/// written (this round-trip is why the unfused down arm measured neutral).
192#[allow(clippy::too_many_arguments)]
193pub fn nvfp4_silu_mul_quant(
194 gpu: &dyn GpuBackend,
195 kernel: KernelHandle, // atlas_nvfp4_silu_mul_quant
196 gate: DevicePtr,
197 up: DevicePtr,
198 out_y: DevicePtr,
199 gate_scale: f32,
200 up_scale: f32,
201 m: u32,
202 k: u32, // inter
203 stream: u64,
204) -> Result<()> {
205 let kpad = div_ceil(k, FP4_MMQ_Y_BLOCK_VALS) * FP4_MMQ_Y_BLOCK_VALS;
206 let grid_y = div_ceil(kpad, 16 * QUANT_BLOCK_THREADS);
207 KernelLaunch::new(gpu, kernel)
208 .grid([m, grid_y, 1])
209 .block([QUANT_BLOCK_THREADS, 1, 1])
210 .arg_ptr(gate)
211 .arg_ptr(up)
212 .arg_ptr(out_y)
213 .arg_f32(gate_scale)
214 .arg_f32(up_scale)
215 .arg_u64(k as u64) // ne00
216 .arg_u64(kpad as u64) // ne0
217 .arg_u32(m) // ne1
218 .launch(stream)
219}
220
221/// In-place ×scale2 for the down-projection MMQ output ([m, h] bf16).
222pub fn nvfp4_scale_bf16(
223 gpu: &dyn GpuBackend,
224 kernel: KernelHandle, // atlas_nvfp4_scale_bf16
225 data: DevicePtr,
226 scale: f32,
227 total: u32,
228 stream: u64,
229) -> Result<()> {
230 KernelLaunch::new(gpu, kernel)
231 .grid([div_ceil(total, 256), 1, 1])
232 .block([256, 1, 1])
233 .arg_ptr(data)
234 .arg_f32(scale)
235 .arg_u32(total)
236 .launch(stream)
237}
238
239/// SiLU(gate×gs)×(up×us) with the per-projection scale2 fold (swiglu ±10 clamp,
240/// mirrors moe_silu_mul). In-place safe (out may alias gate).
241#[allow(clippy::too_many_arguments)]
242pub fn nvfp4_silu_mul_scaled(
243 gpu: &dyn GpuBackend,
244 kernel: KernelHandle, // atlas_nvfp4_silu_mul_scaled
245 gate: DevicePtr,
246 up: DevicePtr,
247 out: DevicePtr,
248 gate_scale: f32,
249 up_scale: f32,
250 total: u32,
251 stream: u64,
252) -> Result<()> {
253 KernelLaunch::new(gpu, kernel)
254 .grid([div_ceil(total, 256), 1, 1])
255 .block([256, 1, 1])
256 .arg_ptr(gate)
257 .arg_ptr(up)
258 .arg_ptr(out)
259 .arg_f32(gate_scale)
260 .arg_f32(up_scale)
261 .arg_u32(total)
262 .launch(stream)
263}