1use ferrotensor_backend::{UnifiedTensor, WgpuContext};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15
16#[derive(Clone, Debug)]
19pub struct FileGradSyncConfig {
20 pub world_size: usize,
22 pub rank: usize,
24 pub sync_dir: PathBuf,
26 pub max_wait: Duration,
28}
29
30impl FileGradSyncConfig {
31 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 pub fn with_max_wait(mut self, max_wait: Duration) -> Self {
55 self.max_wait = max_wait;
56 self
57 }
58
59 pub fn is_root(&self) -> bool {
61 self.rank == 0
62 }
63
64 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
106pub 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 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
187pub 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
221pub 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}