Skip to main content

ferrotensor_trainer/
distributed.rs

1//! Multi-rank gradient averaging for Tier-2 training.
2//!
3//! Each process/rank:
4//! 1. writes local gradients into a shared `sync_dir`
5//! 2. rank 0 averages them and writes the result
6//! 3. every rank loads the averaged gradients before `optimizer.step`
7//!
8//! This is slower than GPU collectives, but it is a working Python-free
9//! multi-process path without pulling in MPI/NCCL.
10
11use ferrotensor_backend::{UnifiedTensor, WgpuContext};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15
16/// Settings for averaging gradients across processes through files in a
17/// shared directory (a prototype for multi-process data parallelism).
18#[derive(Clone, Debug)]
19pub struct FileGradSyncConfig {
20    /// Number of processes.
21    pub world_size: usize,
22    /// This process's index in `0..world_size`.
23    pub rank: usize,
24    /// Directory every rank can read and write.
25    pub sync_dir: PathBuf,
26    /// How long to wait for the other ranks before failing a step.
27    pub max_wait: Duration,
28}
29
30impl FileGradSyncConfig {
31    /// Validate `rank < world_size` and build a config (10-minute wait).
32    pub fn new(
33        world_size: usize,
34        rank: usize,
35        sync_dir: impl Into<PathBuf>,
36    ) -> Result<Self, String> {
37        if world_size == 0 {
38            return Err("world_size must be >= 1".into());
39        }
40        if rank >= world_size {
41            return Err(format!(
42                "rank {rank} is out of range for world_size {world_size}"
43            ));
44        }
45        Ok(Self {
46            world_size,
47            rank,
48            sync_dir: sync_dir.into(),
49            max_wait: Duration::from_secs(600),
50        })
51    }
52
53    /// Override how long to wait for other ranks.
54    pub fn with_max_wait(mut self, max_wait: Duration) -> Self {
55        self.max_wait = max_wait;
56        self
57    }
58
59    /// Whether this is rank 0 (which averages and writes results).
60    pub fn is_root(&self) -> bool {
61        self.rank == 0
62    }
63
64    /// Whether there is more than one process.
65    pub fn enabled(&self) -> bool {
66        self.world_size > 1
67    }
68}
69
70fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
71    let parent = path.parent().unwrap_or_else(|| Path::new("."));
72    fs::create_dir_all(parent)?;
73    let tmp = parent.join(format!(
74        ".{}.tmp.{}",
75        path.file_name().and_then(|n| n.to_str()).unwrap_or("grad"),
76        std::process::id()
77    ));
78    fs::write(&tmp, bytes)?;
79    fs::rename(&tmp, path)?;
80    Ok(())
81}
82
83fn wait_for_path(path: &Path, max_wait: Duration) -> bool {
84    let start = Instant::now();
85    while start.elapsed() < max_wait {
86        if path.exists() {
87            return true;
88        }
89        std::thread::sleep(Duration::from_millis(25));
90    }
91    path.exists()
92}
93
94fn rank_grad_path(step_dir: &Path, rank: usize, param_idx: usize) -> PathBuf {
95    step_dir.join(format!("rank{rank}_param{param_idx}.bin"))
96}
97
98fn avg_grad_path(step_dir: &Path, param_idx: usize) -> PathBuf {
99    step_dir.join(format!("avg_param{param_idx}.bin"))
100}
101
102fn step_dir(cfg: &FileGradSyncConfig, step: usize) -> PathBuf {
103    cfg.sync_dir.join(format!("step_{step}"))
104}
105
106/// Average `grads` across ranks and overwrite them in place.
107pub fn average_grads_file_sync(
108    grads: &mut [UnifiedTensor],
109    ctx: &WgpuContext,
110    cfg: &FileGradSyncConfig,
111    step: usize,
112) -> Result<(), Box<dyn std::error::Error>> {
113    if !cfg.enabled() {
114        return Ok(());
115    }
116
117    let step_dir = step_dir(cfg, step);
118    if cfg.is_root() {
119        fs::create_dir_all(&step_dir)?;
120        // Marker so other ranks know the step directory is ready.
121        atomic_write_bytes(&step_dir.join(".ready"), b"1")?;
122    }
123    let ready = step_dir.join(".ready");
124    if !wait_for_path(&ready, cfg.max_wait) {
125        return Err(format!("timed out waiting for {ready:?}").into());
126    }
127
128    let param_count = grads.len();
129    for (param_idx, grad) in grads.iter().enumerate() {
130        let values = pollster::block_on(grad.to_vec(ctx));
131        let bytes: Vec<u8> = bytemuck::cast_slice(&values).to_vec();
132        atomic_write_bytes(&rank_grad_path(&step_dir, cfg.rank, param_idx), &bytes)?;
133    }
134
135    if cfg.is_root() {
136        for param_idx in 0..param_count {
137            let mut sum: Option<Vec<f32>> = None;
138            for rank in 0..cfg.world_size {
139                let path = rank_grad_path(&step_dir, rank, param_idx);
140                if !wait_for_path(&path, cfg.max_wait) {
141                    return Err(format!("timed out waiting for {path:?}").into());
142                }
143                let bytes = fs::read(&path)?;
144                if bytes.len() % 4 != 0 {
145                    return Err(format!("corrupt gradient file {path:?}").into());
146                }
147                let values: &[f32] = bytemuck::cast_slice(&bytes);
148                match sum.as_mut() {
149                    None => sum = Some(values.to_vec()),
150                    Some(acc) => {
151                        if acc.len() != values.len() {
152                            return Err(format!(
153                                "gradient length mismatch for param {param_idx}: expected {}, got {}",
154                                acc.len(),
155                                values.len()
156                            )
157                            .into());
158                        }
159                        for (dst, src) in acc.iter_mut().zip(values.iter()) {
160                            *dst += *src;
161                        }
162                    }
163                }
164            }
165            let mut avg = sum.ok_or("missing gradients for average")?;
166            let scale = 1.0 / cfg.world_size as f32;
167            for v in &mut avg {
168                *v *= scale;
169            }
170            let avg_bytes: Vec<u8> = bytemuck::cast_slice(&avg).to_vec();
171            atomic_write_bytes(&avg_grad_path(&step_dir, param_idx), &avg_bytes)?;
172        }
173    }
174
175    for (param_idx, grad) in grads.iter().enumerate() {
176        let path = avg_grad_path(&step_dir, param_idx);
177        if !wait_for_path(&path, cfg.max_wait) {
178            return Err(format!("timed out waiting for {path:?}").into());
179        }
180        let bytes = fs::read(&path)?;
181        grad.write_data(ctx, &bytes);
182    }
183
184    Ok(())
185}
186
187/// Average host-side parameter vectors (unit-testable without GPU).
188pub fn average_host_grads(
189    per_rank: &[Vec<Vec<f32>>],
190) -> Result<Vec<Vec<f32>>, Box<dyn std::error::Error>> {
191    if per_rank.is_empty() {
192        return Err("no ranks provided".into());
193    }
194    let world = per_rank.len();
195    let param_count = per_rank[0].len();
196    let mut out = Vec::with_capacity(param_count);
197    for param_idx in 0..param_count {
198        let expected = per_rank[0][param_idx].len();
199        let mut acc = vec![0.0f32; expected];
200        for rank_grads in per_rank {
201            if rank_grads.len() != param_count {
202                return Err("rank param count mismatch".into());
203            }
204            let values = &rank_grads[param_idx];
205            if values.len() != expected {
206                return Err("rank gradient length mismatch".into());
207            }
208            for (dst, src) in acc.iter_mut().zip(values.iter()) {
209                *dst += *src;
210            }
211        }
212        let scale = 1.0 / world as f32;
213        for v in &mut acc {
214            *v *= scale;
215        }
216        out.push(acc);
217    }
218    Ok(out)
219}
220
221/// Select sample indices owned by `rank` in a world of `world_size`.
222pub fn shard_indices(len: usize, rank: usize, world_size: usize) -> Vec<usize> {
223    if world_size <= 1 {
224        return (0..len).collect();
225    }
226    (0..len).filter(|i| i % world_size == rank).collect()
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::time::Duration;
233
234    #[test]
235    fn config_rejects_invalid_rank() {
236        assert!(FileGradSyncConfig::new(2, 2, "/tmp/x").is_err());
237        assert!(FileGradSyncConfig::new(0, 0, "/tmp/x").is_err());
238        assert!(FileGradSyncConfig::new(2, 1, "/tmp/x").is_ok());
239    }
240
241    #[test]
242    fn host_grad_average_is_mean() {
243        let ranks = vec![
244            vec![vec![1.0, 3.0], vec![10.0]],
245            vec![vec![5.0, 7.0], vec![30.0]],
246        ];
247        let avg = average_host_grads(&ranks).unwrap();
248        assert_eq!(avg[0], vec![3.0, 5.0]);
249        assert_eq!(avg[1], vec![20.0]);
250    }
251
252    #[test]
253    fn shard_indices_partition_samples() {
254        let all: Vec<_> = (0..8).collect();
255        let r0 = shard_indices(8, 0, 2);
256        let r1 = shard_indices(8, 1, 2);
257        assert_eq!(r0, vec![0, 2, 4, 6]);
258        assert_eq!(r1, vec![1, 3, 5, 7]);
259        let mut merged = r0;
260        merged.extend(r1);
261        merged.sort_unstable();
262        assert_eq!(merged, all);
263    }
264
265    #[test]
266    fn wait_for_path_times_out() {
267        let missing = std::env::temp_dir().join("ferrotensor_missing_marker_should_not_exist");
268        let _ = fs::remove_file(&missing);
269        assert!(!wait_for_path(&missing, Duration::from_millis(40)));
270    }
271
272    #[test]
273    fn atomic_write_and_ready_marker_roundtrip() {
274        let dir =
275            std::env::temp_dir().join(format!("ferrotensor_sync_test_{}", std::process::id()));
276        let _ = fs::remove_dir_all(&dir);
277        fs::create_dir_all(&dir).unwrap();
278        let marker = dir.join(".ready");
279        atomic_write_bytes(&marker, b"1").unwrap();
280        assert!(wait_for_path(&marker, Duration::from_millis(100)));
281        assert_eq!(fs::read(&marker).unwrap(), b"1");
282        let _ = fs::remove_dir_all(&dir);
283    }
284}