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;