spark_storage/
cascade_backend.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// CascadeBackend — a T1 local pinned-LPDDR write-back cache in front of any
4// `StorageBackend` backing tier (the RDMA peer, or the SSD io_uring backend).
5//
6// The KV cache overflow already spills to `backing`; this inserts the handoff's
7// missing middle tier: hot groups live in a bounded pinned-host cache (fast,
8// GPU-addressable, no RDMA), and only evicted groups flush DOWN to `backing`.
9// A restore hits T1 (a local copy_h2d) or falls through to `backing`. Purely a
10// placement layer — no tier transforms bytes, so the composite is bit-identical
11// to the backing alone (the group-id -> address bijection is the same on every
12// tier). Enabled by $ATLAS_KV_LOCAL_GB; 0 (default) leaves the path untouched.
13
14use std::ffi::c_void;
15
16use anyhow::{Context, Result};
17
18use crate::backend::{ReadRequest, StorageBackend};
19use crate::cascade_policy::SlotCache;
20use crate::cuda_min::{CudaEvent, PinnedBuffer, copy_h_to_d_async, stream_sync};
21use crate::group::{GroupKey, GroupLayout};
22
23/// The T1 byte store: one pinned buffer of `cap_slots * group_bytes`, slot `i`
24/// at `ptr + i*group_bytes`. Pinned so the flush-read is a plain host slice and
25/// the restore copy_h2d is fast (same LPDDR is GPU-addressable on GB10).
26struct PinnedStore {
27    buf: PinnedBuffer,
28    group_bytes: usize,
29}
30
31impl PinnedStore {
32    fn new(cap_slots: u32, group_bytes: usize) -> Result<Self> {
33        let bytes = (cap_slots as usize)
34            .checked_mul(group_bytes)
35            .context("CascadeBackend T1 size overflow")?;
36        Ok(Self {
37            buf: PinnedBuffer::new(bytes).context("alloc T1 pinned store")?,
38            group_bytes,
39        })
40    }
41    #[inline]
42    fn slot_host_ptr(&self, slot: u32) -> *const c_void {
43        // SAFETY: slot < cap_slots (SlotCache invariant); offset within the buf.
44        unsafe {
45            (self.buf.ptr as *const u8).add(slot as usize * self.group_bytes) as *const c_void
46        }
47    }
48    /// Copy the group bytes for `slot` out into a fresh Vec (releases the borrow
49    /// so the flush can call `&mut backing`).
50    fn slot_bytes(&self, slot: u32) -> Vec<u8> {
51        // SAFETY: as above; the slot holds `group_bytes` valid bytes.
52        unsafe {
53            std::slice::from_raw_parts(
54                (self.buf.ptr as *const u8).add(slot as usize * self.group_bytes),
55                self.group_bytes,
56            )
57            .to_vec()
58        }
59    }
60    fn write_slot(&mut self, slot: u32, src: &[u8]) {
61        debug_assert_eq!(src.len(), self.group_bytes);
62        // SAFETY: slot in range; src is exactly group_bytes.
63        unsafe {
64            std::ptr::copy_nonoverlapping(
65                src.as_ptr(),
66                (self.buf.ptr as *mut u8).add(slot as usize * self.group_bytes),
67                self.group_bytes,
68            );
69        }
70    }
71}
72
73pub struct CascadeBackend {
74    hot: SlotCache,
75    store: PinnedStore,
76    backing: Box<dyn StorageBackend>,
77    group_bytes: usize,
78    /// #11-refinement: last async T1 hit-copy event. `read_async` records it
79    /// after its hit `copy_h2d`s (which read the pinned slots); a subsequent
80    /// eviction `write_slot` over one of those slots `sync`s it first, so the
81    /// host cannot overwrite a slot a still-in-flight hit-copy is reading.
82    /// `None` on the sync path → byte-identical for prefetch-OFF.
83    last_read_event: Option<CudaEvent>,
84}
85
86// Single-owner rationale identical to RdmaKvBackend: both trait methods take
87// `&mut self`, no `&self` method touches shared state, and HighSpeedSwap owns it
88// single-threaded. The pinned store's raw ptr is only used under `&mut self`.
89unsafe impl Sync for CascadeBackend {}
90
91impl CascadeBackend {
92    pub fn new(
93        backing: Box<dyn StorageBackend>,
94        layout: GroupLayout,
95        cap_slots: u32,
96    ) -> Result<Self> {
97        let group_bytes = layout.group_bytes() as usize;
98        tracing::info!(
99            "high-speed-swap: T1 cascade cache = {cap_slots} slots × {group_bytes} B = {:.1} GiB local pinned RAM, backing below",
100            (cap_slots as f64 * group_bytes as f64) / (1024.0 * 1024.0 * 1024.0),
101        );
102        Ok(Self {
103            hot: SlotCache::new(cap_slots),
104            store: PinnedStore::new(cap_slots, group_bytes)?,
105            backing,
106            group_bytes,
107            last_read_event: None,
108        })
109    }
110
111    /// Flush every resident T1 group down to backing (durability on teardown).
112    fn flush_all(&mut self) -> Result<()> {
113        for (key, slot) in self.hot.residents() {
114            let bytes = self.store.slot_bytes(slot);
115            self.backing.write_from_host(key, &bytes)?;
116        }
117        Ok(())
118    }
119
120    /// Body shared by `read` (sync) and `read_async` (#11-refinement). The T1
121    /// hit copies pipeline identically; only the tail differs:
122    ///   * sync  (`false`): forward misses via `backing.read`, terminal
123    ///     `stream_sync`. Byte-identical to the pre-refinement `read`.
124    ///   * async (`true`): forward misses via `backing.read_async` (no terminal
125    ///     host sync there either), record `last_read_event` after the hit
126    ///     copies so a later eviction `write_slot` can guard against them, and
127    ///     OMIT the terminal `stream_sync`. Mirror-RAW for the hits is closed by
128    ///     the HSS `kv_prefetch_done` (the hit `copy_h2d`s are enqueued on
129    ///     `prefetch_stream` before HSS records that event).
130    fn read_common(&mut self, requests: &[ReadRequest], stream: u64, is_async: bool) -> Result<()> {
131        let keys: Vec<GroupKey> = requests.iter().map(|r| r.group).collect();
132        let (hits, misses) = self.hot.plan_read(&keys);
133        // T1 hits: local copy_h2d straight from the pinned slot into HBM.
134        for (i, slot) in &hits {
135            let src = self.store.slot_host_ptr(*slot);
136            copy_h_to_d_async(requests[*i].dst_dev_ptr, src, self.group_bytes, stream)?;
137            self.hot.touch(*slot);
138        }
139        // Async: record an event AFTER the hit copies read the pinned slots, so a
140        // subsequent eviction write_slot over one of those slots waits it out.
141        if is_async && !hits.is_empty() {
142            let ev = CudaEvent::new()?;
143            ev.record(stream)?;
144            self.last_read_event = Some(ev);
145        }
146        // Misses fall through to backing (peer RDMA or SSD). Non-promoting: a
147        // miss is NOT pulled up into T1 (smaller correctness surface; write-back
148        // populates T1). backing.read syncs the stream for its own dsts; the
149        // trailing stream_sync also covers the hit copy_h2d above.
150        if !misses.is_empty() {
151            let miss_reqs: Vec<ReadRequest> = misses.iter().map(|&i| requests[i]).collect();
152            if is_async {
153                self.backing.read_async(&miss_reqs, stream)?;
154            } else {
155                self.backing.read(&miss_reqs, stream)?;
156            }
157        }
158        if !is_async {
159            stream_sync(stream)?;
160        }
161        Ok(())
162    }
163}
164
165impl StorageBackend for CascadeBackend {
166    fn write_from_host(&mut self, key: GroupKey, src: &[u8]) -> Result<()> {
167        let plan = self.hot.plan_write(key);
168        // Evicted a resident group → flush its (still-in-slot) bytes DOWN first.
169        if let Some((victim_key, victim_slot)) = plan.flush_victim {
170            let victim_bytes = self.store.slot_bytes(victim_slot);
171            self.backing
172                .write_from_host(victim_key, &victim_bytes)
173                .context("cascade: flush T1 victim to backing")?;
174        }
175        // #11-refinement: an eviction is about to overwrite this slot in place.
176        // If a prior async hit-copy is still reading it (recorded in read_async),
177        // wait it out first — else the host `write_slot` below corrupts the bytes
178        // the copy engine is mid-read. No-op on the sync path (`None`) →
179        // byte-identical; when it fires it is on the offload/eviction path, never
180        // the decode run-ahead loop, so it never stalls decode.
181        if let Some(ev) = self.last_read_event.take() {
182            ev.sync()?;
183        }
184        // Then cache the new group in T1 (write-back — lives here until evicted).
185        self.store.write_slot(plan.slot, src);
186        Ok(())
187    }
188
189    fn read(&mut self, requests: &[ReadRequest], stream: u64) -> Result<()> {
190        self.read_common(requests, stream, false)
191    }
192
193    fn read_async(&mut self, requests: &[ReadRequest], stream: u64) -> Result<()> {
194        self.read_common(requests, stream, true)
195    }
196
197    fn register_landing_region(&mut self, base: u64, len: usize) -> Result<()> {
198        // Forward to backing so RDMA zero-copy restore of MISSES still lands
199        // directly into the UMA pool. (T1 hits copy_h2d locally regardless.)
200        self.backing.register_landing_region(base, len)
201    }
202
203    fn group_layout(&self) -> GroupLayout {
204        // Geometry is the backing's (the group-id ↔ address bijection is the
205        // same on every tier). Cascade inherits the DEFAULT block read/write
206        // (per-head fan-out through its own read/write_from_host = T1 caching) —
207        // correct, just un-coalesced. Native T1 block coalescing is a follow-up.
208        self.backing.group_layout()
209    }
210}
211
212impl Drop for CascadeBackend {
213    fn drop(&mut self) {
214        // Durability: push any T1-resident groups down to backing before the
215        // pinned store frees. Best-effort on teardown.
216        let _ = self.flush_all();
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::backend::PosixBackend;
224    use crate::cuda_min::{CudaCtx, DeviceBuffer, copy_d_to_h_async, stream_sync};
225    use crate::group::KvKind;
226    use crate::layout::Layout;
227
228    fn tempdir(name: &str) -> std::path::PathBuf {
229        let p = std::env::temp_dir().join(format!("atlas-cascade-{name}-{}", std::process::id()));
230        let _ = std::fs::remove_dir_all(&p);
231        std::fs::create_dir_all(&p).unwrap();
232        p
233    }
234
235    /// #11-refinement staging-reuse gate for the cascade T1 tier: an eviction
236    /// `write_slot` must not overwrite a pinned slot that a still-in-flight async
237    /// hit-copy (`read_async`) is reading — `last_read_event.sync` guards it.
238    /// We size T1 to 2 slots over a 4-group set (forcing eviction), do a
239    /// `read_async` that hits both resident slots, then `write_from_host` a new
240    /// group whose eviction victim is one of those just-read slots, and finally
241    /// verify EVERY group reads back correctly (through T1 hits, evicted-then-
242    /// flushed backing reads, and a sync/async parity leg). A missing guard
243    /// would corrupt the victim's flushed-to-backing bytes silently.
244    #[test]
245    #[ignore = "requires GPU"]
246    fn read_async_eviction_no_corruption() {
247        let _ctx = CudaCtx::new(0).expect("cuda init");
248        let dir = tempdir("evict");
249        // 4 blocks, T1 caps at 2 slots → writing block 2/3 evicts 0/1.
250        let spec = GroupLayout::new(1, 4, 1, 16, 128, 2, 4096);
251        let layout = Layout::create(&dir, spec).unwrap();
252        let bytes = spec.group_bytes() as usize;
253        let backing = Box::new(PosixBackend::new(layout).unwrap());
254        let mut cascade = CascadeBackend::new(backing, spec, 2).unwrap();
255
256        let keys: Vec<GroupKey> = (0..4u32)
257            .map(|b| GroupKey::new(0, b, 0, KvKind::K))
258            .collect();
259        let pat = |b: usize| -> Vec<u8> {
260            (0..bytes)
261                .map(|i| ((i * 7 + b * 11) & 0xFF) as u8)
262                .collect()
263        };
264
265        // Cache g0,g1 in T1 (fits in the 2 slots).
266        cascade.write_from_host(keys[0], &pat(0)).unwrap();
267        cascade.write_from_host(keys[1], &pat(1)).unwrap();
268
269        // Async restore g0,g1 → both T1 hits; records last_read_event over the
270        // slots holding g0,g1.
271        let d0 = DeviceBuffer::new(bytes).unwrap();
272        let d1 = DeviceBuffer::new(bytes).unwrap();
273        cascade
274            .read_async(
275                &[
276                    ReadRequest {
277                        group: keys[0],
278                        dst_dev_ptr: d0.ptr,
279                    },
280                    ReadRequest {
281                        group: keys[1],
282                        dst_dev_ptr: d1.ptr,
283                    },
284                ],
285                _ctx.stream,
286            )
287            .unwrap();
288        // Evict: caching g2,g3 flushes g0,g1's slots DOWN to backing. Each
289        // write_from_host must sync the in-flight hit-copy before write_slot.
290        cascade.write_from_host(keys[2], &pat(2)).unwrap();
291        cascade.write_from_host(keys[3], &pat(3)).unwrap();
292        // The async reads' consumer would device-wait kv_prefetch_done; here we
293        // host-sync to read device memory back.
294        stream_sync(_ctx.stream).unwrap();
295        let readback = |d: &DeviceBuffer, want: &[u8]| {
296            let mut got = vec![0u8; bytes];
297            copy_d_to_h_async(got.as_mut_ptr() as *mut c_void, d.ptr, bytes, _ctx.stream).unwrap();
298            stream_sync(_ctx.stream).unwrap();
299            assert_eq!(&got, want, "cascade async restore corrupted");
300        };
301        readback(&d0, &pat(0));
302        readback(&d1, &pat(1));
303
304        // Now g0,g1 live in backing (evicted); g2,g3 in T1. A mixed sync read
305        // (hits g2/g3, misses g0/g1 → backing) must return every original byte.
306        let devs: Vec<DeviceBuffer> = (0..4).map(|_| DeviceBuffer::new(bytes).unwrap()).collect();
307        let reqs: Vec<ReadRequest> = keys
308            .iter()
309            .zip(&devs)
310            .map(|(k, d)| ReadRequest {
311                group: *k,
312                dst_dev_ptr: d.ptr,
313            })
314            .collect();
315        cascade.read(&reqs, _ctx.stream).unwrap();
316        for (b, d) in devs.iter().enumerate() {
317            readback(d, &pat(b));
318        }
319        drop(cascade);
320        std::fs::remove_dir_all(&dir).ok();
321    }
322}