Skip to main content

fuel_dispatch/ranker/
vram_pressure_selector.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `VramPressureSelector` — Phase 5.2 runtime selector that demotes
3//! or skips candidates whose backend can't fit the projected output
4//! at dispatch time.
5//!
6//! # Why this selector exists
7//!
8//! Picker 1 (the optimizer ranker) commits to a top-N at plan time
9//! using static cost + Layer-2 Judge data. Neither layer sees the
10//! current memory pressure on a device — that's a runtime signal.
11//! When VRAM is near-saturated, the static winner may be a kernel
12//! whose backend can no longer accommodate the projected output.
13//! Picking that candidate would surface as an OOM at dispatch.
14//!
15//! `VramPressureSelector` reads each candidate's
16//! [`BackendRuntime::would_fit`] for the projected output bytes and:
17//!
18//! - **[`FitStatus::WontFit`]** → skip the candidate entirely
19//! - **[`FitStatus::Tight`]** → demote below Comfortable / Unknown
20//!   (keep visible, but prefer Comfortable / Unknown alternatives
21//!   if any exist)
22//! - **[`FitStatus::Comfortable`]** / **[`FitStatus::Unknown`]** →
23//!   leave in place. Unknown is honest "no signal"; we don't
24//!   pretend it's pressure, so it sorts equal to Comfortable. The
25//!   cost-rank winner breaks ties.
26//!
27//! If every candidate is `WontFit`, the selector falls back to the
28//! static winner — the caller already lost (an OOM is coming), but
29//! deferring to the winner gives the planner-level surface the
30//! cleanest error site rather than silently picking nothing.
31//!
32//! # What needs to be passed at construction
33//!
34//! The trait's [`RuntimeSelector::select`] signature receives only
35//! `&AlternativeSet`. Per-node context (the projected output bytes)
36//! has to live on the selector. Phase 5.2 ships two extension
37//! points:
38//!
39//! - `backend_runtime_lookup`: maps `(BackendId, DeviceLocation)` to
40//!   a [`BackendRuntime`] handle. The runtime handle is borrowed for
41//!   the duration of one `select` call — the lookup callback owns
42//!   the lifecycle.
43//! - `estimate_output_bytes`: maps a `Candidate` to a projected
44//!   output-size estimate. The default
45//!   ([`default_estimate_output_bytes`]) returns
46//!   `c.static_cost.bytes_moved`, which is a deliberately
47//!   pessimistic upper bound — `bytes_moved` includes reads +
48//!   writes + intermediates, so it overcounts the true output
49//!   allocation by ~3-10×. Callers with the live node-output
50//!   shape resolved at plan time (Shape × DType bytes) should plug
51//!   a tighter estimator in.
52//!
53//! Both are `Arc<dyn Fn>` so the selector stays cloneable + shareable
54//! across executor threads.
55
56use std::sync::Arc;
57
58use fuel_backend_contract::backend::BackendRuntime;
59use fuel_ir::DeviceLocation;
60use fuel_ir::backend::FitStatus;
61use fuel_ir::probe::BackendId;
62
63use super::{AlternativeSet, Candidate, RuntimeSelector};
64
65/// Boxed backend-runtime handle. Returned by the
66/// selector's `backend_runtime_lookup` callback for
67/// each `(backend, device)` pair the selector needs to query.
68pub type BackendRuntimeHandle = Box<dyn BackendRuntime + Send + Sync>;
69
70/// Lookup callback shape — produces a [`BackendRuntimeHandle`] for a
71/// given `(BackendId, DeviceLocation)`. `None` when the executor
72/// hasn't materialized a backend for that pair (e.g. CUDA on a
73/// host with no NVIDIA driver). The selector treats `None` as
74/// [`FitStatus::Unknown`] — no signal, leave the candidate's
75/// position untouched.
76pub type BackendRuntimeLookup =
77    Arc<dyn Fn(BackendId, DeviceLocation) -> Option<BackendRuntimeHandle> + Send + Sync>;
78
79/// Output-size estimator callback — given a candidate, returns the
80/// projected output bytes for the fit check. Callers that have the
81/// exact post-realize output size from the graph plug a precise
82/// estimator in; callers that don't can use [`default_estimate_output_bytes`]
83/// as a deliberately pessimistic fallback.
84pub type OutputBytesEstimator = Arc<dyn Fn(&Candidate) -> u64 + Send + Sync>;
85
86/// Default output-size estimator: treats the candidate's
87/// [`crate::fused::CostEstimate::bytes_moved`] as the projected
88/// allocation. `bytes_moved` covers reads + writes + intermediates,
89/// so this overcounts. That's deliberate — Phase 5.2's pressure
90/// check is a safety net, and false-positives (calling a backend
91/// `Tight` that's actually comfortable) demote rather than block.
92/// Callers wanting tighter estimates can compute one from the
93/// node's output `Shape` and `DType` at plan-build time and plug
94/// their own estimator in.
95pub fn default_estimate_output_bytes(c: &Candidate) -> u64 {
96    c.static_cost.bytes_moved
97}
98
99/// Phase 5.2 runtime selector that demotes candidates whose backend
100/// reports memory pressure for the projected output size.
101///
102/// See module docs for the full rationale + design tradeoffs.
103///
104/// # Construction
105///
106/// ```ignore
107/// use std::sync::Arc;
108/// use fuel_dispatch::ranker::{
109///     VramPressureSelector, default_estimate_output_bytes,
110/// };
111///
112/// let selector = VramPressureSelector::new(
113///     /* backend_runtime_lookup */ Arc::new(|_b, _d| None),
114///     /* estimate_output_bytes  */ Arc::new(default_estimate_output_bytes),
115/// );
116/// ```
117#[derive(Clone)]
118pub struct VramPressureSelector {
119    backend_runtime_lookup: BackendRuntimeLookup,
120    estimate_output_bytes: OutputBytesEstimator,
121}
122
123impl VramPressureSelector {
124    /// Construct a new selector. Both callbacks are `Arc<dyn Fn>` so
125    /// the selector is cheap to clone + share across threads.
126    pub fn new(
127        backend_runtime_lookup: BackendRuntimeLookup,
128        estimate_output_bytes: OutputBytesEstimator,
129    ) -> Self {
130        Self {
131            backend_runtime_lookup,
132            estimate_output_bytes,
133        }
134    }
135
136    /// Construct with the default [`default_estimate_output_bytes`]
137    /// estimator. Use this when the caller only has a runtime-lookup
138    /// callback and wants the conservative fallback estimate.
139    pub fn with_default_estimator(backend_runtime_lookup: BackendRuntimeLookup) -> Self {
140        Self::new(
141            backend_runtime_lookup,
142            Arc::new(default_estimate_output_bytes),
143        )
144    }
145
146    /// Query the lookup callback and the candidate's fit status.
147    /// `Unknown` is returned when:
148    ///
149    /// - the lookup callback returns `None` (no backend handle), or
150    /// - the backend's `would_fit` returns `Unknown` (no memory
151    ///   signal available).
152    fn fit_status_for(&self, c: &Candidate) -> FitStatus {
153        let size = (self.estimate_output_bytes)(c);
154        match (self.backend_runtime_lookup)(c.backend, c.device) {
155            Some(runtime) => runtime.would_fit(size),
156            None => FitStatus::Unknown,
157        }
158    }
159}
160
161impl std::fmt::Debug for VramPressureSelector {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("VramPressureSelector")
164            .field("backend_runtime_lookup", &"<closure>")
165            .field("estimate_output_bytes", &"<closure>")
166            .finish()
167    }
168}
169
170impl RuntimeSelector for VramPressureSelector {
171    fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
172        let alts = set.alternatives();
173        if alts.is_empty() {
174            return None;
175        }
176
177        // Classify each candidate. We then walk in original (cost-
178        // ranked) order and pick the first comfortable / unknown /
179        // tight survivor. Skipped candidates are never picked.
180        //
181        // Priority: {Comfortable, Unknown} > Tight. Unknown is
182        // honest "no signal" — we don't pretend it's pressure, so
183        // it sorts equal to Comfortable. We DON'T sort — we walk
184        // in cost-rank order and remember the best status seen so
185        // far. That way ties (including Comfortable-vs-Unknown
186        // ties) break toward the static winner.
187        let mut best_idx: Option<usize> = None;
188        let mut best_status: Option<FitStatus> = None;
189
190        // Score: lower is better. Comfortable = Unknown = 0, Tight = 1.
191        fn score_of(s: FitStatus) -> u8 {
192            match s {
193                FitStatus::Comfortable | FitStatus::Unknown => 0,
194                FitStatus::Tight => 1,
195                FitStatus::WontFit => unreachable!("filtered above"),
196            }
197        }
198
199        for (i, c) in alts.iter().enumerate() {
200            let status = self.fit_status_for(c);
201            if matches!(status, FitStatus::WontFit) {
202                // Skip — under no circumstances pick this.
203                continue;
204            }
205
206            let score = score_of(status);
207            let prev_score = best_status.map(score_of);
208            match prev_score {
209                None => {
210                    best_idx = Some(i);
211                    best_status = Some(status);
212                }
213                Some(prev) if score < prev => {
214                    best_idx = Some(i);
215                    best_status = Some(status);
216                }
217                _ => {}
218            }
219        }
220
221        // If every candidate was WontFit, fall back to the static
222        // winner. The caller is in trouble either way — surfacing
223        // the winner gives planner-level error reporting the cleanest
224        // site (rather than silently returning None on a non-empty set,
225        // which would violate the trait's contract).
226        match best_idx {
227            Some(i) => alts.get(i),
228            None => set.winner(),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::fused::{CostEstimate, PrecisionGuarantee};
237    use crate::kernel::{KernelCaps, OpParams};
238    use fuel_backend_contract::backend::BackendRuntime;
239    use fuel_ir::probe::BackendId;
240    use fuel_ir::{DeviceLocation, Layout, Result};
241    use fuel_memory::Storage;
242    use std::sync::{Arc, RwLock};
243
244    fn noop_kernel(
245        _i: &[Arc<RwLock<Storage>>],
246        _o: &mut [Arc<RwLock<Storage>>],
247        _l: &[Layout],
248        _p: &OpParams,
249    ) -> Result<()> {
250        Ok(())
251    }
252
253    fn make_candidate(backend: BackendId, device: DeviceLocation, bytes_moved: u64) -> Candidate {
254        Candidate {
255            kernel: noop_kernel,
256            caps: KernelCaps::empty(),
257            backend,
258            device,
259            precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
260            static_cost: CostEstimate {
261                flops: bytes_moved,
262                bytes_moved,
263                kernel_overhead_ns: 0,
264            },
265            inbound_transfer_ns: 0,
266            op_params: OpParams::None,
267            coupling: Vec::new(),
268            kernel_source: "",
269            kernel_revision_hash: 0,
270        }
271    }
272
273    /// Mock BackendRuntime — returns configured (available, total).
274    struct MockRuntime {
275        available: Option<u64>,
276        total: Option<u64>,
277    }
278    impl BackendRuntime for MockRuntime {
279        fn available_bytes(&self) -> Option<u64> {
280            self.available
281        }
282        fn total_bytes(&self) -> Option<u64> {
283            self.total
284        }
285    }
286
287    /// Build a lookup that returns the same `(available, total)` for
288    /// the named backend, and `None` for every other backend.
289    fn single_backend_lookup(
290        target: BackendId,
291        available: Option<u64>,
292        total: Option<u64>,
293    ) -> BackendRuntimeLookup {
294        Arc::new(move |b, _d| {
295            if b == target {
296                Some(Box::new(MockRuntime { available, total })
297                    as Box<dyn BackendRuntime + Send + Sync>)
298            } else {
299                None
300            }
301        })
302    }
303
304    /// Build a lookup that varies per backend.
305    fn per_backend_lookup(
306        cuda: (Option<u64>, Option<u64>),
307        cpu: (Option<u64>, Option<u64>),
308    ) -> BackendRuntimeLookup {
309        Arc::new(move |b, _d| match b {
310            BackendId::Cuda => Some(Box::new(MockRuntime {
311                available: cuda.0,
312                total: cuda.1,
313            }) as Box<dyn BackendRuntime + Send + Sync>),
314            BackendId::Cpu => Some(Box::new(MockRuntime {
315                available: cpu.0,
316                total: cpu.1,
317            }) as Box<dyn BackendRuntime + Send + Sync>),
318            _ => None,
319        })
320    }
321
322    /// Helper to build a selector with the default-bytes estimator.
323    fn make_selector(lookup: BackendRuntimeLookup) -> VramPressureSelector {
324        VramPressureSelector::with_default_estimator(lookup)
325    }
326
327    /// Baseline: all candidates `Comfortable` → top winner picked.
328    #[test]
329    fn all_comfortable_picks_winner() {
330        let mut set = AlternativeSet::empty();
331        set.push(make_candidate(
332            BackendId::Cuda,
333            DeviceLocation::Cuda { gpu_id: 0 },
334            100,
335        ));
336        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 200));
337
338        // Both backends report 1MB free of 2MB total → comfortable.
339        let lookup = per_backend_lookup(
340            (Some(1_000_000), Some(2_000_000)),
341            (Some(1_000_000), Some(2_000_000)),
342        );
343        let sel = make_selector(lookup);
344        let pick = sel.select(&set).expect("non-empty");
345        assert_eq!(
346            pick.backend,
347            BackendId::Cuda,
348            "winner is the cost-ranked first"
349        );
350    }
351
352    /// WontFit candidate gets skipped — picker falls through to the
353    /// next admissible one.
354    #[test]
355    fn wont_fit_skips_candidate() {
356        let mut set = AlternativeSet::empty();
357        set.push(make_candidate(
358            BackendId::Cuda,
359            DeviceLocation::Cuda { gpu_id: 0 },
360            1_000,
361        ));
362        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 1_000));
363
364        // CUDA has only 500 bytes free (won't fit 1_000 estimate).
365        // CPU has plenty of room.
366        let lookup = per_backend_lookup((Some(500), Some(10_000)), (Some(8_000), Some(10_000)));
367        let sel = make_selector(lookup);
368        let pick = sel.select(&set).expect("non-empty");
369        assert_eq!(
370            pick.backend,
371            BackendId::Cpu,
372            "CUDA skipped due to WontFit; CPU is next admissible",
373        );
374    }
375
376    /// Tight candidate gets demoted below Comfortable — even if it
377    /// came first in cost rank.
378    #[test]
379    fn tight_demoted_below_comfortable() {
380        let mut set = AlternativeSet::empty();
381        // CUDA winner by cost (smaller bytes_moved).
382        set.push(make_candidate(
383            BackendId::Cuda,
384            DeviceLocation::Cuda { gpu_id: 0 },
385            100,
386        ));
387        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 100));
388
389        // To trigger Tight: total > 0, size <= available, but
390        // post-used / total > 0.85. With available = total
391        // (nothing currently used), size > 0.85*total triggers Tight.
392        // CUDA: available=100, total=100 → alloc 100 → 100% used
393        // → 1.0 > 0.85 → Tight (and size <= available so not WontFit).
394        // CPU: ample room → Comfortable.
395        let lookup = per_backend_lookup((Some(100), Some(100)), (Some(10_000), Some(10_000)));
396        let sel = make_selector(lookup);
397        let pick = sel.select(&set).expect("non-empty");
398        assert_eq!(
399            pick.backend,
400            BackendId::Cpu,
401            "CUDA demoted (Tight) to favor Comfortable CPU even though CUDA was cost-winner",
402        );
403    }
404
405    /// All candidates WontFit → fall back to the static winner (so
406    /// the executor surfaces a clean OOM at dispatch rather than
407    /// the selector silently returning None on a non-empty set).
408    #[test]
409    fn all_wont_fit_falls_back_to_winner() {
410        let mut set = AlternativeSet::empty();
411        set.push(make_candidate(
412            BackendId::Cuda,
413            DeviceLocation::Cuda { gpu_id: 0 },
414            1_000,
415        ));
416        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 1_000));
417
418        // Both backends report 1 byte free → both WontFit for the
419        // 1_000-byte estimate.
420        let lookup = per_backend_lookup((Some(1), Some(10_000)), (Some(1), Some(10_000)));
421        let sel = make_selector(lookup);
422        let pick = sel.select(&set).expect("non-empty (falls back to winner)");
423        assert_eq!(
424            pick.backend,
425            BackendId::Cuda,
426            "all WontFit → fall back to static winner",
427        );
428    }
429
430    /// Unknown sorts equal to Comfortable — no penalty. The
431    /// cost-winner that came first in the alt set keeps its
432    /// position when its fit status is Unknown and the runner-up
433    /// is Comfortable. We don't pretend "no signal" is pressure.
434    #[test]
435    fn unknown_ties_with_comfortable_preserves_winner() {
436        let mut set = AlternativeSet::empty();
437        // Cost winner is Cuda (cheaper). CPU is the runner-up.
438        set.push(make_candidate(
439            BackendId::Cuda,
440            DeviceLocation::Cuda { gpu_id: 0 },
441            100,
442        ));
443        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 100));
444
445        // Lookup returns a runtime ONLY for CPU; CUDA is Unknown.
446        // CPU is Comfortable; Unknown ties with Comfortable, so the
447        // first-in-cost-rank survivor (CUDA) wins the tie.
448        let lookup = single_backend_lookup(BackendId::Cpu, Some(10_000), Some(10_000));
449        let sel = make_selector(lookup);
450        let pick = sel.select(&set).expect("non-empty");
451        assert_eq!(
452            pick.backend,
453            BackendId::Cuda,
454            "Unknown ties with Comfortable; cost-rank winner preserved",
455        );
456    }
457
458    /// Empty set → None (matches trait contract).
459    #[test]
460    fn empty_set_returns_none() {
461        let set = AlternativeSet::empty();
462        let lookup: BackendRuntimeLookup = Arc::new(|_, _| None);
463        let sel = make_selector(lookup);
464        assert!(sel.select(&set).is_none());
465    }
466
467    /// Custom output-bytes estimator is honored.
468    #[test]
469    fn custom_estimator_changes_fit_outcome() {
470        let mut set = AlternativeSet::empty();
471        // bytes_moved=100; backend has 50 free.
472        set.push(make_candidate(
473            BackendId::Cuda,
474            DeviceLocation::Cuda { gpu_id: 0 },
475            100,
476        ));
477        set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 100));
478
479        // With default estimator (uses bytes_moved=100): CUDA WontFit.
480        // With custom estimator returning 10: CUDA Comfortable.
481        let lookup = per_backend_lookup((Some(50), Some(1_000)), (Some(50), Some(1_000)));
482
483        // Custom estimator returns 10 — well within 50.
484        let custom: OutputBytesEstimator = Arc::new(|_c: &Candidate| 10u64);
485        let sel = VramPressureSelector::new(lookup.clone(), custom);
486        let pick = sel.select(&set).expect("non-empty");
487        assert_eq!(
488            pick.backend,
489            BackendId::Cuda,
490            "custom estimator returned size that fits → cost-winner stays",
491        );
492
493        // Default estimator → CUDA over-allocates (bytes_moved=100 vs
494        // available=50) → WontFit → fallback to CPU. CPU also has
495        // available=50 with bytes_moved=100 → also WontFit → all
496        // WontFit → fall back to static winner (CUDA).
497        let sel_default = VramPressureSelector::with_default_estimator(lookup);
498        let pick = sel_default.select(&set).expect("non-empty");
499        assert_eq!(
500            pick.backend,
501            BackendId::Cuda,
502            "all WontFit → static winner fallback",
503        );
504    }
505
506    /// Debug impl prints the type name without panicking on the
507    /// closure fields.
508    #[test]
509    fn debug_does_not_panic() {
510        let lookup: BackendRuntimeLookup = Arc::new(|_, _| None);
511        let sel = VramPressureSelector::with_default_estimator(lookup);
512        let s = format!("{sel:?}");
513        assert!(s.contains("VramPressureSelector"));
514    }
515}