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}
155
156pub struct GpuPool {
158 pub workers: Vec<Arc<GpuWorker>>,
159}
160
161impl GpuWorker {
162 pub fn is_degraded(&self) -> bool {
171 if self.consecutive_failures.load(Ordering::SeqCst) < 3 {
172 return false;
173 }
174 let cooldown_active = match *self.degraded_until.read().unwrap() {
175 Some(until) => Instant::now() < until,
176 None => false,
177 };
178 if !cooldown_active {
179 self.consecutive_failures.store(0, Ordering::SeqCst);
183 *self.degraded_until.write().unwrap() = None;
184 }
185 cooldown_active
186 }
187
188 pub fn status(&self) -> GpuWorkerStatus {
190 let active_gen = self.active_generation.read().unwrap();
191 let in_flight = self.in_flight.load(Ordering::SeqCst);
192 let loaded_model = active_gen.as_ref().map(|g| g.model.clone()).or_else(|| {
197 let cache = self.model_cache.lock().unwrap();
198 cache.active_model().map(|s| s.to_string())
199 });
200
201 let state = if self.is_degraded() {
202 GpuWorkerState::Degraded
203 } else if active_gen.is_some() || in_flight > 0 {
204 GpuWorkerState::Generating
205 } else {
206 GpuWorkerState::Idle
207 };
208
209 GpuWorkerStatus {
210 ordinal: self.gpu.ordinal,
211 name: self.gpu.name.clone(),
212 vram_total_bytes: self.gpu.total_vram_bytes,
213 vram_used_bytes: mold_inference::device::vram_in_use_bytes(self.gpu.ordinal),
214 loaded_model,
215 state,
216 }
217 }
218}
219
220impl GpuPool {
221 pub fn worker_by_ordinal(&self, ordinal: usize) -> Option<Arc<GpuWorker>> {
223 self.workers
224 .iter()
225 .find(|w| w.gpu.ordinal == ordinal)
226 .cloned()
227 }
228
229 pub fn resolve_explicit_placement_gpu(
236 &self,
237 placement: Option<&DevicePlacement>,
238 ) -> Result<Option<usize>, String> {
239 if self.workers.is_empty() {
240 return Ok(None);
241 }
242 let Some(placement) = placement else {
243 return Ok(None);
244 };
245
246 let ordinals = placement_gpu_ordinals(placement);
247 if ordinals.is_empty() {
248 return Ok(None);
249 }
250 if ordinals.len() > 1 {
251 let rendered = ordinals
252 .iter()
253 .map(|o| format!("gpu:{o}"))
254 .collect::<Vec<_>>()
255 .join(", ");
256 return Err(format!(
257 "multi-GPU worker mode only supports placement on one GPU ordinal per request; got {rendered}"
258 ));
259 }
260
261 let ordinal = *ordinals.iter().next().expect("checked non-empty");
262 if self.worker_by_ordinal(ordinal).is_none() {
263 let available = self
264 .workers
265 .iter()
266 .map(|w| w.gpu.ordinal.to_string())
267 .collect::<Vec<_>>()
268 .join(", ");
269 return Err(format!(
270 "gpu:{ordinal} is not available in this server's worker pool [{available}]"
271 ));
272 }
273 Ok(Some(ordinal))
274 }
275
276 pub fn find_loaded(&self, model_name: &str) -> Option<Arc<GpuWorker>> {
279 let mut candidates: Vec<_> = self
280 .workers
281 .iter()
282 .filter(|w| {
283 if w.is_degraded() {
284 return false;
285 }
286 let active_gen = w.active_generation.read().unwrap();
287 if active_gen.as_ref().is_some_and(|g| g.model == model_name) {
288 return true;
289 }
290 let cache = w.model_cache.lock().unwrap();
291 cache
292 .get(model_name)
293 .map(|e| e.residency == ModelResidency::Gpu)
294 .unwrap_or(false)
295 })
296 .collect();
297
298 candidates.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
299 candidates.into_iter().next().cloned()
300 }
301
302 pub fn select_worker(&self, model_name: &str, estimated_vram: u64) -> Option<Arc<GpuWorker>> {
309 self.select_worker_excluding(model_name, estimated_vram, &[])
310 }
311
312 pub fn select_worker_excluding(
315 &self,
316 model_name: &str,
317 estimated_vram: u64,
318 skip: &[usize],
319 ) -> Option<Arc<GpuWorker>> {
320 let eligible: Vec<&Arc<GpuWorker>> = self
321 .workers
322 .iter()
323 .filter(|w| !w.is_degraded() && !skip.contains(&w.gpu.ordinal))
324 .collect();
325
326 if eligible.is_empty() {
327 return None;
328 }
329
330 let mut loaded_idle: Vec<&Arc<GpuWorker>> = Vec::new();
332 let mut loaded_busy: Vec<&Arc<GpuWorker>> = Vec::new();
333 let mut idle_empty: Vec<&Arc<GpuWorker>> = Vec::new();
334 let mut other: Vec<&Arc<GpuWorker>> = Vec::new();
335
336 for w in &eligible {
337 let active_gen = w.active_generation.read().unwrap();
338 let active_model = active_gen.as_ref().map(|g| g.model.as_str());
339 let (has_model, has_any_loaded) = {
340 let cache = w.model_cache.lock().unwrap();
341 let has_model = active_model == Some(model_name)
342 || cache
343 .get(model_name)
344 .map(|e| e.residency == ModelResidency::Gpu)
345 .unwrap_or(false);
346 (
347 has_model,
348 active_model.is_some() || cache.active_model().is_some(),
349 )
350 };
351 let in_flight = w.in_flight.load(Ordering::SeqCst);
352 let is_busy = in_flight > 0 || active_model.is_some();
364
365 if has_model && !is_busy {
366 loaded_idle.push(w);
367 } else if has_model {
368 loaded_busy.push(w);
369 } else if !has_any_loaded && !is_busy {
370 idle_empty.push(w);
371 } else {
372 other.push(w);
373 }
374 }
375
376 if !loaded_idle.is_empty() {
378 loaded_idle.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
379 return loaded_idle.first().map(|w| (*w).clone());
380 }
381
382 if !loaded_busy.is_empty() {
384 loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
385 return loaded_busy.first().map(|w| (*w).clone());
386 }
387
388 if !idle_empty.is_empty() {
390 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 return idle_empty.last().map(|w| (*w).clone());
399 }
400
401 let mut busy = other;
403 busy.sort_by(|a, b| {
404 let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
405 let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
406 b_headroom.cmp(&a_headroom)
407 });
408 busy.first().map(|w| (*w).clone())
409 }
410
411 pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
413 self.workers.iter().map(|w| w.status()).collect()
414 }
415
416 pub fn worker_count(&self) -> usize {
418 self.workers.len()
419 }
420}
421
422fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
423 let mut ordinals = BTreeSet::new();
424 collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
425 if let Some(adv) = placement.advanced.as_ref() {
426 collect_gpu_ordinal(adv.transformer, &mut ordinals);
427 collect_gpu_ordinal(adv.vae, &mut ordinals);
428 if let Some(device) = adv.clip_l {
429 collect_gpu_ordinal(device, &mut ordinals);
430 }
431 if let Some(device) = adv.clip_g {
432 collect_gpu_ordinal(device, &mut ordinals);
433 }
434 if let Some(device) = adv.t5 {
435 collect_gpu_ordinal(device, &mut ordinals);
436 }
437 if let Some(device) = adv.qwen {
438 collect_gpu_ordinal(device, &mut ordinals);
439 }
440 }
441 ordinals
442}
443
444fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
445 if let DeviceRef::Gpu { ordinal } = device {
446 out.insert(ordinal);
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
455 use crate::model_cache::ModelCache;
456 use mold_core::types::AdvancedPlacement;
457 use mold_inference::shared_pool::SharedPool;
458
459 fn test_worker(
463 ordinal: usize,
464 total_vram_bytes: u64,
465 ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
466 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
467 let worker = Arc::new(GpuWorker {
468 gpu: DiscoveredGpu {
469 ordinal,
470 name: format!("test-gpu-{ordinal}"),
471 total_vram_bytes,
472 free_vram_bytes: total_vram_bytes,
473 },
474 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
475 active_generation: Arc::new(RwLock::new(None)),
476 model_load_lock: Arc::new(Mutex::new(())),
477 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
478 in_flight: AtomicUsize::new(0),
479 consecutive_failures: AtomicUsize::new(0),
480 degraded_until: RwLock::new(None),
481 job_tx,
482 });
483 (worker, job_rx)
484 }
485
486 #[test]
493 fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
494 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
495 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
496
497 busy.in_flight.store(1, Ordering::SeqCst);
500
501 let pool = GpuPool {
502 workers: vec![busy.clone(), idle.clone()],
503 };
504
505 let picked = pool
506 .select_worker("some-small-model:q4", 6_000_000_000)
507 .expect("a worker should be selected");
508 assert_eq!(
509 picked.gpu.ordinal, 1,
510 "new job for an unloaded model must go to the truly idle GPU, \
511 not to the one whose cache momentarily looks empty because \
512 generation is in progress"
513 );
514 }
515
516 #[test]
520 fn select_worker_respects_active_generation_flag() {
521 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
522 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
523
524 *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
525 model: "big-model".to_string(),
526 prompt_sha256: String::new(),
527 started_at_unix_ms: 0,
528 started_at: Instant::now(),
529 });
530
531 let pool = GpuPool {
532 workers: vec![busy.clone(), idle.clone()],
533 };
534
535 let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
536 assert_eq!(picked.gpu.ordinal, 1);
537 }
538
539 #[test]
543 fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
544 let (big, _big_rx) = test_worker(0, 24_000_000_000);
545 let (small, _small_rx) = test_worker(1, 12_000_000_000);
546
547 let pool = GpuPool {
548 workers: vec![big.clone(), small.clone()],
549 };
550
551 let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
553 assert_eq!(picked.gpu.ordinal, 1);
554 }
555
556 #[test]
559 fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
560 let (a, _a_rx) = test_worker(0, 24_000_000_000);
561 let (b, _b_rx) = test_worker(1, 12_000_000_000);
562 a.in_flight.store(1, Ordering::SeqCst);
563 b.in_flight.store(1, Ordering::SeqCst);
564
565 let pool = GpuPool {
566 workers: vec![a.clone(), b.clone()],
567 };
568
569 let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
570 assert_eq!(picked.gpu.ordinal, 0);
572 }
573
574 #[test]
575 fn select_worker_keeps_queueing_behind_busy_warm_worker() {
576 let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
577 let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
578
579 warm_busy.in_flight.store(1, Ordering::SeqCst);
580 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
581 model: "flux-dev:q4".to_string(),
582 prompt_sha256: String::new(),
583 started_at_unix_ms: 0,
584 started_at: Instant::now(),
585 });
586
587 let pool = GpuPool {
588 workers: vec![warm_busy.clone(), cold_idle.clone()],
589 };
590
591 let picked = pool
592 .select_worker("flux-dev:q4", 6_000_000_000)
593 .expect("warm worker should be preferred");
594 assert_eq!(picked.gpu.ordinal, 0);
595 }
596
597 #[test]
598 fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
599 let (worker, _rx) = test_worker(1, 24_000_000_000);
600 let pool = GpuPool {
601 workers: vec![worker],
602 };
603 let placement = DevicePlacement {
604 text_encoders: DeviceRef::Auto,
605 advanced: Some(AdvancedPlacement {
606 transformer: DeviceRef::gpu(1),
607 ..AdvancedPlacement::default()
608 }),
609 };
610
611 assert_eq!(
612 pool.resolve_explicit_placement_gpu(Some(&placement))
613 .unwrap(),
614 Some(1)
615 );
616 }
617
618 #[test]
619 fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
620 let (worker0, _rx0) = test_worker(0, 24_000_000_000);
621 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
622 let pool = GpuPool {
623 workers: vec![worker0, worker1],
624 };
625 let placement = DevicePlacement {
626 text_encoders: DeviceRef::gpu(0),
627 advanced: Some(AdvancedPlacement {
628 transformer: DeviceRef::gpu(1),
629 ..AdvancedPlacement::default()
630 }),
631 };
632
633 let err = pool
634 .resolve_explicit_placement_gpu(Some(&placement))
635 .unwrap_err();
636 assert!(err.contains("one GPU ordinal per request"), "{err}");
637 }
638
639 #[test]
640 fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
641 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
642 let pool = GpuPool {
643 workers: vec![worker1],
644 };
645 let placement = DevicePlacement {
646 text_encoders: DeviceRef::Auto,
647 advanced: Some(AdvancedPlacement {
648 transformer: DeviceRef::gpu(0),
649 ..AdvancedPlacement::default()
650 }),
651 };
652
653 let err = pool
654 .resolve_explicit_placement_gpu(Some(&placement))
655 .unwrap_err();
656 assert!(err.contains("gpu:0"), "{err}");
657 assert!(err.contains("[1]"), "{err}");
658 }
659
660 #[test]
666 fn is_degraded_clears_counter_when_cooldown_has_expired() {
667 let (worker, _rx) = test_worker(0, 24_000_000_000);
668 worker.consecutive_failures.store(3, Ordering::SeqCst);
669 *worker.degraded_until.write().unwrap() =
671 Some(Instant::now() - std::time::Duration::from_secs(1));
672
673 assert!(
674 !worker.is_degraded(),
675 "expired cooldown must mark the worker as healthy again",
676 );
677 assert_eq!(
678 worker.consecutive_failures.load(Ordering::SeqCst),
679 0,
680 "expired cooldown must lazy-reset the failure counter so a \
681 single post-cooldown failure doesn't immediately re-degrade",
682 );
683 assert!(
684 worker.degraded_until.read().unwrap().is_none(),
685 "expired cooldown must clear the timestamp",
686 );
687 }
688
689 #[test]
690 fn is_degraded_respects_active_cooldown() {
691 let (worker, _rx) = test_worker(0, 24_000_000_000);
692 worker.consecutive_failures.store(3, Ordering::SeqCst);
693 *worker.degraded_until.write().unwrap() =
695 Some(Instant::now() + std::time::Duration::from_secs(60));
696
697 assert!(
698 worker.is_degraded(),
699 "active cooldown must keep the worker degraded",
700 );
701 assert_eq!(
702 worker.consecutive_failures.load(Ordering::SeqCst),
703 3,
704 "active cooldown must NOT reset the counter",
705 );
706 }
707
708 #[test]
709 fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
710 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
711 clear_model_cuda_ooms_for_tests();
712 let model = "flux2-klein-9b:bf16";
713
714 let first = record_model_cuda_oom(model, 0);
715 assert!(
716 !first.is_unschedulable(),
717 "first OOM only records the failed ordinal"
718 );
719 assert!(
720 model_unschedulable_message(model).is_none(),
721 "a single-GPU OOM should not cool down the model yet"
722 );
723
724 let second = record_model_cuda_oom(model, 1);
725 assert!(
726 second.is_unschedulable(),
727 "OOM on a sibling GPU should mark the model unschedulable"
728 );
729 let msg = model_unschedulable_message(model).expect("cooldown message");
730 assert!(msg.contains(model), "{msg}");
731 assert!(msg.contains("temporarily unschedulable"), "{msg}");
732
733 clear_model_cuda_ooms_for_tests();
734 }
735
736 #[test]
737 fn failed_model_ordinals_can_be_skipped_before_cooldown() {
738 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
739 clear_model_cuda_ooms_for_tests();
740 let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
741 let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
742 let pool = GpuPool {
743 workers: vec![failed, untested.clone()],
744 };
745 let model = "flux2-klein-9b:bf16";
746
747 record_model_cuda_oom(model, 0);
748 let skip = failed_ordinals_for_model(model);
749 let picked = pool
750 .select_worker_excluding(model, 32_000_000_000, &skip)
751 .expect("sibling GPU should be tried before cooldown");
752
753 assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
754 clear_model_cuda_ooms_for_tests();
755 }
756}