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}