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::{AtomicBool, 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 poisoned: AtomicBool,
121 pub fatal_cuda_error: Arc<AtomicBool>,
124 pub fatal_cuda_shutdown: Arc<tokio::sync::Notify>,
125 pub degraded_until: RwLock<Option<Instant>>,
126 pub job_tx: std::sync::mpsc::SyncSender<GpuJob>,
127}
128
129#[derive(Debug)]
131pub struct ActiveGeneration {
132 pub model: String,
133 pub prompt_sha256: String,
134 pub started_at_unix_ms: u64,
135 pub started_at: Instant,
136}
137
138pub struct GpuJob {
140 pub id: String,
145 pub model: String,
146 pub request: mold_core::GenerateRequest,
147 pub completion_payload: crate::state::SseCompletionPayload,
148 pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::state::SseMessage>>,
149 pub result_tx: tokio::sync::oneshot::Sender<Result<crate::state::GenerationJobResult, String>>,
150 pub output_dir: Option<std::path::PathBuf>,
151 pub config: Arc<tokio::sync::RwLock<mold_core::Config>>,
152 pub metadata_db: Arc<Option<MetadataDb>>,
156 pub queue: crate::state::QueueHandle,
158 pub registry: crate::job_registry::SharedJobRegistry,
162 pub events: Arc<crate::events::EventBroadcaster>,
165}
166
167pub struct GpuPool {
169 pub workers: Vec<Arc<GpuWorker>>,
170}
171
172impl GpuWorker {
173 pub fn is_degraded(&self) -> bool {
182 if self.poisoned.load(Ordering::SeqCst) {
183 return true;
184 }
185 if self.consecutive_failures.load(Ordering::SeqCst) < 3 {
186 return false;
187 }
188 let cooldown_active = match *self.degraded_until.read().unwrap() {
189 Some(until) => Instant::now() < until,
190 None => false,
191 };
192 if !cooldown_active {
193 self.consecutive_failures.store(0, Ordering::SeqCst);
197 *self.degraded_until.write().unwrap() = None;
198 }
199 cooldown_active
200 }
201
202 pub fn status(&self) -> GpuWorkerStatus {
204 let active_gen = self.active_generation.read().unwrap();
205 let in_flight = self.in_flight.load(Ordering::SeqCst);
206 let loaded_model = active_gen.as_ref().map(|g| g.model.clone()).or_else(|| {
211 let cache = self.model_cache.lock().unwrap();
212 cache.active_model().map(|s| s.to_string())
213 });
214
215 let state = if self.is_degraded() {
216 GpuWorkerState::Degraded
217 } else if active_gen.is_some() || in_flight > 0 {
218 GpuWorkerState::Generating
219 } else {
220 GpuWorkerState::Idle
221 };
222
223 GpuWorkerStatus {
224 ordinal: self.gpu.ordinal,
225 name: self.gpu.name.clone(),
226 vram_total_bytes: self.gpu.total_vram_bytes,
227 vram_used_bytes: mold_inference::device::vram_in_use_bytes(self.gpu.ordinal),
228 loaded_model,
229 state,
230 }
231 }
232}
233
234impl GpuPool {
235 pub fn worker_by_ordinal(&self, ordinal: usize) -> Option<Arc<GpuWorker>> {
237 self.workers
238 .iter()
239 .find(|w| w.gpu.ordinal == ordinal)
240 .cloned()
241 }
242
243 pub fn resolve_explicit_placement_gpu(
250 &self,
251 placement: Option<&DevicePlacement>,
252 ) -> Result<Option<usize>, String> {
253 if self.workers.is_empty() {
254 return Ok(None);
255 }
256 let Some(placement) = placement else {
257 return Ok(None);
258 };
259
260 let ordinals = placement_gpu_ordinals(placement);
261 if ordinals.is_empty() {
262 return Ok(None);
263 }
264 if ordinals.len() > 1 {
265 let rendered = ordinals
266 .iter()
267 .map(|o| format!("gpu:{o}"))
268 .collect::<Vec<_>>()
269 .join(", ");
270 return Err(format!(
271 "multi-GPU worker mode only supports placement on one GPU ordinal per request; got {rendered}"
272 ));
273 }
274
275 let ordinal = *ordinals.iter().next().expect("checked non-empty");
276 if self.worker_by_ordinal(ordinal).is_none() {
277 let available = self
278 .workers
279 .iter()
280 .map(|w| w.gpu.ordinal.to_string())
281 .collect::<Vec<_>>()
282 .join(", ");
283 return Err(format!(
284 "gpu:{ordinal} is not available in this server's worker pool [{available}]"
285 ));
286 }
287 Ok(Some(ordinal))
288 }
289
290 pub fn find_loaded(&self, model_name: &str) -> Option<Arc<GpuWorker>> {
293 let mut candidates: Vec<_> = self
294 .workers
295 .iter()
296 .filter(|w| {
297 if w.is_degraded() {
298 return false;
299 }
300 let active_gen = w.active_generation.read().unwrap();
301 if active_gen.as_ref().is_some_and(|g| g.model == model_name) {
302 return true;
303 }
304 let cache = w.model_cache.lock().unwrap();
305 cache
306 .get(model_name)
307 .map(|e| e.residency == ModelResidency::Gpu)
308 .unwrap_or(false)
309 })
310 .collect();
311
312 candidates.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
313 candidates.into_iter().next().cloned()
314 }
315
316 pub fn select_worker(&self, model_name: &str, estimated_vram: u64) -> Option<Arc<GpuWorker>> {
323 self.select_worker_excluding(model_name, estimated_vram, &[])
324 }
325
326 pub fn select_worker_excluding(
329 &self,
330 model_name: &str,
331 estimated_vram: u64,
332 skip: &[usize],
333 ) -> Option<Arc<GpuWorker>> {
334 let eligible: Vec<&Arc<GpuWorker>> = self
335 .workers
336 .iter()
337 .filter(|w| !w.is_degraded() && !skip.contains(&w.gpu.ordinal))
338 .collect();
339
340 if eligible.is_empty() {
341 return None;
342 }
343
344 let mut loaded_idle: Vec<&Arc<GpuWorker>> = Vec::new();
346 let mut loaded_busy: Vec<&Arc<GpuWorker>> = Vec::new();
347 let mut idle_empty: Vec<&Arc<GpuWorker>> = Vec::new();
348 let mut other: Vec<&Arc<GpuWorker>> = Vec::new();
349
350 for w in &eligible {
351 let active_gen = w.active_generation.read().unwrap();
352 let active_model = active_gen.as_ref().map(|g| g.model.as_str());
353 let (has_model, has_any_loaded) = {
354 let cache = w.model_cache.lock().unwrap();
355 let has_model = active_model == Some(model_name)
356 || cache
357 .get(model_name)
358 .map(|e| e.residency == ModelResidency::Gpu)
359 .unwrap_or(false);
360 (
361 has_model,
362 active_model.is_some() || cache.active_model().is_some(),
363 )
364 };
365 let in_flight = w.in_flight.load(Ordering::SeqCst);
366 let is_busy = in_flight > 0 || active_model.is_some();
378
379 if has_model && !is_busy {
380 loaded_idle.push(w);
381 } else if has_model {
382 loaded_busy.push(w);
383 } else if !has_any_loaded && !is_busy {
384 idle_empty.push(w);
385 } else {
386 other.push(w);
387 }
388 }
389
390 if !loaded_idle.is_empty() {
392 loaded_idle.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
393 return loaded_idle.first().map(|w| (*w).clone());
394 }
395
396 idle_empty.sort_by_key(|w| w.gpu.total_vram_bytes);
402 if let Some(w) = idle_empty
403 .iter()
404 .find(|w| w.gpu.total_vram_bytes >= estimated_vram)
405 {
406 return Some((*w).clone());
407 }
408
409 if !loaded_busy.is_empty() {
413 loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
414 return loaded_busy.first().map(|w| (*w).clone());
415 }
416
417 if !idle_empty.is_empty() {
420 return idle_empty.last().map(|w| (*w).clone());
421 }
422
423 let mut busy = other;
427 busy.sort_by(|a, b| {
428 let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
429 let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
430 b_headroom.cmp(&a_headroom)
431 });
432 busy.first().map(|w| (*w).clone())
433 }
434
435 pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
437 self.workers.iter().map(|w| w.status()).collect()
438 }
439
440 pub fn worker_count(&self) -> usize {
442 self.workers.len()
443 }
444}
445
446fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
447 let mut ordinals = BTreeSet::new();
448 collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
449 if let Some(adv) = placement.advanced.as_ref() {
450 collect_gpu_ordinal(adv.transformer, &mut ordinals);
451 collect_gpu_ordinal(adv.vae, &mut ordinals);
452 if let Some(device) = adv.clip_l {
453 collect_gpu_ordinal(device, &mut ordinals);
454 }
455 if let Some(device) = adv.clip_g {
456 collect_gpu_ordinal(device, &mut ordinals);
457 }
458 if let Some(device) = adv.t5 {
459 collect_gpu_ordinal(device, &mut ordinals);
460 }
461 if let Some(device) = adv.qwen {
462 collect_gpu_ordinal(device, &mut ordinals);
463 }
464 }
465 ordinals
466}
467
468fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
469 if let DeviceRef::Gpu { ordinal } = device {
470 out.insert(ordinal);
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
479 use crate::model_cache::ModelCache;
480 use mold_core::types::AdvancedPlacement;
481 use mold_inference::shared_pool::SharedPool;
482
483 fn test_worker(
487 ordinal: usize,
488 total_vram_bytes: u64,
489 ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
490 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
491 let worker = Arc::new(GpuWorker {
492 gpu: DiscoveredGpu {
493 ordinal,
494 name: format!("test-gpu-{ordinal}"),
495 total_vram_bytes,
496 free_vram_bytes: total_vram_bytes,
497 },
498 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
499 active_generation: Arc::new(RwLock::new(None)),
500 model_load_lock: Arc::new(Mutex::new(())),
501 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
502 in_flight: AtomicUsize::new(0),
503 consecutive_failures: AtomicUsize::new(0),
504 poisoned: AtomicBool::new(false),
505 fatal_cuda_error: Arc::new(AtomicBool::new(false)),
506 fatal_cuda_shutdown: Arc::new(tokio::sync::Notify::new()),
507 degraded_until: RwLock::new(None),
508 job_tx,
509 });
510 (worker, job_rx)
511 }
512
513 #[test]
514 fn poisoned_worker_stays_degraded_after_cooldown_expiry() {
515 let (worker, _rx) = test_worker(0, 24_000_000_000);
516 worker.poisoned.store(true, Ordering::SeqCst);
517 worker.consecutive_failures.store(3, Ordering::SeqCst);
518 *worker.degraded_until.write().unwrap() = Some(Instant::now() - Duration::from_secs(1));
519
520 assert!(worker.is_degraded());
521 assert_eq!(worker.consecutive_failures.load(Ordering::SeqCst), 3);
522 assert!(worker.degraded_until.read().unwrap().is_some());
523 }
524
525 #[test]
532 fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
533 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
534 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
535
536 busy.in_flight.store(1, Ordering::SeqCst);
539
540 let pool = GpuPool {
541 workers: vec![busy.clone(), idle.clone()],
542 };
543
544 let picked = pool
545 .select_worker("some-small-model:q4", 6_000_000_000)
546 .expect("a worker should be selected");
547 assert_eq!(
548 picked.gpu.ordinal, 1,
549 "new job for an unloaded model must go to the truly idle GPU, \
550 not to the one whose cache momentarily looks empty because \
551 generation is in progress"
552 );
553 }
554
555 #[test]
559 fn select_worker_respects_active_generation_flag() {
560 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
561 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
562
563 *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
564 model: "big-model".to_string(),
565 prompt_sha256: String::new(),
566 started_at_unix_ms: 0,
567 started_at: Instant::now(),
568 });
569
570 let pool = GpuPool {
571 workers: vec![busy.clone(), idle.clone()],
572 };
573
574 let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
575 assert_eq!(picked.gpu.ordinal, 1);
576 }
577
578 #[test]
582 fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
583 let (big, _big_rx) = test_worker(0, 24_000_000_000);
584 let (small, _small_rx) = test_worker(1, 12_000_000_000);
585
586 let pool = GpuPool {
587 workers: vec![big.clone(), small.clone()],
588 };
589
590 let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
592 assert_eq!(picked.gpu.ordinal, 1);
593 }
594
595 #[test]
598 fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
599 let (a, _a_rx) = test_worker(0, 24_000_000_000);
600 let (b, _b_rx) = test_worker(1, 12_000_000_000);
601 a.in_flight.store(1, Ordering::SeqCst);
602 b.in_flight.store(1, Ordering::SeqCst);
603
604 let pool = GpuPool {
605 workers: vec![a.clone(), b.clone()],
606 };
607
608 let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
609 assert_eq!(picked.gpu.ordinal, 0);
611 }
612
613 #[test]
619 fn select_worker_spreads_same_model_to_idle_gpu_over_busy_warm_worker() {
620 let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
621 let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
622
623 warm_busy.in_flight.store(1, Ordering::SeqCst);
624 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
625 model: "flux-dev:q4".to_string(),
626 prompt_sha256: String::new(),
627 started_at_unix_ms: 0,
628 started_at: Instant::now(),
629 });
630
631 let pool = GpuPool {
632 workers: vec![warm_busy.clone(), cold_idle.clone()],
633 };
634
635 let picked = pool
636 .select_worker("flux-dev:q4", 6_000_000_000)
637 .expect("idle sibling should be preferred");
638 assert_eq!(
639 picked.gpu.ordinal, 1,
640 "same-model job must run in parallel on the idle GPU, not queue \
641 behind the busy warm one"
642 );
643 }
644
645 #[test]
649 fn select_worker_queues_behind_warm_worker_when_no_idle_gpu_fits() {
650 let (warm_busy, _warm_busy_rx) = test_worker(0, 48_000_000_000);
651 let (small_idle, _small_idle_rx) = test_worker(1, 12_000_000_000);
652
653 warm_busy.in_flight.store(1, Ordering::SeqCst);
654 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
655 model: "ltx-2.3-22b-dev:fp8".to_string(),
656 prompt_sha256: String::new(),
657 started_at_unix_ms: 0,
658 started_at: Instant::now(),
659 });
660
661 let pool = GpuPool {
662 workers: vec![warm_busy.clone(), small_idle.clone()],
663 };
664
665 let picked = pool
666 .select_worker("ltx-2.3-22b-dev:fp8", 34_000_000_000)
667 .expect("a worker should be selected");
668 assert_eq!(
669 picked.gpu.ordinal, 0,
670 "a too-small idle GPU must not win over the warm busy worker"
671 );
672 }
673
674 #[test]
675 fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
676 let (worker, _rx) = test_worker(1, 24_000_000_000);
677 let pool = GpuPool {
678 workers: vec![worker],
679 };
680 let placement = DevicePlacement {
681 text_encoders: DeviceRef::Auto,
682 advanced: Some(AdvancedPlacement {
683 transformer: DeviceRef::gpu(1),
684 ..AdvancedPlacement::default()
685 }),
686 };
687
688 assert_eq!(
689 pool.resolve_explicit_placement_gpu(Some(&placement))
690 .unwrap(),
691 Some(1)
692 );
693 }
694
695 #[test]
696 fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
697 let (worker0, _rx0) = test_worker(0, 24_000_000_000);
698 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
699 let pool = GpuPool {
700 workers: vec![worker0, worker1],
701 };
702 let placement = DevicePlacement {
703 text_encoders: DeviceRef::gpu(0),
704 advanced: Some(AdvancedPlacement {
705 transformer: DeviceRef::gpu(1),
706 ..AdvancedPlacement::default()
707 }),
708 };
709
710 let err = pool
711 .resolve_explicit_placement_gpu(Some(&placement))
712 .unwrap_err();
713 assert!(err.contains("one GPU ordinal per request"), "{err}");
714 }
715
716 #[test]
717 fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
718 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
719 let pool = GpuPool {
720 workers: vec![worker1],
721 };
722 let placement = DevicePlacement {
723 text_encoders: DeviceRef::Auto,
724 advanced: Some(AdvancedPlacement {
725 transformer: DeviceRef::gpu(0),
726 ..AdvancedPlacement::default()
727 }),
728 };
729
730 let err = pool
731 .resolve_explicit_placement_gpu(Some(&placement))
732 .unwrap_err();
733 assert!(err.contains("gpu:0"), "{err}");
734 assert!(err.contains("[1]"), "{err}");
735 }
736
737 #[test]
743 fn is_degraded_clears_counter_when_cooldown_has_expired() {
744 let (worker, _rx) = test_worker(0, 24_000_000_000);
745 worker.consecutive_failures.store(3, Ordering::SeqCst);
746 *worker.degraded_until.write().unwrap() =
748 Some(Instant::now() - std::time::Duration::from_secs(1));
749
750 assert!(
751 !worker.is_degraded(),
752 "expired cooldown must mark the worker as healthy again",
753 );
754 assert_eq!(
755 worker.consecutive_failures.load(Ordering::SeqCst),
756 0,
757 "expired cooldown must lazy-reset the failure counter so a \
758 single post-cooldown failure doesn't immediately re-degrade",
759 );
760 assert!(
761 worker.degraded_until.read().unwrap().is_none(),
762 "expired cooldown must clear the timestamp",
763 );
764 }
765
766 #[test]
767 fn is_degraded_respects_active_cooldown() {
768 let (worker, _rx) = test_worker(0, 24_000_000_000);
769 worker.consecutive_failures.store(3, Ordering::SeqCst);
770 *worker.degraded_until.write().unwrap() =
772 Some(Instant::now() + std::time::Duration::from_secs(60));
773
774 assert!(
775 worker.is_degraded(),
776 "active cooldown must keep the worker degraded",
777 );
778 assert_eq!(
779 worker.consecutive_failures.load(Ordering::SeqCst),
780 3,
781 "active cooldown must NOT reset the counter",
782 );
783 }
784
785 #[test]
786 fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
787 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
788 clear_model_cuda_ooms_for_tests();
789 let model = "flux2-klein-9b:bf16";
790
791 let first = record_model_cuda_oom(model, 0);
792 assert!(
793 !first.is_unschedulable(),
794 "first OOM only records the failed ordinal"
795 );
796 assert!(
797 model_unschedulable_message(model).is_none(),
798 "a single-GPU OOM should not cool down the model yet"
799 );
800
801 let second = record_model_cuda_oom(model, 1);
802 assert!(
803 second.is_unschedulable(),
804 "OOM on a sibling GPU should mark the model unschedulable"
805 );
806 let msg = model_unschedulable_message(model).expect("cooldown message");
807 assert!(msg.contains(model), "{msg}");
808 assert!(msg.contains("temporarily unschedulable"), "{msg}");
809
810 clear_model_cuda_ooms_for_tests();
811 }
812
813 #[test]
814 fn failed_model_ordinals_can_be_skipped_before_cooldown() {
815 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
816 clear_model_cuda_ooms_for_tests();
817 let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
818 let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
819 let pool = GpuPool {
820 workers: vec![failed, untested.clone()],
821 };
822 let model = "flux2-klein-9b:bf16";
823
824 record_model_cuda_oom(model, 0);
825 let skip = failed_ordinals_for_model(model);
826 let picked = pool
827 .select_worker_excluding(model, 32_000_000_000, &skip)
828 .expect("sibling GPU should be tried before cooldown");
829
830 assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
831 clear_model_cuda_ooms_for_tests();
832 }
833}