spark_runtime/pinned_hosts.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Which host buffers are page-locked, and therefore which `copy_h2d_async`
4//! calls are genuinely asynchronous.
5//!
6//! `cuMemcpyHtoDAsync_v2` behaves in two completely different ways depending on
7//! the SOURCE, and nothing in its signature says which one you get:
8//!
9//! * **Pageable source** — the driver copies into its own staging buffer before
10//! returning. The call is async with respect to the GPU but synchronous with
11//! respect to the caller's memory, so dropping the source immediately is safe.
12//! * **Page-locked source** — the DMA engine reads the caller's pages directly,
13//! after the call returns. Dropping or rewriting the source is a
14//! use-after-free / torn transfer.
15//!
16//! Almost every `copy_h2d_async` call site in Atlas hands over a stack array or
17//! a local `Vec` that dies on the next line. Those are sound today purely
18//! because they are pageable. Nothing recorded that dependency, so pinning any
19//! one of those buffers — a normal, desirable optimisation, and one this tree
20//! has already started doing for the SSM spill staging — would have turned a
21//! whole class of call sites unsound at once, with no compile error and no
22//! runtime complaint.
23//!
24//! This registry is what makes that fail loudly instead. Every page-locked
25//! allocation Atlas makes is recorded here; the CUDA backend consults it on the
26//! `copy_h2d_async` path and, for a pinned source, adds the synchronisation the
27//! pageable path was getting from the driver for free. The cost of pinning a
28//! buffer that a call site then drops is a stalled stream and a one-time
29//! warning, not corruption.
30//!
31//! Scope and honesty about it: this tracks what goes through
32//! [`crate::gpu::GpuBackend::alloc_host_pinned`], which is the only door to
33//! page-locked memory in this workspace today. Memory page-locked by some other
34//! route — a raw `cuMemHostRegister` on an existing arena, which
35//! `model/ssm_snapshot_spill.rs` explicitly contemplates — would not be seen.
36//! Any such call must register here too.
37
38use std::sync::RwLock;
39
40/// `(base, len)` of each live page-locked host region, kept sorted by base.
41///
42/// A `Vec` and a linear scan rather than anything cleverer: the population is
43/// the model's metadata staging blob, the SSM spill blob and the verify readback
44/// blob — three entries, allocated at model load and held for the process
45/// lifetime. A tree would be more code and slower.
46static PINNED: RwLock<Vec<(usize, usize)>> = RwLock::new(Vec::new());
47
48/// Record a page-locked region. Idempotent for a repeated identical base.
49pub fn register(ptr: *const u8, bytes: usize) {
50 if ptr.is_null() || bytes == 0 {
51 return;
52 }
53 let base = ptr as usize;
54 let mut g = match PINNED.write() {
55 Ok(g) => g,
56 // A poisoned lock means a previous holder panicked. Losing the registry
57 // must not take the process down — the consequence is a missed
58 // detection, so recover and carry on rather than propagate.
59 Err(e) => e.into_inner(),
60 };
61 match g.binary_search_by_key(&base, |&(b, _)| b) {
62 Ok(i) => g[i].1 = bytes,
63 Err(i) => g.insert(i, (base, bytes)),
64 }
65}
66
67/// Forget a region. Called from `free_host_pinned`, so a freed-then-reused
68/// address is not misreported as still pinned.
69pub fn unregister(ptr: *const u8) {
70 if ptr.is_null() {
71 return;
72 }
73 let base = ptr as usize;
74 let mut g = match PINNED.write() {
75 Ok(g) => g,
76 Err(e) => e.into_inner(),
77 };
78 if let Ok(i) = g.binary_search_by_key(&base, |&(b, _)| b) {
79 g.remove(i);
80 }
81}
82
83/// Does `src` lie inside a live page-locked region?
84///
85/// `true` means an H2D copy from it is genuinely asynchronous and the caller's
86/// bytes are read after the enqueue returns.
87pub fn is_pinned(src: &[u8]) -> bool {
88 if src.is_empty() {
89 return false;
90 }
91 let start = src.as_ptr() as usize;
92 let g = match PINNED.read() {
93 Ok(g) => g,
94 Err(e) => e.into_inner(),
95 };
96 // Largest base <= start, then a containment test. Sub-slices of a pinned
97 // blob (every real caller packs fields into one region and copies a prefix)
98 // must match, which is why this is a range test and not a base lookup.
99 match g.binary_search_by_key(&start, |&(b, _)| b) {
100 Ok(i) => start + src.len() <= g[i].0 + g[i].1,
101 Err(0) => false,
102 Err(i) => {
103 let (b, len) = g[i - 1];
104 start < b + len && start + src.len() <= b + len
105 }
106 }
107}
108
109/// Live region count, for tests and teardown assertions.
110pub fn live_count() -> usize {
111 match PINNED.read() {
112 Ok(g) => g.len(),
113 Err(e) => e.into_inner().len(),
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 /// Exercises the registry over real allocations. Uses one shared lock so
122 /// the tests in this module do not observe each other's registrations —
123 /// `PINNED` is process-global by design.
124 static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
125
126 #[test]
127 fn a_pageable_buffer_is_not_pinned() {
128 let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
129 let v = vec![0u8; 128];
130 assert!(!is_pinned(&v));
131 }
132
133 #[test]
134 fn a_registered_region_and_its_subslices_are_pinned() {
135 let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
136 let mut v = vec![0u8; 256];
137 register(v.as_ptr(), v.len());
138
139 assert!(is_pinned(&v), "the whole region");
140 // The shape every real caller uses: pack fields into the blob, copy a
141 // prefix. A base-address-only lookup would miss these.
142 assert!(is_pinned(&v[..64]), "prefix");
143 assert!(is_pinned(&v[64..128]), "interior");
144 assert!(is_pinned(&v[255..]), "last byte");
145
146 unregister(v.as_ptr());
147 assert!(!is_pinned(&v), "unregistered again");
148 v[0] = 1;
149 assert_eq!(v[0], 1);
150 }
151
152 #[test]
153 fn a_neighbouring_pageable_buffer_is_not_caught() {
154 let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
155 let pinned = vec![0u8; 128];
156 let other = vec![0u8; 128];
157 register(pinned.as_ptr(), pinned.len());
158 assert!(is_pinned(&pinned));
159 assert!(!is_pinned(&other), "a different allocation must not match");
160 unregister(pinned.as_ptr());
161 }
162
163 #[test]
164 fn empty_and_null_are_handled() {
165 let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
166 let before = live_count();
167 register(std::ptr::null(), 16);
168 register(0x1000 as *const u8, 0);
169 assert_eq!(live_count(), before, "neither is a real region");
170 unregister(std::ptr::null());
171 assert!(!is_pinned(&[]));
172 }
173
174 #[test]
175 fn re_registering_the_same_base_updates_the_length() {
176 let _s = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
177 let v = vec![0u8; 256];
178 let before = live_count();
179 register(v.as_ptr(), 64);
180 register(v.as_ptr(), 256);
181 assert_eq!(live_count(), before + 1, "one entry, not two");
182 assert!(is_pinned(&v[..256]), "the updated length is in effect");
183 unregister(v.as_ptr());
184 }
185}