Skip to main content

oxicuda_levelzero/
module_cache.rs

1//! Host-side module-binary cache and EU-occupancy heuristics.
2//!
3//! Compiling SPIR-V to an Intel native binary (the `zeModuleCreate` JIT) is
4//! expensive. Level Zero lets an application retrieve the compiled native
5//! binary via `zeModuleGetNativeBinary` and re-create the module from it on a
6//! later run, skipping the JIT. The *cache bookkeeping* — keying compiled
7//! binaries by a stable hash of the SPIR-V input and build flags, tracking hit
8//! / miss statistics — is pure host-side logic modelled here by
9//! [`ModuleBinaryCache`].
10//!
11//! This module also provides [`EuOccupancyAdvisor`], a tile-size heuristic that
12//! consumes an EU-utilisation fraction (which on hardware comes from sysman
13//! `zesDeviceProcessesGetState`) and recommends a workgroup size. The heuristic
14//! itself is CPU-testable; only the utilisation *reading* is device-gated.
15
16use std::collections::HashMap;
17
18use crate::error::{LevelZeroError, LevelZeroResult};
19
20// ─── Stable SPIR-V hashing ───────────────────────────────────
21
22/// 64-bit FNV-1a hash over a SPIR-V word stream and its build flags.
23///
24/// FNV-1a is deterministic across runs and platforms (unlike `DefaultHasher`,
25/// whose seed varies), so the same SPIR-V + flags always produce the same key.
26/// That stability is what makes an on-disk native-binary cache valid across
27/// process invocations.
28#[must_use]
29pub fn spirv_cache_key(spirv: &[u32], build_flags: &str) -> u64 {
30    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
31    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
32    let mut hash = FNV_OFFSET;
33    for &word in spirv {
34        for byte in word.to_le_bytes() {
35            hash ^= u64::from(byte);
36            hash = hash.wrapping_mul(FNV_PRIME);
37        }
38    }
39    // Mix the build flags so e.g. `-ze-opt-disable` keys a distinct binary.
40    for byte in build_flags.as_bytes() {
41        hash ^= u64::from(*byte);
42        hash = hash.wrapping_mul(FNV_PRIME);
43    }
44    hash
45}
46
47// ─── A cached native binary ──────────────────────────────────
48
49/// A compiled Intel native binary retrieved from `zeModuleGetNativeBinary`.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NativeBinary {
52    /// The opaque native-binary blob (driver-specific ISA + metadata).
53    pub bytes: Vec<u8>,
54    /// Build flags the binary was compiled with (part of its identity).
55    pub build_flags: String,
56}
57
58impl NativeBinary {
59    /// Wrap a native-binary blob compiled with `build_flags`.
60    #[must_use]
61    pub fn new(bytes: Vec<u8>, build_flags: impl Into<String>) -> Self {
62        Self {
63            bytes,
64            build_flags: build_flags.into(),
65        }
66    }
67
68    /// Size of the native binary in bytes.
69    #[must_use]
70    pub fn len(&self) -> usize {
71        self.bytes.len()
72    }
73
74    /// `true` when the binary is empty (an invalid/placeholder entry).
75    #[must_use]
76    pub fn is_empty(&self) -> bool {
77        self.bytes.is_empty()
78    }
79}
80
81// ─── Module-binary cache ─────────────────────────────────────
82
83/// An in-memory cache mapping `(SPIR-V, build flags)` to compiled native
84/// binaries, with hit/miss accounting.
85///
86/// A backend looks up a key before calling `zeModuleCreate`; on a miss it JITs
87/// the SPIR-V, retrieves the native binary, and [`insert`](Self::insert)s it so
88/// later identical modules skip the JIT. The cache is the host-side half of
89/// `zeModuleGetNativeBinary` persistence — serialising the map to disk (a
90/// trivial extension) gives cross-run reuse.
91#[derive(Debug, Default)]
92pub struct ModuleBinaryCache {
93    entries: HashMap<u64, NativeBinary>,
94    hits: u64,
95    misses: u64,
96}
97
98impl ModuleBinaryCache {
99    /// An empty cache.
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Number of cached binaries.
106    #[must_use]
107    pub fn len(&self) -> usize {
108        self.entries.len()
109    }
110
111    /// `true` when nothing is cached.
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// Cumulative cache hits.
118    #[must_use]
119    pub fn hits(&self) -> u64 {
120        self.hits
121    }
122
123    /// Cumulative cache misses.
124    #[must_use]
125    pub fn misses(&self) -> u64 {
126        self.misses
127    }
128
129    /// Hit rate in `[0, 1]`; `0.0` when there have been no lookups.
130    #[must_use]
131    pub fn hit_rate(&self) -> f64 {
132        let total = self.hits + self.misses;
133        if total == 0 {
134            0.0
135        } else {
136            self.hits as f64 / total as f64
137        }
138    }
139
140    /// Look up a compiled binary for `spirv` + `build_flags`, recording a
141    /// hit or miss. Returns `None` on a miss (the caller must JIT and insert).
142    pub fn lookup(&mut self, spirv: &[u32], build_flags: &str) -> Option<&NativeBinary> {
143        let key = spirv_cache_key(spirv, build_flags);
144        if self.entries.contains_key(&key) {
145            self.hits += 1;
146            self.entries.get(&key)
147        } else {
148            self.misses += 1;
149            None
150        }
151    }
152
153    /// Insert a compiled native binary for `spirv`, returning its cache key.
154    ///
155    /// The binary's own `build_flags` are folded into the key so two builds of
156    /// the same SPIR-V with different flags do not collide.
157    pub fn insert(&mut self, spirv: &[u32], binary: NativeBinary) -> u64 {
158        let key = spirv_cache_key(spirv, &binary.build_flags);
159        self.entries.insert(key, binary);
160        key
161    }
162
163    /// Fetch a binary by its precomputed key without affecting statistics.
164    #[must_use]
165    pub fn get(&self, key: u64) -> Option<&NativeBinary> {
166        self.entries.get(&key)
167    }
168
169    /// Remove all cached binaries (statistics are preserved).
170    pub fn clear(&mut self) {
171        self.entries.clear();
172    }
173
174    /// Total bytes occupied by all cached native binaries.
175    #[must_use]
176    pub fn total_bytes(&self) -> usize {
177        self.entries.values().map(NativeBinary::len).sum()
178    }
179}
180
181// ─── EU occupancy advisor ────────────────────────────────────
182
183/// Recommends a workgroup size from an EU-utilisation fraction.
184///
185/// When EUs are already busy (high utilisation) a smaller workgroup reduces
186/// per-launch pressure and improves overlap; when EUs are idle a larger
187/// workgroup maximises throughput. The advisor clamps recommendations to the
188/// device's `[min, max]` workgroup-size envelope and to powers of two (Intel
189/// sub-group sizes are 8/16/32, so workgroup sizes are typically power-of-two
190/// multiples).
191#[derive(Debug, Clone)]
192pub struct EuOccupancyAdvisor {
193    min_wg: u32,
194    max_wg: u32,
195    eu_count: u32,
196}
197
198impl EuOccupancyAdvisor {
199    /// Create an advisor for a device with `eu_count` execution units and a
200    /// workgroup-size envelope of `[min_wg, max_wg]` (both powers of two).
201    pub fn new(eu_count: u32, min_wg: u32, max_wg: u32) -> LevelZeroResult<Self> {
202        if eu_count == 0 {
203            return Err(LevelZeroError::InvalidArgument(
204                "EU count must be > 0".into(),
205            ));
206        }
207        if min_wg == 0 || max_wg == 0 || !min_wg.is_power_of_two() || !max_wg.is_power_of_two() {
208            return Err(LevelZeroError::InvalidArgument(
209                "workgroup-size bounds must be non-zero powers of two".into(),
210            ));
211        }
212        if min_wg > max_wg {
213            return Err(LevelZeroError::InvalidArgument(
214                "min workgroup size exceeds max".into(),
215            ));
216        }
217        Ok(Self {
218            min_wg,
219            max_wg,
220            eu_count,
221        })
222    }
223
224    /// The device EU count.
225    #[must_use]
226    pub fn eu_count(&self) -> u32 {
227        self.eu_count
228    }
229
230    /// Recommend a workgroup size given the current EU-utilisation fraction.
231    ///
232    /// `utilisation` is clamped to `[0, 1]`. At full idle (`0.0`) the advisor
233    /// returns `max_wg`; at full saturation (`1.0`) it returns `min_wg`;
234    /// intermediate utilisations interpolate geometrically (halving per
235    /// quartile of busy-ness) and snap down to the nearest power of two within
236    /// the envelope.
237    #[must_use]
238    pub fn recommend_workgroup_size(&self, utilisation: f64) -> u32 {
239        let u = utilisation.clamp(0.0, 1.0);
240        // Geometric scale: idle → max, busy → progressively smaller.
241        // shift = round(u * log2(max/min)); recommended = max >> shift.
242        let span_log2 = (self.max_wg / self.min_wg).trailing_zeros();
243        let shift = (u * f64::from(span_log2)).round() as u32;
244        let candidate = (self.max_wg >> shift).max(self.min_wg);
245        // Snap to power of two within the envelope.
246        let snapped = if candidate.is_power_of_two() {
247            candidate
248        } else {
249            candidate.next_power_of_two()
250        };
251        snapped.clamp(self.min_wg, self.max_wg)
252    }
253
254    /// Estimate the number of concurrent workgroups the device can host for a
255    /// given workgroup size, assuming one sub-group of `subgroup_size` per EU
256    /// thread. This feeds the dispatch grid sizing.
257    #[must_use]
258    pub fn estimated_resident_workgroups(&self, workgroup_size: u32, subgroup_size: u32) -> u32 {
259        if workgroup_size == 0 || subgroup_size == 0 {
260            return 0;
261        }
262        let subgroups_per_wg = workgroup_size.div_ceil(subgroup_size).max(1);
263        // Each EU can host (roughly) one sub-group concurrently in this model.
264        (self.eu_count / subgroups_per_wg).max(1)
265    }
266}
267
268// ─── Tests ───────────────────────────────────────────────────
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn cache_key_is_deterministic_and_flag_sensitive() {
276        let spirv = [0x0723_0203, 0x0001_0200, 1, 2, 3];
277        let k1 = spirv_cache_key(&spirv, "");
278        let k2 = spirv_cache_key(&spirv, "");
279        assert_eq!(k1, k2, "same input → same key");
280        let k3 = spirv_cache_key(&spirv, "-ze-opt-disable");
281        assert_ne!(k1, k3, "build flags must change the key");
282        let mut other = spirv;
283        other[4] = 99;
284        assert_ne!(
285            k1,
286            spirv_cache_key(&other, ""),
287            "different SPIR-V → different key"
288        );
289    }
290
291    #[test]
292    fn native_binary_fields() {
293        let b = NativeBinary::new(vec![1, 2, 3, 4], "-cl-fast-relaxed-math");
294        assert_eq!(b.len(), 4);
295        assert!(!b.is_empty());
296        assert_eq!(b.build_flags, "-cl-fast-relaxed-math");
297        assert!(NativeBinary::new(vec![], "").is_empty());
298    }
299
300    #[test]
301    fn cache_miss_then_hit() {
302        let mut cache = ModuleBinaryCache::new();
303        assert!(cache.is_empty());
304        let spirv = [0x0723_0203, 1, 2, 3, 4];
305
306        // First lookup misses.
307        assert!(cache.lookup(&spirv, "").is_none());
308        assert_eq!(cache.misses(), 1);
309        assert_eq!(cache.hits(), 0);
310
311        // Insert and look up again → hit.
312        let key = cache.insert(&spirv, NativeBinary::new(vec![9, 8, 7], ""));
313        assert_eq!(cache.len(), 1);
314        let got = cache.lookup(&spirv, "").expect("hit");
315        assert_eq!(got.bytes, vec![9, 8, 7]);
316        assert_eq!(cache.hits(), 1);
317        assert_eq!(cache.get(key).map(NativeBinary::len), Some(3));
318        assert_eq!(cache.total_bytes(), 3);
319    }
320
321    #[test]
322    fn cache_hit_rate_and_clear() {
323        let mut cache = ModuleBinaryCache::new();
324        assert_eq!(cache.hit_rate(), 0.0);
325        let spirv = [1u32, 2, 3, 4, 5];
326        cache.insert(&spirv, NativeBinary::new(vec![1], ""));
327        assert!(cache.lookup(&spirv, "").is_some()); // hit
328        assert!(cache.lookup(&[9u32, 9, 9, 9, 9], "").is_none()); // miss
329        assert!(cache.lookup(&[9u32, 9, 9, 9, 9], "").is_none()); // miss
330        // 1 hit, 2 misses → 1/3.
331        assert!((cache.hit_rate() - (1.0 / 3.0)).abs() < 1e-9);
332        cache.clear();
333        assert!(cache.is_empty());
334        // Statistics survive clear.
335        assert_eq!(cache.hits(), 1);
336    }
337
338    #[test]
339    fn cache_distinguishes_build_flags() {
340        let mut cache = ModuleBinaryCache::new();
341        let spirv = [1u32, 2, 3, 4, 5];
342        cache.insert(&spirv, NativeBinary::new(vec![1], ""));
343        cache.insert(&spirv, NativeBinary::new(vec![2, 2], "-O3"));
344        // Two distinct entries despite identical SPIR-V.
345        assert_eq!(cache.len(), 2);
346        assert_eq!(cache.lookup(&spirv, "").unwrap().bytes, vec![1]);
347        assert_eq!(cache.lookup(&spirv, "-O3").unwrap().bytes, vec![2, 2]);
348    }
349
350    #[test]
351    fn occupancy_advisor_idle_vs_busy() {
352        let adv = EuOccupancyAdvisor::new(512, 8, 256).unwrap();
353        assert_eq!(adv.eu_count(), 512);
354        // Idle → maximum workgroup.
355        assert_eq!(adv.recommend_workgroup_size(0.0), 256);
356        // Fully busy → minimum workgroup.
357        assert_eq!(adv.recommend_workgroup_size(1.0), 8);
358        // Midway → somewhere strictly between, power of two.
359        let mid = adv.recommend_workgroup_size(0.5);
360        assert!((8..=256).contains(&mid));
361        assert!(mid.is_power_of_two());
362    }
363
364    #[test]
365    fn occupancy_advisor_monotonic() {
366        let adv = EuOccupancyAdvisor::new(128, 16, 512).unwrap();
367        let low = adv.recommend_workgroup_size(0.1);
368        let high = adv.recommend_workgroup_size(0.9);
369        assert!(
370            low >= high,
371            "busier device should not recommend a larger workgroup (low={low}, high={high})"
372        );
373        // Out-of-range utilisation is clamped, not panicking.
374        assert_eq!(adv.recommend_workgroup_size(-5.0), 512);
375        assert_eq!(adv.recommend_workgroup_size(99.0), 16);
376    }
377
378    #[test]
379    fn occupancy_advisor_resident_estimate() {
380        let adv = EuOccupancyAdvisor::new(512, 8, 256).unwrap();
381        // wg 32, subgroup 16 → 2 subgroups/wg → 256 resident.
382        assert_eq!(adv.estimated_resident_workgroups(32, 16), 256);
383        // Degenerate inputs return 0.
384        assert_eq!(adv.estimated_resident_workgroups(0, 16), 0);
385        assert_eq!(adv.estimated_resident_workgroups(32, 0), 0);
386    }
387
388    #[test]
389    fn occupancy_advisor_rejects_bad_args() {
390        assert!(EuOccupancyAdvisor::new(0, 8, 256).is_err());
391        assert!(EuOccupancyAdvisor::new(128, 0, 256).is_err());
392        assert!(
393            EuOccupancyAdvisor::new(128, 24, 256).is_err(),
394            "non-pow2 min"
395        );
396        assert!(EuOccupancyAdvisor::new(128, 512, 256).is_err(), "min > max");
397    }
398}