Skip to main content

nuvec_nn/
sharded.rs

1//! Tensor-parallel linear layers over a distributed group, as `mlx.nn`'s
2//! `AllToShardedLinear` and `ShardedToAllLinear` (and their quantized forms).
3//!
4//! A pair of them splits a block across the ranks with one collective: the first layer
5//! keeps each rank's slice of the output features (its input is the full activation), the
6//! second each rank's slice of the input features, and sums the partial products over the
7//! group. Every rank holds `1/size` of both weights.
8//!
9//! ```
10//! use nuvec::distributed::Group;
11//! use nuvec::{array, random};
12//! use nuvec_nn::Linear;
13//! use nuvec_nn::sharded::{AllToShardedLinear, ShardedToAllLinear};
14//!
15//! # fn main() -> nuvec::Result<()> {
16//! random::seed(0);
17//! let (up, down) = (Linear::new(4, 8), Linear::new(8, 4));
18//! let group = Group::single(); // `nuvec::distributed::init(false)?` across processes
19//! let up_r = AllToShardedLinear::from_linear(&up, &group)?;
20//! let down_r = ShardedToAllLinear::from_linear(&down, &group)?;
21//! let x = array![[1.0, 2.0, 3.0, 4.0]];
22//! let y = down_r.forward(&up_r.forward(&x));
23//! assert_eq!(y.shape(), [1, 4]);
24//! # Ok(())
25//! # }
26//! ```
27
28use nuvec::distributed::{self, Group};
29use nuvec::{Array, Error, Param, Result};
30
31use crate::{Linear, Module, QuantizedLinear};
32
33/// The projection a sharded layer holds: this rank's slice of a full-precision or a
34/// quantized layer.
35#[derive(Debug, Clone, Module)]
36pub enum Shard {
37    /// A full-precision slice.
38    Linear(Linear),
39    /// A quantized slice.
40    Quantized(QuantizedLinear),
41}
42
43impl Shard {
44    #[track_caller]
45    fn forward(&self, x: &Array) -> Array {
46        match self {
47            Self::Linear(l) => l.forward(x),
48            Self::Quantized(q) => q.forward(x),
49        }
50    }
51}
52
53/// `[start, end)` of rank `rank`'s slice of `n` features, or an error naming `layer`.
54fn slice(layer: &str, what: &str, n: i32, unit: i32, group: &Group) -> Result<(i32, i32)> {
55    let size = group.size() as i32;
56    if n % (size * unit) != 0 {
57        return Err(Error::InvalidArgument(format!(
58            "{layer}: {n} {what} do not split into {size} shards{}",
59            if unit > 1 {
60                format!(" of whole groups of {unit}")
61            } else {
62                String::new()
63            }
64        )));
65    }
66    let per = n / size;
67    let r = group.rank() as i32;
68    Ok((r * per, (r + 1) * per))
69}
70
71fn dims(a: &Array) -> (i32, i32) {
72    let s = a.shape();
73    (
74        s.first().copied().unwrap_or(0),
75        s.get(1).copied().unwrap_or(0),
76    )
77}
78
79/// A linear layer whose output features are split across `group` (MLX's
80/// `AllToShardedLinear`): each rank computes its slice of the output from the full input.
81/// The input's gradient is summed over the group (`sum_gradients`), so training sees every
82/// rank's slice.
83#[derive(Debug, Clone, Module)]
84pub struct AllToShardedLinear {
85    /// This rank's rows of the weight (and its slice of the bias).
86    pub shard: Shard,
87    /// The group the features are split across.
88    #[tree(skip)]
89    pub group: Group,
90}
91
92impl AllToShardedLinear {
93    /// This rank's slice of `linear` (`[output / size, input]`); an error if the output
94    /// features do not split evenly.
95    pub fn from_linear(linear: &Linear, group: &Group) -> Result<Self> {
96        let (out, _) = dims(&linear.weight);
97        let (a, b) = slice("AllToShardedLinear", "output features", out, 1, group)?;
98        Ok(Self {
99            shard: Shard::Linear(Linear {
100                weight: Param::new(linear.weight.i((a..b, ..))),
101                bias: linear.bias.as_ref().map(|p| Param::new(p.i(a..b))),
102            }),
103            group: group.clone(),
104        })
105    }
106
107    /// This rank's rows of a quantized layer (packed weight, scales and biases).
108    pub fn from_quantized(layer: &QuantizedLinear, group: &Group) -> Result<Self> {
109        let (out, _) = dims(&layer.weight);
110        let (a, b) = slice("AllToShardedLinear", "output features", out, 1, group)?;
111        let rows = |x: &Array| x.i((a..b, ..));
112        Ok(Self {
113            shard: Shard::Quantized(QuantizedLinear {
114                weight: rows(&layer.weight),
115                scales: rows(&layer.scales),
116                biases: layer.biases.as_ref().map(rows),
117                bias: layer.bias.as_ref().map(|p| Param::new(p.i(a..b))),
118                quant: layer.quant,
119            }),
120            group: group.clone(),
121        })
122    }
123
124    /// This rank's output features, `[..., output / size]`.
125    #[track_caller]
126    pub fn forward(&self, x: &Array) -> Array {
127        self.shard
128            .forward(&distributed::sum_gradients(x, Some(&self.group)))
129    }
130}
131
132/// A linear layer whose input features are split across `group` (MLX's
133/// `ShardedToAllLinear`): each rank multiplies its slice of the input by its columns of
134/// the weight, and the partial products are summed over the group (`all_sum`) before the
135/// bias is added once.
136#[derive(Debug, Clone, Module)]
137pub struct ShardedToAllLinear {
138    /// This rank's columns of the weight (no bias).
139    pub shard: Shard,
140    /// The full bias, `[output]`.
141    pub bias: Option<Param>,
142    /// The group the features are split across.
143    #[tree(skip)]
144    pub group: Group,
145}
146
147impl ShardedToAllLinear {
148    /// This rank's columns of `linear` (`[output, input / size]`); an error if the input
149    /// features do not split evenly.
150    pub fn from_linear(linear: &Linear, group: &Group) -> Result<Self> {
151        let (_, input) = dims(&linear.weight);
152        let (a, b) = slice("ShardedToAllLinear", "input features", input, 1, group)?;
153        Ok(Self {
154            shard: Shard::Linear(Linear {
155                weight: Param::new(linear.weight.i((.., a..b))),
156                bias: None,
157            }),
158            bias: linear.bias.clone(),
159            group: group.clone(),
160        })
161    }
162
163    /// This rank's columns of a quantized layer: the input features split in whole
164    /// quantization groups.
165    pub fn from_quantized(layer: &QuantizedLinear, group: &Group) -> Result<Self> {
166        let (_, packed) = dims(&layer.weight);
167        let bits = layer.quant.bits.max(1);
168        let input = packed * 32 / bits;
169        let group_size = layer.quant.group_size.max(1);
170        let (a, b) = slice(
171            "ShardedToAllLinear",
172            "input features",
173            input,
174            group_size,
175            group,
176        )?;
177        let (pa, pb) = (a * bits / 32, b * bits / 32);
178        let (sa, sb) = (a / group_size, b / group_size);
179        let cols = |x: &Array| x.i((.., sa..sb));
180        Ok(Self {
181            shard: Shard::Quantized(QuantizedLinear {
182                weight: layer.weight.i((.., pa..pb)),
183                scales: cols(&layer.scales),
184                biases: layer.biases.as_ref().map(cols),
185                bias: None,
186                quant: layer.quant,
187            }),
188            bias: layer.bias.clone(),
189            group: group.clone(),
190        })
191    }
192
193    /// The full output from this rank's slice of the input features, `[..., input / size]`.
194    #[track_caller]
195    pub fn forward(&self, x: &Array) -> Array {
196        let y = distributed::all_sum(&self.shard.forward(x), Some(&self.group));
197        match &self.bias {
198            Some(b) => y.add(b),
199            None => y,
200        }
201    }
202}