Skip to main content

fuel_dispatch/ranker/
judge_aware_selector.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `JudgeAwareSelector` — Phase 5.2 runtime selector that re-queries
3//! the [`JudgeOracle`] at dispatch time and re-ranks candidates by
4//! fresh empirical measurements.
5//!
6//! # Why this selector exists
7//!
8//! Picker 1 (the optimizer ranker) consumes Judge data once during
9//! `compute_static_costs`, at plan-build time. If new measurements
10//! land between plan build and dispatch (the Judge profiles
11//! incrementally; a long-running process accumulates entries during
12//! its first realizes), the plan's ranking can be stale. The static
13//! cost was Layer-1 + whatever Judge entries existed at plan time;
14//! a Layer-2 entry that materializes after the plan freezes is not
15//! reflected.
16//!
17//! `JudgeAwareSelector` fixes this by re-querying the Judge at
18//! select time for each candidate's `(op, dtype, size_class, backend)`
19//! cell. Candidates with a fresh measurement get re-ranked by that
20//! measurement; candidates without a measurement keep their static
21//! rank position. The selector returns the new top-1.
22//!
23//! # Selector context
24//!
25//! The Judge lookup key is
26//! `(OpKind, DType, SizeClass, BackendId)`. Only `BackendId` is on
27//! the [`Candidate`]; the other three components are per-decision-
28//! point context that the trait's `select(&AlternativeSet)` signature
29//! deliberately does NOT carry. Phase 5.2 resolves this the same way
30//! Phase 5.1 documented as the path forward: the selector is
31//! constructed PER DECISION POINT with the op/dtype/size context
32//! baked in. The caller (Phase 4's executor migration, when it lands)
33//! produces one of these per kernel-bearing node from the plan's
34//! enumeration metadata.
35//!
36//! This shape is deliberately not the future "shared selector for
37//! whole realize" — that would require widening the trait. It's the
38//! pragmatic Phase 5.2 shape that lets the selector exist before
39//! the wider trait change has consensus.
40//!
41//! # What "kernel_source" does here
42//!
43//! Phase v0.4 of the backend contract carries `kernel_source` on
44//! both [`Candidate`] and [`fuel_ir::dispatch::ProfileEntry`].
45//! The [`JudgeOracle`] trait's `measured_latency_ns` doesn't take
46//! `kernel_source` today (the trait predates per-alternative
47//! measurement). When a future Judge revision adds it, this selector
48//! is the first consumer that will benefit — multiple candidates
49//! with the same `(op, dtype, size, backend)` but different
50//! `kernel_source` will get distinguished. For now the selector
51//! re-queries by `(op, dtype, size_class, backend)` and accepts
52//! that AOCL vs MKL candidates on `BackendId::Cpu` share a Judge
53//! cell.
54
55use std::sync::Arc;
56
57use fuel_ir::DType;
58use fuel_ir::dispatch::{OpKind, SizeClass};
59
60use super::{
61    AlternativeSet, Candidate, JudgeOracle, RuntimeSelector, composite_ns, default_backend_rates,
62};
63
64/// Phase 5.2 runtime selector that re-queries the Judge at dispatch
65/// time and re-ranks candidates by the freshest measured latency.
66///
67/// See module docs for the full rationale.
68///
69/// # Construction
70///
71/// ```ignore
72/// use std::sync::Arc;
73/// use fuel_ir::dispatch::{OpKind, SizeClass};
74/// use fuel_ir::DType;
75/// use fuel_dispatch::ranker::{HashMapJudge, JudgeAwareSelector};
76///
77/// let judge = Arc::new(HashMapJudge::new());
78/// let selector = JudgeAwareSelector::new(
79///     judge,
80///     OpKind::MatMul,
81///     DType::F32,
82///     SizeClass::from_elem_count(1024 * 1024),
83/// );
84/// ```
85#[derive(Clone)]
86pub struct JudgeAwareSelector {
87    judge: Arc<dyn JudgeOracle>,
88    op: OpKind,
89    dtype: DType,
90    size_class: SizeClass,
91}
92
93impl JudgeAwareSelector {
94    /// Construct a selector bound to one `(op, dtype, size_class)`
95    /// decision point. The selector re-queries `judge` at each
96    /// `select` call for each candidate's backend.
97    pub fn new(
98        judge: Arc<dyn JudgeOracle>,
99        op: OpKind,
100        dtype: DType,
101        size_class: SizeClass,
102    ) -> Self {
103        Self {
104            judge,
105            op,
106            dtype,
107            size_class,
108        }
109    }
110
111    /// The op this selector queries the Judge under.
112    pub fn op(&self) -> OpKind {
113        self.op
114    }
115
116    /// The dtype this selector queries the Judge under.
117    pub fn dtype(&self) -> DType {
118        self.dtype
119    }
120
121    /// The size class this selector queries the Judge under.
122    pub fn size_class(&self) -> SizeClass {
123        self.size_class
124    }
125
126    /// Look up the candidate's measured latency, if any.
127    ///
128    /// Threads the candidate's `kernel_source` AND `kernel_revision_hash`
129    /// through the Judge so sibling kernels at the same `(backend,
130    /// device)` slot get their own measurement, instead of collapsing
131    /// onto whichever registered last. `kernel_source` separates
132    /// providers (AOCL vs MKL vs portable-cpu under `BackendId::Cpu`);
133    /// `kernel_revision_hash` separates variant siblings of one provider
134    /// (forty tilings of one generator), which the tag alone cannot.
135    fn measured_latency(&self, c: &Candidate) -> Option<u64> {
136        self.judge.measured_latency_ns(
137            self.op,
138            self.dtype,
139            self.size_class,
140            c.backend,
141            c.kernel_source,
142            c.kernel_revision_hash,
143        )
144    }
145}
146
147impl std::fmt::Debug for JudgeAwareSelector {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("JudgeAwareSelector")
150            .field("judge", &"<dyn JudgeOracle>")
151            .field("op", &self.op)
152            .field("dtype", &self.dtype)
153            .field("size_class", &self.size_class)
154            .finish()
155    }
156}
157
158impl RuntimeSelector for JudgeAwareSelector {
159    fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
160        let alts = set.alternatives();
161        if alts.is_empty() {
162            return None;
163        }
164
165        // Rank every candidate by a single nanosecond figure:
166        //
167        // - Measured candidates use the Judge's measured latency.
168        // - Unmeasured candidates use `composite_ns(static_cost)`,
169        //   the same Layer-1 scalar Picker 1 already ranks by.
170        //
171        // This produces a uniform ordering across both groups: a
172        // 100ms measured kernel correctly loses to a 50ns unmeasured
173        // one when the Layer-1 estimate is trustworthy. The prior
174        // shape ("any measured candidate trumps all unmeasured
175        // candidates") was wrong — see the 2026-06-08 adversarial
176        // verification of the Phase 5.2 selector. Treating the two
177        // groups on equal footing is the principled fix.
178        //
179        // Tie-break by original index so ties resolve toward
180        // Picker 1's static rank — preserving determinism and the
181        // `set.winner()` invariant when every candidate scores the
182        // same.
183        let (best_idx, _) = alts
184            .iter()
185            .enumerate()
186            .map(|(i, c)| {
187                // Stage 2: the inbound-transfer term adds serially
188                // to both groups — the bytes must land before either
189                // a measured or an estimated kernel can run. Plan-
190                // produced sets are device-pruned so the term is
191                // uniform in practice; adding it keeps the scale
192                // consistent with the plan rank.
193                let score = self
194                    .measured_latency(c)
195                    .unwrap_or_else(|| {
196                        let (cr, bw) = default_backend_rates(c.backend);
197                        composite_ns(&c.static_cost, cr, bw)
198                    })
199                    .saturating_add(c.inbound_transfer_ns);
200                (i, score)
201            })
202            .min_by_key(|&(idx, score)| (score, idx))
203            .expect("non-empty after the early return");
204        alts.get(best_idx)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::fused::{CostEstimate, PrecisionGuarantee};
212    use crate::kernel::{KernelCaps, OpParams};
213    use crate::ranker::HashMapJudge;
214    use fuel_ir::probe::BackendId;
215    use fuel_ir::{DeviceLocation, Layout, Result};
216    use fuel_memory::Storage;
217    use std::sync::{Arc, RwLock};
218
219    fn noop_kernel(
220        _i: &[Arc<RwLock<Storage>>],
221        _o: &mut [Arc<RwLock<Storage>>],
222        _l: &[Layout],
223        _p: &OpParams,
224    ) -> Result<()> {
225        Ok(())
226    }
227
228    fn make_candidate(backend: BackendId, cost: u64) -> Candidate {
229        Candidate {
230            kernel: noop_kernel,
231            caps: KernelCaps::empty(),
232            backend,
233            device: DeviceLocation::Cpu,
234            precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
235            static_cost: CostEstimate {
236                flops: cost,
237                bytes_moved: cost,
238                kernel_overhead_ns: 0,
239            },
240            inbound_transfer_ns: 0,
241            op_params: OpParams::None,
242            coupling: Vec::new(),
243            kernel_source: "",
244            kernel_revision_hash: 0,
245        }
246    }
247
248    /// Helper: build a selector against a freshly-populated judge.
249    fn make_selector(judge: HashMapJudge) -> JudgeAwareSelector {
250        JudgeAwareSelector::new(Arc::new(judge), OpKind::MatMul, DType::F32, SizeClass(16))
251    }
252
253    /// No measurements → behaves like WinnerSelector.
254    #[test]
255    fn empty_judge_falls_back_to_winner() {
256        let mut set = AlternativeSet::empty();
257        set.push(make_candidate(BackendId::Cuda, 100));
258        set.push(make_candidate(BackendId::Cpu, 200));
259
260        let sel = make_selector(HashMapJudge::new());
261        let pick = sel.select(&set).expect("non-empty");
262        assert_eq!(
263            pick.backend,
264            BackendId::Cuda,
265            "no measurements → static winner",
266        );
267    }
268
269    /// Judge measurement on the loser → re-rank flips order.
270    #[test]
271    fn judge_measurement_flips_picker_order() {
272        let mut set = AlternativeSet::empty();
273        // Cost rank: CUDA (100) wins. CPU (200) loses.
274        set.push(make_candidate(BackendId::Cuda, 100));
275        set.push(make_candidate(BackendId::Cpu, 200));
276
277        // Judge says CUDA is actually 10ms while CPU is 1ms.
278        let mut judge = HashMapJudge::new();
279        judge.insert(
280            OpKind::MatMul,
281            DType::F32,
282            SizeClass(16),
283            BackendId::Cuda,
284            "",
285            10_000_000,
286            0,
287        );
288        judge.insert(
289            OpKind::MatMul,
290            DType::F32,
291            SizeClass(16),
292            BackendId::Cpu,
293            "",
294            1_000_000,
295            0,
296        );
297
298        let sel = make_selector(judge);
299        let pick = sel.select(&set).expect("non-empty");
300        assert_eq!(
301            pick.backend,
302            BackendId::Cpu,
303            "Judge says CPU is 10x faster → CPU wins despite static cost",
304        );
305    }
306
307    /// Only one candidate measured → measured and unmeasured rank
308    /// uniformly on the same nanosecond axis. A fast (low static
309    /// cost) unmeasured candidate beats a slow measured one; a
310    /// fast measurement beats a high static cost. The selector no
311    /// longer auto-promotes every measured candidate above every
312    /// unmeasured one.
313    #[test]
314    fn single_measurement_competes_uniformly_against_unmeasured() {
315        // Case A: the measurement is slow → unmeasured static
316        // winner should still win.
317        let mut set = AlternativeSet::empty();
318        set.push(make_candidate(BackendId::Cuda, 100));
319        set.push(make_candidate(BackendId::Cpu, 200));
320        set.push(make_candidate(BackendId::Vulkan, 300));
321
322        // CPU measured at 500_000 ns — slow compared to the
323        // unmeasured CUDA (composite_ns = 100 ns).
324        let mut judge = HashMapJudge::new();
325        judge.insert(
326            OpKind::MatMul,
327            DType::F32,
328            SizeClass(16),
329            BackendId::Cpu,
330            "",
331            500_000,
332            0,
333        );
334        let sel = make_selector(judge);
335        let pick = sel.select(&set).expect("non-empty");
336        assert_eq!(
337            pick.backend,
338            BackendId::Cuda,
339            "slow measurement does NOT trump a faster unmeasured static cost",
340        );
341
342        // Case B: the measurement is fast → the measured candidate
343        // wins.
344        let mut set = AlternativeSet::empty();
345        set.push(make_candidate(BackendId::Cuda, 1_000_000));
346        set.push(make_candidate(BackendId::Cpu, 2_000_000));
347        let mut judge = HashMapJudge::new();
348        judge.insert(
349            OpKind::MatMul,
350            DType::F32,
351            SizeClass(16),
352            BackendId::Cpu,
353            "",
354            500,
355            0,
356        );
357        let sel = make_selector(judge);
358        let pick = sel.select(&set).expect("non-empty");
359        assert_eq!(
360            pick.backend,
361            BackendId::Cpu,
362            "fast measurement beats slower unmeasured static cost",
363        );
364    }
365
366    /// All candidates measured → lowest latency wins.
367    #[test]
368    fn all_measured_picks_lowest_latency() {
369        let mut set = AlternativeSet::empty();
370        set.push(make_candidate(BackendId::Cuda, 100));
371        set.push(make_candidate(BackendId::Cpu, 200));
372        set.push(make_candidate(BackendId::Vulkan, 300));
373
374        let mut judge = HashMapJudge::new();
375        judge.insert(
376            OpKind::MatMul,
377            DType::F32,
378            SizeClass(16),
379            BackendId::Cuda,
380            "",
381            10_000,
382            0,
383        );
384        judge.insert(
385            OpKind::MatMul,
386            DType::F32,
387            SizeClass(16),
388            BackendId::Cpu,
389            "",
390            5_000,
391            0,
392        );
393        judge.insert(
394            OpKind::MatMul,
395            DType::F32,
396            SizeClass(16),
397            BackendId::Vulkan,
398            "",
399            1_000,
400            0,
401        );
402
403        let sel = make_selector(judge);
404        let pick = sel.select(&set).expect("non-empty");
405        assert_eq!(
406            pick.backend,
407            BackendId::Vulkan,
408            "lowest latency wins regardless of static cost order",
409        );
410    }
411
412    /// Different op key in the Judge → no match → falls back to
413    /// winner. Proves the selector queries by its constructed
414    /// (op, dtype, size_class), not by candidate.
415    #[test]
416    fn judge_key_must_match_selector_context() {
417        let mut set = AlternativeSet::empty();
418        set.push(make_candidate(BackendId::Cuda, 100));
419        set.push(make_candidate(BackendId::Cpu, 200));
420
421        // Judge has entries — but under a DIFFERENT op.
422        let mut judge = HashMapJudge::new();
423        judge.insert(
424            OpKind::AddElementwise, // selector is configured for MatMul
425            DType::F32,
426            SizeClass(16),
427            BackendId::Cpu,
428            "",
429            1_000,
430            0,
431        );
432
433        // Selector configured for MatMul.
434        let sel = make_selector(judge);
435        let pick = sel.select(&set).expect("non-empty");
436        assert_eq!(
437            pick.backend,
438            BackendId::Cuda,
439            "Judge has no entry at our (op,dtype,size) → static winner",
440        );
441    }
442
443    /// Tie in latency → earlier-original-index wins (stable).
444    #[test]
445    fn latency_tie_breaks_toward_static_winner() {
446        let mut set = AlternativeSet::empty();
447        // CUDA at original idx 0; CPU at idx 1; both measured at
448        // the same latency.
449        set.push(make_candidate(BackendId::Cuda, 100));
450        set.push(make_candidate(BackendId::Cpu, 200));
451
452        let mut judge = HashMapJudge::new();
453        judge.insert(
454            OpKind::MatMul,
455            DType::F32,
456            SizeClass(16),
457            BackendId::Cuda,
458            "",
459            5_000,
460            0,
461        );
462        judge.insert(
463            OpKind::MatMul,
464            DType::F32,
465            SizeClass(16),
466            BackendId::Cpu,
467            "",
468            5_000,
469            0,
470        );
471
472        let sel = make_selector(judge);
473        let pick = sel.select(&set).expect("non-empty");
474        assert_eq!(
475            pick.backend,
476            BackendId::Cuda,
477            "tie on latency → earlier original index wins (static-rank tiebreak)",
478        );
479    }
480
481    /// Empty set → None.
482    #[test]
483    fn empty_set_returns_none() {
484        let set = AlternativeSet::empty();
485        let sel = make_selector(HashMapJudge::new());
486        assert!(sel.select(&set).is_none());
487    }
488
489    /// Debug impl doesn't panic on the Arc<dyn> field.
490    #[test]
491    fn debug_does_not_panic() {
492        let sel = make_selector(HashMapJudge::new());
493        let s = format!("{sel:?}");
494        assert!(s.contains("JudgeAwareSelector"));
495        assert!(s.contains("MatMul"));
496    }
497
498    /// Accessor methods round-trip the construction args.
499    #[test]
500    fn accessors_round_trip_construction() {
501        let sel = JudgeAwareSelector::new(
502            Arc::new(HashMapJudge::new()),
503            OpKind::AddElementwise,
504            DType::BF16,
505            SizeClass(7),
506        );
507        assert_eq!(sel.op(), OpKind::AddElementwise);
508        assert_eq!(sel.dtype(), DType::BF16);
509        assert_eq!(sel.size_class(), SizeClass(7));
510    }
511
512    /// Two `BackendId::Cpu` candidates with distinct `kernel_source`
513    /// tags receive DISTINCT measurements from the Judge. The trait
514    /// fix that threads `kernel_source` into `measured_latency_ns`
515    /// only matters if the selector propagates it correctly — this
516    /// test pins that behavior.
517    ///
518    /// Without the fix, the second `judge.insert(..., "aocl", _)` and
519    /// `judge.insert(..., "mkl", _)` would collide on the same key and
520    /// last-write-wins would collapse the ranking. With the fix, each
521    /// sibling kernel is judged on its own merits and the selector
522    /// picks the cheaper of the two.
523    #[test]
524    fn distinct_kernel_sources_get_distinct_measurements() {
525        let mut set = AlternativeSet::empty();
526        // Two Cpu candidates: aocl and mkl. Identical backend, identical
527        // device, identical static cost — only `kernel_source` differs.
528        let mut c_aocl = make_candidate(BackendId::Cpu, 500);
529        c_aocl.kernel_source = "aocl";
530        let mut c_mkl = make_candidate(BackendId::Cpu, 500);
531        c_mkl.kernel_source = "mkl";
532        set.push(c_aocl);
533        set.push(c_mkl);
534
535        // Judge: aocl is slow (10ms), mkl is fast (1ms).
536        let mut judge = HashMapJudge::new();
537        judge.insert(
538            OpKind::MatMul,
539            DType::F32,
540            SizeClass(16),
541            BackendId::Cpu,
542            "aocl",
543            10_000_000,
544            0,
545        );
546        judge.insert(
547            OpKind::MatMul,
548            DType::F32,
549            SizeClass(16),
550            BackendId::Cpu,
551            "mkl",
552            1_000_000,
553            0,
554        );
555
556        let sel = make_selector(judge);
557        let pick = sel.select(&set).expect("non-empty");
558        assert_eq!(pick.backend, BackendId::Cpu);
559        assert_eq!(
560            pick.kernel_source, "mkl",
561            "selector threads kernel_source into the Judge so siblings rank independently",
562        );
563
564        // Flip the measurements and confirm the OTHER sibling wins.
565        let mut set = AlternativeSet::empty();
566        let mut c_aocl = make_candidate(BackendId::Cpu, 500);
567        c_aocl.kernel_source = "aocl";
568        let mut c_mkl = make_candidate(BackendId::Cpu, 500);
569        c_mkl.kernel_source = "mkl";
570        set.push(c_aocl);
571        set.push(c_mkl);
572
573        let mut judge = HashMapJudge::new();
574        judge.insert(
575            OpKind::MatMul,
576            DType::F32,
577            SizeClass(16),
578            BackendId::Cpu,
579            "aocl",
580            500_000,
581            0,
582        );
583        judge.insert(
584            OpKind::MatMul,
585            DType::F32,
586            SizeClass(16),
587            BackendId::Cpu,
588            "mkl",
589            5_000_000,
590            0,
591        );
592        let sel = make_selector(judge);
593        let pick = sel.select(&set).expect("non-empty");
594        assert_eq!(
595            pick.kernel_source, "aocl",
596            "flipped measurements → other sibling wins, proving siblings are not collapsed",
597        );
598    }
599}