spark_runtime/
op_cache.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! [`OpCache`] — per-backend kernel handles and scratch buffers.
4//!
5//! Kernel-launching ops memoize two things: the [`KernelHandle`] they resolve
6//! from the module registry, and any device scratch they grow on demand. Both
7//! were being cached in function-local `static OnceLock` / `static Mutex`, and
8//! both are **owned by the model**:
9//!
10//! * A `KernelHandle` is a raw `CUfunction` from an `AtlasRegistry` module.
11//!   The registry unloads its modules on drop, so a handle cached in a static
12//!   outlives the module it points into — a launch after a swap is a
13//!   use-after-unload, not a stale value.
14//! * A scratch `DevicePtr` is an allocation in the model's context. Cached in
15//!   a static, the next model writes its activations through a pointer that
16//!   was freed with the previous one.
17//!
18//! Neither fails loudly. Both are the kind of defect that surfaces as
19//! corrupted output or an illegal address in an unrelated kernel.
20//!
21//! An `OpCache` lives on the backend, so its lifetime is exactly the model's:
22//! when the backend drops, the handles go with the registry that owns them and
23//! the scratch goes with the context that allocated it.
24
25use std::collections::HashMap;
26// parking_lot: no poisoning, so a panic in one op cannot turn every later
27// lookup — or teardown — into an error path that has to be handled.
28use parking_lot::{Mutex, RwLock};
29
30use crate::gpu::{DevicePtr, GpuBackend, KernelHandle};
31use anyhow::Result;
32
33/// Memoized kernel handles and scratch allocations for one backend.
34#[derive(Default)]
35pub struct OpCache {
36    /// `(module, function)` → resolved handle. `RwLock` because the steady
37    /// state is read-only: every entry is filled on the first launch of its op
38    /// and read on every launch after.
39    kernels: RwLock<HashMap<(&'static str, &'static str), KernelHandle>>,
40    /// Purpose tag → `(pointer, bytes)`. Grow-only within a model's life.
41    scratch: Mutex<HashMap<&'static str, (DevicePtr, usize)>>,
42    /// Device allocation has failed on this backend at least once.
43    alloc_fell_back: std::sync::atomic::AtomicBool,
44    /// `(name-hash, M, N, K)` combinations whose route line has been logged.
45    /// Backend-scoped like everything else here: the shapes a model dispatches
46    /// are its own, and a set shared across a swap suppresses the FIRST route
47    /// line for every shape the previous model happened to use — the lines
48    /// that say which kernel a model actually took.
49    logged_shapes: Mutex<std::collections::HashSet<(u64, u32, u32, u32)>>,
50    /// Per-key call counts for the report-the-first-few diagnostics.
51    counters: Mutex<HashMap<&'static str, u32>>,
52}
53
54impl OpCache {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Resolve `module::func`, memoized. Equivalent to `gpu.kernel(..)` on a
60    /// miss; a map read on a hit.
61    pub fn kernel(
62        &self,
63        gpu: &dyn GpuBackend,
64        module: &'static str,
65        func: &'static str,
66    ) -> Result<KernelHandle> {
67        if let Some(k) = self.kernels.read().get(&(module, func)) {
68            return Ok(*k);
69        }
70        let handle = gpu.kernel(module, func)?;
71        self.kernels.write().insert((module, func), handle);
72        Ok(handle)
73    }
74
75    /// A scratch allocation of at least `bytes`, memoized under `tag`.
76    ///
77    /// Grow-only: a request larger than the current buffer allocates a new one
78    /// and abandons the old, which is bounded because the sizes that drive it
79    /// (batch × hidden) have a ceiling per model. The abandoned block is
80    /// reclaimed when the context goes, which is the point of scoping the
81    /// cache to the backend.
82    pub fn scratch(
83        &self,
84        gpu: &dyn GpuBackend,
85        tag: &'static str,
86        bytes: usize,
87    ) -> Result<DevicePtr> {
88        let mut g = self.scratch.lock();
89        match g.get(tag) {
90            Some(&(p, sz)) if sz >= bytes => Ok(p),
91            _ => {
92                let p = gpu.alloc(bytes)?;
93                g.insert(tag, (p, bytes));
94                Ok(p)
95            }
96        }
97    }
98
99    /// Has device allocation already failed on this backend?
100    ///
101    /// Retrying a failing `cuMemAlloc` per tensor wastes minutes of load time
102    /// and fragments what is left, so the first failure latches the loader
103    /// onto managed memory. Per BACKEND rather than per process: after a
104    /// model is unloaded the pressure is gone, and the next model's load
105    /// should try device memory again instead of inheriting a UVM sentence
106    /// from a model that is no longer resident.
107    pub fn alloc_fell_back(&self) -> bool {
108        self.alloc_fell_back
109            .load(std::sync::atomic::Ordering::Relaxed)
110    }
111
112    /// Latch the managed-memory fallback for the rest of this model's load.
113    pub fn note_alloc_fallback(&self) {
114        self.alloc_fell_back
115            .store(true, std::sync::atomic::Ordering::Relaxed);
116    }
117
118    /// `true` for the first `n` times this backend reaches `key`.
119    ///
120    /// For the diagnostics that report the first few of something and then
121    /// go quiet. Counted per backend, so a second model reports its own.
122    pub fn first_n(&self, key: &'static str, n: u32) -> bool {
123        let mut g = self.counters.lock();
124        let c = g.entry(key).or_insert(0);
125        *c += 1;
126        *c <= n
127    }
128
129    /// `true` the first time this backend reaches `key`, `false` after.
130    ///
131    /// For the log/dump gates whose call site holds a `GpuBackend` and
132    /// nothing else. Backend-scoped, so a second model re-arms them.
133    pub fn once(&self, key: &'static str) -> bool {
134        self.first_shape(key, 0, 0, 0)
135    }
136
137    /// `true` the first time this backend dispatches `(name, m, n, k)`.
138    /// Diagnostic de-duplication for the GEMM route/shape log lines.
139    pub fn first_shape(&self, name: &str, m: u32, n: u32, k: u32) -> bool {
140        let mut h: u64 = 1469598103934665603;
141        for b in name.bytes() {
142            h = (h ^ b as u64).wrapping_mul(1099511628211);
143        }
144        self.logged_shapes.lock().insert((h, m, n, k))
145    }
146}
147
148impl std::fmt::Debug for OpCache {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        let kernels = self.kernels.read().len();
151        let scratch = self.scratch.lock().len();
152        f.debug_struct("OpCache")
153            .field("kernels", &kernels)
154            .field("scratch", &scratch)
155            .finish()
156    }
157}
158
159/// Release the scratch allocations.
160///
161/// The kernel handles are not freed here: they are module-scoped and die with
162/// the `AtlasRegistry` the backend holds, which `cuda_host::release` unloads
163/// once every handle to it is gone. Freeing them here would be a double-unload.
164impl atlas_core::scope::ModelResource<dyn crate::gpu::GpuBackend> for OpCache {
165    fn label(&self) -> &'static str {
166        "op scratch"
167    }
168
169    fn release(&mut self, gpu: &dyn crate::gpu::GpuBackend) -> anyhow::Result<()> {
170        let mut first_error = None;
171        // `drain` makes this idempotent and stops a later launch from finding a
172        // pointer into freed memory.
173        let taken: Vec<(DevicePtr, usize)> = self
174            .scratch
175            .lock()
176            .drain()
177            .map(|(_, entry)| entry)
178            .collect();
179        for (ptr, _) in taken {
180            if let Err(e) = gpu.free(ptr)
181                && first_error.is_none()
182            {
183                first_error = Some(e);
184            }
185        }
186        match first_error {
187            Some(e) => Err(e),
188            None => Ok(()),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::gpu::mock::MockGpuBackend;
197
198    #[test]
199    fn a_kernel_is_resolved_once_and_reused() {
200        let gpu = MockGpuBackend::new();
201        let c = OpCache::new();
202        let a = c.kernel(&gpu, "w4a16", "bf16_to_fp8").expect("resolves");
203        let b = c.kernel(&gpu, "w4a16", "bf16_to_fp8").expect("resolves");
204        assert_eq!(a.0, b.0);
205    }
206
207    #[test]
208    fn two_caches_do_not_share_handles_or_scratch() {
209        // The property the statics could not have. Each cache belongs to one
210        // backend, so nothing a model resolved is reachable from the next.
211        let gpu = MockGpuBackend::new();
212        let a = OpCache::new();
213        let b = OpCache::new();
214        let _ = a.scratch(&gpu, "fp8_activation", 1024).expect("allocs");
215        assert!(
216            format!("{a:?}").contains("scratch: 1"),
217            "the first cache holds it"
218        );
219        assert!(
220            format!("{b:?}").contains("scratch: 0"),
221            "the second starts empty"
222        );
223    }
224
225    #[test]
226    fn scratch_grows_but_never_shrinks() {
227        let gpu = MockGpuBackend::new();
228        let c = OpCache::new();
229        let small = c.scratch(&gpu, "act", 64).expect("allocs");
230        let same = c.scratch(&gpu, "act", 32).expect("reuses");
231        assert_eq!(small.0, same.0, "a smaller request reuses the buffer");
232        let bigger = c.scratch(&gpu, "act", 4096).expect("reallocs");
233        assert_ne!(small.0, bigger.0, "a larger request gets a new buffer");
234    }
235}