spark_model/weight_map/quantize_fp8_bs.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Runtime BF16 → FP8 (E4M3) weight quantization with 128×128 block scales.
4//!
5//! The third runtime quantizer, and the one that exists for MoE experts.
6//! Atlas already had:
7//! - BF16 → NVFP4 (`quantize_to_nvfp4`) — 4 bits, what `Bf16Raw` models get
8//! - BF16 → FP8 per-ROW (`quantize_bf16_to_fp8`) — for the DFlash head
9//!
10//! Neither fits routed experts on a plain-BF16 checkpoint. NVFP4 costs real
11//! output quality, and the FP8 grouped MoE GEMM
12//! (`moe_fp8_grouped_gemm.cu`) reads BLOCK scales `[N/128, K/128]` FP32,
13//! not per-row ones. Feeding it a per-row buffer is not a shape error the
14//! kernel can detect — it would index a shorter array and silently dequant
15//! with the wrong scale, which is why `Fp8Weight::scale_format` is tagged
16//! and asserted at dispatch.
17//!
18//! Why block-scaled FP8 rather than BF16 for experts: on LongCat-Flash-Lite
19//! the routed experts are 63.0 GB of the 70.2 GB resident. BF16 does not fit
20//! (+47 GB against a 97.3 GB budget at 0.80 util); FP8 does (+15.75 GB). And
21//! because MoE reads only top-12 of 256 experts per token, the DECODE cost is
22//! +0.74 GB/token — less than either of the dense BF16 levers.
23
24use anyhow::Result;
25use spark_runtime::gpu::GpuBackend;
26use spark_runtime::kernel_args::KernelLaunch;
27
28use super::{DenseWeight, Fp8Weight, WeightQuantFormat};
29
30/// Elements per block scale, both axes. Must match `FP8_BLOCK` in
31/// `moe_fp8_grouped_gemm.cu` — the consumer hardcodes 128.
32const FP8_BLOCK: usize = 128;
33
34/// Quantize an `[n, k]` BF16 dense weight to block-scaled FP8 E4M3 on GPU.
35///
36/// Returns a `Fp8Weight` tagged `Fp8BlockScaled`, laid out exactly as the
37/// on-disk Qwen FP8 releases are after widening, so every consumer that
38/// already accepts those accepts this with no change.
39///
40/// Called once per projection at load time, never on the hot path. The BF16
41/// source is the caller's to free — this does not take ownership.
42pub fn quantize_to_fp8_blockscaled(
43 bf16_weight: &DenseWeight,
44 n: usize,
45 k: usize,
46 gpu: &dyn GpuBackend,
47 quantize_kernel: spark_runtime::gpu::KernelHandle,
48 stream: u64,
49) -> Result<Fp8Weight> {
50 anyhow::ensure!(
51 n > 0 && k > 0,
52 "quantize_to_fp8_blockscaled: empty [{n},{k}]"
53 );
54
55 let n_blocks = n.div_ceil(FP8_BLOCK);
56 let k_blocks = k.div_ceil(FP8_BLOCK);
57
58 // One byte per weight, one f32 per [128,128] tile.
59 let weight_buf = gpu.alloc(n * k)?;
60 let scale_buf = gpu.alloc(n_blocks * k_blocks * 4)?;
61
62 KernelLaunch::new(gpu, quantize_kernel)
63 .grid([k_blocks as u32, n_blocks as u32, 1])
64 .block([256, 1, 1])
65 .arg_ptr(bf16_weight.weight)
66 .arg_ptr(weight_buf)
67 .arg_ptr(scale_buf)
68 .arg_u32(n as u32)
69 .arg_u32(k as u32)
70 .launch(stream)?;
71
72 Ok(Fp8Weight {
73 weight: weight_buf,
74 row_scale: scale_buf,
75 n: n as u32,
76 k: k as u32,
77 scale_format: WeightQuantFormat::Fp8BlockScaled,
78 })
79}