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}