Skip to main content

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}