Skip to main content

fuel_dispatch/ranker/
device_load.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `DeviceLoadSelector` + the `load_tier` bucketing — Step E Phase C,
3//! PR C2: the **live-load arm re-pick**.
4//!
5//! This is the concrete selector the [`super::runtime_selector`] module
6//! sketch names (`DeviceLoadSelector` — "queue-depth + stream-utilization
7//! probing") and that said "needs telemetry infrastructure that doesn't
8//! exist yet". B1 shipped that infrastructure: the process-wide per-device
9//! in-flight counter (`fuel-dispatch::dispatch::inflight_count`) exposed
10//! through the Tier-2 `BackendStreams::pending_work_count` seam.
11//!
12//! # What C2 adds
13//!
14//! At each `Op::Branch` the runtime picker ranks the viable arms. C1 made
15//! that ranking happen lazily at the streaming-walk frontier; C2 makes the
16//! ranking **load-aware** by demoting arms whose device is busy *right
17//! now*. The "right now" is the whole point of Step E: the executor picks
18//! the path that drains the device queues fastest at the moment the
19//! frontier reaches the branch.
20//!
21//! # The composition (where the load leg slots)
22//!
23//! Load is **not** a standalone production selector — it is folded into
24//! [`super::ChainedSelector`] as a key leg between the VRAM guard and the
25//! latency rank:
26//!
27//! ```text
28//! (pressure_tier, load_tier, latency_ns, original_index)   ← pick the min
29//!       │             │            │             │
30//!       │             │            │             └ static-winner determinism
31//!       │             │            └ Judge-measured / static composite
32//!       │             └ THIS module: coarse bucket of pending_work_count / slot_capacity
33//!       └ VRAM fit (WontFit skipped; Tight=1; Comfortable/Unknown=0)
34//! ```
35//!
36//! **VRAM outranks load (critical).** `pressure_tier` stays the FIRST key
37//! component, so load reorders arms ONLY within a VRAM fit tier — the
38//! selector never picks a `WontFit`/OOM-adjacent device to balance load.
39//! See [`super::ChainedSelector`]'s `select`.
40//!
41//! This module ships:
42//!
43//! - [`load_tier`] — the pure `(count, capacity) -> u8` bucketing the
44//!   `ChainedSelector` key uses (and a standalone unit can test in
45//!   isolation).
46//! - [`DeviceLoadSelector`] — a standalone [`RuntimeSelector`] that ranks
47//!   purely on `(load_tier, original_index)`. Not on the production path
48//!   (that is `ChainedSelector`'s folded leg, per design §3.3(a)); it
49//!   exists as the **unit-testable core** of the load decision, mirroring
50//!   how `VramPressureSelector` is the standalone for the guard leg.
51//! - [`LoadLookup`] / [`load_tier_for`] — read the live `pending_work_count`
52//!   for a candidate's `(backend, device)` through the route picker's
53//!   existing [`super::BackendRuntimeLookup`] (the handle the bridge hands
54//!   out — `DeviceRuntimeHandle` — already implements `BackendStreams`, so
55//!   the SAME lookup carries both the VRAM and the load signal; no second
56//!   lookup is needed).
57//!
58//! # Honesty (no-signal = tier 0)
59//!
60//! A candidate whose handle is absent, is not a `BackendStreams` (CPU /
61//! Reference — no queue concept), or reports `pending_work_count() == None`
62//! gets **`load_tier` 0** — an honest "no load signal", exactly the way a
63//! VRAM `Unknown` is treated. Tier 0 is NEVER a fabricated "idle": it means
64//! "this leg has nothing to say", so an arm with no signal ties with a
65//! genuinely-idle arm and the lower-index (static winner) breaks the tie.
66//! With no signal anywhere every arm is tier 0 and the load leg vanishes
67//! from the key — the degenerate-fallback that keeps C2 byte-identical to
68//! pre-C2 dispatch (design §3.2).
69
70use fuel_backend_contract::backend::BackendRuntime;
71use fuel_ir::DeviceLocation;
72use fuel_ir::probe::BackendId;
73
74use super::{AlternativeSet, BackendRuntimeLookup, Candidate, RuntimeSelector};
75
76/// Coarse load buckets for a device's live in-flight count, relative to
77/// its advertised slot capacity. Coarse (3 buckets) on purpose — mirrors
78/// the `super::route_picker::free_bytes_bucket` philosophy so jitter (a
79/// single op submitted/drained between two reads) does not thrash the arm
80/// pick or invalidate the `RouteCache` fingerprint, while a genuine
81/// idle→saturated transition still reorders.
82pub const LOAD_TIER_IDLE: u8 = 0;
83/// Below ~one slot-capacity's worth of in-flight work, but not idle.
84pub const LOAD_TIER_MODERATE: u8 = 1;
85/// At or above the advertised slot capacity — the device's queues are
86/// full; another arm on a less-loaded device drains faster.
87pub const LOAD_TIER_SATURATED: u8 = 2;
88
89/// Bucket a device's live in-flight `count` (from
90/// `BackendStreams::pending_work_count`) against its advertised
91/// `capacity` (from `slot_capacity`) into a coarse load tier.
92///
93/// Device-relative (`count / capacity`) so the tiering means the same
94/// thing across a 1-stream CUDA device and a hypothetical N-queue one,
95/// per design §9 open-Q1:
96///
97/// - `count == 0`                → [`LOAD_TIER_IDLE`] (nothing in flight).
98/// - `count < capacity` (utilization in `(0, 1)`) → either idle or
99///   moderate: utilization `< 0.25` is still [`LOAD_TIER_IDLE`] (a single
100///   op on a multi-slot device isn't "loaded"), else
101///   [`LOAD_TIER_MODERATE`].
102/// - `count >= capacity` (utilization `>= 1.0`) → [`LOAD_TIER_SATURATED`].
103///
104/// `capacity == 0` is treated as 1 (a backend that opts into
105/// `BackendStreams` has at least one slot; defending against a 0 avoids a
106/// divide-by-zero and reads any non-zero count as saturated, which is the
107/// honest pessimistic reading).
108///
109/// This is a pure function of two integers — no I/O, no panic — so it is
110/// the unit-test seam for the whole load decision.
111pub fn load_tier(count: u32, capacity: u32) -> u8 {
112    if count == 0 {
113        return LOAD_TIER_IDLE;
114    }
115    let capacity = capacity.max(1);
116    if count >= capacity {
117        return LOAD_TIER_SATURATED;
118    }
119    // count in (0, capacity): split idle vs moderate at 25% utilization so a
120    // lone op on a wide device stays "idle". `count * 4 < capacity` is the
121    // integer form of `count / capacity < 0.25`.
122    if count.saturating_mul(4) < capacity {
123        LOAD_TIER_IDLE
124    } else {
125        LOAD_TIER_MODERATE
126    }
127}
128
129/// A lookup that hands the load-aware selector a [`BackendRuntime`] handle
130/// for a `(backend, device)` — the SAME shape + the SAME instances as the
131/// route picker's [`super::BackendRuntimeLookup`]. C2 reuses that lookup
132/// rather than introducing a second one: the bridge's handle
133/// (`DeviceRuntimeHandle`) already implements `BackendStreams`, so the
134/// load signal is reachable through `as_backend_streams()` on the handle
135/// the VRAM guard already queries (design §3.3 — one load source serves
136/// all backends, the selector never holds executor internals).
137pub type LoadLookup = BackendRuntimeLookup;
138
139/// Read the live load tier for a candidate's `(backend, device)` through
140/// `lookup`. Returns [`LOAD_TIER_IDLE`] (the honest no-signal tier) when:
141///
142/// - no lookup is configured,
143/// - the lookup has no handle for the pair,
144/// - the handle is not a `BackendStreams` (CPU / Reference — no queue
145///   concept; `as_backend_streams()` is `None`), or
146/// - the handle's `pending_work_count()` is `None` (a streaming backend
147///   that genuinely can't report depth right now).
148///
149/// Never fabricates a non-zero load from a missing signal — see the module
150/// honesty note.
151pub fn load_tier_for(lookup: Option<&LoadLookup>, c: &Candidate) -> u8 {
152    let Some(lookup) = lookup else {
153        return LOAD_TIER_IDLE;
154    };
155    let Some(handle) = lookup(c.backend, c.device) else {
156        return LOAD_TIER_IDLE;
157    };
158    load_tier_of_handle(handle.as_ref())
159}
160
161/// The load tier of one already-resolved runtime handle. Split out so
162/// [`super::ChainedSelector`] (which already holds the boxed handle for
163/// its VRAM `would_fit` query) can read load off the *same* handle without
164/// a second lookup call.
165pub(crate) fn load_tier_of_handle(handle: &dyn BackendRuntime) -> u8 {
166    match handle.as_backend_streams() {
167        Some(streams) => match streams.pending_work_count() {
168            Some(count) => load_tier(count, streams.slot_capacity()),
169            None => LOAD_TIER_IDLE,
170        },
171        None => LOAD_TIER_IDLE,
172    }
173}
174
175/// Standalone load-only runtime selector — the **unit-testable core** of
176/// the C2 load decision (design §3.3(b)). It ranks viable candidates by
177/// `(load_tier, original_index)` and picks the minimum: the least-loaded
178/// arm, ties broken toward the static winner (lowest index).
179///
180/// NOT the production path — production folds the load leg into
181/// [`super::ChainedSelector`] so it composes with the VRAM guard + Judge
182/// rank in one key (a standalone load selector can't see VRAM, so it could
183/// pick a busy-but-fitting device's idle peer that won't fit — exactly the
184/// composition problem `ChainedSelector` exists to solve). This selector
185/// is the isolated proof that the `load_tier` leg picks the unloaded arm.
186#[derive(Clone)]
187pub struct DeviceLoadSelector {
188    lookup: Option<LoadLookup>,
189}
190
191impl DeviceLoadSelector {
192    /// Construct with a load lookup. `None` ⇒ every candidate is
193    /// [`LOAD_TIER_IDLE`] ⇒ the pick is `set.winner()` (the no-signal
194    /// degenerate, mirroring `ChainedSelector` with no signals).
195    pub fn new(lookup: Option<LoadLookup>) -> Self {
196        Self { lookup }
197    }
198}
199
200impl std::fmt::Debug for DeviceLoadSelector {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("DeviceLoadSelector")
203            .field("lookup", &self.lookup.as_ref().map(|_| "<closure>"))
204            .finish()
205    }
206}
207
208impl RuntimeSelector for DeviceLoadSelector {
209    fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
210        let alts = set.alternatives();
211        if alts.is_empty() {
212            return None;
213        }
214        // Walk in original (cost-ranked / arm) order, tracking the minimum
215        // (load_tier, idx) key. No VRAM guard here — that is the
216        // production ChainedSelector's job; this standalone proves the
217        // load leg alone picks the least-loaded arm.
218        let mut best: Option<(u8, usize)> = None;
219        for (i, c) in alts.iter().enumerate() {
220            let key = (load_tier_for(self.lookup.as_ref(), c), i);
221            if best.is_none_or(|b| key < b) {
222                best = Some(key);
223            }
224        }
225        match best {
226            Some((_, i)) => alts.get(i),
227            None => set.winner(),
228        }
229    }
230}
231
232/// A test-only [`fuel_backend_contract::backend::BackendStreams`] handle
233/// reporting a fixed `(pending_work_count, slot_capacity)` — the simulated
234/// live-load signal C2's tests inject in place of a real device. Lives
235/// here (not behind `#[cfg(test)]`) so the route-picker + bridge tests can
236/// build a fake load lookup without re-deriving the trait wiring.
237pub struct MockBackendStreams {
238    /// What `pending_work_count()` reports. `None` = "I can't measure" (a
239    /// streaming backend with no depth available) ⇒ tier 0.
240    pub pending: Option<u32>,
241    /// What `slot_capacity()` reports.
242    pub capacity: u32,
243}
244
245impl MockBackendStreams {
246    /// A handle reporting `pending` in-flight ops against `capacity` slots.
247    pub fn new(pending: Option<u32>, capacity: u32) -> Self {
248        Self { pending, capacity }
249    }
250}
251
252impl BackendRuntime for MockBackendStreams {
253    // No VRAM signal — this mock carries only the load signal, so the VRAM
254    // guard sees Unknown (tier 0) for it. The headline gate's two arms are
255    // both VRAM-fit (Unknown), so the load leg is the only differentiator.
256    fn available_bytes(&self) -> Option<u64> {
257        None
258    }
259    fn total_bytes(&self) -> Option<u64> {
260        None
261    }
262    fn as_backend_streams(&self) -> Option<&dyn fuel_backend_contract::backend::BackendStreams> {
263        Some(self)
264    }
265}
266
267impl fuel_backend_contract::backend::BackendStreams for MockBackendStreams {
268    fn pending_work_count(&self) -> Option<u32> {
269        self.pending
270    }
271    fn slot_capacity(&self) -> u32 {
272        self.capacity
273    }
274    fn flush(&self) -> fuel_ir::Result<()> {
275        Ok(())
276    }
277}
278
279/// Build a load lookup that returns a [`MockBackendStreams`] per backend
280/// from `(backend, pending, capacity)` entries; backends not listed
281/// resolve to `None` (= no handle = tier 0). The shared shape the C2 tests
282/// (here, route_picker, and the bridge) use to inject a fake live-load
283/// signal. `device` is ignored — the entries key on `backend`, matching
284/// how the route picker maps an arm to a single default device per
285/// backend.
286pub fn mock_load_lookup(entries: Vec<(BackendId, Option<u32>, u32)>) -> LoadLookup {
287    use std::sync::Arc;
288    Arc::new(move |b: BackendId, _d: DeviceLocation| {
289        entries
290            .iter()
291            .find(|(eb, _, _)| *eb == b)
292            .map(|&(_, pending, capacity)| {
293                Box::new(MockBackendStreams::new(pending, capacity)) as super::BackendRuntimeHandle
294            })
295    })
296}
297
298/// A test-only runtime handle carrying BOTH a VRAM signal
299/// (available/total, read by the VRAM guard) AND a live-load signal
300/// (pending/capacity, read through `BackendStreams`) — the exact shape of
301/// the production `DeviceRuntimeHandle`, which answers both `would_fit` and
302/// `pending_work_count` off one handle. Used by the C2 headline-gate test
303/// to build a 2-device branched graph whose arms are both VRAM-fit but
304/// differ in live load.
305pub struct MockCombinedRuntime {
306    /// VRAM available bytes (the guard's `available_bytes`).
307    pub available: Option<u64>,
308    /// VRAM total bytes (the guard's `total_bytes`).
309    pub total: Option<u64>,
310    /// Live in-flight count (`pending_work_count`).
311    pub pending: Option<u32>,
312    /// Advertised slot capacity (`slot_capacity`).
313    pub capacity: u32,
314}
315
316impl BackendRuntime for MockCombinedRuntime {
317    fn available_bytes(&self) -> Option<u64> {
318        self.available
319    }
320    fn total_bytes(&self) -> Option<u64> {
321        self.total
322    }
323    fn as_backend_streams(&self) -> Option<&dyn fuel_backend_contract::backend::BackendStreams> {
324        Some(self)
325    }
326}
327
328impl fuel_backend_contract::backend::BackendStreams for MockCombinedRuntime {
329    fn pending_work_count(&self) -> Option<u32> {
330        self.pending
331    }
332    fn slot_capacity(&self) -> u32 {
333        self.capacity
334    }
335    fn flush(&self) -> fuel_ir::Result<()> {
336        Ok(())
337    }
338}
339
340/// Build a combined VRAM+load lookup from
341/// `(backend, available, total, pending, capacity)` entries — one
342/// One mock runtime entry: `(backend, vram_total, vram_free, inflight,
343/// capacity)`.
344///
345/// A five-tuple is unreadable at the call site — nothing in
346/// `(BackendId, Option<u64>, Option<u64>, Option<u32>, u32)` says which
347/// `Option<u64>` is total and which is free. The alias carries the field
348/// order that the type cannot.
349pub type MockCombinedEntry = (BackendId, Option<u64>, Option<u64>, Option<u32>, u32);
350
351/// [`MockCombinedRuntime`] per backend, answering both the VRAM guard and
352/// the load leg, exactly like the production `DeviceRuntimeHandle`.
353/// Backends not listed resolve to `None`.
354pub fn mock_combined_lookup(entries: Vec<MockCombinedEntry>) -> LoadLookup {
355    use std::sync::Arc;
356    Arc::new(move |b: BackendId, _d: DeviceLocation| {
357        entries.iter().find(|(eb, _, _, _, _)| *eb == b).map(
358            |&(_, available, total, pending, capacity)| {
359                Box::new(MockCombinedRuntime {
360                    available,
361                    total,
362                    pending,
363                    capacity,
364                }) as super::BackendRuntimeHandle
365            },
366        )
367    })
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::fused::{CostEstimate, PrecisionGuarantee};
374    use crate::kernel::{KernelCaps, OpParams};
375    use fuel_ir::{DeviceLocation, Layout, Result};
376    use fuel_memory::Storage;
377    use std::sync::{Arc, RwLock};
378
379    fn noop_kernel(
380        _i: &[Arc<RwLock<Storage>>],
381        _o: &mut [Arc<RwLock<Storage>>],
382        _l: &[Layout],
383        _p: &OpParams,
384    ) -> Result<()> {
385        Ok(())
386    }
387
388    fn make_candidate(backend: BackendId, device: DeviceLocation) -> Candidate {
389        Candidate {
390            kernel: noop_kernel,
391            caps: KernelCaps::empty(),
392            backend,
393            device,
394            precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
395            static_cost: CostEstimate {
396                flops: 0,
397                bytes_moved: 0,
398                kernel_overhead_ns: 0,
399            },
400            inbound_transfer_ns: 0,
401            op_params: OpParams::None,
402            coupling: Vec::new(),
403            kernel_source: "",
404            kernel_revision_hash: 0,
405        }
406    }
407
408    // ===== load_tier bucketing =====
409
410    /// Zero in flight is always idle, regardless of capacity.
411    #[test]
412    fn load_tier_zero_is_idle() {
413        assert_eq!(load_tier(0, 1), LOAD_TIER_IDLE);
414        assert_eq!(load_tier(0, 8), LOAD_TIER_IDLE);
415    }
416
417    /// At/above capacity is saturated.
418    #[test]
419    fn load_tier_at_or_above_capacity_is_saturated() {
420        assert_eq!(load_tier(1, 1), LOAD_TIER_SATURATED);
421        assert_eq!(load_tier(8, 8), LOAD_TIER_SATURATED);
422        assert_eq!(load_tier(100, 1), LOAD_TIER_SATURATED);
423        assert_eq!(load_tier(9, 8), LOAD_TIER_SATURATED);
424    }
425
426    /// Below 25% utilization on a wide device is still idle; at/above 25%
427    /// (but below capacity) is moderate.
428    #[test]
429    fn load_tier_splits_idle_and_moderate_at_quarter() {
430        // capacity 8: count 1 → 12.5% → idle; count 2 → 25% → moderate.
431        assert_eq!(load_tier(1, 8), LOAD_TIER_IDLE);
432        assert_eq!(load_tier(2, 8), LOAD_TIER_MODERATE);
433        assert_eq!(load_tier(7, 8), LOAD_TIER_MODERATE);
434    }
435
436    /// The single-slot device (the B1 default `slot_capacity == 1`): any
437    /// in-flight work is saturated, zero is idle — there is no moderate
438    /// band, which is exactly right for a 1-stream device.
439    #[test]
440    fn load_tier_single_slot_is_binary() {
441        assert_eq!(load_tier(0, 1), LOAD_TIER_IDLE);
442        assert_eq!(load_tier(1, 1), LOAD_TIER_SATURATED);
443        assert_eq!(load_tier(5, 1), LOAD_TIER_SATURATED);
444    }
445
446    /// `capacity == 0` defends against divide-by-zero and reads any
447    /// non-zero count as saturated (the pessimistic honest reading).
448    #[test]
449    fn load_tier_zero_capacity_is_safe() {
450        assert_eq!(load_tier(0, 0), LOAD_TIER_IDLE);
451        assert_eq!(load_tier(1, 0), LOAD_TIER_SATURATED);
452    }
453
454    // ===== load_tier_for: reading through the lookup =====
455
456    /// No lookup ⇒ tier 0 (no signal).
457    #[test]
458    fn load_tier_for_no_lookup_is_idle() {
459        let c = make_candidate(BackendId::Cuda, DeviceLocation::Cuda { gpu_id: 0 });
460        assert_eq!(load_tier_for(None, &c), LOAD_TIER_IDLE);
461    }
462
463    /// A backend absent from the lookup ⇒ tier 0 (no handle).
464    #[test]
465    fn load_tier_for_absent_backend_is_idle() {
466        let lookup = mock_load_lookup(vec![(BackendId::Cuda, Some(8), 1)]);
467        let cpu = make_candidate(BackendId::Cpu, DeviceLocation::Cpu);
468        assert_eq!(load_tier_for(Some(&lookup), &cpu), LOAD_TIER_IDLE);
469    }
470
471    /// `pending_work_count() == None` (a streaming backend that can't
472    /// report depth) ⇒ tier 0, NOT a fabricated load.
473    #[test]
474    fn load_tier_for_none_count_is_idle() {
475        let lookup = mock_load_lookup(vec![(BackendId::Cuda, None, 1)]);
476        let cuda = make_candidate(BackendId::Cuda, DeviceLocation::Cuda { gpu_id: 0 });
477        assert_eq!(load_tier_for(Some(&lookup), &cuda), LOAD_TIER_IDLE);
478    }
479
480    /// A saturated device reads as saturated through the lookup.
481    #[test]
482    fn load_tier_for_reads_saturated() {
483        let lookup = mock_load_lookup(vec![(BackendId::Cuda, Some(4), 1)]);
484        let cuda = make_candidate(BackendId::Cuda, DeviceLocation::Cuda { gpu_id: 0 });
485        assert_eq!(load_tier_for(Some(&lookup), &cuda), LOAD_TIER_SATURATED);
486    }
487
488    // ===== DeviceLoadSelector =====
489
490    /// No lookup ⇒ the standalone selector picks the winner (arm 0) — the
491    /// no-signal degenerate.
492    #[test]
493    fn selector_no_signal_picks_winner() {
494        let mut set = AlternativeSet::empty();
495        set.push(make_candidate(
496            BackendId::Cuda,
497            DeviceLocation::Cuda { gpu_id: 0 },
498        ));
499        set.push(make_candidate(
500            BackendId::Vulkan,
501            DeviceLocation::Vulkan { gpu_id: 0 },
502        ));
503        let sel = DeviceLoadSelector::new(None);
504        assert_eq!(
505            sel.select(&set).expect("non-empty").backend,
506            BackendId::Cuda
507        );
508    }
509
510    /// The headline mechanic in isolation: arm-0 (CUDA) is SATURATED, arm-1
511    /// (Vulkan) is idle ⇒ the load-only selector flips to the unloaded
512    /// arm-1.
513    #[test]
514    fn selector_flips_to_unloaded_arm() {
515        let mut set = AlternativeSet::empty();
516        set.push(make_candidate(
517            BackendId::Cuda,
518            DeviceLocation::Cuda { gpu_id: 0 },
519        ));
520        set.push(make_candidate(
521            BackendId::Vulkan,
522            DeviceLocation::Vulkan { gpu_id: 0 },
523        ));
524
525        // CUDA saturated (4 in flight, 1 slot), Vulkan idle (0 in flight).
526        let lookup = mock_load_lookup(vec![
527            (BackendId::Cuda, Some(4), 1),
528            (BackendId::Vulkan, Some(0), 1),
529        ]);
530        let sel = DeviceLoadSelector::new(Some(lookup));
531        assert_eq!(
532            sel.select(&set).expect("non-empty").backend,
533            BackendId::Vulkan,
534            "saturated arm-0 demoted below idle arm-1",
535        );
536    }
537
538    /// Equal load ⇒ the static winner (arm 0) breaks the tie — determinism.
539    #[test]
540    fn selector_equal_load_keeps_winner() {
541        let mut set = AlternativeSet::empty();
542        set.push(make_candidate(
543            BackendId::Cuda,
544            DeviceLocation::Cuda { gpu_id: 0 },
545        ));
546        set.push(make_candidate(
547            BackendId::Vulkan,
548            DeviceLocation::Vulkan { gpu_id: 0 },
549        ));
550
551        // Both saturated → same tier → lower index wins.
552        let lookup = mock_load_lookup(vec![
553            (BackendId::Cuda, Some(4), 1),
554            (BackendId::Vulkan, Some(4), 1),
555        ]);
556        let sel = DeviceLoadSelector::new(Some(lookup));
557        assert_eq!(
558            sel.select(&set).expect("non-empty").backend,
559            BackendId::Cuda
560        );
561    }
562
563    /// Empty set ⇒ None (trait contract).
564    #[test]
565    fn selector_empty_set_is_none() {
566        let sel = DeviceLoadSelector::new(None);
567        assert!(sel.select(&AlternativeSet::empty()).is_none());
568    }
569
570    /// Debug impl does not panic on the closure field.
571    #[test]
572    fn debug_does_not_panic() {
573        let sel = DeviceLoadSelector::new(Some(mock_load_lookup(vec![])));
574        assert!(format!("{sel:?}").contains("DeviceLoadSelector"));
575    }
576}