Skip to main content

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, &current).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}