tensor_wasm_jit/cache.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Compiled-kernel cache.
4//!
5//! The cache is keyed by `(blueprint_fingerprint, sm_version)`. LRU eviction
6//! caps the entry count. On a cache hit the PTX text is reused without
7//! re-emitting (cheap) and without re-compiling via `ptxas`/`cust` (expensive
8//! — 10-50 ms for non-trivial kernels).
9//!
10//! Storage: a [`dashmap::DashMap`] holds the actual `(CacheKey, CachedKernel)`
11//! entries — `get` / `put` / `len` go straight through the lock-free
12//! per-shard locks so the hot path is uncontended under multi-threaded
13//! dispatch. A separate `parking_lot::Mutex<LruCache<CacheKey, ()>>` is the
14//! eviction queue — touched on `put` (insert/promote) and on `get` (promote)
15//! and used to compute which key to evict when the soft cap is exceeded.
16//! Splitting storage from policy means lookups never block on the eviction
17//! mutex, only inserts that need to evict do. The `get` read path is
18//! lock-free on contention; LRU promotion is best-effort under load —
19//! contended promotions are skipped, so eviction order is approximately
20//! (not strictly) LRU when many readers race.
21//!
22//! `Mutex` poisoning recovery: every lock acquisition uses `into_inner` on a
23//! poisoned guard (after emitting a `tracing::error!`) rather than the prior
24//! `.expect("cache poisoned")` panic — a single panic on any thread used to
25//! poison the entire cache for the rest of the process.
26
27use std::num::NonZeroUsize;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::Arc;
31
32use dashmap::DashMap;
33use lru::LruCache;
34use parking_lot::Mutex;
35use tensor_wasm_artifacts::{ArtifactError, ArtifactStore, ContentHash, DiskArtifactStore};
36use tensor_wasm_core::types::TenantId;
37use zeroize::Zeroizing;
38
39use crate::ir::TensorWasmKernelBlueprint;
40use crate::ptx_emit::EmittedPtx;
41#[cfg(feature = "kernel-registry")]
42use crate::registry::{BlueprintResolver, KernelRegistry};
43
44/// Cache key.
45///
46/// `tenant_id` is the first field so that it dominates the derived `Hash`
47/// (field-order) and lexicographic `Ord` orderings. Keeping the cache keyed
48/// by tenant is the only thing preventing tenant A from looking up — and on
49/// the CUDA path executing — a compiled kernel that tenant B installed
50/// (exec S-7, cross-tenant confused-deputy). Every host-side `get` / `put`
51/// MUST therefore include the calling tenant; constructing a key without
52/// one (e.g. directly from guest-supplied bytes) is the bug we are fixing.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub struct CacheKey {
55 /// Owning tenant. Cache lookups MUST be scoped to the caller's tenant —
56 /// see the type-level docs. Use [`CacheKey::for_tenant`] to construct.
57 pub tenant_id: u64,
58 /// `TensorWasmKernelBlueprint::fingerprint()`.
59 pub blueprint: u64,
60 /// CUDA compute capability (e.g. 80 for sm_80, 89 for sm_89).
61 pub sm_version: u32,
62 /// Hash of the full [`crate::ptx_emit::EmitConfig`] used at emit time
63 /// (jit S-2). `sm_version` covers the compute capability number but NOT
64 /// the architecture suffix (e.g. `"sm_80"` vs `"sm_80a"`), the PTX
65 /// language version, or the `launch_bounds` flag — two `EmitConfig`s
66 /// that differ in any of those produce non-interchangeable PTX. Without
67 /// this field two such configs would collide on the same key and the
68 /// second caller would silently get the first caller's PTX.
69 ///
70 /// Callers that don't have an `EmitConfig` (rewriter pre-population,
71 /// benches) pass `0`. Construct via [`CacheKey::for_tenant`] (defaults
72 /// to `0`) or [`CacheKey::for_tenant_with_emit_config`] (computes a
73 /// stable hash over the config).
74 pub emit_config_hash: u64,
75}
76
77impl CacheKey {
78 /// Construct a tenant-scoped cache key with no `EmitConfig` hash.
79 ///
80 /// Equivalent to passing `emit_config_hash: 0` — appropriate for the
81 /// rewriter and bench paths that use the default emitter config. The
82 /// `tenant_id` MUST come from trusted store state (e.g.
83 /// `InstanceState::tenant_id`), never from guest-supplied fingerprint
84 /// bytes. See the [`CacheKey`] docs for the confused-deputy primitive
85 /// this guards against.
86 pub fn for_tenant(tenant_id: TenantId, blueprint: u64, sm_version: u32) -> Self {
87 Self {
88 tenant_id: tenant_id.get(),
89 blueprint,
90 sm_version,
91 emit_config_hash: 0,
92 }
93 }
94
95 /// Construct a tenant-scoped cache key that also covers the emitter
96 /// config. Use this when the lookup must distinguish between
97 /// PTX-version variants, target-architecture suffixes, or
98 /// launch-bounds settings (jit S-2).
99 ///
100 /// Cost note: each call builds a fresh `blake3::Hasher` and finalises
101 /// over a handful of bytes. The amortised wall cost is ~µs on a
102 /// modern x86-64 box — negligible compared to a kernel dispatch but
103 /// non-zero on the hot path. Callers that resolve the same
104 /// `EmitConfig` for every lookup (the typical pattern: emit-config is
105 /// pinned at instance-spawn time) should hash it once at spawn and
106 /// reuse the [`CacheKey`] rather than re-deriving it for every
107 /// dispatch. The hasher itself is intentionally inline here (not
108 /// memoised) so the function stays pure and `Send`-friendly for the
109 /// rewriter's `rayon::par_iter` callers.
110 pub fn for_tenant_with_emit_config(
111 tenant_id: TenantId,
112 blueprint: u64,
113 sm_version: u32,
114 cfg: &crate::ptx_emit::EmitConfig,
115 ) -> Self {
116 let emit_config_hash = blake3::Hasher::new()
117 .update(b"tensor-wasm-jit::EmitConfig::v1\0")
118 .update(cfg.target.as_bytes())
119 .update(b"\0")
120 .update(cfg.ptx_version.as_bytes())
121 .update(b"\0")
122 .update(&[u8::from(cfg.launch_bounds)])
123 .finalize();
124 let bytes = emit_config_hash.as_bytes();
125 let mut buf = [0u8; 8];
126 buf.copy_from_slice(&bytes[..8]);
127 Self {
128 tenant_id: tenant_id.get(),
129 blueprint,
130 sm_version,
131 emit_config_hash: u64::from_le_bytes(buf),
132 }
133 }
134}
135
136/// Default cache capacity (kernels).
137///
138/// Memory-ceiling note: each entry holds an `Arc<EmittedPtx>` whose `text`
139/// is the emitted PTX string. Typical kernels emit ~5-15 KB of PTX (a
140/// vector-add lands around 2 KB; a small fused matmul around 12 KB), so at
141/// 256 entries the steady-state L1 footprint is on the order of ~2.5 MB
142/// (~10 KB PTX × 256) plus the per-entry `BLAKE3` hash (32 B) and the
143/// LRU policy queue (one `CacheKey` per entry, 24 B). Hostile or
144/// pathological blueprints could push individual entries into the
145/// multi-MB range — a deliberately unrolled blueprint emitting 10 MB of
146/// PTX would push the 256-slot cache to ~2.5 GB. Operators expecting
147/// adversarial workloads should clamp this via
148/// [`KernelCache::with_capacity`] (lower) and pair it with the on-disk
149/// L2 cache so cold lookups still hit a persisted path. The cache does
150/// NOT enforce a per-entry byte limit; the count cap is the only knob.
151pub const DEFAULT_CAPACITY: usize = 256;
152
153/// Construction-time configuration for [`KernelCache`].
154///
155/// Holds the small set of policy knobs the cache supports today: capacity
156/// (count cap) and whether to recompute the per-entry BLAKE3 integrity
157/// hash on every `get`. Future knobs (per-byte cap, eviction policy
158/// choice) will land here without breaking the existing
159/// [`KernelCache::with_capacity`] / [`KernelCache::with_disk_persistence`]
160/// shorthands — those construct an equivalent `KernelCacheConfig` under
161/// the hood.
162///
163/// Construct via [`KernelCacheConfig::default`] (which mirrors the
164/// historical defaults — capacity [`DEFAULT_CAPACITY`], verify-on-get
165/// `true`) and refine with the `with_*` builders, then hand the config
166/// to [`KernelCache::with_config`].
167#[derive(Clone)]
168pub struct KernelCacheConfig {
169 /// Soft maximum L1 entry count. Clamped to `>= 1` inside the cache.
170 pub capacity: usize,
171 /// Optional soft maximum on the *total* bytes of cached PTX text held
172 /// in L1, summed across every live entry (each entry contributes
173 /// `cached.ptx.text.len()`). When `Some(cap)`, [`KernelCache::put`]
174 /// evicts LRU entries — using the same eviction queue the count cap
175 /// drives — until the running byte total fits under `cap`, *after*
176 /// admitting the new entry. The count cap ([`Self::capacity`]) still
177 /// applies independently; whichever cap binds first wins.
178 ///
179 /// Why this exists: the count cap alone is a DoS vector. A 256-slot
180 /// cache fed adversarial multi-MB PTX blueprints (a deliberately
181 /// unrolled kernel can emit 10 MB of PTX) reaches ~2.5 GB of resident
182 /// L1 — see the memory-ceiling note on [`DEFAULT_CAPACITY`]. The byte
183 /// cap bounds the worst case regardless of per-entry size.
184 ///
185 /// A single entry larger than `cap` is still admitted (the cache never
186 /// refuses an insert outright — it would otherwise wedge the dispatch
187 /// path) but it will evict every other entry first; the byte total may
188 /// transiently exceed `cap` by at most one such oversized entry.
189 ///
190 /// Default `None` (preserves the historical count-only behaviour). Set
191 /// via [`Self::with_max_total_bytes`]. Track the live total via
192 /// [`KernelCache::total_bytes`].
193 pub max_total_bytes: Option<u64>,
194 /// When `true` (the default), [`KernelCache::get`] recomputes a
195 /// BLAKE3 over the cached `ptx.text` on every L1 hit and compares
196 /// the result against the entry's stored `integrity_hash` (jit S-3
197 /// in-mem poisoning defence). When `false`, the recompute is skipped
198 /// — the cache still refuses entries whose stored hash is all-zero
199 /// (the construction signal for "built without [`CachedKernel::new`]")
200 /// as defence-in-depth.
201 ///
202 /// The recompute costs ~10 µs over a typical multi-KB PTX blob;
203 /// skipping it shaves that off every L1 hit at the cost of widening
204 /// the in-memory poisoning window from "one `get` call" to "the
205 /// lifetime of the entry in L1". Operators on a high-QPS path with
206 /// multi-MB PTX where the recompute dominates can opt out, but the
207 /// safe default is verify-on-get.
208 pub verify_on_get: bool,
209
210 #[cfg(feature = "kernel-registry")]
211 /// Optional registry consulted on L1+L2 miss. v0.4 path: caller resolves
212 /// (tenant, blueprint, sm_version) → (name, version) via an external
213 /// lookup, then `KernelCache::get_with_registry_fallback` consults the
214 /// registry by that pair. v0.3.8 ships a `resolve_by_blueprint_hint`
215 /// trait method on the cache config for the resolver step; the
216 /// in-memory test impl resolves blueprint fingerprint → name@version
217 /// directly via a `HashMap`.
218 pub registry: Option<Arc<dyn KernelRegistry>>,
219}
220
221impl Default for KernelCacheConfig {
222 fn default() -> Self {
223 Self {
224 capacity: DEFAULT_CAPACITY,
225 max_total_bytes: None,
226 verify_on_get: true,
227 #[cfg(feature = "kernel-registry")]
228 registry: None,
229 }
230 }
231}
232
233impl KernelCacheConfig {
234 /// Override the L1 entry count cap. Clamped to `>= 1` at cache
235 /// construction time; values below 1 are silently raised.
236 #[must_use]
237 pub fn with_capacity(mut self, capacity: usize) -> Self {
238 self.capacity = capacity;
239 self
240 }
241
242 /// Set the soft total-bytes cap on resident L1 PTX. `None` (the
243 /// default) preserves the historical count-only behaviour; `Some(cap)`
244 /// makes [`KernelCache::put`] evict LRU entries until the running PTX
245 /// byte total fits under `cap`. See the field-level docs on
246 /// [`Self::max_total_bytes`] for the DoS-mitigation rationale and the
247 /// single-oversized-entry caveat.
248 #[must_use]
249 pub fn with_max_total_bytes(mut self, max_total_bytes: u64) -> Self {
250 self.max_total_bytes = Some(max_total_bytes);
251 self
252 }
253
254 /// Toggle the per-`get` BLAKE3 recompute. Default `true`; setting
255 /// `false` is the high-QPS opt-out. See the field-level docs on
256 /// [`Self::verify_on_get`] for the threat-model trade-off.
257 #[must_use]
258 pub fn with_verify_on_get(mut self, on: bool) -> Self {
259 self.verify_on_get = on;
260 self
261 }
262
263 /// Attach a [`KernelRegistry`] as the L3 fallback consulted by
264 /// [`KernelCache::get_with_registry_fallback`] on an L1+L2 miss.
265 /// Default is `None` (registry path disabled). See the field-level
266 /// docs on [`Self::registry`] for the resolution contract.
267 #[cfg(feature = "kernel-registry")]
268 #[must_use]
269 pub fn with_registry(mut self, reg: Arc<dyn KernelRegistry>) -> Self {
270 self.registry = Some(reg);
271 self
272 }
273}
274
275// Manual `Debug` impl: `Arc<dyn KernelRegistry>` does not implement
276// `Debug` (the trait deliberately does not require it so embedder
277// backends like `InMemoryRegistry` — whose interior `Mutex<HashMap>`
278// is awkward to debug-format — stay easy to write). Render the
279// registry field as a presence-only marker so `{:?}` on a cache
280// config still surfaces whether the L3 path is wired without forcing
281// every registry impl to derive `Debug`.
282impl std::fmt::Debug for KernelCacheConfig {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 let mut d = f.debug_struct("KernelCacheConfig");
285 d.field("capacity", &self.capacity);
286 d.field("max_total_bytes", &self.max_total_bytes);
287 d.field("verify_on_get", &self.verify_on_get);
288 #[cfg(feature = "kernel-registry")]
289 d.field(
290 "registry",
291 &self.registry.as_ref().map(|_| "<dyn KernelRegistry>"),
292 );
293 d.finish()
294 }
295}
296
297/// Cached PTX module entry.
298///
299/// `integrity_hash` is a BLAKE3 over `ptx.text` computed at construction
300/// time and re-verified on every [`KernelCache::get`] (jit S-3). It defends
301/// against in-memory poisoning by a sibling holder of a mutable reference
302/// and forms the basis of the integrity tag stored in the on-disk
303/// persistence layer (see [`DiskCacheConfig`]). The field is `pub` so
304/// downstream tests and observability can inspect the recorded hash, but
305/// construction via [`CachedKernel::new`] is the only path that produces a
306/// correct-by-construction value — `KernelCache::put` calls `verify`
307/// before accepting the entry, so a hand-crafted `CachedKernel` with a
308/// mismatched hash will be rejected.
309#[derive(Debug, Clone)]
310pub struct CachedKernel {
311 /// The blueprint that produced this PTX (for diagnostics).
312 pub fingerprint: u64,
313 /// The emitted PTX text.
314 pub ptx: Arc<EmittedPtx>,
315 /// The cuda module handle is only meaningful when the `cuda` feature is
316 /// on; for the stub path we keep `()`.
317 pub compiled: CompiledHandle,
318 /// BLAKE3 over `ptx.text` (jit S-3). Recomputed and compared on every
319 /// `get`. See the type-level doc for the threat model.
320 ///
321 /// The field is `pub` for backward compatibility with v0.1 struct-literal
322 /// callers and so the forged-blob regression tests can hand-craft a
323 /// `CachedKernel` with a deliberately wrong hash and assert
324 /// [`KernelCache::put`] rejects it. It is hidden from generated docs and
325 /// excluded from the stable surface; prefer [`Self::integrity_hash`] for
326 /// read access and [`Self::new`] for construction.
327 #[doc(hidden)]
328 pub integrity_hash: [u8; 32],
329}
330
331impl CachedKernel {
332 /// Borrow the stored BLAKE3 integrity hash over `ptx.text`. Prefer this
333 /// over reaching for the (hidden) field directly — the field is
334 /// retained as `pub` for source compatibility only and may be sealed
335 /// to `pub(crate)` in a future minor release.
336 #[must_use]
337 pub fn integrity_hash(&self) -> &[u8; 32] {
338 &self.integrity_hash
339 }
340
341 /// Construct a `CachedKernel`, computing `integrity_hash` from
342 /// `ptx.text`. This is the only way to obtain a correct-by-
343 /// construction value; the older struct-literal pattern still works
344 /// (the field is `pub` for backward compatibility) but the literal
345 /// must provide a matching hash or `KernelCache::put` will reject it.
346 pub fn new(fingerprint: u64, ptx: Arc<EmittedPtx>, compiled: CompiledHandle) -> Self {
347 let h = blake3::hash(ptx.text.as_bytes());
348 Self {
349 fingerprint,
350 ptx,
351 compiled,
352 integrity_hash: *h.as_bytes(),
353 }
354 }
355
356 /// Re-compute the integrity hash and compare it against the stored
357 /// value. Returns `true` iff the PTX text has not been tampered with
358 /// since [`Self::new`] ran.
359 #[must_use]
360 pub fn verify_integrity(&self) -> bool {
361 let h = blake3::hash(self.ptx.text.as_bytes());
362 h.as_bytes() == &self.integrity_hash
363 }
364}
365
366/// Compiled-module handle. On CUDA hosts this would hold
367/// `cust::module::Module`; for the no-CUDA path it is just `()`.
368#[derive(Debug, Clone, Default)]
369pub struct CompiledHandle {
370 #[allow(dead_code)]
371 private: (),
372}
373
374/// Thread-safe LRU cache of compiled kernels backed by [`dashmap::DashMap`].
375///
376/// `get` and `put` are O(1) and (under typical concurrent workloads) lock-
377/// free except for the per-shard `dashmap` lock and the eviction-policy
378/// `parking_lot::Mutex`. The eviction lock is taken only on `put`s that
379/// would push the cache over capacity; reads never touch it.
380#[derive(Clone)]
381pub struct KernelCache {
382 /// Lock-free storage of the cached values themselves.
383 ///
384 /// T20 perf: values are held as `Arc<CachedKernel>` so a cache hit only
385 /// bumps the strong-count refcount instead of cloning the wrapper
386 /// (32-byte integrity hash + fingerprint + an inner `Arc<EmittedPtx>`
387 /// refcount bump). The inner PTX text was already `Arc`-shared; this
388 /// extension closes the matching gap on the outer wrapper.
389 storage: Arc<DashMap<CacheKey, Arc<CachedKernel>>>,
390 /// LRU policy: keys ordered by recency. `Mutex` (parking_lot) for
391 /// fast, panic-safe contention. The value side is `()` — the real value
392 /// lives in `storage`.
393 lru: Arc<Mutex<LruCache<CacheKey, ()>>>,
394 /// Construction-time policy bag (capacity, verify-on-get). Held by
395 /// value (cheap to clone, `Copy`-ish payload) so the `get` hot path
396 /// can read `verify_on_get` without an extra `Arc` deref.
397 config: KernelCacheConfig,
398 /// Optional L2 on-disk cache. When present (configured via
399 /// [`KernelCache::with_disk_persistence`]), `put` writes each entry to
400 /// disk under an HMAC-keyed integrity tag, and `get` falls through to
401 /// disk on an L1 miss — verifying the HMAC before deserialising. See
402 /// [`DiskCacheConfig`] for the threat model that motivates the design
403 /// (jit S-3).
404 disk: Option<Arc<DiskCache>>,
405 /// Running sum of the PTX-text bytes of every entry currently resident
406 /// in `storage` (each entry contributes `cached.ptx.text.len()`).
407 /// Maintained incrementally on `put` (add the inserted entry, subtract
408 /// every eviction) so the byte cap in
409 /// [`KernelCacheConfig::max_total_bytes`] can be enforced without
410 /// re-summing the whole map. Exposed via [`Self::total_bytes`] for the
411 /// Prometheus gauge `tensor_wasm_jit_cache_bytes`. `Arc`-shared so
412 /// cache clones agree on the total.
413 ///
414 /// Accuracy note: this is a best-effort accounting that tracks the
415 /// entries this cache instance inserted and evicted. The `verify_on_get`
416 /// failure path and L2 disk-hit promotion also keep it in step. Under
417 /// pathological concurrent races on the same key it may drift by a
418 /// bounded amount, but it is never used as a correctness gate — only to
419 /// drive best-effort byte-based eviction — so a transient skew at most
420 /// delays an eviction by one `put`.
421 total_bytes: Arc<AtomicU64>,
422 /// Cumulative count of `get` calls that skipped the BLAKE3 recompute
423 /// because [`KernelCacheConfig::verify_on_get`] was `false`.
424 /// Exposed via [`Self::verify_skipped_total`] so operators can wire
425 /// it to the Prometheus counter
426 /// `tensor_wasm_jit_cache_verify_skipped_total`. Wrapped in an `Arc`
427 /// so clones of `KernelCache` share the same counter (the storage
428 /// and LRU policy are likewise `Arc`-shared).
429 verify_skipped_total: Arc<AtomicU64>,
430 /// Cumulative count of `get` calls that returned an entry (L1 hit, L2
431 /// disk hit, or L3 registry hit). Exposed via [`Self::cache_hits_total`]
432 /// for the Prometheus counter `tensor_wasm_jit_cache_hits_total`.
433 /// `Arc`-shared so cache clones agree on the count.
434 cache_hits_total: Arc<AtomicU64>,
435 /// Cumulative count of `get` calls that returned `None` (full miss after
436 /// L1, L2, and any registry fallback). Exposed via
437 /// [`Self::cache_misses_total`] for the Prometheus counter
438 /// `tensor_wasm_jit_cache_misses_total`. `Arc`-shared so cache clones
439 /// agree on the count.
440 cache_misses_total: Arc<AtomicU64>,
441 /// Cumulative count of entries refused on `get` because their
442 /// `integrity_hash` failed verification (L1 recompute mismatch, an
443 /// all-zero hash on the verify-skip path, or an L2 disk integrity
444 /// failure). These rejections also bump `cache_misses_total`, but this
445 /// dedicated counter lets operators distinguish ordinary cache misses
446 /// from *tamper attempts* — a rising
447 /// `tensor_wasm_jit_cache_integrity_reject_total` is an alarm signal,
448 /// not just a cold cache. Exposed via [`Self::integrity_reject_total`].
449 /// `Arc`-shared so cache clones agree on the count.
450 integrity_reject_total: Arc<AtomicU64>,
451}
452
453impl KernelCache {
454 /// Construct with default capacity.
455 pub fn new() -> Self {
456 Self::with_config(KernelCacheConfig::default())
457 }
458
459 /// Construct with explicit capacity. Anything below 1 is clamped to 1.
460 pub fn with_capacity(cap: usize) -> Self {
461 Self::with_config(KernelCacheConfig::default().with_capacity(cap))
462 }
463
464 /// Construct from a full [`KernelCacheConfig`]. Anything below 1 in
465 /// `config.capacity` is clamped to 1.
466 pub fn with_config(mut config: KernelCacheConfig) -> Self {
467 config.capacity = config.capacity.max(1);
468 // The eviction queue is sized to `cap` so the LRU crate's internal
469 // bucket pre-allocation is bounded (sizing it to `usize::MAX` triggers
470 // a hashbrown capacity-overflow panic). Storage eviction is still
471 // driven from the `storage.len() > capacity` check in `put`; both
472 // sides agree on the same `cap` so they stay in sync.
473 let nz = NonZeroUsize::new(config.capacity).expect(">0 (clamped above)");
474 Self {
475 storage: Arc::new(DashMap::with_capacity(config.capacity)),
476 lru: Arc::new(Mutex::new(LruCache::new(nz))),
477 config,
478 disk: None,
479 total_bytes: Arc::new(AtomicU64::new(0)),
480 verify_skipped_total: Arc::new(AtomicU64::new(0)),
481 cache_hits_total: Arc::new(AtomicU64::new(0)),
482 cache_misses_total: Arc::new(AtomicU64::new(0)),
483 integrity_reject_total: Arc::new(AtomicU64::new(0)),
484 }
485 }
486
487 /// Enable the on-disk L2 cache, persisting entries to `cfg.dir` under
488 /// an HMAC-keyed integrity tag (jit S-3).
489 ///
490 /// Construct via:
491 /// ```
492 /// # // Hidden doctest stub — real deployments must read the key from
493 /// # // an out-of-process secret store (Vault, KMS, sealed file under
494 /// # // mode 0400, etc.) and never embed it in source. This stub lets
495 /// # // the doctest compile without shipping a fake key literal that
496 /// # // operators might copy-paste verbatim.
497 /// # fn load_hmac_key_from_secret_store() -> Result<[u8; 32], Box<dyn std::error::Error>> {
498 /// # Ok([0u8; 32])
499 /// # }
500 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
501 /// use std::path::PathBuf;
502 /// use tensor_wasm_jit::cache::{KernelCache, DiskCacheConfig};
503 /// let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
504 /// dir: PathBuf::from("/var/cache/tensor-wasm/kernels"),
505 /// hmac_key: load_hmac_key_from_secret_store()?, /* loaded from secrets at startup */
506 /// });
507 /// # let _ = cache;
508 /// # Ok(())
509 /// # }
510 /// ```
511 ///
512 /// jit S-3 (T13): the example deliberately routes the key through a
513 /// `load_hmac_key_from_secret_store()` stub rather than an inline
514 /// literal — operators have a habit of copy-pasting rustdoc examples
515 /// verbatim, and an embedded `[0xAB; 32]` (or similar) would survive
516 /// into production deployments as a fixed, attacker-known key.
517 ///
518 /// The directory is created lazily on the first `put`. The HMAC key
519 /// MUST be stable across process restarts AND treated as a server-
520 /// side secret — possession of the key lets an attacker forge cache
521 /// entries that the loader will accept as authentic (and that would
522 /// then be handed to `cust::module::Module::from_ptx` as trusted GPU
523 /// code).
524 #[must_use]
525 pub fn with_disk_persistence(mut self, cfg: DiskCacheConfig) -> Self {
526 // Share the integrity-rejection counter with the disk layer so an
527 // L2 tamper rejection bumps the same
528 // `tensor_wasm_jit_cache_integrity_reject_total` as an L1 one.
529 self.disk = Some(Arc::new(DiskCache::new(
530 cfg,
531 Arc::clone(&self.integrity_reject_total),
532 )));
533 self
534 }
535
536 /// Byte cost a single entry contributes to [`Self::total_bytes`]: the
537 /// length of its PTX text. The 32-byte integrity hash, fingerprint, and
538 /// LRU bookkeeping are fixed-size per entry and already bounded by the
539 /// count cap, so the byte cap deliberately accounts only the variable-
540 /// size payload that the DoS note on [`DEFAULT_CAPACITY`] flags.
541 fn entry_bytes(kernel: &CachedKernel) -> u64 {
542 kernel.ptx.text.len() as u64
543 }
544
545 /// Remove a key from storage *and* decrement the byte total by the
546 /// removed entry's payload size. Centralised so every eviction site
547 /// keeps [`Self::total_bytes`] in step with `storage`. Returns the
548 /// removed entry (if any) for callers that want it.
549 fn evict_key(&self, key: &CacheKey) -> Option<Arc<CachedKernel>> {
550 if let Some((_, removed)) = self.storage.remove(key) {
551 self.total_bytes
552 .fetch_sub(Self::entry_bytes(&removed), Ordering::Relaxed);
553 Some(removed)
554 } else {
555 None
556 }
557 }
558
559 /// Byte-cap eviction (DoS mitigation — see
560 /// [`KernelCacheConfig::max_total_bytes`]). Reuse the SAME LRU policy
561 /// queue that drives the count cap: pop least-recently-used keys and
562 /// evict them until the running PTX-byte total fits under `cap`. We
563 /// never evict down to an empty cache — the loop stops once at most one
564 /// entry (the most-recently-used, i.e. typically the entry just
565 /// inserted) remains, so a single entry larger than `cap` is still
566 /// served rather than wedging dispatch.
567 ///
568 /// A no-op when no byte cap is configured. Factored out of [`Self::put`]
569 /// so EVERY L1 insert site — `put` and the L2 disk-hit promotion in
570 /// [`Self::get`] — binds the byte cap, not just `put`. Holds the `lru`
571 /// lock across the whole eviction burst (one acquisition for the loop)
572 /// exactly as the count-cap loop does; `evict_key` keeps
573 /// [`Self::total_bytes`] in step with each storage removal.
574 fn enforce_byte_cap(&self) {
575 if let Some(cap) = self.config.max_total_bytes {
576 if self.total_bytes.load(Ordering::Relaxed) > cap {
577 let mut lru = self.lru.lock();
578 while self.total_bytes.load(Ordering::Relaxed) > cap && self.storage.len() > 1 {
579 match lru.pop_lru() {
580 Some((evict_key, ())) => {
581 self.evict_key(&evict_key);
582 }
583 None => break,
584 }
585 }
586 }
587 }
588 }
589
590 /// Insert (or replace) a kernel. If the insert pushes the cache over
591 /// the count cap (or, when configured, the byte cap), evicts LRU
592 /// entries from storage and the policy queue until both caps are
593 /// satisfied.
594 ///
595 /// jit S-3: the kernel's `integrity_hash` is recomputed and compared
596 /// against the stored hash; a mismatch is treated as a programmer
597 /// error (`CachedKernel` constructed via struct-literal with a wrong
598 /// hash), logged at `error!`, and the entry is dropped rather than
599 /// admitted. Use [`CachedKernel::new`] for the correct-by-
600 /// construction path. The on-disk L2 also writes the entry when
601 /// configured.
602 pub fn put(&self, key: CacheKey, kernel: CachedKernel) {
603 if !kernel.verify_integrity() {
604 tracing::error!(
605 target: "tensor_wasm_jit::cache",
606 fingerprint = kernel.fingerprint,
607 tenant = key.tenant_id,
608 "refusing to cache kernel whose integrity hash does not match \
609 its PTX text -- likely a struct-literal construction with a \
610 stale hash; use CachedKernel::new"
611 );
612 return;
613 }
614 if let Some(disk) = &self.disk {
615 if let Err(e) = disk.put(&key, &kernel) {
616 tracing::warn!(
617 target: "tensor_wasm_jit::cache",
618 fingerprint = kernel.fingerprint,
619 error = %e,
620 "disk-cache put failed; entry remains in L1 only"
621 );
622 }
623 }
624 // T20 perf: storage holds `Arc<CachedKernel>` so cache hits return a
625 // refcount bump rather than a wrapper-clone. Account the new entry's
626 // bytes; if `insert` replaced an existing entry under the same key,
627 // subtract the displaced entry's bytes so the running total reflects
628 // a replace rather than an add.
629 let new_bytes = Self::entry_bytes(&kernel);
630 let replaced = self.storage.insert(key, Arc::new(kernel));
631 self.total_bytes.fetch_add(new_bytes, Ordering::Relaxed);
632 if let Some(old) = replaced {
633 self.total_bytes
634 .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
635 }
636 // `LruCache::push` returns `Some((evicted_key, ()))` when sizing the
637 // LRU triggers eviction of an older entry. We use this as the
638 // authoritative signal for storage eviction so the two stay in sync.
639 // Sized to `cap`, the LRU evicts exactly when we want storage to,
640 // and we get the evicted key directly without a second pop_lru call.
641 let evicted = self.lru.lock().push(key, ());
642 if let Some((evicted_key, ())) = evicted {
643 if evicted_key != key {
644 // Don't remove if `push` returned the just-inserted key
645 // (which happens when the cache already held it — push acts
646 // as a replace and returns the old `(K, V)`). `evict_key`
647 // keeps `total_bytes` in step with the storage removal.
648 self.evict_key(&evicted_key);
649 }
650 }
651 // Safety net for the rare burst case where two concurrent `put`s
652 // both insert without either triggering LRU's internal eviction
653 // (because they raced on the lock). Drive storage back under cap.
654 //
655 // Hold the `lru` lock across the entire eviction loop instead of
656 // taking and releasing it on every iteration: a tight burst of
657 // overflow eviction used to lock-unlock the mutex `N` times,
658 // letting unrelated readers/writers contend on each pass. One
659 // acquisition costs the same as one iteration's lock+unlock but
660 // dispatches the whole burst in a single critical section.
661 if self.storage.len() > self.config.capacity {
662 let mut lru = self.lru.lock();
663 while self.storage.len() > self.config.capacity {
664 match lru.pop_lru() {
665 Some((evict_key, ())) => {
666 self.evict_key(&evict_key);
667 }
668 None => {
669 tracing::error!(
670 target: "tensor_wasm_jit::cache",
671 storage_len = self.storage.len(),
672 capacity = self.config.capacity,
673 "cache storage exceeds capacity but eviction queue is empty"
674 );
675 break;
676 }
677 }
678 }
679 }
680 // Byte-cap eviction (DoS mitigation — see
681 // `KernelCacheConfig::max_total_bytes`). Shared with the L2
682 // disk-promotion path so both insert sites bind the byte cap.
683 self.enforce_byte_cap();
684 }
685
686 /// Look up a kernel; best-effort touches the LRU position.
687 ///
688 /// T20 perf: returns `Option<Arc<CachedKernel>>` rather than the
689 /// previous `Option<CachedKernel>` so a cache hit shares the wrapper
690 /// allocation via refcount bump instead of cloning the 32-byte
691 /// integrity hash + fingerprint + inner-`Arc` refcount bump. The
692 /// inner PTX text was already shared; this closes the matching
693 /// gap on the outer wrapper.
694 pub fn get(&self, key: &CacheKey) -> Option<Arc<CachedKernel>> {
695 // Promote in the policy queue if we can grab the lock uncontended;
696 // otherwise skip promotion this time so the read path stays
697 // contention-free. Eviction order is approximate (not strict) LRU
698 // under load — an acceptable trade for a lock-free hot path.
699 // The storage read is the single source of truth for "is this
700 // cached", so skipping promotion never affects correctness.
701 if let Some(mut lru) = self.lru.try_lock() {
702 // `get` on LruCache promotes if present.
703 let _ = lru.get(key);
704 }
705 if let Some(entry) = self.storage.get(key) {
706 // T20 perf: clone the Arc (refcount bump) rather than the
707 // inner `CachedKernel` value.
708 let kernel: Arc<CachedKernel> = Arc::clone(entry.value());
709 drop(entry); // release shard lock before the hash recompute
710 // jit S-3: verify the in-memory entry hasn't been tampered
711 // with since `put`. A mismatch should be impossible (the
712 // `CachedKernel` is owned by the cache and never handed out
713 // mutably) but failing closed costs ~µs over the public
714 // PTX bytes and definitively closes the in-mem poisoning
715 // path the audit flagged.
716 //
717 // The recompute can be opted out of via
718 // [`KernelCacheConfig::verify_on_get`] for high-QPS callers
719 // where the ~10 µs BLAKE3 cost over multi-MB PTX dominates.
720 // Even on the opt-out path we keep a cheap defence-in-depth
721 // check: refuse entries whose `integrity_hash` is all-zero,
722 // because that is the signature of a `CachedKernel`
723 // constructed via the `#[doc(hidden)]` struct-literal path
724 // without [`CachedKernel::new`] having computed a real hash.
725 // A real BLAKE3 over any PTX text is overwhelmingly unlikely
726 // to collide with the zero hash (probability `2^-256`), so
727 // the rejection is unambiguous.
728 if self.config.verify_on_get {
729 if !kernel.verify_integrity() {
730 tracing::error!(
731 target: "tensor_wasm_jit::cache",
732 fingerprint = kernel.fingerprint,
733 tenant = key.tenant_id,
734 "L1 cache entry failed integrity verification on get; \
735 evicting and refusing to return it"
736 );
737 self.evict_key(key);
738 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
739 self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
740 return None;
741 }
742 } else {
743 // Defence-in-depth on the opt-out path: reject the
744 // "constructed-without-`new`" signal (all-zero hash) so
745 // hand-crafted `CachedKernel`s with a zeroed hash cannot
746 // slip through. Counter increment records that the user
747 // chose to skip the full recompute on this hit.
748 self.verify_skipped_total.fetch_add(1, Ordering::Relaxed);
749 if kernel.integrity_hash == [0u8; 32] {
750 tracing::error!(
751 target: "tensor_wasm_jit::cache",
752 fingerprint = kernel.fingerprint,
753 tenant = key.tenant_id,
754 "L1 cache entry has zero integrity_hash on verify-skip get; \
755 likely a struct-literal CachedKernel built without \
756 CachedKernel::new — evicting and refusing to return it"
757 );
758 self.evict_key(key);
759 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
760 self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
761 return None;
762 }
763 }
764 self.cache_hits_total.fetch_add(1, Ordering::Relaxed);
765 return Some(kernel);
766 }
767 // jit S-3 + audit P-4: L1 miss falls through to the optional L2
768 // on-disk cache. The disk path HMAC-verifies the entry before
769 // deserialising, so a tampered file on disk is rejected with the
770 // same "no such entry" outcome as a real miss.
771 if let Some(disk) = &self.disk {
772 match disk.get(key) {
773 Ok(Some(kernel)) => {
774 // Promote the disk hit into L1 so subsequent lookups
775 // stay on the fast path. Storage owns an `Arc<CachedKernel>`;
776 // wrap once here and clone the Arc for the return value
777 // so the caller and the cache share the allocation.
778 let arc = Arc::new(kernel);
779 let promoted_bytes = Self::entry_bytes(&arc);
780 let replaced = self.storage.insert(*key, Arc::clone(&arc));
781 self.total_bytes
782 .fetch_add(promoted_bytes, Ordering::Relaxed);
783 if let Some(old) = replaced {
784 self.total_bytes
785 .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
786 }
787 // Drive count-cap eviction via the policy queue exactly as
788 // `put` does, keeping `total_bytes` in step through
789 // `evict_key`.
790 if let Some((evicted_key, ())) = self.lru.lock().push(*key, ()) {
791 if evicted_key != *key {
792 self.evict_key(&evicted_key);
793 }
794 }
795 // Byte-cap parity: `put` enforces BOTH the count cap and the
796 // byte cap on every insert, but this promotion path used to
797 // run only count-cap eviction — so an L2 disk hit could push
798 // `total_bytes` past `config.max_total_bytes` and leave it
799 // over the cap until the next `put`. Run the shared byte-cap
800 // eviction here too so both caps bind on every L1 insert.
801 self.enforce_byte_cap();
802 self.cache_hits_total.fetch_add(1, Ordering::Relaxed);
803 return Some(arc);
804 }
805 Ok(None) => {}
806 Err(e) => {
807 tracing::warn!(
808 target: "tensor_wasm_jit::cache",
809 tenant = key.tenant_id,
810 error = %e,
811 "disk-cache get failed; treating as miss"
812 );
813 }
814 }
815 }
816 self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
817 None
818 }
819
820 /// Look up by blueprint + sm_version for a given tenant; convenience
821 /// wrapper around [`Self::get`].
822 ///
823 /// T20 perf: returns `Option<Arc<CachedKernel>>` to mirror
824 /// [`Self::get`]; callers that only need to peek at fields go
825 /// through auto-deref unchanged.
826 pub fn get_for(
827 &self,
828 tenant_id: TenantId,
829 blueprint: &TensorWasmKernelBlueprint,
830 sm_version: u32,
831 ) -> Option<Arc<CachedKernel>> {
832 self.get(&CacheKey::for_tenant(
833 tenant_id,
834 blueprint.fingerprint(),
835 sm_version,
836 ))
837 }
838
839 /// L1 → L2 → L3(registry) resolution path. v0.3.8 scaffold: invokes the
840 /// registered resolver + registry on every miss; v0.4 may add a
841 /// resolver-level cache to amortise the (blueprint → name@version)
842 /// translation.
843 #[cfg(feature = "kernel-registry")]
844 pub fn get_with_registry_fallback(
845 &self,
846 key: &CacheKey,
847 resolver: &dyn BlueprintResolver,
848 ) -> Option<Arc<CachedKernel>> {
849 // L1 + L2 — `get` now already returns `Arc<CachedKernel>` (T20).
850 if let Some(hit) = self.get(key) {
851 return Some(hit);
852 }
853 // L3
854 let registry = self.config.registry.as_ref()?;
855 let (name, version) = resolver.resolve(key.blueprint, key.sm_version)?;
856 let entry = registry.get(&name, &version).ok()?;
857 // Promote into L1 via the standard put path (which re-checks
858 // integrity) so subsequent calls hit fast.
859 //
860 // Perf: `entry` is an `Arc<(KernelManifest, String)>` owned by the
861 // registry, so the PTX `String` cannot be moved out — exactly one
862 // owned copy into `EmittedPtx` is the irreducible minimum on this
863 // path. That copy is wrapped in an `Arc<EmittedPtx>` once; the L1
864 // promotion and the handle returned to the caller then share it via
865 // refcount bump, so the PTX bytes are never re-copied. The single
866 // `CachedKernel::new` below also hashes the PTX exactly once (its
867 // BLAKE3 integrity tag); the cheap wrapper `clone()` we hand to `put`
868 // copies only the 32-byte hash + fingerprint + an `Arc` refcount
869 // bump, not the PTX text.
870 let emitted = Arc::new(crate::ptx_emit::EmittedPtx {
871 text: entry.1.clone(),
872 // Thread the real launch geometry through from the manifest.
873 // `KernelManifest::launch_geometry` is an optional, unsigned
874 // hint (publishers set it via `KernelManifest::with_launch_geometry`);
875 // when present we promote it onto the L1 entry so a registry-sourced
876 // kernel carries the same geometry an L1/L2 hit would. When the
877 // manifest omits it (older blobs, or publishers that never set it) we
878 // fall back to `(0, 0)`, exactly as before — geometry is a launch
879 // hint, not a correctness invariant for resolution. The earlier
880 // `(0, 0)` hard-code lost geometry on every L3 hit (the bug the V2
881 // disk envelope fixed on L2); this closes the L3 gap without a signed-
882 // format break, since the hint rides outside the v2 HMAC envelope.
883 launch_geometry: entry.0.launch_geometry.unwrap_or((0, 0)),
884 });
885 // Fingerprint MUST match the cache key under which this entry is
886 // inserted. `put` keys L1 entries by `CacheKey` (whose `blueprint` is the
887 // blueprint fingerprint) and the L2 disk reader reconstructs with
888 // `fingerprint == key.blueprint` (see `DiskCache::get`). Using
889 // `manifest.digest_as_u64()` (the PTX digest) here made the promoted
890 // entry's `fingerprint` disagree with `key.blueprint`, breaking
891 // diagnostics/log correlation and the `OffloadedFunction.fingerprint`
892 // contract for registry-sourced entries. Key on `key.blueprint` so L1,
893 // L2, and L3 entries are all fingerprinted consistently.
894 let cached = CachedKernel::new(key.blueprint, emitted, CompiledHandle::default());
895 // Best-effort L1 promote; `put` is infallible-by-design (any
896 // integrity-mismatch case logs + drops the entry rather than
897 // returning an error), so there is nothing to propagate even if
898 // the registry-derived `cached` were somehow rejected — the
899 // caller still gets the verified `Arc<CachedKernel>` from this
900 // call, the next call simply pays another L3 round-trip.
901 //
902 // `put` keeps its by-value `CachedKernel` signature, so we hand it a
903 // wrapper clone (cheap — see above); the returned `Arc<CachedKernel>`
904 // shares the same inner `Arc<EmittedPtx>` as the L1 copy.
905 self.put(*key, cached.clone());
906 Some(Arc::new(cached))
907 }
908
909 /// Number of entries currently held.
910 pub fn len(&self) -> usize {
911 self.storage.len()
912 }
913
914 /// True if empty.
915 pub fn is_empty(&self) -> bool {
916 self.storage.is_empty()
917 }
918
919 /// Configured capacity.
920 pub fn capacity(&self) -> usize {
921 self.config.capacity
922 }
923
924 /// Running sum of resident PTX-text bytes across all L1 entries (each
925 /// entry contributes `cached.ptx.text.len()`). This is the quantity the
926 /// optional [`KernelCacheConfig::max_total_bytes`] cap bounds. Surface
927 /// on the Prometheus gauge `tensor_wasm_jit_cache_bytes` so operators
928 /// can watch the L1 footprint against the configured byte cap. Always
929 /// present (returns `0` for an empty cache, and tracks the live total
930 /// whether or not a byte cap is configured).
931 pub fn total_bytes(&self) -> u64 {
932 self.total_bytes.load(Ordering::Relaxed)
933 }
934
935 /// The configured byte cap, if any (mirror of
936 /// [`KernelCacheConfig::max_total_bytes`]). `None` means count-only
937 /// eviction. Useful for diagnostic endpoints that surface both the
938 /// live [`Self::total_bytes`] and the ceiling it is measured against.
939 pub fn max_total_bytes(&self) -> Option<u64> {
940 self.config.max_total_bytes
941 }
942
943 /// Fingerprint of the *active* on-disk HMAC key — the partition prefix
944 /// every file this cache writes lands under — or `None` if no L2 disk
945 /// cache is configured.
946 ///
947 /// Pass this into the `retain` set of [`Self::gc_disk`] to keep the
948 /// live generation (though `gc_disk` retains it unconditionally as a
949 /// safety net).
950 pub fn active_disk_key_fingerprint(&self) -> Option<KeyFingerprint> {
951 self.disk.as_ref().map(|d| d.active_key_fingerprint())
952 }
953
954 /// Enumerate the distinct HMAC-key fingerprints present in the on-disk
955 /// L2 cache directory (one per key generation that has written at least
956 /// one file). Returns an empty `Vec` when no disk cache is configured or
957 /// the directory does not yet exist.
958 ///
959 /// Use this to discover stale generations before a [`Self::gc_disk`]
960 /// sweep — anything in this list that is not the active fingerprint and
961 /// not a key you still want to honour is a rotation leftover.
962 pub fn disk_key_fingerprints(&self) -> std::io::Result<Vec<KeyFingerprint>> {
963 match &self.disk {
964 Some(d) => d.key_fingerprints_on_disk(),
965 None => Ok(Vec::new()),
966 }
967 }
968
969 /// Garbage-collect the on-disk L2 cache after an HMAC-key rotation:
970 /// remove every persisted entry whose key fingerprint is NOT in
971 /// `retain`, returning the number of files removed. A no-op returning
972 /// `Ok(0)` when no disk cache is configured.
973 ///
974 /// The *active* key's fingerprint is always retained — even if the
975 /// caller omits it from `retain` — so a sweep can never delete the live
976 /// generation's entries. Only stale-fingerprint files are removed, and
977 /// only files matching the cache's own naming scheme are considered
978 /// (unrelated files in the directory are left untouched).
979 ///
980 /// # Example
981 ///
982 /// ```
983 /// # fn load_hmac_key_from_secret_store() -> Result<[u8; 32], Box<dyn std::error::Error>> {
984 /// # Ok([0u8; 32])
985 /// # }
986 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
987 /// use std::path::PathBuf;
988 /// use tensor_wasm_jit::cache::{KernelCache, DiskCacheConfig};
989 ///
990 /// // After rotating to a fresh HMAC key, stand up a cache under it…
991 /// let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
992 /// dir: PathBuf::from("/var/cache/tensor-wasm/kernels"),
993 /// hmac_key: load_hmac_key_from_secret_store()?,
994 /// });
995 ///
996 /// // …then sweep every previous generation's files. Retaining only the
997 /// // active key (which `gc_disk` keeps regardless) drops all the rest.
998 /// let active = cache.active_disk_key_fingerprint().into_iter().collect::<Vec<_>>();
999 /// let removed = cache.gc_disk(&active)?;
1000 /// println!("swept {removed} stale-fingerprint cache files");
1001 /// # let _ = removed;
1002 /// # Ok(())
1003 /// # }
1004 /// ```
1005 pub fn gc_disk(&self, retain: &[KeyFingerprint]) -> std::io::Result<usize> {
1006 match &self.disk {
1007 Some(d) => d.gc(retain),
1008 None => Ok(0),
1009 }
1010 }
1011
1012 /// Borrow the construction-time [`KernelCacheConfig`]. Useful for
1013 /// tests and diagnostic endpoints that want to surface whether
1014 /// `verify_on_get` is on for this cache instance.
1015 pub fn config(&self) -> &KernelCacheConfig {
1016 &self.config
1017 }
1018
1019 /// Cumulative count of L1 `get` hits that skipped the BLAKE3
1020 /// integrity recompute because [`KernelCacheConfig::verify_on_get`]
1021 /// is `false`. Surface this on the Prometheus counter
1022 /// `tensor_wasm_jit_cache_verify_skipped_total` so operators can see
1023 /// how often the cache is trusting an L1 entry without re-hashing
1024 /// the PTX. The counter is always present (returns `0` when
1025 /// `verify_on_get` is on) so dashboards can scrape it unconditionally.
1026 pub fn verify_skipped_total(&self) -> u64 {
1027 self.verify_skipped_total.load(Ordering::Relaxed)
1028 }
1029
1030 /// Cumulative L1/L2/L3 cache hit count. Incremented once per `get`
1031 /// call that returns `Some`. Surface on the Prometheus counter
1032 /// `tensor_wasm_jit_cache_hits_total` so operators can compute the
1033 /// hit ratio against [`Self::cache_misses_total`]. (T20 perf.)
1034 pub fn cache_hits_total(&self) -> u64 {
1035 self.cache_hits_total.load(Ordering::Relaxed)
1036 }
1037
1038 /// Cumulative cache miss count. Incremented once per `get` call that
1039 /// returns `None` — including the rare path where an L1 entry failed
1040 /// integrity verification and was evicted. Surface on the Prometheus
1041 /// counter `tensor_wasm_jit_cache_misses_total`. (T20 perf.)
1042 pub fn cache_misses_total(&self) -> u64 {
1043 self.cache_misses_total.load(Ordering::Relaxed)
1044 }
1045
1046 /// Cumulative count of entries refused on `get` because they failed
1047 /// integrity verification — an L1 BLAKE3 recompute mismatch, an
1048 /// all-zero `integrity_hash` on the verify-skip path, or an L2 disk
1049 /// integrity failure (artifact-store HMAC/content-hash mismatch,
1050 /// inner-envelope magic/header/length/UTF-8 corruption). These also
1051 /// bump [`Self::cache_misses_total`], but this dedicated counter lets
1052 /// operators alarm on *tamper attempts* specifically rather than
1053 /// drown them in ordinary cold-cache misses. Surface on the
1054 /// Prometheus counter `tensor_wasm_jit_cache_integrity_reject_total`.
1055 pub fn integrity_reject_total(&self) -> u64 {
1056 self.integrity_reject_total.load(Ordering::Relaxed)
1057 }
1058
1059 /// Test-only insert that skips the `put`-side integrity check.
1060 ///
1061 /// `put` rejects any `CachedKernel` whose stored `integrity_hash`
1062 /// does not match a fresh BLAKE3 over its `ptx.text` (jit S-3).
1063 /// That is the correct production behaviour, but the
1064 /// `verify_on_get=false` regression test in
1065 /// `tests/cache_verify_opt_out.rs` needs to install a hand-crafted
1066 /// zero-hash entry to confirm the opt-out path still rejects it on
1067 /// `get`. This entry-point exists for that test only — it is
1068 /// `#[doc(hidden)]` (excluded from generated docs and rustdoc search)
1069 /// and named with a `__test_only_` prefix to broadcast "do NOT call
1070 /// this from production code". Integration tests under
1071 /// `crates/.../tests/` compile as external consumers of the library and
1072 /// therefore cannot see `cfg(test)` items, so this cannot be gated on
1073 /// `cfg(test)`. It is instead gated behind the dedicated
1074 /// `__unstable-test-internals` cargo feature (jit M2): without that
1075 /// feature the symbol does not exist, so a downstream crate cannot use
1076 /// it to install a `CachedKernel` with a forged/zeroed integrity hash
1077 /// and thereby bypass the S-3 integrity check. The double-underscore
1078 /// feature name signals it is unstable and not covered by semver.
1079 ///
1080 /// Production code MUST go through [`Self::put`].
1081 #[doc(hidden)]
1082 #[cfg(feature = "__unstable-test-internals")]
1083 pub fn __test_only_insert_unchecked(&self, key: CacheKey, kernel: CachedKernel) {
1084 // T20 perf: storage holds `Arc<CachedKernel>`; wrap on insert.
1085 let bytes = Self::entry_bytes(&kernel);
1086 let replaced = self.storage.insert(key, Arc::new(kernel));
1087 self.total_bytes.fetch_add(bytes, Ordering::Relaxed);
1088 if let Some(old) = replaced {
1089 self.total_bytes
1090 .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
1091 }
1092 let _ = self.lru.lock().push(key, ());
1093 }
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Disk persistence (audit P-4 + jit S-3)
1098// ---------------------------------------------------------------------------
1099
1100/// Configuration for the on-disk L2 cache.
1101///
1102/// The cache directory must be writable by the runtime user and SHOULD be
1103/// owned exclusively by that user (mode 0700 on Unix) — operators on a
1104/// hardened deployment can additionally `chattr +i` files after first
1105/// write so a parallel attacker process cannot tamper with cached
1106/// entries even with the same UID.
1107///
1108/// `hmac_key` is the secret that gates load: the writer HMACs each
1109/// persisted entry with the key, and the reader rejects any file whose
1110/// recomputed HMAC does not match. Without the key (or with a different
1111/// key) the loader treats every existing entry as a miss, so rotating
1112/// the key invalidates the disk cache without requiring an `rm -rf`.
1113///
1114/// The caller-supplied `hmac_key` field is plain `[u8; 32]` for ergonomic
1115/// construction; once handed to [`KernelCache::with_disk_persistence`] the
1116/// bytes are copied into a private `Zeroizing<[u8; 32]>` inside
1117/// `DiskCache` which wipes them on drop. The caller's own copy is the
1118/// caller's responsibility (e.g. construct via `Zeroizing::new` upstream
1119/// and let it drop after the call).
1120///
1121/// jit S-3 hardening (T13): `Debug` is implemented manually so the
1122/// `hmac_key` bytes are redacted in any `{:?}` formatting, panic message,
1123/// or `tracing` field expansion — the derived `Debug` would have dumped
1124/// the raw 32-byte array. `Drop` zeroizes `hmac_key` on drop so the
1125/// construction-time copy does not survive in freed memory.
1126#[derive(Clone)]
1127pub struct DiskCacheConfig {
1128 /// Directory where the L2 cache files live. Created lazily on first
1129 /// `put`.
1130 pub dir: PathBuf,
1131 /// 32-byte HMAC-keyed-BLAKE3 key. MUST be process-stable across the
1132 /// cache's lifetime and SHOULD be treated as a server-side secret.
1133 /// The long-lived copy held by the cache is zeroized on drop; this
1134 /// field is the construction-time hand-off only.
1135 ///
1136 /// jit S-3 (T13): redacted in the manual [`std::fmt::Debug`] impl
1137 /// below and zeroized in [`Drop`] so the construction-time copy
1138 /// does not linger in freed memory after the value is moved into
1139 /// the long-lived `DiskCache`.
1140 pub hmac_key: [u8; 32],
1141}
1142
1143// Manual `Debug` so `hmac_key` never appears verbatim in formatted output
1144// (jit S-3 T13). Any `{:?}` print, panic message, or `tracing::error!`
1145// field expansion that includes a `DiskCacheConfig` would otherwise dump
1146// the raw key bytes into the log stream — which is the exact server-side
1147// secret the disk cache is supposed to protect.
1148impl std::fmt::Debug for DiskCacheConfig {
1149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150 f.debug_struct("DiskCacheConfig")
1151 .field("dir", &self.dir)
1152 .field("hmac_key", &"<redacted 32 bytes>")
1153 .finish()
1154 }
1155}
1156
1157// Zeroize the in-place HMAC key bytes when the config is dropped so the
1158// construction-time copy does not survive in freed memory once
1159// [`KernelCache::with_disk_persistence`] has consumed the value (jit S-3
1160// T13). The long-lived copy in [`DiskCache`] already lives inside
1161// `Zeroizing<[u8; 32]>`; this `Drop` plugs the matching gap for the
1162// caller-side hand-off struct.
1163impl Drop for DiskCacheConfig {
1164 fn drop(&mut self) {
1165 use zeroize::Zeroize;
1166 self.hmac_key.zeroize();
1167 }
1168}
1169
1170/// 16-hex-char fingerprint of an HMAC key, as it appears in on-disk
1171/// filenames.
1172///
1173/// The disk layout partitions every file by the first 8 bytes of
1174/// `blake3::hash(hmac_key)` rendered as 16 lowercase hex chars — the
1175/// sidecar prefix in `{fp}-{cache_key}.ptxbin` and the middle segment in
1176/// the artifact store's `{content_hash}.{fp}.bin` blobs both use the same
1177/// fingerprint. Rotating the HMAC key changes the fingerprint, so a
1178/// rotation leaves the previous generation's files behind under their old
1179/// fingerprint, trivially sweepable by [`KernelCache::gc_disk`].
1180///
1181/// This is *not* secret: it is `blake3(key)` truncated, already publicly
1182/// observable to anyone with directory-list access (it is in the
1183/// filename). It is a partitioning tag, not a confidentiality boundary —
1184/// the HMAC trailer on each blob is what actually gates load.
1185///
1186/// Obtain the fingerprint of the currently-active key via
1187/// [`KernelCache::active_disk_key_fingerprint`], and the set present on
1188/// disk via [`KernelCache::disk_key_fingerprints`].
1189#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1190pub struct KeyFingerprint(pub String);
1191
1192impl KeyFingerprint {
1193 /// The 16-hex-char fingerprint of `key` — the same value the disk
1194 /// layout stamps into filenames. Equivalent to the artifact store's
1195 /// own `key_fingerprint_hex`; kept in lock-step so a `KernelCache` and
1196 /// its underlying [`DiskArtifactStore`] always agree on the partition.
1197 #[must_use]
1198 pub fn of_key(key: &[u8; 32]) -> Self {
1199 let h = blake3::hash(&key[..]);
1200 let mut buf = [0u8; 16];
1201 hex::encode_to_slice(&h.as_bytes()[..8], &mut buf).expect("16 byte buf for 8 byte input");
1202 // `buf` is ASCII hex by construction, so the UTF-8 check never fails.
1203 Self(std::str::from_utf8(&buf).expect("hex is utf8").to_string())
1204 }
1205
1206 /// Borrow the underlying hex string.
1207 #[must_use]
1208 pub fn as_str(&self) -> &str {
1209 &self.0
1210 }
1211}
1212
1213impl std::fmt::Display for KeyFingerprint {
1214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1215 f.write_str(&self.0)
1216 }
1217}
1218
1219/// Disk-backed L2 cache.
1220///
1221/// ## T30 layering (current)
1222///
1223/// Stores each kernel as two cooperating on-disk artefacts under
1224/// [`DiskCacheConfig::dir`]:
1225///
1226/// 1. A *sidecar* file at [`Self::path_for`] (`*.ptxbin`) containing a
1227/// fixed-size record that maps the [`CacheKey`] to the
1228/// [`ContentHash`] of the underlying blob (16-byte magic
1229/// [`SIDECAR_MAGIC_V1`] + 32-byte content hash).
1230/// 2. A *blob* file under the
1231/// [`tensor_wasm_artifacts::DiskArtifactStore`] layout
1232/// (`*.<key-fp>.bin`) holding the streaming-encoded HMAC-SHA256 +
1233/// zstd envelope around the V2 kernel-manifest framing.
1234///
1235/// The V2 kernel-manifest framing (16-byte `TWJIT-KRNL-v2\0\0\0` magic +
1236/// length-prefixed body: blueprint, sm_version, grid_x, block_x,
1237/// ptx_len, ptx bytes) is built ABOVE the artifact store: a `put` first
1238/// serialises the kernel into a V2 envelope `Vec<u8>`, then hands that
1239/// to [`DiskArtifactStore::put`] which streams it through an HMAC-tee +
1240/// zstd encoder onto disk. A `get` reverses the layering: look up the
1241/// sidecar to get the [`ContentHash`], call [`DiskArtifactStore::get`]
1242/// which streaming-verifies the outer HMAC + decompresses, then parse
1243/// the inner V2 envelope to recover the kernel.
1244///
1245/// ## Why two files
1246///
1247/// [`DiskArtifactStore`] is content-addressed: lookups go through a
1248/// [`ContentHash`] derived from the payload bytes, not through a caller
1249/// key. The kernel cache needs lookups by tenant-scoped [`CacheKey`],
1250/// so the sidecar maps `CacheKey -> ContentHash` while the blob owns
1251/// the bytes. This preserves the streaming HMAC + zstd properties of
1252/// the store (T22) without baking the cache-key shape into the store
1253/// itself. Filenames stay partitioned by HMAC-key fingerprint on both
1254/// sides — the sidecar via `path_for`'s `{key_prefix}-…` prefix, the
1255/// blob via [`DiskArtifactStore`]'s own `{hash}.{key_fp}.bin` shape —
1256/// so two stores in the same directory under different keys cannot
1257/// race on the same path.
1258///
1259/// ## V2 envelope (current writer, body inside the artifact store)
1260///
1261/// ```text
1262/// [0..16) magic = "TWJIT-KRNL-v2\0\0"
1263/// [16..24) tenant_id (u64 LE) <- tenant-binding defence-in-depth
1264/// [24..32) blueprint fingerprint (u64 LE)
1265/// [32..36) sm_version (u32 LE)
1266/// [36..40) launch_geometry.grid_x (u32 LE)
1267/// [40..44) launch_geometry.block_x (u32 LE)
1268/// [44..52) ptx length (u64 LE)
1269/// [52..52+ptx_len) PTX text (UTF-8, NOT null-terminated)
1270/// ```
1271///
1272/// ## Tenant binding (defence-in-depth)
1273///
1274/// The `tenant_id` word at `[16..24)` was added so the *persisted*
1275/// envelope binds the owning tenant, not merely the filename path
1276/// (`path_for` already folds `tenant_id` into the path hash, but the
1277/// path alone is the only thing scoping a blob to a tenant). Without
1278/// this, an attacker who can drop a well-formed, HMAC-valid sidecar +
1279/// blob at *another* tenant's `path_for` location would have it served
1280/// to that tenant on `get`. The reader now cross-checks
1281/// `tenant_on_disk == key.tenant_id` alongside the existing
1282/// fingerprint / sm_version defence-in-depth check and rejects a
1283/// mismatch as a miss.
1284///
1285/// The 16-byte magic stays `TWJIT-KRNL-v2\0\0\0` (the cross-version
1286/// compat invariant pinned by the artifact-store round-trip test); the
1287/// tenant word is an in-place, length-extending revision of the V2 body.
1288/// A genuinely pre-tenant V2 blob fed to this reader misaligns every
1289/// field and is rejected cleanly by the tenant / fingerprint / sm_version
1290/// cross-check (worst case a clean miss + rewrite on the next `put`) —
1291/// the L2 cache is regenerable, so no migration step is required.
1292///
1293/// The HMAC trailer that pre-T30 V2 sat at the end of the file is gone
1294/// from this layer — the artifact store provides streaming HMAC over
1295/// the entire envelope (T22), so a second per-envelope MAC would be
1296/// redundant.
1297///
1298/// ## V1 magic (read-only)
1299///
1300/// Pre-T30 V1/V2 files (magic `"TWJIT-KRNL-v1\0\0\0"` or
1301/// `"TWJIT-KRNL-v2\0\0\0"` at offset 0 of a `.ptxbin` file written by
1302/// the legacy raw-file writer) sit at a different path shape than the
1303/// new T30 sidecar and are silently invisible to the new reader: they
1304/// no longer occupy the post-T30 sidecar path, so a fresh `get` of
1305/// the corresponding key returns a clean miss and the next `put` rewrites
1306/// the entry as `(sidecar, blob)` under the unified store. This matches
1307/// the `cache.l2.miss.legacy_magic` behaviour of pre-T30 V1 detection
1308/// without any decoder support for the legacy raw layout.
1309struct DiskCache {
1310 /// Directory the cache writes to. Cloned out of the supplied
1311 /// [`DiskCacheConfig`] so we can drop the original config (and its
1312 /// plain `[u8; 32]` copy of the key) and keep only the zeroizing
1313 /// copy below as the long-lived owner.
1314 dir: PathBuf,
1315 /// 32-byte HMAC-keyed-BLAKE3 key, wiped on drop. `Zeroizing` Derefs
1316 /// to `[u8; 32]` so existing `&self.hmac_key[..]`-style call sites
1317 /// keep compiling. The artifact store holds an independent copy
1318 /// (also zeroising) — the duplication is intentional so the
1319 /// sidecar's path-prefix fingerprint and the store's own
1320 /// per-blob path-prefix fingerprint agree byte-for-byte without
1321 /// either side reaching into the other's private field.
1322 hmac_key: Zeroizing<[u8; 32]>,
1323 /// Cached 16-hex-char fingerprint of `hmac_key` — the `{key_prefix}`
1324 /// segment every `path_for` filename leads with.
1325 ///
1326 /// Perf: the HMAC-key fingerprint is `blake3(hmac_key)` truncated, which
1327 /// is constant for the cache's lifetime (the key never changes after
1328 /// construction). `path_for` used to recompute it via
1329 /// `blake3::hash(&self.hmac_key[..])` on *every* disk op; hoisting it
1330 /// here computes the digest exactly once in `new`. The per-cache-key
1331 /// digest in `path_for` genuinely varies per call and stays inline.
1332 /// This is *not* secret (it is already in every filename), so caching
1333 /// the rendered hex rather than the key bytes leaks nothing.
1334 key_prefix_hex: String,
1335 /// Underlying streaming content-addressed signed blob store.
1336 /// Holds the v2-envelope-wrapped kernel payloads. Wrapped in an
1337 /// `Arc` so the disk cache is cheaply clonable — `KernelCache`
1338 /// already holds the cache behind `Arc<DiskCache>`, this is the
1339 /// only field whose backend benefits from sharing.
1340 store: Arc<DiskArtifactStore>,
1341 /// Shared clone of the owning [`KernelCache`]'s integrity-rejection
1342 /// counter (backs `tensor_wasm_jit_cache_integrity_reject_total`).
1343 /// Bumped on the L2 read path when an entry is refused for a genuine
1344 /// integrity failure (artifact-store HMAC/content-hash mismatch or an
1345 /// inner-envelope magic mismatch) — i.e. a tamper signal, distinct
1346 /// from a benign miss (missing sidecar / legacy magic) which leaves
1347 /// this untouched.
1348 integrity_reject_total: Arc<AtomicU64>,
1349}
1350
1351/// V2 magic for the inner kernel-manifest envelope wrapped inside each
1352/// artifact-store blob. Unchanged across the T30 migration so cross-
1353/// version compat is preserved: an older reader extracting this body
1354/// from any future archive can still parse it.
1355const DISK_CACHE_MAGIC_V2: &[u8; 16] = b"TWJIT-KRNL-v2\0\0\0";
1356/// V2 header: magic + tenant_id + fingerprint + sm_version + grid_x +
1357/// block_x + ptx_len. The `tenant_id` word is the tenant-binding
1358/// defence-in-depth field — see the [`DiskCache`] type-level "Tenant
1359/// binding" note.
1360const DISK_CACHE_HEADER_LEN_V2: usize = 16 + 8 + 8 + 4 + 4 + 4 + 8;
1361
1362/// 16-byte sidecar magic. Stamped at the head of every `*.ptxbin`
1363/// sidecar so the reader can tell a T30 sidecar apart from any legacy
1364/// pre-T30 raw-V2 file that happens to live under the same filename
1365/// scheme. Pre-T30 files start with `TWJIT-KRNL-v2\0\0\0`; T30 sidecars
1366/// start with this distinct magic, so a stale legacy file is treated
1367/// as a miss and the next `put` overwrites it.
1368const SIDECAR_MAGIC_V1: &[u8; 16] = b"TWJIT-IDX-v1\0\0\0\0";
1369/// Sidecar total length: 16-byte magic + 32-byte BLAKE3 content hash.
1370const SIDECAR_LEN_V1: usize = 16 + 32;
1371
1372impl DiskCache {
1373 fn new(mut cfg: DiskCacheConfig, integrity_reject_total: Arc<AtomicU64>) -> Self {
1374 // Move the key bytes into a `Zeroizing` newtype so the long-lived
1375 // copy is wiped on `DiskCache::drop`. We cannot partially move
1376 // fields out of `cfg` directly because `DiskCacheConfig`
1377 // implements `Drop` (jit S-3 T13) — the language forbids
1378 // partial moves out of a `Drop` type. Instead we `mem::take`
1379 // each field, leaving the source struct in a default-valued
1380 // state before its `Drop::drop` runs and zeroizes the (now
1381 // already-defaulted) `hmac_key` array a second time.
1382 let dir = std::mem::take(&mut cfg.dir);
1383 // Wrap the moved-out key in `Zeroizing` *immediately* (jit L4) so the
1384 // stack copy is wiped when this function returns, rather than left as
1385 // a plain `[u8; 32]` lingering in the frame after both consumers have
1386 // taken their own copies.
1387 let key_bytes = Zeroizing::new(std::mem::take(&mut cfg.hmac_key));
1388 // The artifact store gets its own copy of the same key so its
1389 // streaming HMAC matches the one the sidecar's path-prefix
1390 // fingerprint will agree with. Both copies are wrapped in
1391 // `Zeroizing` (the artifact store wraps internally) so neither
1392 // construction-time bytes linger after drop. `*key_bytes` derefs the
1393 // `Zeroizing` to hand the store its by-value `[u8; 32]`.
1394 let store = Arc::new(DiskArtifactStore::new(dir.clone(), *key_bytes));
1395 // Perf: the HMAC-key fingerprint (`blake3(hmac_key)` truncated to 8
1396 // bytes → 16 hex chars) is constant for the cache's lifetime, so
1397 // compute it once here rather than on every `path_for` call. See the
1398 // `key_prefix_hex` field doc.
1399 let key_fp = blake3::hash(&key_bytes[..]);
1400 let mut key_prefix_buf = [0u8; 16];
1401 hex::encode_to_slice(&key_fp.as_bytes()[..8], &mut key_prefix_buf)
1402 .expect("16 byte buf for 8 byte input");
1403 let key_prefix_hex = std::str::from_utf8(&key_prefix_buf)
1404 .expect("hex is utf8")
1405 .to_string();
1406 Self {
1407 dir,
1408 hmac_key: key_bytes,
1409 key_prefix_hex,
1410 store,
1411 integrity_reject_total,
1412 }
1413 }
1414
1415 /// Build the on-disk path for a key. Hash the full key (including
1416 /// tenant_id and emit_config_hash) so two tenants cannot collide
1417 /// on the same blueprint+sm and so the file name itself does not
1418 /// leak the blueprint fingerprint to anyone with directory-list
1419 /// access.
1420 ///
1421 /// The filename is also prefixed with the first 8 bytes of
1422 /// `blake3::hash(hmac_key)` so two `KernelCache`s pointed at the
1423 /// same directory but configured with different HMAC keys produce
1424 /// *disjoint* paths. Without this prefix the two writers would
1425 /// race on the same final path (`tmp.persist` overwrites whichever
1426 /// landed first) and both readers would then fail the HMAC check
1427 /// on each other's writes — every put-then-get round-trip would
1428 /// look like a miss in steady state.
1429 ///
1430 /// The 8-byte HMAC-key fingerprint is NOT the key itself: it's
1431 /// `blake3::hash(key)` truncated, which is already publicly
1432 /// observable (anyone with directory-list access can read the
1433 /// filename). The actual MAC trailer still gates load — partitioning
1434 /// just avoids the inter-key collision, it is not a confidentiality
1435 /// boundary.
1436 ///
1437 /// Format: `{key_prefix:016x}-{cache_key_hex}.ptxbin`. The key
1438 /// prefix leads so `ls`-style directory listings group entries by
1439 /// the writing key — handy for operators rotating keys (each
1440 /// rotation lands under a new prefix and the old generation is
1441 /// trivially `rm`-able by prefix glob).
1442 fn path_for(&self, key: &CacheKey) -> PathBuf {
1443 let mut hasher = blake3::Hasher::new();
1444 hasher.update(b"tensor-wasm-jit::DiskCache::path::v1\0");
1445 hasher.update(&key.tenant_id.to_le_bytes());
1446 hasher.update(&key.blueprint.to_le_bytes());
1447 hasher.update(&key.sm_version.to_le_bytes());
1448 hasher.update(&key.emit_config_hash.to_le_bytes());
1449 let h = hasher.finalize();
1450 // First 16 bytes (32 hex chars) is plenty of entropy for filenames.
1451 //
1452 // T20 perf: render the digest into a fixed 32-byte stack buffer via
1453 // `hex::encode_to_slice` rather than 16× `format!("{b:02x}")` —
1454 // the per-byte `format!` path used to allocate 16 transient `String`s
1455 // (plus the `.collect()` target) on every disk-cache op. The slice
1456 // form writes ASCII hex directly into the buffer with no heap
1457 // traffic; `str::from_utf8` then borrows it as a `&str` for the
1458 // final `format!`-built filename.
1459 let digest = h.as_bytes();
1460 let mut cache_key_hex_buf = [0u8; 32];
1461 hex::encode_to_slice(&digest[..16], &mut cache_key_hex_buf)
1462 .expect("32 byte buf for 16 byte input");
1463 let cache_key_hex = std::str::from_utf8(&cache_key_hex_buf).expect("hex is utf8");
1464 // Perf: the HMAC-key fingerprint prefix is constant for the cache's
1465 // lifetime, so it was hoisted to a `key_prefix_hex` field computed
1466 // once in `DiskCache::new` rather than re-hashing `blake3(hmac_key)`
1467 // on every disk op. The per-cache-key digest above genuinely varies
1468 // per call and stays inline. See the `key_prefix_hex` field doc.
1469 let key_prefix_hex = &self.key_prefix_hex;
1470 self.dir
1471 .join(format!("{key_prefix_hex}-{cache_key_hex}.ptxbin"))
1472 }
1473
1474 /// Encode a kernel into the inner V2 envelope (the same byte layout
1475 /// pre-T30 used at the head of every `.ptxbin` file, minus the
1476 /// per-envelope HMAC trailer — the streaming HMAC is now the
1477 /// artifact store's job). The result is what gets handed to
1478 /// [`DiskArtifactStore::put`].
1479 fn encode_v2_envelope(key: &CacheKey, kernel: &CachedKernel) -> Vec<u8> {
1480 let ptx_bytes = kernel.ptx.text.as_bytes();
1481 let (grid_x, block_x) = kernel.ptx.launch_geometry;
1482 let mut buf = Vec::with_capacity(DISK_CACHE_HEADER_LEN_V2 + ptx_bytes.len());
1483 buf.extend_from_slice(DISK_CACHE_MAGIC_V2);
1484 // jit L (tenant-isolation defence-in-depth): bind the owning tenant
1485 // into the persisted envelope, not just the filename path. The reader
1486 // asserts this matches the requesting key so a sidecar+blob planted at
1487 // another tenant's `path_for` location is rejected. See the `DiskCache`
1488 // "Tenant binding" doc.
1489 buf.extend_from_slice(&key.tenant_id.to_le_bytes());
1490 buf.extend_from_slice(&key.blueprint.to_le_bytes());
1491 buf.extend_from_slice(&key.sm_version.to_le_bytes());
1492 // jit S-3 follow-up: persist `launch_geometry` so L2 hits round-trip
1493 // the (grid_x, block_x) hint that `ptx_emit` populates. Prior to V2
1494 // the reconstructor defaulted this to (0, 0) on every disk hit.
1495 buf.extend_from_slice(&grid_x.to_le_bytes());
1496 buf.extend_from_slice(&block_x.to_le_bytes());
1497 buf.extend_from_slice(&(ptx_bytes.len() as u64).to_le_bytes());
1498 buf.extend_from_slice(ptx_bytes);
1499 buf
1500 }
1501
1502 /// Write a kernel via the layered `(sidecar -> blob)` representation:
1503 ///
1504 /// 1. Build the inner V2 envelope `Vec<u8>` (cross-version-compat
1505 /// format, T12-aligned).
1506 /// 2. Hand the envelope bytes to [`DiskArtifactStore::put`] which
1507 /// streams HMAC + zstd onto disk under a content-addressed path
1508 /// and returns the [`ContentHash`] of the envelope.
1509 /// 3. Write a small sidecar at [`Self::path_for`] mapping the
1510 /// [`CacheKey`] to that [`ContentHash`] via an atomic temp-then-
1511 /// rename, so a partial write never strands a half-formed
1512 /// sidecar that a concurrent reader might trip over.
1513 ///
1514 /// The artifact store inherits T22's streaming property: the
1515 /// envelope's bytes are not re-buffered into another `Vec` before
1516 /// HMAC + zstd — the store's `put` tees through a `MacWriter` /
1517 /// zstd encoder pipeline straight to the file. The only buffered
1518 /// allocation here is the v2 envelope itself, which is bounded by
1519 /// the kernel's PTX size and lives just long enough to be consumed
1520 /// by `store.put`.
1521 fn put(&self, key: &CacheKey, kernel: &CachedKernel) -> std::io::Result<()> {
1522 use std::io::Write;
1523 std::fs::create_dir_all(&self.dir)?;
1524 // Step 1: build the inner V2 envelope (no per-envelope MAC —
1525 // the artifact store's HMAC covers it transitively).
1526 let envelope = Self::encode_v2_envelope(key, kernel);
1527 // Step 2: stream the envelope through the artifact store. The
1528 // store handles atomic temp-then-rename of the blob itself.
1529 let hash = self.store.put(&envelope).map_err(|e| {
1530 // Wrap as `io::Error` so the existing `Result<(), io::Error>`
1531 // signature of `DiskCache::put` (and the warn-level log path
1532 // in `KernelCache::put`) stays unchanged.
1533 std::io::Error::other(e.to_string())
1534 })?;
1535 // Step 3: stamp the sidecar atomically. The sidecar is small
1536 // (48 bytes) and lives at a path derived from the cache key, so
1537 // the lookup side only needs one read to find the content
1538 // hash and one more (through the artifact store) to fetch the
1539 // verified envelope bytes.
1540 let mut sidecar = Vec::with_capacity(SIDECAR_LEN_V1);
1541 sidecar.extend_from_slice(SIDECAR_MAGIC_V1);
1542 sidecar.extend_from_slice(hash.as_bytes());
1543 debug_assert_eq!(sidecar.len(), SIDECAR_LEN_V1);
1544 let sidecar_path = self.path_for(key);
1545 let mut tmp = tempfile::NamedTempFile::new_in(&self.dir)?;
1546 tmp.as_file_mut().write_all(&sidecar)?;
1547 tmp.persist(&sidecar_path).map_err(std::io::Error::other)?;
1548 Ok(())
1549 }
1550
1551 /// Read and verify a kernel from disk. Returns `Ok(None)` on a
1552 /// genuine miss (sidecar does not exist), `Err` on I/O failure,
1553 /// and `Ok(None)` (with a warn-level log) on any integrity failure
1554 /// — magic mismatch on the sidecar, blob lookup failure,
1555 /// artifact-store HMAC mismatch, or envelope header mismatch. The
1556 /// loader treats every integrity failure as "no such entry" so a
1557 /// poisoned file behaves identically to a fresh cache.
1558 fn get(&self, key: &CacheKey) -> std::io::Result<Option<CachedKernel>> {
1559 // ---- Sidecar lookup. ----
1560 let sidecar_path = self.path_for(key);
1561 let sidecar = match std::fs::read(&sidecar_path) {
1562 Ok(b) => b,
1563 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1564 Err(e) => return Err(e),
1565 };
1566 if sidecar.len() != SIDECAR_LEN_V1 {
1567 tracing::warn!(
1568 target: "tensor_wasm_jit::cache",
1569 file = %sidecar_path.display(),
1570 len = sidecar.len(),
1571 "disk-cache sidecar wrong length; treating as miss"
1572 );
1573 return Ok(None);
1574 }
1575 if &sidecar[..16] != SIDECAR_MAGIC_V1 {
1576 // Either a legacy pre-T30 raw-V2 record sitting at the same
1577 // path (TWJIT-KRNL-v2 magic) or unrelated garbage. Either
1578 // way the new reader does not understand it — treat as a
1579 // miss so the next `put` rewrites it under the T30 layout.
1580 tracing::info!(
1581 target: "tensor_wasm_jit::cache",
1582 file = %sidecar_path.display(),
1583 "cache.l2.miss.legacy_or_unknown_magic: sidecar will be rewritten on next put"
1584 );
1585 return Ok(None);
1586 }
1587 let mut hash_bytes = [0u8; 32];
1588 hash_bytes.copy_from_slice(&sidecar[16..48]);
1589 let content_hash = ContentHash::from_bytes(hash_bytes);
1590
1591 // ---- Streaming-verified blob fetch via the artifact store. ----
1592 //
1593 // The store handles HMAC-SHA256 verification (constant-time),
1594 // zstd decompression with a [`MAX_DECOMPRESSED_LEN`] cap, and
1595 // content-hash defence-in-depth. Any failure (NotFound,
1596 // BadHmac, HashMismatch, …) collapses to a miss here, mirroring
1597 // the pre-T30 reader's "log + return Ok(None)" convention so
1598 // the call site's `cache.misses_total` counter still ticks
1599 // correctly on integrity rejection.
1600 let envelope = match self.store.get(&content_hash) {
1601 Ok(bytes) => bytes,
1602 Err(ArtifactError::NotFound(_)) => {
1603 tracing::warn!(
1604 target: "tensor_wasm_jit::cache",
1605 file = %sidecar_path.display(),
1606 "disk-cache sidecar references missing artifact blob; treating as miss"
1607 );
1608 return Ok(None);
1609 }
1610 Err(e) => {
1611 // Anything that is not a clean NotFound — BadHmac,
1612 // HashMismatch, decompression-bomb cap, … — is a genuine
1613 // integrity failure (tamper signal), so bump the dedicated
1614 // integrity-rejection counter in addition to the call
1615 // site's miss counter.
1616 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1617 tracing::warn!(
1618 target: "tensor_wasm_jit::cache",
1619 file = %sidecar_path.display(),
1620 error = %e,
1621 "disk-cache artifact-store read failed; treating as miss"
1622 );
1623 return Ok(None);
1624 }
1625 };
1626
1627 // ---- Parse the inner V2 envelope. ----
1628 if envelope.len() < DISK_CACHE_HEADER_LEN_V2 {
1629 tracing::warn!(
1630 target: "tensor_wasm_jit::cache",
1631 file = %sidecar_path.display(),
1632 len = envelope.len(),
1633 "disk-cache V2 envelope too short; treating as miss"
1634 );
1635 return Ok(None);
1636 }
1637 if &envelope[..16] != DISK_CACHE_MAGIC_V2 {
1638 // The artifact store already HMAC-verified the blob, so a bad
1639 // envelope magic here means the *verified* bytes are not a
1640 // kernel envelope — corruption/tamper inside the signed
1641 // payload. Count it as an integrity rejection.
1642 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1643 tracing::warn!(
1644 target: "tensor_wasm_jit::cache",
1645 file = %sidecar_path.display(),
1646 "disk-cache V2 envelope magic mismatch; treating as miss"
1647 );
1648 return Ok(None);
1649 }
1650 // Header integrity is implied by the artifact store's HMAC, but
1651 // cross-check tenant_id, fingerprint and sm_version against the
1652 // requested key as defence-in-depth — a sidecar that points at a
1653 // foreign blob (e.g. someone hand-edited the sidecar's content hash)
1654 // would still survive the store's MAC because each blob carries
1655 // its own self-consistent envelope; only this final check
1656 // refuses the mismatch.
1657 let mut tenant_bytes = [0u8; 8];
1658 tenant_bytes.copy_from_slice(&envelope[16..24]);
1659 let mut bp_bytes = [0u8; 8];
1660 bp_bytes.copy_from_slice(&envelope[24..32]);
1661 let mut sm_bytes = [0u8; 4];
1662 sm_bytes.copy_from_slice(&envelope[32..36]);
1663 let mut grid_x_bytes = [0u8; 4];
1664 grid_x_bytes.copy_from_slice(&envelope[36..40]);
1665 let mut block_x_bytes = [0u8; 4];
1666 block_x_bytes.copy_from_slice(&envelope[40..44]);
1667 let mut len_bytes = [0u8; 8];
1668 len_bytes.copy_from_slice(&envelope[44..52]);
1669 let tenant_on_disk = u64::from_le_bytes(tenant_bytes);
1670 let fingerprint_on_disk = u64::from_le_bytes(bp_bytes);
1671 let sm_version_on_disk = u32::from_le_bytes(sm_bytes);
1672 let grid_x_on_disk = u32::from_le_bytes(grid_x_bytes);
1673 let block_x_on_disk = u32::from_le_bytes(block_x_bytes);
1674 let ptx_len_on_disk = u64::from_le_bytes(len_bytes) as usize;
1675 // jit L (tenant-isolation defence-in-depth): the persisted tenant_id
1676 // MUST match the requesting key. The filename path already partitions
1677 // by tenant (path_for folds tenant_id into the hash), but binding the
1678 // tenant *inside* the signed envelope closes the gap where a planted
1679 // sidecar+blob at another tenant's path would otherwise be served to
1680 // that tenant. A genuine pre-tenant V2 blob also lands here (its
1681 // misaligned fields will not satisfy this check) and is rejected as a
1682 // clean miss to be rewritten on the next put.
1683 if tenant_on_disk != key.tenant_id
1684 || fingerprint_on_disk != key.blueprint
1685 || sm_version_on_disk != key.sm_version
1686 {
1687 // Fires only on a HMAC-verified blob whose header does not match
1688 // the requested key — a hand-edited sidecar pointing at a
1689 // foreign blob, or a blob planted under a foreign tenant's path.
1690 // Integrity rejection.
1691 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1692 tracing::warn!(
1693 target: "tensor_wasm_jit::cache",
1694 file = %sidecar_path.display(),
1695 tenant = key.tenant_id,
1696 tenant_on_disk,
1697 "disk-cache V2 envelope header key mismatch (tenant/fingerprint/sm); treating as miss"
1698 );
1699 return Ok(None);
1700 }
1701 let ptx_start = DISK_CACHE_HEADER_LEN_V2;
1702 let ptx_end = ptx_start.saturating_add(ptx_len_on_disk);
1703 if ptx_end > envelope.len() {
1704 // Declared length overruns the verified envelope — structural
1705 // corruption inside the signed payload. Integrity rejection.
1706 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1707 tracing::warn!(
1708 target: "tensor_wasm_jit::cache",
1709 file = %sidecar_path.display(),
1710 "disk-cache declared ptx_len overruns envelope; treating as miss"
1711 );
1712 return Ok(None);
1713 }
1714 let ptx_text = match std::str::from_utf8(&envelope[ptx_start..ptx_end]) {
1715 Ok(s) => s.to_string(),
1716 Err(_) => {
1717 // Non-UTF-8 PTX in a verified blob — corruption/tamper.
1718 self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1719 tracing::warn!(
1720 target: "tensor_wasm_jit::cache",
1721 file = %sidecar_path.display(),
1722 "disk-cache PTX bytes are not valid UTF-8; treating as miss"
1723 );
1724 return Ok(None);
1725 }
1726 };
1727 // Reconstruct the kernel via the integrity-aware constructor so
1728 // the L1 cache accepts it without further verification work.
1729 // V2 persists the emit-time `launch_geometry` hint; reading it
1730 // back here closes the lost-geometry bug (previously this defaulted
1731 // to (0, 0) and the dispatch path silently fell back to guest-
1732 // declared launch params for every L2 hit).
1733 let ptx = Arc::new(EmittedPtx {
1734 text: ptx_text,
1735 launch_geometry: (grid_x_on_disk, block_x_on_disk),
1736 });
1737 Ok(Some(CachedKernel::new(
1738 fingerprint_on_disk,
1739 ptx,
1740 CompiledHandle::default(),
1741 )))
1742 }
1743
1744 /// Fingerprint of this cache's *own* (currently-active) HMAC key — the
1745 /// partition prefix every file this cache writes lands under.
1746 fn active_key_fingerprint(&self) -> KeyFingerprint {
1747 KeyFingerprint::of_key(&self.hmac_key)
1748 }
1749
1750 /// Parse the key-fingerprint segment out of a cache file name.
1751 ///
1752 /// Two on-disk shapes carry the fingerprint:
1753 /// * sidecars `{fp}-{cache_key}.ptxbin` — fingerprint is the segment
1754 /// before the first `-`.
1755 /// * artifact blobs `{content_hash}.{fp}.bin` — fingerprint is the
1756 /// second-to-last `.`-delimited segment.
1757 ///
1758 /// Returns `None` for any name that does not match one of these shapes
1759 /// (so unrelated files in the directory are never touched by `gc`).
1760 fn fingerprint_of_filename(name: &str) -> Option<KeyFingerprint> {
1761 if let Some(rest) = name.strip_suffix(".ptxbin") {
1762 // `{fp}-{cache_key}` — split on the first '-'.
1763 let fp = rest.split('-').next()?;
1764 if fp.is_empty() || fp.len() != 16 {
1765 return None;
1766 }
1767 return Some(KeyFingerprint(fp.to_string()));
1768 }
1769 if let Some(rest) = name.strip_suffix(".bin") {
1770 // `{content_hash}.{fp}` — fingerprint is the trailing segment.
1771 let fp = rest.rsplit('.').next()?;
1772 if fp.len() != 16 {
1773 return None;
1774 }
1775 return Some(KeyFingerprint(fp.to_string()));
1776 }
1777 None
1778 }
1779
1780 /// Enumerate the distinct HMAC-key fingerprints with at least one file
1781 /// (sidecar or artifact blob) present in the cache directory. A missing
1782 /// directory yields an empty set (a fresh cache has nothing to sweep).
1783 fn key_fingerprints_on_disk(&self) -> std::io::Result<Vec<KeyFingerprint>> {
1784 let mut seen = std::collections::BTreeSet::new();
1785 let rd = match std::fs::read_dir(&self.dir) {
1786 Ok(rd) => rd,
1787 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1788 return Ok(Vec::new());
1789 }
1790 Err(e) => return Err(e),
1791 };
1792 for entry in rd {
1793 let entry = entry?;
1794 if let Some(name) = entry.file_name().to_str() {
1795 if let Some(fp) = Self::fingerprint_of_filename(name) {
1796 seen.insert(fp);
1797 }
1798 }
1799 }
1800 Ok(seen.into_iter().collect())
1801 }
1802
1803 /// Garbage-collect stale-fingerprint files: remove every sidecar /
1804 /// artifact-blob whose key fingerprint is NOT in `retain`, returning
1805 /// the count of files removed.
1806 ///
1807 /// The active key's fingerprint is unconditionally retained even if a
1808 /// caller forgets to list it — this is the safety guarantee in the
1809 /// public-API docs: `gc` only sweeps *stale* generations, never the
1810 /// live one. Files that do not match a known cache-file shape (anything
1811 /// `fingerprint_of_filename` rejects) are left untouched.
1812 fn gc(&self, retain: &[KeyFingerprint]) -> std::io::Result<usize> {
1813 let active = self.active_key_fingerprint();
1814 let rd = match std::fs::read_dir(&self.dir) {
1815 Ok(rd) => rd,
1816 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1817 return Ok(0);
1818 }
1819 Err(e) => return Err(e),
1820 };
1821 let mut removed = 0usize;
1822 for entry in rd {
1823 let entry = entry?;
1824 let name_os = entry.file_name();
1825 let name = match name_os.to_str() {
1826 Some(n) => n,
1827 None => continue,
1828 };
1829 let fp = match Self::fingerprint_of_filename(name) {
1830 Some(fp) => fp,
1831 None => continue, // not one of ours — leave it alone
1832 };
1833 // Never sweep the active key's files; never sweep a retained one.
1834 if fp == active || retain.contains(&fp) {
1835 continue;
1836 }
1837 match std::fs::remove_file(entry.path()) {
1838 Ok(()) => removed += 1,
1839 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1840 // Raced with another sweeper / writer; treat as already gone.
1841 }
1842 Err(e) => return Err(e),
1843 }
1844 }
1845 Ok(removed)
1846 }
1847}
1848
1849impl Default for KernelCache {
1850 fn default() -> Self {
1851 Self::new()
1852 }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use super::*;
1858 use crate::ir::{ElemType, TensorWasmKernelBlueprint, TensorWasmOp};
1859 use crate::ptx_emit::EmittedPtx;
1860 use std::thread;
1861
1862 fn dummy_kernel(fp: u64) -> CachedKernel {
1863 // Route through `CachedKernel::new` so `integrity_hash` matches the
1864 // PTX text by construction; `KernelCache::put` rejects entries
1865 // whose hash disagrees with the text (jit S-3).
1866 CachedKernel::new(
1867 fp,
1868 Arc::new(EmittedPtx {
1869 text: String::new(),
1870 launch_geometry: (1, 1),
1871 }),
1872 CompiledHandle::default(),
1873 )
1874 }
1875
1876 #[test]
1877 fn put_then_get() {
1878 let cache = KernelCache::new();
1879 let key = CacheKey::for_tenant(TenantId(7), 1, 80);
1880 cache.put(key, dummy_kernel(1));
1881 assert_eq!(cache.get(&key).unwrap().fingerprint, 1);
1882 }
1883
1884 /// Build a kernel whose PTX text is exactly `bytes` long so byte-cap
1885 /// tests can reason about `total_bytes` precisely.
1886 fn sized_kernel(fp: u64, bytes: usize) -> CachedKernel {
1887 CachedKernel::new(
1888 fp,
1889 Arc::new(EmittedPtx {
1890 text: "x".repeat(bytes),
1891 launch_geometry: (1, 1),
1892 }),
1893 CompiledHandle::default(),
1894 )
1895 }
1896
1897 /// Byte-cap eviction: with a generous count cap but a tight byte cap,
1898 /// inserting oversized blueprints must keep the resident byte total
1899 /// bounded by evicting LRU entries, and the eviction order must be LRU.
1900 #[test]
1901 fn byte_cap_evicts_lru_and_bounds_total_bytes() {
1902 // Count cap of 100 (won't bind) but a 250-byte total cap. Each
1903 // entry is 100 bytes, so at most 2 entries fit (200 <= 250; a 3rd
1904 // would push to 300 > 250).
1905 let cache = KernelCache::with_config(
1906 KernelCacheConfig::default()
1907 .with_capacity(100)
1908 .with_max_total_bytes(250),
1909 );
1910 let k1 = CacheKey::for_tenant(TenantId(0), 1, 80);
1911 let k2 = CacheKey::for_tenant(TenantId(0), 2, 80);
1912 let k3 = CacheKey::for_tenant(TenantId(0), 3, 80);
1913
1914 cache.put(k1, sized_kernel(1, 100));
1915 cache.put(k2, sized_kernel(2, 100));
1916 assert_eq!(cache.total_bytes(), 200, "two 100-byte entries fit");
1917 assert_eq!(cache.len(), 2);
1918
1919 // Inserting the third 100-byte entry overflows the byte cap (300 >
1920 // 250). The LRU entry (k1) must be evicted, bringing the total back
1921 // to 200 and keeping k2 + k3.
1922 cache.put(k3, sized_kernel(3, 100));
1923 assert!(
1924 cache.total_bytes() <= 250,
1925 "byte total must stay bounded by the cap, got {}",
1926 cache.total_bytes()
1927 );
1928 assert_eq!(cache.total_bytes(), 200);
1929 assert_eq!(cache.len(), 2);
1930 assert!(
1931 cache.get(&k1).is_none(),
1932 "k1 is the LRU and must be evicted"
1933 );
1934 assert!(cache.get(&k2).is_some(), "k2 must survive");
1935 assert!(cache.get(&k3).is_some(), "k3 was just inserted");
1936 }
1937
1938 /// A single entry larger than the byte cap is still admitted (the cache
1939 /// never wedges dispatch by refusing an insert) but it evicts every
1940 /// other entry, so the byte total settles at just that one oversized
1941 /// entry.
1942 #[test]
1943 fn byte_cap_admits_single_oversized_entry() {
1944 let cache = KernelCache::with_config(
1945 KernelCacheConfig::default()
1946 .with_capacity(100)
1947 .with_max_total_bytes(50),
1948 );
1949 let small = CacheKey::for_tenant(TenantId(0), 1, 80);
1950 let big = CacheKey::for_tenant(TenantId(0), 2, 80);
1951 cache.put(small, sized_kernel(1, 40));
1952 cache.put(big, sized_kernel(2, 1000));
1953 // The oversized entry is served; the small one was evicted to make
1954 // room. Total transiently exceeds the cap by exactly that one entry.
1955 assert_eq!(cache.len(), 1);
1956 assert_eq!(cache.total_bytes(), 1000);
1957 assert!(
1958 cache.get(&big).is_some(),
1959 "oversized entry must still serve"
1960 );
1961 assert!(cache.get(&small).is_none(), "smaller LRU entry evicted");
1962 }
1963
1964 /// `total_bytes` must track replacement (same key re-inserted with a
1965 /// different-sized payload) as a delta, not an add.
1966 #[test]
1967 fn total_bytes_tracks_replacement() {
1968 let cache = KernelCache::new();
1969 let key = CacheKey::for_tenant(TenantId(0), 1, 80);
1970 cache.put(key, sized_kernel(1, 100));
1971 assert_eq!(cache.total_bytes(), 100);
1972 // Replace the same key with a larger payload — net delta is +50.
1973 cache.put(key, sized_kernel(1, 150));
1974 assert_eq!(cache.len(), 1);
1975 assert_eq!(cache.total_bytes(), 150);
1976 }
1977
1978 /// Default config keeps the byte cap disabled (count-only behaviour),
1979 /// while `total_bytes` still tracks the live total.
1980 #[test]
1981 fn byte_cap_default_is_none() {
1982 let cache = KernelCache::new();
1983 assert_eq!(cache.max_total_bytes(), None);
1984 cache.put(
1985 CacheKey::for_tenant(TenantId(0), 1, 80),
1986 sized_kernel(1, 512),
1987 );
1988 assert_eq!(cache.total_bytes(), 512);
1989 }
1990
1991 #[test]
1992 fn lru_evicts_oldest() {
1993 let cache = KernelCache::with_capacity(2);
1994 let k1 = CacheKey::for_tenant(TenantId(0), 1, 80);
1995 let k2 = CacheKey::for_tenant(TenantId(0), 2, 80);
1996 let k3 = CacheKey::for_tenant(TenantId(0), 3, 80);
1997 cache.put(k1, dummy_kernel(1));
1998 cache.put(k2, dummy_kernel(2));
1999 cache.put(k3, dummy_kernel(3));
2000 assert!(cache.get(&k1).is_none(), "k1 should have been evicted");
2001 assert!(cache.get(&k2).is_some());
2002 assert!(cache.get(&k3).is_some());
2003 assert_eq!(cache.len(), 2);
2004 }
2005
2006 #[test]
2007 fn lookup_by_blueprint() {
2008 let cache = KernelCache::new();
2009 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2010 elem: ElemType::F32,
2011 lanes: 4,
2012 });
2013 let tenant = TenantId(11);
2014 let key = CacheKey::for_tenant(tenant, bp.fingerprint(), 80);
2015 cache.put(key, dummy_kernel(bp.fingerprint()));
2016 assert!(cache.get_for(tenant, &bp, 80).is_some());
2017 assert!(
2018 cache.get_for(tenant, &bp, 89).is_none(),
2019 "different sm_version is a miss"
2020 );
2021 assert!(
2022 cache.get_for(TenantId(12), &bp, 80).is_none(),
2023 "different tenant is a miss — keys are tenant-scoped"
2024 );
2025 }
2026
2027 /// jit HIGH (finding 2): a kernel cached under an `f32x4.add` blueprint
2028 /// must NOT be returned for an `i32x4.add` blueprint. Before the
2029 /// element-type fix both blueprints fingerprinted identically, so the
2030 /// integer lookup would HIT the float kernel — cross-kernel cache
2031 /// aliasing serving a miscompiled kernel. The lookup now misses, which
2032 /// drives a correct (integer) emit.
2033 #[test]
2034 fn element_type_is_not_cache_aliased() {
2035 let cache = KernelCache::new();
2036 let tenant = TenantId(7);
2037 let f32_bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2038 elem: ElemType::F32,
2039 lanes: 4,
2040 });
2041 let i32_bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2042 elem: ElemType::I32,
2043 lanes: 4,
2044 });
2045 // Distinct cache keys (the rewrite-time pre-population keys on this).
2046 assert_ne!(f32_bp.fingerprint(), i32_bp.fingerprint());
2047
2048 // Populate ONLY the f32 kernel.
2049 let key = CacheKey::for_tenant(tenant, f32_bp.fingerprint(), 80);
2050 cache.put(key, dummy_kernel(f32_bp.fingerprint()));
2051
2052 // The f32 lookup hits; the i32 lookup must MISS (no aliasing).
2053 assert!(cache.get_for(tenant, &f32_bp, 80).is_some());
2054 assert!(
2055 cache.get_for(tenant, &i32_bp, 80).is_none(),
2056 "i32x4.add must not alias the f32x4.add cache slot"
2057 );
2058 }
2059
2060 #[test]
2061 fn capacity_floor_one() {
2062 let cache = KernelCache::with_capacity(0);
2063 assert_eq!(cache.capacity(), 1);
2064 }
2065
2066 #[test]
2067 fn empty_when_new() {
2068 let cache = KernelCache::new();
2069 assert!(cache.is_empty());
2070 assert_eq!(cache.len(), 0);
2071 }
2072
2073 #[test]
2074 fn cache_hit_returns_arc_shared_ptx() {
2075 use std::sync::Arc;
2076 let cache = KernelCache::new();
2077 let bp = TensorWasmKernelBlueprint::new("matmul").push(TensorWasmOp::MatMul {
2078 m: 16,
2079 n: 16,
2080 k: 16,
2081 });
2082 let key = CacheKey::for_tenant(TenantId(3), bp.fingerprint(), 80);
2083 let original = Arc::new(EmittedPtx {
2084 text: "// pre-emitted".into(),
2085 launch_geometry: (1, 128),
2086 });
2087 cache.put(
2088 key,
2089 CachedKernel::new(
2090 bp.fingerprint(),
2091 original.clone(),
2092 CompiledHandle::default(),
2093 ),
2094 );
2095 let hit = cache.get(&key).expect("cache hit");
2096 // The hit returns the same underlying allocation — no re-emit.
2097 assert!(Arc::ptr_eq(&hit.ptx, &original));
2098 }
2099
2100 /// Concurrent get/put across many threads must not corrupt the cache
2101 /// or drop entries that haven't been evicted. Uses a capacity large
2102 /// enough to hold every key inserted so we can assert all `get`s
2103 /// targeting still-present keys succeed.
2104 #[test]
2105 fn concurrent_get_put_dashmap_safe() {
2106 const N_THREADS: usize = 8;
2107 const KEYS_PER_THREAD: u64 = 32;
2108 let cache = KernelCache::with_capacity((N_THREADS as u64 * KEYS_PER_THREAD) as usize);
2109 let mut handles = Vec::new();
2110 for t in 0..N_THREADS {
2111 let cache = cache.clone();
2112 handles.push(thread::spawn(move || {
2113 for i in 0..KEYS_PER_THREAD {
2114 let key =
2115 CacheKey::for_tenant(TenantId(0), (t as u64) * KEYS_PER_THREAD + i, 80);
2116 cache.put(key, dummy_kernel(key.blueprint));
2117 // Interleave reads of own and (possibly absent) others.
2118 let _ = cache.get(&key);
2119 }
2120 }));
2121 }
2122 for h in handles {
2123 h.join().expect("worker thread panicked");
2124 }
2125 // Every key written must still be retrievable.
2126 for t in 0..N_THREADS {
2127 for i in 0..KEYS_PER_THREAD {
2128 let key = CacheKey::for_tenant(TenantId(0), (t as u64) * KEYS_PER_THREAD + i, 80);
2129 assert!(
2130 cache.get(&key).is_some(),
2131 "missing key after concurrent inserts: ({t}, {i})"
2132 );
2133 }
2134 }
2135 }
2136
2137 /// T20 perf regression: `KernelCache::get` returns a shared
2138 /// `Arc<CachedKernel>` rather than a cloned wrapper value. Two
2139 /// successive hits on the same key must yield pointer-equal Arcs
2140 /// (same allocation, just a refcount bump per hit) — proving the
2141 /// hot path no longer clones the 32-byte integrity hash, the
2142 /// fingerprint word, and the inner `Arc<EmittedPtx>` refcount on
2143 /// every dispatch.
2144 #[test]
2145 fn cache_get_returns_arc_no_clone() {
2146 let cache = KernelCache::new();
2147 let key = CacheKey::for_tenant(TenantId(1), 0xABCD, 80);
2148 let original_ptx = Arc::new(EmittedPtx {
2149 text: ".visible .entry t20_arc(){}".into(),
2150 launch_geometry: (1, 32),
2151 });
2152 cache.put(
2153 key,
2154 CachedKernel::new(0xABCD, original_ptx.clone(), CompiledHandle::default()),
2155 );
2156 let first = cache.get(&key).expect("first hit");
2157 let second = cache.get(&key).expect("second hit");
2158 // The two `Arc<CachedKernel>` handles must point at the same
2159 // allocation. If `get` had reverted to cloning the wrapper, the
2160 // two handles would point at distinct allocations (still sharing
2161 // the inner `Arc<EmittedPtx>`, but that is a different invariant).
2162 assert!(
2163 Arc::ptr_eq(&first, &second),
2164 "cache.get must return refcount-bumped Arc handles, not cloned \
2165 wrappers — distinct allocations indicate the T20 perf fix \
2166 regressed (clone-on-hit reintroduced)"
2167 );
2168 // Bonus: the inner PTX is still shared with the original (this was
2169 // already pinned by `cache_hit_returns_arc_shared_ptx` above).
2170 assert!(Arc::ptr_eq(&first.ptx, &original_ptx));
2171 }
2172
2173 /// T20 perf: `get` increments `cache_hits_total` on every Some-returning
2174 /// call and `cache_misses_total` on every None-returning call. A miss
2175 /// followed by a hit must produce one of each, with neither counter
2176 /// double-counting.
2177 #[test]
2178 fn cache_hits_misses_counter() {
2179 let cache = KernelCache::new();
2180 assert_eq!(cache.cache_hits_total(), 0);
2181 assert_eq!(cache.cache_misses_total(), 0);
2182
2183 let key = CacheKey::for_tenant(TenantId(9), 0xC0FFEE, 80);
2184
2185 // Miss: nothing has been inserted yet.
2186 assert!(cache.get(&key).is_none(), "fresh cache must miss");
2187 assert_eq!(
2188 cache.cache_misses_total(),
2189 1,
2190 "first miss must bump the miss counter exactly once"
2191 );
2192 assert_eq!(
2193 cache.cache_hits_total(),
2194 0,
2195 "a miss must not bump the hit counter"
2196 );
2197
2198 // Hit: install then look up.
2199 cache.put(key, dummy_kernel(0xC0FFEE));
2200 assert!(cache.get(&key).is_some(), "post-put get must hit");
2201 assert_eq!(
2202 cache.cache_hits_total(),
2203 1,
2204 "first hit must bump the hit counter exactly once"
2205 );
2206 assert_eq!(
2207 cache.cache_misses_total(),
2208 1,
2209 "a hit must not bump the miss counter"
2210 );
2211
2212 // Second hit increments hits again.
2213 assert!(cache.get(&key).is_some());
2214 assert_eq!(cache.cache_hits_total(), 2);
2215 assert_eq!(cache.cache_misses_total(), 1);
2216 }
2217
2218 /// jit L (tenant-isolation defence-in-depth): the V2 disk envelope now
2219 /// binds `tenant_id`, and the L2 reader cross-checks it against the
2220 /// requesting key. A sidecar+blob planted at another tenant's
2221 /// `path_for` location (simulated here by writing under tenant A's key,
2222 /// then reading the *exact same path* with a key that differs only in
2223 /// `tenant_id`) must be rejected as a miss and bump the
2224 /// integrity-rejection counter, rather than being served cross-tenant.
2225 #[test]
2226 fn disk_envelope_binds_tenant_and_rejects_cross_tenant_blob() {
2227 let tmp = tempfile::TempDir::new().expect("tempdir");
2228 let dir = tmp.path().to_path_buf();
2229 let hmac_key = [0x5Au8; 32];
2230
2231 let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
2232 dir: dir.clone(),
2233 hmac_key,
2234 });
2235 let disk = cache.disk.as_ref().expect("disk configured");
2236
2237 // Same blueprint/sm/emit_config for two tenants; the only difference
2238 // is `tenant_id`. `path_for` already folds tenant_id into the hash,
2239 // so a real put lands tenant A and tenant B at different paths — to
2240 // simulate a *planted* blob we deliberately write A's envelope to B's
2241 // path and confirm the in-envelope tenant check still rejects it.
2242 let key_a = CacheKey::for_tenant(TenantId(100), 0xAA, 80);
2243 let key_b = CacheKey::for_tenant(TenantId(200), 0xAA, 80);
2244
2245 let kernel = CachedKernel::new(
2246 0xAA,
2247 Arc::new(EmittedPtx {
2248 text: ".visible .entry tenant_bound(){}".into(),
2249 launch_geometry: (4, 8),
2250 }),
2251 CompiledHandle::default(),
2252 );
2253
2254 // Honest round-trip for tenant A succeeds and round-trips geometry.
2255 disk.put(&key_a, &kernel).expect("put A");
2256 let hit_a = disk.get(&key_a).expect("get A ok").expect("A present");
2257 assert_eq!(hit_a.fingerprint, 0xAA);
2258 assert_eq!(hit_a.ptx.launch_geometry, (4, 8));
2259
2260 // Plant A's blob at B's sidecar path: write A's envelope through the
2261 // artifact store, then stamp a sidecar at B's `path_for` pointing at
2262 // it. This mimics an attacker who can drop files at B's path.
2263 let envelope = DiskCache::encode_v2_envelope(&key_a, &kernel);
2264 let hash = disk.store.put(&envelope).expect("store put");
2265 let mut sidecar = Vec::with_capacity(SIDECAR_LEN_V1);
2266 sidecar.extend_from_slice(SIDECAR_MAGIC_V1);
2267 sidecar.extend_from_slice(hash.as_bytes());
2268 std::fs::write(disk.path_for(&key_b), &sidecar).expect("plant sidecar");
2269
2270 let before = cache.integrity_reject_total();
2271 // Reading as tenant B must reject: the envelope's bound tenant (A)
2272 // does not match B's key, so the cross-check fires.
2273 assert!(
2274 disk.get(&key_b).expect("get B ok").is_none(),
2275 "a blob bound to tenant A must not be served to tenant B"
2276 );
2277 assert_eq!(
2278 cache.integrity_reject_total(),
2279 before + 1,
2280 "cross-tenant blob must count as an integrity rejection"
2281 );
2282 }
2283
2284 /// jit S-3 T13 regression: `DiskCacheConfig`'s `Debug` impl MUST NOT
2285 /// dump the raw `hmac_key` bytes. A derived `Debug` would have
2286 /// formatted the array as `[222, 222, 222, …]` (or `[de, de, …]`
2287 /// under hex), leaking the server-side secret into any log line,
2288 /// panic message, or `tracing` field expansion that happens to
2289 /// include a `DiskCacheConfig`.
2290 ///
2291 /// We use the sentinel byte `0xDE` repeated 32 times because the
2292 /// derived `Debug` would have produced either `222` (decimal —
2293 /// stdlib default for `u8` arrays) or `de` (hex) at every position;
2294 /// asserting that neither pattern appears anywhere in the formatted
2295 /// output catches both cases. We also positively assert the
2296 /// "redacted" substring is present so the test fails informatively
2297 /// if someone replaces the manual impl with something else that
2298 /// happens to omit the bytes but also omits the redaction marker.
2299 #[test]
2300 fn disk_cache_config_debug_redacts_hmac_key() {
2301 let cfg = DiskCacheConfig {
2302 dir: PathBuf::from("/tmp/tensor-wasm-jit-debug-test"),
2303 hmac_key: [0xDEu8; 32],
2304 };
2305 let dbg = format!("{cfg:?}");
2306 let lowered = dbg.to_ascii_lowercase();
2307 // Negative: neither decimal nor hex representation of the key
2308 // bytes should appear in the formatted output. Derived `Debug`
2309 // for `[u8; 32]` would have produced `222` thirty-two times
2310 // (decimal) or `de` thirty-two times (hex/upper-hex
2311 // alternatives); a single occurrence of either is suspicious,
2312 // but to keep the assertion robust against incidental
2313 // substrings in `dir` we count occurrences instead.
2314 let decimal_hits = lowered.matches("222").count();
2315 let hex_hits = lowered.matches("de").count();
2316 assert!(
2317 decimal_hits < 32,
2318 "DiskCacheConfig Debug output appears to contain the raw key in \
2319 decimal form ({decimal_hits} occurrences of \"222\"): {dbg}"
2320 );
2321 assert!(
2322 hex_hits < 32,
2323 "DiskCacheConfig Debug output appears to contain the raw key in \
2324 hex form ({hex_hits} occurrences of \"de\"): {dbg}"
2325 );
2326 // Positive: the redaction marker is present.
2327 assert!(
2328 lowered.contains("redacted"),
2329 "DiskCacheConfig Debug output should mark hmac_key as redacted: {dbg}"
2330 );
2331 }
2332}