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}