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}