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}