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}