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/// Worker count for the absorb loops. Shared spelling with
45/// `mistral_loader::loader_impl::phase_qk_absorbed`, including the
46/// `ATLAS_MLA_ABSORB_THREADS` override, so one lever tunes both.
47fn absorb_threads(rows: usize) -> usize {
48    let want = std::env::var("ATLAS_MLA_ABSORB_THREADS")
49        .ok()
50        .and_then(|v| v.trim().parse::<usize>().ok())
51        .filter(|&n| n > 0)
52        .unwrap_or_else(|| {
53            std::thread::available_parallelism()
54                .map(|n| n.get())
55                .unwrap_or(1)
56        });
57    want.clamp(1, rows.max(1))
58}
59
60/// A disjoint span of `absorb_q` output rows, row `row0` onward.
61///
62/// 🔴 The accumulation order over `r` is IDENTICAL to the original nest, so
63/// every output element is the same f32 sequence of adds — this is a memory
64/// layout and threading change, not a numerics one. The original indexed
65/// `kv_b[(kv_base+r)*kvl + c]` inside the `k` loop, re-loading a strided scalar
66/// `ql` times; hoisting it makes the inner loop a contiguous walk of `q_b`'s
67/// row and cuts `kv_b` loads per output row from `nope*ql` to `nope`.
68#[allow(clippy::too_many_arguments)]
69fn absorb_q_rows(
70    out: &mut [f32],
71    row0: usize,
72    kv_b: &[f32],
73    q_b: &[f32],
74    kvl: usize,
75    ql: usize,
76    nope: usize,
77    vd: usize,
78) {
79    for (i, acc) in out.chunks_exact_mut(ql).enumerate() {
80        let row = row0 + i;
81        let h = row / kvl;
82        let c = row % kvl;
83        let kv_base = h * (nope + vd);
84        let qb_base = h * nope;
85        acc.fill(0.0);
86        for r in 0..nope {
87            let w = kv_b[(kv_base + r) * kvl + c];
88            let q_row = &q_b[(qb_base + r) * ql..][..ql];
89            for (a, &q) in acc.iter_mut().zip(q_row.iter()) {
90                *a += q * w;
91            }
92        }
93    }
94}
95
96/// vs `nope + v_head_dim` = 512). Using one stride for the other still yields a
97/// well-formed 2-D tensor of plausible values.
98pub fn absorb_q(
99    cfg: &Glm5NextDsaConfig,
100    q_b: &[f32],
101    kv_b: &[f32],
102    full_heads: usize,
103) -> Result<Vec<f32>> {
104    let (nope, vd, kvl, ql) = (
105        cfg.qk_nope_head_dim,
106        cfg.v_head_dim,
107        cfg.kv_lora_rank,
108        cfg.q_lora_rank,
109    );
110    let qk = cfg.qk_head_dim();
111    if q_b.len() != full_heads * qk * ql {
112        bail!(
113            "absorb_q: q_b_proj has {} elems, expected {}",
114            q_b.len(),
115            full_heads * qk * ql
116        );
117    }
118    if kv_b.len() != full_heads * (nope + vd) * kvl {
119        bail!(
120            "absorb_q: kv_b_proj has {} elems, expected {}",
121            kv_b.len(),
122            full_heads * (nope + vd) * kvl
123        );
124    }
125    // NoPE: qk_head_dim == qk_nope_head_dim, so the K half of kv_b lines up with the whole
126    // of q_b. A rope section would need the rope rows carried separately and is refused.
127    if cfg.qk_rope_head_dim != 0 {
128        bail!(
129            "absorb_q: NoPE only; qk_rope_head_dim is {}",
130            cfg.qk_rope_head_dim
131        );
132    }
133
134    let rows = full_heads * kvl;
135    let mut out = vec![0f32; rows * ql];
136    let threads = absorb_threads(rows);
137    if threads <= 1 {
138        absorb_q_rows(&mut out, 0, kv_b, q_b, kvl, ql, nope, vd);
139    } else {
140        let rows_per = rows.div_ceil(threads);
141        std::thread::scope(|scope| {
142            for (chunk_idx, chunk) in out.chunks_mut(rows_per * ql).enumerate() {
143                let row0 = chunk_idx * rows_per;
144                scope.spawn(move || {
145                    absorb_q_rows(chunk, row0, kv_b, q_b, kvl, ql, nope, vd);
146                });
147            }
148        });
149    }
150    Ok(out)
151}
152
153/// `o_absorb[i][h*kvl + c] = Σ_r o_proj[i][h*vd + r] · kv_b[h*(nope+vd) + nope + r][c]`.
154///
155/// The output-side twin of [`absorb_q`]. Absorbed MLA is a PAIR of transforms — Q into the
156/// latent space on the way in, the output projection back out of it on the way out — and
157/// shipping only the first leaves the decode kernel's latent output being read by a
158/// V-space weight.
159///
160/// 🪤 Unlike `absorb_q`, this one is done AFTER sharding, and that is not an optimisation
161/// that happens to be safe — it is exact. `absorb_q` pairs `q_b` head `h` with `kv_b` head
162/// `h`, so slicing before pairing would cross heads. Here both operands are indexed by the
163/// SAME `h` and the head axis is a plain outer sum, so this rank's heads never touch
164/// another rank's rows. Doing it on full heads would double the load-time cost for an
165/// identical result.
166///
167/// 🪤 `kv_b`'s V half starts at `nope`, not 0. Using the K half compiles, runs, and gives a
168/// well-formed wrong answer — the same trap `absorb_q` documents from the other side.
169pub fn absorb_o(
170    cfg: &Glm5NextDsaConfig,
171    o_local: &[f32],
172    kv_b_local: &[f32],
173    local_heads: usize,
174) -> Result<Vec<f32>> {
175    let (nope, vd, kvl, hidden) = (
176        cfg.qk_nope_head_dim,
177        cfg.v_head_dim,
178        cfg.kv_lora_rank,
179        cfg.hidden,
180    );
181    if cfg.qk_rope_head_dim != 0 {
182        bail!(
183            "absorb_o: NoPE only; qk_rope_head_dim is {}",
184            cfg.qk_rope_head_dim
185        );
186    }
187    let in_w = local_heads * vd;
188    let out_w = local_heads * kvl;
189    if o_local.len() != hidden * in_w {
190        bail!(
191            "absorb_o: o_proj has {} elems, expected {} ([{hidden}, {in_w}])",
192            o_local.len(),
193            hidden * in_w
194        );
195    }
196    if kv_b_local.len() != local_heads * (nope + vd) * kvl {
197        bail!(
198            "absorb_o: kv_b_proj slice has {} elems, expected {}",
199            kv_b_local.len(),
200            local_heads * (nope + vd) * kvl
201        );
202    }
203
204    let mut out = vec![0f32; hidden * out_w];
205    // Rows are independent and contiguous, so a plain row split is the whole story.
206    // Single-threaded this is `hidden * local_heads * kvl * vd` MACs — 17 G at GLM-5.3's
207    // TP=2 shape, per layer, times 11 layers, on the GB10's CPU. That is minutes of load
208    // time, paid on every bring-up.
209    let threads = std::thread::available_parallelism()
210        .map(|n| n.get())
211        .unwrap_or(1)
212        .clamp(1, hidden);
213    let rows = hidden.div_ceil(threads);
214    std::thread::scope(|sc| {
215        for (o_chunk, i_chunk) in out
216            .chunks_mut(rows * out_w)
217            .zip(o_local.chunks(rows * in_w))
218        {
219            sc.spawn(move || {
220                for (dst_row, src_row) in o_chunk.chunks_mut(out_w).zip(i_chunk.chunks(in_w)) {
221                    for h in 0..local_heads {
222                        let dst = &mut dst_row[h * kvl..(h + 1) * kvl];
223                        let v_base = h * (nope + vd) + nope;
224                        for r in 0..vd {
225                            let w = src_row[h * vd + r];
226                            let src = &kv_b_local[(v_base + r) * kvl..(v_base + r) * kvl + kvl];
227                            for (d, k) in dst.iter_mut().zip(src) {
228                                *d += w * k;
229                            }
230                        }
231                    }
232                }
233            });
234        }
235    });
236    Ok(out)
237}
238
239/// Rows `[start, end)` of a `[rows, row_elems]` row-major tensor.
240fn row_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
241    v[start * row_elems..end * row_elems].to_vec()
242}
243
244/// Column range `[start, end)` of every row — the row-parallel case (`o_proj`).
245fn col_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
246    v.chunks(row_elems)
247        .flat_map(|r| r[start..end].iter().copied())
248        .collect()
249}
250
251/// Apply one tensor's shard plan to full host values.
252pub fn shard_host(plan: &super::tp::DsaTensorPlan, full: &[f32]) -> Vec<f32> {
253    match plan.kind {
254        DsaShard::Replicated => full.to_vec(),
255        DsaShard::HeadRows => row_slice(
256            full,
257            plan.full_row_elems,
258            plan.src_row_offset,
259            plan.src_row_offset + plan.local_rows,
260        ),
261        DsaShard::HeadCols => col_slice(
262            full,
263            plan.full_row_elems,
264            plan.src_col_offset,
265            plan.src_col_offset + plan.local_row_elems,
266        ),
267    }
268}
269
270fn up_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
271    let b: Vec<u8> = v
272        .iter()
273        .flat_map(|x| half::bf16::from_f32(*x).to_le_bytes())
274        .collect();
275    let p = gpu.alloc(b.len().max(1))?;
276    gpu.copy_h2d(&b, p)?;
277    Ok(p)
278}
279fn up_f32(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
280    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
281    let p = gpu.alloc(b.len().max(1))?;
282    gpu.copy_h2d(&b, p)?;
283    Ok(p)
284}
285
286/// Bind one DSA block for this rank.
287pub fn build_dsa_weights(
288    gpu: &dyn GpuBackend,
289    cfg: &Glm5NextDsaConfig,
290    plan: &DsaTpPlan,
291    load: LoadFn<'_>,
292) -> Result<Glm5NextDsaWeights> {
293    let get = |n: &str| -> Result<Vec<f32>> { load(&format!("self_attn.{n}")) };
294    let shard = |n: &'static str, full: Vec<f32>| -> Result<Vec<f32>> {
295        let p = plan
296            .get(n)
297            .ok_or_else(|| anyhow::anyhow!("no shard plan for {n}"))?;
298        Ok(shard_host(p, &full))
299    };
300
301    let kv_b = get("kv_b_proj.weight")?;
302
303    // ── transform 1: absorb Q into latent space, THEN shard by head ──
304    // Absorption is over full heads because it pairs q_b and kv_b head-for-head; slicing
305    // first would pair this rank's q_b heads with the wrong kv_b rows.
306    let q_absorb_full = absorb_q(cfg, &get("q_b_proj.weight")?, &kv_b, plan.full_heads)?;
307    let per_head = cfg.kv_lora_rank;
308    let start = plan.tp_rank * plan.local_heads * per_head;
309    let len = plan.local_heads * per_head;
310    let q_absorb = row_slice(&q_absorb_full, cfg.q_lora_rank, start, start + len);
311
312    // ── transform 3: absorb the output projection back OUT of latent space ──
313    // Sharded first (see `absorb_o`): both operands index the same head, so this rank's
314    // slice is exact and costs half the arithmetic.
315    let kv_b_rows = cfg.qk_nope_head_dim + cfg.v_head_dim;
316    let kv_b_local = row_slice(
317        &kv_b,
318        cfg.kv_lora_rank,
319        plan.tp_rank * plan.local_heads * kv_b_rows,
320        (plan.tp_rank + 1) * plan.local_heads * kv_b_rows,
321    );
322    let o_absorb = absorb_o(
323        cfg,
324        &shard("o_proj", get("o_proj.weight")?)?,
325        &kv_b_local,
326        plan.local_heads,
327    )?;
328
329    // ── transform 2: fold index_heads^-0.5 into weights_proj ──
330    let scale = (cfg.index_heads as f32).powf(-0.5);
331    let weights_proj: Vec<f32> = get("indexer.weights_proj.weight")?
332        .iter()
333        .map(|x| x * scale)
334        .collect();
335
336    // ── transform 4: ape BF16 on disk -> F32 for the kernel ──
337    let ape = get("indexer.index_kpool_compress_ape")?;
338
339    Ok(Glm5NextDsaWeights {
340        q_a_proj: up_bf16(gpu, &get("q_a_proj.weight")?)?,
341        q_a_layernorm: up_bf16(gpu, &get("q_a_layernorm.weight")?)?,
342        q_absorb: up_bf16(gpu, &q_absorb)?,
343        kv_a_proj: up_bf16(gpu, &get("kv_a_proj_with_mqa.weight")?)?,
344        kv_a_layernorm: up_bf16(gpu, &get("kv_a_layernorm.weight")?)?,
345        o_absorb: up_bf16(gpu, &o_absorb)?,
346        wk: up_bf16(gpu, &get("indexer.wk.weight")?)?,
347        k_norm_weight: up_bf16(gpu, &get("indexer.k_norm.weight")?)?,
348        // 🪤 REQUIRED — LayerNorm bias, not optional.
349        k_norm_bias: up_bf16(gpu, &get("indexer.k_norm.bias")?)?,
350        compress_gate: up_bf16(gpu, &get("indexer.index_kpool_compress_gate")?)?,
351        wq_b: up_bf16(gpu, &get("indexer.wq_b.weight")?)?,
352        weights_proj: up_bf16(gpu, &weights_proj)?,
353        ape: up_f32(gpu, &ape)?,
354    })
355}
356
357#[cfg(test)]
358mod tests;