metrale_model_layers/weight_map/
fp8_lut.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Load-time weight conversions: NVFP4 and NVFP4-with-E8M0 to BF16,
4//! the E8M0 lookup, dense-FFN loading, row concatenation and the BA
5//! interleave for the GDN gate kernel.
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 NVFP4 weight to a new BF16 GPU buffer, on the GPU.
19///
20/// Detects the layout from the store keys:
21/// - **compressed-tensors**: `weight_packed`, `weight_scale`, `weight_global_scale` (reciprocal)
22/// - **Standard (modelopt)**: `weight`, `weight_scale`, `weight_scale_2` (direct multiplier)
23pub fn dequant_nvfp4_to_bf16(
24    store: &WeightStore,
25    prefix: &str,
26    n: usize,
27    k: usize,
28    gpu: &dyn GpuBackend,
29) -> Result<DenseWeight> {
30    let total = n * k;
31
32    let (packed_ptr, scale_ptr, global_scale, is_reciprocal) =
33        if store.contains(&format!("{prefix}.weight_packed")) {
34            let pp = ptr(store, &format!("{prefix}.weight_packed"))?;
35            let sp = ptr(store, &format!("{prefix}.weight_scale"))?;
36            let gs = scalar_f32(store, &format!("{prefix}.weight_global_scale"), gpu)?;
37            (pp, sp, gs, true)
38        } else {
39            let pp = ptr(store, &format!("{prefix}.weight"))?;
40            let sp = ptr(store, &format!("{prefix}.weight_scale"))?;
41            let gs = scalar_f32(store, &format!("{prefix}.weight_scale_2"), gpu)?;
42            (pp, sp, gs, false)
43        };
44
45    // 2026-09-25: Fold the global-scale convention into one multiplier for the
46    // kernel: compressed-tensors stores a reciprocal global (val = E2M1 *
47    // fp8_scale / global), ModelOpt a direct multiplier (val = E2M1 * fp8_scale
48    // * global). A zero reciprocal global gives 0.
49    let combined_global = if is_reciprocal {
50        if global_scale != 0.0 {
51            1.0 / global_scale
52        } else {
53            0.0
54        }
55    } else {
56        global_scale
57    };
58
59    // 2026-09-25: One sync after the launch, so the BF16 is ready when this
60    // returns.
61    let out = gpu.alloc(total * 2)?;
62    let kernel = gpu.kernel("dequant_nvfp4_bf16", "dequant_nvfp4_to_bf16")?;
63    let stream = gpu.default_stream();
64    metrale_gpu_runtime::kernel_args::KernelLaunch::new(gpu, kernel)
65        .grid([n as u32, 1, 1])
66        .block([256, 1, 1])
67        .arg_ptr(packed_ptr)
68        .arg_ptr(scale_ptr)
69        .arg_ptr(out)
70        .arg_f32(combined_global)
71        .arg_u32(n as u32)
72        .arg_u32(k as u32)
73        .launch(stream)?;
74    gpu.synchronize(stream)?;
75    Ok(DenseWeight { weight: out })
76}
77
78/// 2026-09-25: Dequantize a 4-bit E2M1 weight with E8M0 (power-of-2) per-block
79/// scales and no global scale to BF16 on the host, then upload it. `.weight`
80/// holds two E2M1 values per byte and `.scale` one E8M0 byte per block; the
81/// block size is `total / scale elements`. It has no caller in this crate.
82#[allow(dead_code)]
83pub(crate) fn dequant_nvfp4_e8m0_to_bf16(
84    store: &WeightStore,
85    prefix: &str,
86    n: usize,
87    k: usize,
88    gpu: &dyn GpuBackend,
89) -> Result<DenseWeight> {
90    let total = n * k;
91    let packed_bytes = total / 2;
92    let packed_ptr = ptr(store, &format!("{prefix}.weight"))?;
93    let scale_t = store.get(&format!("{prefix}.scale"))?;
94    let num_groups = scale_t.num_elements();
95    ensure!(
96        num_groups > 0 && total.is_multiple_of(num_groups),
97        "{prefix}: weight elems {total} not divisible by E8M0 scale groups {num_groups}"
98    );
99    let block = total / num_groups;
100
101    let mut packed = vec![0u8; packed_bytes];
102    let mut scales = vec![0u8; num_groups];
103    gpu.copy_d2h(packed_ptr, &mut packed)?;
104    gpu.copy_d2h(scale_t.ptr, &mut scales)?;
105
106    let e2m1_table: [f32; 16] = [
107        0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, -0.0, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -6.0,
108    ];
109    // 2026-09-25: Scale group `g` covers flat weight indices
110    // `g*block .. (g+1)*block`; an even flat index is the low nibble, as in the
111    // `dequant_nvfp4_bf16` kernel.
112    let mut bf16_out = vec![0u16; total];
113    for group in 0..num_groups {
114        let block_scale = fp8_e8m0_to_f32(scales[group]);
115        for elem in 0..block {
116            let flat_idx = group * block + elem;
117            let byte_idx = flat_idx / 2;
118            let nibble = if flat_idx.is_multiple_of(2) {
119                packed[byte_idx] & 0x0F
120            } else {
121                (packed[byte_idx] >> 4) & 0x0F
122            };
123            bf16_out[flat_idx] = f32_to_bf16(e2m1_table[nibble as usize] * block_scale);
124        }
125    }
126
127    let buf = gpu.alloc(total * 2)?;
128    // 2026-09-25: SAFETY: `bf16_out` is `vec![0u16; total]`, so `bf16_out.len() == total` and
129    // every element is initialised (zeroed at construction, then overwritten by the
130    // `group`/`elem` dequant loop above). `total * 2 == bf16_out.len() *
131    // size_of::<u16>()`, so the span is exactly the Vec's buffer. Shared borrow
132    // only; `buf` was allocated at `total * 2` bytes so the H2D destination matches.
133    let bf16_bytes: &[u8] =
134        unsafe { std::slice::from_raw_parts(bf16_out.as_ptr() as *const u8, total * 2) };
135    gpu.copy_h2d(bf16_bytes, buf)?;
136    Ok(DenseWeight { weight: buf })
137}
138
139/// 2026-09-25: FP8 E4M3 decode and the f32 → BF16 cast, from
140/// `metrale_core::numeric`, for the `weight_map` modules.
141pub(super) use metrale_core::numeric::{FP8_E4M3_LUT, f32_to_bf16, fp8_e4m3_to_f32};
142
143/// 2026-09-25: FP8 E8M0 → f32 lookup table (256 entries): `2^(exp - 127)` for
144/// exp 1..=254. exp 0 and exp 255 (NaN) map to 0.0.
145const FP8_E8M0_LUT: [f32; 256] = {
146    let mut table = [0.0f32; 256];
147    let mut i: u32 = 0;
148    while i < 256 {
149        let exp = i as u8;
150        table[i as usize] = if exp == 0 {
151            0.0f32
152        } else if exp == 255 {
153            0.0f32
154        } else {
155            f32::from_bits((exp as u32) << 23)
156        };
157        i += 1;
158    }
159    table
160};
161
162/// 2026-09-25: Convert an FP8 E8M0 byte to f32 through `FP8_E8M0_LUT`.
163#[inline(always)]
164pub(super) fn fp8_e8m0_to_f32(bits: u8) -> f32 {
165    FP8_E8M0_LUT[bits as usize]
166}
167
168/// 2026-09-25: Load a dense (non-MoE) FFN's gate/up/down projections as NVFP4,
169/// with transposed copies of each.
170pub fn load_dense_ffn(
171    store: &WeightStore,
172    prefix: &str,
173    gpu: &dyn GpuBackend,
174    variant: Nvfp4Variant,
175    absmax_k: metrale_gpu_runtime::gpu::KernelHandle,
176    quantize_k: metrale_gpu_runtime::gpu::KernelHandle,
177    stream: u64,
178    config: &metrale_config::ModelConfig,
179) -> Result<crate::layers::dense_ffn::DenseFfnWeights> {
180    use crate::layers::dense_ffn::DenseFfnWeights;
181    match variant {
182        Nvfp4Variant::Fp8Dequanted => {
183            // 2026-09-25: The FFN width is `intermediate_size`, or
184            // `moe_intermediate_size` when `intermediate_size` is 0.
185            let inter = if config.intermediate_size > 0 {
186                config.intermediate_size
187            } else {
188                config.moe_intermediate_size
189            };
190            let h = config.hidden_size;
191            let gate = quantized_from_fp8(
192                store,
193                &format!("{prefix}.mlp.gate_proj"),
194                inter,
195                h,
196                gpu,
197                absmax_k,
198                quantize_k,
199                stream,
200            )?;
201            let up = quantized_from_fp8(
202                store,
203                &format!("{prefix}.mlp.up_proj"),
204                inter,
205                h,
206                gpu,
207                absmax_k,
208                quantize_k,
209                stream,
210            )?;
211            let down = quantized_from_fp8(
212                store,
213                &format!("{prefix}.mlp.down_proj"),
214                h,
215                inter,
216                gpu,
217                absmax_k,
218                quantize_k,
219                stream,
220            )?;
221            // 2026-09-25: Transposed copies for the `w4a16_gemm_t*` prefill kernels.
222            Ok(DenseFfnWeights {
223                gate_proj: gate,
224                up_proj: up,
225                down_proj: down,
226                gate_proj_t: Some(gate.transpose_for_gemm(gpu, inter, h)?),
227                up_proj_t: Some(up.transpose_for_gemm(gpu, inter, h)?),
228                down_proj_t: Some(down.transpose_for_gemm(gpu, h, inter)?),
229            })
230        }
231        Nvfp4Variant::Bf16Raw => {
232            // 2026-09-25: An unquantized BF16 FFN is quantized to NVFP4 at load
233            // through `quantized_any`; `quantized_auto` refuses `Bf16Raw`.
234            let inter = if config.intermediate_size > 0 {
235                config.intermediate_size
236            } else {
237                config.moe_intermediate_size
238            };
239            let h = config.hidden_size;
240            let qctx = QuantizeCtx {
241                absmax_k,
242                quantize_k,
243                stream,
244            };
245            let gate = quantized_any(
246                store,
247                &format!("{prefix}.mlp.gate_proj"),
248                inter,
249                h,
250                gpu,
251                variant,
252                qctx,
253            )?;
254            let up = quantized_any(
255                store,
256                &format!("{prefix}.mlp.up_proj"),
257                inter,
258                h,
259                gpu,
260                variant,
261                qctx,
262            )?;
263            let down = quantized_any(
264                store,
265                &format!("{prefix}.mlp.down_proj"),
266                h,
267                inter,
268                gpu,
269                variant,
270                qctx,
271            )?;
272            Ok(DenseFfnWeights {
273                gate_proj: gate,
274                up_proj: up,
275                down_proj: down,
276                gate_proj_t: Some(gate.transpose_for_gemm(gpu, inter, h)?),
277                up_proj_t: Some(up.transpose_for_gemm(gpu, inter, h)?),
278                down_proj_t: Some(down.transpose_for_gemm(gpu, h, inter)?),
279            })
280        }
281        _ => {
282            // 2026-09-25: `quantized_any` detects each key's layout, so FP8 keys
283            // inside an NVFP4 checkpoint are dequantized and requantized rather
284            // than failing on a missing `weight_global_scale`.
285            let inter_ = if config.intermediate_size > 0 {
286                config.intermediate_size
287            } else {
288                config.moe_intermediate_size
289            };
290            let h_ = config.hidden_size;
291            let qctx = QuantizeCtx {
292                absmax_k,
293                quantize_k,
294                stream,
295            };
296            let gate = quantized_any(
297                store,
298                &format!("{prefix}.mlp.gate_proj"),
299                inter_,
300                h_,
301                gpu,
302                variant,
303                qctx,
304            )?;
305            let up = quantized_any(
306                store,
307                &format!("{prefix}.mlp.up_proj"),
308                inter_,
309                h_,
310                gpu,
311                variant,
312                qctx,
313            )?;
314            let down = quantized_any(
315                store,
316                &format!("{prefix}.mlp.down_proj"),
317                h_,
318                inter_,
319                gpu,
320                variant,
321                qctx,
322            )?;
323            let inter = if config.intermediate_size > 0 {
324                config.intermediate_size
325            } else {
326                config.moe_intermediate_size
327            };
328            let h = config.hidden_size;
329            Ok(DenseFfnWeights {
330                gate_proj: gate,
331                up_proj: up,
332                down_proj: down,
333                gate_proj_t: Some(gate.transpose_for_gemm(gpu, inter, h)?),
334                up_proj_t: Some(up.transpose_for_gemm(gpu, inter, h)?),
335                down_proj_t: Some(down.transpose_for_gemm(gpu, h, inter)?),
336            })
337        }
338    }
339}
340
341/// 2026-09-25: `load_mtp` under the Qwen3.5 name.
342#[allow(dead_code)]
343pub(crate) fn load_mtp_qwen35(
344    store: &WeightStore,
345    num_experts: usize,
346    gpu: &dyn GpuBackend,
347    variant: Nvfp4Variant,
348) -> Result<MtpWeights> {
349    load_mtp(store, num_experts, gpu, variant)
350}
351
352/// 2026-09-25: GPU-concatenate two BF16 matrices row-wise: [A; B] →
353/// [A_rows + B_rows, K], in a new buffer. Both inputs must be contiguous with
354/// the same K.
355pub fn gpu_concat_rows(
356    a: &DenseWeight,
357    a_rows: usize,
358    b: &DenseWeight,
359    b_rows: usize,
360    k: usize,
361    gpu: &dyn GpuBackend,
362) -> Result<DenseWeight> {
363    let a_bytes = a_rows * k * 2;
364    let b_bytes = b_rows * k * 2;
365    let total = a_bytes + b_bytes;
366    let buf = gpu.alloc(total)?;
367    tracing::debug!(
368        target: "metrale_model_layers::weight_map::concat",
369        a_rows, b_rows, k, a_bytes, b_bytes, total,
370        a = a.weight.0, b = b.weight.0, buf = buf.0,
371        "gpu concat rows [a;b]"
372    );
373    gpu.copy_d2d(a.weight, buf, a_bytes)?;
374    gpu.copy_d2d(b.weight, buf.offset(a_bytes), b_bytes)?;
375    Ok(DenseWeight { weight: buf })
376}
377
378/// 2026-09-25: Interleave A (alpha) and B (beta) rows on the host into the BA
379/// layout `dense_gemv_ba_gates` reads, and upload it.
380///
381/// Per group of `vpg = nv / nk` value heads: the group's `vpg` beta rows,
382/// then its `vpg` alpha rows. A and B are `[nv, K]` BF16; the result is
383/// `[2*nv, K]` BF16 on the GPU.
384pub fn interleave_ba(
385    a_weight: &DenseWeight,
386    b_weight: &DenseWeight,
387    nv: usize,
388    nk: usize,
389    k: usize,
390    gpu: &dyn GpuBackend,
391) -> Result<DenseWeight> {
392    let vpg = nv / nk;
393    let row_bytes = k * 2;
394    let ba_size = nv * 2;
395
396    let mut a_cpu = vec![0u8; nv * row_bytes];
397    let mut b_cpu = vec![0u8; nv * row_bytes];
398    gpu.copy_d2h(a_weight.weight, &mut a_cpu)?;
399    gpu.copy_d2h(b_weight.weight, &mut b_cpu)?;
400
401    let mut ba_cpu = vec![0u8; ba_size * row_bytes];
402    for g in 0..nk {
403        for v in 0..vpg {
404            let vh = g * vpg + v;
405            let dst_row = g * (2 * vpg) + v;
406            ba_cpu[dst_row * row_bytes..(dst_row + 1) * row_bytes]
407                .copy_from_slice(&b_cpu[vh * row_bytes..(vh + 1) * row_bytes]);
408            let dst_row = g * (2 * vpg) + vpg + v;
409            ba_cpu[dst_row * row_bytes..(dst_row + 1) * row_bytes]
410                .copy_from_slice(&a_cpu[vh * row_bytes..(vh + 1) * row_bytes]);
411        }
412    }
413
414    let buf = gpu.alloc(ba_size * row_bytes)?;
415    gpu.copy_h2d(&ba_cpu, buf)?;
416    Ok(DenseWeight { weight: buf })
417}