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;