metrale_gpu_runtime/op_cache.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: [`OpCache`]: per-backend memo of kernel handles, grow-only device
4//! scratch, and the once / first-N gates of diagnostics.
5//!
6//! A kernel handle points into a module owned by the backend's registry, and a
7//! scratch pointer is a device allocation of the backend. Keeping both on the
8//! backend, not in a process-wide static, stops a later model from reaching
9//! handles or scratch of a model that is gone.
10//!
11//! Owner: gpu-runtime.
12//! Invariants:
13//! - One `OpCache` per backend: `MetraleCudaBackend`, `MetalGpuBackend` and
14//! `MockGpuBackend` each construct their own.
15//! - A scratch tag's buffer only grows: a request that fits returns the stored
16//! pointer.
17
18use std::collections::HashMap;
19// 2026-09-25: parking_lot locks do not poison, so a panic in one op leaves later
20// lookups and teardown usable.
21use parking_lot::{Mutex, RwLock};
22
23use crate::gpu::{DevicePtr, GpuBackend, KernelHandle};
24use anyhow::Result;
25
26/// 2026-09-25: Memoized kernel handles and scratch allocations for one backend.
27#[derive(Default)]
28pub struct OpCache {
29 /// 2026-09-25: `(module, function)` to resolved handle. An entry is written on
30 /// the first lookup of its key and only read after that.
31 kernels: RwLock<HashMap<(&'static str, &'static str), KernelHandle>>,
32 /// 2026-09-25: Purpose tag to `(pointer, bytes)`. Grow-only.
33 scratch: Mutex<HashMap<&'static str, (DevicePtr, usize)>>,
34 /// 2026-09-25: Set by `note_alloc_fallback`, after a loader's device allocation failed.
35 alloc_fell_back: std::sync::atomic::AtomicBool,
36 /// 2026-09-25: `(name hash, m, n, k)` keys already seen by `first_shape`, and by
37 /// `once` as `(hash, 0, 0, 0)`. Per backend, so a new model logs its own first
38 /// line for each shape.
39 logged_shapes: Mutex<std::collections::HashSet<(u64, u32, u32, u32)>>,
40 /// 2026-09-25: Per-key call counts for `first_n`.
41 counters: Mutex<HashMap<&'static str, u32>>,
42 /// 2026-09-29: Kernel handle to the output-column (N) tile width the kernel publishes
43 /// as `<entry>_n_tile`, recorded when the handle is resolved. Launch wrappers size
44 /// their grid from it (`GpuBackend::kernel_n_tile`).
45 n_tiles: RwLock<HashMap<u64, u32>>,
46 /// 2026-09-29: Kernel handles whose module publishes `<entry>_a_e4m3`: the kernel casts
47 /// its BF16 A operand to E4M3 with no scale (`GpuBackend::kernel_casts_a_to_e4m3`).
48 a_e4m3: RwLock<std::collections::HashSet<u64>>,
49}
50
51impl OpCache {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// 2026-09-29: Record the N tile `kernel` publishes; a later value for the same
57 /// handle replaces it.
58 pub fn record_n_tile(&self, kernel: KernelHandle, n_tile: u32) {
59 self.n_tiles.write().insert(kernel.0, n_tile);
60 }
61
62 /// 2026-09-29: The N tile recorded for `kernel`, or `None` when it publishes none.
63 pub fn n_tile(&self, kernel: KernelHandle) -> Option<u32> {
64 self.n_tiles.read().get(&kernel.0).copied()
65 }
66
67 /// 2026-09-29: Record that `kernel` casts its A operand to E4M3 with no scale.
68 pub fn record_a_e4m3(&self, kernel: KernelHandle) {
69 self.a_e4m3.write().insert(kernel.0);
70 }
71
72 /// 2026-09-29: Whether `kernel` was recorded as casting its A operand to E4M3.
73 pub fn a_e4m3(&self, kernel: KernelHandle) -> bool {
74 self.a_e4m3.read().contains(&kernel.0)
75 }
76
77 /// 2026-09-25: Resolve `module::func`, memoized: `gpu.kernel(..)` on a miss, a
78 /// map read on a hit.
79 pub fn kernel(
80 &self,
81 gpu: &dyn GpuBackend,
82 module: &'static str,
83 func: &'static str,
84 ) -> Result<KernelHandle> {
85 if let Some(k) = self.kernels.read().get(&(module, func)) {
86 return Ok(*k);
87 }
88 let handle = gpu.kernel(module, func)?;
89 self.kernels.write().insert((module, func), handle);
90 Ok(handle)
91 }
92
93 /// 2026-09-25: A scratch allocation of at least `bytes`, memoized under `tag`.
94 ///
95 /// Grow-only: a larger request allocates a new buffer and drops the old pointer
96 /// from the cache without freeing it. On the CUDA backend the old buffer stays
97 /// on the allocation ledger until `sweep_unreleased` frees it when the backend
98 /// drops.
99 pub fn scratch(
100 &self,
101 gpu: &dyn GpuBackend,
102 tag: &'static str,
103 bytes: usize,
104 ) -> Result<DevicePtr> {
105 let mut g = self.scratch.lock();
106 match g.get(tag) {
107 Some(&(p, sz)) if sz >= bytes => Ok(p),
108 _ => {
109 let p = gpu.alloc(bytes)?;
110 g.insert(tag, (p, bytes));
111 Ok(p)
112 }
113 }
114 }
115
116 /// 2026-09-25: Whether a loader latched the managed-memory fallback on this
117 /// backend. The latch is per backend, so the next model's load tries device
118 /// memory again.
119 pub fn alloc_fell_back(&self) -> bool {
120 self.alloc_fell_back
121 .load(std::sync::atomic::Ordering::Relaxed)
122 }
123
124 /// 2026-09-25: Latch the managed-memory fallback for the rest of this backend's life.
125 pub fn note_alloc_fallback(&self) {
126 self.alloc_fell_back
127 .store(true, std::sync::atomic::Ordering::Relaxed);
128 }
129
130 /// 2026-09-25: `true` for the first `n` times this backend reaches `key`.
131 /// Counted per backend, so a second model reports its own.
132 pub fn first_n(&self, key: &'static str, n: u32) -> bool {
133 let mut g = self.counters.lock();
134 let c = g.entry(key).or_insert(0);
135 *c += 1;
136 *c <= n
137 }
138
139 /// 2026-09-25: `true` the first time this backend reaches `key`, `false` after.
140 /// Shares `first_shape`'s set, as `(key, 0, 0, 0)`.
141 pub fn once(&self, key: &'static str) -> bool {
142 self.first_shape(key, 0, 0, 0)
143 }
144
145 /// 2026-09-25: `true` the first time this backend sees `(name, m, n, k)`; used to
146 /// de-duplicate the GEMM route and shape log lines.
147 pub fn first_shape(&self, name: &str, m: u32, n: u32, k: u32) -> bool {
148 let mut h: u64 = 1469598103934665603;
149 for b in name.bytes() {
150 h = (h ^ b as u64).wrapping_mul(1099511628211);
151 }
152 self.logged_shapes.lock().insert((h, m, n, k))
153 }
154}
155
156impl std::fmt::Debug for OpCache {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 let kernels = self.kernels.read().len();
159 let scratch = self.scratch.lock().len();
160 f.debug_struct("OpCache")
161 .field("kernels", &kernels)
162 .field("scratch", &scratch)
163 .finish()
164 }
165}
166
167/// 2026-09-25: Frees every scratch allocation and empties the scratch map.
168///
169/// Kernel handles need no release here: they point into the modules of the
170/// backend's `MetraleRegistry`, which unloads them when it is dropped or passed
171/// to `cuda_host::release`.
172impl metrale_core::scope::ModelResource<dyn crate::gpu::GpuBackend> for OpCache {
173 fn label(&self) -> &'static str {
174 "op scratch"
175 }
176
177 fn release(&mut self, gpu: &dyn crate::gpu::GpuBackend) -> anyhow::Result<()> {
178 let mut first_error = None;
179 // 2026-09-25: `drain` makes this idempotent and stops a later lookup from
180 // finding a pointer into freed memory.
181 let taken: Vec<(DevicePtr, usize)> = self
182 .scratch
183 .lock()
184 .drain()
185 .map(|(_, entry)| entry)
186 .collect();
187 for (ptr, _) in taken {
188 if let Err(e) = gpu.free(ptr)
189 && first_error.is_none()
190 {
191 first_error = Some(e);
192 }
193 }
194 match first_error {
195 Some(e) => Err(e),
196 None => Ok(()),
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::gpu::mock::MockGpuBackend;
205
206 #[test]
207 fn a_kernel_is_resolved_once_and_reused() {
208 let gpu = MockGpuBackend::new();
209 let c = OpCache::new();
210 let a = c.kernel(&gpu, "w4a16", "bf16_to_fp8").expect("resolves");
211 let b = c.kernel(&gpu, "w4a16", "bf16_to_fp8").expect("resolves");
212 assert_eq!(a.0, b.0);
213 }
214
215 #[test]
216 fn two_caches_do_not_share_handles_or_scratch() {
217 let gpu = MockGpuBackend::new();
218 let a = OpCache::new();
219 let b = OpCache::new();
220 let _ = a.scratch(&gpu, "fp8_activation", 1024).expect("allocs");
221 assert!(
222 format!("{a:?}").contains("scratch: 1"),
223 "the first cache holds it"
224 );
225 assert!(
226 format!("{b:?}").contains("scratch: 0"),
227 "the second starts empty"
228 );
229 }
230
231 #[test]
232 fn scratch_grows_but_never_shrinks() {
233 let gpu = MockGpuBackend::new();
234 let c = OpCache::new();
235 let small = c.scratch(&gpu, "act", 64).expect("allocs");
236 let same = c.scratch(&gpu, "act", 32).expect("reuses");
237 assert_eq!(small.0, same.0, "a smaller request reuses the buffer");
238 let bigger = c.scratch(&gpu, "act", 4096).expect("reallocs");
239 assert_ne!(small.0, bigger.0, "a larger request gets a new buffer");
240 }
241}