spark_storage/
rdma_snapshot.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Offset-addressed RDMA arena for the SSM-snapshot spill tier.
4//!
5//! A minimal, **synchronous** transport over the CX7 verbs + RW-blade paging
6//! peer protocol the KV overflow tier uses, but addressed by a flat byte
7//! **offset** (snapshots are keyed by an opaque id → arena slot) rather than the
8//! KV `GroupKey`/`group_stride` layout — reusing that layout would corrupt live
9//! KV (its `write_from_host` asserts `src.len()==group_bytes`).
10//!
11//! The paging peer is layout-agnostic (client sends `total_bytes`, the peer
12//! registers ONE RW MR and serves `base+offset`), so a second peer instance on
13//! its own port serves the snapshot arena with zero peer-side change. Each op is
14//! drained to completion before returning (one blob ~64 MB, ~5–7 ms — the
15//! spill/fault path is latency-, not throughput-critical), so the caller's
16//! `SnapshotBlobStore::{put,get}` contract (durable on return) holds.
17//!
18//! Gathering the scattered per-layer SSM state into the contiguous blob and all
19//! device-stream ordering already happen in `SsmSnapshotPool::{spill_slot,
20//! fault_in_slot}`; this transport only moves host bytes.
21
22// The real transport needs the CUDA pinned bounce + the verbs FFI; when either
23// is absent, a stub whose `connect` always errors lets dependents reference the
24// type unconditionally (the tier selector then falls back to host-RAM).
25#[cfg(all(feature = "cuda", atlas_rdma_verbs))]
26pub use imp::RdmaSnapshotArena;
27#[cfg(not(all(feature = "cuda", atlas_rdma_verbs)))]
28pub use stub::RdmaSnapshotArena;
29
30#[cfg(not(all(feature = "cuda", atlas_rdma_verbs)))]
31mod stub {
32    use anyhow::{Result, bail};
33    /// Placeholder when RDMA verbs / CUDA aren't built. `connect` always errors,
34    /// so [`crate`] dependents degrade to the host-RAM tier; `write`/`read` are
35    /// unreachable (a stub arena is never successfully constructed).
36    pub struct RdmaSnapshotArena;
37    impl RdmaSnapshotArena {
38        pub fn connect(_addr: &str, _arena_bytes: u64, _blob_bytes: usize) -> Result<Self> {
39            bail!("RDMA snapshot tier not built (needs feature `cuda` + atlas_rdma_verbs)")
40        }
41        pub fn connect_paging(_addr: &str, _arena_bytes: u64, _blob_bytes: usize) -> Result<Self> {
42            bail!("RDMA snapshot tier not built (needs feature `cuda` + atlas_rdma_verbs)")
43        }
44        pub fn write(&self, _offset: u64, _bytes: &[u8]) -> Result<()> {
45            unreachable!("stub RdmaSnapshotArena is never constructed")
46        }
47        pub fn read(&self, _offset: u64, _out: &mut [u8]) -> Result<()> {
48            unreachable!("stub RdmaSnapshotArena is never constructed")
49        }
50        pub fn paging_put(&self, _key: u64, _bytes: &[u8]) -> Result<()> {
51            unreachable!("stub RdmaSnapshotArena is never constructed")
52        }
53        pub fn paging_get(&self, _key: u64, _out: &mut [u8]) -> Result<bool> {
54            unreachable!("stub RdmaSnapshotArena is never constructed")
55        }
56        pub fn paging_remove(&self, _key: u64) -> Result<()> {
57            unreachable!("stub RdmaSnapshotArena is never constructed")
58        }
59    }
60}
61
62#[cfg(all(feature = "cuda", atlas_rdma_verbs))]
63mod imp {
64    use std::io::Write;
65    use std::net::TcpStream;
66    use std::sync::Mutex;
67
68    use anyhow::{Result, bail};
69
70    use crate::cuda_min::PinnedBuffer;
71    use atlas_rdma::env::{first_set, first_set_u32};
72    use atlas_rdma::railset::{RailSet, RailSpec};
73    use atlas_rdma::verbs::Verbs;
74
75    /// One rail: its QP + a single persistent registered bounce (`blob_bytes`).
76    struct SnapRail {
77        verbs: Verbs,
78        bounce: PinnedBuffer,
79        lkey: u32,
80        remote_rkey: u32,
81        /// lkey for the SHARED striped-staging buffer registered on THIS rail (0 if
82        /// staging disabled). Every rail registers the SAME contiguous staging
83        /// buffer, so whichever rail fetches a chunk it lands at its true offset.
84        staging_lkey: u32,
85    }
86
87    /// Mutable rail state, serialized under one lock (the trait exposes `&self`).
88    struct ArenaInner {
89        rails: Vec<SnapRail>,
90        /// ONE contiguous `blob_bytes` staging buffer for the striped/pipelined
91        /// dual-rail path (ATLAS_SSM_STAGING); None = single-WR bounce fallback.
92        staging: Option<PinnedBuffer>,
93        rr: usize,
94        next_wr: u64,
95        /// In raw (dumb one-sided) mode: kept alive for the QP's lifetime, else idle.
96        /// In paging mode: the live control channel — alloc/commit/get/remove
97        /// requests ride this stream, interleaved with the RDMA data plane below.
98        stream: TcpStream,
99    }
100
101    /// Offset-addressed RDMA snapshot arena. Connect to the paging peer sized for
102    /// `arena_slots × blob_bytes`; `write`/`read` move one `blob_bytes` blob to/from
103    /// `base + offset`.
104    pub struct RdmaSnapshotArena {
105        inner: Mutex<ArenaInner>,
106        remote_base: u64,
107        blob_bytes: usize,
108    }
109
110    // SAFETY: every access to the raw verbs/bounce state goes through `inner`'s
111    // Mutex, so there is no unsynchronized sharing; mirrors the KV backend's
112    // single-owner contract. `Verbs` is `Send`; `PinnedBuffer` is `Send + Sync`.
113    unsafe impl Send for RdmaSnapshotArena {}
114    unsafe impl Sync for RdmaSnapshotArena {}
115
116    impl RdmaSnapshotArena {
117        /// Handshake with the snapshot peer at `addr` and register `blob_bytes`
118        /// bounces. Rail devices/GIDs reuse the KV env (`ATLAS_EXPERT_RDMA_DEV`/`GID`
119        /// = rail 0, `ATLAS_KV_RAIL2_DEV`/`GID` = rail 1, dual only when
120        /// `ATLAS_KV_DUAL_RAIL=1`). `arena_bytes` = `arena_slots × blob_bytes`.
121        pub fn connect(addr: &str, arena_bytes: u64, blob_bytes: usize) -> Result<Self> {
122            Self::connect_inner(addr, arena_bytes, blob_bytes, false)
123        }
124
125        /// Paging-mode connect: the peer arena becomes a page-cache over an
126        /// NVMe swap file and OWNS residency; this client uses the control channel
127        /// (`paging_put`/`paging_get`/`paging_remove`) instead of a client-side
128        /// allocator. Requires the peer be started with `--swap-dir`.
129        pub fn connect_paging(addr: &str, arena_bytes: u64, blob_bytes: usize) -> Result<Self> {
130            Self::connect_inner(addr, arena_bytes, blob_bytes, true)
131        }
132
133        fn connect_inner(
134            addr: &str,
135            arena_bytes: u64,
136            blob_bytes: usize,
137            paging: bool,
138        ) -> Result<Self> {
139            // Rail env: the KV triple verbatim (shared CX7 fabric config). Fresh
140            // random 24-bit PSN per rail.
141            let spec =
142                |dev: String, gid: u32| RailSpec::new(dev, gid, rand::random::<u32>() & 0xff_ffff);
143            let rail0 = spec(
144                first_set(&["ATLAS_EXPERT_RDMA_DEV"], "roceP2p1s0f1"),
145                first_set_u32(&["ATLAS_EXPERT_RDMA_GID"], 3),
146            );
147            let dual = std::env::var("ATLAS_KV_DUAL_RAIL").ok().as_deref() == Some("1");
148            let specs: Vec<RailSpec> = if dual {
149                let rail1 = spec(
150                    first_set(&["ATLAS_KV_RAIL2_DEV"], "rocep1s0f1"),
151                    first_set_u32(&["ATLAS_KV_RAIL2_GID"], 3),
152                );
153                vec![rail0, rail1]
154            } else {
155                vec![rail0]
156            };
157            let n_rails = specs.len();
158
159            let mut stream = TcpStream::connect(addr)
160                .map_err(|e| anyhow::anyhow!("connect snapshot peer {addr}: {e}"))?;
161            stream.set_nodelay(true).ok();
162            // v2 handshake: both modes send the same 25-byte header.
163            // Paging carries the real blob size; the raw (dumb one-sided) mode
164            // signals itself with blob_bytes == 0 — the peer then hands this
165            // connection a private arena with a client-owned allocator.
166            stream.write_all(&crate::snapshot_swap::encode_paging_v2_header(
167                crate::snapshot_swap::PagingKind::SSM,
168                arena_bytes,
169                if paging { blob_bytes as u64 } else { 0 },
170            ))?;
171            // [u8 n_rails] + one QP per rail.
172            let mut rs = RailSet::begin(&mut stream, &specs)?;
173
174            // ONE contiguous staging buffer (ATLAS_SSM_STAGING) registered on EVERY
175            // rail → each rail gets its own lkey over the SAME memory, so a chunk
176            // striped to any rail lands at its true offset (the inc-6 reassembly fix).
177            let staging_on = std::env::var("ATLAS_SSM_STAGING").ok().as_deref() == Some("1");
178            let staging = if staging_on {
179                Some(PinnedBuffer::new(blob_bytes)?)
180            } else {
181                None
182            };
183
184            // Per-rail bounce + shared staging MRs, both LOCAL_WRITE only
185            // (`remote_read == false`, invariant — we WRITE from and READ into them).
186            let mut parts: Vec<(PinnedBuffer, u32, u32)> = Vec::with_capacity(n_rails);
187            for rail in &mut rs.rails {
188                let bounce = PinnedBuffer::new(blob_bytes)?;
189                // SAFETY: bounce lives as long as the rail (and thus the MR).
190                let keys = unsafe { rail.verbs.reg_mr(bounce.ptr, blob_bytes, false)? };
191                // SAFETY: staging outlives the rail (both live in ArenaInner,
192                // dropped together).
193                let staging_lkey = match &staging {
194                    Some(s) => unsafe { rail.verbs.reg_mr(s.ptr, blob_bytes, false)?.lkey },
195                    None => 0,
196                };
197                parts.push((bounce, keys.lkey, staging_lkey));
198            }
199
200            // Peer's per-rail QP + shared arena base/rkey, client params, connect,
201            // ack. Shared base: keep the LAST (pre-RailSet loop-overwrite behavior).
202            let server = rs.finish_rw(&mut stream, "snapshot peer")?;
203            let base = server.last().map(|sp| sp.base_addr).unwrap_or(0);
204            let rails: Vec<SnapRail> = rs
205                .into_verbs()
206                .into_iter()
207                .zip(parts)
208                .zip(&server)
209                .map(|((verbs, (bounce, lkey, staging_lkey)), sp)| SnapRail {
210                    verbs,
211                    bounce,
212                    lkey,
213                    remote_rkey: sp.rkey,
214                    staging_lkey,
215                })
216                .collect();
217            tracing::info!(
218                "RdmaSnapshotArena connected to {addr}: {:.1} GiB arena, {n_rails} rail(s), blob {blob_bytes} B",
219                arena_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
220            );
221            Ok(Self {
222                inner: Mutex::new(ArenaInner {
223                    rails,
224                    staging,
225                    rr: 0,
226                    next_wr: 1, // 0 == "no completion yet" sentinel in the poll loop
227                    stream,
228                }),
229                remote_base: base,
230                blob_bytes,
231            })
232        }
233
234        #[inline]
235        pub fn blob_bytes(&self) -> usize {
236            self.blob_bytes
237        }
238
239        /// RDMA-WRITE one `blob_bytes` blob to `base + offset`, drained to completion.
240        pub fn write(&self, offset: u64, bytes: &[u8]) -> Result<()> {
241            if bytes.len() != self.blob_bytes {
242                bail!(
243                    "snapshot write: {} != blob_bytes {}",
244                    bytes.len(),
245                    self.blob_bytes
246                );
247            }
248            let mut g = self.inner.lock().expect("snapshot arena mutex");
249            self.rdma_write_locked(&mut g, self.remote_base + offset, bytes)
250        }
251
252        /// RDMA-READ one `blob_bytes` blob from `base + offset` into `out`, drained.
253        pub fn read(&self, offset: u64, out: &mut [u8]) -> Result<()> {
254            if out.len() != self.blob_bytes {
255                bail!(
256                    "snapshot read: {} != blob_bytes {}",
257                    out.len(),
258                    self.blob_bytes
259                );
260            }
261            let mut g = self.inner.lock().expect("snapshot arena mutex");
262            self.rdma_read_locked(&mut g, self.remote_base + offset, out)
263        }
264
265        /// Pick a rail (round-robin) and a fresh wr id.
266        fn rail_and_wr(g: &mut ArenaInner) -> (usize, u64) {
267            let n = g.rails.len();
268            let ri = g.rr % n;
269            g.rr = g.rr.wrapping_add(1);
270            let wr = g.next_wr;
271            g.next_wr = g.next_wr.wrapping_add(1).max(1);
272            (ri, wr)
273        }
274
275        fn rdma_write_locked(&self, g: &mut ArenaInner, raddr: u64, bytes: &[u8]) -> Result<()> {
276            if g.staging.is_some() {
277                return self.rdma_staged(g, raddr, Some(bytes), None);
278            }
279            let (ri, wr) = Self::rail_and_wr(g);
280            let rail = &mut g.rails[ri];
281            // SAFETY: bounce is a live registered MR of blob_bytes; copy the blob in,
282            // RDMA-WRITE it to the peer arena, drain the single completion.
283            unsafe {
284                std::ptr::copy_nonoverlapping(
285                    bytes.as_ptr(),
286                    rail.bounce.ptr as *mut u8,
287                    self.blob_bytes,
288                );
289                rail.verbs.post_write(
290                    rail.bounce.ptr,
291                    rail.lkey,
292                    raddr,
293                    rail.remote_rkey,
294                    self.blob_bytes as u32,
295                    wr,
296                )?;
297            }
298            while rail.verbs.poll()? != wr {}
299            Ok(())
300        }
301
302        fn rdma_read_locked(&self, g: &mut ArenaInner, raddr: u64, out: &mut [u8]) -> Result<()> {
303            if g.staging.is_some() {
304                return self.rdma_staged(g, raddr, None, Some(out));
305            }
306            let (ri, wr) = Self::rail_and_wr(g);
307            let rail = &mut g.rails[ri];
308            // SAFETY: read into the live bounce MR, drain, then copy host-side to out.
309            unsafe {
310                rail.verbs.post_read(
311                    rail.bounce.ptr,
312                    rail.lkey,
313                    raddr,
314                    rail.remote_rkey,
315                    self.blob_bytes as u32,
316                    wr,
317                )?;
318            }
319            while rail.verbs.poll()? != wr {}
320            unsafe {
321                std::ptr::copy_nonoverlapping(
322                    rail.bounce.ptr as *const u8,
323                    out.as_mut_ptr(),
324                    self.blob_bytes,
325                );
326            }
327            Ok(())
328        }
329
330        /// Striped, pipelined, dual-rail transfer of ONE blob through the shared
331        /// contiguous staging buffer (ATLAS_SSM_STAGING). `write_src` set = WRITE
332        /// (memcpy in, stripe out); `read_dst` set = READ (stripe in, memcpy out).
333        /// Chunks round-robin across rails, ≤ depth in-flight per rail (bounds the
334        /// send queue). Because every rail registers the SAME staging buffer, chunk
335        /// j lands at its true offset regardless of rail → one memcpy reassembles.
336        fn rdma_staged(
337            &self,
338            g: &mut ArenaInner,
339            raddr: u64,
340            write_src: Option<&[u8]>,
341            read_dst: Option<&mut [u8]>,
342        ) -> Result<()> {
343            let staging = g.staging.as_ref().expect("staging present").ptr as *mut u8;
344            let is_read = read_dst.is_some();
345            if let Some(src) = write_src {
346                // WRITE: assemble the whole blob into staging first.
347                unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), staging, self.blob_bytes) };
348            }
349            let n = g.rails.len();
350            let chunk = crate::snapshot_swap::staging_chunk_bytes();
351            let depth = crate::snapshot_swap::staging_depth();
352            let plan = crate::snapshot_swap::stripe_plan(self.blob_bytes, chunk, n);
353            let total: usize = plan.iter().map(|w| w.len()).sum();
354            let mut posted = vec![0usize; n];
355            let mut reaped = vec![0usize; n];
356            let mut done = 0usize;
357            while done < total {
358                // Post up to `depth` in-flight per rail (bounds each SQ).
359                for ri in 0..n {
360                    while posted[ri] < plan[ri].len() && (posted[ri] - reaped[ri]) < depth {
361                        let (off, len) = plan[ri][posted[ri]];
362                        let wr = g.next_wr;
363                        g.next_wr = g.next_wr.wrapping_add(1).max(1);
364                        let rail = &mut g.rails[ri];
365                        let local = unsafe { staging.add(off) } as *mut _;
366                        let raddr_chunk = raddr + off as u64;
367                        unsafe {
368                            if is_read {
369                                rail.verbs.post_read(
370                                    local,
371                                    rail.staging_lkey,
372                                    raddr_chunk,
373                                    rail.remote_rkey,
374                                    len as u32,
375                                    wr,
376                                )?;
377                            } else {
378                                rail.verbs.post_write(
379                                    local,
380                                    rail.staging_lkey,
381                                    raddr_chunk,
382                                    rail.remote_rkey,
383                                    len as u32,
384                                    wr,
385                                )?;
386                            }
387                        }
388                        posted[ri] += 1;
389                    }
390                }
391                // Reap one completion from each rail with work outstanding.
392                for ri in 0..n {
393                    if posted[ri] > reaped[ri] {
394                        g.rails[ri].verbs.poll()?;
395                        reaped[ri] += 1;
396                        done += 1;
397                    }
398                }
399            }
400            if let Some(dst) = read_dst {
401                // READ: one memcpy reassembles (every chunk is at its true offset).
402                unsafe {
403                    std::ptr::copy_nonoverlapping(
404                        staging as *const u8,
405                        dst.as_mut_ptr(),
406                        self.blob_bytes,
407                    )
408                };
409            }
410            Ok(())
411        }
412
413        // ─────────────────────────── paging data path ───────────────────────
414        // The peer owns residency; we ALLOC a slot (control), RDMA-WRITE the blob,
415        // then COMMIT — all under one lock so the peer's single-threaded per-conn
416        // request order is respected. GET faults from the peer's NVMe swap if needed.
417
418        /// PUT `key`'s blob into the tier. Never "full" — the peer spills to NVMe.
419        pub fn paging_put(&self, key: u64, bytes: &[u8]) -> Result<()> {
420            if bytes.len() != self.blob_bytes {
421                bail!(
422                    "paging_put: {} != blob_bytes {}",
423                    bytes.len(),
424                    self.blob_bytes
425                );
426            }
427            let mut g = self.inner.lock().expect("snapshot arena mutex");
428            let off = crate::snapshot_swap::client_alloc(&mut g.stream, key)?;
429            self.rdma_write_locked(&mut g, self.remote_base + off, bytes)?;
430            crate::snapshot_swap::client_commit(&mut g.stream, key)
431        }
432
433        /// GET `key`'s blob into `out`. `Ok(false)` = the peer has no such key.
434        pub fn paging_get(&self, key: u64, out: &mut [u8]) -> Result<bool> {
435            if out.len() != self.blob_bytes {
436                bail!(
437                    "paging_get: {} != blob_bytes {}",
438                    out.len(),
439                    self.blob_bytes
440                );
441            }
442            let mut g = self.inner.lock().expect("snapshot arena mutex");
443            match crate::snapshot_swap::client_get(&mut g.stream, key)? {
444                Some(off) => {
445                    self.rdma_read_locked(&mut g, self.remote_base + off, out)?;
446                    Ok(true)
447                }
448                None => Ok(false),
449            }
450        }
451
452        /// Drop `key` from the peer cache.
453        pub fn paging_remove(&self, key: u64) -> Result<()> {
454            let mut g = self.inner.lock().expect("snapshot arena mutex");
455            crate::snapshot_swap::client_remove(&mut g.stream, key)
456        }
457    }
458}