metrale_model_arch/glm5next_dsa/
build.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Loading one DSA block for this rank: tensor-parallel sharding plus four
4//! load-time weight transforms.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants: none beyond the types.
8//!
9//! [`build_dsa_weights`] takes a `load` closure that returns a checkpoint tensor by
10//! layer-relative name, so the transforms are testable without a `WeightStore`.
11//!
12//! # The transforms, each done once at load
13//!
14//! 1. **`q_absorb`** ([`absorb_q`]): `q_b_proj` multiplied through `kv_b_proj`'s K half, so
15//!    Q is in the `kv_lora_rank`-wide latent space the decode kernel reads.
16//! 2. **`weights_proj` scaled by `index_heads^-0.5`**: `dsa_index_scores` does not apply the
17//!    factor, and a positive scale folds into the weight exactly.
18//! 3. **`o_absorb`** ([`absorb_o`]): `o_proj` multiplied through `kv_b_proj`'s V half. The
19//!    decode kernel's output is `[local_heads, kv_lora_rank]` in latent space, while the
20//!    checkpoint `o_proj` takes `[local_heads, v_head_dim]` (256 per head against 512 on
21//!    GLM-5.3).
22//! 4. **`ape` uploaded as f32**: the `dsa_kpool_compress` parameter is `const float*` and
23//!    the checkpoint stores BF16.
24
25use anyhow::{Result, bail};
26use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
27
28use super::Glm5NextDsaConfig;
29use super::layer::Glm5NextDsaWeights;
30use super::tp::{DsaShard, DsaTpPlan};
31
32/// 2026-09-25: Returns a checkpoint tensor by layer-relative name: the full (unsharded)
33/// BF16 values, widened to f32 on the host.
34pub type LoadFn<'a> = &'a dyn Fn(&str) -> Result<Vec<f32>>;
35
36/// 2026-09-25: Worker threads for [`absorb_q`]: `METRALE_MLA_ABSORB_THREADS` when it parses
37/// to a positive integer, else the available parallelism, clamped to `1..=rows`.
38/// `mistral_loader::loader_impl::phase_qk_absorbed` reads the variable the same way.
39fn absorb_threads(rows: usize) -> usize {
40    let want = std::env::var("METRALE_MLA_ABSORB_THREADS")
41        .ok()
42        .and_then(|v| v.trim().parse::<usize>().ok())
43        .filter(|&n| n > 0)
44        .unwrap_or_else(|| {
45            std::thread::available_parallelism()
46                .map(|n| n.get())
47                .unwrap_or(1)
48        });
49    want.clamp(1, rows.max(1))
50}
51
52/// 2026-09-25: A disjoint span of `absorb_q` output rows, row `row0` onward.
53///
54/// Each output element sums over `r` in ascending order inside one thread, so the result
55/// does not depend on the thread count (`absorb_q_bit_exact` in the tests).
56#[allow(clippy::too_many_arguments)]
57fn absorb_q_rows(
58    out: &mut [f32],
59    row0: usize,
60    kv_b: &[f32],
61    q_b: &[f32],
62    kvl: usize,
63    ql: usize,
64    nope: usize,
65    vd: usize,
66) {
67    for (i, acc) in out.chunks_exact_mut(ql).enumerate() {
68        let row = row0 + i;
69        let h = row / kvl;
70        let c = row % kvl;
71        let kv_base = h * (nope + vd);
72        let qb_base = h * nope;
73        acc.fill(0.0);
74        for r in 0..nope {
75            let w = kv_b[(kv_base + r) * kvl + c];
76            let q_row = &q_b[(qb_base + r) * ql..][..ql];
77            for (a, &q) in acc.iter_mut().zip(q_row.iter()) {
78                *a += q * w;
79            }
80        }
81    }
82}
83
84/// 2026-09-25: `q_absorb[h*kvl + c][k] = Σ_r kv_b[h*(nope+vd) + r][c] · q_b[h*nope + r][k]`
85/// over all `full_heads` heads, computed on the host.
86///
87/// `q_b_proj` has `qk_head_dim` rows per head (256 on GLM-5.3) and `kv_b_proj` has
88/// `qk_nope_head_dim + v_head_dim` (512); the element-count checks refuse either tensor at
89/// the other's width. NoPE only: a nonzero `qk_rope_head_dim` is refused.
90pub fn absorb_q(
91    cfg: &Glm5NextDsaConfig,
92    q_b: &[f32],
93    kv_b: &[f32],
94    full_heads: usize,
95) -> Result<Vec<f32>> {
96    let (nope, vd, kvl, ql) = (
97        cfg.qk_nope_head_dim,
98        cfg.v_head_dim,
99        cfg.kv_lora_rank,
100        cfg.q_lora_rank,
101    );
102    let qk = cfg.qk_head_dim();
103    if q_b.len() != full_heads * qk * ql {
104        bail!(
105            "absorb_q: q_b_proj has {} elems, expected {}",
106            q_b.len(),
107            full_heads * qk * ql
108        );
109    }
110    if kv_b.len() != full_heads * (nope + vd) * kvl {
111        bail!(
112            "absorb_q: kv_b_proj has {} elems, expected {}",
113            kv_b.len(),
114            full_heads * (nope + vd) * kvl
115        );
116    }
117    // 2026-09-25: NoPE: qk_head_dim == qk_nope_head_dim, so the K half of kv_b lines up with
118    // the whole of q_b. A rope section would need its rows carried separately.
119    if cfg.qk_rope_head_dim != 0 {
120        bail!(
121            "absorb_q: NoPE only; qk_rope_head_dim is {}",
122            cfg.qk_rope_head_dim
123        );
124    }
125
126    let rows = full_heads * kvl;
127    let mut out = vec![0f32; rows * ql];
128    let threads = absorb_threads(rows);
129    if threads <= 1 {
130        absorb_q_rows(&mut out, 0, kv_b, q_b, kvl, ql, nope, vd);
131    } else {
132        let rows_per = rows.div_ceil(threads);
133        std::thread::scope(|scope| {
134            for (chunk_idx, chunk) in out.chunks_mut(rows_per * ql).enumerate() {
135                let row0 = chunk_idx * rows_per;
136                scope.spawn(move || {
137                    absorb_q_rows(chunk, row0, kv_b, q_b, kvl, ql, nope, vd);
138                });
139            }
140        });
141    }
142    Ok(out)
143}
144
145/// 2026-09-25: The output-side absorption:
146/// `o_absorb[i][h*kvl + c] = Σ_r o_proj[i][h*vd + r] · kv_b[h*(nope+vd) + nope + r][c]`.
147///
148/// The output-side counterpart of [`absorb_q`]: it maps the decode kernel's latent output
149/// back through `kv_b_proj`'s V half. It runs on this rank's heads only (`o_local`,
150/// `kv_b_local`): output columns of head `h` read only head `h`'s `o_proj` columns and
151/// `kv_b` rows. Each head's V half starts at row `qk_nope_head_dim` of that head's `kv_b`
152/// block.
153pub fn absorb_o(
154    cfg: &Glm5NextDsaConfig,
155    o_local: &[f32],
156    kv_b_local: &[f32],
157    local_heads: usize,
158) -> Result<Vec<f32>> {
159    let (nope, vd, kvl, hidden) = (
160        cfg.qk_nope_head_dim,
161        cfg.v_head_dim,
162        cfg.kv_lora_rank,
163        cfg.hidden,
164    );
165    if cfg.qk_rope_head_dim != 0 {
166        bail!(
167            "absorb_o: NoPE only; qk_rope_head_dim is {}",
168            cfg.qk_rope_head_dim
169        );
170    }
171    let in_w = local_heads * vd;
172    let out_w = local_heads * kvl;
173    if o_local.len() != hidden * in_w {
174        bail!(
175            "absorb_o: o_proj has {} elems, expected {} ([{hidden}, {in_w}])",
176            o_local.len(),
177            hidden * in_w
178        );
179    }
180    if kv_b_local.len() != local_heads * (nope + vd) * kvl {
181        bail!(
182            "absorb_o: kv_b_proj slice has {} elems, expected {}",
183            kv_b_local.len(),
184            local_heads * (nope + vd) * kvl
185        );
186    }
187
188    let mut out = vec![0f32; hidden * out_w];
189    // 2026-09-25: Output rows are independent and contiguous, so they are split across
190    // threads: the work is `hidden * local_heads * kvl * vd` MACs per layer.
191    let threads = std::thread::available_parallelism()
192        .map(|n| n.get())
193        .unwrap_or(1)
194        .clamp(1, hidden);
195    let rows = hidden.div_ceil(threads);
196    std::thread::scope(|sc| {
197        for (o_chunk, i_chunk) in out
198            .chunks_mut(rows * out_w)
199            .zip(o_local.chunks(rows * in_w))
200        {
201            sc.spawn(move || {
202                for (dst_row, src_row) in o_chunk.chunks_mut(out_w).zip(i_chunk.chunks(in_w)) {
203                    for h in 0..local_heads {
204                        let dst = &mut dst_row[h * kvl..(h + 1) * kvl];
205                        let v_base = h * (nope + vd) + nope;
206                        for r in 0..vd {
207                            let w = src_row[h * vd + r];
208                            let src = &kv_b_local[(v_base + r) * kvl..(v_base + r) * kvl + kvl];
209                            for (d, k) in dst.iter_mut().zip(src) {
210                                *d += w * k;
211                            }
212                        }
213                    }
214                }
215            });
216        }
217    });
218    Ok(out)
219}
220
221/// 2026-09-25: Rows `[start, end)` of a `[rows, row_elems]` row-major tensor.
222fn row_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
223    v[start * row_elems..end * row_elems].to_vec()
224}
225
226/// 2026-09-25: Column range `[start, end)` of every row: the `DsaShard::HeadCols` case
227/// (`o_proj`).
228fn col_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
229    v.chunks(row_elems)
230        .flat_map(|r| r[start..end].iter().copied())
231        .collect()
232}
233
234/// 2026-09-25: Apply one tensor's shard plan to full host values.
235pub fn shard_host(plan: &super::tp::DsaTensorPlan, full: &[f32]) -> Vec<f32> {
236    match plan.kind {
237        DsaShard::Replicated => full.to_vec(),
238        DsaShard::HeadRows => row_slice(
239            full,
240            plan.full_row_elems,
241            plan.src_row_offset,
242            plan.src_row_offset + plan.local_rows,
243        ),
244        DsaShard::HeadCols => col_slice(
245            full,
246            plan.full_row_elems,
247            plan.src_col_offset,
248            plan.src_col_offset + plan.local_row_elems,
249        ),
250    }
251}
252
253fn up_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
254    let b: Vec<u8> = v
255        .iter()
256        .flat_map(|x| half::bf16::from_f32(*x).to_le_bytes())
257        .collect();
258    let p = gpu.alloc(b.len().max(1))?;
259    gpu.copy_h2d(&b, p)?;
260    Ok(p)
261}
262fn up_f32(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
263    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
264    let p = gpu.alloc(b.len().max(1))?;
265    gpu.copy_h2d(&b, p)?;
266    Ok(p)
267}
268
269/// 2026-09-25: Load, transform, shard and upload one DSA block for this rank.
270pub fn build_dsa_weights(
271    gpu: &dyn GpuBackend,
272    cfg: &Glm5NextDsaConfig,
273    plan: &DsaTpPlan,
274    load: LoadFn<'_>,
275) -> Result<Glm5NextDsaWeights> {
276    let get = |n: &str| -> Result<Vec<f32>> { load(&format!("self_attn.{n}")) };
277    let shard = |n: &'static str, full: Vec<f32>| -> Result<Vec<f32>> {
278        let p = plan
279            .get(n)
280            .ok_or_else(|| anyhow::anyhow!("no shard plan for {n}"))?;
281        Ok(shard_host(p, &full))
282    };
283
284    let kv_b = get("kv_b_proj.weight")?;
285
286    // 2026-09-25: Transform 1 over all heads, then this rank's rows: heads
287    // `tp_rank * local_heads ..`, `kv_lora_rank` rows each.
288    let q_absorb_full = absorb_q(cfg, &get("q_b_proj.weight")?, &kv_b, plan.full_heads)?;
289    let per_head = cfg.kv_lora_rank;
290    let start = plan.tp_rank * plan.local_heads * per_head;
291    let len = plan.local_heads * per_head;
292    let q_absorb = row_slice(&q_absorb_full, cfg.q_lora_rank, start, start + len);
293
294    // 2026-09-25: Transform 3 on this rank's heads: its `kv_b` rows and its `o_proj`
295    // column slice.
296    let kv_b_rows = cfg.qk_nope_head_dim + cfg.v_head_dim;
297    let kv_b_local = row_slice(
298        &kv_b,
299        cfg.kv_lora_rank,
300        plan.tp_rank * plan.local_heads * kv_b_rows,
301        (plan.tp_rank + 1) * plan.local_heads * kv_b_rows,
302    );
303    let o_absorb = absorb_o(
304        cfg,
305        &shard("o_proj", get("o_proj.weight")?)?,
306        &kv_b_local,
307        plan.local_heads,
308    )?;
309
310    // 2026-09-25: Transform 2: fold index_heads^-0.5 into weights_proj.
311    let scale = (cfg.index_heads as f32).powf(-0.5);
312    let weights_proj: Vec<f32> = get("indexer.weights_proj.weight")?
313        .iter()
314        .map(|x| x * scale)
315        .collect();
316
317    // 2026-09-25: Transform 4: ape stays f32 for the kernel.
318    let ape = get("indexer.index_kpool_compress_ape")?;
319
320    Ok(Glm5NextDsaWeights {
321        q_a_proj: up_bf16(gpu, &get("q_a_proj.weight")?)?,
322        q_a_layernorm: up_bf16(gpu, &get("q_a_layernorm.weight")?)?,
323        q_absorb: up_bf16(gpu, &q_absorb)?,
324        kv_a_proj: up_bf16(gpu, &get("kv_a_proj_with_mqa.weight")?)?,
325        kv_a_layernorm: up_bf16(gpu, &get("kv_a_layernorm.weight")?)?,
326        o_absorb: up_bf16(gpu, &o_absorb)?,
327        wk: up_bf16(gpu, &get("indexer.wk.weight")?)?,
328        k_norm_weight: up_bf16(gpu, &get("indexer.k_norm.weight")?)?,
329        k_norm_bias: up_bf16(gpu, &get("indexer.k_norm.bias")?)?,
330        compress_gate: up_bf16(gpu, &get("indexer.index_kpool_compress_gate")?)?,
331        wq_b: up_bf16(gpu, &get("indexer.wq_b.weight")?)?,
332        weights_proj: up_bf16(gpu, &weights_proj)?,
333        ape: up_f32(gpu, &ape)?,
334    })
335}
336
337#[cfg(test)]
338mod tests;