Files
rustytorch/crates/training/rtx-distributed/src/fsdp2.rs
T
osobhandClaude Sonnet 5 4aaa36a57a style: cargo fmt --workspace (whitespace/wrapping only, no semantic change)
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]>
2026-08-10 07:09:36 -07:00

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)]
);
}
}
}