ipfrs_interface/gradient_sync.rs
1//! Gradient synchronisation service for distributed federated learning.
2//!
3//! This module exposes a gRPC-style server-streaming service that allows
4//! nodes to exchange gradient chunks during a distributed training round.
5//! The implementation is manual (not tonic-generated) so that it can be
6//! compiled and tested independently of the proto-build toolchain.
7//!
8//! # Architecture
9//!
10//! ```text
11//! Client GradientSyncService
12//! | |
13//! |-- GradientSyncRequest ------>|
14//! | | decode local gradient
15//! | | commit_local → BlockStore
16//! |<-- GradientChunkResponse 0 --| (local gradient chunk 0)
17//! |<-- GradientChunkResponse 1 --| (local gradient chunk 1)
18//! | … |
19//! |<-- GradientChunkResponse N --| (local gradient chunk N)
20//! | |
21//! ```
22//!
23//! In production, peer gradients would be streamed by hooking into the
24//! `ipfrs-network` GossipSub event loop. That wiring lives at the `ipfrs`
25//! crate level (which owns `Node`) and must not be referenced here to avoid
26//! a circular dependency. Instead, the service pushes the *local* gradient
27//! as chunks so that clients can observe server-streaming behaviour without
28//! a live network.
29
30use ipfrs_tensorlogic::gradient::arrow_ipc::{load_gradient_from_arrow, store_gradient_as_arrow};
31use ipfrs_tensorlogic::gradient::backward_pass::BackwardPassConfig;
32use ipfrs_tensorlogic::gradient::federated::DistributedGradientAccumulator;
33
34// ── Request / Response types ──────────────────────────────────────────────
35
36/// Request to initiate a distributed gradient synchronisation session.
37///
38/// The `local_gradient` field carries the caller's local gradient encoded
39/// as Arrow IPC bytes (use
40/// [`store_gradient_as_arrow`] to produce them).
41#[derive(Debug, Clone)]
42pub struct GradientSyncRequest {
43 /// Unique identifier for this synchronisation round.
44 pub session_id: String,
45 /// Arrow IPC-encoded local gradient contributed by the caller.
46 pub local_gradient: Vec<u8>,
47 /// Minimum number of peer gradients required before aggregating.
48 pub min_peers: u32,
49 /// Wall-clock timeout in seconds before the session is abandoned.
50 pub timeout_secs: u64,
51}
52
53/// A single gradient chunk streamed back to the client.
54///
55/// During a [`GradientSyncService::sync_gradients`] call the service pushes
56/// one `GradientChunkResponse` per Arrow IPC chunk received from a peer so
57/// that clients can start processing data before all peers have responded.
58#[derive(Debug, Clone)]
59pub struct GradientChunkResponse {
60 /// Session identifier matching [`GradientSyncRequest::session_id`].
61 pub session_id: String,
62 /// Zero-based index of this chunk within the peer's gradient stream.
63 pub chunk_index: u32,
64 /// Total chunks expected from this peer.
65 pub total_chunks: u32,
66 /// Arrow IPC bytes for this chunk.
67 pub data: Vec<u8>,
68 /// Peer that contributed this chunk.
69 pub peer_id: String,
70}
71
72// ── GradientSyncService ───────────────────────────────────────────────────
73
74/// gRPC-style service that streams gradient chunks to clients as they arrive
75/// from peers.
76///
77/// The service wraps a `BlockStore` so that concurrent sync sessions can
78/// safely share the same block store without requiring access to the full
79/// `Node` type (which lives in the `ipfrs` crate and cannot be referenced
80/// from `ipfrs-interface` without a circular dependency).
81///
82/// # Usage
83///
84/// ```rust,no_run
85/// use ipfrs_interface::gradient_sync::{GradientSyncRequest, GradientSyncService};
86/// use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
87/// use ipfrs_tensorlogic::gradient::arrow_ipc::store_gradient_as_arrow;
88/// use std::sync::Arc;
89///
90/// # async fn example() -> anyhow::Result<()> {
91/// let config = BlockStoreConfig { path: std::env::temp_dir().join("grad"), cache_size: 64 * 1024 * 1024 };
92/// let store = Arc::new(SledBlockStore::new(config)?);
93/// let service = GradientSyncService::new(store);
94///
95/// let gradient: Vec<f32> = (0u32..256).map(|i| i as f32 * 0.01).collect();
96/// let local_gradient = store_gradient_as_arrow(&gradient)?;
97///
98/// let request = GradientSyncRequest {
99/// session_id: "round-1".to_string(),
100/// local_gradient,
101/// min_peers: 0,
102/// timeout_secs: 30,
103/// };
104///
105/// let (tx, mut rx) = tokio::sync::mpsc::channel(64);
106/// service.sync_gradients(request, tx).await?;
107///
108/// while let Some(chunk) = rx.recv().await {
109/// println!("received chunk {}/{}", chunk.chunk_index + 1, chunk.total_chunks);
110/// }
111/// # Ok(())
112/// # }
113/// ```
114pub struct GradientSyncService {
115 store: std::sync::Arc<dyn ipfrs_storage::traits::BlockStore>,
116}
117
118impl GradientSyncService {
119 /// Create a new service backed by `store`.
120 pub fn new(store: std::sync::Arc<dyn ipfrs_storage::traits::BlockStore>) -> Self {
121 Self { store }
122 }
123
124 /// Start a gradient sync session and stream chunks via `chunk_tx`.
125 ///
126 /// # Workflow
127 ///
128 /// 1. Decode `request.local_gradient` from Arrow IPC.
129 /// 2. Commit the local gradient to the block store via
130 /// [`DistributedGradientAccumulator::commit_local`].
131 /// 3. Poll for peer gradients (stubbed — no live network in this layer).
132 /// 4. Push one [`GradientChunkResponse`] per local chunk onto `chunk_tx`
133 /// so that the caller can observe streaming behaviour end-to-end.
134 ///
135 /// When full peer-to-peer transport is wired up at the `ipfrs` crate
136 /// level, step 3 would await actual peer CIDs from the GossipSub
137 /// `GRADIENT_SYNC` event loop.
138 pub async fn sync_gradients(
139 &self,
140 request: GradientSyncRequest,
141 chunk_tx: tokio::sync::mpsc::Sender<GradientChunkResponse>,
142 ) -> anyhow::Result<()> {
143 // ── 1. Decode the caller's local gradient from Arrow IPC ──────────────
144 let local_gradient = load_gradient_from_arrow(&request.local_gradient)
145 .map_err(|e| anyhow::anyhow!("failed to decode local gradient: {e}"))?;
146
147 tracing::debug!(
148 session_id = %request.session_id,
149 gradient_len = local_gradient.len(),
150 min_peers = request.min_peers,
151 timeout_secs = request.timeout_secs,
152 "GradientSyncService: starting sync session"
153 );
154
155 // ── 2. Commit the local gradient to the block store ───────────────────
156 let mut accumulator =
157 DistributedGradientAccumulator::new(&request.session_id, BackwardPassConfig::default());
158
159 let local_cid = accumulator
160 .commit_local(local_gradient.clone(), self.store.as_ref())
161 .await
162 .map_err(|e| anyhow::anyhow!("commit_local failed: {e}"))?;
163
164 tracing::debug!(
165 session_id = %request.session_id,
166 cid = %local_cid,
167 "GradientSyncService: local gradient committed"
168 );
169
170 // ── 3. Stream local gradient back as chunks ───────────────────────────
171 //
172 // In a live deployment peer CIDs would be discovered via the network
173 // layer and fed into `accumulator.add_peer_gradient(...)`. Since
174 // ipfrs-interface has no direct access to the network, we stream the
175 // local gradient back in chunks so that the caller can observe the
176 // server-streaming pattern end-to-end.
177 let chunk_size = 65_536usize;
178 let total_chunks = local_gradient.len().div_ceil(chunk_size).max(1);
179
180 if local_gradient.is_empty() {
181 // Send a single empty chunk to signal stream completion.
182 let ipc = store_gradient_as_arrow(&[])
183 .map_err(|e| anyhow::anyhow!("Arrow IPC encode: {e}"))?;
184 chunk_tx
185 .send(GradientChunkResponse {
186 session_id: request.session_id.clone(),
187 chunk_index: 0,
188 total_chunks: 1,
189 data: ipc,
190 peer_id: "local".to_string(),
191 })
192 .await
193 .map_err(|_| anyhow::anyhow!("chunk_tx receiver dropped"))?;
194 return Ok(());
195 }
196
197 for (idx, window) in local_gradient.chunks(chunk_size).enumerate() {
198 let ipc = store_gradient_as_arrow(window)
199 .map_err(|e| anyhow::anyhow!("Arrow IPC encode chunk {idx}: {e}"))?;
200
201 chunk_tx
202 .send(GradientChunkResponse {
203 session_id: request.session_id.clone(),
204 chunk_index: idx as u32,
205 total_chunks: total_chunks as u32,
206 data: ipc,
207 peer_id: "local".to_string(),
208 })
209 .await
210 .map_err(|_| anyhow::anyhow!("chunk_tx receiver dropped at chunk {idx}"))?;
211 }
212
213 tracing::debug!(
214 session_id = %request.session_id,
215 total_chunks,
216 "GradientSyncService: streamed all local chunks"
217 );
218
219 Ok(())
220 }
221}
222
223// ── Tests ─────────────────────────────────────────────────────────────────
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
229 use std::sync::Arc;
230
231 fn make_store(suffix: &str) -> Arc<SledBlockStore> {
232 let path = std::env::temp_dir().join(format!("ipfrs-test-gradsync-{suffix}"));
233 let _ = std::fs::remove_dir_all(&path);
234 let config = BlockStoreConfig {
235 path,
236 cache_size: 16 * 1024 * 1024,
237 };
238 Arc::new(SledBlockStore::new(config).expect("SledBlockStore::new"))
239 }
240
241 /// `GradientSyncService::new` must construct without panicking.
242 #[test]
243 fn test_gradient_sync_service_new() {
244 let store = make_store("new");
245 let _service = GradientSyncService::new(store);
246 }
247
248 /// A small gradient must be committed and streamed back as at least one chunk.
249 #[tokio::test]
250 async fn test_gradient_sync_service_streams_chunks() {
251 let store = make_store("streams-chunks");
252 let service = GradientSyncService::new(store);
253
254 let gradient: Vec<f32> = (0u32..128).map(|i| i as f32 * 0.1).collect();
255 let local_gradient_bytes =
256 store_gradient_as_arrow(&gradient).expect("store_gradient_as_arrow");
257
258 let request = GradientSyncRequest {
259 session_id: "test-sync-session".to_string(),
260 local_gradient: local_gradient_bytes,
261 min_peers: 0,
262 timeout_secs: 5,
263 };
264
265 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
266 service
267 .sync_gradients(request, tx)
268 .await
269 .expect("sync_gradients");
270
271 let mut received = Vec::new();
272 while let Some(chunk) = rx.recv().await {
273 assert_eq!(chunk.session_id, "test-sync-session");
274 assert_eq!(chunk.peer_id, "local");
275 received.push(chunk);
276 }
277
278 assert!(
279 !received.is_empty(),
280 "at least one chunk must be streamed back"
281 );
282 assert_eq!(received[0].chunk_index, 0);
283 assert!(
284 !received[0].data.is_empty(),
285 "chunk data must be non-empty Arrow IPC bytes"
286 );
287 }
288
289 /// An empty gradient must produce exactly one empty-chunk response.
290 #[tokio::test]
291 async fn test_gradient_sync_service_empty_gradient() {
292 let store = make_store("empty-grad");
293 let service = GradientSyncService::new(store);
294
295 let local_gradient_bytes =
296 store_gradient_as_arrow(&[]).expect("store_gradient_as_arrow on empty");
297
298 let request = GradientSyncRequest {
299 session_id: "empty-session".to_string(),
300 local_gradient: local_gradient_bytes,
301 min_peers: 0,
302 timeout_secs: 1,
303 };
304
305 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
306 service
307 .sync_gradients(request, tx)
308 .await
309 .expect("sync_gradients");
310
311 let first = rx.recv().await.expect("must receive one chunk");
312 assert_eq!(first.chunk_index, 0);
313 assert_eq!(first.total_chunks, 1);
314 assert_eq!(first.peer_id, "local");
315
316 // No further chunks.
317 assert!(rx.recv().await.is_none());
318 }
319
320 /// A large gradient that exceeds one chunk must be split correctly.
321 #[tokio::test]
322 async fn test_gradient_sync_service_large_gradient() {
323 let store = make_store("large-grad");
324 let service = GradientSyncService::new(store);
325
326 // 131_072 elements → 2 chunks of 65_536 each.
327 let gradient: Vec<f32> = (0u32..131_072).map(|i| i as f32 * 1e-5).collect();
328 let local_gradient_bytes =
329 store_gradient_as_arrow(&gradient).expect("store_gradient_as_arrow");
330
331 let request = GradientSyncRequest {
332 session_id: "large-session".to_string(),
333 local_gradient: local_gradient_bytes,
334 min_peers: 0,
335 timeout_secs: 10,
336 };
337
338 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
339 service
340 .sync_gradients(request, tx)
341 .await
342 .expect("sync_gradients");
343
344 let mut received = Vec::new();
345 while let Some(chunk) = rx.recv().await {
346 received.push(chunk);
347 }
348
349 assert_eq!(received.len(), 2, "131_072 elements / 65_536 = 2 chunks");
350 assert_eq!(received[0].chunk_index, 0);
351 assert_eq!(received[1].chunk_index, 1);
352 assert_eq!(received[0].total_chunks, 2);
353 assert_eq!(received[1].total_chunks, 2);
354 }
355}