metrale_model_layers/weight_map/
quantized.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Device-resident weight types (`QuantizedWeight`, `DenseWeight`, `Fp8Weight`, `PackedQ2Weight`) and the `WeightQuantFormat` tag.
4//!
5//! Owner: model-layers (weight loading).
6//! Invariants: none beyond the types.
7
8#![allow(unused_imports)]
9
10use anyhow::{Context, Result, bail, ensure};
11use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
12use metrale_model_weights::weights::{WeightDtype, WeightStore};
13
14use super::*;
15#[path = "quantized/transpose.rs"]
16mod transpose;
17
18/// 2026-09-25: The format of a weight buffer in device memory, as opposed to
19/// the on-disk format (`Nvfp4Variant`). A kernel call site checks it with
20/// [`WeightQuantFormat::expect`], because a kernel cannot tell one layout
21/// from another by its pointer.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum WeightQuantFormat {
24    /// 2026-09-25: BF16, not quantized.
25    Bf16,
26    /// 2026-09-25: FP8 E4M3 with one F32 scale per row (`[N]`), as
27    /// `load_fp8_weight` returns.
28    Fp8PerRow,
29    /// 2026-09-25: FP8 E4M3 with one F32 scale per 128x128 block, as
30    /// `load_fp8_block_scaled_as_fp8weight` and `quantize_to_fp8_blockscaled` return.
31    Fp8BlockScaled,
32    /// 2026-09-25: FP8 E4M3 with no scale buffer.
33    Fp8SingleScale,
34    /// 2026-09-25: NVFP4: packed E2M1 nibbles, one FP8 scale per 16 weights, and
35    /// an F32 per-tensor scale.
36    Nvfp4,
37    /// 2026-09-25: Native MXFP4: packed E2M1 nibbles and one E8M0 scale per 32
38    /// weights, with no per-tensor scale. The DeepSeek-V4 loader tags its routed
39    /// experts with it (`MoeLayer::experts_scale_kind`).
40    Mxfp4E8m0,
41    /// 2026-09-25: Keep-packed ternary Q2_0 (ggml id 42): raw `block_q2_0`
42    /// blocks, each an fp16 scale and 2-bit codes, as [`PackedQ2Weight`] holds them.
43    PackedQ2_0,
44}
45
46impl WeightQuantFormat {
47    /// 2026-09-25: Panic, naming `context`, when `self` is not `expected`.
48    #[inline]
49    #[track_caller]
50    pub fn expect(self, expected: WeightQuantFormat, context: &str) {
51        if self != expected {
52            panic!(
53                "WeightQuantFormat mismatch at {context}: kernel expects {expected:?}, \
54                 but the weight buffer is tagged {self:?}. This is a silent quant-leak \
55                 that would produce wrong outputs without this assertion."
56            );
57        }
58    }
59}
60
61/// 2026-09-25: Keep-packed ternary Q2_0 weight: one buffer of raw `block_q2_0`
62/// blocks (`[fp16 d][group/4 bytes of 2-bit codes]`, `value = (code - 1) * d`),
63/// row-major over `[n, k]`, with the scale inline and no separate scale tensor.
64/// Built from a `WeightDtype::PackedQ2_0` store tensor, which the GGUF loader
65/// produces under `METRALE_GGUF_NATIVE_Q2=1`. The store owns the buffer; this
66/// struct only holds the pointer.
67#[derive(Debug, Clone, Copy)]
68pub struct PackedQ2Weight {
69    /// 2026-09-25: Raw packed `block_q2_0` bytes, `n * (k/group) * (2 + group/4)` long.
70    pub weight: DevicePtr,
71    /// 2026-09-25: Output rows (the weight is `[n, k]`).
72    pub n: u32,
73    /// 2026-09-25: Input columns (the contraction dimension).
74    pub k: u32,
75    /// 2026-09-25: Elements per block and per inline scale.
76    pub group: u16,
77}
78
79impl PackedQ2Weight {
80    /// 2026-09-25: True when the buffer pointer is NULL.
81    pub fn is_null(&self) -> bool {
82        self.weight == DevicePtr::NULL
83    }
84}
85
86/// 2026-09-25: 4-bit quantized weight: packed E2M1 data, per-group scales and a
87/// host-side per-tensor scale.
88#[derive(Debug, Clone, Copy)]
89pub struct QuantizedWeight {
90    /// 2026-09-25: Packed E2M1 weights, 2 per byte.
91    pub weight: DevicePtr,
92    /// 2026-09-25: Per-group scales: FP8 for NVFP4, E8M0 for native MXFP4.
93    pub weight_scale: DevicePtr,
94    /// 2026-09-25: Per-tensor scale, held on the host.
95    pub weight_scale_2: f32,
96    /// 2026-09-25: Input activation scale on the device, or NULL.
97    pub input_scale: DevicePtr,
98    /// 2026-09-25: Per-row scale2 on the device, or NULL. The loaders in this
99    /// module always set NULL; the transposes copy it through.
100    pub weight_scale_2_vec: DevicePtr,
101    /// 2026-09-28: The activation format the weight-quantization policy set for this weight
102    /// (`metrale_config::WeightQuantPolicy::nvfp4_act`). Unstamped unless a loader asks the
103    /// policy; derived copies (transposes, concatenations, TP shards) carry it through.
104    pub act: metrale_config::Nvfp4Act,
105}
106
107impl QuantizedWeight {
108    /// 2026-09-25: All pointers NULL and `weight_scale_2` 0.0.
109    pub fn null() -> Self {
110        Self {
111            weight: DevicePtr::NULL,
112            weight_scale: DevicePtr::NULL,
113            weight_scale_2: 0.0,
114            input_scale: DevicePtr::NULL,
115            weight_scale_2_vec: DevicePtr::NULL,
116            act: metrale_config::Nvfp4Act::Unstamped,
117        }
118    }
119
120    /// 2026-09-25: True when `weight_scale_2_vec` is not NULL.
121    pub fn has_per_row_scale2(&self) -> bool {
122        self.weight_scale_2_vec != DevicePtr::NULL
123    }
124
125    /// 2026-09-25: True when `weight` is NULL.
126    pub fn is_null(&self) -> bool {
127        self.weight == DevicePtr::NULL
128    }
129
130    /// 2026-09-25: Concatenate two NVFP4 weights by rows into new buffers:
131    /// `[N1, K/2]` + `[N2, K/2]` → `[N1+N2, K/2]`, with group size 16.
132    ///
133    /// Both must have the same `K`, which the caller guarantees. Their
134    /// `weight_scale_2` must be bit-identical, or it returns an error, because
135    /// the result carries `self`'s scale for every row. The result has NULL
136    /// `input_scale` and `weight_scale_2_vec`.
137    pub fn concat_rows(
138        &self,
139        other: &QuantizedWeight,
140        n1: usize,
141        n2: usize,
142        k: usize,
143        gpu: &dyn GpuBackend,
144    ) -> anyhow::Result<QuantizedWeight> {
145        anyhow::ensure!(
146            self.weight_scale_2 == other.weight_scale_2,
147            "concat_rows: weight_scale_2 mismatch (self={}, other={}) — both NVFP4 \
148             tensors must share the same per-tensor scale to be concatenated. \
149             This is expected for ModelOpt/Standard NVFP4 checkpoints (single \
150             global per-tensor scale2); re-quantize with the ModelOpt/Standard \
151             quantizer, or report which checkpoint/quantizer produced independent \
152             per-tensor scales for these projections",
153            self.weight_scale_2,
154            other.weight_scale_2,
155        );
156        const GROUP_SIZE: usize = 16;
157        let half_k = k / 2;
158        let num_groups = k / GROUP_SIZE;
159
160        let total_n = n1 + n2;
161        let packed_size = total_n * half_k;
162        let scale_size = total_n * num_groups;
163
164        let new_weight = gpu.alloc(packed_size)?;
165        let new_scale = gpu.alloc(scale_size)?;
166
167        gpu.copy_d2d(self.weight, new_weight, n1 * half_k)?;
168        gpu.copy_d2d(other.weight, new_weight.offset(n1 * half_k), n2 * half_k)?;
169
170        gpu.copy_d2d(self.weight_scale, new_scale, n1 * num_groups)?;
171        gpu.copy_d2d(
172            other.weight_scale,
173            new_scale.offset(n1 * num_groups),
174            n2 * num_groups,
175        )?;
176
177        Ok(QuantizedWeight {
178            weight: new_weight,
179            weight_scale: new_scale,
180            weight_scale_2: self.weight_scale_2,
181            input_scale: DevicePtr::NULL,
182            weight_scale_2_vec: DevicePtr::NULL,
183            act: metrale_config::Nvfp4Act::combine([self.act, other.act]),
184        })
185    }
186
187    /// 2026-09-25: Dequantize this NVFP4 weight `[n, k]` into a new FP8 E4M3
188    /// buffer `[n, k]` with `weight_scale_2` applied, for prefill GEMMs that read
189    /// FP8. It synchronizes `stream` before returning.
190    pub fn predequant_to_fp8(
191        &self,
192        gpu: &dyn GpuBackend,
193        predequant_kernel: metrale_gpu_runtime::gpu::KernelHandle,
194        n: usize,
195        k: usize,
196        stream: u64,
197    ) -> Result<DevicePtr> {
198        let fp8_buf = gpu.alloc(n * k)?;
199        crate::layers::ops::predequant_nvfp4_to_fp8(
200            gpu,
201            predequant_kernel,
202            self.weight,
203            self.weight_scale,
204            self.weight_scale_2,
205            fp8_buf,
206            n as u32,
207            k as u32,
208            stream,
209        )?;
210        gpu.synchronize(stream)?;
211        Ok(fp8_buf)
212    }
213}
214
215/// 2026-09-25: Device pointer to an unquantized weight: BF16 unless the loader
216/// that built it says otherwise (for example `dense_keep_f32`).
217#[derive(Debug, Clone, Copy)]
218pub struct DenseWeight {
219    pub weight: DevicePtr,
220}
221
222impl DenseWeight {
223    /// 2026-09-25: Quantize a BF16 weight `[N, K]` to FP8 E4M3 `[N, K]` with one
224    /// f32 scale per row, in new buffers, with the `quantize_bf16_to_fp8`
225    /// kernel. It synchronizes `stream` before returning.
226    pub fn quantize_to_fp8(
227        &self,
228        gpu: &dyn GpuBackend,
229        quantize_kernel: metrale_gpu_runtime::gpu::KernelHandle,
230        n: usize,
231        k: usize,
232        stream: u64,
233    ) -> Result<Fp8DenseWeight> {
234        let fp8_buf = gpu.alloc(n * k)?;
235        let row_scale_buf = gpu.alloc(n * std::mem::size_of::<f32>())?;
236        crate::layers::ops::quantize_bf16_to_fp8(
237            gpu,
238            quantize_kernel,
239            self.weight,
240            fp8_buf,
241            row_scale_buf,
242            n as u32,
243            k as u32,
244            stream,
245        )?;
246        gpu.synchronize(stream)?;
247        Ok(Fp8DenseWeight {
248            weight: fp8_buf,
249            row_scale: row_scale_buf,
250        })
251    }
252}
253
254/// 2026-09-25: FP8 E4M3 weight quantized from BF16 at load, with one f32 scale per row.
255#[derive(Debug, Clone, Copy)]
256pub struct Fp8DenseWeight {
257    /// 2026-09-25: `[N, K]` FP8 E4M3 bytes.
258    pub weight: DevicePtr,
259    /// 2026-09-25: `[N]` f32 row scales.
260    pub row_scale: DevicePtr,
261}
262
263/// 2026-09-25: FP8 E4M3 weight with its scale. The layout of `row_scale`
264/// depends on `scale_format`:
265///   - [`WeightQuantFormat::Fp8PerRow`]: `[N]` f32;
266///   - [`WeightQuantFormat::Fp8BlockScaled`]: `[N/128, K/128]` f32, rounded up;
267///   - [`WeightQuantFormat::Fp8SingleScale`]: no scale buffer.
268///
269/// Check `scale_format` before reading `row_scale`.
270#[derive(Debug, Clone, Copy)]
271pub struct Fp8Weight {
272    /// 2026-09-25: `[N, K]` FP8 E4M3 bytes.
273    pub weight: DevicePtr,
274    /// 2026-09-25: The scale buffer, laid out as `scale_format` says.
275    pub row_scale: DevicePtr,
276    pub n: u32,
277    pub k: u32,
278    pub scale_format: WeightQuantFormat,
279}
280
281/// 2026-09-25: The transpose of a block-scaled [`Fp8Weight`], built by
282/// [`Fp8Weight::transpose_for_gemm`] for the prefill GEMM.
283#[derive(Debug, Clone, Copy)]
284pub struct Fp8WeightTransposed {
285    /// 2026-09-25: `[K, N]` FP8 E4M3 bytes.
286    pub weight_t: DevicePtr,
287    /// 2026-09-25: `[K/128, N/128]` FP32 block scales, rounded up.
288    pub scale_t: DevicePtr,
289    pub n: u32,
290    pub k: u32,
291}
292
293impl Fp8Weight {
294    /// 2026-09-25: Transpose this weight into new buffers `weight_t[K, N]` and
295    /// `scale_t[K/128, N/128]`, and synchronize `stream`. `row_scale` is read as
296    /// FP32 block scales; `scale_format` is not checked.
297    pub fn transpose_for_gemm(
298        &self,
299        gpu: &dyn GpuBackend,
300        transpose_k: metrale_gpu_runtime::gpu::KernelHandle,
301        transpose_scale_k: metrale_gpu_runtime::gpu::KernelHandle,
302        stream: u64,
303    ) -> anyhow::Result<Fp8WeightTransposed> {
304        let n = self.n as usize;
305        let k = self.k as usize;
306
307        let weight_t = gpu.alloc(k * n)?;
308        crate::layers::ops::transpose_fp8(
309            gpu,
310            transpose_k,
311            self.weight,
312            weight_t,
313            self.n,
314            self.k,
315            stream,
316        )?;
317
318        let n_blocks = n.div_ceil(128);
319        let k_blocks = k.div_ceil(128);
320        let scale_t = gpu.alloc(k_blocks * n_blocks * 4)?;
321        crate::layers::ops::transpose_block_scale(
322            gpu,
323            transpose_scale_k,
324            self.row_scale,
325            scale_t,
326            n_blocks as u32,
327            k_blocks as u32,
328            stream,
329        )?;
330
331        gpu.synchronize(stream)?;
332
333        Ok(Fp8WeightTransposed {
334            weight_t,
335            scale_t,
336            n: self.n,
337            k: self.k,
338        })
339    }
340}