metrale_model_arch/tp_shard.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Tensor-parallel weight sharding helpers.
4//!
5//! Each weight tensor is sliced along one of two axes:
6//!
7//! - **Column-parallel** (Q/K/V proj, gate_proj, up_proj): weight shape
8//! `[out, in]` becomes `[out / tp, in]`. Rank `r` keeps rows
9//! `[r * out / tp, (r + 1) * out / tp)`, one contiguous slice in row-major
10//! layout, so one `copy_d2d`.
11//!
12//! - **Row-parallel** (O proj, down_proj): weight `[out, in]` becomes
13//! `[out, in / tp]`. Rank `r` keeps cols `[r * in / tp, (r + 1) * in / tp)`,
14//! one strided copy per row.
15//!
16//! 1D per-output vectors shard along the output axis of their column-parallel
17//! GEMM (`shard_dense_1d_bf16`).
18//!
19//! The BF16 primitives are here; `quant_shard.rs` slices weights that are
20//! already NVFP4 or FP8 block-scaled, and `gdn.rs` the segmented Gated-DeltaNet
21//! projections.
22//!
23//! Owner: model-arch (tensor parallelism).
24//! Invariants:
25//! - With `tp_size <= 1` every shard function returns its source unchanged
26//! and allocates nothing.
27
28use anyhow::{Result, ensure};
29use metrale_config::ModelConfig;
30use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
31
32use metrale_model_layers::weight_map::DenseWeight;
33
34/// 2026-09-25: Bytes per BF16 element.
35const BF16_BYTES: usize = 2;
36
37/// 2026-09-25: TP shard kind for a 2D weight `[out_dim, in_dim]`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TpShardKind {
40 /// 2026-09-25: Replicated: every TP rank holds the full tensor.
41 Replicated,
42 /// 2026-09-25: Column-parallel: split `out_dim` evenly across ranks. Rank `r` keeps
43 /// rows `[r * out_dim / tp, (r + 1) * out_dim / tp)`.
44 ColumnParallel,
45 /// 2026-09-25: Row-parallel: split `in_dim` evenly across ranks. Rank `r` keeps
46 /// cols `[r * in_dim / tp, (r + 1) * in_dim / tp)`.
47 RowParallel,
48}
49
50/// 2026-09-25: Shard a BF16 dense weight `[out_dim, in_dim]` according to `kind`.
51///
52/// Returns `(sharded_ptr, sharded_out, sharded_in)`. When `tp_size <= 1`
53/// or `kind == Replicated`, returns the source pointer untouched; the caller
54/// must not free the source separately (no shard happened).
55///
56/// Otherwise allocates a new device buffer holding the local rank's slice,
57/// copies into it, and returns the new pointer. The caller owns the source
58/// and must `gpu.free` it after the shard is built. Errors when
59/// `tp_rank >= tp_size` or the split dim is not divisible by `tp_size`.
60pub fn shard_dense_bf16(
61 src: DevicePtr,
62 out_dim: usize,
63 in_dim: usize,
64 kind: TpShardKind,
65 tp_rank: usize,
66 tp_size: usize,
67 gpu: &dyn GpuBackend,
68) -> Result<(DevicePtr, usize, usize)> {
69 if tp_size <= 1 || kind == TpShardKind::Replicated {
70 return Ok((src, out_dim, in_dim));
71 }
72 ensure!(tp_rank < tp_size, "tp_rank {tp_rank} >= tp_size {tp_size}");
73 match kind {
74 TpShardKind::Replicated => unreachable!("handled above"),
75 TpShardKind::ColumnParallel => {
76 ensure!(
77 out_dim.is_multiple_of(tp_size),
78 "ColumnParallel: out_dim {out_dim} not divisible by tp_size {tp_size}",
79 );
80 let local_out = out_dim / tp_size;
81 let row_bytes = in_dim * BF16_BYTES;
82 let local_bytes = local_out * row_bytes;
83 let dst = gpu.alloc(local_bytes)?;
84 let src_offset = tp_rank * local_out * row_bytes;
85 let src_slice = DevicePtr(src.0 + src_offset as u64);
86 gpu.copy_d2d(src_slice, dst, local_bytes)?;
87 Ok((dst, local_out, in_dim))
88 }
89 TpShardKind::RowParallel => {
90 ensure!(
91 in_dim.is_multiple_of(tp_size),
92 "RowParallel: in_dim {in_dim} not divisible by tp_size {tp_size}",
93 );
94 let local_in = in_dim / tp_size;
95 let local_row_bytes = local_in * BF16_BYTES;
96 let src_row_bytes = in_dim * BF16_BYTES;
97 let local_bytes = out_dim * local_row_bytes;
98 let dst = gpu.alloc(local_bytes)?;
99 // 2026-09-25: Per-row strided copy: row r of dst comes from row r of src,
100 // starting at column `tp_rank * local_in`.
101 let col_offset_bytes = tp_rank * local_row_bytes;
102 tracing::debug!(
103 target: "metrale_model_arch::tp_shard",
104 out_dim, in_dim, local_in, src_row_bytes, local_row_bytes,
105 tp_rank, tp_size, src = src.0,
106 "dense row-parallel shard (per-row strided)"
107 );
108 for r in 0..out_dim {
109 let src_off = r * src_row_bytes + col_offset_bytes;
110 let dst_off = r * local_row_bytes;
111 gpu.copy_d2d(
112 DevicePtr(src.0 + src_off as u64),
113 DevicePtr(dst.0 + dst_off as u64),
114 local_row_bytes,
115 )?;
116 }
117 Ok((dst, out_dim, local_in))
118 }
119 }
120}
121
122/// 2026-09-25: Shard a 1D BF16 vector `[dim]` on dim 0, for per-output
123/// vectors of column-parallel GEMMs. Same ownership rule as `shard_dense_bf16`.
124pub fn shard_dense_1d_bf16(
125 src: DevicePtr,
126 dim: usize,
127 tp_rank: usize,
128 tp_size: usize,
129 gpu: &dyn GpuBackend,
130) -> Result<(DevicePtr, usize)> {
131 if tp_size <= 1 {
132 return Ok((src, dim));
133 }
134 ensure!(tp_rank < tp_size, "tp_rank {tp_rank} >= tp_size {tp_size}");
135 ensure!(
136 dim.is_multiple_of(tp_size),
137 "shard_dense_1d_bf16: dim {dim} not divisible by tp_size {tp_size}",
138 );
139 let local_dim = dim / tp_size;
140 let local_bytes = local_dim * BF16_BYTES;
141 let dst = gpu.alloc(local_bytes)?;
142 let src_offset = tp_rank * local_bytes;
143 gpu.copy_d2d(DevicePtr(src.0 + src_offset as u64), dst, local_bytes)?;
144 Ok((dst, local_dim))
145}
146
147/// 2026-09-25: `shard_dense_bf16` on a `DenseWeight`, with the same ownership
148/// rule: a new buffer only when a shard happened, and then the caller frees
149/// the source.
150pub fn shard_dense_weight(
151 src: &DenseWeight,
152 out_dim: usize,
153 in_dim: usize,
154 kind: TpShardKind,
155 tp_rank: usize,
156 tp_size: usize,
157 gpu: &dyn GpuBackend,
158) -> Result<(DenseWeight, usize, usize)> {
159 let (ptr, n, k) = shard_dense_bf16(src.weight, out_dim, in_dim, kind, tp_rank, tp_size, gpu)?;
160 Ok((DenseWeight { weight: ptr }, n, k))
161}
162
163// 2026-09-25: Dimension helpers shared by the loaders: `TpAttentionDims` and
164// `TpMoeDims` rebuild the pre-shard sizes from `config`, and `load_qkvo_tp` /
165// `load_qk_norms_tp` run the per-projection loads through a caller closure,
166// which picks the format's slicing primitive.
167
168/// 2026-09-25: Pre-TP-shard attention dimensions reconstructed from `config`.
169///
170/// The server's `serve_phases/topology.rs` divides `num_attention_heads` and
171/// `num_key_value_heads` by the TP size before any loader runs, so `config`
172/// holds per-rank head counts. The `full_*` fields multiply back up to the
173/// pre-shard sizes the shard functions take.
174///
175/// When `config.attn_gated` is true the Q projection's output includes the
176/// gate, doubling it: `full_q_n` includes the gate and `full_o_in` does not.
177#[derive(Debug, Clone, Copy)]
178pub struct TpAttentionDims {
179 pub tp_rank: usize,
180 /// 2026-09-25: `tp_world_size` clamped to `>= 1`.
181 pub tp_size: usize,
182 /// 2026-09-25: Hidden size, not divided per rank.
183 pub h: usize,
184 pub head_dim: usize,
185 /// 2026-09-25: Q-projection output dim, doubled for gated attention.
186 pub full_q_n: usize,
187 /// 2026-09-25: O-projection input dim, the un-gated attention output:
188 /// `num_attention_heads * tp_size * head_dim`, not doubled.
189 pub full_o_in: usize,
190 /// 2026-09-25: `num_key_value_heads_local * tp_size * head_dim`: full K/V pre-shard.
191 pub full_kv_n: usize,
192 /// 2026-09-25: `config.attn_gated`.
193 pub gated: bool,
194}
195
196impl TpAttentionDims {
197 pub fn from_config(config: &ModelConfig) -> Self {
198 let tp_size = config.tp_world_size.max(1);
199 let head_dim = config.head_dim;
200 let num_heads_local = config.num_attention_heads;
201 let num_kv_heads_local = config.num_key_value_heads;
202 let gated = config.attn_gated;
203 let attn_out = num_heads_local * tp_size * head_dim;
204 let q_factor = if gated { 2 } else { 1 };
205 Self {
206 tp_rank: config.tp_rank,
207 tp_size,
208 h: config.hidden_size,
209 head_dim,
210 full_q_n: attn_out * q_factor,
211 full_o_in: attn_out,
212 full_kv_n: num_kv_heads_local * tp_size * head_dim,
213 gated,
214 }
215 }
216
217 /// 2026-09-25: `(out_dim, in_dim, kind)` for a given QKVO projection; `None` for
218 /// any other name.
219 pub fn proj_shape(&self, name: &str) -> Option<(usize, usize, TpShardKind)> {
220 match name {
221 "q_proj" => Some((self.full_q_n, self.h, TpShardKind::ColumnParallel)),
222 "k_proj" | "v_proj" => Some((self.full_kv_n, self.h, TpShardKind::ColumnParallel)),
223 "o_proj" => Some((self.h, self.full_o_in, TpShardKind::RowParallel)),
224 _ => None,
225 }
226 }
227}
228
229/// 2026-09-25: Sequence the four Q/K/V/O loads via a loader-supplied closure. The
230/// closure receives `(name, full_out, full_in, kind)` and returns the
231/// loader's representation of that projection (BF16 dense, NVFP4
232/// quantized, FP8 block-scaled — varies by format).
233///
234/// Returns `[Q, K, V, O]`; callers destructure with
235/// `let [q, k, v, o] = load_qkvo_tp(config, |name, n, k, kind| { ... })?;`.
236pub fn load_qkvo_tp<F, T>(config: &ModelConfig, mut proj_loader: F) -> Result<[T; 4]>
237where
238 F: FnMut(&str, usize, usize, TpShardKind) -> Result<T>,
239{
240 let dims = TpAttentionDims::from_config(config);
241 let q = proj_loader("q_proj", dims.full_q_n, dims.h, TpShardKind::ColumnParallel)?;
242 let k = proj_loader(
243 "k_proj",
244 dims.full_kv_n,
245 dims.h,
246 TpShardKind::ColumnParallel,
247 )?;
248 let v = proj_loader(
249 "v_proj",
250 dims.full_kv_n,
251 dims.h,
252 TpShardKind::ColumnParallel,
253 )?;
254 // 2026-09-25: O proj input dim is the un-gated attention output; for gated
255 // attention it differs from `full_q_n`, which includes the gate.
256 let o = proj_loader("o_proj", dims.h, dims.full_o_in, TpShardKind::RowParallel)?;
257 Ok([q, k, v, o])
258}
259
260/// 2026-09-25: Q/K-norm 1D shard pair. The closure receives `(name, full_dim)` and
261/// returns the loader's sharded norm — typically a `DenseWeight`.
262/// `q_norm` is sharded against `full_q_n`; `k_norm` against `full_kv_n`.
263/// Returns `(q_norm, k_norm)`.
264pub fn load_qk_norms_tp<F, T>(config: &ModelConfig, mut norm_loader: F) -> Result<(T, T)>
265where
266 F: FnMut(&str, usize) -> Result<T>,
267{
268 let dims = TpAttentionDims::from_config(config);
269 let q_norm = norm_loader("q_norm", dims.full_q_n)?;
270 let k_norm = norm_loader("k_norm", dims.full_kv_n)?;
271 Ok((q_norm, k_norm))
272}
273
274/// 2026-09-25: Pre-TP-shard dimensions for MoE expert projections. Unlike the
275/// head counts, `serve_phases/topology.rs` does not divide
276/// `moe_intermediate_size`, so `full_inter == config.moe_intermediate_size`
277/// and the local size is computed here.
278#[derive(Debug, Clone, Copy)]
279pub struct TpMoeDims {
280 pub tp_rank: usize,
281 pub tp_size: usize,
282 pub h: usize,
283 /// 2026-09-25: Full MoE intermediate dim (not TP-divided).
284 pub full_inter: usize,
285 /// 2026-09-25: Local (post-shard) MoE intermediate dim, `full_inter / tp_size`.
286 pub local_inter: usize,
287}
288
289impl TpMoeDims {
290 pub fn from_config(config: &ModelConfig) -> Self {
291 let tp_size = config.tp_world_size.max(1);
292 let full_inter = config.moe_intermediate_size;
293 Self {
294 tp_rank: config.tp_rank,
295 tp_size,
296 h: config.hidden_size,
297 full_inter,
298 local_inter: full_inter / tp_size,
299 }
300 }
301
302 /// 2026-09-25: `(out_dim, in_dim, kind)` for one of `gate_proj` / `up_proj` /
303 /// `down_proj`. Gate/up are column-parallel on inter; down is
304 /// row-parallel on inter (so `[h, inter]` rows truncate to `[h, inter/tp]`).
305 pub fn proj_shape(&self, name: &str) -> Option<(usize, usize, TpShardKind)> {
306 match name {
307 "gate_proj" | "up_proj" => Some((self.full_inter, self.h, TpShardKind::ColumnParallel)),
308 "down_proj" => Some((self.h, self.full_inter, TpShardKind::RowParallel)),
309 _ => None,
310 }
311 }
312}
313
314mod gdn;
315pub use gdn::*;
316
317mod quant_shard;
318pub use quant_shard::{shard_fp8_block_scaled, shard_quantized_nvfp4};
319
320#[cfg(test)]
321mod tests;