fuel_dispatch/ranker/runtime_selector.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `RuntimeSelector` — Picker 2 substrate.
3//!
4//! Phase 5.1 of the picker-work arc. The optimizer ranker (Picker
5//! 1, Phases 1.1–1.5 + 3) commits to a per-decision-point top-N
6//! [`AlternativeSet`]; the runtime selector (this module) picks
7//! among them at dispatch time.
8//!
9//! # Why two pickers
10//!
11//! Per architecture v1.0 §04 and the 2026-05-30 picker-alternatives
12//! audit, the architectural endpoint is two distinct pick surfaces:
13//!
14//! - **Picker 1 — the optimizer ranker.** Plan-time. Enumerates
15//! candidates via SystemTopology, hard-filters by correctness
16//! (precision floor, capability), ranks by composite cost (Layer-1
17//! static + Layer-2 Judge data), preserves top-N. The output is
18//! the [`crate::plan::ExecutionPlan`] consumed by the executor.
19//!
20//! - **Picker 2 — the runtime selector (this trait).** Dispatch-time.
21//! Receives the plan's top-N for a node, picks one based on
22//! layer-3 signals (device load, recent failures, fresh
23//! telemetry) that didn't exist at plan time.
24//!
25//! Picker 1 commits to a *ranking*; Picker 2 commits to a *pick*.
26//! The seam between them lets Picker 1 cache plans across realizes
27//! while Picker 2's choices reflect current state at the moment of
28//! dispatch.
29//!
30//! # What ships in 5.1
31//!
32//! Just the trait surface + [`WinnerSelector`] (the static default —
33//! returns `set.winner()` = the top-N's first entry = the static-
34//! cost winner). Behaviorally identical to pre-5.1 dispatch.
35//!
36//! # What 5.2+ will add
37//!
38//! Concrete selectors that exploit runtime signals. Likely
39//! candidates (sketched, not committed):
40//!
41//! - `JudgeAwareSelector` — re-query the Judge at dispatch time
42//! for any candidate whose Layer-2 measurement landed after the
43//! plan's snapshot.
44//! - `SizeAwareDownscaleSelector` — for tiny ops, prefer CPU over
45//! GPU to avoid kernel-launch overhead even when GPU is the
46//! static-cost winner.
47//! - `FailureMemorySelector` — per-(op, backend) recent-failure
48//! tracking; demote a backend in the top-N after a transient
49//! error.
50//! - `DeviceLoadSelector` — queue-depth + stream-utilization
51//! probing; spread work across co-located backends.
52//!
53//! None of these are wired today — each needs telemetry
54//! infrastructure that doesn't exist yet. The seam lets them ship
55//! independently when the signals do.
56
57use crate::ranker::{AlternativeSet, Candidate};
58
59/// Dispatch-time selector over a plan's per-node [`AlternativeSet`].
60/// Phase 5.1 of the picker-work arc.
61///
62/// # Contract
63///
64/// - `select` is called exactly once per kernel-bearing node, at
65/// dispatch time, by the executor's `resolve_compiled`.
66/// - Implementors return `None` only when the set is empty — every
67/// non-empty set MUST have a pick. The default implementation
68/// ([`WinnerSelector`]) returns `set.winner()` (the first entry).
69/// - The selected `Candidate`'s `kernel`, `caps`, and `backend`
70/// become the executor's dispatch parameters for that node.
71/// - Selectors are queried per-node and may make different picks
72/// for different nodes in the same realize — the trait carries
73/// no per-realize state.
74///
75/// # Sendness
76///
77/// Implementations must be `Send + Sync` so the executor can share
78/// a single selector across the compiler thread and the executor
79/// thread.
80///
81/// # Why not pass NodeId / OpKind / dtypes?
82///
83/// The [`Candidate`] already carries `backend`, `precision`,
84/// `static_cost`, and `op_params`; selectors that need op identity
85/// can derive it from these fields. If a future selector needs the
86/// graph-level NodeId or OpKind, we add a `SelectorContext`
87/// param — but Phase 5.1 keeps the surface minimal so the trait
88/// stays usable by selectors that only care about set contents.
89pub trait RuntimeSelector: Send + Sync + std::fmt::Debug {
90 /// Pick one candidate from the set. `None` only when the set is
91 /// empty; non-empty sets MUST produce a pick.
92 ///
93 /// The default implementation returns `set.winner()` — the top-N's
94 /// first entry, which is Picker 1's static-cost winner after
95 /// rank+truncate.
96 fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
97 set.winner()
98 }
99}
100
101/// The trivial selector: always returns `set.winner()`. Behaviorally
102/// identical to pre-Phase-5 dispatch — every call that doesn't
103/// explicitly supply a selector falls back to this.
104///
105/// Implements [`RuntimeSelector`] via the trait's default `select`.
106/// Use directly via `WinnerSelector` or wrap in
107/// `Arc::new(WinnerSelector) as Arc<dyn RuntimeSelector>` for the
108/// executor APIs that take an `Arc<dyn RuntimeSelector>`.
109#[derive(Debug, Default, Clone, Copy)]
110pub struct WinnerSelector;
111
112impl RuntimeSelector for WinnerSelector {}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::fused::{CostEstimate, PrecisionGuarantee};
118 use crate::kernel::{KernelCaps, OpParams};
119 use fuel_ir::probe::BackendId;
120 use fuel_ir::{DeviceLocation, Layout, Result};
121 use fuel_memory::Storage;
122 use std::sync::{Arc, RwLock};
123
124 fn noop_kernel(
125 _i: &[Arc<RwLock<Storage>>],
126 _o: &mut [Arc<RwLock<Storage>>],
127 _l: &[Layout],
128 _p: &OpParams,
129 ) -> Result<()> {
130 Ok(())
131 }
132
133 fn make_candidate(backend: BackendId, cost_ns: u64) -> Candidate {
134 Candidate {
135 kernel: noop_kernel,
136 caps: KernelCaps::empty(),
137 backend,
138 device: DeviceLocation::Cpu,
139 precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
140 static_cost: CostEstimate {
141 flops: cost_ns,
142 bytes_moved: cost_ns,
143 kernel_overhead_ns: 0,
144 },
145 inbound_transfer_ns: 0,
146 op_params: OpParams::None,
147 coupling: Vec::new(),
148 kernel_source: "",
149 kernel_revision_hash: 0,
150 }
151 }
152
153 /// WinnerSelector returns the set's first entry — the static-
154 /// cost winner. Behavioral baseline.
155 #[test]
156 fn winner_selector_returns_set_winner() {
157 let mut set = AlternativeSet::empty();
158 set.push(make_candidate(BackendId::Cuda, 100));
159 set.push(make_candidate(BackendId::Cpu, 200));
160 let selector = WinnerSelector;
161 let pick = selector.select(&set).expect("non-empty set");
162 assert_eq!(pick.backend, BackendId::Cuda);
163 }
164
165 /// WinnerSelector on empty set returns None — matches
166 /// `AlternativeSet::winner` semantics.
167 #[test]
168 fn winner_selector_empty_set_returns_none() {
169 let set = AlternativeSet::empty();
170 let selector = WinnerSelector;
171 assert!(selector.select(&set).is_none());
172 }
173
174 /// Custom impl can override the default and pick non-winner.
175 /// This proves the trait is a real seam — a future Picker 2
176 /// implementation can demote the static winner based on its
177 /// own logic.
178 #[test]
179 fn custom_selector_can_pick_non_winner() {
180 /// Picks the candidate with the LAST backend in the set
181 /// (opposite of winner-first). Test-only.
182 #[derive(Debug)]
183 struct LastEntrySelector;
184 impl RuntimeSelector for LastEntrySelector {
185 fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
186 set.alternatives().last()
187 }
188 }
189
190 let mut set = AlternativeSet::empty();
191 set.push(make_candidate(BackendId::Cuda, 100));
192 set.push(make_candidate(BackendId::Vulkan, 200));
193 set.push(make_candidate(BackendId::Cpu, 300));
194
195 let pick = LastEntrySelector.select(&set).expect("non-empty");
196 assert_eq!(
197 pick.backend,
198 BackendId::Cpu,
199 "custom selector overrides default winner-pick",
200 );
201 // And the default would have picked AOCL — proves they
202 // disagree.
203 assert_eq!(
204 WinnerSelector.select(&set).unwrap().backend,
205 BackendId::Cuda
206 );
207 }
208
209 /// Trait is dyn-compatible so the executor can pass
210 /// `&dyn RuntimeSelector` / `Arc<dyn RuntimeSelector>`.
211 #[test]
212 fn trait_is_dyn_compatible() {
213 let boxed: Box<dyn RuntimeSelector> = Box::new(WinnerSelector);
214 let set = AlternativeSet::empty();
215 assert!(boxed.select(&set).is_none());
216 }
217}