use crate::comm::{Communicator, ReduceOp};
use fuel::lazy::{Tensor, WeightStorage};
use fuel::{Error, Result};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct Linear {
weight: WeightStorage,
in_features: usize,
out_features: usize,
bias: Option<Arc<[f32]>>,
}
impl Linear {
pub fn new(
weight: WeightStorage,
in_features: usize,
out_features: usize,
bias: Option<Arc<[f32]>>,
) -> Result<Self> {
let dtype = weight.dtype();
if matches!(dtype, fuel::DType::F32 | fuel::DType::BF16) {
let expected = in_features * out_features;
if weight.elem_count() != expected {
return Err(Error::Msg(format!(
"Linear::new: weight has {} elements but [in={in_features}, out={out_features}] \
needs {expected}",
weight.elem_count()
)));
}
}
if let Some(b) = &bias
&& b.len() != out_features
{
return Err(Error::Msg(format!(
"Linear::new: bias has {} elements but out_features is {out_features}",
b.len()
)));
}
Ok(Self {
weight,
in_features,
out_features,
bias,
})
}
pub fn from_f32(
weight: impl Into<Arc<[f32]>>,
in_features: usize,
out_features: usize,
bias: Option<Arc<[f32]>>,
) -> Result<Self> {
Self::new(
WeightStorage::F32(weight.into()),
in_features,
out_features,
bias,
)
}
pub fn features(&self) -> (usize, usize) {
(self.in_features, self.out_features)
}
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
let (in_features, out_features) = (self.in_features, self.out_features);
let labelled = |e: Error| Error::Msg(format!("Linear({in_features}->{out_features}): {e}"));
match &self.bias {
None => self
.weight
.apply_linear(x, in_features, out_features)
.map_err(labelled),
Some(bias) => self
.weight
.apply_linear_with_bias(x, in_features, out_features, Arc::clone(bias))
.map_err(labelled),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ShardDim {
Column,
Row,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TensorShard {
pub rank: usize,
pub world_size: usize,
pub dim: ShardDim,
pub original_shape: [usize; 2],
}
impl TensorShard {
pub fn start(&self) -> usize {
let full = match self.dim {
ShardDim::Column => self.original_shape[1],
ShardDim::Row => self.original_shape[0],
};
let per_rank = full / self.world_size.max(1);
self.rank * per_rank
}
pub fn end(&self) -> usize {
let full = match self.dim {
ShardDim::Column => self.original_shape[1],
ShardDim::Row => self.original_shape[0],
};
let per_rank = full / self.world_size.max(1);
if self.rank + 1 >= self.world_size {
full } else {
(self.rank + 1) * per_rank
}
}
pub fn shard_size(&self) -> usize {
self.end().saturating_sub(self.start())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TensorParallelConfig {
world_size: usize,
}
impl TensorParallelConfig {
pub fn new(world_size: usize) -> Result<Self> {
if world_size == 0 {
return Err(Error::Msg(
"TensorParallelConfig::new: world_size must be > 0".into(),
));
}
Ok(Self { world_size })
}
pub fn world_size(&self) -> usize {
self.world_size
}
pub fn shard_range(&self, rank: usize, full_size: usize, dim: ShardDim) -> (usize, usize) {
let _ = dim; let per_rank = full_size / self.world_size;
let start = rank * per_rank;
let end = if rank + 1 >= self.world_size {
full_size
} else {
(rank + 1) * per_rank
};
(start, end)
}
pub fn make_shard(
&self,
rank: usize,
dim: ShardDim,
original_shape: [usize; 2],
) -> TensorShard {
TensorShard {
rank,
world_size: self.world_size,
dim,
original_shape,
}
}
}
pub struct ColumnParallel {
linear: Linear,
shard: TensorShard,
}
impl ColumnParallel {
pub fn new(linear: Linear, shard: TensorShard) -> Self {
Self { linear, shard }
}
pub fn shard(&self) -> &TensorShard {
&self.shard
}
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
self.linear.forward(x)
}
}
pub struct RowParallel<C: Communicator> {
linear: Linear,
shard: TensorShard,
comm: C,
}
impl<C: Communicator> RowParallel<C> {
pub fn new(linear: Linear, shard: TensorShard, comm: C) -> Self {
Self {
linear,
shard,
comm,
}
}
pub fn shard(&self) -> &TensorShard {
&self.shard
}
pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
let local = self.linear.forward(x)?;
self.comm.all_reduce(&local, ReduceOp::Sum)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerParallelPlan {
pub name: String,
pub strategy: ShardDim,
pub group_id: usize,
}
impl LayerParallelPlan {
pub fn new(name: impl Into<String>, strategy: ShardDim) -> Self {
Self {
name: name.into(),
strategy,
group_id: 0,
}
}
pub fn with_group(mut self, group_id: usize) -> Self {
self.group_id = group_id;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::comm::IdentityComm;
use fuel::{Device, Shape};
fn ones_linear(in_features: usize, out_features: usize) -> Linear {
Linear::from_f32(
vec![1.0f32; in_features * out_features],
in_features,
out_features,
None,
)
.unwrap()
}
fn ones_input(dims: &[usize]) -> Tensor {
let n: usize = dims.iter().product();
Tensor::from_f32(vec![1.0f32; n], Shape::from_dims(dims), &Device::cpu()).unwrap()
}
#[test]
fn config_shard_range() {
let config = TensorParallelConfig::new(4).unwrap();
let (s, e) = config.shard_range(0, 4096, ShardDim::Column);
assert_eq!((s, e), (0, 1024));
let (s, e) = config.shard_range(3, 4096, ShardDim::Column);
assert_eq!((s, e), (3072, 4096));
}
#[test]
fn shard_range_with_remainder() {
let config = TensorParallelConfig::new(3).unwrap();
let (s, e) = config.shard_range(2, 10, ShardDim::Row);
assert_eq!((s, e), (6, 10));
}
#[test]
fn shard_ranges_partition_exactly() {
for world in 1..=5usize {
let config = TensorParallelConfig::new(world).unwrap();
for full in [0usize, 1, 7, 10, 4096] {
let mut cursor = 0usize;
for rank in 0..world {
let (s, e) = config.shard_range(rank, full, ShardDim::Row);
assert_eq!(
s, cursor,
"gap/overlap at rank {rank} (world={world}, full={full})"
);
assert!(
e >= s,
"inverted range at rank {rank} (world={world}, full={full})"
);
cursor = e;
}
assert_eq!(
cursor, full,
"ranges do not cover full={full} (world={world})"
);
}
}
}
#[test]
fn tensor_shard_metadata() {
let config = TensorParallelConfig::new(2).unwrap();
let shard = config.make_shard(1, ShardDim::Column, [4096, 4096]);
assert_eq!(shard.start(), 2048);
assert_eq!(shard.end(), 4096);
assert_eq!(shard.shard_size(), 2048);
}
#[test]
fn column_parallel_forward() {
let col = ColumnParallel::new(
ones_linear(2, 2),
TensorShard {
rank: 0,
world_size: 2,
dim: ShardDim::Column,
original_shape: [2, 4],
},
);
let y = col.forward(&ones_input(&[3, 2])).unwrap();
assert_eq!(y.shape().dims(), &[3, 2]);
assert_eq!(y.realize_f32(), vec![2.0f32; 6]);
}
#[test]
fn row_parallel_forward() {
let row = RowParallel::new(
ones_linear(2, 4),
TensorShard {
rank: 0,
world_size: 2,
dim: ShardDim::Row,
original_shape: [4, 4],
},
IdentityComm,
);
let y = row.forward(&ones_input(&[3, 2])).unwrap();
assert_eq!(y.shape().dims(), &[3, 4]);
assert_eq!(y.realize_f32(), vec![2.0f32; 12]);
}
#[test]
fn linear_applies_to_inputs_on_different_graphs() {
let lin = ones_linear(3, 2);
let a = ones_input(&[1, 3]);
let b = ones_input(&[2, 3]);
assert_ne!(
a.graph_id(),
b.graph_id(),
"inputs must be on separate graphs"
);
assert_eq!(lin.forward(&a).unwrap().realize_f32(), vec![3.0f32; 2]);
assert_eq!(lin.forward(&b).unwrap().realize_f32(), vec![3.0f32; 4]);
}
#[test]
fn linear_output_shares_the_input_graph() {
let lin = ones_linear(3, 2);
let x = ones_input(&[1, 3]);
let y = lin.forward(&x).unwrap();
assert_eq!(y.graph_id(), x.graph_id());
}
#[test]
fn linear_adds_bias() {
let lin =
Linear::from_f32(vec![1.0f32; 3 * 2], 3, 2, Some(vec![10.0f32, 20.0].into())).unwrap();
let y = lin.forward(&ones_input(&[1, 3])).unwrap();
assert_eq!(y.realize_f32(), vec![13.0, 23.0]);
}
#[test]
fn linear_forward_is_batched_over_leading_dims() {
let lin = ones_linear(3, 2);
let y = lin.forward(&ones_input(&[2, 4, 3])).unwrap();
assert_eq!(y.shape().dims(), &[2, 4, 2]);
assert_eq!(y.realize_f32(), vec![3.0f32; 16]);
}
#[test]
fn linear_rejects_mismatched_input() {
let lin = ones_linear(3, 2);
let err = lin.forward(&ones_input(&[1, 5])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("[1, 5]") && msg.contains("trailing dim 3"),
"error should name the offending shape and the required in_features, got: {err}"
);
assert!(
msg.contains("Linear(3->2)"),
"error should name the layer, got: {err}"
);
}
#[test]
fn linear_rejects_weight_shape_mismatch() {
let err = Linear::from_f32(vec![1.0f32; 5], 3, 2, None).unwrap_err();
assert!(format!("{err}").contains("needs 6"), "got: {err}");
}
#[test]
fn linear_rejects_bias_length_mismatch() {
let err =
Linear::from_f32(vec![1.0f32; 6], 3, 2, Some(vec![0.0f32; 3].into())).unwrap_err();
assert!(format!("{err}").contains("out_features is 2"), "got: {err}");
}
#[test]
fn linear_rejects_rank_1_input_instead_of_panicking() {
let lin = ones_linear(3, 2);
let x = Tensor::from_f32(vec![1.0f32; 3], Shape::from_dims(&[3]), &Device::cpu()).unwrap();
let err = lin.forward(&x).unwrap_err();
assert!(format!("{err}").contains("rank >= 2"), "got: {err}");
}
#[test]
fn linear_carries_a_bf16_weight() {
let w: Vec<half::bf16> = vec![half::bf16::from_f32(1.0); 3 * 2];
let lin = Linear::new(WeightStorage::BF16(w.into()), 3, 2, None).unwrap();
assert_eq!(lin.features(), (3, 2));
}
#[test]
fn layer_parallel_plan() {
let plan = LayerParallelPlan::new("mlp.gate_proj", ShardDim::Column).with_group(1);
assert_eq!(plan.name, "mlp.gate_proj");
assert_eq!(plan.strategy, ShardDim::Column);
assert_eq!(plan.group_id, 1);
}
#[test]
fn zero_world_size_is_an_error_not_a_panic() {
let err = TensorParallelConfig::new(0).unwrap_err();
assert!(
format!("{err}").contains("world_size must be > 0"),
"got: {err}"
);
}
#[test]
fn single_device_shard_covers_all() {
let config = TensorParallelConfig::new(1).unwrap();
let (s, e) = config.shard_range(0, 4096, ShardDim::Column);
assert_eq!((s, e), (0, 4096));
}
#[test]
fn shard_dims_are_distinct() {
assert_ne!(ShardDim::Column, ShardDim::Row);
}
}