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}