spark_model/layers/glm5next_kda/tp.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3 KDA tensor-parallel shard plan β **head-parallel, one all-reduce**.
4//!
5//! Scoped to `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`. Shapes below are measured
6//! from the checkpoint's safetensors headers, not inferred.
7//!
8//! # Why a pure plan
9//!
10//! The GPU copy is three lines of [`crate::tp_shard`] reuse. The part that is easy
11//! to get silently wrong is *which rows belong to this rank* β and a wrong head
12//! range produces a running model with quietly mixed-up heads, not a crash. So the
13//! row arithmetic lives here as pure data that can be proven without a GPU, exactly
14//! like the EP residency proof.
15//!
16//! # The pattern this follows
17//!
18//! Qwen3.5 GDN HeadParallel (`weight_loader/qwen35/load_layers/linear_attn_arms.rs`,
19//! helpers in `tp_shard/gdn.rs`): each rank owns a contiguous head range, `out_proj`
20//! is row-parallel, and **one** all-reduce follows it. KDA differs from GDN in two
21//! ways that matter here:
22//!
23//! * KDA's `q/k/v_proj` and `q/k/v_conv1d` are **separate tensors on disk** β GDN
24//! fuses them into `in_proj_qkv` / `conv1d`. So KDA needs no segmented slice: each
25//! tensor is sliced independently and the 3-segment trap
26//! (`crate::tp_shard::gdn::segment_copy_plan`) simply does not arise.
27//! * KDA has **no `Z` tensor**. The output gate is low-rank `g_a`/`g_b`.
28//!
29//! # πͺ€ Traps this module encodes
30//!
31//! * **`a_log` is per-HEAD `[64]`; `dt_bias` is per-CHANNEL `[8192]`.** The KDA
32//! module doc already calls this "the highest-risk line". Under TP they shard at
33//! different granularity β heads vs headsΓhead_dim. Slicing `dt_bias` by head
34//! count silently keeps 1/128th of the right data.
35//! * **`o_norm` is `[head_dim]`, NOT `[heads*head_dim]`** β it is per-channel-within-
36//! a-head and therefore **replicated**, never sharded. It is 256 B; a "shard
37//! everything that looks per-head" rule corrupts it.
38//! * **`f_a`/`g_a` are down-projections `[rank, hidden]` β replicated.** Only the
39//! `_b` up-projections carry head structure. Sharding an `_a` splits the low-rank
40//! bottleneck and every head reads a truncated gate.
41//! * **`o_proj` is row-parallel**: `[hidden, heads*head_dim]` sliced on its INPUT
42//! dim. Each rank produces a partial `[hidden]` that is only correct after the
43//! all-reduce. Column-slicing it instead yields a plausible, wrong output.
44
45use anyhow::{Result, bail};
46use atlas_core::config::ModelConfig;
47
48/// How one KDA tensor maps onto TP ranks.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum KdaShard {
51 /// Every rank holds the whole tensor.
52 Replicated,
53 /// Leading dim is `heads` β slice by head range.
54 HeadRows,
55 /// Leading dim is `heads * head_dim` β slice by channel range.
56 ChannelRows,
57 /// Trailing (input) dim is `heads * head_dim` β row-parallel GEMM, slice the
58 /// input dim, then **all-reduce** the output.
59 ChannelCols,
60}
61
62/// One tensor's placement. `row_elems` is the width of a row in elements; for
63/// [`KdaShard::ChannelCols`] the roles invert and `row_elems` is the sharded axis.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct KdaTensorPlan {
66 pub name: &'static str,
67 pub kind: KdaShard,
68 pub elem_bytes: usize,
69 /// Rows of the full, on-disk tensor.
70 pub full_rows: usize,
71 /// Elements per row of the full tensor.
72 pub full_row_elems: usize,
73 /// Rows this rank keeps.
74 pub local_rows: usize,
75 /// Elements per row this rank keeps (differs from full only for `ChannelCols`).
76 pub local_row_elems: usize,
77 /// Offset, in rows, of this rank's slice into the full tensor. Always 0 for
78 /// `Replicated` and for `ChannelCols` (which slices columns, not rows).
79 pub src_row_offset: usize,
80 /// Offset, in elements, of this rank's column slice. Non-zero only for
81 /// `ChannelCols`.
82 pub src_col_offset: usize,
83}
84
85impl KdaTensorPlan {
86 /// Bytes this rank stores for this tensor.
87 pub fn local_bytes(&self) -> usize {
88 self.local_rows * self.local_row_elems * self.elem_bytes
89 }
90 /// Bytes the full tensor occupies on disk.
91 pub fn full_bytes(&self) -> usize {
92 self.full_rows * self.full_row_elems * self.elem_bytes
93 }
94}
95
96const BF16: usize = 2;
97const F32: usize = 4;
98
99/// The complete per-rank shard plan for one KDA block.
100#[derive(Debug, Clone)]
101pub struct KdaTpPlan {
102 pub tp_rank: usize,
103 pub tp_size: usize,
104 pub hidden: usize,
105 pub head_dim: usize,
106 /// Pre-shard head count (all ranks combined).
107 pub full_heads: usize,
108 /// Heads this rank owns.
109 pub local_heads: usize,
110 pub conv_kernel: usize,
111 /// Low-rank width of the `f`/`g` gate bottleneck.
112 pub gate_rank: usize,
113 pub tensors: Vec<KdaTensorPlan>,
114}
115
116impl KdaTpPlan {
117 /// Build from a `ModelConfig` whose linear-head counts are already **per-rank
118 /// local** β `serve_phases::topology` divides them before any loader runs, and
119 /// `TpGdnDims::from_config` reconstructs `full = local * tp_size` the same way.
120 ///
121 /// `gate_rank` is not a config key; it is the `f_a_proj` row count read from the
122 /// checkpoint (128 for GLM-5.3).
123 pub fn from_config(config: &ModelConfig, gate_rank: usize) -> Result<Self> {
124 let tp_size = config.tp_world_size.max(1);
125 Self::new(
126 config.tp_rank,
127 tp_size,
128 config.hidden_size,
129 config.linear_key_head_dim,
130 config.linear_num_key_heads * tp_size,
131 config.linear_conv_kernel_dim,
132 gate_rank,
133 )
134 }
135
136 #[allow(clippy::too_many_arguments)]
137 pub fn new(
138 tp_rank: usize,
139 tp_size: usize,
140 hidden: usize,
141 head_dim: usize,
142 full_heads: usize,
143 conv_kernel: usize,
144 gate_rank: usize,
145 ) -> Result<Self> {
146 if tp_rank >= tp_size {
147 bail!("tp_rank {tp_rank} >= tp_size {tp_size}");
148 }
149 if tp_size == 0 || head_dim == 0 || full_heads == 0 {
150 bail!("degenerate KDA TP geometry: heads={full_heads} head_dim={head_dim}");
151 }
152 if !full_heads.is_multiple_of(tp_size) {
153 bail!("KDA TP requires heads ({full_heads}) divisible by tp_size ({tp_size})");
154 }
155 let local_heads = full_heads / tp_size;
156
157 // The fused conv+L2 kernel's contract, re-checked on the LOCAL width: it
158 // reads 2 heads per 256-thread block over the q|k channels only.
159 let local_qk_channels = 2 * local_heads * head_dim;
160 if !local_qk_channels.is_multiple_of(256) {
161 bail!(
162 "KDA TP: local qk_channels ({local_qk_channels}) must be a multiple of 256 \
163 (heads={local_heads}, head_dim={head_dim}); causal_conv1d_update_l2norm \
164 hardcodes 2 heads per 256-thread block"
165 );
166 }
167
168 let full_ch = full_heads * head_dim;
169 let local_ch = local_heads * head_dim;
170 let ch_off = tp_rank * local_ch;
171 let head_off = tp_rank * local_heads;
172
173 let rows = |name, kind, elem_bytes, full_rows, full_row_elems| {
174 let (local_rows, local_row_elems, src_row_offset, src_col_offset) = match kind {
175 KdaShard::Replicated => (full_rows, full_row_elems, 0, 0),
176 KdaShard::HeadRows => (full_rows / tp_size, full_row_elems, head_off, 0),
177 KdaShard::ChannelRows => (full_rows / tp_size, full_row_elems, ch_off, 0),
178 // Row-parallel: slice the INPUT (column) dim, keep every row.
179 KdaShard::ChannelCols => (full_rows, full_row_elems / tp_size, 0, ch_off),
180 };
181 KdaTensorPlan {
182 name,
183 kind,
184 elem_bytes,
185 full_rows,
186 full_row_elems,
187 local_rows,
188 local_row_elems,
189 src_row_offset,
190 src_col_offset,
191 }
192 };
193
194 let tensors = vec![
195 // [heads*head_dim, hidden] β column-parallel by output channel.
196 rows("q_proj", KdaShard::ChannelRows, BF16, full_ch, hidden),
197 rows("k_proj", KdaShard::ChannelRows, BF16, full_ch, hidden),
198 rows("v_proj", KdaShard::ChannelRows, BF16, full_ch, hidden),
199 // [heads*head_dim, conv_kernel] each β SEPARATE on disk, so each slices
200 // independently. (GDN fuses these; KDA does not.)
201 rows(
202 "q_conv1d",
203 KdaShard::ChannelRows,
204 BF16,
205 full_ch,
206 conv_kernel,
207 ),
208 rows(
209 "k_conv1d",
210 KdaShard::ChannelRows,
211 BF16,
212 full_ch,
213 conv_kernel,
214 ),
215 rows(
216 "v_conv1d",
217 KdaShard::ChannelRows,
218 BF16,
219 full_ch,
220 conv_kernel,
221 ),
222 // Low-rank forget/output gates: `_a` down-projects (replicate),
223 // `_b` up-projects into channel space (shard).
224 rows("f_a_proj", KdaShard::Replicated, BF16, gate_rank, hidden),
225 rows("f_b_proj", KdaShard::ChannelRows, BF16, full_ch, gate_rank),
226 rows("g_a_proj", KdaShard::Replicated, BF16, gate_rank, hidden),
227 rows("g_b_proj", KdaShard::ChannelRows, BF16, full_ch, gate_rank),
228 // beta β one row per HEAD.
229 rows("b_proj", KdaShard::HeadRows, BF16, full_heads, hidden),
230 // πͺ€ per-HEAD ...
231 rows("A_log", KdaShard::HeadRows, F32, full_heads, 1),
232 // πͺ€ ... versus per-CHANNEL. Different granularity, same block.
233 rows("dt_bias", KdaShard::ChannelRows, F32, full_ch, 1),
234 // πͺ€ [head_dim] β within-head, so REPLICATED.
235 rows("o_norm", KdaShard::Replicated, BF16, head_dim, 1),
236 // [hidden, heads*head_dim] β row-parallel, all-reduce after.
237 rows("o_proj", KdaShard::ChannelCols, BF16, hidden, full_ch),
238 ];
239
240 Ok(Self {
241 tp_rank,
242 tp_size,
243 hidden,
244 head_dim,
245 full_heads,
246 local_heads,
247 conv_kernel,
248 gate_rank,
249 tensors,
250 })
251 }
252
253 pub fn get(&self, name: &str) -> Option<&KdaTensorPlan> {
254 self.tensors.iter().find(|t| t.name == name)
255 }
256
257 /// Total bytes this rank stores for one KDA block.
258 pub fn local_bytes(&self) -> usize {
259 self.tensors.iter().map(|t| t.local_bytes()).sum()
260 }
261
262 /// Total bytes one KDA block occupies on disk.
263 pub fn full_bytes(&self) -> usize {
264 self.tensors.iter().map(|t| t.full_bytes()).sum()
265 }
266
267 /// Whether the layer must all-reduce after `o_proj`. False at `tp_size == 1`,
268 /// where the row-parallel slice is the whole tensor and the reduce is a no-op.
269 pub fn needs_output_all_reduce(&self) -> bool {
270 self.tp_size > 1
271 }
272}
273
274#[cfg(test)]
275mod tests;