metrale_model_arch/glm5next_dsa/tp.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3-Flash DSA tensor-parallel shard plan: MLA heads sharded, the indexer
4//! replicated.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - [`DsaTpPlan::new`] refuses `tp_rank >= tp_size` and a config that fails
9//! `Glm5NextDsaConfig::validate`.
10//! - Every indexer tensor, `kv_a_proj_with_mqa`, and the low-rank down-projections and their
11//! norms are replicated; `q_b_proj` and `kv_b_proj` shard by head; `o_proj` is row-parallel.
12//!
13//! Pure data, as in [`crate::glm5next_kda::tp`], so the per-rank arithmetic is tested without a
14//! GPU.
15//!
16//! # Why the indexer is replicated
17//!
18//! The indexer emits a token selection, not a partial sum. `index_scores` adds
19//! `weights[h] * relu(scale * dot)` over the indexer heads, so a head-sharded indexer gives each
20//! rank a partial score, and a per-rank top-k over partial scores can select different tokens on
21//! different ranks. Sharding is correct only with an all-reduce of the scores before the top-k.
22//! Scores are `[q_rows, n_pools]` with `n_pools = seq / kpool`: at a 262,144-token context
23//! (`max_position_embeddings` in MODEL.toml) that is 65,536 x 4 B = 256 KiB per decode token per
24//! DSA layer, against a replicated indexer of about 15 MB per layer (the `indexer.*` sizes in
25//! `tp/tests.rs`). An all-reduce would also change the order of the per-head sum, which can
26//! reorder pools whose scores tie at the cutoff.
27//!
28//! * `kv_a_proj_with_mqa` produces the shared latent every head decompresses from; it has no
29//! head axis.
30//! * `q_b_proj` shards at `qk_head_dim` rows per head and `kv_b_proj` at
31//! `qk_nope_head_dim + v_head_dim`; the two strides differ.
32//! * `o_proj` is row-parallel on `heads * v_head_dim`.
33
34use anyhow::{Result, bail};
35
36use super::Glm5NextDsaConfig;
37
38/// 2026-09-25: How one DSA tensor maps onto TP ranks.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DsaShard {
41 /// 2026-09-25: Every rank holds the whole tensor: the indexer, the latent KV projection, and
42 /// the low-rank down-projections and norms.
43 Replicated,
44 /// 2026-09-25: Leading dim is `heads * per_head`: slice by this rank's head range.
45 HeadRows,
46 /// 2026-09-25: Trailing (input) dim is `heads * v_head_dim`: a row-parallel GEMM, sliced on the
47 /// input dim, whose output is all-reduced.
48 HeadCols,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DsaTensorPlan {
53 pub name: &'static str,
54 pub kind: DsaShard,
55 pub elem_bytes: usize,
56 pub full_rows: usize,
57 pub full_row_elems: usize,
58 pub local_rows: usize,
59 pub local_row_elems: usize,
60 pub src_row_offset: usize,
61 pub src_col_offset: usize,
62}
63
64impl DsaTensorPlan {
65 pub fn local_bytes(&self) -> usize {
66 self.local_rows * self.local_row_elems * self.elem_bytes
67 }
68 pub fn full_bytes(&self) -> usize {
69 self.full_rows * self.full_row_elems * self.elem_bytes
70 }
71}
72
73const BF16: usize = 2;
74
75/// 2026-09-25: Per-rank shard plan for one DSA block.
76#[derive(Debug, Clone)]
77pub struct DsaTpPlan {
78 pub tp_rank: usize,
79 pub tp_size: usize,
80 pub full_heads: usize,
81 pub local_heads: usize,
82 pub tensors: Vec<DsaTensorPlan>,
83}
84
85impl DsaTpPlan {
86 /// 2026-09-25: `cfg.local_heads` is already per rank, so the full head count is
87 /// `local_heads * tp_size`, as in `TpGdnDims::from_config`.
88 pub fn new(tp_rank: usize, tp_size: usize, cfg: &Glm5NextDsaConfig) -> Result<Self> {
89 if tp_rank >= tp_size {
90 bail!("tp_rank {tp_rank} >= tp_size {tp_size}");
91 }
92 cfg.validate()?;
93 let local_heads = cfg.local_heads;
94 let full_heads = local_heads * tp_size;
95
96 let h = cfg.hidden;
97 let qk = cfg.qk_head_dim();
98 let kvb_per_head = cfg.qk_nope_head_dim + cfg.v_head_dim;
99 let ihd = cfg.index_head_dim;
100
101 let mk = |name, kind, full_rows: usize, full_row_elems: usize| {
102 let (local_rows, local_row_elems, src_row_offset, src_col_offset) = match kind {
103 DsaShard::Replicated => (full_rows, full_row_elems, 0, 0),
104 DsaShard::HeadRows => {
105 let per = full_rows / tp_size;
106 (per, full_row_elems, tp_rank * per, 0)
107 }
108 DsaShard::HeadCols => {
109 let per = full_row_elems / tp_size;
110 (full_rows, per, 0, tp_rank * per)
111 }
112 };
113 DsaTensorPlan {
114 name,
115 kind,
116 elem_bytes: BF16,
117 full_rows,
118 full_row_elems,
119 local_rows,
120 local_row_elems,
121 src_row_offset,
122 src_col_offset,
123 }
124 };
125
126 let tensors = vec![
127 mk("q_a_proj", DsaShard::Replicated, cfg.q_lora_rank, h),
128 mk("q_a_layernorm", DsaShard::Replicated, cfg.q_lora_rank, 1),
129 mk(
130 "q_b_proj",
131 DsaShard::HeadRows,
132 full_heads * qk,
133 cfg.q_lora_rank,
134 ),
135 // 2026-09-25: The shared latent has no head axis, so it is replicated.
136 mk(
137 "kv_a_proj_with_mqa",
138 DsaShard::Replicated,
139 cfg.kv_cache_dim(),
140 h,
141 ),
142 mk("kv_a_layernorm", DsaShard::Replicated, cfg.kv_lora_rank, 1),
143 mk(
144 "kv_b_proj",
145 DsaShard::HeadRows,
146 full_heads * kvb_per_head,
147 cfg.kv_lora_rank,
148 ),
149 mk("o_proj", DsaShard::HeadCols, h, full_heads * cfg.v_head_dim),
150 // 2026-09-25: The indexer, replicated (see the module doc).
151 mk(
152 "indexer.wq_b",
153 DsaShard::Replicated,
154 cfg.index_heads * ihd,
155 cfg.q_lora_rank,
156 ),
157 mk("indexer.wk", DsaShard::Replicated, ihd, h),
158 mk("indexer.k_norm.weight", DsaShard::Replicated, ihd, 1),
159 // 2026-09-25: `k_norm` is a LayerNorm, so it has a bias.
160 mk("indexer.k_norm.bias", DsaShard::Replicated, ihd, 1),
161 mk(
162 "indexer.weights_proj",
163 DsaShard::Replicated,
164 cfg.index_heads,
165 h,
166 ),
167 mk(
168 "indexer.index_kpool_compress_gate",
169 DsaShard::Replicated,
170 ihd,
171 h,
172 ),
173 mk(
174 "indexer.index_kpool_compress_ape",
175 DsaShard::Replicated,
176 cfg.index_kpool,
177 ihd,
178 ),
179 ];
180
181 Ok(Self {
182 tp_rank,
183 tp_size,
184 full_heads,
185 local_heads,
186 tensors,
187 })
188 }
189
190 pub fn get(&self, name: &str) -> Option<&DsaTensorPlan> {
191 self.tensors.iter().find(|t| t.name == name)
192 }
193 pub fn local_bytes(&self) -> usize {
194 self.tensors.iter().map(|t| t.local_bytes()).sum()
195 }
196 pub fn full_bytes(&self) -> usize {
197 self.tensors.iter().map(|t| t.full_bytes()).sum()
198 }
199 /// 2026-09-25: `o_proj` is row-parallel, so above `tp_size = 1` its output needs the reduce.
200 pub fn needs_output_all_reduce(&self) -> bool {
201 self.tp_size > 1
202 }
203 /// 2026-09-25: Bytes every rank holds in full.
204 pub fn replicated_bytes(&self) -> usize {
205 self.tensors
206 .iter()
207 .filter(|t| t.kind == DsaShard::Replicated)
208 .map(|t| t.full_bytes())
209 .sum()
210 }
211}
212
213#[cfg(test)]
214mod tests;