spark_model/weight_map/
quantized.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Auto-extracted from `weight_map.rs` during refactor wave 4a.
4
5#![allow(unused_imports)]
6
7use anyhow::{Context, Result, bail, ensure};
8use spark_runtime::gpu::{DevicePtr, GpuBackend};
9use spark_runtime::weights::{WeightDtype, WeightStore};
10
11use super::*;
12
13/// Runtime tag for the actual quantization format of a weight buffer in
14/// GPU memory. Distinct from on-disk format (which `Nvfp4Variant` describes).
15/// Used to assert at kernel-call sites that the weight matches what the
16/// kernel expects — preventing silent leaks like FP8-block-scaled data
17/// being passed through a NVFP4 GEMM, or single-scale FP8 being passed
18/// through a kernel that expects per-row scales.
19///
20/// Phase 2c day-3 follow-up (2026-05-24): introduced after the audit at
21/// `bench/phase2c-kv-sweep/CAUSAL-PATHWAY-AUDIT.md` found that block-scaled
22/// FP8 weights from disk were being silently stuffed into the `row_scale`
23/// field of `Fp8Weight` (which documents itself as per-row F32), causing
24/// either crashes (when concat math read past the smaller block-scale
25/// tensor) or — if the concat dimension happened to fit — silent precision
26/// loss because downstream kernels (`fp8_gemm_n128`) take no scale arg
27/// and assume single-scale FP8.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum WeightQuantFormat {
30    /// BF16 dense — no quantization. Kernel must consume BF16 inputs.
31    Bf16,
32    /// FP8 E4M3 weight + per-row F32 dequant scale (`[N]` f32).
33    /// Produced by runtime quantization from BF16 (`Fp8DenseWeight`)
34    /// or by checkpoints that ship per-row scales.
35    /// Consumed by `w8a16_gemv` / `w8a16_gemm`.
36    Fp8PerRow,
37    /// FP8 E4M3 weight + per-block BF16 dequant scale (`[N/BS, K/BS]` BF16).
38    /// Standard Qwen-team FP8 release format (BS=128). NO Atlas kernel
39    /// currently consumes this directly for SSM — kernels expect either
40    /// dequant-to-BF16-then-NVFP4 (current path) or single-scale FP8.
41    /// **Block-scaled FP8 GEMV/GEMM is the missing kernel** (open task).
42    Fp8BlockScaled,
43    /// FP8 E4M3 weight with a single global scale baked into the kernel
44    /// (or implicit). Produced by `bf16_to_fp8` from a BF16 dense.
45    /// Consumed by `fp8_gemm_n128` (takes no scale argument).
46    Fp8SingleScale,
47    /// NVFP4: packed E2M1 nibbles + per-group FP8 block scales + per-tensor
48    /// F32 scale. Consumed by `w4a16_gemv`, `w4a16_gemm`, and variants.
49    Nvfp4,
50    /// Native MXFP4 (OCP micro-scaling): packed E2M1 nibbles + per-block
51    /// **E8M0** power-of-2 scales (`GROUP_SIZE=32`), **no** per-tensor global.
52    /// This is DeepSeek-V4-Flash's ORIGINAL on-disk routed-expert format. The
53    /// bytes are landed device-resident UNCHANGED (transcode-free) — the
54    /// scale byte is a biased exponent, effective scale `2^(byte-127)`.
55    /// Consumed by the E8M0 variants of the MoE grouped/decode GEMMs
56    /// (Phase-K lane); feeding these bytes through an `Nvfp4` kernel (which
57    /// reads the scale as FP8-E4M3 per-16 and applies a global) = silent
58    /// garbage — assert with `WeightQuantFormat::expect` at the dispatch site.
59    Mxfp4E8m0,
60    /// Keep-packed PrismML ternary Q2_0 (ggml id 42): raw `block_q2_0` blocks
61    /// (fp16 inline scale + 2-bit codes per group), dequantized in-kernel by the
62    /// native `q2_0_gemv` decode GEMV. Consumed only by that kernel — feeding
63    /// these bytes through any other GEMV/GEMM is silent garbage.
64    PackedQ2_0,
65}
66
67impl WeightQuantFormat {
68    /// Assert that `self` matches `expected`; panic with a descriptive
69    /// message if not. Used at kernel-call sites to prevent silent leaks
70    /// of one quant format into a kernel that expects a different one.
71    #[inline]
72    #[track_caller]
73    pub fn expect(self, expected: WeightQuantFormat, context: &str) {
74        if self != expected {
75            panic!(
76                "WeightQuantFormat mismatch at {context}: kernel expects {expected:?}, \
77                 but the weight buffer is tagged {self:?}. This is a silent quant-leak \
78                 that would produce wrong outputs without this assertion."
79            );
80        }
81    }
82}
83
84/// Keep-packed ternary Q2_0 weight: a single contiguous buffer of raw PrismML
85/// `block_q2_0` blocks ([fp16 d][group/4 bytes of 2-bit codes], `value =
86/// (code-1)*d`), row-major over `[n, k]`. The scale is INLINE (one fp16 per
87/// group of `group` elements) — there is no companion scale tensor, unlike
88/// NVFP4/FP8. Consumed by the native `q2_0_gemv` decode kernel, which reads the
89/// scale from each block. Built from a `WeightDtype::PackedQ2_0` store tensor
90/// under `ATLAS_GGUF_NATIVE_Q2=1`; the buffer is owned by the `WeightStore`, so
91/// this struct only borrows the pointer (no free on drop).
92#[derive(Debug, Clone, Copy)]
93pub struct PackedQ2Weight {
94    /// Raw packed `block_q2_0` bytes, `n * (k/group) * (2 + group/4)` long.
95    pub weight: DevicePtr,
96    /// Output rows (weight is `[n, k]`).
97    pub n: u32,
98    /// Input columns (contraction dim).
99    pub k: u32,
100    /// Group size (128 or 64) — elements per block / per inline scale.
101    pub group: u16,
102}
103
104impl PackedQ2Weight {
105    /// True if the backing buffer is NULL (unset placeholder).
106    pub fn is_null(&self) -> bool {
107        self.weight == DevicePtr::NULL
108    }
109}
110
111/// NVFP4 quantized weight: packed E2M1 data + FP8 block scales + FP32 per-tensor scale.
112#[derive(Debug, Clone, Copy)]
113pub struct QuantizedWeight {
114    /// Packed E2M1 weights (2 values per byte).
115    pub weight: DevicePtr,
116    /// Per-group FP8 block scales.
117    pub weight_scale: DevicePtr,
118    /// Per-tensor FP32 scale factor (extracted from GPU via D2H copy at load time).
119    pub weight_scale_2: f32,
120    /// Input activation scale (FP32 on device, for FP8 activation path).
121    pub input_scale: DevicePtr,
122    /// Per-row FP32 scale2 on device (`[N]` floats). When set, the `w4a16_gemv_prs`
123    /// kernel reads scale2 per output row instead of the scalar `weight_scale_2`,
124    /// eliminating precision loss from per-tensor absmax on outlier rows.
125    pub weight_scale_2_vec: DevicePtr,
126}
127
128impl QuantizedWeight {
129    /// Null weight (all pointers NULL). Used for remote experts under EP.
130    pub fn null() -> Self {
131        Self {
132            weight: DevicePtr::NULL,
133            weight_scale: DevicePtr::NULL,
134            weight_scale_2: 0.0,
135            input_scale: DevicePtr::NULL,
136            weight_scale_2_vec: DevicePtr::NULL,
137        }
138    }
139
140    /// Whether this weight has per-row scale2 (for PRS GEMV dispatch).
141    pub fn has_per_row_scale2(&self) -> bool {
142        self.weight_scale_2_vec != DevicePtr::NULL
143    }
144
145    /// Whether this weight points to NULL (remote expert placeholder).
146    pub fn is_null(&self) -> bool {
147        self.weight == DevicePtr::NULL
148    }
149
150    /// Concatenate two NVFP4 weights by rows: `[N1, K/2]` + `[N2, K/2]` → `[N1+N2, K/2]`.
151    ///
152    /// Both weights MUST share the same `K` (input dimension) and the same scalar
153    /// `weight_scale_2`. The packed weight bytes and FP8 block scales are concatenated
154    /// on-GPU via `cuMemcpy`.
155    pub fn concat_rows(
156        &self,
157        other: &QuantizedWeight,
158        n1: usize,
159        n2: usize,
160        k: usize,
161        gpu: &dyn GpuBackend,
162    ) -> anyhow::Result<QuantizedWeight> {
163        // The concatenated weight carries a single scalar scale2 (self's) for
164        // ALL rows — a mismatched `other` would silently dequantize its rows
165        // with the wrong per-tensor scale. This bit-exact equality only holds
166        // for `Nvfp4Variant::Standard` (NVIDIA ModelOpt) checkpoints, whose
167        // convention is a single global per-tensor `weight_scale_2` scalar
168        // shared across every row of the tensor — so two tensors quantized
169        // together by the same run share the identical f32 bit pattern.
170        // Other conventions (e.g. compressed-tensors) may carry independent
171        // per-tensor scales even for logically concatenable projections.
172        anyhow::ensure!(
173            self.weight_scale_2 == other.weight_scale_2,
174            "concat_rows: weight_scale_2 mismatch (self={}, other={}) — both NVFP4 \
175             tensors must share the same per-tensor scale to be concatenated. \
176             This is expected for ModelOpt/Standard NVFP4 checkpoints (single \
177             global per-tensor scale2); re-quantize with the ModelOpt/Standard \
178             quantizer, or report which checkpoint/quantizer produced independent \
179             per-tensor scales for these projections",
180            self.weight_scale_2,
181            other.weight_scale_2,
182        );
183        const GROUP_SIZE: usize = 16;
184        let half_k = k / 2;
185        let num_groups = k / GROUP_SIZE;
186
187        let total_n = n1 + n2;
188        let packed_size = total_n * half_k;
189        let scale_size = total_n * num_groups;
190
191        let new_weight = gpu.alloc(packed_size)?;
192        let new_scale = gpu.alloc(scale_size)?;
193
194        gpu.copy_d2d(self.weight, new_weight, n1 * half_k)?;
195        gpu.copy_d2d(other.weight, new_weight.offset(n1 * half_k), n2 * half_k)?;
196
197        gpu.copy_d2d(self.weight_scale, new_scale, n1 * num_groups)?;
198        gpu.copy_d2d(
199            other.weight_scale,
200            new_scale.offset(n1 * num_groups),
201            n2 * num_groups,
202        )?;
203
204        Ok(QuantizedWeight {
205            weight: new_weight,
206            weight_scale: new_scale,
207            weight_scale_2: self.weight_scale_2,
208            input_scale: DevicePtr::NULL,
209            weight_scale_2_vec: DevicePtr::NULL,
210        })
211    }
212
213    /// Resolve the `transpose_u8` GPU kernel for the load-time transpose
214    /// paths, or `None` to use the host byte-loop fallback. `None` when the
215    /// target's kernel set lacks it, or when `ATLAS_HOST_TRANSPOSE=1` forces
216    /// the host path (parity/debug kill switch).
217    fn host_transpose_kernel(gpu: &dyn GpuBackend) -> Option<spark_runtime::gpu::KernelHandle> {
218        if std::env::var("ATLAS_HOST_TRANSPOSE").as_deref() == Ok("1") {
219            return None;
220        }
221        let k = crate::layers::try_kernel(gpu, "transpose_u8", "transpose_u8");
222        (k.0 != 0).then_some(k)
223    }
224
225    /// Transpose weight layout from [N, K/2] to [K/2, N] for coalesced GEMM reads.
226    ///
227    /// Also transposes scale from [N, K/GROUP_SIZE] to [K/GROUP_SIZE, N].
228    /// Returns a NEW `QuantizedWeight` with freshly allocated GPU buffers,
229    /// leaving the original untouched (needed for decode kernels).
230    pub fn transpose_for_gemm(
231        &self,
232        gpu: &dyn GpuBackend,
233        n: usize,
234        k: usize,
235    ) -> Result<QuantizedWeight> {
236        // NVFP4 default: per-16 block scales. Native MXFP4 (E8M0) is per-32 —
237        // ARM-2 Phase-K callers use `transpose_for_gemm_gs(.., 32)` for routed
238        // experts (the scale tensor is [N, K/32], not [N, K/16]).
239        self.transpose_for_gemm_gs(gpu, n, k, 16)
240    }
241
242    /// `transpose_for_gemm` with an explicit scale block size. Scale tensor is
243    /// `[N, K/group_size]`; the packed-weight transpose is group-size-independent.
244    pub fn transpose_for_gemm_gs(
245        &self,
246        gpu: &dyn GpuBackend,
247        n: usize,
248        k: usize,
249        group_size: usize,
250    ) -> Result<QuantizedWeight> {
251        let half_k = k / 2;
252        let num_groups = k / group_size;
253        let packed_size = n * half_k;
254        let scale_size = n * num_groups;
255
256        // GPU path: two transpose_u8 launches instead of D2H -> host
257        // O(N*K) byte loop -> H2D (the cold-load host bounce; ~13.6 GB at
258        // 27B). ATLAS_HOST_TRANSPOSE=1 forces the host path (parity/debug);
259        // targets without the kernel fall back to it silently.
260        if let Some(tk) = Self::host_transpose_kernel(gpu) {
261            let new_weight = gpu.alloc(packed_size)?;
262            let new_scale = gpu.alloc(scale_size)?;
263            crate::layers::ops::transpose_u8(
264                gpu,
265                tk,
266                self.weight,
267                new_weight,
268                n as u32,
269                half_k as u32,
270                0,
271            )?;
272            crate::layers::ops::transpose_u8(
273                gpu,
274                tk,
275                self.weight_scale,
276                new_scale,
277                n as u32,
278                num_groups as u32,
279                0,
280            )?;
281            gpu.synchronize(0)?;
282            return Ok(QuantizedWeight {
283                weight: new_weight,
284                weight_scale: new_scale,
285                weight_scale_2: self.weight_scale_2,
286                input_scale: self.input_scale,
287                weight_scale_2_vec: self.weight_scale_2_vec,
288            });
289        }
290
291        // Transpose B_packed: [N, K/2] → [K/2, N] into a NEW GPU allocation.
292        let mut buf = vec![0u8; packed_size];
293        gpu.copy_d2h(self.weight, &mut buf)?;
294        let mut t_buf = vec![0u8; packed_size];
295        for i in 0..n {
296            for j in 0..half_k {
297                t_buf[j * n + i] = buf[i * half_k + j];
298            }
299        }
300        let new_weight = gpu.alloc(packed_size)?;
301        gpu.copy_h2d(&t_buf, new_weight)?;
302
303        // Transpose B_scale: [N, K/group_size] → [K/group_size, N] into a NEW allocation.
304        let mut sbuf = vec![0u8; scale_size];
305        gpu.copy_d2h(self.weight_scale, &mut sbuf)?;
306        let mut st_buf = vec![0u8; scale_size];
307        for i in 0..n {
308            for j in 0..num_groups {
309                st_buf[j * n + i] = sbuf[i * num_groups + j];
310            }
311        }
312        let new_scale = gpu.alloc(scale_size)?;
313        gpu.copy_h2d(&st_buf, new_scale)?;
314
315        Ok(QuantizedWeight {
316            weight: new_weight,
317            weight_scale: new_scale,
318            weight_scale_2: self.weight_scale_2,
319            input_scale: self.input_scale,
320            weight_scale_2_vec: self.weight_scale_2_vec,
321        })
322    }
323
324    /// Transpose SEVERAL weights sharing one K and concatenate them along N
325    /// into a single `[K/2, N_total]` twin, so three GEMMs become one.
326    ///
327    /// Motivation (GB10, decode M=16): the attention k/v projections are
328    /// N=1024, which against the 128-wide N tile yields **8 CTAs on 48 SMs** —
329    /// 40 SMs idle, 23.6 GB/s, 9.75x off the bandwidth floor. Concatenating
330    /// q|k|v to N=14336 gives 112 CTAs in ONE launch. Bit-identical: every
331    /// output element is the same dot product against the same column, merely
332    /// relocated along N.
333    ///
334    /// REQUIRES all parts to share `weight_scale_2` — the GEMM applies a single
335    /// `scale2` to the whole launch. Callers MUST verify this (the values live
336    /// on device); `None` is returned if the caller passes an empty list.
337    pub fn transpose_concat_for_gemm(
338        gpu: &dyn GpuBackend,
339        parts: &[(&QuantizedWeight, usize)],
340        k: usize,
341    ) -> Result<QuantizedWeight> {
342        Self::transpose_concat_for_gemm_gs(gpu, parts, k, 16)
343    }
344
345    /// `transpose_concat_for_gemm` with an explicit scale block size.
346    /// `transpose_concat_for_gemm_gs` with the output ROW STRIDE padded to
347    /// `align_up(n_total, align)`, pad columns left zero.
348    ///
349    /// The transposed layout puts row r at byte offset `r * stride`, and the
350    /// tile GEMM reads B with 16-byte `cp.async`, which requires a 16-byte
351    /// aligned source. When `n_total` is not a multiple of 16 — lm_head's N is
352    /// the VOCAB SIZE, 248077 here, which is ODD — 15 of every 16 rows are
353    /// misaligned and the kernel faults with CUDA_ERROR_MISALIGNED_ADDRESS.
354    /// Padding the stride is what makes a transposed lm_head legal at all.
355    ///
356    /// Returns `(weight, stride)`; pass the stride to `w4a16_gemm_n128_ldb`.
357    pub fn transpose_concat_for_gemm_padded(
358        gpu: &dyn GpuBackend,
359        parts: &[(&QuantizedWeight, usize)],
360        k: usize,
361        group_size: usize,
362        align: usize,
363    ) -> Result<(QuantizedWeight, usize)> {
364        let n_total: usize = parts.iter().map(|(_, n)| *n).sum();
365        let stride = n_total.div_ceil(align) * align;
366        Self::transpose_impl(gpu, parts, k, group_size, stride).map(|w| (w, stride))
367    }
368
369    pub fn transpose_concat_for_gemm_gs(
370        gpu: &dyn GpuBackend,
371        parts: &[(&QuantizedWeight, usize)],
372        k: usize,
373        group_size: usize,
374    ) -> Result<QuantizedWeight> {
375        let n_total: usize = parts.iter().map(|(_, n)| *n).sum();
376        Self::transpose_impl(gpu, parts, k, group_size, n_total)
377    }
378
379    /// Single implementation for both (SSOT). `stride >= n_total` is the row
380    /// pitch of the transposed output; columns `n_total..stride` stay zero.
381    fn transpose_impl(
382        gpu: &dyn GpuBackend,
383        parts: &[(&QuantizedWeight, usize)],
384        k: usize,
385        group_size: usize,
386        stride: usize,
387    ) -> Result<QuantizedWeight> {
388        let first = parts
389            .first()
390            .map(|(w, _)| *w)
391            .context("transpose_concat_for_gemm: empty parts")?;
392        let half_k = k / 2;
393        let num_groups = k / group_size;
394        let n_total: usize = parts.iter().map(|(_, n)| *n).sum();
395        debug_assert!(
396            stride >= n_total,
397            "transpose_impl: stride {stride} < n_total {n_total}"
398        );
399
400        // GPU path: per part, one transpose_u8 launch into a contiguous
401        // [half_k, n] temp, then ONE pitched 2D copy into the strided dest
402        // column window (cudaMemcpy2DAsync on the CUDA backend). Replaces
403        // the D2H -> host O(N*K) byte loop -> H2D cold-load bounce. Pad
404        // columns `n_total..stride` are zeroed by the memset up front,
405        // matching the host path's zeroed staging vec.
406        if let Some(tk) = Self::host_transpose_kernel(gpu) {
407            let new_weight = gpu.alloc(stride * half_k)?;
408            let new_scale = gpu.alloc(stride * num_groups)?;
409            if stride > n_total {
410                gpu.memset(new_weight, 0, stride * half_k)?;
411                gpu.memset(new_scale, 0, stride * num_groups)?;
412            }
413            let mut temps: Vec<DevicePtr> = Vec::with_capacity(parts.len() * 2);
414            let mut n_off = 0usize;
415            for (w, n) in parts {
416                let n = *n;
417                let t_w = gpu.alloc(n * half_k)?;
418                crate::layers::ops::transpose_u8(
419                    gpu,
420                    tk,
421                    w.weight,
422                    t_w,
423                    n as u32,
424                    half_k as u32,
425                    0,
426                )?;
427                gpu.copy_d2d_2d_async(t_w, n, new_weight.offset(n_off), stride, n, half_k, 0)?;
428                let t_s = gpu.alloc(n * num_groups)?;
429                crate::layers::ops::transpose_u8(
430                    gpu,
431                    tk,
432                    w.weight_scale,
433                    t_s,
434                    n as u32,
435                    num_groups as u32,
436                    0,
437                )?;
438                gpu.copy_d2d_2d_async(t_s, n, new_scale.offset(n_off), stride, n, num_groups, 0)?;
439                temps.push(t_w);
440                temps.push(t_s);
441                n_off += n;
442            }
443            gpu.synchronize(0)?;
444            for t in temps {
445                gpu.free(t)?;
446            }
447            return Ok(QuantizedWeight {
448                weight: new_weight,
449                weight_scale: new_scale,
450                weight_scale_2: first.weight_scale_2,
451                input_scale: first.input_scale,
452                weight_scale_2_vec: first.weight_scale_2_vec,
453            });
454        }
455
456        let mut t_buf = vec![0u8; stride * half_k];
457        let mut st_buf = vec![0u8; stride * num_groups];
458        let mut n_off = 0usize;
459        for (w, n) in parts {
460            let n = *n;
461            let mut buf = vec![0u8; n * half_k];
462            gpu.copy_d2h(w.weight, &mut buf)?;
463            for i in 0..n {
464                for j in 0..half_k {
465                    t_buf[j * stride + n_off + i] = buf[i * half_k + j];
466                }
467            }
468            let mut sbuf = vec![0u8; n * num_groups];
469            gpu.copy_d2h(w.weight_scale, &mut sbuf)?;
470            for i in 0..n {
471                for j in 0..num_groups {
472                    st_buf[j * stride + n_off + i] = sbuf[i * num_groups + j];
473                }
474            }
475            n_off += n;
476        }
477
478        let new_weight = gpu.alloc(t_buf.len())?;
479        gpu.copy_h2d(&t_buf, new_weight)?;
480        let new_scale = gpu.alloc(st_buf.len())?;
481        gpu.copy_h2d(&st_buf, new_scale)?;
482
483        Ok(QuantizedWeight {
484            weight: new_weight,
485            weight_scale: new_scale,
486            weight_scale_2: first.weight_scale_2,
487            input_scale: first.input_scale,
488            weight_scale_2_vec: first.weight_scale_2_vec,
489        })
490    }
491
492    /// Pre-dequant NVFP4 → FP8 E4M3 for zero-overhead prefill GEMMs.
493    ///
494    /// Reads B_packed[N, K/2] + B_scale[N, K/GROUP_SIZE] + scale2 and produces
495    /// B_fp8[N, K] on GPU.  The resulting DevicePtr can be used with `fp8_gemm_t`
496    /// which eliminates the per-inference dequant phase entirely.
497    pub fn predequant_to_fp8(
498        &self,
499        gpu: &dyn GpuBackend,
500        predequant_kernel: spark_runtime::gpu::KernelHandle,
501        n: usize,
502        k: usize,
503        stream: u64,
504    ) -> Result<DevicePtr> {
505        let fp8_buf = gpu.alloc(n * k)?;
506        crate::layers::ops::predequant_nvfp4_to_fp8(
507            gpu,
508            predequant_kernel,
509            self.weight,
510            self.weight_scale,
511            self.weight_scale_2,
512            fp8_buf,
513            n as u32,
514            k as u32,
515            stream,
516        )?;
517        gpu.synchronize(stream)?;
518        Ok(fp8_buf)
519    }
520}
521
522/// BF16 dense weight (no quantization).
523#[derive(Debug, Clone, Copy)]
524pub struct DenseWeight {
525    pub weight: DevicePtr,
526}
527
528impl DenseWeight {
529    /// Quantize a BF16 weight `[N, K]` to FP8 E4M3 `[N, K]` with per-row
530    /// f32 scales. Allocates the FP8 buffer + row_scale buffer on the
531    /// GPU, runs the `quantize_bf16_to_fp8` kernel, and returns the
532    /// resulting [`Fp8DenseWeight`].
533    ///
534    /// Called once at model load time. Caller is responsible for any
535    /// stream synchronization needed before the returned weight is
536    /// consumed by `fp8_gemm_n128` or related kernels.
537    ///
538    /// Phase G (DFlash drafter FP8 weights). Mirrors
539    /// [`QuantizedWeight::predequant_to_fp8`] for the BF16 source path.
540    pub fn quantize_to_fp8(
541        &self,
542        gpu: &dyn GpuBackend,
543        quantize_kernel: spark_runtime::gpu::KernelHandle,
544        n: usize,
545        k: usize,
546        stream: u64,
547    ) -> Result<Fp8DenseWeight> {
548        let fp8_buf = gpu.alloc(n * k)?;
549        let row_scale_buf = gpu.alloc(n * std::mem::size_of::<f32>())?;
550        crate::layers::ops::quantize_bf16_to_fp8(
551            gpu,
552            quantize_kernel,
553            self.weight,
554            fp8_buf,
555            row_scale_buf,
556            n as u32,
557            k as u32,
558            stream,
559        )?;
560        gpu.synchronize(stream)?;
561        Ok(Fp8DenseWeight {
562            weight: fp8_buf,
563            row_scale: row_scale_buf,
564        })
565    }
566}
567
568/// FP8 E4M3 dense weight (runtime-quantized from BF16).
569///
570/// Halves weight bandwidth vs BF16. Per-row f32 scale preserves accuracy.
571/// Created at model load time via GPU-side quantization kernel.
572#[derive(Debug, Clone, Copy)]
573pub struct Fp8DenseWeight {
574    /// FP8 E4M3 weight data: [N, K] bytes.
575    pub weight: DevicePtr,
576    /// Per-row dequant scale: `[N]` f32.
577    pub row_scale: DevicePtr,
578}
579
580/// FP8 E4M3 checkpoint weight loaded directly from safetensors.
581///
582/// This struct carries an FP8 weight buffer along with its dequantization
583/// scale. The exact scale layout depends on the [`WeightQuantFormat`] tag
584/// in `scale_format`:
585///   - [`WeightQuantFormat::Fp8PerRow`] — `scale` is `[N]` f32 per-row.
586///   - [`WeightQuantFormat::Fp8BlockScaled`] — `scale` is `[N/BS, K/BS]`
587///     BF16 per-block (BS = 128 typically, the Qwen FP8 release convention).
588///   - [`WeightQuantFormat::Fp8SingleScale`] — `scale` is the NULL DevicePtr;
589///     a single global scale is baked into the kernel that consumes this.
590///
591/// **Always check `scale_format` before reading `scale` as a particular
592/// shape.** Prior to the format tag (Phase 2c day-3 follow-up), the
593/// `Fp8Weight` struct silently mixed all three layouts in a single
594/// field, causing a `cuMemcpyDtoDAsync_v2 INVALID_VALUE` crash when the
595/// SSM build path tried to concat per-row F32 scales out of a buffer
596/// that actually held per-block BF16 scales (lower memory than expected).
597#[derive(Debug, Clone, Copy)]
598pub struct Fp8Weight {
599    /// [N, K] FP8 E4M3 weight bytes on GPU.
600    pub weight: DevicePtr,
601    /// Dequantization scale pointer. **Shape and dtype depend on
602    /// `scale_format`** — see struct docs.
603    pub row_scale: DevicePtr,
604    /// Output dimension (rows).
605    pub n: u32,
606    /// Input dimension (columns).
607    pub k: u32,
608    /// Tag for the `row_scale` buffer's actual format. Asserted at
609    /// kernel call sites via `WeightQuantFormat::expect(...)`.
610    pub scale_format: WeightQuantFormat,
611}
612
613/// FP8 E4M3 weight with transposed layout for coalesced prefill GEMM.
614///
615/// B_t: [K, N] — transposed from checkpoint's B[N, K].
616/// block_scale_t: [K/128, N/128] — transposed from [N/128, K/128].
617/// Enables ~14x faster prefill via w8a16_gemm_t kernel.
618#[derive(Debug, Clone, Copy)]
619pub struct Fp8WeightTransposed {
620    /// [K, N] FP8 E4M3 transposed weight on GPU.
621    pub weight_t: DevicePtr,
622    /// [K/128, N/128] FP32 transposed block scales on GPU (widened at load).
623    pub scale_t: DevicePtr,
624    pub n: u32,
625    pub k: u32,
626}
627
628impl Fp8Weight {
629    /// Transpose this FP8 weight for coalesced prefill GEMM.
630    /// Allocates new GPU buffers for `B_t[K,N]` (FP8 bytes) and
631    /// `scale_t[K/128, N/128]` (FP32; `row_scale` is already FP32).
632    pub fn transpose_for_gemm(
633        &self,
634        gpu: &dyn GpuBackend,
635        transpose_k: spark_runtime::gpu::KernelHandle,
636        transpose_scale_k: spark_runtime::gpu::KernelHandle,
637        stream: u64,
638    ) -> anyhow::Result<Fp8WeightTransposed> {
639        let n = self.n as usize;
640        let k = self.k as usize;
641
642        // Allocate transposed weight: [K, N] bytes
643        let weight_t = gpu.alloc(k * n)?;
644        crate::layers::ops::transpose_fp8(
645            gpu,
646            transpose_k,
647            self.weight,
648            weight_t,
649            self.n,
650            self.k,
651            stream,
652        )?;
653
654        // Allocate transposed scale: [K/128, N/128] × 4 bytes (FP32).
655        // `row_scale` is now an FP32 block-scale buffer (widened at load), and
656        // `transpose_block_scale` is an FP32→FP32 transpose — see
657        // `load_fp8_block_scaled_as_fp8weight` / `w8a16_gemm_t.cu`.
658        let n_blocks = n.div_ceil(128);
659        let k_blocks = k.div_ceil(128);
660        let scale_t = gpu.alloc(k_blocks * n_blocks * 4)?;
661        crate::layers::ops::transpose_block_scale(
662            gpu,
663            transpose_scale_k,
664            self.row_scale,
665            scale_t,
666            n_blocks as u32,
667            k_blocks as u32,
668            stream,
669        )?;
670
671        gpu.synchronize(stream)?;
672
673        Ok(Fp8WeightTransposed {
674            weight_t,
675            scale_t,
676            n: self.n,
677            k: self.k,
678        })
679    }
680}