spark_model/tp_shard/gdn.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GDN HeadParallel — tensor-parallel sharding of the Gated-DeltaNet (SSM /
4//! linear-attention) layers. Split out of `tp_shard.rs` (file-size cap).
5
6use anyhow::{Result, ensure};
7use atlas_core::config::ModelConfig;
8use spark_runtime::gpu::{DevicePtr, GpuBackend};
9
10use super::{BF16_BYTES, TpShardKind, shard_dense_bf16};
11
12// ════════════════════════════════════════════════════════════════════
13// GDN HeadParallel — tensor-parallel sharding of the Gated-DeltaNet
14// (linear_attention / SSM) layers for Qwen3.5 / 3.6.
15//
16// The GDN recurrence is embarrassingly parallel across *value-head groups*:
17// each TP rank owns a contiguous range of key/value heads, runs the whole
18// scan locally with LOCAL nk/nv/conv_dim, and the ranks reconcile with a
19// single all-reduce after `out_proj` (row-parallel, exactly like attention
20// after `o_proj`). No cross-rank comm inside the scan.
21//
22// CRITICAL LAYOUT FACT — the in-projection is stored as *segmented*
23// contiguous blocks, NOT one flat matrix:
24//
25// in_proj_qkv : [Q | K | V] rows = nk·kd + nk·kd + nv·vd (= conv_dim)
26// in_proj_z : [Z] rows = nv·vd
27// → gpu_concat_rows → QKVZ [Q | K | V | Z]
28//
29// A naive "first out_dim/tp rows" slice is WRONG: it would give rank 0 the
30// whole Q block plus part of K. Each segment (Q, K, V, Z) must be sliced by
31// the LOCAL head range *independently*, then the local slices re-concatenated
32// in the same [Q|K|V|Z] order. The `segment_copy_plan` below encodes exactly
33// that: one contiguous copy per segment, packed back-to-back into the local
34// buffer.
35//
36// The depthwise `conv1d` weight `[conv_dim, d_conv]` is sharded with the SAME
37// segment pattern as QKV (its channels ARE the QKV channels — one filter per
38// channel), NOT replicated. `a_log`/`dt_bias` (`[nv]` FP32), `norm`
39// (`[nv·vd]` BF16) and `out_proj` (`[h, nv·vd]`, row-parallel) shard on the
40// value-head axis. The BA gate buffer is per-group interleaved but the rank
41// boundary always lands on a group boundary, so it slices contiguously.
42// ════════════════════════════════════════════════════════════════════
43
44/// Pre-TP-shard GDN (linear-attention / SSM) dimensions reconstructed from
45/// `config`.
46///
47/// Mirrors [`super::TpAttentionDims`]: `topology.rs` divides
48/// `linear_num_key_heads` / `linear_num_value_heads` by `tp_world_size` at
49/// startup, so by the time a loader runs `config` holds **per-rank-local**
50/// head counts. The `full_*` fields multiply back up to the pre-shard sizes
51/// that the segment slicers expect. Head *dims* (`kd`, `vd`) and the hidden
52/// size `h` are never sharded.
53#[derive(Debug, Clone, Copy)]
54pub struct TpGdnDims {
55 pub tp_rank: usize,
56 /// `tp_world_size` clamped to `>= 1`. Loaders treat `tp_size == 1` as the
57 /// no-shard fast path.
58 pub tp_size: usize,
59 /// Hidden size (model embed dim) — never sharded.
60 pub h: usize,
61 /// Key head dim (`linear_key_head_dim`) — never sharded.
62 pub kd: usize,
63 /// Value head dim (`linear_value_head_dim`) — never sharded.
64 pub vd: usize,
65 /// Per-rank key heads (Q and K share this count).
66 pub local_nk: usize,
67 /// Full pre-shard key heads = `local_nk * tp_size`.
68 pub full_nk: usize,
69 /// Per-rank value heads.
70 pub local_nv: usize,
71 /// Full pre-shard value heads = `local_nv * tp_size`.
72 pub full_nv: usize,
73}
74
75impl TpGdnDims {
76 pub fn from_config(config: &ModelConfig) -> Self {
77 let tp_size = config.tp_world_size.max(1);
78 let local_nk = config.linear_num_key_heads;
79 let local_nv = config.linear_num_value_heads;
80 Self {
81 tp_rank: config.tp_rank,
82 tp_size,
83 h: config.hidden_size,
84 kd: config.linear_key_head_dim,
85 vd: config.linear_value_head_dim,
86 local_nk,
87 full_nk: local_nk * tp_size,
88 local_nv,
89 full_nv: local_nv * tp_size,
90 }
91 }
92
93 /// Full (pre-shard) key projection width: `full_nk * kd`.
94 pub fn full_key_dim(&self) -> usize {
95 self.full_nk * self.kd
96 }
97 /// Local key projection width: `local_nk * kd`.
98 pub fn local_key_dim(&self) -> usize {
99 self.local_nk * self.kd
100 }
101 /// Full (pre-shard) value projection width: `full_nv * vd`.
102 pub fn full_value_dim(&self) -> usize {
103 self.full_nv * self.vd
104 }
105 /// Local value projection width: `local_nv * vd`.
106 pub fn local_value_dim(&self) -> usize {
107 self.local_nv * self.vd
108 }
109 /// Full conv / QKV width: `2*full_nk*kd + full_nv*vd`.
110 pub fn full_conv_dim(&self) -> usize {
111 2 * self.full_key_dim() + self.full_value_dim()
112 }
113 /// Local conv / QKV width: `2*local_nk*kd + local_nv*vd`.
114 pub fn local_conv_dim(&self) -> usize {
115 2 * self.local_key_dim() + self.local_value_dim()
116 }
117 /// Full QKVZ out dim: `2*full_nk*kd + 2*full_nv*vd`.
118 pub fn full_qkvz_out(&self) -> usize {
119 self.full_conv_dim() + self.full_value_dim()
120 }
121 /// Local QKVZ out dim: `2*local_nk*kd + 2*local_nv*vd`.
122 pub fn local_qkvz_out(&self) -> usize {
123 self.local_conv_dim() + self.local_value_dim()
124 }
125
126 /// Full-row segment list for the `[Q|K|V]` in-projection.
127 pub(crate) fn qkv_segments(&self) -> [usize; 3] {
128 [
129 self.full_key_dim(),
130 self.full_key_dim(),
131 self.full_value_dim(),
132 ]
133 }
134 /// Full-row segment list for the concatenated `[Q|K|V|Z]` in-projection.
135 pub(crate) fn qkvz_segments(&self) -> [usize; 4] {
136 [
137 self.full_key_dim(),
138 self.full_key_dim(),
139 self.full_value_dim(),
140 self.full_value_dim(),
141 ]
142 }
143}
144
145/// A single device-to-device copy in a segmented-slice plan.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub(crate) struct CopyOp {
148 pub(crate) src_off: usize,
149 pub(crate) dst_off: usize,
150 pub(crate) len: usize,
151}
152
153/// Build the copy plan for a SEGMENTED row-slice.
154///
155/// `segments` lists the full (pre-shard) row count of each contiguous block
156/// (Q, K, V[, Z] for QKVZ). Each block is sliced *independently* to the local
157/// rank's head range `[tp_rank * seg/tp, (tp_rank+1) * seg/tp)` and the local
158/// slices are packed back-to-back into the output buffer, preserving segment
159/// order. `row_bytes` is the byte width of one row (`in_dim * elem_bytes`).
160///
161/// Returns `(ops, local_total_rows)`. Every segment must be divisible by
162/// `tp_size` — the caller has already reconstructed `full_*` as
163/// `local_* * tp_size`, so this holds by construction, but it is checked to
164/// fail loudly on a mis-wired config rather than silently corrupt heads.
165pub(crate) fn segment_copy_plan(
166 segments: &[usize],
167 row_bytes: usize,
168 tp_rank: usize,
169 tp_size: usize,
170) -> Result<(Vec<CopyOp>, usize)> {
171 ensure!(tp_rank < tp_size, "tp_rank {tp_rank} >= tp_size {tp_size}");
172 let mut ops = Vec::with_capacity(segments.len());
173 let mut src_rows = 0usize; // running offset into the source (in rows)
174 let mut dst_rows = 0usize; // running offset into the packed dst (in rows)
175 for (i, &seg) in segments.iter().enumerate() {
176 ensure!(
177 seg.is_multiple_of(tp_size),
178 "segment {i} ({seg} rows) not divisible by tp_size {tp_size}",
179 );
180 let local = seg / tp_size;
181 ops.push(CopyOp {
182 src_off: (src_rows + tp_rank * local) * row_bytes,
183 dst_off: dst_rows * row_bytes,
184 len: local * row_bytes,
185 });
186 src_rows += seg;
187 dst_rows += local;
188 }
189 Ok((ops, dst_rows))
190}
191
192/// Execute a segmented row-slice on the GPU. `row_elems` is the number of
193/// elements per row (`in_dim`); `elem_bytes` its size (2 = BF16, 4 = FP32).
194/// Returns `(local_ptr, local_total_rows)`. For `tp_size <= 1` returns the
195/// source untouched (no allocation, caller must not double-free).
196fn slice_segments(
197 src: DevicePtr,
198 segments: &[usize],
199 row_elems: usize,
200 elem_bytes: usize,
201 tp_rank: usize,
202 tp_size: usize,
203 gpu: &dyn GpuBackend,
204) -> Result<(DevicePtr, usize)> {
205 let full_rows: usize = segments.iter().sum();
206 if tp_size <= 1 {
207 return Ok((src, full_rows));
208 }
209 let row_bytes = row_elems * elem_bytes;
210 let (ops, local_rows) = segment_copy_plan(segments, row_bytes, tp_rank, tp_size)?;
211 let dst = gpu.alloc(local_rows * row_bytes)?;
212 tracing::debug!(
213 target: "spark_model::tp_shard",
214 ?segments, full_rows, row_elems, elem_bytes, local_rows,
215 tp_rank, tp_size, src = src.0,
216 "gdn segmented row-slice"
217 );
218 for op in &ops {
219 gpu.copy_d2d(src.offset(op.src_off), dst.offset(op.dst_off), op.len)?;
220 }
221 Ok((dst, local_rows))
222}
223
224/// Shard the `[Q|K|V]` (`in_proj_qkv`) BF16 weight `[full_conv_dim, h]` to the
225/// local rank's `[local_conv_dim, h]`, slicing Q, K and V independently by the
226/// local head range. Returns `(ptr, local_rows, h)`.
227pub fn shard_gdn_qkv_rows(
228 src: DevicePtr,
229 dims: &TpGdnDims,
230 gpu: &dyn GpuBackend,
231) -> Result<(DevicePtr, usize, usize)> {
232 let (ptr, rows) = slice_segments(
233 src,
234 &dims.qkv_segments(),
235 dims.h,
236 BF16_BYTES,
237 dims.tp_rank,
238 dims.tp_size,
239 gpu,
240 )?;
241 Ok((ptr, rows, dims.h))
242}
243
244/// Shard the concatenated `[Q|K|V|Z]` (`in_proj_qkvz`) BF16 weight
245/// `[full_qkvz_out, h]` to the local rank's `[local_qkvz_out, h]`, slicing all
246/// four segments independently. Returns `(ptr, local_rows, h)`.
247pub fn shard_gdn_qkvz_rows(
248 src: DevicePtr,
249 dims: &TpGdnDims,
250 gpu: &dyn GpuBackend,
251) -> Result<(DevicePtr, usize, usize)> {
252 let (ptr, rows) = slice_segments(
253 src,
254 &dims.qkvz_segments(),
255 dims.h,
256 BF16_BYTES,
257 dims.tp_rank,
258 dims.tp_size,
259 gpu,
260 )?;
261 Ok((ptr, rows, dims.h))
262}
263
264/// Shard the BA gate BF16 weight `[2*full_nv, h]` to `[2*local_nv, h]`.
265///
266/// The interleave is per key-head group (`[β₀..β_{vpg-1}, α₀..α_{vpg-1}]` per
267/// group, `vpg = nv/nk`), but rank `r` owns key-head groups
268/// `[r*local_nk, (r+1)*local_nk)` which map to the contiguous row range
269/// `[r*2*local_nv, (r+1)*2*local_nv)` — the rank boundary always lands on a
270/// group boundary, so a single contiguous slice preserves the interleave.
271pub fn shard_gdn_ba_rows(
272 src: DevicePtr,
273 dims: &TpGdnDims,
274 gpu: &dyn GpuBackend,
275) -> Result<(DevicePtr, usize, usize)> {
276 // Group-boundary alignment guarantee: full_nk divisible by tp_size ⇒
277 // each rank gets whole groups.
278 ensure!(
279 dims.full_nk.is_multiple_of(dims.tp_size),
280 "BA: full_nk {} not divisible by tp_size {}",
281 dims.full_nk,
282 dims.tp_size,
283 );
284 let (ptr, rows) = slice_segments(
285 src,
286 &[2 * dims.full_nv],
287 dims.h,
288 BF16_BYTES,
289 dims.tp_rank,
290 dims.tp_size,
291 gpu,
292 )?;
293 Ok((ptr, rows, dims.h))
294}
295
296/// Shard the depthwise `conv1d` BF16 weight `[full_conv_dim, d_conv]` to
297/// `[local_conv_dim, d_conv]`. Channels ARE the QKV channels (one filter per
298/// channel), so this uses the SAME `[Q|K|V]` segment pattern as the QKV
299/// in-projection — the conv is NOT replicated across ranks.
300pub fn shard_gdn_conv_rows(
301 src: DevicePtr,
302 dims: &TpGdnDims,
303 d_conv: usize,
304 gpu: &dyn GpuBackend,
305) -> Result<(DevicePtr, usize, usize)> {
306 let (ptr, rows) = slice_segments(
307 src,
308 &dims.qkv_segments(),
309 d_conv,
310 BF16_BYTES,
311 dims.tp_rank,
312 dims.tp_size,
313 gpu,
314 )?;
315 Ok((ptr, rows, d_conv))
316}
317
318/// Shard a per-value-head 1D vector on the value-head axis. Handles BF16
319/// (`norm`, `[full_nv*vd]` → `[local_nv*vd]` with `elem_bytes = 2`,
320/// `unit = vd`) and FP32 (`a_log` / `dt_bias`, `[full_nv]` → `[local_nv]` with
321/// `elem_bytes = 4`, `unit = 1`). `unit` is the number of elements per value
322/// head. Returns `(ptr, local_len_elems)`.
323pub fn shard_gdn_value_vector(
324 src: DevicePtr,
325 dims: &TpGdnDims,
326 unit: usize,
327 elem_bytes: usize,
328 gpu: &dyn GpuBackend,
329) -> Result<(DevicePtr, usize)> {
330 let full_len = dims.full_nv * unit;
331 if dims.tp_size <= 1 {
332 return Ok((src, full_len));
333 }
334 ensure!(
335 dims.tp_rank < dims.tp_size,
336 "tp_rank {} >= tp_size {}",
337 dims.tp_rank,
338 dims.tp_size,
339 );
340 let local_len = dims.local_nv * unit;
341 let local_bytes = local_len * elem_bytes;
342 let dst = gpu.alloc(local_bytes)?;
343 let src_off = dims.tp_rank * local_bytes;
344 tracing::debug!(
345 target: "spark_model::tp_shard",
346 full_nv = dims.full_nv, local_nv = dims.local_nv, unit, elem_bytes,
347 full_len, local_len, local_bytes, src_off, tp_rank = dims.tp_rank,
348 src = src.0,
349 "gdn value-vector shard (per-value-head axis)"
350 );
351 gpu.copy_d2d(src.offset(src_off), dst, local_bytes)?;
352 Ok((dst, local_len))
353}
354
355/// Shard the `out_proj` BF16 weight `[h, full_value_dim]` row-parallel on its
356/// input dim (value_dim). Rank `r` keeps columns
357/// `[r*local_value_dim, (r+1)*local_value_dim)` of every output row; the
358/// partial products are summed with an all-reduce after the GEMM (mirrors
359/// attention `o_proj`). Returns `(ptr, h, local_value_dim)`.
360pub fn shard_gdn_out_proj_row_parallel(
361 src: DevicePtr,
362 dims: &TpGdnDims,
363 gpu: &dyn GpuBackend,
364) -> Result<(DevicePtr, usize, usize)> {
365 shard_dense_bf16(
366 src,
367 dims.h,
368 dims.full_value_dim(),
369 TpShardKind::RowParallel,
370 dims.tp_rank,
371 dims.tp_size,
372 gpu,
373 )
374}