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}