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}