spark_model/
tp_shard.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Tensor-parallel weight sharding helpers.
4//!
5//! Megatron-style TP slices each weight tensor along one of two axes:
6//!
7//! - **Column-parallel** (Q/K/V proj, gate_proj, up_proj, lm_head): weight
8//!   shape `[out, in]` becomes `[out / tp, in]`. Rank `r` keeps rows
9//!   `[r * out / tp, (r + 1) * out / tp)`. This is a single contiguous
10//!   slice in row-major layout — one `copy_d2d`.
11//!
12//! - **Row-parallel** (O proj, down_proj): weight `[out, in]` becomes
13//!   `[out, in / tp]`. Rank `r` keeps cols
14//!   `[r * in / tp, (r + 1) * in / tp)`. Per-row strided copy because the
15//!   surviving slice is non-contiguous in row-major layout.
16//!
17//! 1D per-output vectors (q_norm_full, k_norm_full, gate_proj bias, etc.)
18//! shard with the same axis as their associated GEMM's column-parallel output.
19//!
20//! All shard helpers operate on BF16 weights *before* NVFP4 quantization;
21//! sharding the packed FP4 storage + FP8 scales is mechanical but adds two
22//! more axes to bookkeep, and pre-quant slicing keeps the existing quantize
23//! path untouched.
24
25use anyhow::{Result, ensure};
26use atlas_core::config::ModelConfig;
27use spark_runtime::gpu::{DevicePtr, GpuBackend};
28
29use crate::weight_map::DenseWeight;
30
31/// Bytes per BF16 element.
32const BF16_BYTES: usize = 2;
33
34/// TP shard kind for a 2D BF16 weight `[out_dim, in_dim]`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum TpShardKind {
37    /// Replicated: every TP rank holds the full tensor (norm scalars,
38    /// embedding-tied weights, MTP heads in v1).
39    Replicated,
40    /// Column-parallel: split `out_dim` evenly across ranks. Rank `r` keeps
41    /// rows `[r * out_dim / tp, (r + 1) * out_dim / tp)`.
42    ColumnParallel,
43    /// Row-parallel: split `in_dim` evenly across ranks. Rank `r` keeps
44    /// cols `[r * in_dim / tp, (r + 1) * in_dim / tp)`.
45    RowParallel,
46}
47
48/// Shard a BF16 dense weight `[out_dim, in_dim]` according to `kind`.
49///
50/// Returns `(sharded_ptr, sharded_out, sharded_in)`. When `tp_size == 1`
51/// or `kind == Replicated`, returns the source pointer untouched and the
52/// caller must NOT free the source separately (no shard happened).
53///
54/// Otherwise allocates a new device buffer holding the local rank's slice,
55/// copies into it, and returns the new pointer. The caller owns the source
56/// and must `gpu.free` it after the shard is built.
57pub fn shard_dense_bf16(
58    src: DevicePtr,
59    out_dim: usize,
60    in_dim: usize,
61    kind: TpShardKind,
62    tp_rank: usize,
63    tp_size: usize,
64    gpu: &dyn GpuBackend,
65) -> Result<(DevicePtr, usize, usize)> {
66    if tp_size <= 1 || kind == TpShardKind::Replicated {
67        return Ok((src, out_dim, in_dim));
68    }
69    ensure!(tp_rank < tp_size, "tp_rank {tp_rank} >= tp_size {tp_size}");
70    match kind {
71        TpShardKind::Replicated => unreachable!("handled above"),
72        TpShardKind::ColumnParallel => {
73            ensure!(
74                out_dim.is_multiple_of(tp_size),
75                "ColumnParallel: out_dim {out_dim} not divisible by tp_size {tp_size}",
76            );
77            let local_out = out_dim / tp_size;
78            let row_bytes = in_dim * BF16_BYTES;
79            let local_bytes = local_out * row_bytes;
80            let dst = gpu.alloc(local_bytes)?;
81            let src_offset = tp_rank * local_out * row_bytes;
82            let src_slice = DevicePtr(src.0 + src_offset as u64);
83            gpu.copy_d2d(src_slice, dst, local_bytes)?;
84            Ok((dst, local_out, in_dim))
85        }
86        TpShardKind::RowParallel => {
87            ensure!(
88                in_dim.is_multiple_of(tp_size),
89                "RowParallel: in_dim {in_dim} not divisible by tp_size {tp_size}",
90            );
91            let local_in = in_dim / tp_size;
92            let local_row_bytes = local_in * BF16_BYTES;
93            let src_row_bytes = in_dim * BF16_BYTES;
94            let local_bytes = out_dim * local_row_bytes;
95            let dst = gpu.alloc(local_bytes)?;
96            // Per-row strided copy: row r of dst comes from row r of src,
97            // starting at column `tp_rank * local_in`.
98            let col_offset_bytes = tp_rank * local_row_bytes;
99            tracing::debug!(
100                target: "spark_model::tp_shard",
101                out_dim, in_dim, local_in, src_row_bytes, local_row_bytes,
102                tp_rank, tp_size, src = src.0,
103                "dense row-parallel shard (per-row strided)"
104            );
105            for r in 0..out_dim {
106                let src_off = r * src_row_bytes + col_offset_bytes;
107                let dst_off = r * local_row_bytes;
108                gpu.copy_d2d(
109                    DevicePtr(src.0 + src_off as u64),
110                    DevicePtr(dst.0 + dst_off as u64),
111                    local_row_bytes,
112                )?;
113            }
114            Ok((dst, out_dim, local_in))
115        }
116    }
117}
118
119/// Shard a 1D BF16 vector `[dim]` (e.g. q_norm_full, gate_proj bias) on
120/// dim 0. Used for per-output vectors that pair with column-parallel GEMMs.
121pub fn shard_dense_1d_bf16(
122    src: DevicePtr,
123    dim: usize,
124    tp_rank: usize,
125    tp_size: usize,
126    gpu: &dyn GpuBackend,
127) -> Result<(DevicePtr, usize)> {
128    if tp_size <= 1 {
129        return Ok((src, dim));
130    }
131    ensure!(tp_rank < tp_size, "tp_rank {tp_rank} >= tp_size {tp_size}");
132    ensure!(
133        dim.is_multiple_of(tp_size),
134        "shard_dense_1d_bf16: dim {dim} not divisible by tp_size {tp_size}",
135    );
136    let local_dim = dim / tp_size;
137    let local_bytes = local_dim * BF16_BYTES;
138    let dst = gpu.alloc(local_bytes)?;
139    let src_offset = tp_rank * local_bytes;
140    gpu.copy_d2d(DevicePtr(src.0 + src_offset as u64), dst, local_bytes)?;
141    Ok((dst, local_dim))
142}
143
144/// Convenience wrapper: shard a `DenseWeight` BF16 tensor. The source weight
145/// is freed by the caller — this fn allocates a new device buffer.
146pub fn shard_dense_weight(
147    src: &DenseWeight,
148    out_dim: usize,
149    in_dim: usize,
150    kind: TpShardKind,
151    tp_rank: usize,
152    tp_size: usize,
153    gpu: &dyn GpuBackend,
154) -> Result<(DenseWeight, usize, usize)> {
155    let (ptr, n, k) = shard_dense_bf16(src.weight, out_dim, in_dim, kind, tp_rank, tp_size, gpu)?;
156    Ok((DenseWeight { weight: ptr }, n, k))
157}
158
159// ════════════════════════════════════════════════════════════════════
160// Higher-level helpers — DRY across per-architecture weight loaders.
161//
162// Each loader was repeating the same dimension math + the same Q/K/V/O
163// (col, col, col, row) Megatron pattern. The four helpers below capture
164// the isomorphism so a new loader only needs the format-specific load
165// closure, not the dimension bookkeeping.
166//
167// Cross-loader patterns extracted:
168//   1. Attention QKVO: 3× ColumnParallel + 1× RowParallel. `TpAttentionDims`
169//      reconstructs full pre-shard sizes from `config` (which `main.rs`
170//      already TP-divided for head counts), then `load_qkvo_tp` sequences
171//      the four loads via a caller closure.
172//   2. Q/K norm pair: 1D shards aligned with the QKV column-parallel axis.
173//      `load_qk_norms_tp` calls a 1D-shard closure for `q_norm`/`k_norm`.
174//   3. MoE expert projections: 2× ColumnParallel (gate, up) + 1× RowParallel
175//      (down) on the routed-expert intermediate dim. `TpMoeDims` + the
176//      caller-side closure mirror the QKVO pattern but on the MoE axes.
177//
178// Per-quantization-format byte-slicing primitives (`shard_dense_bf16`
179// above, `shard_quantized_nvfp4` and `shard_fp8_block_scaled` below)
180// stay in this module so each loader can pick the matching primitive
181// from inside its closure.
182// ════════════════════════════════════════════════════════════════════
183
184/// Pre-TP-shard attention dimensions reconstructed from `config`.
185///
186/// `main.rs` divides `num_attention_heads` and `num_key_value_heads` by
187/// `tp_world_size` at startup, so by the time a loader runs, `config`
188/// holds **per-rank-local** head counts. The `full_*` fields multiply
189/// back up to the pre-shard sizes that `slice_for_rank` and friends
190/// expect.
191///
192/// When `config.attn_gated` is true (Qwen3-Next), the Q projection
193/// output dim is doubled — the second half is the per-token gate
194/// applied after attention. `full_q_n` includes the gate; `full_o_in`
195/// does NOT (O proj's input dim matches the un-gated attention
196/// output, since the gate is applied before O proj).
197#[derive(Debug, Clone, Copy)]
198pub struct TpAttentionDims {
199    pub tp_rank: usize,
200    /// `tp_world_size` clamped to `>= 1`. Loaders should treat
201    /// `tp_size == 1` as the no-shard fast path.
202    pub tp_size: usize,
203    /// Hidden size (model embed dim) — never sharded.
204    pub h: usize,
205    pub head_dim: usize,
206    /// Q-projection output dim. For gated attention this is doubled
207    /// (the second half is the gate).
208    pub full_q_n: usize,
209    /// O-projection input dim. Equals the un-gated attention output —
210    /// `num_attention_heads * tp_size * head_dim`, NOT doubled.
211    pub full_o_in: usize,
212    /// `num_key_value_heads_local * tp_size * head_dim` — full K/V pre-shard.
213    pub full_kv_n: usize,
214    /// Whether the loader is operating on a gated-attention config.
215    pub gated: bool,
216}
217
218impl TpAttentionDims {
219    pub fn from_config(config: &ModelConfig) -> Self {
220        let tp_size = config.tp_world_size.max(1);
221        let head_dim = config.head_dim;
222        let num_heads_local = config.num_attention_heads;
223        let num_kv_heads_local = config.num_key_value_heads;
224        let gated = config.attn_gated;
225        let attn_out = num_heads_local * tp_size * head_dim;
226        let q_factor = if gated { 2 } else { 1 };
227        Self {
228            tp_rank: config.tp_rank,
229            tp_size,
230            h: config.hidden_size,
231            head_dim,
232            full_q_n: attn_out * q_factor,
233            full_o_in: attn_out,
234            full_kv_n: num_kv_heads_local * tp_size * head_dim,
235            gated,
236        }
237    }
238
239    /// `(out_dim, in_dim, kind)` for a given QKVO projection.
240    pub fn proj_shape(&self, name: &str) -> Option<(usize, usize, TpShardKind)> {
241        match name {
242            "q_proj" => Some((self.full_q_n, self.h, TpShardKind::ColumnParallel)),
243            "k_proj" | "v_proj" => Some((self.full_kv_n, self.h, TpShardKind::ColumnParallel)),
244            "o_proj" => Some((self.h, self.full_o_in, TpShardKind::RowParallel)),
245            _ => None,
246        }
247    }
248}
249
250/// Sequence the four Q/K/V/O loads via a loader-supplied closure. The
251/// closure receives `(name, full_out, full_in, kind)` and returns the
252/// loader's representation of that projection (BF16 dense, NVFP4
253/// quantized, FP8 block-scaled — varies by format).
254///
255/// Returns `[Q, K, V, O]`; callers destructure with
256/// `let [q, k, v, o] = load_qkvo_tp(config, |name, n, k, kind| { ... })?;`.
257pub fn load_qkvo_tp<F, T>(config: &ModelConfig, mut proj_loader: F) -> Result<[T; 4]>
258where
259    F: FnMut(&str, usize, usize, TpShardKind) -> Result<T>,
260{
261    let dims = TpAttentionDims::from_config(config);
262    let q = proj_loader("q_proj", dims.full_q_n, dims.h, TpShardKind::ColumnParallel)?;
263    let k = proj_loader(
264        "k_proj",
265        dims.full_kv_n,
266        dims.h,
267        TpShardKind::ColumnParallel,
268    )?;
269    let v = proj_loader(
270        "v_proj",
271        dims.full_kv_n,
272        dims.h,
273        TpShardKind::ColumnParallel,
274    )?;
275    // O proj input dim is the un-gated attention output. For gated
276    // models (Qwen3-Next), this differs from `full_q_n` which includes
277    // the doubled gate.
278    let o = proj_loader("o_proj", dims.h, dims.full_o_in, TpShardKind::RowParallel)?;
279    Ok([q, k, v, o])
280}
281
282/// Q/K-norm 1D shard pair. The closure receives `(name, full_dim)` and
283/// returns the loader's sharded norm — typically a `DenseWeight`.
284/// `q_norm` is sharded against `full_q_n`; `k_norm` against `full_kv_n`.
285/// Returns `(q_norm, k_norm)`.
286pub fn load_qk_norms_tp<F, T>(config: &ModelConfig, mut norm_loader: F) -> Result<(T, T)>
287where
288    F: FnMut(&str, usize) -> Result<T>,
289{
290    let dims = TpAttentionDims::from_config(config);
291    let q_norm = norm_loader("q_norm", dims.full_q_n)?;
292    let k_norm = norm_loader("k_norm", dims.full_kv_n)?;
293    Ok((q_norm, k_norm))
294}
295
296/// Pre-TP-shard dimensions for MoE expert projections. Unlike attention,
297/// `main.rs` does NOT divide `moe_intermediate_size` by `tp_size`, so
298/// `full_inter == config.moe_intermediate_size`. Local size is computed
299/// here for downstream callers.
300#[derive(Debug, Clone, Copy)]
301pub struct TpMoeDims {
302    pub tp_rank: usize,
303    pub tp_size: usize,
304    pub h: usize,
305    /// Full MoE intermediate dim (NOT yet TP-divided).
306    pub full_inter: usize,
307    /// Local (post-shard) MoE intermediate dim.
308    pub local_inter: usize,
309}
310
311impl TpMoeDims {
312    pub fn from_config(config: &ModelConfig) -> Self {
313        let tp_size = config.tp_world_size.max(1);
314        let full_inter = config.moe_intermediate_size;
315        Self {
316            tp_rank: config.tp_rank,
317            tp_size,
318            h: config.hidden_size,
319            full_inter,
320            local_inter: full_inter / tp_size,
321        }
322    }
323
324    /// `(out_dim, in_dim, kind)` for one of `gate_proj` / `up_proj` /
325    /// `down_proj`. Gate/up are column-parallel on inter; down is
326    /// row-parallel on inter (so `[h, inter]` rows truncate to `[h, inter/tp]`).
327    pub fn proj_shape(&self, name: &str) -> Option<(usize, usize, TpShardKind)> {
328        match name {
329            "gate_proj" | "up_proj" => Some((self.full_inter, self.h, TpShardKind::ColumnParallel)),
330            "down_proj" => Some((self.h, self.full_inter, TpShardKind::RowParallel)),
331            _ => None,
332        }
333    }
334}
335
336mod gdn;
337pub use gdn::*;
338
339mod quant_shard;
340pub use quant_shard::{shard_fp8_block_scaled, shard_quantized_nvfp4};
341
342#[cfg(test)]
343mod tests;