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}