Whole-workspace rustfmt pass picked up while iterating on Mamba GPU backward work. Verified formatting-only via diff sampling; no logic changed. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1105 lines
39 KiB
Rust
1105 lines
39 KiB
Rust
//! Parallelism strategies for distributed training
|
|
//!
|
|
//! This module implements various parallelism approaches including:
|
|
//! - Data Parallel (DP)
|
|
//! - Tensor Parallel (TP)
|
|
//! - Pipeline Parallel (PP)
|
|
//! - Fully Sharded Data Parallel (FSDP/ZeRO)
|
|
|
|
use crate::error::{DistributedError, Result};
|
|
use crate::group::ProcessGroup;
|
|
use rtx_tensor::Tensor;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
/// Data Parallel implementation
|
|
pub struct DataParallel {
|
|
/// Process group for data parallel communication
|
|
pub process_group: ProcessGroup,
|
|
/// Gradient accumulation steps
|
|
pub accumulation_steps: usize,
|
|
/// Current accumulation step
|
|
current_step: usize,
|
|
/// Whether gradients are currently being accumulated
|
|
accumulating: bool,
|
|
}
|
|
|
|
impl DataParallel {
|
|
/// Create new data parallel instance
|
|
pub fn new(process_group: ProcessGroup, accumulation_steps: usize) -> Self {
|
|
Self {
|
|
process_group,
|
|
accumulation_steps: accumulation_steps.max(1),
|
|
current_step: 0,
|
|
accumulating: false,
|
|
}
|
|
}
|
|
|
|
/// Start gradient accumulation
|
|
pub fn start_accumulation(&mut self) {
|
|
self.accumulating = true;
|
|
self.current_step = 0;
|
|
}
|
|
|
|
/// Accumulate gradients (called after backward pass)
|
|
pub async fn accumulate_gradients(&mut self, gradients: &mut [Tensor]) -> Result<bool> {
|
|
if !self.accumulating {
|
|
return Err(DistributedError::parallelism(
|
|
"DataParallel",
|
|
"not accumulating - call start_accumulation() first",
|
|
));
|
|
}
|
|
|
|
self.current_step += 1;
|
|
|
|
// Check if we should synchronize
|
|
let should_sync = self.current_step >= self.accumulation_steps;
|
|
|
|
if should_sync {
|
|
self.sync_gradients(gradients).await?;
|
|
self.accumulating = false;
|
|
self.current_step = 0;
|
|
}
|
|
|
|
Ok(should_sync)
|
|
}
|
|
|
|
/// Synchronize gradients across all processes
|
|
async fn sync_gradients(&self, gradients: &mut [Tensor]) -> Result<()> {
|
|
use crate::comm::ReduceOp;
|
|
|
|
for gradient in gradients.iter_mut() {
|
|
// AllReduce to sum gradients across all processes
|
|
self.process_group
|
|
.all_reduce(gradient, ReduceOp::Sum)
|
|
.await?;
|
|
|
|
// Average the gradients
|
|
let world_size = self.process_group.world_size() as f32;
|
|
*gradient = gradient.div_scalar(world_size)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Fully Sharded Data Parallel (FSDP) implementation
|
|
pub struct Fsdp {
|
|
/// Process group for FSDP communication
|
|
pub process_group: ProcessGroup,
|
|
/// FSDP configuration
|
|
pub config: FsdpConfig,
|
|
/// Sharded parameters
|
|
sharded_params: HashMap<String, ShardedParameter>,
|
|
/// Memory usage statistics
|
|
memory_stats: FsdpMemoryStats,
|
|
}
|
|
|
|
/// FSDP configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FsdpConfig {
|
|
/// Sharding strategy
|
|
pub sharding_strategy: ShardingStrategy,
|
|
/// Minimum parameter size to shard (in elements)
|
|
pub min_param_size: usize,
|
|
/// CPU offloading enabled
|
|
pub cpu_offload: bool,
|
|
/// Mixed precision configuration
|
|
pub mixed_precision: bool,
|
|
/// Flatten parameters for sharding
|
|
pub flatten_parameters: bool,
|
|
}
|
|
|
|
/// Sharding strategies for FSDP
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ShardingStrategy {
|
|
/// Full sharding (ZeRO-3)
|
|
FullShard,
|
|
/// Shard gradients only (ZeRO-2)
|
|
ShardGradOp,
|
|
/// No sharding, replicate parameters
|
|
NoShard,
|
|
}
|
|
|
|
/// Sharded parameter representation
|
|
#[derive(Debug)]
|
|
pub struct ShardedParameter {
|
|
/// Parameter name/identifier
|
|
pub name: String,
|
|
/// Local shard of the parameter
|
|
pub local_shard: Tensor,
|
|
/// Full parameter shape
|
|
pub full_shape: Vec<usize>,
|
|
/// Shard metadata
|
|
pub shard_metadata: ShardMetadata,
|
|
}
|
|
|
|
/// Metadata for parameter sharding
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ShardMetadata {
|
|
/// Rank that owns this shard
|
|
pub owner_rank: i32,
|
|
/// Start index in flattened parameter
|
|
pub start_idx: usize,
|
|
/// End index in flattened parameter
|
|
pub end_idx: usize,
|
|
/// Original parameter offset
|
|
pub param_offset: usize,
|
|
}
|
|
|
|
/// Memory usage statistics for FSDP
|
|
#[derive(Debug, Default)]
|
|
pub struct FsdpMemoryStats {
|
|
/// Total parameter memory (MB)
|
|
pub total_param_memory_mb: f32,
|
|
/// Local shard memory (MB)
|
|
pub local_shard_memory_mb: f32,
|
|
/// Peak memory during all-gather (MB)
|
|
pub peak_allgather_memory_mb: f32,
|
|
/// Memory saved compared to non-sharded (MB)
|
|
pub memory_saved_mb: f32,
|
|
}
|
|
|
|
impl Default for FsdpConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
sharding_strategy: ShardingStrategy::FullShard,
|
|
min_param_size: 1000, // Only shard parameters with >1K elements
|
|
cpu_offload: false,
|
|
mixed_precision: true,
|
|
flatten_parameters: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Fsdp {
|
|
/// Create new FSDP instance with default config
|
|
pub fn new(process_group: ProcessGroup) -> Result<Self> {
|
|
let config = FsdpConfig::default();
|
|
Ok(Self {
|
|
process_group,
|
|
config,
|
|
sharded_params: HashMap::new(),
|
|
memory_stats: FsdpMemoryStats::default(),
|
|
})
|
|
}
|
|
|
|
/// Create new FSDP instance with custom config
|
|
pub fn new_with_config(process_group: ProcessGroup, config: FsdpConfig) -> Self {
|
|
Self {
|
|
process_group,
|
|
config,
|
|
sharded_params: HashMap::new(),
|
|
memory_stats: FsdpMemoryStats::default(),
|
|
}
|
|
}
|
|
|
|
/// Shard model parameters across the process group
|
|
pub fn shard_parameters(&mut self, parameters: &Tensor) -> Result<Tensor> {
|
|
let world_size = self.process_group.world_size();
|
|
let rank = self.process_group.rank();
|
|
|
|
if world_size == 1 {
|
|
// No sharding needed for single GPU
|
|
return Ok(parameters.clone());
|
|
}
|
|
|
|
// Calculate shard size
|
|
let total_elements = parameters.numel();
|
|
let elements_per_shard = total_elements.div_ceil(world_size); // Round up
|
|
let start_idx = rank * elements_per_shard;
|
|
let end_idx = (start_idx + elements_per_shard).min(total_elements);
|
|
let shard_size = end_idx - start_idx;
|
|
|
|
// Create local shard (simplified - in real implementation would slice the actual tensor)
|
|
let shard_shape = crate::TensorShape::new(vec![shard_size])?;
|
|
let local_shard = Tensor::zeros(shard_shape, &crate::Device::default())?;
|
|
|
|
// Update memory statistics
|
|
let total_memory_mb = (total_elements * 4) as f32 / (1024.0 * 1024.0); // f32 = 4 bytes
|
|
let shard_memory_mb = (shard_size * 4) as f32 / (1024.0 * 1024.0);
|
|
|
|
self.memory_stats.total_param_memory_mb = total_memory_mb;
|
|
self.memory_stats.local_shard_memory_mb = shard_memory_mb;
|
|
self.memory_stats.memory_saved_mb = total_memory_mb - shard_memory_mb;
|
|
|
|
tracing::debug!(
|
|
"FSDP sharded {:.2}MB -> {:.2}MB ({:.1}% reduction)",
|
|
total_memory_mb,
|
|
shard_memory_mb,
|
|
(self.memory_stats.memory_saved_mb / total_memory_mb) * 100.0
|
|
);
|
|
|
|
Ok(local_shard)
|
|
}
|
|
|
|
/// Synchronize gradients across all processes
|
|
pub async fn sync_gradients(&self, gradients: &mut Tensor) -> Result<()> {
|
|
use crate::comm::ReduceOp;
|
|
|
|
// Perform AllReduce to sum gradients across all processes
|
|
self.process_group
|
|
.all_reduce(gradients, ReduceOp::Sum)
|
|
.await?;
|
|
|
|
// Average the gradients
|
|
let world_size = self.process_group.world_size() as f32;
|
|
*gradients = gradients.div_scalar(world_size)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get current memory statistics
|
|
pub fn memory_stats(&self) -> &FsdpMemoryStats {
|
|
&self.memory_stats
|
|
}
|
|
|
|
/// Calculate memory reduction percentage
|
|
pub fn memory_reduction_percent(&self) -> f32 {
|
|
if self.memory_stats.total_param_memory_mb > 0.0 {
|
|
(self.memory_stats.memory_saved_mb / self.memory_stats.total_param_memory_mb) * 100.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tensor Parallel implementation
|
|
pub struct TensorParallel {
|
|
/// Process group for tensor parallel communication
|
|
pub process_group: ProcessGroup,
|
|
}
|
|
|
|
impl TensorParallel {
|
|
/// Create new tensor parallel instance
|
|
pub fn new(process_group: ProcessGroup) -> Result<Self> {
|
|
Ok(Self { process_group })
|
|
}
|
|
|
|
/// Parallel matrix multiplication using [`ColParallelLinear`].
|
|
///
|
|
/// `a` is `[batch, in_features]` and `b` is the weight matrix
|
|
/// `[out_features, in_features]` in row-major order. The method shards
|
|
/// with `tp_size = 1` (this rank is the sole rank) so the result equals a
|
|
/// full, un-sharded matmul.
|
|
pub fn matmul(&self, a: &Tensor, b: &Tensor) -> Result<Tensor> {
|
|
let a_dims = a.shape().dims();
|
|
let b_dims = b.shape().dims();
|
|
|
|
if a_dims.len() != 2 || b_dims.len() != 2 {
|
|
return Err(DistributedError::parallelism(
|
|
"TensorParallel::matmul",
|
|
"expected 2-D tensors",
|
|
));
|
|
}
|
|
|
|
let batch = a_dims[0];
|
|
let in_features = a_dims[1];
|
|
let out_features = b_dims[0];
|
|
|
|
if b_dims[1] != in_features {
|
|
return Err(DistributedError::parallelism(
|
|
"TensorParallel::matmul",
|
|
format!(
|
|
"dimension mismatch: a has in_features={in_features} but b has inner dim={}",
|
|
b_dims[1]
|
|
),
|
|
));
|
|
}
|
|
|
|
let a_data = a.to_vec().map_err(|e| {
|
|
DistributedError::parallelism("TensorParallel::matmul", format!("to_vec a: {e}"))
|
|
})?;
|
|
let b_data = b.to_vec().map_err(|e| {
|
|
DistributedError::parallelism("TensorParallel::matmul", format!("to_vec b: {e}"))
|
|
})?;
|
|
|
|
// Use a single-rank ColParallelLinear (tp_size = 1) for the full matmul.
|
|
let col = ColParallelLinear::new(&b_data, None, in_features, out_features, 1, 0)?;
|
|
let output = col.forward_cpu(&a_data, batch);
|
|
|
|
tracing::debug!(
|
|
"Tensor parallel matmul: {batch}x{in_features} * {out_features}x{in_features}"
|
|
);
|
|
|
|
Tensor::from_vec(output, &[batch, out_features], &crate::Device::default()).map_err(|e| {
|
|
DistributedError::parallelism("TensorParallel::matmul", format!("from_vec: {e}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Column-parallel linear layer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Column-parallel linear layer for tensor parallelism.
|
|
///
|
|
/// The full weight matrix `[out_features, in_features]` is partitioned along
|
|
/// the output (row) dimension. Each TP rank holds
|
|
/// `[out_features / tp_size, in_features]` rows.
|
|
///
|
|
/// `forward_cpu(x)` computes `x @ weight_shard.T` locally. Because each
|
|
/// rank produces a non-overlapping output slice, the sharded outputs can be
|
|
/// concatenated (AllGather) by the caller, or fed directly into a
|
|
/// [`RowParallelLinear`] which will AllReduce the partial sums.
|
|
///
|
|
/// # Invariants
|
|
/// * `weight_full.len() == out_features * in_features`
|
|
/// * `out_features % tp_size == 0`
|
|
/// * `0 <= rank < tp_size`
|
|
pub struct ColParallelLinear {
|
|
/// Weight shard: `[out_features / tp_size, in_features]`, row-major.
|
|
pub weight_shard: Vec<f32>,
|
|
/// Optional bias shard: `[out_features / tp_size]`.
|
|
pub bias_shard: Option<Vec<f32>>,
|
|
/// Inner dimension (columns of the weight matrix).
|
|
pub in_features: usize,
|
|
/// Rows owned by this rank (`out_features / tp_size`).
|
|
pub out_features_per_rank: usize,
|
|
/// Total tensor-parallel degree.
|
|
pub tp_size: usize,
|
|
/// This rank's index within the TP group (`0..tp_size`).
|
|
pub rank: usize,
|
|
}
|
|
|
|
impl ColParallelLinear {
|
|
/// Construct a column-parallel linear layer from the full weight matrix.
|
|
///
|
|
/// # Arguments
|
|
/// * `weight_full` — flat, row-major `[out_features, in_features]`.
|
|
/// * `bias_full` — optional flat `[out_features]` bias.
|
|
/// * `in_features` — inner dimension K.
|
|
/// * `out_features` — outer dimension N (must be divisible by `tp_size`).
|
|
/// * `tp_size` — number of tensor-parallel ranks.
|
|
/// * `rank` — this rank's index (`0..tp_size`).
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DistributedError`] if `out_features` is not evenly divisible
|
|
/// by `tp_size`, if the weight slice length is wrong, or if `rank >= tp_size`.
|
|
pub fn new(
|
|
weight_full: &[f32],
|
|
bias_full: Option<&[f32]>,
|
|
in_features: usize,
|
|
out_features: usize,
|
|
tp_size: usize,
|
|
rank: usize,
|
|
) -> Result<Self> {
|
|
if tp_size == 0 {
|
|
return Err(DistributedError::parallelism(
|
|
"ColParallelLinear",
|
|
"tp_size must be >= 1",
|
|
));
|
|
}
|
|
if rank >= tp_size {
|
|
return Err(DistributedError::parallelism(
|
|
"ColParallelLinear",
|
|
format!("rank {rank} is out of range for tp_size {tp_size}"),
|
|
));
|
|
}
|
|
if out_features % tp_size != 0 {
|
|
return Err(DistributedError::parallelism(
|
|
"ColParallelLinear",
|
|
format!("out_features {out_features} must be divisible by tp_size {tp_size}"),
|
|
));
|
|
}
|
|
|
|
let expected = out_features * in_features;
|
|
if weight_full.len() != expected {
|
|
return Err(DistributedError::parallelism(
|
|
"ColParallelLinear",
|
|
format!(
|
|
"weight_full length {} does not match out_features*in_features={expected}",
|
|
weight_full.len()
|
|
),
|
|
));
|
|
}
|
|
|
|
let shard_rows = out_features / tp_size;
|
|
let row_start = rank * shard_rows;
|
|
let row_end = row_start + shard_rows;
|
|
|
|
// Extract rows [row_start, row_end) from the weight matrix.
|
|
let weight_shard = weight_full[row_start * in_features..row_end * in_features].to_vec();
|
|
|
|
let bias_shard = bias_full.map(|bias| bias[row_start..row_end].to_vec());
|
|
|
|
Ok(Self {
|
|
weight_shard,
|
|
bias_shard,
|
|
in_features,
|
|
out_features_per_rank: shard_rows,
|
|
tp_size,
|
|
rank,
|
|
})
|
|
}
|
|
|
|
/// CPU reference forward pass.
|
|
///
|
|
/// Computes `x [batch, in_features] @ weight_shard.T`
|
|
/// → `[batch, out_features_per_rank]`.
|
|
/// Adds `bias_shard` if present.
|
|
pub fn forward_cpu(&self, x: &[f32], batch: usize) -> Vec<f32> {
|
|
let n = self.out_features_per_rank;
|
|
let k = self.in_features;
|
|
let mut out = vec![0.0_f32; batch * n];
|
|
|
|
for b in 0..batch {
|
|
for j in 0..n {
|
|
let mut acc = 0.0_f32;
|
|
for i in 0..k {
|
|
acc += x[b * k + i] * self.weight_shard[j * k + i];
|
|
}
|
|
out[b * n + j] = acc + self.bias_shard.as_ref().map_or(0.0, |bias| bias[j]);
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Row-parallel linear layer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Row-parallel linear layer for tensor parallelism.
|
|
///
|
|
/// The full weight matrix `[out_features, in_features]` is partitioned along
|
|
/// the input (column) dimension. Each TP rank holds
|
|
/// `[out_features, in_features / tp_size]` columns.
|
|
///
|
|
/// Each rank receives a matching input shard `x_shard [batch, in_features/tp_size]`
|
|
/// (typically the output of a [`ColParallelLinear`]) and computes
|
|
/// `x_shard @ weight_shard.T` locally to produce a partial sum
|
|
/// `[batch, out_features]`. A subsequent AllReduce over all TP ranks yields
|
|
/// the full output.
|
|
///
|
|
/// # Invariants
|
|
/// * `weight_full.len() == out_features * in_features`
|
|
/// * `in_features % tp_size == 0`
|
|
/// * `0 <= rank < tp_size`
|
|
pub struct RowParallelLinear {
|
|
/// Weight shard: `[out_features, in_features / tp_size]`, row-major.
|
|
pub weight_shard: Vec<f32>,
|
|
/// Bias applied after AllReduce — **only rank 0 adds it** to avoid
|
|
/// double-counting during the reduce. Shape: `[out_features]`.
|
|
pub bias: Option<Vec<f32>>,
|
|
/// Output feature count (unchanged by column-sharding).
|
|
pub out_features: usize,
|
|
/// Columns owned by this rank (`in_features / tp_size`).
|
|
pub in_features_per_rank: usize,
|
|
/// Total tensor-parallel degree.
|
|
pub tp_size: usize,
|
|
/// This rank's index within the TP group (`0..tp_size`).
|
|
pub rank: usize,
|
|
/// Process group used for the AllReduce collective.
|
|
pub process_group: ProcessGroup,
|
|
}
|
|
|
|
impl RowParallelLinear {
|
|
/// Construct a row-parallel linear layer from the full weight matrix.
|
|
///
|
|
/// # Arguments
|
|
/// * `weight_full` — flat, row-major `[out_features, in_features]`.
|
|
/// * `bias` — optional flat `[out_features]` bias (owned; only
|
|
/// rank 0 applies it after AllReduce).
|
|
/// * `out_features` — output dimension M.
|
|
/// * `in_features` — inner dimension K (must be divisible by `tp_size`).
|
|
/// * `tp_size` — number of tensor-parallel ranks.
|
|
/// * `rank` — this rank's index (`0..tp_size`).
|
|
/// * `process_group`— process group for the AllReduce collective.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`DistributedError`] if `in_features` is not evenly divisible
|
|
/// by `tp_size`, if the weight slice length is wrong, or if `rank >= tp_size`.
|
|
pub fn new(
|
|
weight_full: &[f32],
|
|
bias: Option<Vec<f32>>,
|
|
out_features: usize,
|
|
in_features: usize,
|
|
tp_size: usize,
|
|
rank: usize,
|
|
process_group: ProcessGroup,
|
|
) -> Result<Self> {
|
|
if tp_size == 0 {
|
|
return Err(DistributedError::parallelism(
|
|
"RowParallelLinear",
|
|
"tp_size must be >= 1",
|
|
));
|
|
}
|
|
if rank >= tp_size {
|
|
return Err(DistributedError::parallelism(
|
|
"RowParallelLinear",
|
|
format!("rank {rank} is out of range for tp_size {tp_size}"),
|
|
));
|
|
}
|
|
if in_features % tp_size != 0 {
|
|
return Err(DistributedError::parallelism(
|
|
"RowParallelLinear",
|
|
format!("in_features {in_features} must be divisible by tp_size {tp_size}"),
|
|
));
|
|
}
|
|
|
|
let expected = out_features * in_features;
|
|
if weight_full.len() != expected {
|
|
return Err(DistributedError::parallelism(
|
|
"RowParallelLinear",
|
|
format!(
|
|
"weight_full length {} does not match out_features*in_features={expected}",
|
|
weight_full.len()
|
|
),
|
|
));
|
|
}
|
|
|
|
let shard_cols = in_features / tp_size;
|
|
let col_start = rank * shard_cols;
|
|
let col_end = col_start + shard_cols;
|
|
|
|
// Extract columns [col_start, col_end) from every row of the weight matrix.
|
|
// weight_full is row-major [out_features, in_features], so row i spans
|
|
// indices [i*in_features .. (i+1)*in_features].
|
|
let mut weight_shard = Vec::with_capacity(out_features * shard_cols);
|
|
for row in 0..out_features {
|
|
let row_base = row * in_features;
|
|
weight_shard.extend_from_slice(&weight_full[row_base + col_start..row_base + col_end]);
|
|
}
|
|
|
|
Ok(Self {
|
|
weight_shard,
|
|
bias,
|
|
out_features,
|
|
in_features_per_rank: shard_cols,
|
|
tp_size,
|
|
rank,
|
|
process_group,
|
|
})
|
|
}
|
|
|
|
/// CPU reference forward pass (no network I/O).
|
|
///
|
|
/// Computes the local partial sum
|
|
/// `x_shard [batch, in_features/tp_size] @ weight_shard.T`
|
|
/// → `[batch, out_features]`.
|
|
///
|
|
/// Bias is added **only on rank 0** so that, after an AllReduce sum, the
|
|
/// bias is incorporated exactly once in the final result.
|
|
///
|
|
/// In production this is followed by
|
|
/// `process_group.all_reduce(&mut partial, ReduceOp::Sum)`.
|
|
pub fn forward_cpu(&self, x_shard: &[f32], batch: usize) -> Vec<f32> {
|
|
let n = self.out_features;
|
|
let k = self.in_features_per_rank;
|
|
let mut partial = vec![0.0_f32; batch * n];
|
|
|
|
for b in 0..batch {
|
|
for j in 0..n {
|
|
let mut acc = 0.0_f32;
|
|
for i in 0..k {
|
|
acc += x_shard[b * k + i] * self.weight_shard[j * k + i];
|
|
}
|
|
partial[b * n + j] = acc;
|
|
}
|
|
}
|
|
|
|
// Add bias only on rank 0 to avoid double-counting across TP ranks.
|
|
if self.rank == 0 {
|
|
if let Some(bias) = &self.bias {
|
|
for b in 0..batch {
|
|
for j in 0..n {
|
|
partial[b * n + j] += bias[j];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
partial
|
|
}
|
|
|
|
/// Async forward pass with a real AllReduce collective.
|
|
///
|
|
/// 1. Computes the local partial sum via [`forward_cpu`][Self::forward_cpu].
|
|
/// 2. Wraps the result in a [`Tensor`] and issues an AllReduce Sum across
|
|
/// all TP ranks via `self.process_group`.
|
|
/// 3. Returns the reduced flat `[batch * out_features]` buffer.
|
|
///
|
|
/// In CPU simulation mode (`Backend::Cpu`, single process) the AllReduce
|
|
/// is a no-op that scales by `world_size`; the returned data is therefore
|
|
/// correct only when `tp_size == 1` or when multiple real processes run.
|
|
/// Use [`forward_cpu`][Self::forward_cpu] directly for unit tests that
|
|
/// manually sum partials.
|
|
pub async fn forward(&self, x_shard: &[f32], batch: usize) -> Result<Vec<f32>> {
|
|
use crate::comm::ReduceOp;
|
|
|
|
let local = self.forward_cpu(x_shard, batch);
|
|
|
|
// Wrap in a Tensor for the collective API.
|
|
let mut t = Tensor::from_vec(
|
|
local,
|
|
&[batch, self.out_features],
|
|
&crate::Device::default(),
|
|
)
|
|
.map_err(|e| {
|
|
DistributedError::parallelism("RowParallelLinear::forward", format!("from_vec: {e}"))
|
|
})?;
|
|
|
|
// AllReduce: sum partial results across all TP ranks.
|
|
self.process_group.all_reduce(&mut t, ReduceOp::Sum).await?;
|
|
|
|
t.to_vec().map_err(|e| {
|
|
DistributedError::parallelism(
|
|
"RowParallelLinear::forward",
|
|
format!("to_vec after allreduce: {e}"),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Pipeline Parallel implementation
|
|
pub struct PipelineParallel {
|
|
/// Process group for pipeline parallel communication
|
|
pub process_group: ProcessGroup,
|
|
/// Number of pipeline stages
|
|
pub num_stages: usize,
|
|
}
|
|
|
|
impl PipelineParallel {
|
|
/// Create new pipeline parallel instance
|
|
pub fn new(process_group: ProcessGroup, num_stages: usize) -> Result<Self> {
|
|
Ok(Self {
|
|
process_group,
|
|
num_stages,
|
|
})
|
|
}
|
|
|
|
/// Forward pass through pipeline stages
|
|
pub fn forward(&self, inputs: &Tensor) -> Result<Tensor> {
|
|
// Simplified pipeline forward pass
|
|
// In real implementation, this would:
|
|
// 1. Split model into stages
|
|
// 2. Pass activations between stages
|
|
// 3. Handle micro-batching
|
|
|
|
let outputs = inputs.clone();
|
|
|
|
tracing::debug!(
|
|
"Pipeline forward: {} stages, input shape: {:?}",
|
|
self.num_stages,
|
|
inputs.shape().dims()
|
|
);
|
|
|
|
Ok(outputs)
|
|
}
|
|
|
|
/// Backward pass through pipeline stages
|
|
pub fn backward(&self, grad_outputs: &Tensor) -> Result<Tensor> {
|
|
// Simplified pipeline backward pass
|
|
let grad_inputs = grad_outputs.clone();
|
|
|
|
tracing::debug!(
|
|
"Pipeline backward: {} stages, grad_output shape: {:?}",
|
|
self.num_stages,
|
|
grad_outputs.shape().dims()
|
|
);
|
|
|
|
Ok(grad_inputs)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::group::ProcessGroup;
|
|
use crate::{Backend, BackendConfig, Device, Tensor, TensorShape};
|
|
|
|
#[tokio::test]
|
|
async fn test_data_parallel() {
|
|
let config = BackendConfig::cpu();
|
|
let pg = ProcessGroup::new_with_config(Backend::Cpu, 2, 0, config)
|
|
.await
|
|
.unwrap();
|
|
let mut dp = DataParallel::new(pg, 2);
|
|
|
|
dp.start_accumulation();
|
|
|
|
let mut gradients = vec![
|
|
Tensor::ones(&TensorShape::new(vec![10, 10]).unwrap(), &Device::default()).unwrap(),
|
|
Tensor::ones(&TensorShape::new(vec![5, 5]).unwrap(), &Device::default()).unwrap(),
|
|
];
|
|
|
|
let should_sync = dp.accumulate_gradients(&mut gradients).await.unwrap();
|
|
assert!(!should_sync); // First accumulation shouldn't sync
|
|
|
|
let should_sync = dp.accumulate_gradients(&mut gradients).await.unwrap();
|
|
assert!(should_sync); // Second accumulation should sync
|
|
}
|
|
|
|
#[test]
|
|
fn test_fsdp_config() {
|
|
let config = FsdpConfig::default();
|
|
assert_eq!(config.sharding_strategy, ShardingStrategy::FullShard);
|
|
assert_eq!(config.min_param_size, 1000);
|
|
assert!(config.mixed_precision);
|
|
assert!(config.flatten_parameters);
|
|
assert!(!config.cpu_offload);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tensor-parallel unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tp_tests {
|
|
use super::*;
|
|
use crate::{Backend, BackendConfig, Device, Tensor, TensorShape};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Naive CPU matmul: A [batch, k] @ W.T [k, n] → [batch, n].
|
|
/// Weight W is stored row-major as [n, k].
|
|
fn naive_matmul(a: &[f32], w: &[f32], batch: usize, k: usize, n: usize) -> Vec<f32> {
|
|
let mut out = vec![0.0_f32; batch * n];
|
|
for b in 0..batch {
|
|
for j in 0..n {
|
|
let mut acc = 0.0_f32;
|
|
for i in 0..k {
|
|
acc += a[b * k + i] * w[j * k + i];
|
|
}
|
|
out[b * n + j] = acc;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn assert_vec_approx(got: &[f32], expected: &[f32], tol: f32, label: &str) {
|
|
assert_eq!(
|
|
got.len(),
|
|
expected.len(),
|
|
"{label}: length mismatch: got {} expected {}",
|
|
got.len(),
|
|
expected.len()
|
|
);
|
|
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
|
assert!(
|
|
(g - e).abs() <= tol,
|
|
"{label}: element[{i}] got={g} expected={e} diff={}",
|
|
(g - e).abs()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cpu_pg() -> ProcessGroup {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async {
|
|
let config = BackendConfig::cpu();
|
|
ProcessGroup::new_with_config(Backend::Cpu, 1, 0, config)
|
|
.await
|
|
.unwrap()
|
|
})
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// ColParallelLinear tests
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_col_parallel_single_rank() {
|
|
// tp_size = 1 → weight_shard == weight_full; output matches naive matmul.
|
|
let batch = 2;
|
|
let in_f = 3;
|
|
let out_f = 4;
|
|
|
|
// Weight [out_f, in_f]
|
|
let w: Vec<f32> = (1..=(out_f * in_f) as i32).map(|x| x as f32).collect();
|
|
// Input [batch, in_f]
|
|
let x: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let col = ColParallelLinear::new(&w, None, in_f, out_f, 1, 0).unwrap();
|
|
assert_eq!(col.weight_shard, w, "single rank should hold full weight");
|
|
assert_eq!(col.out_features_per_rank, out_f);
|
|
|
|
let got = col.forward_cpu(&x, batch);
|
|
let expected = naive_matmul(&x, &w, batch, in_f, out_f);
|
|
assert_vec_approx(&got, &expected, 1e-5, "col_single_rank");
|
|
}
|
|
|
|
#[test]
|
|
fn test_col_parallel_two_rank_sharding() {
|
|
// out_features = 4, tp_size = 2:
|
|
// rank 0 → rows [0, 1] (out cols 0,1 of full result)
|
|
// rank 1 → rows [2, 3] (out cols 2,3 of full result)
|
|
// Concatenated outputs must equal the full matmul.
|
|
let batch = 2;
|
|
let in_f = 3;
|
|
let out_f = 4;
|
|
|
|
let w: Vec<f32> = (1..=(out_f * in_f) as i32).map(|x| x as f32).collect();
|
|
let x: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let col0 = ColParallelLinear::new(&w, None, in_f, out_f, 2, 0).unwrap();
|
|
let col1 = ColParallelLinear::new(&w, None, in_f, out_f, 2, 1).unwrap();
|
|
|
|
assert_eq!(col0.out_features_per_rank, 2);
|
|
assert_eq!(col1.out_features_per_rank, 2);
|
|
|
|
let out0 = col0.forward_cpu(&x, batch); // [batch, 2]
|
|
let out1 = col1.forward_cpu(&x, batch); // [batch, 2]
|
|
|
|
// Interleave: for each batch row, concatenate out0[b] then out1[b] → [batch, 4]
|
|
let mut combined = Vec::with_capacity(batch * out_f);
|
|
for b in 0..batch {
|
|
combined.extend_from_slice(&out0[b * 2..(b + 1) * 2]);
|
|
combined.extend_from_slice(&out1[b * 2..(b + 1) * 2]);
|
|
}
|
|
|
|
let expected = naive_matmul(&x, &w, batch, in_f, out_f);
|
|
assert_vec_approx(&combined, &expected, 1e-5, "col_two_rank_sharding");
|
|
}
|
|
|
|
#[test]
|
|
fn test_col_parallel_with_bias() {
|
|
// Bias is added to the output slice owned by this rank.
|
|
let batch = 2;
|
|
let in_f = 3;
|
|
let out_f = 4;
|
|
|
|
let w: Vec<f32> = vec![1.0; out_f * in_f];
|
|
let x: Vec<f32> = vec![1.0; batch * in_f]; // all-ones → each output is in_f
|
|
let bias: Vec<f32> = vec![10.0, 20.0, 30.0, 40.0]; // one per output feature
|
|
|
|
// tp_size = 1: rank 0 gets all rows and the full bias.
|
|
let col = ColParallelLinear::new(&w, Some(&bias), in_f, out_f, 1, 0).unwrap();
|
|
let got = col.forward_cpu(&x, batch);
|
|
|
|
// Each output element should be in_f * 1.0 + bias[j] = 3 + bias[j]
|
|
let expected: Vec<f32> = (0..batch)
|
|
.flat_map(|_| bias.iter().map(|b| in_f as f32 + b))
|
|
.collect();
|
|
assert_vec_approx(&got, &expected, 1e-5, "col_with_bias");
|
|
}
|
|
|
|
#[test]
|
|
fn test_col_parallel_bias_two_ranks() {
|
|
// With tp_size = 2: rank 0 gets bias[0..2], rank 1 gets bias[2..4].
|
|
let in_f = 2;
|
|
let out_f = 4;
|
|
let batch = 1;
|
|
|
|
let w: Vec<f32> = vec![1.0; out_f * in_f];
|
|
let x: Vec<f32> = vec![1.0; in_f]; // [1, in_f]
|
|
let bias: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
|
|
|
|
let col0 = ColParallelLinear::new(&w, Some(&bias), in_f, out_f, 2, 0).unwrap();
|
|
let col1 = ColParallelLinear::new(&w, Some(&bias), in_f, out_f, 2, 1).unwrap();
|
|
|
|
let out0 = col0.forward_cpu(&x, batch); // expects [in_f + 1, in_f + 2] = [3,4]
|
|
let out1 = col1.forward_cpu(&x, batch); // expects [in_f + 3, in_f + 4] = [5,6]
|
|
|
|
assert_vec_approx(&out0, &[3.0, 4.0], 1e-5, "bias_rank0");
|
|
assert_vec_approx(&out1, &[5.0, 6.0], 1e-5, "bias_rank1");
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// RowParallelLinear tests
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_row_parallel_single_rank() {
|
|
// tp_size = 1 → forward_cpu equals naive matmul.
|
|
let batch = 2;
|
|
let in_f = 4;
|
|
let out_f = 3;
|
|
|
|
let w: Vec<f32> = (1..=(out_f * in_f) as i32).map(|x| x as f32).collect();
|
|
let x: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let pg = cpu_pg();
|
|
let row = RowParallelLinear::new(&w, None, out_f, in_f, 1, 0, pg).unwrap();
|
|
assert_eq!(row.in_features_per_rank, in_f);
|
|
|
|
let got = row.forward_cpu(&x, batch);
|
|
let expected = naive_matmul(&x, &w, batch, in_f, out_f);
|
|
assert_vec_approx(&got, &expected, 1e-5, "row_single_rank");
|
|
}
|
|
|
|
#[test]
|
|
fn test_row_parallel_two_rank_sharding() {
|
|
// in_features = 4, tp_size = 2:
|
|
// rank 0 → cols [0,1] of W → partial sum from x[0..2]
|
|
// rank 1 → cols [2,3] of W → partial sum from x[2..4]
|
|
// Adding partial sums → full matmul output.
|
|
let batch = 2;
|
|
let in_f = 4;
|
|
let out_f = 3;
|
|
|
|
let w: Vec<f32> = (1..=(out_f * in_f) as i32).map(|x| x as f32).collect();
|
|
let x: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let pg0 = cpu_pg();
|
|
let pg1 = cpu_pg();
|
|
|
|
let row0 = RowParallelLinear::new(&w, None, out_f, in_f, 2, 0, pg0).unwrap();
|
|
let row1 = RowParallelLinear::new(&w, None, out_f, in_f, 2, 1, pg1).unwrap();
|
|
|
|
// x_shard for each rank: columns [0..2] and [2..4] of x per batch row.
|
|
let shard_size = in_f / 2;
|
|
let mut x_shard0 = Vec::with_capacity(batch * shard_size);
|
|
let mut x_shard1 = Vec::with_capacity(batch * shard_size);
|
|
for b in 0..batch {
|
|
x_shard0.extend_from_slice(&x[b * in_f..b * in_f + shard_size]);
|
|
x_shard1.extend_from_slice(&x[b * in_f + shard_size..b * in_f + in_f]);
|
|
}
|
|
|
|
let partial0 = row0.forward_cpu(&x_shard0, batch);
|
|
let partial1 = row1.forward_cpu(&x_shard1, batch);
|
|
|
|
// AllReduce (sum): add the two partials element-wise.
|
|
let combined: Vec<f32> = partial0
|
|
.iter()
|
|
.zip(partial1.iter())
|
|
.map(|(a, b)| a + b)
|
|
.collect();
|
|
|
|
let expected = naive_matmul(&x, &w, batch, in_f, out_f);
|
|
assert_vec_approx(&combined, &expected, 1e-5, "row_two_rank_sharding");
|
|
}
|
|
|
|
#[test]
|
|
fn test_row_parallel_bias_rank0_only() {
|
|
// Bias is added only by rank 0; rank 1 must not add it.
|
|
let batch = 1;
|
|
let in_f = 2;
|
|
let out_f = 2;
|
|
|
|
// Identity-ish weight [out_f, in_f] = [[1,0],[0,1]]
|
|
let w: Vec<f32> = vec![1.0, 0.0, 0.0, 1.0];
|
|
let bias: Vec<f32> = vec![100.0, 200.0];
|
|
let x: Vec<f32> = vec![3.0, 5.0]; // [1, 2]
|
|
|
|
let pg0 = cpu_pg();
|
|
let pg1 = cpu_pg();
|
|
|
|
// rank 0 gets cols [0..1], rank 1 gets cols [1..2].
|
|
let row0 = RowParallelLinear::new(&w, Some(bias.clone()), out_f, in_f, 2, 0, pg0).unwrap();
|
|
let row1 = RowParallelLinear::new(&w, Some(bias.clone()), out_f, in_f, 2, 1, pg1).unwrap();
|
|
|
|
// x_shard0 = [3.0], x_shard1 = [5.0]
|
|
let partial0 = row0.forward_cpu(&[3.0], batch);
|
|
let partial1 = row1.forward_cpu(&[5.0], batch);
|
|
|
|
// partial0 = [3*1 + bias[0], 3*0 + bias[1]] = [103, 200]
|
|
// partial1 = [5*0, 5*1 ] = [ 0, 5]
|
|
assert_vec_approx(&partial0, &[103.0, 200.0], 1e-5, "partial_rank0");
|
|
assert_vec_approx(&partial1, &[0.0, 5.0], 1e-5, "partial_rank1");
|
|
|
|
// AllReduce (sum) → [103, 205] = expected [3+100, 5+200]
|
|
let summed: Vec<f32> = partial0
|
|
.iter()
|
|
.zip(partial1.iter())
|
|
.map(|(a, b)| a + b)
|
|
.collect();
|
|
assert_vec_approx(&summed, &[103.0, 205.0], 1e-5, "bias_rank0_only_final");
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// TensorParallel::matmul
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_matmul_not_zeros() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async {
|
|
let config = BackendConfig::cpu();
|
|
let pg = ProcessGroup::new_with_config(Backend::Cpu, 1, 0, config)
|
|
.await
|
|
.unwrap();
|
|
let tp = TensorParallel::new(pg).unwrap();
|
|
|
|
let batch = 2;
|
|
let in_f = 3;
|
|
let out_f = 4;
|
|
|
|
let a_data: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
let b_data: Vec<f32> = (1..=(out_f * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let a = Tensor::from_vec(a_data.clone(), &[batch, in_f], &Device::default()).unwrap();
|
|
let b = Tensor::from_vec(b_data.clone(), &[out_f, in_f], &Device::default()).unwrap();
|
|
|
|
let result = tp.matmul(&a, &b).unwrap();
|
|
let result_data = result.to_vec().unwrap();
|
|
|
|
// Verify output is non-zero.
|
|
let all_zero = result_data.iter().all(|&v| v == 0.0);
|
|
assert!(!all_zero, "matmul output must not be all zeros");
|
|
|
|
// Verify correctness against naive matmul.
|
|
let expected = naive_matmul(&a_data, &b_data, batch, in_f, out_f);
|
|
assert_eq!(result.dims(), &[batch, out_f]);
|
|
assert_vec_approx(&result_data, &expected, 1e-4, "tp_matmul_correctness");
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// ColParallel → RowParallel round-trip
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_col_then_row_roundtrip() {
|
|
// Simulate the standard TP linear pipeline:
|
|
// x [batch, in_f] → ColParallel → x_shard [batch, in_f/tp]
|
|
// x_shard → RowParallel → partial [batch, out_f]
|
|
// AllReduce (sum of tp partials) → [batch, out_f]
|
|
//
|
|
// With tp_size = 2 this is a genuine two-stage sharded pipeline.
|
|
// We run both ranks in the same process (CPU sim) and manually add
|
|
// their partial outputs to mimic the AllReduce.
|
|
let batch = 3;
|
|
let in_f = 4;
|
|
let hidden = 6; // ColParallel output / RowParallel input
|
|
let out_f = 5;
|
|
|
|
// Two weight matrices simulating a two-layer MLP.
|
|
let w1: Vec<f32> = (1..=(hidden * in_f) as i32)
|
|
.map(|x| x as f32 * 0.1)
|
|
.collect();
|
|
let w2: Vec<f32> = (1..=(out_f * hidden) as i32)
|
|
.map(|x| x as f32 * 0.05)
|
|
.collect();
|
|
let x: Vec<f32> = (1..=(batch * in_f) as i32).map(|x| x as f32).collect();
|
|
|
|
let tp_size = 2;
|
|
|
|
// --- Stage 1: ColParallel on w1 (shard along hidden / output dim) ---
|
|
let col0 = ColParallelLinear::new(&w1, None, in_f, hidden, tp_size, 0).unwrap();
|
|
let col1 = ColParallelLinear::new(&w1, None, in_f, hidden, tp_size, 1).unwrap();
|
|
|
|
let h_shard0 = col0.forward_cpu(&x, batch); // [batch, hidden/2]
|
|
let h_shard1 = col1.forward_cpu(&x, batch); // [batch, hidden/2]
|
|
|
|
// --- Stage 2: RowParallel on w2 (shard along hidden / input dim) ---
|
|
// Each rank receives the corresponding column shard from Stage 1.
|
|
let pg0 = cpu_pg();
|
|
let pg1 = cpu_pg();
|
|
let row0 = RowParallelLinear::new(&w2, None, out_f, hidden, tp_size, 0, pg0).unwrap();
|
|
let row1 = RowParallelLinear::new(&w2, None, out_f, hidden, tp_size, 1, pg1).unwrap();
|
|
|
|
let partial0 = row0.forward_cpu(&h_shard0, batch);
|
|
let partial1 = row1.forward_cpu(&h_shard1, batch);
|
|
|
|
// AllReduce (sum) → [batch, out_f]
|
|
let combined: Vec<f32> = partial0
|
|
.iter()
|
|
.zip(partial1.iter())
|
|
.map(|(a, b)| a + b)
|
|
.collect();
|
|
|
|
// Reference: single-rank two-layer matmul.
|
|
let h_ref = naive_matmul(&x, &w1, batch, in_f, hidden);
|
|
let out_ref = naive_matmul(&h_ref, &w2, batch, hidden, out_f);
|
|
|
|
assert_eq!(combined.len(), batch * out_f);
|
|
assert_vec_approx(&combined, &out_ref, 1e-3, "col_then_row_roundtrip");
|
|
}
|
|
}
|