spark_runtime/cublaslt/
scale_layout.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! SSOT for the cuBLASLt block-scaling factor layouts — what the library
4//! documents, mirrored as index math the CUDA adapter kernel and the CPU tests
5//! share.
6//!
7//! WHY THIS FILE EXISTS. Measured on 1xH100 (2026-09-11 07:15Z,
8//! `native_fp8_ffn_w8a8_microtest`, tip `5f78270dc`, cuBLASLt 13.1): the
9//! [`super::fp8_gemm_act_weight_t_blkscaled`] arm ran at 1140 TFLOP/s but
10//! disagreed with the in-tree `fp8_gemm_t_blockscaled` on the SAME quantized
11//! inputs — rel_rms 1.1e-2 / cosine 0.99994 at M=64, 7.7e-2-8.8e-2 / cosine
12//! 0.996 at M=1193, ~33000 BF16 ULP, 84-94% of elements unequal. Two correct
13//! W8A8 implementations over identical FP8 bytes with FP32 accumulation agree
14//! to ~1e-3, and the error grew with M, so the defect was a scale-tensor
15//! layout, not arithmetic. It was: the activation (VEC128) scale tensor was
16//! handed over in the quantizer's `[M, K/128]` order, and cuBLASLt reads that
17//! operand's scales MN-major.
18//!
19//! THE DOCUMENTED RULES (cuBLAS 13.4 manual, "128-element 1D and 128x128 2D
20//! Block Scaling For FP8 Data Types" and its "Scaling factors layouts"
21//! subsection):
22//!
23//! * Supported mode pairs are VEC128/VEC128, VEC128/BLK128x128 and
24//!   BLK128x128/VEC128; BLK128x128 on BOTH A and B is listed unsupported. The
25//!   A=BLK128x128 (weight) + B=VEC128 (activation) pairing Atlas uses is
26//!   therefore legal as written — the pairing was never the bug.
27//! * Scaling-factor start addresses must be 16 B aligned, and the matmul's M
28//!   and N "must be multiples of 4" — which is what the caller's ceil16(M) pad
29//!   satisfies for the token dimension.
30//! * VEC128_32F: the factors are "M-major for A with shape M x L" and
31//!   "N-major for B with shape N x L", where `L = ceil(K/128)` and major means
32//!   that dimension is contiguous. So for B the token index is contiguous and
33//!   the K-group index strides by the (padded) token count — the TRANSPOSE of
34//!   the `[M, K/128]` the quantizer writes.
35//! * BLK128x128_32F: the factors are K-major, "the stride between the
36//!   consecutive columns must be a multiple of 4", shape `L4 x ceil(M/128)`
37//!   for A (`L4 x ceil(N/128)` for B) with `L4` = L rounded up to a multiple
38//!   of 4. K-major with `ceil(M/128)` columns IS the checkpoint's row-major
39//!   `[N/128, K/128]` weight-scale grid whenever L is already a multiple of 4
40//!   — see [`blk128x128_stride_ok`]. The weight side needed no change.
41//!
42//! Atlas maps `out[M,N] = act[M,K] @ weight[N,K]ᵀ` onto cuBLASLt as
43//! `D[N,M] = opT(weightᶜ[K,N]) · opN(actᶜ[K,M])`, so the library's M is the
44//! weight's N and the library's N is the token count. Read the doc quotes
45//! above with that substitution: the VEC128 "N-major" operand is the
46//! activation, and its contiguous dimension is tokens.
47
48/// Number of 128-wide K groups a K extent carries (`L` in the cuBLAS docs).
49#[must_use]
50pub fn k_groups(k: usize) -> usize {
51    k.div_ceil(128)
52}
53
54/// Offset of the VEC128 scale for `(token, k_group)` in the layout cuBLASLt
55/// documents for the B operand: shape `N x L`, N-major, N = `m_pad` tokens.
56///
57/// This is the SSOT the CUDA adapter `fp8_act_scale_to_kmajor` mirrors.
58#[must_use]
59pub const fn vec128_b_index(m_pad: usize, token: usize, k_group: usize) -> usize {
60    k_group * m_pad + token
61}
62
63/// Offset of the same `(token, k_group)` scale in the layout
64/// `per_token_group_quant_fp8` writes: row-major `[M, K/128]`, K-group
65/// contiguous. The in-tree `fp8_gemm_t_blockscaled` indexes this one.
66#[must_use]
67pub const fn rowmajor_index(l: usize, token: usize, k_group: usize) -> usize {
68    token * l + k_group
69}
70
71/// FP32 element count of the VEC128 B-scale tensor cuBLASLt reads.
72#[must_use]
73pub fn vec128_b_elems(m_pad: usize, k: usize) -> usize {
74    m_pad * k_groups(k)
75}
76
77/// Whether a checkpoint's row-major `[N/128, K/128]` weight-scale grid already
78/// satisfies the BLK128x128 column-stride rule ("must be a multiple of 4"),
79/// i.e. whether `L = ceil(K/128)` needs no padding to `L4`.
80///
81/// True for every shape Atlas serves today (K=5120 → L=40, K=17408 → L=136),
82/// which is why the weight scales pass through untouched. A K that breaks it
83/// would need a padded copy, so the dispatch gate checks this rather than
84/// assuming it.
85#[must_use]
86pub fn blk128x128_stride_ok(k: usize) -> bool {
87    k_groups(k).is_multiple_of(4)
88}
89
90/// CPU reference for the `fp8_act_scale_to_kmajor` CUDA kernel: read the
91/// quantizer's row-major `[m, l]` scales, write cuBLASLt's `[l, m_pad]`, with
92/// the `m..m_pad` pad slots zeroed (their FP8 activation bytes are zeroed too,
93/// so the phantom rows contribute a defined zero).
94///
95/// Exists so the layout can be pinned by a unit test on any host — the GPU
96/// kernel is one line of index math and this is that line, in Rust.
97#[must_use]
98pub fn act_scale_rowmajor_to_kmajor(src: &[f32], m: usize, m_pad: usize, l: usize) -> Vec<f32> {
99    debug_assert!(m_pad >= m, "pad cannot shrink the token extent");
100    debug_assert!(src.len() >= m * l, "source holds fewer than m x l scales");
101    let mut dst = vec![0.0f32; vec128_b_elems(m_pad, l * 128)];
102    for token in 0..m {
103        for kg in 0..l {
104            dst[vec128_b_index(m_pad, token, kg)] = src[rowmajor_index(l, token, kg)];
105        }
106    }
107    dst
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    /// Known values, hand-placed. `src` is `[M=3, L=2]` row-major, so reading
115    /// it as-is gives `[1, 2, 3, 4, 5, 6]`; the VEC128 B layout wants tokens
116    /// contiguous within each K group.
117    #[test]
118    fn kmajor_adapter_transposes_and_zero_fills_the_pad() {
119        let src = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
120        let dst = act_scale_rowmajor_to_kmajor(&src, 3, 4, 2);
121        // group 0: tokens 0..3 then the pad token; group 1: the same.
122        assert_eq!(dst, vec![1.0, 3.0, 5.0, 0.0, 2.0, 4.0, 6.0, 0.0]);
123    }
124
125    #[test]
126    fn adapter_is_identity_free_only_when_one_dimension_is_trivial() {
127        // A single K group or a single token means the two orders coincide —
128        // useful as a sanity anchor, and the reason a 1-token decode never
129        // showed the bug.
130        let src = [7.0f32, 8.0, 9.0];
131        assert_eq!(
132            act_scale_rowmajor_to_kmajor(&src, 3, 3, 1),
133            vec![7.0, 8.0, 9.0]
134        );
135        assert_eq!(
136            act_scale_rowmajor_to_kmajor(&src, 1, 1, 3),
137            vec![7.0, 8.0, 9.0]
138        );
139    }
140
141    #[test]
142    fn the_two_readings_disagree_at_the_measured_shape() {
143        // WHY: this is the H100 failure, reduced. Same buffer, two readings;
144        // at M=64/K=5120 the offsets coincide only where 63*kg == 39*token, so
145        // all but 4 of the 2560 scales land on the wrong element. A permutation
146        // of plausible per-token scale values is "wrong but not garbage" —
147        // cosine 0.99994, not 0, which is what made this look like precision.
148        let (m_pad, l) = (64usize, 40usize);
149        let same = (0..m_pad)
150            .flat_map(|token| (0..l).map(move |kg| (token, kg)))
151            .filter(|(token, kg)| {
152                vec128_b_index(m_pad, *token, *kg) == rowmajor_index(l, *token, *kg)
153            })
154            .count();
155        assert_eq!(same, 4, "only (0,0), (21,13), (42,26), (63,39) coincide");
156        assert_eq!(m_pad * l - same, 2556);
157    }
158
159    #[test]
160    fn documented_extents_match_the_shapes_we_serve() {
161        // Qwen3.8-27B dense FFN: gate/up K=5120, down K=17408.
162        assert_eq!(k_groups(5120), 40);
163        assert_eq!(k_groups(17408), 136);
164        assert!(blk128x128_stride_ok(5120));
165        assert!(blk128x128_stride_ok(17408));
166        // A K whose group count is not a multiple of 4 would need the L4 pad.
167        assert!(!blk128x128_stride_ok(128 * 5));
168        assert_eq!(vec128_b_elems(1200, 5120), 1200 * 40);
169    }
170}