spark_model/layers/glm5next_dsa/
build.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Loading one DSA block: TP sharding plus the three load-time transforms the runtime
4//! cannot do per token.
5//!
6//! Takes a `load` closure yielding an uploaded BF16 tensor by layer-relative name, rather
7//! than a `WeightStore`, so the transforms are testable and the loader wiring stays one
8//! call site.
9//!
10//! # The three transforms, and why each is here rather than in `decode`
11//!
12//! 1. **`q_absorb`** — `q_b_proj` pre-multiplied by `kv_b_proj`'s K half, so Q arrives in
13//!    the 512-dim latent space the decode kernel dots against. Doing it per token would be
14//!    a second GEMM on the critical path for a weight that never changes.
15//! 2. **`weights_proj` scaled by `index_heads^-0.5`** — `dsa_index_scores` does not apply
16//!    the factor. Folding it into the weight is exact (a positive scalar) and free.
17//! 3. **`o_absorb`** — `o_proj` pre-multiplied by `kv_b_proj`'s V half, the counterpart of
18//!    `q_absorb` and the half that was missing. The decode kernel consumes the latent KV
19//!    directly, so its output is `[local_heads, kv_lora_rank]` in LATENT space; the raw
20//!    checkpoint `o_proj` expects `[local_heads, v_head_dim]` in V space. Feeding one to the
21//!    other is not a numeric drift — the GEMM reads `kv_lora_rank / v_head_dim` = 2x past
22//!    the end of every weight row. Measured 2026-08-28: `CUDA_ERROR_ILLEGAL_ADDRESS` at
23//!    layer 3, `grid=[256,1,1] block=[16,16,1]`, on the first forward that reached it.
24//! 4. **`ape` upconverted BF16 → F32** — the kernel's parameter is `const float*` while
25//!    the checkpoint stores BF16. This is the #341/#347 dtype-mismatch class: reading it
26//!    at the wrong width is silent.
27
28use anyhow::{Result, bail};
29use spark_runtime::gpu::{DevicePtr, GpuBackend};
30
31use super::Glm5NextDsaConfig;
32use super::layer::Glm5NextDsaWeights;
33use super::tp::{DsaShard, DsaTpPlan};
34
35/// A tensor as the checkpoint holds it: full (unsharded) BF16 values on the host.
36pub type LoadFn<'a> = &'a dyn Fn(&str) -> Result<Vec<f32>>;
37
38/// `q_absorb[h*kvl + c][k] = Σ_r kv_b[h*(nope+vd) + r][c] · q_b[h*nope + r][k]`.
39///
40/// Per head this is `W_k^Tᐧq_b` — an `A^T B` contraction, which the `A @ B^T` GEMM kernel
41/// cannot express without a transpose, so it runs on the host once at load.
42///
43/// 🪤 `q_b_proj` and `kv_b_proj` carry **different per-head widths** (`qk_head_dim` = 256
44/// vs `nope + v_head_dim` = 512). Using one stride for the other still yields a
45/// well-formed 2-D tensor of plausible values.
46pub fn absorb_q(
47    cfg: &Glm5NextDsaConfig,
48    q_b: &[f32],
49    kv_b: &[f32],
50    full_heads: usize,
51) -> Result<Vec<f32>> {
52    let (nope, vd, kvl, ql) = (
53        cfg.qk_nope_head_dim,
54        cfg.v_head_dim,
55        cfg.kv_lora_rank,
56        cfg.q_lora_rank,
57    );
58    let qk = cfg.qk_head_dim();
59    if q_b.len() != full_heads * qk * ql {
60        bail!(
61            "absorb_q: q_b_proj has {} elems, expected {}",
62            q_b.len(),
63            full_heads * qk * ql
64        );
65    }
66    if kv_b.len() != full_heads * (nope + vd) * kvl {
67        bail!(
68            "absorb_q: kv_b_proj has {} elems, expected {}",
69            kv_b.len(),
70            full_heads * (nope + vd) * kvl
71        );
72    }
73    // NoPE: qk_head_dim == qk_nope_head_dim, so the K half of kv_b lines up with the whole
74    // of q_b. A rope section would need the rope rows carried separately and is refused.
75    if cfg.qk_rope_head_dim != 0 {
76        bail!(
77            "absorb_q: NoPE only; qk_rope_head_dim is {}",
78            cfg.qk_rope_head_dim
79        );
80    }
81
82    let mut out = vec![0f32; full_heads * kvl * ql];
83    for h in 0..full_heads {
84        let kv_base = h * (nope + vd);
85        let qb_base = h * nope;
86        for c in 0..kvl {
87            for k in 0..ql {
88                let mut acc = 0f32;
89                for r in 0..nope {
90                    acc += kv_b[(kv_base + r) * kvl + c] * q_b[(qb_base + r) * ql + k];
91                }
92                out[(h * kvl + c) * ql + k] = acc;
93            }
94        }
95    }
96    Ok(out)
97}
98
99/// `o_absorb[i][h*kvl + c] = Σ_r o_proj[i][h*vd + r] · kv_b[h*(nope+vd) + nope + r][c]`.
100///
101/// The output-side twin of [`absorb_q`]. Absorbed MLA is a PAIR of transforms — Q into the
102/// latent space on the way in, the output projection back out of it on the way out — and
103/// shipping only the first leaves the decode kernel's latent output being read by a
104/// V-space weight.
105///
106/// 🪤 Unlike `absorb_q`, this one is done AFTER sharding, and that is not an optimisation
107/// that happens to be safe — it is exact. `absorb_q` pairs `q_b` head `h` with `kv_b` head
108/// `h`, so slicing before pairing would cross heads. Here both operands are indexed by the
109/// SAME `h` and the head axis is a plain outer sum, so this rank's heads never touch
110/// another rank's rows. Doing it on full heads would double the load-time cost for an
111/// identical result.
112///
113/// 🪤 `kv_b`'s V half starts at `nope`, not 0. Using the K half compiles, runs, and gives a
114/// well-formed wrong answer — the same trap `absorb_q` documents from the other side.
115pub fn absorb_o(
116    cfg: &Glm5NextDsaConfig,
117    o_local: &[f32],
118    kv_b_local: &[f32],
119    local_heads: usize,
120) -> Result<Vec<f32>> {
121    let (nope, vd, kvl, hidden) = (
122        cfg.qk_nope_head_dim,
123        cfg.v_head_dim,
124        cfg.kv_lora_rank,
125        cfg.hidden,
126    );
127    if cfg.qk_rope_head_dim != 0 {
128        bail!(
129            "absorb_o: NoPE only; qk_rope_head_dim is {}",
130            cfg.qk_rope_head_dim
131        );
132    }
133    let in_w = local_heads * vd;
134    let out_w = local_heads * kvl;
135    if o_local.len() != hidden * in_w {
136        bail!(
137            "absorb_o: o_proj has {} elems, expected {} ([{hidden}, {in_w}])",
138            o_local.len(),
139            hidden * in_w
140        );
141    }
142    if kv_b_local.len() != local_heads * (nope + vd) * kvl {
143        bail!(
144            "absorb_o: kv_b_proj slice has {} elems, expected {}",
145            kv_b_local.len(),
146            local_heads * (nope + vd) * kvl
147        );
148    }
149
150    let mut out = vec![0f32; hidden * out_w];
151    // Rows are independent and contiguous, so a plain row split is the whole story.
152    // Single-threaded this is `hidden * local_heads * kvl * vd` MACs — 17 G at GLM-5.3's
153    // TP=2 shape, per layer, times 11 layers, on the GB10's CPU. That is minutes of load
154    // time, paid on every bring-up.
155    let threads = std::thread::available_parallelism()
156        .map(|n| n.get())
157        .unwrap_or(1)
158        .clamp(1, hidden);
159    let rows = hidden.div_ceil(threads);
160    std::thread::scope(|sc| {
161        for (o_chunk, i_chunk) in out
162            .chunks_mut(rows * out_w)
163            .zip(o_local.chunks(rows * in_w))
164        {
165            sc.spawn(move || {
166                for (dst_row, src_row) in o_chunk.chunks_mut(out_w).zip(i_chunk.chunks(in_w)) {
167                    for h in 0..local_heads {
168                        let dst = &mut dst_row[h * kvl..(h + 1) * kvl];
169                        let v_base = h * (nope + vd) + nope;
170                        for r in 0..vd {
171                            let w = src_row[h * vd + r];
172                            let src = &kv_b_local[(v_base + r) * kvl..(v_base + r) * kvl + kvl];
173                            for (d, k) in dst.iter_mut().zip(src) {
174                                *d += w * k;
175                            }
176                        }
177                    }
178                }
179            });
180        }
181    });
182    Ok(out)
183}
184
185/// Rows `[start, end)` of a `[rows, row_elems]` row-major tensor.
186fn row_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
187    v[start * row_elems..end * row_elems].to_vec()
188}
189
190/// Column range `[start, end)` of every row — the row-parallel case (`o_proj`).
191fn col_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
192    v.chunks(row_elems)
193        .flat_map(|r| r[start..end].iter().copied())
194        .collect()
195}
196
197/// Apply one tensor's shard plan to full host values.
198pub fn shard_host(plan: &super::tp::DsaTensorPlan, full: &[f32]) -> Vec<f32> {
199    match plan.kind {
200        DsaShard::Replicated => full.to_vec(),
201        DsaShard::HeadRows => row_slice(
202            full,
203            plan.full_row_elems,
204            plan.src_row_offset,
205            plan.src_row_offset + plan.local_rows,
206        ),
207        DsaShard::HeadCols => col_slice(
208            full,
209            plan.full_row_elems,
210            plan.src_col_offset,
211            plan.src_col_offset + plan.local_row_elems,
212        ),
213    }
214}
215
216fn up_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
217    let b: Vec<u8> = v
218        .iter()
219        .flat_map(|x| half::bf16::from_f32(*x).to_le_bytes())
220        .collect();
221    let p = gpu.alloc(b.len().max(1))?;
222    gpu.copy_h2d(&b, p)?;
223    Ok(p)
224}
225fn up_f32(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
226    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
227    let p = gpu.alloc(b.len().max(1))?;
228    gpu.copy_h2d(&b, p)?;
229    Ok(p)
230}
231
232/// Bind one DSA block for this rank.
233pub fn build_dsa_weights(
234    gpu: &dyn GpuBackend,
235    cfg: &Glm5NextDsaConfig,
236    plan: &DsaTpPlan,
237    load: LoadFn<'_>,
238) -> Result<Glm5NextDsaWeights> {
239    let get = |n: &str| -> Result<Vec<f32>> { load(&format!("self_attn.{n}")) };
240    let shard = |n: &'static str, full: Vec<f32>| -> Result<Vec<f32>> {
241        let p = plan
242            .get(n)
243            .ok_or_else(|| anyhow::anyhow!("no shard plan for {n}"))?;
244        Ok(shard_host(p, &full))
245    };
246
247    let kv_b = get("kv_b_proj.weight")?;
248
249    // ── transform 1: absorb Q into latent space, THEN shard by head ──
250    // Absorption is over full heads because it pairs q_b and kv_b head-for-head; slicing
251    // first would pair this rank's q_b heads with the wrong kv_b rows.
252    let q_absorb_full = absorb_q(cfg, &get("q_b_proj.weight")?, &kv_b, plan.full_heads)?;
253    let per_head = cfg.kv_lora_rank;
254    let start = plan.tp_rank * plan.local_heads * per_head;
255    let len = plan.local_heads * per_head;
256    let q_absorb = row_slice(&q_absorb_full, cfg.q_lora_rank, start, start + len);
257
258    // ── transform 3: absorb the output projection back OUT of latent space ──
259    // Sharded first (see `absorb_o`): both operands index the same head, so this rank's
260    // slice is exact and costs half the arithmetic.
261    let kv_b_rows = cfg.qk_nope_head_dim + cfg.v_head_dim;
262    let kv_b_local = row_slice(
263        &kv_b,
264        cfg.kv_lora_rank,
265        plan.tp_rank * plan.local_heads * kv_b_rows,
266        (plan.tp_rank + 1) * plan.local_heads * kv_b_rows,
267    );
268    let o_absorb = absorb_o(
269        cfg,
270        &shard("o_proj", get("o_proj.weight")?)?,
271        &kv_b_local,
272        plan.local_heads,
273    )?;
274
275    // ── transform 2: fold index_heads^-0.5 into weights_proj ──
276    let scale = (cfg.index_heads as f32).powf(-0.5);
277    let weights_proj: Vec<f32> = get("indexer.weights_proj.weight")?
278        .iter()
279        .map(|x| x * scale)
280        .collect();
281
282    // ── transform 4: ape BF16 on disk -> F32 for the kernel ──
283    let ape = get("indexer.index_kpool_compress_ape")?;
284
285    Ok(Glm5NextDsaWeights {
286        q_a_proj: up_bf16(gpu, &get("q_a_proj.weight")?)?,
287        q_a_layernorm: up_bf16(gpu, &get("q_a_layernorm.weight")?)?,
288        q_absorb: up_bf16(gpu, &q_absorb)?,
289        kv_a_proj: up_bf16(gpu, &get("kv_a_proj_with_mqa.weight")?)?,
290        kv_a_layernorm: up_bf16(gpu, &get("kv_a_layernorm.weight")?)?,
291        o_absorb: up_bf16(gpu, &o_absorb)?,
292        wk: up_bf16(gpu, &get("indexer.wk.weight")?)?,
293        k_norm_weight: up_bf16(gpu, &get("indexer.k_norm.weight")?)?,
294        // 🪤 REQUIRED — LayerNorm bias, not optional.
295        k_norm_bias: up_bf16(gpu, &get("indexer.k_norm.bias")?)?,
296        compress_gate: up_bf16(gpu, &get("indexer.index_kpool_compress_gate")?)?,
297        wq_b: up_bf16(gpu, &get("indexer.wq_b.weight")?)?,
298        weights_proj: up_bf16(gpu, &weights_proj)?,
299        ape: up_f32(gpu, &ape)?,
300    })
301}
302
303#[cfg(test)]
304mod tests;