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}