1use crate::model_cache::{ModelCache, ModelResidency};
2use mold_core::types::{DevicePlacement, DeviceRef, GpuWorkerState, GpuWorkerStatus};
3use mold_db::MetadataDb;
4use mold_inference::device::DiscoveredGpu;
5use mold_inference::shared_pool::SharedPool;
6use std::collections::{BTreeSet, HashMap};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::{Arc, LazyLock, Mutex, RwLock};
9use std::time::{Duration, Instant};
10
11const MODEL_CUDA_OOM_COOLDOWN: Duration = Duration::from_secs(60);
12
13#[derive(Debug, Default)]
14struct ModelCudaOomState {
15 failed_ordinals: BTreeSet<usize>,
16 unschedulable_until: Option<Instant>,
17}
18
19static MODEL_CUDA_OOMS: LazyLock<RwLock<HashMap<String, ModelCudaOomState>>> =
20 LazyLock::new(|| RwLock::new(HashMap::new()));
21
22#[derive(Debug, Clone)]
23pub(crate) struct ModelCudaOomOutcome {
24 unschedulable_until: Option<Instant>,
25}
26
27impl ModelCudaOomOutcome {
28 pub(crate) fn is_unschedulable(&self) -> bool {
29 self.unschedulable_until
30 .is_some_and(|until| Instant::now() < until)
31 }
32}
33
34pub(crate) fn record_model_cuda_oom(model_name: &str, ordinal: usize) -> ModelCudaOomOutcome {
35 let now = Instant::now();
36 let mut states = MODEL_CUDA_OOMS.write().unwrap();
37 let state = states.entry(model_name.to_string()).or_default();
38
39 if let Some(until) = state.unschedulable_until {
40 if now < until {
41 return ModelCudaOomOutcome {
42 unschedulable_until: Some(until),
43 };
44 }
45 state.unschedulable_until = None;
46 state.failed_ordinals.clear();
47 }
48
49 state.failed_ordinals.insert(ordinal);
50 let unschedulable_until = if state.failed_ordinals.len() >= 2 {
51 let until = now + MODEL_CUDA_OOM_COOLDOWN;
52 state.unschedulable_until = Some(until);
53 tracing::warn!(
54 model = %model_name,
55 failed_gpus = ?state.failed_ordinals,
56 cooldown_secs = MODEL_CUDA_OOM_COOLDOWN.as_secs(),
57 "model marked temporarily unschedulable after CUDA OOM on multiple GPUs"
58 );
59 Some(until)
60 } else {
61 None
62 };
63
64 ModelCudaOomOutcome {
65 unschedulable_until,
66 }
67}
68
69pub(crate) fn model_unschedulable_message(model_name: &str) -> Option<String> {
70 let now = Instant::now();
71 let mut states = MODEL_CUDA_OOMS.write().unwrap();
72 let state = states.get_mut(model_name)?;
73 let until = state.unschedulable_until?;
74 if now >= until {
75 states.remove(model_name);
76 return None;
77 }
78 let remaining = until.saturating_duration_since(now).as_secs().max(1);
79 Some(format!(
80 "model '{model_name}' is temporarily unschedulable after CUDA OOM on multiple GPUs; \
81 retry in {remaining}s or use a quantized/smaller variant."
82 ))
83}
84
85pub(crate) fn failed_ordinals_for_model(model_name: &str) -> Vec<usize> {
86 let now = Instant::now();
87 let mut states = MODEL_CUDA_OOMS.write().unwrap();
88 let Some(state) = states.get_mut(model_name) else {
89 return Vec::new();
90 };
91 if let Some(until) = state.unschedulable_until {
92 if now >= until {
93 states.remove(model_name);
94 }
95 return Vec::new();
96 }
97 state.failed_ordinals.iter().copied().collect()
98}
99
100pub(crate) fn clear_model_cuda_oom(model_name: &str) {
101 MODEL_CUDA_OOMS.write().unwrap().remove(model_name);
102}
103
104#[cfg(test)]
105pub(crate) fn clear_model_cuda_ooms_for_tests() {
106 MODEL_CUDA_OOMS.write().unwrap().clear();
107}
108
109pub struct GpuWorker {
111 pub gpu: DiscoveredGpu,
112 pub model_cache: Arc<Mutex<ModelCache>>,
113 pub active_generation: Arc<RwLock<Option<ActiveGeneration>>>,
114 pub model_load_lock: Arc<Mutex<()>>,
115 pub shared_pool: Arc<Mutex<SharedPool>>,
116 pub in_flight: AtomicUsize,
117 pub consecutive_failures: AtomicUsize,
118 pub degraded_until: RwLock<Option<Instant>>,
119 pub job_tx: std::sync::mpsc::SyncSender<GpuJob>,
120}
121
122#[derive(Debug)]
124pub struct ActiveGeneration {
125 pub model: String,
126 pub prompt_sha256: String,
127 pub started_at_unix_ms: u64,
128 pub started_at: Instant,
129}
130
131pub struct GpuJob {
133 pub id: String,
138 pub model: String,
139 pub request: mold_core::GenerateRequest,
140 pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::state::SseMessage>>,
141 pub result_tx: tokio::sync::oneshot::Sender<Result<crate::state::GenerationJobResult, String>>,
142 pub output_dir: Option<std::path::PathBuf>,
143 pub config: Arc<tokio::sync::RwLock<mold_core::Config>>,
144 pub metadata_db: Arc<Option<MetadataDb>>,
148 pub queue: crate::state::QueueHandle,
150 pub registry: crate::job_registry::SharedJobRegistry,
154 pub events: Arc<crate::events::EventBroadcaster>,
157}
158
159pub struct GpuPool {
161 pub workers: Vec<Arc<GpuWorker>>,
162}
163
164impl GpuWorker {
165 pub fn is_degraded(&self) -> bool {
174 if self.consecutive_failures.load(Ordering::SeqCst) < 3 {
175 return false;
176 }
177 let cooldown_active = match *self.degraded_until.read().unwrap() {
178 Some(until) => Instant::now() < until,
179 None => false,
180 };
181 if !cooldown_active {
182 self.consecutive_failures.store(0, Ordering::SeqCst);
186 *self.degraded_until.write().unwrap() = None;
187 }
188 cooldown_active
189 }
190
191 pub fn status(&self) -> GpuWorkerStatus {
193 let active_gen = self.active_generation.read().unwrap();
194 let in_flight = self.in_flight.load(Ordering::SeqCst);
195 let loaded_model = active_gen.as_ref().map(|g| g.model.clone()).or_else(|| {
200 let cache = self.model_cache.lock().unwrap();
201 cache.active_model().map(|s| s.to_string())
202 });
203
204 let state = if self.is_degraded() {
205 GpuWorkerState::Degraded
206 } else if active_gen.is_some() || in_flight > 0 {
207 GpuWorkerState::Generating
208 } else {
209 GpuWorkerState::Idle
210 };
211
212 GpuWorkerStatus {
213 ordinal: self.gpu.ordinal,
214 name: self.gpu.name.clone(),
215 vram_total_bytes: self.gpu.total_vram_bytes,
216 vram_used_bytes: mold_inference::device::vram_in_use_bytes(self.gpu.ordinal),
217 loaded_model,
218 state,
219 }
220 }
221}
222
223impl GpuPool {
224 pub fn worker_by_ordinal(&self, ordinal: usize) -> Option<Arc<GpuWorker>> {
226 self.workers
227 .iter()
228 .find(|w| w.gpu.ordinal == ordinal)
229 .cloned()
230 }
231
232 pub fn resolve_explicit_placement_gpu(
239 &self,
240 placement: Option<&DevicePlacement>,
241 ) -> Result<Option<usize>, String> {
242 if self.workers.is_empty() {
243 return Ok(None);
244 }
245 let Some(placement) = placement else {
246 return Ok(None);
247 };
248
249 let ordinals = placement_gpu_ordinals(placement);
250 if ordinals.is_empty() {
251 return Ok(None);
252 }
253 if ordinals.len() > 1 {
254 let rendered = ordinals
255 .iter()
256 .map(|o| format!("gpu:{o}"))
257 .collect::<Vec<_>>()
258 .join(", ");
259 return Err(format!(
260 "multi-GPU worker mode only supports placement on one GPU ordinal per request; got {rendered}"
261 ));
262 }
263
264 let ordinal = *ordinals.iter().next().expect("checked non-empty");
265 if self.worker_by_ordinal(ordinal).is_none() {
266 let available = self
267 .workers
268 .iter()
269 .map(|w| w.gpu.ordinal.to_string())
270 .collect::<Vec<_>>()
271 .join(", ");
272 return Err(format!(
273 "gpu:{ordinal} is not available in this server's worker pool [{available}]"
274 ));
275 }
276 Ok(Some(ordinal))
277 }
278
279 pub fn find_loaded(&self, model_name: &str) -> Option<Arc<GpuWorker>> {
282 let mut candidates: Vec<_> = self
283 .workers
284 .iter()
285 .filter(|w| {
286 if w.is_degraded() {
287 return false;
288 }
289 let active_gen = w.active_generation.read().unwrap();
290 if active_gen.as_ref().is_some_and(|g| g.model == model_name) {
291 return true;
292 }
293 let cache = w.model_cache.lock().unwrap();
294 cache
295 .get(model_name)
296 .map(|e| e.residency == ModelResidency::Gpu)
297 .unwrap_or(false)
298 })
299 .collect();
300
301 candidates.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
302 candidates.into_iter().next().cloned()
303 }
304
305 pub fn select_worker(&self, model_name: &str, estimated_vram: u64) -> Option<Arc<GpuWorker>> {
312 self.select_worker_excluding(model_name, estimated_vram, &[])
313 }
314
315 pub fn select_worker_excluding(
318 &self,
319 model_name: &str,
320 estimated_vram: u64,
321 skip: &[usize],
322 ) -> Option<Arc<GpuWorker>> {
323 let eligible: Vec<&Arc<GpuWorker>> = self
324 .workers
325 .iter()
326 .filter(|w| !w.is_degraded() && !skip.contains(&w.gpu.ordinal))
327 .collect();
328
329 if eligible.is_empty() {
330 return None;
331 }
332
333 let mut loaded_idle: Vec<&Arc<GpuWorker>> = Vec::new();
335 let mut loaded_busy: Vec<&Arc<GpuWorker>> = Vec::new();
336 let mut idle_empty: Vec<&Arc<GpuWorker>> = Vec::new();
337 let mut other: Vec<&Arc<GpuWorker>> = Vec::new();
338
339 for w in &eligible {
340 let active_gen = w.active_generation.read().unwrap();
341 let active_model = active_gen.as_ref().map(|g| g.model.as_str());
342 let (has_model, has_any_loaded) = {
343 let cache = w.model_cache.lock().unwrap();
344 let has_model = active_model == Some(model_name)
345 || cache
346 .get(model_name)
347 .map(|e| e.residency == ModelResidency::Gpu)
348 .unwrap_or(false);
349 (
350 has_model,
351 active_model.is_some() || cache.active_model().is_some(),
352 )
353 };
354 let in_flight = w.in_flight.load(Ordering::SeqCst);
355 let is_busy = in_flight > 0 || active_model.is_some();
367
368 if has_model && !is_busy {
369 loaded_idle.push(w);
370 } else if has_model {
371 loaded_busy.push(w);
372 } else if !has_any_loaded && !is_busy {
373 idle_empty.push(w);
374 } else {
375 other.push(w);
376 }
377 }
378
379 if !loaded_idle.is_empty() {
381 loaded_idle.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
382 return loaded_idle.first().map(|w| (*w).clone());
383 }
384
385 idle_empty.sort_by_key(|w| w.gpu.total_vram_bytes);
391 if let Some(w) = idle_empty
392 .iter()
393 .find(|w| w.gpu.total_vram_bytes >= estimated_vram)
394 {
395 return Some((*w).clone());
396 }
397
398 if !loaded_busy.is_empty() {
402 loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
403 return loaded_busy.first().map(|w| (*w).clone());
404 }
405
406 if !idle_empty.is_empty() {
409 return idle_empty.last().map(|w| (*w).clone());
410 }
411
412 let mut busy = other;
416 busy.sort_by(|a, b| {
417 let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
418 let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
419 b_headroom.cmp(&a_headroom)
420 });
421 busy.first().map(|w| (*w).clone())
422 }
423
424 pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
426 self.workers.iter().map(|w| w.status()).collect()
427 }
428
429 pub fn worker_count(&self) -> usize {
431 self.workers.len()
432 }
433}
434
435fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
436 let mut ordinals = BTreeSet::new();
437 collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
438 if let Some(adv) = placement.advanced.as_ref() {
439 collect_gpu_ordinal(adv.transformer, &mut ordinals);
440 collect_gpu_ordinal(adv.vae, &mut ordinals);
441 if let Some(device) = adv.clip_l {
442 collect_gpu_ordinal(device, &mut ordinals);
443 }
444 if let Some(device) = adv.clip_g {
445 collect_gpu_ordinal(device, &mut ordinals);
446 }
447 if let Some(device) = adv.t5 {
448 collect_gpu_ordinal(device, &mut ordinals);
449 }
450 if let Some(device) = adv.qwen {
451 collect_gpu_ordinal(device, &mut ordinals);
452 }
453 }
454 ordinals
455}
456
457fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
458 if let DeviceRef::Gpu { ordinal } = device {
459 out.insert(ordinal);
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
468 use crate::model_cache::ModelCache;
469 use mold_core::types::AdvancedPlacement;
470 use mold_inference::shared_pool::SharedPool;
471
472 fn test_worker(
476 ordinal: usize,
477 total_vram_bytes: u64,
478 ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
479 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
480 let worker = Arc::new(GpuWorker {
481 gpu: DiscoveredGpu {
482 ordinal,
483 name: format!("test-gpu-{ordinal}"),
484 total_vram_bytes,
485 free_vram_bytes: total_vram_bytes,
486 },
487 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
488 active_generation: Arc::new(RwLock::new(None)),
489 model_load_lock: Arc::new(Mutex::new(())),
490 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
491 in_flight: AtomicUsize::new(0),
492 consecutive_failures: AtomicUsize::new(0),
493 degraded_until: RwLock::new(None),
494 job_tx,
495 });
496 (worker, job_rx)
497 }
498
499 #[test]
506 fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
507 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
508 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
509
510 busy.in_flight.store(1, Ordering::SeqCst);
513
514 let pool = GpuPool {
515 workers: vec![busy.clone(), idle.clone()],
516 };
517
518 let picked = pool
519 .select_worker("some-small-model:q4", 6_000_000_000)
520 .expect("a worker should be selected");
521 assert_eq!(
522 picked.gpu.ordinal, 1,
523 "new job for an unloaded model must go to the truly idle GPU, \
524 not to the one whose cache momentarily looks empty because \
525 generation is in progress"
526 );
527 }
528
529 #[test]
533 fn select_worker_respects_active_generation_flag() {
534 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
535 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
536
537 *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
538 model: "big-model".to_string(),
539 prompt_sha256: String::new(),
540 started_at_unix_ms: 0,
541 started_at: Instant::now(),
542 });
543
544 let pool = GpuPool {
545 workers: vec![busy.clone(), idle.clone()],
546 };
547
548 let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
549 assert_eq!(picked.gpu.ordinal, 1);
550 }
551
552 #[test]
556 fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
557 let (big, _big_rx) = test_worker(0, 24_000_000_000);
558 let (small, _small_rx) = test_worker(1, 12_000_000_000);
559
560 let pool = GpuPool {
561 workers: vec![big.clone(), small.clone()],
562 };
563
564 let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
566 assert_eq!(picked.gpu.ordinal, 1);
567 }
568
569 #[test]
572 fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
573 let (a, _a_rx) = test_worker(0, 24_000_000_000);
574 let (b, _b_rx) = test_worker(1, 12_000_000_000);
575 a.in_flight.store(1, Ordering::SeqCst);
576 b.in_flight.store(1, Ordering::SeqCst);
577
578 let pool = GpuPool {
579 workers: vec![a.clone(), b.clone()],
580 };
581
582 let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
583 assert_eq!(picked.gpu.ordinal, 0);
585 }
586
587 #[test]
593 fn select_worker_spreads_same_model_to_idle_gpu_over_busy_warm_worker() {
594 let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
595 let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
596
597 warm_busy.in_flight.store(1, Ordering::SeqCst);
598 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
599 model: "flux-dev:q4".to_string(),
600 prompt_sha256: String::new(),
601 started_at_unix_ms: 0,
602 started_at: Instant::now(),
603 });
604
605 let pool = GpuPool {
606 workers: vec![warm_busy.clone(), cold_idle.clone()],
607 };
608
609 let picked = pool
610 .select_worker("flux-dev:q4", 6_000_000_000)
611 .expect("idle sibling should be preferred");
612 assert_eq!(
613 picked.gpu.ordinal, 1,
614 "same-model job must run in parallel on the idle GPU, not queue \
615 behind the busy warm one"
616 );
617 }
618
619 #[test]
623 fn select_worker_queues_behind_warm_worker_when_no_idle_gpu_fits() {
624 let (warm_busy, _warm_busy_rx) = test_worker(0, 48_000_000_000);
625 let (small_idle, _small_idle_rx) = test_worker(1, 12_000_000_000);
626
627 warm_busy.in_flight.store(1, Ordering::SeqCst);
628 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
629 model: "ltx-2.3-22b-dev:fp8".to_string(),
630 prompt_sha256: String::new(),
631 started_at_unix_ms: 0,
632 started_at: Instant::now(),
633 });
634
635 let pool = GpuPool {
636 workers: vec![warm_busy.clone(), small_idle.clone()],
637 };
638
639 let picked = pool
640 .select_worker("ltx-2.3-22b-dev:fp8", 34_000_000_000)
641 .expect("a worker should be selected");
642 assert_eq!(
643 picked.gpu.ordinal, 0,
644 "a too-small idle GPU must not win over the warm busy worker"
645 );
646 }
647
648 #[test]
649 fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
650 let (worker, _rx) = test_worker(1, 24_000_000_000);
651 let pool = GpuPool {
652 workers: vec![worker],
653 };
654 let placement = DevicePlacement {
655 text_encoders: DeviceRef::Auto,
656 advanced: Some(AdvancedPlacement {
657 transformer: DeviceRef::gpu(1),
658 ..AdvancedPlacement::default()
659 }),
660 };
661
662 assert_eq!(
663 pool.resolve_explicit_placement_gpu(Some(&placement))
664 .unwrap(),
665 Some(1)
666 );
667 }
668
669 #[test]
670 fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
671 let (worker0, _rx0) = test_worker(0, 24_000_000_000);
672 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
673 let pool = GpuPool {
674 workers: vec![worker0, worker1],
675 };
676 let placement = DevicePlacement {
677 text_encoders: DeviceRef::gpu(0),
678 advanced: Some(AdvancedPlacement {
679 transformer: DeviceRef::gpu(1),
680 ..AdvancedPlacement::default()
681 }),
682 };
683
684 let err = pool
685 .resolve_explicit_placement_gpu(Some(&placement))
686 .unwrap_err();
687 assert!(err.contains("one GPU ordinal per request"), "{err}");
688 }
689
690 #[test]
691 fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
692 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
693 let pool = GpuPool {
694 workers: vec![worker1],
695 };
696 let placement = DevicePlacement {
697 text_encoders: DeviceRef::Auto,
698 advanced: Some(AdvancedPlacement {
699 transformer: DeviceRef::gpu(0),
700 ..AdvancedPlacement::default()
701 }),
702 };
703
704 let err = pool
705 .resolve_explicit_placement_gpu(Some(&placement))
706 .unwrap_err();
707 assert!(err.contains("gpu:0"), "{err}");
708 assert!(err.contains("[1]"), "{err}");
709 }
710
711 #[test]
717 fn is_degraded_clears_counter_when_cooldown_has_expired() {
718 let (worker, _rx) = test_worker(0, 24_000_000_000);
719 worker.consecutive_failures.store(3, Ordering::SeqCst);
720 *worker.degraded_until.write().unwrap() =
722 Some(Instant::now() - std::time::Duration::from_secs(1));
723
724 assert!(
725 !worker.is_degraded(),
726 "expired cooldown must mark the worker as healthy again",
727 );
728 assert_eq!(
729 worker.consecutive_failures.load(Ordering::SeqCst),
730 0,
731 "expired cooldown must lazy-reset the failure counter so a \
732 single post-cooldown failure doesn't immediately re-degrade",
733 );
734 assert!(
735 worker.degraded_until.read().unwrap().is_none(),
736 "expired cooldown must clear the timestamp",
737 );
738 }
739
740 #[test]
741 fn is_degraded_respects_active_cooldown() {
742 let (worker, _rx) = test_worker(0, 24_000_000_000);
743 worker.consecutive_failures.store(3, Ordering::SeqCst);
744 *worker.degraded_until.write().unwrap() =
746 Some(Instant::now() + std::time::Duration::from_secs(60));
747
748 assert!(
749 worker.is_degraded(),
750 "active cooldown must keep the worker degraded",
751 );
752 assert_eq!(
753 worker.consecutive_failures.load(Ordering::SeqCst),
754 3,
755 "active cooldown must NOT reset the counter",
756 );
757 }
758
759 #[test]
760 fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
761 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
762 clear_model_cuda_ooms_for_tests();
763 let model = "flux2-klein-9b:bf16";
764
765 let first = record_model_cuda_oom(model, 0);
766 assert!(
767 !first.is_unschedulable(),
768 "first OOM only records the failed ordinal"
769 );
770 assert!(
771 model_unschedulable_message(model).is_none(),
772 "a single-GPU OOM should not cool down the model yet"
773 );
774
775 let second = record_model_cuda_oom(model, 1);
776 assert!(
777 second.is_unschedulable(),
778 "OOM on a sibling GPU should mark the model unschedulable"
779 );
780 let msg = model_unschedulable_message(model).expect("cooldown message");
781 assert!(msg.contains(model), "{msg}");
782 assert!(msg.contains("temporarily unschedulable"), "{msg}");
783
784 clear_model_cuda_ooms_for_tests();
785 }
786
787 #[test]
788 fn failed_model_ordinals_can_be_skipped_before_cooldown() {
789 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
790 clear_model_cuda_ooms_for_tests();
791 let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
792 let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
793 let pool = GpuPool {
794 workers: vec![failed, untested.clone()],
795 };
796 let model = "flux2-klein-9b:bf16";
797
798 record_model_cuda_oom(model, 0);
799 let skip = failed_ordinals_for_model(model);
800 let picked = pool
801 .select_worker_excluding(model, 32_000_000_000, &skip)
802 .expect("sibling GPU should be tried before cooldown");
803
804 assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
805 clear_model_cuda_ooms_for_tests();
806 }
807}