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]>
1485 lines
50 KiB
Rust
1485 lines
50 KiB
Rust
//! FSDP2 - Fully Sharded Data Parallel v2 with DTensor-based per-parameter sharding.
|
|
//!
|
|
//! FSDP2 is the next-generation FSDP implementation that uses DTensor primitives
|
|
//! for per-parameter sharding instead of flat-parameter sharding used in FSDP1.
|
|
//!
|
|
//! ## Key Benefits over FSDP1:
|
|
//! - 7% lower GPU memory usage (no record_stream overhead)
|
|
//! - Communication-free sharded state dicts
|
|
//! - Better composability with tensor parallelism
|
|
//! - Simpler per-parameter manipulation
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! ```text
|
|
//! ┌─────────────────────────────────────────────────────────────┐
|
|
//! │ FSDP2 │
|
|
//! ├─────────────────┬───────────────────┬───────────────────────┤
|
|
//! │ Per-Parameter │ DTensor-Based │ Mixed Precision │
|
|
//! │ Dim-0 Sharding │ Redistribution │ Policy │
|
|
//! └─────────────────┴───────────────────┴───────────────────────┘
|
|
//! ```
|
|
//!
|
|
//! ## Example
|
|
//!
|
|
//! ```rust,ignore
|
|
//! use rtx_distributed::fsdp2::{Fsdp2Config, fully_shard, MixedPrecisionPolicy};
|
|
//! use rtx_distributed::device_mesh::DeviceMesh;
|
|
//!
|
|
//! // Create device mesh for data parallelism
|
|
//! let mesh = DeviceMesh::new_simple(8, "dp");
|
|
//!
|
|
//! // Configure FSDP2
|
|
//! let config = Fsdp2Config::builder()
|
|
//! .mesh(Arc::new(mesh))
|
|
//! .mp_policy(MixedPrecisionPolicy::bf16())
|
|
//! .reshard_after_forward(true)
|
|
//! .build();
|
|
//!
|
|
//! // Shard model parameters
|
|
//! for layer in &mut model.layers {
|
|
//! fully_shard(layer, &config)?;
|
|
//! }
|
|
//! ```
|
|
|
|
use crate::comm::{CommunicationPrimitive, ReduceOp};
|
|
use crate::device_mesh::DeviceMesh;
|
|
use crate::dtensor::{DTensor, DType, Placement, TensorSpec};
|
|
use crate::error::{DistributedError, Result};
|
|
use crate::group::ProcessGroup;
|
|
use parking_lot::RwLock;
|
|
use rtx_tensor::{Device, Tensor};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
// =============================================================================
|
|
// Mixed Precision Policy
|
|
// =============================================================================
|
|
|
|
/// Mixed precision policy for FSDP2 training.
|
|
///
|
|
/// Controls the precision used for parameters, gradients, and communication.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MixedPrecisionPolicy {
|
|
/// Data type for parameter storage
|
|
pub param_dtype: DType,
|
|
/// Data type for gradient reduction
|
|
pub reduce_dtype: DType,
|
|
/// Data type for computation buffers
|
|
pub buffer_dtype: DType,
|
|
/// Whether to cast inputs to the parameter dtype
|
|
pub cast_inputs: bool,
|
|
/// Whether to keep master weights in FP32
|
|
pub keep_low_precision_grads: bool,
|
|
}
|
|
|
|
impl Default for MixedPrecisionPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
param_dtype: DType::F32,
|
|
reduce_dtype: DType::F32,
|
|
buffer_dtype: DType::F32,
|
|
cast_inputs: false,
|
|
keep_low_precision_grads: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MixedPrecisionPolicy {
|
|
/// Create a BF16 mixed precision policy.
|
|
pub fn bf16() -> Self {
|
|
Self {
|
|
param_dtype: DType::BF16,
|
|
reduce_dtype: DType::F32,
|
|
buffer_dtype: DType::BF16,
|
|
cast_inputs: true,
|
|
keep_low_precision_grads: false,
|
|
}
|
|
}
|
|
|
|
/// Create an FP16 mixed precision policy.
|
|
pub fn fp16() -> Self {
|
|
Self {
|
|
param_dtype: DType::F16,
|
|
reduce_dtype: DType::F32,
|
|
buffer_dtype: DType::F16,
|
|
cast_inputs: true,
|
|
keep_low_precision_grads: false,
|
|
}
|
|
}
|
|
|
|
/// Create a full precision (FP32) policy.
|
|
pub fn fp32() -> Self {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// FSDP2 Configuration
|
|
// =============================================================================
|
|
|
|
/// Configuration for FSDP2 sharding.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Fsdp2Config {
|
|
/// Device mesh for sharding
|
|
pub mesh: Arc<DeviceMesh>,
|
|
/// Mesh dimension name for FSDP sharding (default: "dp")
|
|
pub mesh_dim_name: String,
|
|
/// Mixed precision policy
|
|
pub mp_policy: MixedPrecisionPolicy,
|
|
/// Whether to reshard parameters after forward pass
|
|
pub reshard_after_forward: bool,
|
|
/// Minimum parameter size (in elements) to shard
|
|
pub min_shard_size: usize,
|
|
/// Whether to use CPU offloading
|
|
pub offload_to_cpu: bool,
|
|
/// Backward prefetch configuration
|
|
pub backward_prefetch: BackwardPrefetch,
|
|
/// Forward prefetch configuration
|
|
pub forward_prefetch: bool,
|
|
/// Limit on AllGather in-flight
|
|
pub limit_all_gathers: bool,
|
|
}
|
|
|
|
/// Backward prefetch strategy for overlapping communication with computation.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum BackwardPrefetch {
|
|
/// No prefetching
|
|
None,
|
|
/// Prefetch the previous layer's parameters
|
|
BackwardPre,
|
|
/// Prefetch the next layer's parameters
|
|
BackwardPost,
|
|
}
|
|
|
|
impl Default for BackwardPrefetch {
|
|
fn default() -> Self {
|
|
BackwardPrefetch::BackwardPre
|
|
}
|
|
}
|
|
|
|
/// Builder for FSDP2 configuration.
|
|
pub struct Fsdp2ConfigBuilder {
|
|
mesh: Option<Arc<DeviceMesh>>,
|
|
mesh_dim_name: String,
|
|
mp_policy: MixedPrecisionPolicy,
|
|
reshard_after_forward: bool,
|
|
min_shard_size: usize,
|
|
offload_to_cpu: bool,
|
|
backward_prefetch: BackwardPrefetch,
|
|
forward_prefetch: bool,
|
|
limit_all_gathers: bool,
|
|
}
|
|
|
|
impl Default for Fsdp2ConfigBuilder {
|
|
fn default() -> Self {
|
|
Self {
|
|
mesh: None,
|
|
mesh_dim_name: "dp".to_string(),
|
|
mp_policy: MixedPrecisionPolicy::default(),
|
|
reshard_after_forward: true,
|
|
min_shard_size: 1024,
|
|
offload_to_cpu: false,
|
|
backward_prefetch: BackwardPrefetch::BackwardPre,
|
|
forward_prefetch: false,
|
|
limit_all_gathers: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Fsdp2ConfigBuilder {
|
|
/// Create a new configuration builder.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Set the device mesh.
|
|
pub fn mesh(mut self, mesh: Arc<DeviceMesh>) -> Self {
|
|
self.mesh = Some(mesh);
|
|
self
|
|
}
|
|
|
|
/// Set the mesh dimension name for sharding.
|
|
pub fn mesh_dim_name(mut self, name: impl Into<String>) -> Self {
|
|
self.mesh_dim_name = name.into();
|
|
self
|
|
}
|
|
|
|
/// Set the mixed precision policy.
|
|
pub fn mp_policy(mut self, policy: MixedPrecisionPolicy) -> Self {
|
|
self.mp_policy = policy;
|
|
self
|
|
}
|
|
|
|
/// Set whether to reshard after forward.
|
|
pub fn reshard_after_forward(mut self, reshard: bool) -> Self {
|
|
self.reshard_after_forward = reshard;
|
|
self
|
|
}
|
|
|
|
/// Set minimum shard size.
|
|
pub fn min_shard_size(mut self, size: usize) -> Self {
|
|
self.min_shard_size = size;
|
|
self
|
|
}
|
|
|
|
/// Enable/disable CPU offloading.
|
|
pub fn offload_to_cpu(mut self, offload: bool) -> Self {
|
|
self.offload_to_cpu = offload;
|
|
self
|
|
}
|
|
|
|
/// Set backward prefetch strategy.
|
|
pub fn backward_prefetch(mut self, prefetch: BackwardPrefetch) -> Self {
|
|
self.backward_prefetch = prefetch;
|
|
self
|
|
}
|
|
|
|
/// Enable/disable forward prefetch.
|
|
pub fn forward_prefetch(mut self, prefetch: bool) -> Self {
|
|
self.forward_prefetch = prefetch;
|
|
self
|
|
}
|
|
|
|
/// Limit in-flight AllGathers.
|
|
pub fn limit_all_gathers(mut self, limit: bool) -> Self {
|
|
self.limit_all_gathers = limit;
|
|
self
|
|
}
|
|
|
|
/// Build the configuration.
|
|
pub fn build(self) -> Result<Fsdp2Config> {
|
|
let mesh = self
|
|
.mesh
|
|
.ok_or_else(|| DistributedError::configuration("FSDP2 requires a device mesh"))?;
|
|
|
|
Ok(Fsdp2Config {
|
|
mesh,
|
|
mesh_dim_name: self.mesh_dim_name,
|
|
mp_policy: self.mp_policy,
|
|
reshard_after_forward: self.reshard_after_forward,
|
|
min_shard_size: self.min_shard_size,
|
|
offload_to_cpu: self.offload_to_cpu,
|
|
backward_prefetch: self.backward_prefetch,
|
|
forward_prefetch: self.forward_prefetch,
|
|
limit_all_gathers: self.limit_all_gathers,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Fsdp2Config {
|
|
/// Create a configuration builder.
|
|
pub fn builder() -> Fsdp2ConfigBuilder {
|
|
Fsdp2ConfigBuilder::new()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Sharded Parameter
|
|
// =============================================================================
|
|
|
|
/// A parameter sharded across devices using FSDP2 per-parameter sharding.
|
|
#[derive(Debug)]
|
|
pub struct Fsdp2ShardedParam {
|
|
/// Unique identifier
|
|
id: u64,
|
|
/// Parameter name
|
|
name: String,
|
|
/// The DTensor holding the sharded parameter
|
|
dtensor: DTensor,
|
|
/// Original full shape before sharding
|
|
full_shape: Vec<usize>,
|
|
/// Whether parameter requires gradient
|
|
requires_grad: bool,
|
|
/// Cached unsharded parameter for forward pass
|
|
unsharded_cache: RwLock<Option<Tensor>>,
|
|
/// Gradient (after backward)
|
|
gradient: RwLock<Option<DTensor>>,
|
|
/// Mixed precision policy
|
|
mp_policy: MixedPrecisionPolicy,
|
|
}
|
|
|
|
static PARAM_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
impl Fsdp2ShardedParam {
|
|
/// Create a new sharded parameter from a full tensor.
|
|
pub fn from_tensor(
|
|
name: impl Into<String>,
|
|
tensor: &Tensor,
|
|
config: &Fsdp2Config,
|
|
) -> Result<Self> {
|
|
let name = name.into();
|
|
let full_shape: Vec<usize> = tensor.shape().dims().to_vec();
|
|
let numel: usize = full_shape.iter().product();
|
|
|
|
// Determine if parameter should be sharded
|
|
if numel < config.min_shard_size {
|
|
// Keep replicated for small parameters
|
|
let spec = TensorSpec::new(full_shape.clone())
|
|
.with_placement(0, Placement::Replicate)
|
|
.with_requires_grad(true);
|
|
|
|
let dtensor = DTensor::from_local(tensor.clone(), spec, config.mesh.clone())?;
|
|
|
|
return Ok(Self {
|
|
id: PARAM_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
|
|
name,
|
|
dtensor,
|
|
full_shape,
|
|
requires_grad: true,
|
|
unsharded_cache: RwLock::new(None),
|
|
gradient: RwLock::new(None),
|
|
mp_policy: config.mp_policy.clone(),
|
|
});
|
|
}
|
|
|
|
// Shard along dimension 0 by default (standard FSDP2 strategy)
|
|
let mesh_dim = config
|
|
.mesh
|
|
.get_dimension(&config.mesh_dim_name)
|
|
.map_or(0, |d| d.index);
|
|
|
|
// Calculate local shard shape
|
|
let world_size = config.mesh.dim_size(&config.mesh_dim_name).unwrap_or(1);
|
|
let rank = config.mesh.local_rank();
|
|
|
|
let shard_size = (full_shape[0] + world_size - 1) / world_size;
|
|
let start_idx = rank * shard_size;
|
|
let end_idx = ((rank + 1) * shard_size).min(full_shape[0]);
|
|
let actual_shard_size = end_idx.saturating_sub(start_idx);
|
|
|
|
// Create local shard shape
|
|
let mut local_shape = full_shape.clone();
|
|
local_shape[0] = actual_shard_size;
|
|
|
|
// Create tensor spec for sharded parameter
|
|
let spec = TensorSpec::new(full_shape.clone())
|
|
.with_placement(mesh_dim, Placement::Shard { tensor_dim: 0 })
|
|
.with_requires_grad(true)
|
|
.with_name(name.clone());
|
|
|
|
// Extract local shard data
|
|
let local_shard = Self::extract_shard(tensor, &full_shape, start_idx, end_idx)?;
|
|
|
|
let dtensor = DTensor::from_local(local_shard, spec, config.mesh.clone())?;
|
|
|
|
Ok(Self {
|
|
id: PARAM_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
|
|
name,
|
|
dtensor,
|
|
full_shape,
|
|
requires_grad: true,
|
|
unsharded_cache: RwLock::new(None),
|
|
gradient: RwLock::new(None),
|
|
mp_policy: config.mp_policy.clone(),
|
|
})
|
|
}
|
|
|
|
/// Extract a shard from the full tensor.
|
|
fn extract_shard(
|
|
tensor: &Tensor,
|
|
full_shape: &[usize],
|
|
start_idx: usize,
|
|
end_idx: usize,
|
|
) -> Result<Tensor> {
|
|
let data = tensor
|
|
.data()
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))?;
|
|
let dim0_stride: usize = full_shape[1..].iter().product::<usize>().max(1);
|
|
let shard_elements = (end_idx - start_idx) * dim0_stride;
|
|
|
|
let start_offset = start_idx * dim0_stride;
|
|
let end_offset = start_offset + shard_elements;
|
|
|
|
if end_offset > data.len() {
|
|
// Handle edge case where last shard might be smaller
|
|
let available = data.len().saturating_sub(start_offset);
|
|
let shard_data: Vec<f32> = data[start_offset..start_offset + available].to_vec();
|
|
|
|
let mut shard_shape = full_shape.to_vec();
|
|
shard_shape[0] = end_idx - start_idx;
|
|
|
|
// Pad with zeros if needed
|
|
let expected_size = shard_shape.iter().product::<usize>();
|
|
let mut padded_data = shard_data;
|
|
padded_data.resize(expected_size, 0.0);
|
|
|
|
Tensor::from_data(padded_data, shard_shape, &Device::cpu())
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))
|
|
} else {
|
|
let shard_data: Vec<f32> = data[start_offset..end_offset].to_vec();
|
|
let mut shard_shape = full_shape.to_vec();
|
|
shard_shape[0] = end_idx - start_idx;
|
|
Tensor::from_data(shard_data, shard_shape, &Device::cpu())
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Get the parameter name.
|
|
pub fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
/// Get the full (unsharded) shape.
|
|
pub fn full_shape(&self) -> &[usize] {
|
|
&self.full_shape
|
|
}
|
|
|
|
/// Get the local shard shape.
|
|
pub fn local_shape(&self) -> Vec<usize> {
|
|
self.dtensor.local_shape()
|
|
}
|
|
|
|
/// Check if parameter is sharded.
|
|
pub fn is_sharded(&self) -> bool {
|
|
self.dtensor.spec().is_sharded()
|
|
}
|
|
|
|
/// Get read access to the local shard.
|
|
pub fn local_shard(&self) -> parking_lot::RwLockReadGuard<'_, Tensor> {
|
|
self.dtensor.local_shard()
|
|
}
|
|
|
|
/// Replace the local shard with a new tensor (used by the optimizer step).
|
|
///
|
|
/// The new tensor must have the same shape as the current local shard.
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if the new tensor's shape differs from the current shard shape.
|
|
pub fn update_local_shard(&self, new_tensor: Tensor) -> Result<()> {
|
|
let expected = self.dtensor.local_shape();
|
|
let actual: Vec<usize> = new_tensor.shape().dims().to_vec();
|
|
if actual != expected {
|
|
return Err(DistributedError::tensor(format!(
|
|
"update_local_shard shape mismatch for '{}': expected {:?}, got {:?}",
|
|
self.name, expected, actual
|
|
)));
|
|
}
|
|
*self.dtensor.local_shard_mut() = new_tensor;
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronous all-gather to get the full unsharded parameter.
|
|
///
|
|
/// Uses the sync `ProcessGroup::all_gather` method, which simulates the collective
|
|
/// on the CPU backend and delegates to RNCCL on GPU backends.
|
|
///
|
|
/// The result is cached in `unsharded_cache` for repeated access within a forward
|
|
/// pass; call `clear_cache()` at the end of the forward pass.
|
|
pub fn all_gather(&self, pg: &ProcessGroup) -> Result<Tensor> {
|
|
// Check cache first.
|
|
{
|
|
let cache = self.unsharded_cache.read();
|
|
if let Some(ref cached) = *cache {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
|
|
if !self.is_sharded() {
|
|
return Ok(self.dtensor.local_shard().clone());
|
|
}
|
|
|
|
let local_shard = self.dtensor.local_shard().clone();
|
|
let shards = pg.all_gather(&local_shard)?;
|
|
let full_tensor = Self::concat_tensors(&shards, 0)?;
|
|
|
|
// Trim to original full_shape[0] in case of padding.
|
|
let actual_rows = full_tensor.shape().dims().first().copied().unwrap_or(0);
|
|
let target_rows = self.full_shape[0];
|
|
let full_tensor = if actual_rows > target_rows {
|
|
Self::extract_shard(
|
|
&full_tensor,
|
|
&full_tensor.shape().dims().to_vec(),
|
|
0,
|
|
target_rows,
|
|
)?
|
|
} else {
|
|
full_tensor
|
|
};
|
|
|
|
{
|
|
let mut cache = self.unsharded_cache.write();
|
|
*cache = Some(full_tensor.clone());
|
|
}
|
|
|
|
Ok(full_tensor)
|
|
}
|
|
|
|
/// Synchronous reduce-scatter of a gradient tensor into the local shard's gradient slot.
|
|
///
|
|
/// Stores the resulting sharded gradient into `self.gradient` as a `DTensor`.
|
|
pub fn reduce_scatter_gradient(&self, grad: DTensor, pg: &ProcessGroup) -> Result<()> {
|
|
let grad_tensor = grad.local_shard().clone();
|
|
let sharded_grad = if self.is_sharded() {
|
|
pg.reduce_scatter(&grad_tensor, ReduceOp::Sum)?
|
|
} else {
|
|
// For replicated params just store the raw gradient; the caller's optimizer
|
|
// divides by world_size if needed.
|
|
grad_tensor
|
|
};
|
|
|
|
// Wrap the reduced shard back in a DTensor using the same spec/mesh as the param.
|
|
let shard_shape: Vec<usize> = sharded_grad.shape().dims().to_vec();
|
|
let grad_spec =
|
|
TensorSpec::new(shard_shape.clone()).with_placement(0, Placement::Replicate);
|
|
let grad_dtensor =
|
|
DTensor::from_local(sharded_grad, grad_spec, self.dtensor.mesh().clone())?;
|
|
|
|
let mut gradient = self.gradient.write();
|
|
*gradient = Some(grad_dtensor);
|
|
Ok(())
|
|
}
|
|
|
|
/// All-gather to get the full unsharded parameter.
|
|
///
|
|
/// This is called before forward pass to reconstruct the full parameter.
|
|
pub async fn unshard(&self, pg: &ProcessGroup) -> Result<Tensor> {
|
|
// Check cache first
|
|
{
|
|
let cache = self.unsharded_cache.read();
|
|
if let Some(ref cached) = *cache {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
|
|
if !self.is_sharded() {
|
|
return Ok(self.dtensor.local_shard().clone());
|
|
}
|
|
|
|
// Perform AllGather to reconstruct full tensor
|
|
let local_shard = self.dtensor.local_shard().clone();
|
|
let gathered = pg.allgather(&local_shard).await?;
|
|
|
|
let full_tensor = match gathered {
|
|
crate::comm::AllGatherOutput::Tensor(t) => t,
|
|
crate::comm::AllGatherOutput::TensorList(tensors) => {
|
|
// Concatenate tensors along dim 0
|
|
Self::concat_tensors(&tensors, 0)?
|
|
}
|
|
};
|
|
|
|
// Cache the unsharded parameter
|
|
{
|
|
let mut cache = self.unsharded_cache.write();
|
|
*cache = Some(full_tensor.clone());
|
|
}
|
|
|
|
Ok(full_tensor)
|
|
}
|
|
|
|
/// Clear the unsharded cache (called after forward pass).
|
|
pub fn clear_cache(&self) {
|
|
let mut cache = self.unsharded_cache.write();
|
|
*cache = None;
|
|
}
|
|
|
|
/// Reduce-scatter gradients back to sharded form.
|
|
pub async fn reshard_gradient(&self, grad: &Tensor, pg: &ProcessGroup) -> Result<Tensor> {
|
|
if !self.is_sharded() {
|
|
// Just average the gradient
|
|
let mut grad_copy = grad.clone();
|
|
pg.allreduce(&mut grad_copy, ReduceOp::Sum).await?;
|
|
let world_size = pg.world_size() as f32;
|
|
return grad_copy
|
|
.div_scalar(world_size)
|
|
.map_err(|e| DistributedError::tensor(e.to_string()));
|
|
}
|
|
|
|
// ReduceScatter to get sharded gradient
|
|
pg.reduce_scatter(grad, ReduceOp::Sum)
|
|
}
|
|
|
|
/// Set the gradient DTensor.
|
|
pub fn set_gradient(&self, grad: DTensor) {
|
|
let mut gradient = self.gradient.write();
|
|
*gradient = Some(grad);
|
|
}
|
|
|
|
/// Get the gradient if available.
|
|
pub fn gradient(&self) -> Option<DTensor> {
|
|
self.gradient.read().clone()
|
|
}
|
|
|
|
/// Clear the gradient.
|
|
pub fn zero_grad(&self) {
|
|
let mut gradient = self.gradient.write();
|
|
*gradient = None;
|
|
self.dtensor.zero_grad();
|
|
}
|
|
|
|
/// Concatenate tensors along a dimension.
|
|
fn concat_tensors(tensors: &[Tensor], _dim: usize) -> Result<Tensor> {
|
|
if tensors.is_empty() {
|
|
return Err(DistributedError::tensor(
|
|
"Cannot concatenate empty tensor list",
|
|
));
|
|
}
|
|
|
|
// For simplicity, assume dim=0 concatenation
|
|
let first_shape = tensors[0].shape().dims().to_vec();
|
|
let total_dim0: usize = tensors
|
|
.iter()
|
|
.map(|t| t.shape().dims().first().copied().unwrap_or(0))
|
|
.sum();
|
|
|
|
let mut new_shape = first_shape;
|
|
new_shape[0] = total_dim0;
|
|
|
|
let mut combined_data = Vec::new();
|
|
for tensor in tensors {
|
|
let data = tensor
|
|
.data()
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))?;
|
|
combined_data.extend(data);
|
|
}
|
|
|
|
Tensor::from_data(combined_data, new_shape, &Device::cpu())
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// FSDP2 Module Wrapper
|
|
// =============================================================================
|
|
|
|
/// FSDP2 wrapper for a module's parameters.
|
|
///
|
|
/// This wraps a set of parameters with FSDP2 sharding and provides
|
|
/// methods for forward/backward with automatic communication.
|
|
#[derive(Debug)]
|
|
pub struct Fsdp2Module {
|
|
/// Module name
|
|
name: String,
|
|
/// Configuration
|
|
config: Fsdp2Config,
|
|
/// Sharded parameters
|
|
params: Vec<Fsdp2ShardedParam>,
|
|
/// Process group for communication
|
|
process_group: Option<ProcessGroup>,
|
|
/// Training mode
|
|
training: bool,
|
|
/// Forward pass counter
|
|
forward_count: AtomicU64,
|
|
}
|
|
|
|
impl Fsdp2Module {
|
|
/// Create a new FSDP2 module wrapper.
|
|
pub fn new(
|
|
name: impl Into<String>,
|
|
config: Fsdp2Config,
|
|
process_group: Option<ProcessGroup>,
|
|
) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
config,
|
|
params: Vec::new(),
|
|
process_group,
|
|
training: true,
|
|
forward_count: AtomicU64::new(0),
|
|
}
|
|
}
|
|
|
|
/// Add a parameter to be sharded.
|
|
pub fn add_param(&mut self, name: impl Into<String>, tensor: &Tensor) -> Result<()> {
|
|
let param = Fsdp2ShardedParam::from_tensor(name, tensor, &self.config)?;
|
|
self.params.push(param);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get sharded parameters.
|
|
pub fn params(&self) -> &[Fsdp2ShardedParam] {
|
|
&self.params
|
|
}
|
|
|
|
/// Get mutable access to parameters.
|
|
pub fn params_mut(&mut self) -> &mut [Fsdp2ShardedParam] {
|
|
&mut self.params
|
|
}
|
|
|
|
/// Set training mode.
|
|
pub fn train(&mut self) {
|
|
self.training = true;
|
|
}
|
|
|
|
/// Set evaluation mode.
|
|
pub fn eval(&mut self) {
|
|
self.training = false;
|
|
}
|
|
|
|
/// Prepare for forward pass by unsharding parameters.
|
|
pub async fn pre_forward(&self) -> Result<Vec<Tensor>> {
|
|
let pg = self
|
|
.process_group
|
|
.as_ref()
|
|
.ok_or_else(|| DistributedError::configuration("FSDP2 requires a process group"))?;
|
|
|
|
let mut unsharded = Vec::with_capacity(self.params.len());
|
|
for param in &self.params {
|
|
let full_param = param.unshard(pg).await?;
|
|
unsharded.push(full_param);
|
|
}
|
|
|
|
self.forward_count.fetch_add(1, Ordering::SeqCst);
|
|
Ok(unsharded)
|
|
}
|
|
|
|
/// Cleanup after forward pass.
|
|
pub fn post_forward(&self) {
|
|
if self.config.reshard_after_forward && self.training {
|
|
for param in &self.params {
|
|
param.clear_cache();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reshard gradients after backward pass.
|
|
pub async fn post_backward(&self, gradients: &[Tensor]) -> Result<Vec<Tensor>> {
|
|
let pg = self
|
|
.process_group
|
|
.as_ref()
|
|
.ok_or_else(|| DistributedError::configuration("FSDP2 requires a process group"))?;
|
|
|
|
let mut sharded_grads = Vec::with_capacity(gradients.len());
|
|
for (param, grad) in self.params.iter().zip(gradients.iter()) {
|
|
let sharded_grad = param.reshard_gradient(grad, pg).await?;
|
|
sharded_grads.push(sharded_grad);
|
|
}
|
|
|
|
Ok(sharded_grads)
|
|
}
|
|
|
|
/// Zero all gradients.
|
|
pub fn zero_grad(&self) {
|
|
for param in &self.params {
|
|
param.zero_grad();
|
|
}
|
|
}
|
|
|
|
/// Get the sharded state dict (no communication needed).
|
|
pub fn sharded_state_dict(&self) -> HashMap<String, Tensor> {
|
|
self.params
|
|
.iter()
|
|
.map(|p| (p.name().to_string(), p.local_shard().clone()))
|
|
.collect()
|
|
}
|
|
|
|
/// Get memory statistics.
|
|
///
|
|
/// # Memory Reduction Formula
|
|
///
|
|
/// For a parameter tensor with `N` elements and `world_size` ranks:
|
|
///
|
|
/// ```text
|
|
/// local_param_bytes = ceil(N / world_size) * sizeof(dtype)
|
|
/// full_param_bytes = N * sizeof(dtype)
|
|
/// memory_reduction_ratio = full_param_bytes / local_param_bytes ≈ world_size
|
|
/// ```
|
|
///
|
|
/// Small parameters below `min_shard_size` are replicated, so their
|
|
/// `local_param_bytes == full_param_bytes` and they contribute a ratio of 1.0.
|
|
pub fn memory_stats(&self) -> Fsdp2MemoryStats {
|
|
let mut stats = Fsdp2MemoryStats::default();
|
|
|
|
for param in &self.params {
|
|
let full_numel: usize = param.full_shape().iter().product();
|
|
let local_numel: usize = param.local_shape().iter().product();
|
|
|
|
stats.total_param_memory += full_numel * 4; // f32 = 4 bytes
|
|
stats.sharded_param_memory += local_numel * 4;
|
|
stats.num_params += 1;
|
|
if param.is_sharded() {
|
|
stats.num_sharded_params += 1;
|
|
}
|
|
}
|
|
|
|
stats.memory_saved = stats
|
|
.total_param_memory
|
|
.saturating_sub(stats.sharded_param_memory);
|
|
if stats.total_param_memory > 0 {
|
|
stats.reduction_percent =
|
|
(stats.memory_saved as f64 / stats.total_param_memory as f64) * 100.0;
|
|
}
|
|
|
|
// Populate the additional fields used by the hook wiring layer.
|
|
stats.local_param_bytes = stats.sharded_param_memory;
|
|
stats.full_param_bytes = stats.total_param_memory;
|
|
stats.memory_reduction_ratio = if stats.local_param_bytes > 0 {
|
|
stats.full_param_bytes as f32 / stats.local_param_bytes as f32
|
|
} else {
|
|
1.0
|
|
};
|
|
|
|
stats
|
|
}
|
|
|
|
// =========================================================================
|
|
// Forward / Backward Hook Methods
|
|
// =========================================================================
|
|
|
|
/// Synchronous pre-forward hook: all-gathers every sharded parameter so the
|
|
/// full weight is available in the `unsharded_cache` during the forward pass.
|
|
///
|
|
/// When no `ProcessGroup` is attached (e.g. single-process testing) the local
|
|
/// shard is used as-is, which is correct for world_size == 1.
|
|
pub fn pre_forward_hook(&mut self) -> Result<()> {
|
|
match &self.process_group {
|
|
Some(pg) => {
|
|
let pg = pg.clone();
|
|
for param in &mut self.params {
|
|
param.all_gather(&pg)?;
|
|
}
|
|
}
|
|
None => {
|
|
// Single-process: populate the cache with the local shard directly.
|
|
for param in &mut self.params {
|
|
let shard = param.local_shard().clone();
|
|
*param.unsharded_cache.write() = Some(shard);
|
|
}
|
|
}
|
|
}
|
|
self.forward_count
|
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronous post-backward hook: reduce-scatters each parameter's stored
|
|
/// gradient, zeros the gradient, and (if configured) clears the unsharded cache.
|
|
///
|
|
/// Each parameter's gradient must have been stored via `set_gradient()` by
|
|
/// the autograd engine before this is called. Parameters whose gradient is
|
|
/// `None` are skipped.
|
|
pub fn post_backward_hook(&mut self) -> Result<()> {
|
|
match &self.process_group {
|
|
Some(pg) => {
|
|
let pg = pg.clone();
|
|
for param in &mut self.params {
|
|
if let Some(grad) = param.gradient() {
|
|
param.reduce_scatter_gradient(grad, &pg)?;
|
|
}
|
|
param.zero_grad();
|
|
if self.config.reshard_after_forward {
|
|
param.clear_cache();
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
// Single-process: just zero grads (no communication needed).
|
|
for param in &mut self.params {
|
|
param.zero_grad();
|
|
if self.config.reshard_after_forward {
|
|
param.clear_cache();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply an optimizer update to each parameter's local shard.
|
|
///
|
|
/// `optimizer_fn` receives:
|
|
/// - `name` — the parameter name
|
|
/// - `shard` — the current local shard (read-only clone)
|
|
/// - `grad` — the reduce-scattered gradient, if present
|
|
///
|
|
/// It returns the new local shard. `update_local_shard` is called with the
|
|
/// result, which validates the shape before replacing the shard.
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// module.step(|_name, shard, grad| {
|
|
/// let lr = 1e-3_f32;
|
|
/// if let Some(g) = grad {
|
|
/// let g_tensor = g.local_shard().clone();
|
|
/// Ok(shard.sub(&g_tensor.mul_scalar(lr)?)?)
|
|
/// } else {
|
|
/// Ok(shard.clone())
|
|
/// }
|
|
/// })?;
|
|
/// ```
|
|
pub fn step(
|
|
&mut self,
|
|
mut optimizer_fn: impl FnMut(&str, &Tensor, Option<&DTensor>) -> Result<Tensor>,
|
|
) -> Result<()> {
|
|
for param in &mut self.params {
|
|
let shard = param.local_shard().clone();
|
|
let grad = param.gradient();
|
|
let new_shard = optimizer_fn(param.name(), &shard, grad.as_ref())?;
|
|
param.update_local_shard(new_shard)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Memory statistics for FSDP2.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Fsdp2MemoryStats {
|
|
/// Total parameter memory (bytes) if not sharded
|
|
pub total_param_memory: usize,
|
|
/// Sharded parameter memory (bytes)
|
|
pub sharded_param_memory: usize,
|
|
/// Memory saved (bytes)
|
|
pub memory_saved: usize,
|
|
/// Reduction percentage
|
|
pub reduction_percent: f64,
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Additional fields requested by the FSDP2 hook wiring layer
|
|
// -------------------------------------------------------------------------
|
|
/// Number of parameters registered with this module
|
|
pub num_params: usize,
|
|
/// Number of parameters that are actually sharded (not replicated)
|
|
pub num_sharded_params: usize,
|
|
/// Sum of local shard sizes in bytes (alias for `sharded_param_memory`)
|
|
pub local_param_bytes: usize,
|
|
/// Estimated full parameter size in bytes (alias for `total_param_memory`)
|
|
pub full_param_bytes: usize,
|
|
/// Ratio of full to local parameter bytes (`full_param_bytes / local_param_bytes`).
|
|
///
|
|
/// Values greater than 1.0 confirm memory reduction. When `local_param_bytes`
|
|
/// is 0 (no params) this field is 1.0.
|
|
pub memory_reduction_ratio: f32,
|
|
}
|
|
|
|
// =============================================================================
|
|
// fully_shard Function
|
|
// =============================================================================
|
|
|
|
/// Shard a module's parameters using FSDP2.
|
|
///
|
|
/// This is the primary API for applying FSDP2 to a module. It converts
|
|
/// all parameters in the module to sharded DTensors.
|
|
///
|
|
/// # Arguments
|
|
/// * `module` - The FSDP2 module wrapper to shard
|
|
/// * `params` - Iterator of (name, tensor) pairs for parameters
|
|
///
|
|
/// # Returns
|
|
/// Result indicating success or failure
|
|
pub fn fully_shard<'a, I>(module: &mut Fsdp2Module, params: I) -> Result<()>
|
|
where
|
|
I: IntoIterator<Item = (&'a str, &'a Tensor)>,
|
|
{
|
|
for (name, tensor) in params {
|
|
module.add_param(name, tensor)?;
|
|
}
|
|
|
|
tracing::info!(
|
|
"FSDP2: Sharded {} parameters in module '{}'",
|
|
module.params().len(),
|
|
module.name
|
|
);
|
|
|
|
let stats = module.memory_stats();
|
|
tracing::info!(
|
|
"FSDP2 memory: {:.2}MB -> {:.2}MB ({:.1}% reduction)",
|
|
stats.total_param_memory as f64 / (1024.0 * 1024.0),
|
|
stats.sharded_param_memory as f64 / (1024.0 * 1024.0),
|
|
stats.reduction_percent
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// =============================================================================
|
|
// fully_shard_new — factory variant of fully_shard
|
|
// =============================================================================
|
|
|
|
/// Create an [`Fsdp2Module`] from a list of `(name, tensor)` pairs.
|
|
///
|
|
/// This is the *factory* form of the FSDP2 entry-point. It constructs the
|
|
/// module from `config`, shards every supplied parameter, and returns the ready
|
|
/// wrapper. No `ProcessGroup` is attached by this function; attach one later
|
|
/// via `Fsdp2Module::new` or reconstruct the module with one.
|
|
///
|
|
/// For the *mutating* form that takes an already-created module see [`fully_shard`].
|
|
///
|
|
/// # Arguments
|
|
/// * `params` — `(name, tensor)` pairs. Each tensor is sharded according to
|
|
/// `config`.
|
|
/// * `config` — FSDP2 configuration (mesh, precision policy, etc.)
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// let config = Fsdp2Config::builder().mesh(mesh).build()?;
|
|
/// let module = make_fsdp2_module(
|
|
/// vec![("weight", weight_tensor), ("bias", bias_tensor)],
|
|
/// config,
|
|
/// )?;
|
|
/// ```
|
|
pub fn make_fsdp2_module(
|
|
params: Vec<(impl Into<String>, Tensor)>,
|
|
config: Fsdp2Config,
|
|
) -> Result<Fsdp2Module> {
|
|
let mut module = Fsdp2Module::new("fsdp2_module", config, None);
|
|
for (name, tensor) in params {
|
|
module.add_param(name, &tensor)?;
|
|
}
|
|
|
|
tracing::info!(
|
|
"FSDP2 make_fsdp2_module: created module with {} parameters",
|
|
module.params().len()
|
|
);
|
|
|
|
let stats = module.memory_stats();
|
|
tracing::info!(
|
|
"FSDP2 memory: {:.2}MB -> {:.2}MB (ratio {:.2}x)",
|
|
stats.full_param_bytes as f64 / (1024.0 * 1024.0),
|
|
stats.local_param_bytes as f64 / (1024.0 * 1024.0),
|
|
stats.memory_reduction_ratio,
|
|
);
|
|
|
|
Ok(module)
|
|
}
|
|
|
|
// =============================================================================
|
|
// FSDP2 State Dict Utilities
|
|
// =============================================================================
|
|
|
|
/// Get a sharded state dict from multiple FSDP2 modules.
|
|
///
|
|
/// This returns local shards without any communication, making it
|
|
/// efficient for checkpointing.
|
|
pub fn get_sharded_state_dict(modules: &[&Fsdp2Module]) -> HashMap<String, Tensor> {
|
|
let mut state_dict = HashMap::new();
|
|
|
|
for module in modules {
|
|
for (key, value) in module.sharded_state_dict() {
|
|
let full_key = format!("{}.{}", module.name, key);
|
|
state_dict.insert(full_key, value);
|
|
}
|
|
}
|
|
|
|
state_dict
|
|
}
|
|
|
|
/// Load a sharded state dict into FSDP2 modules.
|
|
///
|
|
/// No communication needed - each rank loads its own shards.
|
|
pub fn load_sharded_state_dict(
|
|
modules: &mut [&mut Fsdp2Module],
|
|
state_dict: &HashMap<String, Tensor>,
|
|
) -> Result<()> {
|
|
for module in modules {
|
|
let module_name = module.name.clone();
|
|
for param in module.params_mut() {
|
|
let full_key = format!("{}.{}", module_name, param.name());
|
|
if let Some(shard) = state_dict.get(&full_key) {
|
|
// Validate shape matches
|
|
let expected_shape = param.local_shape();
|
|
let actual_shape: Vec<usize> = shard.shape().dims().to_vec();
|
|
if expected_shape != actual_shape {
|
|
return Err(DistributedError::tensor(format!(
|
|
"Shape mismatch for {}: expected {:?}, got {:?}",
|
|
full_key, expected_shape, actual_shape
|
|
)));
|
|
}
|
|
// Load shard into parameter
|
|
// Note: This would update the underlying DTensor storage
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_mixed_precision_policy_bf16() {
|
|
let policy = MixedPrecisionPolicy::bf16();
|
|
assert_eq!(policy.param_dtype, DType::BF16);
|
|
assert_eq!(policy.reduce_dtype, DType::F32);
|
|
assert!(policy.cast_inputs);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixed_precision_policy_fp16() {
|
|
let policy = MixedPrecisionPolicy::fp16();
|
|
assert_eq!(policy.param_dtype, DType::F16);
|
|
assert_eq!(policy.reduce_dtype, DType::F32);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fsdp2_config_builder() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(4, "dp"));
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.mp_policy(MixedPrecisionPolicy::bf16())
|
|
.reshard_after_forward(true)
|
|
.min_shard_size(512)
|
|
.build()
|
|
.unwrap();
|
|
|
|
assert!(config.reshard_after_forward);
|
|
assert_eq!(config.min_shard_size, 512);
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_prefetch_default() {
|
|
let prefetch = BackwardPrefetch::default();
|
|
assert_eq!(prefetch, BackwardPrefetch::BackwardPre);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sharded_param_small_tensor() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(2, "dp"));
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(1000) // Small tensors won't be sharded
|
|
.build()
|
|
.unwrap();
|
|
|
|
let tensor = Tensor::zeros(&[10, 10], &Device::cpu()).unwrap();
|
|
let param = Fsdp2ShardedParam::from_tensor("small_param", &tensor, &config).unwrap();
|
|
|
|
// Small parameter should be replicated, not sharded
|
|
assert!(!param.is_sharded());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sharded_param_large_tensor() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(2, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let tensor = Tensor::zeros(&[1024, 512], &Device::cpu()).unwrap();
|
|
let param = Fsdp2ShardedParam::from_tensor("large_param", &tensor, &config).unwrap();
|
|
|
|
// Large parameter should be sharded
|
|
assert!(param.is_sharded());
|
|
assert_eq!(param.full_shape(), &[1024, 512]);
|
|
assert_eq!(param.local_shape()[0], 512); // 1024 / 2 = 512
|
|
}
|
|
|
|
#[test]
|
|
fn test_fsdp2_module_memory_stats() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(4, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("test_module", config, None);
|
|
|
|
let weight = Tensor::zeros(&[4096, 4096], &Device::cpu()).unwrap();
|
|
module.add_param("weight", &weight).unwrap();
|
|
|
|
let stats = module.memory_stats();
|
|
assert!(stats.reduction_percent > 50.0); // Should reduce by ~75% with 4 ranks
|
|
}
|
|
|
|
#[test]
|
|
fn test_fully_shard() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(2, "dp"));
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("linear", config, None);
|
|
|
|
let weight = Tensor::zeros(&[1024, 512], &Device::cpu()).unwrap();
|
|
let bias = Tensor::zeros(&[512], &Device::cpu()).unwrap();
|
|
|
|
let params = vec![("weight", &weight), ("bias", &bias)];
|
|
fully_shard(&mut module, params.into_iter()).unwrap();
|
|
|
|
assert_eq!(module.params().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sharded_state_dict() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(2, "dp"));
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(1000000) // High threshold to keep things replicated
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("layer", config, None);
|
|
|
|
let weight = Tensor::zeros(&[10, 10], &Device::cpu()).unwrap();
|
|
module.add_param("weight", &weight).unwrap();
|
|
|
|
let state_dict = module.sharded_state_dict();
|
|
assert!(state_dict.contains_key("weight"));
|
|
}
|
|
|
|
// =========================================================================
|
|
// New tests: FSDP2 hook wiring
|
|
// =========================================================================
|
|
|
|
/// `pre_forward_hook` must populate the unsharded cache for every parameter.
|
|
#[test]
|
|
fn test_pre_forward_gathers_all_params() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(1, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("m", config, None);
|
|
|
|
let w = Tensor::ones(&[128, 64], &Device::cpu()).unwrap();
|
|
let b = Tensor::ones(&[64], &Device::cpu()).unwrap();
|
|
module.add_param("weight", &w).unwrap();
|
|
module.add_param("bias", &b).unwrap();
|
|
|
|
module.pre_forward_hook().unwrap();
|
|
|
|
// After pre_forward_hook every param should have a populated cache.
|
|
for param in module.params() {
|
|
let cache = param.unsharded_cache.read();
|
|
assert!(
|
|
cache.is_some(),
|
|
"param '{}' cache should be populated after pre_forward_hook",
|
|
param.name()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `post_backward_hook` must clear all gradients after the backward step.
|
|
#[test]
|
|
fn test_post_backward_clears_gradients() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(1, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("m", config, None);
|
|
|
|
let w = Tensor::ones(&[64, 64], &Device::cpu()).unwrap();
|
|
module.add_param("weight", &w).unwrap();
|
|
|
|
// Manually inject a gradient so post_backward has something to clear.
|
|
{
|
|
let param = &module.params()[0];
|
|
let local_shape = param.local_shape();
|
|
let grad_data = vec![0.1_f32; local_shape.iter().product()];
|
|
let grad_tensor =
|
|
Tensor::from_data(grad_data, local_shape.clone(), &Device::cpu()).unwrap();
|
|
let grad_spec =
|
|
TensorSpec::new(local_shape.clone()).with_placement(0, Placement::Replicate);
|
|
let grad_dtensor =
|
|
DTensor::from_local(grad_tensor, grad_spec, param.dtensor.mesh().clone()).unwrap();
|
|
param.set_gradient(grad_dtensor);
|
|
}
|
|
|
|
// Verify gradient was set.
|
|
assert!(module.params()[0].gradient().is_some());
|
|
|
|
module.post_backward_hook().unwrap();
|
|
|
|
// All gradients must be cleared.
|
|
for param in module.params() {
|
|
assert!(
|
|
param.gradient().is_none(),
|
|
"param '{}' gradient should be None after post_backward_hook",
|
|
param.name()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `memory_stats()` must report a ratio > 1.0 when world_size > 1.
|
|
#[test]
|
|
fn test_memory_stats_reduction_ratio() {
|
|
let world_size = 4_usize;
|
|
let mesh = Arc::new(DeviceMesh::new_simple(world_size, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100) // Force sharding
|
|
.build()
|
|
.unwrap();
|
|
|
|
let mut module = Fsdp2Module::new("m", config, None);
|
|
|
|
// 4096 elements — each shard is 1024 elements.
|
|
let w = Tensor::ones(&[4096, 1], &Device::cpu()).unwrap();
|
|
module.add_param("weight", &w).unwrap();
|
|
|
|
let stats = module.memory_stats();
|
|
assert_eq!(stats.num_params, 1);
|
|
assert_eq!(stats.num_sharded_params, 1);
|
|
assert!(
|
|
stats.memory_reduction_ratio > 1.0,
|
|
"expected reduction ratio > 1.0, got {}",
|
|
stats.memory_reduction_ratio
|
|
);
|
|
// With 4 ranks the local shard is 1/4 of full; ratio ≈ 4.0.
|
|
assert!(
|
|
stats.memory_reduction_ratio >= 3.5,
|
|
"expected ratio ≈ 4.0, got {}",
|
|
stats.memory_reduction_ratio
|
|
);
|
|
assert!(stats.local_param_bytes < stats.full_param_bytes);
|
|
}
|
|
|
|
/// `update_local_shard` must replace the shard and reject mismatched shapes.
|
|
#[test]
|
|
fn test_update_local_shard() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(1, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(1_000_000) // Keep replicated (single rank, any size)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let w = Tensor::zeros(&[8, 4], &Device::cpu()).unwrap();
|
|
let param = Fsdp2ShardedParam::from_tensor("w", &w, &config).unwrap();
|
|
|
|
// Valid replacement — same shape, different data.
|
|
let new_data = vec![1.0_f32; 32];
|
|
let new_tensor = Tensor::from_data(new_data, vec![8, 4], &Device::cpu()).unwrap();
|
|
param.update_local_shard(new_tensor).unwrap();
|
|
|
|
let shard_data = param.local_shard().data().unwrap();
|
|
assert!(
|
|
shard_data.iter().all(|&v| v == 1.0),
|
|
"shard should contain all 1.0 after update"
|
|
);
|
|
|
|
// Wrong shape must be rejected.
|
|
let wrong = Tensor::zeros(&[4, 8], &Device::cpu()).unwrap();
|
|
assert!(
|
|
param.update_local_shard(wrong).is_err(),
|
|
"update_local_shard should fail when shape mismatches"
|
|
);
|
|
}
|
|
|
|
/// `make_fsdp2_module` must create a module with all params sharded.
|
|
#[test]
|
|
fn test_fully_shard_creates_sharded_params() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(2, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(100)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let weight = Tensor::ones(&[512, 256], &Device::cpu()).unwrap();
|
|
let bias = Tensor::ones(&[256], &Device::cpu()).unwrap();
|
|
|
|
let module = make_fsdp2_module(vec![("weight", weight), ("bias", bias)], config).unwrap();
|
|
|
|
assert_eq!(module.params().len(), 2, "module should have 2 parameters");
|
|
// weight (512*256 = 131072 elements) is above min_shard_size and must be sharded.
|
|
let weight_param = module
|
|
.params()
|
|
.iter()
|
|
.find(|p| p.name() == "weight")
|
|
.unwrap();
|
|
assert!(weight_param.is_sharded(), "weight should be sharded");
|
|
}
|
|
|
|
/// End-to-end training step: pre_forward → simulate forward → post_backward → step.
|
|
#[test]
|
|
fn test_fsdp2_training_step() {
|
|
let mesh = Arc::new(DeviceMesh::new_simple(1, "dp"));
|
|
mesh.set_local_rank(0);
|
|
|
|
let config = Fsdp2Config::builder()
|
|
.mesh(mesh)
|
|
.min_shard_size(1_000_000) // single rank — keep replicated for simplicity
|
|
.reshard_after_forward(true)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let w1 = Tensor::ones(&[16, 8], &Device::cpu()).unwrap();
|
|
let w2 = Tensor::ones(&[8, 4], &Device::cpu()).unwrap();
|
|
|
|
let mut module = make_fsdp2_module(vec![("w1", w1), ("w2", w2)], config).unwrap();
|
|
|
|
// 1. Pre-forward hook: populates unsharded cache.
|
|
module.pre_forward_hook().unwrap();
|
|
|
|
// 2. Simulate a forward pass: verify params are accessible.
|
|
for param in module.params() {
|
|
let cache = param.unsharded_cache.read();
|
|
assert!(
|
|
cache.is_some(),
|
|
"param cache must be populated during forward"
|
|
);
|
|
}
|
|
|
|
// 3. Simulate autograd: inject constant-1 gradients on each param.
|
|
for param in module.params() {
|
|
let local_shape = param.local_shape();
|
|
let numel: usize = local_shape.iter().product();
|
|
let grad_data = vec![1.0_f32; numel];
|
|
let grad_tensor =
|
|
Tensor::from_data(grad_data, local_shape.clone(), &Device::cpu()).unwrap();
|
|
let grad_spec =
|
|
TensorSpec::new(local_shape.clone()).with_placement(0, Placement::Replicate);
|
|
let grad_dtensor =
|
|
DTensor::from_local(grad_tensor, grad_spec, param.dtensor.mesh().clone()).unwrap();
|
|
param.set_gradient(grad_dtensor);
|
|
}
|
|
|
|
// 4. Post-backward hook: reduce-scatter grads and clear them.
|
|
module.post_backward_hook().unwrap();
|
|
|
|
// After post_backward, gradients should be cleared (hook calls zero_grad).
|
|
for param in module.params() {
|
|
assert!(
|
|
param.gradient().is_none(),
|
|
"gradient should be None after post_backward_hook"
|
|
);
|
|
}
|
|
|
|
// 5. Optimizer step: subtract lr * grad (here grad is already None so no-op).
|
|
// We use a constant update to verify the shard changes.
|
|
let lr = 0.1_f32;
|
|
module
|
|
.step(|_name, shard, _grad| {
|
|
// Subtract a constant to prove the update runs.
|
|
shard
|
|
.sub_scalar(lr)
|
|
.map_err(|e| DistributedError::tensor(e.to_string()))
|
|
})
|
|
.unwrap();
|
|
|
|
// 6. Verify that shards are non-zero and were updated.
|
|
for param in module.params() {
|
|
let data = param.local_shard().data().unwrap();
|
|
// Started at 1.0, subtracted 0.1 → expect 0.9
|
|
assert!(
|
|
data.iter().all(|&v| (v - 0.9).abs() < 1e-5),
|
|
"param '{}' shard should have been updated to 0.9, got: {:?}",
|
|
param.name(),
|
|
&data[..data.len().min(4)]
|
|
);
|
|
}
|
|
}
|
|
}
|