spark_model/layers/glm5next_mlp/
build.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Binding one GLM MLP site for a rank: TP slicing of the dense/shared halves, EP selection of
4//! the routed experts.
5//!
6//! Takes `load` closures rather than a `WeightStore`, for the same reason
7//! [`crate::layers::glm5next_dsa::build`] does: the slicing is then testable without a
8//! checkpoint, and the loader wiring stays one call site.
9//!
10//! # πŸ”΄ The two axes are different, and mixing them is silent
11//!
12//! * **TP** splits the *width* of the dense FFN and the shared expert. `gate_proj`/`up_proj` are
13//!   `[inter, hidden]` and split by ROW; `down_proj` is `[hidden, inter]` and splits by COLUMN.
14//!   Slicing `down_proj` by row instead gives a well-formed `[hidden/tp, inter]` tensor and a
15//!   plausible, wrong output.
16//! * **EP** splits the *set* of routed experts. An expert is never cut β€” it is owned whole.
17//!   The router stays replicated so every rank selects the same ids.
18
19use anyhow::{Result, bail};
20use spark_runtime::gpu::{DevicePtr, GpuBackend};
21
22use super::Glm5NextMlpConfig;
23use super::weights::{
24    Glm5NextDenseMlpWeights, Glm5NextExpertPtrTable, Glm5NextExpertWeights, Glm5NextMoePtrTables,
25    Glm5NextMoeWeights, Nvfp4Proj,
26};
27
28/// A BF16/F32 tensor as host `f32`, by layer-relative name.
29pub type LoadFn<'a> = &'a dyn Fn(&str) -> Result<Vec<f32>>;
30/// One routed expert, by GLOBAL id.
31///
32/// πŸ”΄ A closure rather than a nameβ†’bytes loader on purpose. The routed experts are the only
33/// thing here that is NOT sharded β€” an expert is owned whole β€” so the caller can hand over the
34/// checkpoint's own device pointers with **no copy**. Routing 3.85 GiB per layer through a host
35/// `f32` round trip, as the TP-sliced halves must, would be pure waste. It also keeps the packed
36/// `e2m1` codes and `e4m3` block scales from ever passing through `f32`.
37pub type ExpertFn<'a> = &'a dyn Fn(usize) -> Result<Glm5NextExpertWeights>;
38
39/// Rows `[start, end)` of a `[rows, row_elems]` row-major tensor β€” column-parallel projections.
40fn row_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
41    v[start * row_elems..end * row_elems].to_vec()
42}
43
44/// Columns `[start, end)` of every row β€” the row-parallel case (`down_proj`).
45fn col_slice(v: &[f32], row_elems: usize, start: usize, end: usize) -> Vec<f32> {
46    v.chunks(row_elems)
47        .flat_map(|r| r[start..end].iter().copied())
48        .collect()
49}
50
51fn up_bf16(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
52    let b: Vec<u8> = v
53        .iter()
54        .flat_map(|x| half::bf16::from_f32(*x).to_le_bytes())
55        .collect();
56    let p = gpu.alloc(b.len().max(1))?;
57    gpu.copy_h2d(&b, p)?;
58    Ok(p)
59}
60
61fn up_f32(gpu: &dyn GpuBackend, v: &[f32]) -> Result<DevicePtr> {
62    let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
63    let p = gpu.alloc(b.len().max(1))?;
64    gpu.copy_h2d(&b, p)?;
65    Ok(p)
66}
67
68/// TP-slice and upload one BF16 SwiGLU MLP β€” a dense layer, or a routed layer's shared expert.
69///
70/// `full_inter` is the tensor's width in the checkpoint; the rank keeps `full_inter / tp`.
71pub fn build_dense_mlp(
72    gpu: &dyn GpuBackend,
73    cfg: &Glm5NextMlpConfig,
74    tp_rank: usize,
75    full_inter: usize,
76    prefix: &str,
77    load: LoadFn<'_>,
78) -> Result<Glm5NextDenseMlpWeights> {
79    let tp = cfg.tp_world_size;
80    if !full_inter.is_multiple_of(tp) {
81        bail!("GLM MLP {prefix}: intermediate {full_inter} does not divide over tp {tp}");
82    }
83    let local = full_inter / tp;
84    let lo = tp_rank * local;
85
86    let get = |n: &str| -> Result<Vec<f32>> { load(&format!("{prefix}.{n}")) };
87
88    let expect = |name: &str, v: &[f32], want: usize| -> Result<()> {
89        if v.len() != want {
90            bail!(
91                "GLM MLP {prefix}.{name}: {} elements, expected {want}",
92                v.len()
93            );
94        }
95        Ok(())
96    };
97
98    let gate = get("gate_proj.weight")?;
99    expect("gate_proj.weight", &gate, full_inter * cfg.hidden)?;
100    let up = get("up_proj.weight")?;
101    expect("up_proj.weight", &up, full_inter * cfg.hidden)?;
102    let down = get("down_proj.weight")?;
103    expect("down_proj.weight", &down, cfg.hidden * full_inter)?;
104
105    Ok(Glm5NextDenseMlpWeights {
106        // Column-parallel: [inter, hidden] sliced by ROW.
107        gate_proj: up_bf16(gpu, &row_slice(&gate, cfg.hidden, lo, lo + local))?,
108        up_proj: up_bf16(gpu, &row_slice(&up, cfg.hidden, lo, lo + local))?,
109        // Row-parallel: [hidden, inter] sliced by COLUMN. Output is a partial sum.
110        down_proj: up_bf16(gpu, &col_slice(&down, full_inter, lo, lo + local))?,
111    })
112}
113
114/// One projection's device pointer table over the FULL expert set.
115///
116/// πŸͺ€ Indexed by GLOBAL id β€” remote ids get a **null** pointer, not a wrapped local slot. The
117/// grouped kernel's only remote test is `packed == 0`; handing it a local expert's pointer for
118/// a remote id would silently run the wrong expert on both ranks.
119fn build_expert_ptr_table(
120    gpu: &dyn GpuBackend,
121    cfg: &Glm5NextMlpConfig,
122    experts: &[Glm5NextExpertWeights],
123    proj: impl Fn(&Glm5NextExpertWeights) -> Nvfp4Proj,
124) -> Result<Glm5NextExpertPtrTable> {
125    let n = cfg.num_experts;
126    let mut packed = vec![0u8; n * 8];
127    let mut scale = vec![0u8; n * 8];
128    let mut scale2 = vec![0u8; n * 4];
129    for id in 0..n {
130        let Some(local) = cfg.local_slot(id) else {
131            continue; // remote β€” null stays, and the kernel skips the slot
132        };
133        let p = proj(&experts[local]);
134        packed[id * 8..id * 8 + 8].copy_from_slice(&p.packed.0.to_le_bytes());
135        scale[id * 8..id * 8 + 8].copy_from_slice(&p.scale.0.to_le_bytes());
136        scale2[id * 4..id * 4 + 4].copy_from_slice(&p.scale_2.to_le_bytes());
137    }
138    let packed_ptrs = gpu.alloc(packed.len())?;
139    gpu.copy_h2d(&packed, packed_ptrs)?;
140    let scale_ptrs = gpu.alloc(scale.len())?;
141    gpu.copy_h2d(&scale, scale_ptrs)?;
142    let scale2_vals = gpu.alloc(scale2.len())?;
143    gpu.copy_h2d(&scale2, scale2_vals)?;
144    Ok(Glm5NextExpertPtrTable {
145        packed_ptrs,
146        scale_ptrs,
147        scale2_vals,
148    })
149}
150
151/// Bind one routed MoE site for this rank: replicated router, TP-sharded shared expert, and
152/// exactly the `local_experts` routed experts this EP rank owns.
153pub fn build_moe(
154    gpu: &dyn GpuBackend,
155    cfg: &Glm5NextMlpConfig,
156    tp_rank: usize,
157    full_shared_inter: usize,
158    load: LoadFn<'_>,
159    expert: ExpertFn<'_>,
160) -> Result<Glm5NextMoeWeights> {
161    // πŸͺ€ REPLICATED, both of them. A sharded router gives each rank partial logits and a
162    // different top-k, which makes masked-local EP select different experts per rank β€” no
163    // crash, no shape error, a different answer.
164    let router = load("mlp.gate.weight")?;
165    if router.len() != cfg.num_experts * cfg.hidden {
166        bail!(
167            "GLM MoE mlp.gate.weight: {} elements, expected {} ({} experts x {} hidden)",
168            router.len(),
169            cfg.num_experts * cfg.hidden,
170            cfg.num_experts,
171            cfg.hidden
172        );
173    }
174    let bias = load("mlp.gate.e_score_correction_bias")?;
175    if bias.len() != cfg.num_experts {
176        bail!(
177            "GLM MoE e_score_correction_bias: {} entries, expected {}",
178            bias.len(),
179            cfg.num_experts
180        );
181    }
182
183    let shared = build_dense_mlp(
184        gpu,
185        cfg,
186        tp_rank,
187        full_shared_inter,
188        "mlp.shared_experts",
189        load,
190    )?;
191
192    // Ascending GLOBAL id, so slot `i` is id `range.start + i` β€” the inverse of
193    // `Glm5NextMlpConfig::local_slot`, and the only ordering the forward may assume.
194    let mut experts = Vec::with_capacity(cfg.local_experts);
195    for id in cfg.local_expert_range() {
196        experts.push(expert(id)?);
197    }
198
199    let ptrs = Glm5NextMoePtrTables {
200        gate: build_expert_ptr_table(gpu, cfg, &experts, |e| e.gate_proj)?,
201        up: build_expert_ptr_table(gpu, cfg, &experts, |e| e.up_proj)?,
202        down: build_expert_ptr_table(gpu, cfg, &experts, |e| e.down_proj)?,
203    };
204
205    Ok(Glm5NextMoeWeights {
206        router: up_bf16(gpu, &router)?,
207        // F32 in the checkpoint and `const float*` at the kernel β€” uploaded as F32, not BF16.
208        router_bias: up_f32(gpu, &bias)?,
209        shared,
210        experts,
211        ptrs,
212    })
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    /// Column-parallel and row-parallel slicing produce the SAME shape at tp=2 and are trivially
220    /// swappable. This pins which axis each takes, on a tensor whose values encode their index.
221    #[test]
222    fn dense_slicing_takes_rows_for_gate_and_columns_for_down() {
223        // [inter=4, hidden=3] gate: value = row*10 + col.
224        let gate: Vec<f32> = (0..4)
225            .flat_map(|r| (0..3).map(move |c| (r * 10 + c) as f32))
226            .collect();
227        // rank 1 of 2 keeps rows 2..4.
228        assert_eq!(
229            row_slice(&gate, 3, 2, 4),
230            vec![20., 21., 22., 30., 31., 32.]
231        );
232
233        // [hidden=3, inter=4] down: value = row*10 + col. rank 1 keeps columns 2..4.
234        let down: Vec<f32> = (0..3)
235            .flat_map(|r| (0..4).map(move |c| (r * 10 + c) as f32))
236            .collect();
237        assert_eq!(col_slice(&down, 4, 2, 4), vec![2., 3., 12., 13., 22., 23.]);
238        // On a square tensor the two axes give the SAME element count and different values β€”
239        // which is why this is a test and not a length assertion in the loader.
240        let sq: Vec<f32> = (0..16).map(|i| i as f32).collect();
241        let by_row = row_slice(&sq, 4, 2, 4);
242        let by_col = col_slice(&sq, 4, 2, 4);
243        assert_eq!(by_row.len(), by_col.len());
244        assert_ne!(by_row, by_col);
245    }
246}