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;