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}