metrale_model_layers/weight_map/
fp8_dequant.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: FP8 E4M3 → BF16 dequantization for weights with one per-tensor
4//! scale: a GPU path for an FP32 scale, and a host path for an FP32 or BF16
5//! scale.
6//!
7//! Owner: model-layers (weight loading).
8//! Invariants: none beyond the types.
9
10#![allow(unused_imports)]
11
12use anyhow::{Context, Result, bail, ensure};
13use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
14use metrale_model_weights::weights::{WeightDtype, WeightStore};
15
16use super::*;
17
18/// 2026-09-25: Dequantize an FP8 E4M3 weight with an FP32 per-tensor scale into
19/// `out` (BF16) on the GPU.
20///
21/// Launches `dequant_fp8_blockscaled_bf16` with a single block covering the
22/// tensor (`block_n = N`, `block_k = K`, `sk = 1`), so every element reads
23/// `weight_scale[0]`, which is already on the device. Like the host path
24/// (`dequant_fp8_bytes_to_bf16`), it computes the E4M3 value times the scale in
25/// f32 and rounds to BF16.
26fn gpu_dequant_fp8_pertensor(
27    store: &WeightStore,
28    prefix: &str,
29    gpu: &dyn GpuBackend,
30    out: DevicePtr,
31) -> Result<()> {
32    use metrale_gpu_runtime::kernel_args::{KernelLaunch, div_ceil};
33
34    let w = store.get(&format!("{prefix}.weight"))?;
35    ensure!(
36        w.dtype == WeightDtype::FP8E4M3,
37        "Expected FP8E4M3 for {prefix}.weight, got {:?}",
38        w.dtype,
39    );
40    // 2026-09-25: Any factorization N*K = total works (the scale is constant);
41    // a 2D weight uses its own shape, anything else is flattened.
42    let total = w.num_elements();
43    let (n, k) = if w.shape.len() == 2 {
44        (w.shape[0], w.shape[1])
45    } else {
46        (1usize, total)
47    };
48    ensure!(
49        n * k == total,
50        "FP8 shape mismatch for {prefix}: {n}*{k} != {total}"
51    );
52
53    let s = store.get(&format!("{prefix}.weight_scale"))?;
54    ensure!(
55        s.dtype == WeightDtype::FP32 && s.num_elements() == 1,
56        "Expected FP32 scalar weight_scale for {prefix}, got {:?} ({} elems)",
57        s.dtype,
58        s.num_elements(),
59    );
60
61    let stream = gpu.default_stream();
62    let kernel = gpu.kernel(
63        "dequant_fp8_blockscaled_bf16",
64        "dequant_fp8_blockscaled_bf16",
65    )?;
66    KernelLaunch::new(gpu, kernel)
67        .grid([div_ceil(k as u32, 64), div_ceil(n as u32, 4), 1])
68        .block([64, 4, 1])
69        .arg_ptr(w.ptr)
70        .arg_ptr(s.ptr)
71        .arg_ptr(out)
72        .arg_u32(n as u32)
73        .arg_u32(k as u32)
74        .arg_u32(n as u32)
75        .arg_u32(k as u32)
76        .arg_u32(1)
77        .arg_u32(1)
78        .launch(stream)?;
79    // 2026-09-25: No sync after the launch: later work on the same stream is
80    // ordered after it, and a fault surfaces at the next sync.
81    Ok(())
82}
83
84/// 2026-09-25: Read a per-tensor `weight_scale` scalar (FP32 or BF16) from the
85/// store as `f32`, for the host dequant path.
86fn read_scalar_weight_scale(
87    store: &WeightStore,
88    prefix: &str,
89    gpu: &dyn GpuBackend,
90) -> Result<f32> {
91    let scale_key = format!("{prefix}.weight_scale");
92    let s = store.get(&scale_key)?;
93    ensure!(
94        s.num_elements() == 1,
95        "Expected scalar for {scale_key}, got {} elements",
96        s.num_elements()
97    );
98    match s.dtype {
99        WeightDtype::FP32 => {
100            let mut buf = [0u8; 4];
101            gpu.copy_d2h(s.ptr, &mut buf)?;
102            Ok(f32::from_le_bytes(buf))
103        }
104        WeightDtype::BF16 => {
105            let mut buf = [0u8; 2];
106            gpu.copy_d2h(s.ptr, &mut buf)?;
107            Ok(bf16_bytes_to_f32(buf))
108        }
109        other => bail!("Expected FP32 or BF16 for {scale_key}, got {:?}", other),
110    }
111}
112
113/// 2026-09-25: Dequantize FP8 E4M3 + per-tensor scale → BF16 into a newly
114/// allocated GPU buffer. `dequant_fp8_to_bf16_into` writes into a caller's
115/// buffer instead.
116pub(crate) fn dequant_fp8_to_bf16(
117    store: &WeightStore,
118    prefix: &str,
119    gpu: &dyn GpuBackend,
120) -> Result<DenseWeight> {
121    let w = store.get(&format!("{prefix}.weight"))?;
122    // 2026-09-25: An FP32 scalar scale takes the GPU path; any other scale (a
123    // BF16 scalar) takes the host path, which reads FP32 or BF16.
124    let scale_is_fp32_scalar = store
125        .get(&format!("{prefix}.weight_scale"))
126        .map(|s| s.dtype == WeightDtype::FP32 && s.num_elements() == 1)
127        .unwrap_or(false);
128    if scale_is_fp32_scalar {
129        let total = w.num_elements();
130        let out = gpu.alloc(total * 2)?;
131        gpu_dequant_fp8_pertensor(store, prefix, gpu, out)?;
132        Ok(DenseWeight { weight: out })
133    } else {
134        let n_bytes = w.num_elements();
135        let mut fp8_buf = vec![0u8; n_bytes];
136        gpu.copy_d2h(w.ptr, &mut fp8_buf)?;
137
138        let scale = read_scalar_weight_scale(store, prefix, gpu)?;
139        let bf16_buf = dequant_fp8_bytes_to_bf16(&fp8_buf, scale);
140        let ptr = gpu.alloc(bf16_buf.len())?;
141        gpu.copy_h2d(&bf16_buf, ptr)?;
142        Ok(DenseWeight { weight: ptr })
143    }
144}
145
146/// 2026-09-25: Dequantize FP8 E4M3 + per-tensor scale → BF16 into `dest`, a
147/// caller-provided buffer of at least `2 × elements` bytes, and return it as a
148/// `DenseWeight`. The GPU path does not synchronise, so the caller must order
149/// any reuse of `dest` after the launch (e.g. after `quantize_to_nvfp4`, which
150/// synchronises its stream).
151pub fn dequant_fp8_to_bf16_into(
152    store: &WeightStore,
153    prefix: &str,
154    gpu: &dyn GpuBackend,
155    dest: DevicePtr,
156) -> Result<DenseWeight> {
157    // 2026-09-25: FP32 scalar scale → GPU path; otherwise the host path.
158    let scale_is_fp32_scalar = store
159        .get(&format!("{prefix}.weight_scale"))
160        .map(|s| s.dtype == WeightDtype::FP32 && s.num_elements() == 1)
161        .unwrap_or(false);
162    if scale_is_fp32_scalar {
163        gpu_dequant_fp8_pertensor(store, prefix, gpu, dest)?;
164    } else {
165        let w = store.get(&format!("{prefix}.weight"))?;
166        let n_bytes = w.num_elements();
167        let mut fp8_buf = vec![0u8; n_bytes];
168        gpu.copy_d2h(w.ptr, &mut fp8_buf)?;
169
170        let scale = read_scalar_weight_scale(store, prefix, gpu)?;
171        let bf16_buf = dequant_fp8_bytes_to_bf16(&fp8_buf, scale);
172        gpu.copy_h2d(&bf16_buf, dest)?;
173    }
174    Ok(DenseWeight { weight: dest })
175}