Skip to main content

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}