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 use crate::model_cache::ModelCache;
454 use mold_core::types::AdvancedPlacement;
455 use mold_inference::shared_pool::SharedPool;
456
457 fn test_worker(
461 ordinal: usize,
462 total_vram_bytes: u64,
463 ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
464 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
465 let worker = Arc::new(GpuWorker {
466 gpu: DiscoveredGpu {
467 ordinal,
468 name: format!("test-gpu-{ordinal}"),
469 total_vram_bytes,
470 free_vram_bytes: total_vram_bytes,
471 },
472 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
473 active_generation: Arc::new(RwLock::new(None)),
474 model_load_lock: Arc::new(Mutex::new(())),
475 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
476 in_flight: AtomicUsize::new(0),
477 consecutive_failures: AtomicUsize::new(0),
478 degraded_until: RwLock::new(None),
479 job_tx,
480 });
481 (worker, job_rx)
482 }
483
484 #[test]
491 fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
492 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
493 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
494
495 busy.in_flight.store(1, Ordering::SeqCst);
498
499 let pool = GpuPool {
500 workers: vec![busy.clone(), idle.clone()],
501 };
502
503 let picked = pool
504 .select_worker("some-small-model:q4", 6_000_000_000)
505 .expect("a worker should be selected");
506 assert_eq!(
507 picked.gpu.ordinal, 1,
508 "new job for an unloaded model must go to the truly idle GPU, \
509 not to the one whose cache momentarily looks empty because \
510 generation is in progress"
511 );
512 }
513
514 #[test]
518 fn select_worker_respects_active_generation_flag() {
519 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
520 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
521
522 *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
523 model: "big-model".to_string(),
524 prompt_sha256: String::new(),
525 started_at_unix_ms: 0,
526 started_at: Instant::now(),
527 });
528
529 let pool = GpuPool {
530 workers: vec![busy.clone(), idle.clone()],
531 };
532
533 let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
534 assert_eq!(picked.gpu.ordinal, 1);
535 }
536
537 #[test]
541 fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
542 let (big, _big_rx) = test_worker(0, 24_000_000_000);
543 let (small, _small_rx) = test_worker(1, 12_000_000_000);
544
545 let pool = GpuPool {
546 workers: vec![big.clone(), small.clone()],
547 };
548
549 let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
551 assert_eq!(picked.gpu.ordinal, 1);
552 }
553
554 #[test]
557 fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
558 let (a, _a_rx) = test_worker(0, 24_000_000_000);
559 let (b, _b_rx) = test_worker(1, 12_000_000_000);
560 a.in_flight.store(1, Ordering::SeqCst);
561 b.in_flight.store(1, Ordering::SeqCst);
562
563 let pool = GpuPool {
564 workers: vec![a.clone(), b.clone()],
565 };
566
567 let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
568 assert_eq!(picked.gpu.ordinal, 0);
570 }
571
572 #[test]
573 fn select_worker_keeps_queueing_behind_busy_warm_worker() {
574 let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
575 let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
576
577 warm_busy.in_flight.store(1, Ordering::SeqCst);
578 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
579 model: "flux-dev:q4".to_string(),
580 prompt_sha256: String::new(),
581 started_at_unix_ms: 0,
582 started_at: Instant::now(),
583 });
584
585 let pool = GpuPool {
586 workers: vec![warm_busy.clone(), cold_idle.clone()],
587 };
588
589 let picked = pool
590 .select_worker("flux-dev:q4", 6_000_000_000)
591 .expect("warm worker should be preferred");
592 assert_eq!(picked.gpu.ordinal, 0);
593 }
594
595 #[test]
596 fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
597 let (worker, _rx) = test_worker(1, 24_000_000_000);
598 let pool = GpuPool {
599 workers: vec![worker],
600 };
601 let placement = DevicePlacement {
602 text_encoders: DeviceRef::Auto,
603 advanced: Some(AdvancedPlacement {
604 transformer: DeviceRef::gpu(1),
605 ..AdvancedPlacement::default()
606 }),
607 };
608
609 assert_eq!(
610 pool.resolve_explicit_placement_gpu(Some(&placement))
611 .unwrap(),
612 Some(1)
613 );
614 }
615
616 #[test]
617 fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
618 let (worker0, _rx0) = test_worker(0, 24_000_000_000);
619 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
620 let pool = GpuPool {
621 workers: vec![worker0, worker1],
622 };
623 let placement = DevicePlacement {
624 text_encoders: DeviceRef::gpu(0),
625 advanced: Some(AdvancedPlacement {
626 transformer: DeviceRef::gpu(1),
627 ..AdvancedPlacement::default()
628 }),
629 };
630
631 let err = pool
632 .resolve_explicit_placement_gpu(Some(&placement))
633 .unwrap_err();
634 assert!(err.contains("one GPU ordinal per request"), "{err}");
635 }
636
637 #[test]
638 fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
639 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
640 let pool = GpuPool {
641 workers: vec![worker1],
642 };
643 let placement = DevicePlacement {
644 text_encoders: DeviceRef::Auto,
645 advanced: Some(AdvancedPlacement {
646 transformer: DeviceRef::gpu(0),
647 ..AdvancedPlacement::default()
648 }),
649 };
650
651 let err = pool
652 .resolve_explicit_placement_gpu(Some(&placement))
653 .unwrap_err();
654 assert!(err.contains("gpu:0"), "{err}");
655 assert!(err.contains("[1]"), "{err}");
656 }
657
658 #[test]
664 fn is_degraded_clears_counter_when_cooldown_has_expired() {
665 let (worker, _rx) = test_worker(0, 24_000_000_000);
666 worker.consecutive_failures.store(3, Ordering::SeqCst);
667 *worker.degraded_until.write().unwrap() =
669 Some(Instant::now() - std::time::Duration::from_secs(1));
670
671 assert!(
672 !worker.is_degraded(),
673 "expired cooldown must mark the worker as healthy again",
674 );
675 assert_eq!(
676 worker.consecutive_failures.load(Ordering::SeqCst),
677 0,
678 "expired cooldown must lazy-reset the failure counter so a \
679 single post-cooldown failure doesn't immediately re-degrade",
680 );
681 assert!(
682 worker.degraded_until.read().unwrap().is_none(),
683 "expired cooldown must clear the timestamp",
684 );
685 }
686
687 #[test]
688 fn is_degraded_respects_active_cooldown() {
689 let (worker, _rx) = test_worker(0, 24_000_000_000);
690 worker.consecutive_failures.store(3, Ordering::SeqCst);
691 *worker.degraded_until.write().unwrap() =
693 Some(Instant::now() + std::time::Duration::from_secs(60));
694
695 assert!(
696 worker.is_degraded(),
697 "active cooldown must keep the worker degraded",
698 );
699 assert_eq!(
700 worker.consecutive_failures.load(Ordering::SeqCst),
701 3,
702 "active cooldown must NOT reset the counter",
703 );
704 }
705
706 #[test]
707 fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
708 clear_model_cuda_ooms_for_tests();
709 let model = "flux2-klein-9b:bf16";
710
711 let first = record_model_cuda_oom(model, 0);
712 assert!(
713 !first.is_unschedulable(),
714 "first OOM only records the failed ordinal"
715 );
716 assert!(
717 model_unschedulable_message(model).is_none(),
718 "a single-GPU OOM should not cool down the model yet"
719 );
720
721 let second = record_model_cuda_oom(model, 1);
722 assert!(
723 second.is_unschedulable(),
724 "OOM on a sibling GPU should mark the model unschedulable"
725 );
726 let msg = model_unschedulable_message(model).expect("cooldown message");
727 assert!(msg.contains(model), "{msg}");
728 assert!(msg.contains("temporarily unschedulable"), "{msg}");
729
730 clear_model_cuda_ooms_for_tests();
731 }
732
733 #[test]
734 fn failed_model_ordinals_can_be_skipped_before_cooldown() {
735 clear_model_cuda_ooms_for_tests();
736 let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
737 let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
738 let pool = GpuPool {
739 workers: vec![failed, untested.clone()],
740 };
741 let model = "flux2-klein-9b:bf16";
742
743 record_model_cuda_oom(model, 0);
744 let skip = failed_ordinals_for_model(model);
745 let picked = pool
746 .select_worker_excluding(model, 32_000_000_000, &skip)
747 .expect("sibling GPU should be tried before cooldown");
748
749 assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
750 clear_model_cuda_ooms_for_tests();
751 }
752}