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