fuel_dispatch/judge_cache.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Ranked dispatch tables — Phase 6b's O(1) runtime lookup.
3//!
4//! Moved here from `fuel-tensor/src/judge/cache.rs` (board #109 PR-D
5//! follow-up). `docs/restructure-migration-design.md` §5.1 Row 3 (RULED
6//! 2026-09-02) called this move clean, but that was measured before board
7//! #109 landed `judge/mod.rs` itself in `fuel-tensor` (it needs `Tensor` to
8//! realize profiling ops). The ORIGINAL `cache.rs` had exactly one
9//! Tensor-dependent call (`populate_dispatch_table` ran `judge::Judge::run`
10//! directly) blocking a bare move — moving it as-is would have recreated
11//! the `fuel-dispatch -> fuel-tensor` cycle #305 avoided. The fix inverts
12//! that one write path: this module no longer RUNS the judge, it only
13//! STORES an already-computed [`ProfileReport`] via [`store`].
14//! `fuel-tensor/src/judge/cache.rs` keeps a thin `populate_dispatch_table`
15//! that runs `Judge::run` (needs Tensor) and hands the result to [`store`]
16//! — a downward call, no cycle.
17//!
18//! `Judge` (mentioned above) is `fuel_tensor::judge::Judge` — plain text,
19//! not a doc link, since this crate cannot resolve a path in a crate above
20//! it in the dependency graph. This module has no dependency on it.
21//!
22//! # Criteria
23//!
24//! - **Fastest** — pick the backend with the lowest median latency.
25//! Excludes the reference backend by default (it's the oracle, not a
26//! production path — pathologically slow by design).
27//! - **MostAccurate** — pick the backend with the lowest `max_rel_error`
28//! against the reference backend. Also excludes reference (reference vs
29//! reference is by definition 0, but dispatching to it defeats the
30//! purpose of having an optimized backend at all). Ties broken by
31//! latency.
32//! - **Balanced** — minimise `latency_ns * (1 + accuracy_penalty *
33//! rel_error)`. Penalty coefficient defaults to 100 so a 1% rel-error
34//! bump is equivalent to a 2× latency penalty — steep enough that
35//! numerically-unsound fast paths don't win by default.
36//!
37//! # Process-wide cache
38//!
39//! The dispatch table is hardware-determined: it doesn't depend on the
40//! app, only on the CPU/GPU configuration. So a single process-wide
41//! instance is the right granularity, with three states:
42//!
43//! 1. **In-memory** — populated by an explicit [`store`] call this
44//! process. Authoritative.
45//! 2. **On-disk** — persisted from a prior process; the same hardware was
46//! profiled previously. Lazy-loaded on first [`cached`] call.
47//! 3. **Absent** — no profile yet, or hardware changed since the persisted
48//! profile was taken. Routed ops fall through to the Router's default
49//! backend until [`store`] runs successfully.
50
51use fuel_ir::Result;
52pub use fuel_ir::dispatch::{
53 Criterion, DEFAULT_ACCURACY_PENALTY, DispatchOptions, DispatchTable, OpKind, Pick,
54 ProfileEntry, ProfileReport, SizeClass,
55};
56
57use crate::judge_report_path::default_report_path;
58use crate::ranker::profile_oracle::ProfileJudgeOracle;
59
60use std::sync::{Arc, OnceLock, RwLock};
61
62/// Both artifacts a Judge profile run produces, derived from the SAME
63/// [`ProfileReport`] so they can never disagree about what was measured:
64///
65/// - `table` — winners-only O(1) lookup for the Router's routed realize
66/// path.
67/// - `oracle` — every raw measurement (losing alternatives included) for
68/// the optimizer ranker's Layer-2 cost refinement
69/// ([`crate::plan::PlanOptions::with_judge`]).
70#[derive(Clone)]
71struct CachedJudge {
72 table: Arc<DispatchTable>,
73 oracle: Arc<ProfileJudgeOracle>,
74}
75
76impl CachedJudge {
77 fn from_report(report: &ProfileReport) -> Self {
78 Self {
79 table: Arc::new(DispatchTable::build(report)),
80 oracle: Arc::new(ProfileJudgeOracle::from_report(report)),
81 }
82 }
83}
84
85/// Process-wide Judge cache. The outer `OnceLock` is set on first access,
86/// with lazy-loaded contents from disk if a prior run persisted a profile
87/// for the current hardware. The inner `RwLock` exists so [`store`] and
88/// [`invalidate`] can update the cache after first access — `OnceLock`
89/// alone is write-once, which would prevent re-profiling on driver
90/// upgrades.
91static DISPATCH_TABLE: OnceLock<RwLock<Option<CachedJudge>>> = OnceLock::new();
92
93fn slot() -> &'static RwLock<Option<CachedJudge>> {
94 DISPATCH_TABLE.get_or_init(|| {
95 // First access this process: try to populate from disk
96 // synchronously. Sub-millisecond if a valid prior profile exists
97 // for the same hardware; returns None otherwise.
98 let initial = try_load_persisted();
99 RwLock::new(initial)
100 })
101}
102
103/// The currently-active dispatch table, if any.
104///
105/// Returns `None` when no profile has been computed for this hardware
106/// (first run, fresh install, or after [`invalidate`]). Routed ops should
107/// fall through to a default backend in that case.
108///
109/// On the first call this process, lazily attempts to load a prior run's
110/// profile from disk — sub-millisecond on cache hit. Subsequent calls
111/// return without touching the filesystem.
112pub fn cached() -> Option<Arc<DispatchTable>> {
113 // A poisoned lock means a panic mid-update elsewhere; treat it as "no
114 // profile" rather than propagating the panic — callers all have a
115 // default-backend fallback.
116 slot()
117 .read()
118 .ok()
119 .and_then(|g| g.as_ref().map(|c| Arc::clone(&c.table)))
120}
121
122/// The currently-active [`ProfileJudgeOracle`], if any — same lifecycle
123/// and data source as [`cached`], but exposing EVERY raw measurement
124/// (losing alternatives included) instead of winners only. The pipelined
125/// bridge attaches it to [`crate::plan::PlanOptions::with_judge`] so
126/// `compile_plan` refines Layer-1 static costs with measured latencies.
127///
128/// Returns `None` when no profile has been computed for this hardware —
129/// plan compilation then ranks on Layer-1 static costs alone, exactly the
130/// pre-oracle behavior.
131pub fn cached_oracle() -> Option<Arc<ProfileJudgeOracle>> {
132 slot()
133 .read()
134 .ok()
135 .and_then(|g| g.as_ref().map(|c| Arc::clone(&c.oracle)))
136}
137
138/// Populate the dispatch table from an already-computed [`ProfileReport`]
139/// and persist it to disk. The caller runs the actual Judge measurement
140/// (that needs `Tensor`, hence lives in `fuel-tensor`) and hands the
141/// finished report here — this function only stores and serves it.
142///
143/// Idempotent is the CALLER's responsibility (fuel-tensor's
144/// `populate_dispatch_table` checks [`cached().is_some()`](cached) first
145/// so a redundant Judge run is skipped); `store` itself always writes.
146pub fn store(report: &ProfileReport) -> Result<()> {
147 if let Some(p) = default_report_path() {
148 if let Some(parent) = p.parent() {
149 let _ = std::fs::create_dir_all(parent);
150 }
151 report.save(&p)?;
152 }
153 let built = CachedJudge::from_report(report);
154 *slot()
155 .write()
156 .map_err(|_| fuel_ir::Error::Msg("judge cache lock poisoned".into()))? = Some(built);
157 Ok(())
158}
159
160/// Drop the in-memory cache and delete the persisted profile on disk. The
161/// next populate call (fuel-tensor's `populate_dispatch_table`, which runs
162/// the Judge and calls [`store`]) will re-run the probe + judge from
163/// scratch.
164///
165/// Use this when an external change has invalidated the existing profile
166/// — driver upgrade, BLAS library swap, OS update — and you want the next
167/// measurement to reflect the new state. Without this call, [`cached`]
168/// would keep returning the stale in-memory table and a redundant
169/// populate call would no-op because of its idempotence guard.
170pub fn invalidate() -> Result<()> {
171 *slot()
172 .write()
173 .map_err(|_| fuel_ir::Error::Msg("judge cache lock poisoned".into()))? = None;
174 if let Some(p) = fuel_hardware::probe::default_report_path() {
175 let _ = std::fs::remove_file(&p);
176 }
177 if let Some(p) = default_report_path() {
178 let _ = std::fs::remove_file(&p);
179 }
180 Ok(())
181}
182
183/// Try to load a previously-persisted profile from disk. Returns `None`
184/// if anything is missing, the schema versions mismatch, or the current
185/// hardware doesn't match what was probed when the profile was last
186/// saved.
187fn try_load_persisted() -> Option<CachedJudge> {
188 let probe_path = fuel_hardware::probe::default_report_path()?;
189 let prior_probe = fuel_hardware::probe::ProbeReport::load(&probe_path)
190 .ok()
191 .flatten()?;
192 let now_probe = fuel_hardware::probe::ProbeReport::probe_all();
193 if now_probe.diff(&prior_probe).needs_rejudge() {
194 return None;
195 }
196 let judge_path = default_report_path()?;
197 // Judge item 7: the load gate refuses a report measured on hardware this
198 // machine does not have (one run, one machine). Pass this box's device
199 // equivalence classes as the identity to validate the persisted report
200 // against — a profile written on another GPU is a cache miss, not data.
201 let current: Vec<_> = now_probe.equivalence_classes().into_keys().collect();
202 let report = ProfileReport::load(&judge_path, ¤t).ok().flatten()?;
203 Some(CachedJudge::from_report(&report))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use fuel_ir::DType;
210 use fuel_ir::probe::BackendId;
211
212 /// Trivial test-only fixture key — duplicated from
213 /// `fuel-tensor/src/judge/mod.rs::test_equiv_key` (that one is
214 /// `pub(crate)` there and this module has no Tensor dependency to
215 /// justify pulling in the crate that defines it; 7 lines, not worth
216 /// a cross-crate seam).
217 fn test_equiv_key(backend: BackendId) -> fuel_ir::probe::EquivalenceKey {
218 fuel_ir::probe::EquivalenceKey {
219 backend,
220 vendor_id: 0,
221 device_id: 0,
222 compute_capability: None,
223 driver_version: String::new(),
224 }
225 }
226
227 fn entry(backend: BackendId, op: OpKind, size: u32, latency: u64, err: f32) -> ProfileEntry {
228 ProfileEntry {
229 op,
230 dtype: DType::F32,
231 size_class: SizeClass(size),
232 backend,
233 device_index: 0,
234 latency_ns: latency,
235 iterations: 7,
236 max_rel_error: err,
237 kernel_source: String::new(),
238 kernel_revision_hash: 0,
239 device: test_equiv_key(backend),
240 }
241 }
242
243 /// Variant that lets a test pin a specific `kernel_source` on the
244 /// fixture entry — exercises per-alternative dispatch (e.g. AOCL vs
245 /// MKL siblings at one cell).
246 fn entry_with_source(
247 backend: BackendId,
248 op: OpKind,
249 size: u32,
250 latency: u64,
251 err: f32,
252 kernel_source: &str,
253 ) -> ProfileEntry {
254 ProfileEntry {
255 op,
256 dtype: DType::F32,
257 size_class: SizeClass(size),
258 backend,
259 device_index: 0,
260 latency_ns: latency,
261 iterations: 7,
262 max_rel_error: err,
263 kernel_source: kernel_source.to_string(),
264 kernel_revision_hash: 0,
265 device: test_equiv_key(backend),
266 }
267 }
268
269 fn sample_report() -> ProfileReport {
270 ProfileReport {
271 version: fuel_ir::dispatch::PROFILE_REPORT_VERSION,
272 entries: vec![
273 // Size class 12: CUDA wins fastest (2ms < 10ms) but errs more
274 entry(BackendId::Cpu, OpKind::MatMul, 12, 10_000_000, 1e-6),
275 entry(BackendId::Cuda, OpKind::MatMul, 12, 2_000_000, 1e-4),
276 // Size class 16: CPU is fastest + most accurate
277 entry(BackendId::Cpu, OpKind::MatMul, 16, 500_000, 1e-6),
278 entry(BackendId::Cuda, OpKind::MatMul, 16, 1_000_000, 1e-3),
279 ],
280 }
281 }
282
283 #[test]
284 fn fastest_picks_lowest_latency() {
285 let tbl = DispatchTable::build(&sample_report());
286 let p = tbl
287 .pick(
288 OpKind::MatMul,
289 DType::F32,
290 SizeClass(12),
291 Criterion::Fastest,
292 )
293 .unwrap();
294 assert_eq!(
295 p,
296 Pick {
297 backend: BackendId::Cuda,
298 device_index: 0,
299 kernel_source: "",
300 kernel_revision_hash: 0,
301 }
302 );
303 }
304
305 #[test]
306 fn most_accurate_picks_lowest_rel_err() {
307 let tbl = DispatchTable::build(&sample_report());
308 let p = tbl
309 .pick(
310 OpKind::MatMul,
311 DType::F32,
312 SizeClass(12),
313 Criterion::MostAccurate,
314 )
315 .unwrap();
316 // CPU has 1e-6 rel_err; CUDA has 1e-4. CPU wins MostAccurate.
317 assert_eq!(
318 p,
319 Pick {
320 backend: BackendId::Cpu,
321 device_index: 0,
322 kernel_source: "",
323 kernel_revision_hash: 0,
324 }
325 );
326 }
327
328 #[test]
329 fn pick_nearest_falls_back_to_largest_class() {
330 let tbl = DispatchTable::build(&sample_report());
331 // Size class 14: not profiled. Nearest are 12 (diff 2) and 16
332 // (diff 2). Tie-break prefers larger → 16 → CPU wins fastest.
333 let p = tbl
334 .pick_nearest(
335 OpKind::MatMul,
336 DType::F32,
337 SizeClass(14),
338 Criterion::Fastest,
339 )
340 .unwrap();
341 assert_eq!(
342 p,
343 Pick {
344 backend: BackendId::Cpu,
345 device_index: 0,
346 kernel_source: "",
347 kernel_revision_hash: 0,
348 }
349 );
350 }
351
352 #[test]
353 fn keys_returns_distinct_combinations() {
354 let tbl = DispatchTable::build(&sample_report());
355 let keys = tbl.keys();
356 // Two distinct (op, dtype, size_class) triples in the sample
357 assert_eq!(keys.len(), 2);
358 }
359
360 /// Per-alternative measurement v2: two CPU kernel siblings at one
361 /// `(op, dtype, size_class)` cell produce two separate `ProfileEntry`s.
362 /// The picker selects the faster as winner; the `Pick::kernel_source`
363 /// surfaces which sibling won, even though both share
364 /// `(BackendId::Cpu, device_index=0)`.
365 #[test]
366 fn two_kernel_sources_at_one_cpu_cell_produce_two_entries_and_picker_names_winner() {
367 let report = ProfileReport {
368 version: fuel_ir::dispatch::PROFILE_REPORT_VERSION,
369 entries: vec![
370 // Two CPU MatMul kernels at the same cell: AOCL is faster
371 // (200 µs) vs portable-cpu (1 ms). They differ ONLY in
372 // kernel_source — same backend, same device.
373 entry_with_source(
374 BackendId::Cpu,
375 OpKind::MatMul,
376 12,
377 1_000_000,
378 1e-6,
379 "portable-cpu",
380 ),
381 entry_with_source(BackendId::Cpu, OpKind::MatMul, 12, 200_000, 1e-6, "aocl"),
382 ],
383 };
384
385 // ProfileEntry shape: both entries are present and the
386 // kernel_source field distinguishes them.
387 assert_eq!(report.entries.len(), 2);
388 let portable = &report.entries[0];
389 let aocl = &report.entries[1];
390 assert_eq!(portable.kernel_source, "portable-cpu");
391 assert_eq!(aocl.kernel_source, "aocl");
392 assert_eq!(portable.backend, aocl.backend); // same backend
393 assert_eq!(portable.device_index, aocl.device_index); // same device
394
395 // DispatchTable picks the winner ACROSS kernel_sources at one
396 // cell. Pick.kernel_source identifies it.
397 let tbl = DispatchTable::build(&report);
398 let p = tbl
399 .pick(
400 OpKind::MatMul,
401 DType::F32,
402 SizeClass(12),
403 Criterion::Fastest,
404 )
405 .unwrap();
406 assert_eq!(p.backend, BackendId::Cpu);
407 assert_eq!(p.device_index, 0);
408 // AOCL is faster → wins Fastest.
409 assert_eq!(p.kernel_source, "aocl");
410 }
411
412 /// Fastest-pick backend at the sample report's shared (MatMul, F32,
413 /// size 12) cell — the one both [`sample_report`] and
414 /// [`swapped_backend_report`] share, just with the winner flipped.
415 fn fastest_matmul_12_backend(table: &DispatchTable) -> BackendId {
416 table
417 .pick(
418 OpKind::MatMul,
419 DType::F32,
420 SizeClass(12),
421 Criterion::Fastest,
422 )
423 .unwrap()
424 .backend
425 }
426
427 /// Same (op, dtype, size_class) cell as [`sample_report`] but with the
428 /// winner flipped (CPU fast, CUDA slow) — `entry`'s `device` field
429 /// stays co-encoded with `backend`, as `from_report`'s debug_assert
430 /// requires.
431 fn swapped_backend_report() -> ProfileReport {
432 ProfileReport {
433 version: fuel_ir::dispatch::PROFILE_REPORT_VERSION,
434 entries: vec![
435 entry(BackendId::Cuda, OpKind::MatMul, 12, 10_000_000, 1e-6),
436 entry(BackendId::Cpu, OpKind::MatMul, 12, 2_000_000, 1e-4),
437 ],
438 }
439 }
440
441 /// PR-D condition (2): cached()/cached_oracle() behave identically
442 /// across the move -- same hit/miss/invalidate sequence this test
443 /// would have exercised against the pre-move `fuel-tensor` copy.
444 /// Serialized (the process-wide static is shared across tests in this
445 /// module) via a single test that drives the full sequence itself.
446 #[test]
447 fn cached_hit_miss_invalidate_sequence_and_store_is_the_only_writer() {
448 // Start from a clean slot regardless of other tests' residue or a
449 // real persisted profile on this machine.
450 invalidate().unwrap();
451 assert!(cached().is_none(), "miss: no report stored yet");
452 assert!(cached_oracle().is_none(), "miss: no report stored yet");
453
454 store(&sample_report()).unwrap();
455 assert!(cached().is_some(), "hit: store() populated the slot");
456 assert!(
457 cached_oracle().is_some(),
458 "hit: store() populated the oracle half too"
459 );
460 assert_eq!(
461 fastest_matmul_12_backend(&cached().unwrap()),
462 BackendId::Cuda,
463 "same table as sample_report direct-build"
464 );
465
466 invalidate().unwrap();
467 assert!(cached().is_none(), "miss again: invalidate cleared it");
468 assert!(
469 cached_oracle().is_none(),
470 "miss again: invalidate cleared it"
471 );
472
473 // store() is the only writer of the slot (besides invalidate's
474 // clear and the disk-backed lazy init in slot()): a second store
475 // with a different report must be the one and only thing that
476 // changes what cached() returns next, proving no other write path
477 // exists.
478 store(&swapped_backend_report()).unwrap();
479 assert_eq!(
480 fastest_matmul_12_backend(&cached().unwrap()),
481 BackendId::Cpu,
482 "cached() reflects the SECOND store(), confirming store is the sole writer"
483 );
484 invalidate().unwrap();
485 }
486}