fuel_parallel/tensor_parallel.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Tensor parallelism: column-parallel and row-parallel linear layers — **lazy-only**.
3//!
4//! Tensor parallelism (TP) splits weight matrices across devices so each device
5//! computes a partial result, then collective communication merges the outputs.
6//!
7//! ## Sharding strategies
8//!
9//! | Strategy | Weight split | Communication |
10//! |----------|-------------|---------------|
11//! | [`ColumnParallel`] | Split output dim | None (outputs are disjoint shards) |
12//! | [`RowParallel`] | Split input dim | AllReduce after matmul |
13//!
14//! In a standard MLP (`Y = XA`):
15//! - **Column-parallel**: `A` is split column-wise → each rank gets `A_i` (columns
16//! `[i*cols/N..(i+1)*cols/N]`) → `Y_i = X @ A_i` → results are concatenated.
17//! - **Row-parallel**: `A` is split row-wise → each rank gets `A_i` (rows
18//! `[i*rows/N..(i+1)*rows/N]`) → `Y_i = X_i @ A_i` → results are *summed*
19//! via all-reduce.
20//!
21//! ## Usage pattern
22//!
23//! ```rust
24//! use fuel_parallel::tensor_parallel::{TensorParallelConfig, ShardDim};
25//!
26//! let config = TensorParallelConfig::new(4)?; // 4-way TP
27//! assert_eq!(config.world_size(), 4);
28//!
29//! // Compute shard boundaries for a [4096, 4096] weight on rank 1
30//! let (start, end) = config.shard_range(1, 4096, ShardDim::Column);
31//! assert_eq!(start, 1024);
32//! assert_eq!(end, 2048);
33//! # Ok::<(), fuel::Error>(())
34//! ```
35
36use crate::comm::{Communicator, ReduceOp};
37use fuel::lazy::{Tensor, WeightStorage};
38use fuel::{Error, Result};
39use serde::{Deserialize, Serialize};
40use std::sync::Arc;
41
42/// Minimal inlined Linear layer, **lazy**.
43///
44/// Computes `y = x @ W + b`, delegating to
45/// [`WeightStorage::apply_linear`](fuel::lazy::WeightStorage::apply_linear) —
46/// the same builder every lazy model in `fuel-core` uses. That buys F32, BF16,
47/// Q4_0 and LoRA weights for free; a hand-rolled `const_f32_like` + `matmul`
48/// here would have been f32-only.
49///
50/// # Weight layout — CHANGED from the eager version
51///
52/// **`[in_features, out_features]`, applied as `x @ W`.**
53///
54/// The eager `Linear` this replaces stored `[out_features, in_features]` and
55/// computed `x @ W.t()` (PyTorch's `nn.Linear` convention). `WeightStorage`
56/// uses the transposed layout, and adopting it is what makes the quantized and
57/// LoRA paths reachable. The difference is silent — feeding
58/// `[out, in]` data to a square layer produces a wrong answer rather than an
59/// error — so it is called out here rather than left to be discovered.
60///
61/// `fuel-parallel` has no consumers today, so nothing in-tree breaks.
62///
63/// # Why host storage rather than a `Tensor` weight
64///
65/// `Tensor` is **graph-affine**: two tensors combine iff they share a
66/// graph. A `Linear` holding a `Tensor` weight could only ever be applied
67/// to inputs on that one graph — useless for a model that builds a fresh graph
68/// per step, which is exactly what Fuel's lazy decode path does.
69/// `WeightStorage` materializes onto **the input's** graph at `forward` time,
70/// so one `Linear` serves any number of graphs.
71#[derive(Clone, Debug)]
72pub struct Linear {
73 weight: WeightStorage,
74 in_features: usize,
75 out_features: usize,
76 /// Length `out_features`.
77 bias: Option<Arc<[f32]>>,
78}
79
80impl Linear {
81 /// Build from a [`WeightStorage`] laid out `[in_features, out_features]`.
82 ///
83 /// Returns `Err` on a size mismatch rather than deferring it: the
84 /// `apply_linear` path this delegates to reports mismatches via
85 /// `assert_eq!`/`unwrap()`, which would be a panic on a production path.
86 /// Validating here keeps Fuel's never-panic rule intact.
87 ///
88 /// The element-count check applies to F32/BF16 only — a Q4_0 weight's
89 /// `elem_count` counts packed `u32` words, not logical elements, so its
90 /// dimensions are checked by `WeightStorage` itself against the values
91 /// stored in the variant.
92 pub fn new(
93 weight: WeightStorage,
94 in_features: usize,
95 out_features: usize,
96 bias: Option<Arc<[f32]>>,
97 ) -> Result<Self> {
98 let dtype = weight.dtype();
99 if matches!(dtype, fuel::DType::F32 | fuel::DType::BF16) {
100 let expected = in_features * out_features;
101 if weight.elem_count() != expected {
102 return Err(Error::Msg(format!(
103 "Linear::new: weight has {} elements but [in={in_features}, out={out_features}] \
104 needs {expected}",
105 weight.elem_count()
106 )));
107 }
108 }
109 if let Some(b) = &bias
110 && b.len() != out_features
111 {
112 return Err(Error::Msg(format!(
113 "Linear::new: bias has {} elements but out_features is {out_features}",
114 b.len()
115 )));
116 }
117 Ok(Self {
118 weight,
119 in_features,
120 out_features,
121 bias,
122 })
123 }
124
125 /// Convenience constructor for a plain f32 weight, `[in_features, out_features]`.
126 pub fn from_f32(
127 weight: impl Into<Arc<[f32]>>,
128 in_features: usize,
129 out_features: usize,
130 bias: Option<Arc<[f32]>>,
131 ) -> Result<Self> {
132 Self::new(
133 WeightStorage::F32(weight.into()),
134 in_features,
135 out_features,
136 bias,
137 )
138 }
139
140 /// `(in_features, out_features)`.
141 pub fn features(&self) -> (usize, usize) {
142 (self.in_features, self.out_features)
143 }
144
145 /// Forward: `y = x @ W + b`, built on **`x`'s** graph.
146 ///
147 /// The eager original branched on `x.is_contiguous()` and hand-picked
148 /// between a reshape-then-matmul and a broadcast matmul, "to avoid a
149 /// broadcasted matmul — it's much slower". That is a dispatch-time
150 /// optimisation, and in a lazy DAG choosing it here would be the model layer
151 /// making a strategy decision that belongs to the optimizer. Lazy `matmul`
152 /// is N-D batched with rank-2 broadcasting, so the whole dance collapses to
153 /// one delegated call and the optimizer keeps its job.
154 pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
155 let (in_features, out_features) = (self.in_features, self.out_features);
156
157 // The trailing-dim and stored-shape checks that were duplicated here
158 // now live inside `WeightStorage::apply_linear`, which returns
159 // `Result` rather than panicking. Guarding at this one caller only
160 // ever protected this one caller; validating inside the builder
161 // protects all ~620 of them. What is left here is naming the layer in
162 // the error chain.
163 let labelled = |e: Error| Error::Msg(format!("Linear({in_features}->{out_features}): {e}"));
164 match &self.bias {
165 None => self
166 .weight
167 .apply_linear(x, in_features, out_features)
168 .map_err(labelled),
169 Some(bias) => self
170 .weight
171 .apply_linear_with_bias(x, in_features, out_features, Arc::clone(bias))
172 .map_err(labelled),
173 }
174 }
175}
176
177/// Which dimension to shard.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179pub enum ShardDim {
180 /// Shard along columns (output features / dim 1 of weight).
181 Column,
182 /// Shard along rows (input features / dim 0 of weight).
183 Row,
184}
185
186/// Metadata for one weight shard.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TensorShard {
189 /// Rank that owns this shard.
190 pub rank: usize,
191 /// Total number of ranks.
192 pub world_size: usize,
193 /// Shard dimension.
194 pub dim: ShardDim,
195 /// Original (unsharded) shape as `[rows, cols]`.
196 pub original_shape: [usize; 2],
197}
198
199impl TensorShard {
200 /// Start index of this shard along the sharded dimension.
201 pub fn start(&self) -> usize {
202 let full = match self.dim {
203 ShardDim::Column => self.original_shape[1],
204 ShardDim::Row => self.original_shape[0],
205 };
206 let per_rank = full / self.world_size.max(1);
207 self.rank * per_rank
208 }
209
210 /// End index (exclusive) of this shard.
211 pub fn end(&self) -> usize {
212 let full = match self.dim {
213 ShardDim::Column => self.original_shape[1],
214 ShardDim::Row => self.original_shape[0],
215 };
216 let per_rank = full / self.world_size.max(1);
217 if self.rank + 1 >= self.world_size {
218 full // last rank gets remainder
219 } else {
220 (self.rank + 1) * per_rank
221 }
222 }
223
224 /// Number of elements along the sharded dimension for this rank.
225 pub fn shard_size(&self) -> usize {
226 self.end().saturating_sub(self.start())
227 }
228}
229
230/// Configuration for tensor parallelism.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct TensorParallelConfig {
233 world_size: usize,
234}
235
236impl TensorParallelConfig {
237 /// Create a TP config with the given number of devices.
238 ///
239 /// Returns `Err` for `world_size == 0` rather than panicking: a zero-rank TP
240 /// group has no valid shard range, and Fuel's never-panic rule applies to
241 /// config construction as much as to a kernel.
242 pub fn new(world_size: usize) -> Result<Self> {
243 if world_size == 0 {
244 return Err(Error::Msg(
245 "TensorParallelConfig::new: world_size must be > 0".into(),
246 ));
247 }
248 Ok(Self { world_size })
249 }
250
251 /// Number of devices in the TP group.
252 pub fn world_size(&self) -> usize {
253 self.world_size
254 }
255
256 /// Compute the `[start, end)` range for a given rank along a dimension of
257 /// size `full_size`.
258 ///
259 /// Ranges are contiguous and cover `0..full_size` exactly; the last rank
260 /// absorbs the remainder when `full_size` does not divide evenly. When
261 /// `world_size > full_size` the leading ranks get **empty** ranges and the
262 /// last rank takes everything — degenerate but still a partition, never
263 /// overlapping.
264 pub fn shard_range(&self, rank: usize, full_size: usize, dim: ShardDim) -> (usize, usize) {
265 let _ = dim; // used for API consistency; range is the same regardless
266 let per_rank = full_size / self.world_size;
267 let start = rank * per_rank;
268 let end = if rank + 1 >= self.world_size {
269 full_size
270 } else {
271 (rank + 1) * per_rank
272 };
273 (start, end)
274 }
275
276 /// Create a [`TensorShard`] descriptor.
277 pub fn make_shard(
278 &self,
279 rank: usize,
280 dim: ShardDim,
281 original_shape: [usize; 2],
282 ) -> TensorShard {
283 TensorShard {
284 rank,
285 world_size: self.world_size,
286 dim,
287 original_shape,
288 }
289 }
290}
291
292/// Column-parallel linear layer.
293///
294/// Each rank holds columns `[rank * out/N .. (rank+1) * out/N]` of the weight.
295/// Forward pass: `Y_local = X @ W_local` — no communication needed.
296/// The caller is responsible for gathering outputs if needed
297/// ([`DeviceGroup::all_gather`](crate::device_group::DeviceGroup::all_gather)).
298pub struct ColumnParallel {
299 linear: Linear,
300 shard: TensorShard,
301}
302
303impl ColumnParallel {
304 /// Wrap a pre-sharded linear layer.
305 pub fn new(linear: Linear, shard: TensorShard) -> Self {
306 Self { linear, shard }
307 }
308
309 /// Shard metadata.
310 pub fn shard(&self) -> &TensorShard {
311 &self.shard
312 }
313
314 /// Forward: local matmul only, no communication.
315 pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
316 self.linear.forward(x)
317 }
318}
319
320/// Row-parallel linear layer.
321///
322/// Each rank holds rows `[rank * in/N .. (rank+1) * in/N]` of the weight.
323/// Forward pass: `Y_local = X_local @ W_local`, then AllReduce(Sum) to combine.
324///
325/// # This is the SPMD form
326///
327/// It reduces via a [`Communicator`], so it describes *one rank's* half of the
328/// work. Fuel ships no multi-process `Communicator` (see [`crate::comm`]), so
329/// with the only in-tree impl — `IdentityComm`, `world_size == 1` — the
330/// all-reduce is an identity and the result is the single rank's own product.
331/// That is correct for one rank and is **not** a multi-device reduction.
332///
333/// For one process driving N devices, hold all N partials and fold them with
334/// [`DeviceGroup::all_reduce`](crate::device_group::DeviceGroup::all_reduce),
335/// which is a real cross-device collective.
336pub struct RowParallel<C: Communicator> {
337 linear: Linear,
338 shard: TensorShard,
339 comm: C,
340}
341
342impl<C: Communicator> RowParallel<C> {
343 /// Wrap a pre-sharded linear layer with a communicator for all-reduce.
344 pub fn new(linear: Linear, shard: TensorShard, comm: C) -> Self {
345 Self {
346 linear,
347 shard,
348 comm,
349 }
350 }
351
352 /// Shard metadata.
353 pub fn shard(&self) -> &TensorShard {
354 &self.shard
355 }
356
357 /// Forward: local matmul then all-reduce sum.
358 pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
359 let local = self.linear.forward(x)?;
360 self.comm.all_reduce(&local, ReduceOp::Sum)
361 }
362}
363
364/// Describes how a model layer should be parallelized.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct LayerParallelPlan {
367 /// Layer name (for debugging / weight loading).
368 pub name: String,
369 /// Sharding strategy for this layer.
370 pub strategy: ShardDim,
371 /// Which TP group this layer belongs to (for multi-group configs).
372 pub group_id: usize,
373}
374
375impl LayerParallelPlan {
376 /// Create a plan entry.
377 pub fn new(name: impl Into<String>, strategy: ShardDim) -> Self {
378 Self {
379 name: name.into(),
380 strategy,
381 group_id: 0,
382 }
383 }
384
385 /// Builder: set group ID.
386 pub fn with_group(mut self, group_id: usize) -> Self {
387 self.group_id = group_id;
388 self
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::comm::IdentityComm;
396 use fuel::{Device, Shape};
397
398 /// All-ones weight, `[in_features, out_features]`. Note the argument order
399 /// follows the `WeightStorage` layout, not the eager `[out, in]` one.
400 fn ones_linear(in_features: usize, out_features: usize) -> Linear {
401 Linear::from_f32(
402 vec![1.0f32; in_features * out_features],
403 in_features,
404 out_features,
405 None,
406 )
407 .unwrap()
408 }
409
410 fn ones_input(dims: &[usize]) -> Tensor {
411 let n: usize = dims.iter().product();
412 Tensor::from_f32(vec![1.0f32; n], Shape::from_dims(dims), &Device::cpu()).unwrap()
413 }
414
415 #[test]
416 fn config_shard_range() {
417 let config = TensorParallelConfig::new(4).unwrap();
418 let (s, e) = config.shard_range(0, 4096, ShardDim::Column);
419 assert_eq!((s, e), (0, 1024));
420
421 let (s, e) = config.shard_range(3, 4096, ShardDim::Column);
422 assert_eq!((s, e), (3072, 4096));
423 }
424
425 #[test]
426 fn shard_range_with_remainder() {
427 let config = TensorParallelConfig::new(3).unwrap();
428 // 10 / 3 = 3 per rank, last rank gets 10 - 6 = 4
429 let (s, e) = config.shard_range(2, 10, ShardDim::Row);
430 assert_eq!((s, e), (6, 10));
431 }
432
433 #[test]
434 fn shard_ranges_partition_exactly() {
435 // The property that matters and that per-rank spot checks miss: the
436 // ranges must tile [0, full) with no gap and no overlap, for sizes that
437 // divide evenly and sizes that do not.
438 for world in 1..=5usize {
439 let config = TensorParallelConfig::new(world).unwrap();
440 for full in [0usize, 1, 7, 10, 4096] {
441 let mut cursor = 0usize;
442 for rank in 0..world {
443 let (s, e) = config.shard_range(rank, full, ShardDim::Row);
444 assert_eq!(
445 s, cursor,
446 "gap/overlap at rank {rank} (world={world}, full={full})"
447 );
448 assert!(
449 e >= s,
450 "inverted range at rank {rank} (world={world}, full={full})"
451 );
452 cursor = e;
453 }
454 assert_eq!(
455 cursor, full,
456 "ranges do not cover full={full} (world={world})"
457 );
458 }
459 }
460 }
461
462 #[test]
463 fn tensor_shard_metadata() {
464 let config = TensorParallelConfig::new(2).unwrap();
465 let shard = config.make_shard(1, ShardDim::Column, [4096, 4096]);
466
467 assert_eq!(shard.start(), 2048);
468 assert_eq!(shard.end(), 4096);
469 assert_eq!(shard.shard_size(), 2048);
470 }
471
472 #[test]
473 fn column_parallel_forward() {
474 // 3×2 input, 2×2 weight shard → 3×2 output.
475 let col = ColumnParallel::new(
476 ones_linear(2, 2),
477 TensorShard {
478 rank: 0,
479 world_size: 2,
480 dim: ShardDim::Column,
481 original_shape: [2, 4],
482 },
483 );
484 let y = col.forward(&ones_input(&[3, 2])).unwrap();
485 assert_eq!(y.shape().dims(), &[3, 2]);
486 // ones(3,2) @ ones(2,2).t() => every element is the contracted dim, 2.
487 assert_eq!(y.realize_f32(), vec![2.0f32; 6]);
488 }
489
490 #[test]
491 fn row_parallel_forward() {
492 let row = RowParallel::new(
493 ones_linear(2, 4),
494 TensorShard {
495 rank: 0,
496 world_size: 2,
497 dim: ShardDim::Row,
498 original_shape: [4, 4],
499 },
500 IdentityComm,
501 );
502 let y = row.forward(&ones_input(&[3, 2])).unwrap();
503 // With identity comm, all_reduce is a no-op → same as local matmul
504 assert_eq!(y.shape().dims(), &[3, 4]);
505 assert_eq!(y.realize_f32(), vec![2.0f32; 12]);
506 }
507
508 #[test]
509 fn linear_applies_to_inputs_on_different_graphs() {
510 // The point of holding host data instead of a `Tensor` weight: one
511 // `Linear` must work against inputs rooted on unrelated graphs. Holding
512 // a `Tensor` would make the second call fail an operand-graph check.
513 let lin = ones_linear(3, 2);
514 let a = ones_input(&[1, 3]);
515 let b = ones_input(&[2, 3]);
516 assert_ne!(
517 a.graph_id(),
518 b.graph_id(),
519 "inputs must be on separate graphs"
520 );
521
522 assert_eq!(lin.forward(&a).unwrap().realize_f32(), vec![3.0f32; 2]);
523 assert_eq!(lin.forward(&b).unwrap().realize_f32(), vec![3.0f32; 4]);
524 }
525
526 #[test]
527 fn linear_output_shares_the_input_graph() {
528 let lin = ones_linear(3, 2);
529 let x = ones_input(&[1, 3]);
530 let y = lin.forward(&x).unwrap();
531 assert_eq!(y.graph_id(), x.graph_id());
532 }
533
534 #[test]
535 fn linear_adds_bias() {
536 let lin =
537 Linear::from_f32(vec![1.0f32; 3 * 2], 3, 2, Some(vec![10.0f32, 20.0].into())).unwrap();
538 let y = lin.forward(&ones_input(&[1, 3])).unwrap();
539 // row of ones · ones(3) = 3, then + per-output bias
540 assert_eq!(y.realize_f32(), vec![13.0, 23.0]);
541 }
542
543 #[test]
544 fn linear_forward_is_batched_over_leading_dims() {
545 // Rank-3 input must work without the caller reshaping — lazy matmul is
546 // N-D batched, which is why the eager contiguity branch could go.
547 let lin = ones_linear(3, 2);
548 let y = lin.forward(&ones_input(&[2, 4, 3])).unwrap();
549 assert_eq!(y.shape().dims(), &[2, 4, 2]);
550 assert_eq!(y.realize_f32(), vec![3.0f32; 16]);
551 }
552
553 #[test]
554 fn linear_rejects_mismatched_input() {
555 let lin = ones_linear(3, 2);
556 let err = lin.forward(&ones_input(&[1, 5])).unwrap_err();
557 let msg = format!("{err}");
558 // The check now lives in `WeightStorage::apply_linear` rather than
559 // being duplicated in `Linear::forward`, so the wording comes from
560 // there; `Linear(..)` is this layer's label on the error chain. Assert
561 // on the facts (offending shape + required dim), not the phrasing.
562 assert!(
563 msg.contains("[1, 5]") && msg.contains("trailing dim 3"),
564 "error should name the offending shape and the required in_features, got: {err}"
565 );
566 assert!(
567 msg.contains("Linear(3->2)"),
568 "error should name the layer, got: {err}"
569 );
570 }
571
572 #[test]
573 fn linear_rejects_weight_shape_mismatch() {
574 let err = Linear::from_f32(vec![1.0f32; 5], 3, 2, None).unwrap_err();
575 assert!(format!("{err}").contains("needs 6"), "got: {err}");
576 }
577
578 #[test]
579 fn linear_rejects_bias_length_mismatch() {
580 let err =
581 Linear::from_f32(vec![1.0f32; 6], 3, 2, Some(vec![0.0f32; 3].into())).unwrap_err();
582 assert!(format!("{err}").contains("out_features is 2"), "got: {err}");
583 }
584
585 #[test]
586 fn linear_rejects_rank_1_input_instead_of_panicking() {
587 // `WeightStorage::apply_linear` reaches `x.matmul(&w).unwrap()`, which
588 // panics on rank < 2. Guarding here is what keeps the never-panic rule
589 // intact through the delegation.
590 let lin = ones_linear(3, 2);
591 let x = Tensor::from_f32(vec![1.0f32; 3], Shape::from_dims(&[3]), &Device::cpu()).unwrap();
592 let err = lin.forward(&x).unwrap_err();
593 assert!(format!("{err}").contains("rank >= 2"), "got: {err}");
594 }
595
596 #[test]
597 fn linear_carries_a_bf16_weight() {
598 // The point of delegating to `WeightStorage`: dtypes beyond f32 come
599 // for free. A hand-rolled `const_f32_like` Linear could not do this.
600 let w: Vec<half::bf16> = vec![half::bf16::from_f32(1.0); 3 * 2];
601 let lin = Linear::new(WeightStorage::BF16(w.into()), 3, 2, None).unwrap();
602 assert_eq!(lin.features(), (3, 2));
603 }
604
605 #[test]
606 fn layer_parallel_plan() {
607 let plan = LayerParallelPlan::new("mlp.gate_proj", ShardDim::Column).with_group(1);
608 assert_eq!(plan.name, "mlp.gate_proj");
609 assert_eq!(plan.strategy, ShardDim::Column);
610 assert_eq!(plan.group_id, 1);
611 }
612
613 #[test]
614 fn zero_world_size_is_an_error_not_a_panic() {
615 // Was `#[should_panic]`. Fuel's never-panic rule applies to config
616 // construction too, so this now surfaces as a typed error.
617 let err = TensorParallelConfig::new(0).unwrap_err();
618 assert!(
619 format!("{err}").contains("world_size must be > 0"),
620 "got: {err}"
621 );
622 }
623
624 #[test]
625 fn single_device_shard_covers_all() {
626 let config = TensorParallelConfig::new(1).unwrap();
627 let (s, e) = config.shard_range(0, 4096, ShardDim::Column);
628 assert_eq!((s, e), (0, 4096));
629 }
630
631 #[test]
632 fn shard_dims_are_distinct() {
633 assert_ne!(ShardDim::Column, ShardDim::Row);
634 }
635}