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 if !loaded_busy.is_empty() {
387 loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
388 return loaded_busy.first().map(|w| (*w).clone());
389 }
390
391 if !idle_empty.is_empty() {
393 idle_empty.sort_by_key(|w| w.gpu.total_vram_bytes);
394 if let Some(w) = idle_empty
395 .iter()
396 .find(|w| w.gpu.total_vram_bytes >= estimated_vram)
397 {
398 return Some((*w).clone());
399 }
400 return idle_empty.last().map(|w| (*w).clone());
402 }
403
404 let mut busy = other;
406 busy.sort_by(|a, b| {
407 let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
408 let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
409 b_headroom.cmp(&a_headroom)
410 });
411 busy.first().map(|w| (*w).clone())
412 }
413
414 pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
416 self.workers.iter().map(|w| w.status()).collect()
417 }
418
419 pub fn worker_count(&self) -> usize {
421 self.workers.len()
422 }
423}
424
425fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
426 let mut ordinals = BTreeSet::new();
427 collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
428 if let Some(adv) = placement.advanced.as_ref() {
429 collect_gpu_ordinal(adv.transformer, &mut ordinals);
430 collect_gpu_ordinal(adv.vae, &mut ordinals);
431 if let Some(device) = adv.clip_l {
432 collect_gpu_ordinal(device, &mut ordinals);
433 }
434 if let Some(device) = adv.clip_g {
435 collect_gpu_ordinal(device, &mut ordinals);
436 }
437 if let Some(device) = adv.t5 {
438 collect_gpu_ordinal(device, &mut ordinals);
439 }
440 if let Some(device) = adv.qwen {
441 collect_gpu_ordinal(device, &mut ordinals);
442 }
443 }
444 ordinals
445}
446
447fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
448 if let DeviceRef::Gpu { ordinal } = device {
449 out.insert(ordinal);
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
458 use crate::model_cache::ModelCache;
459 use mold_core::types::AdvancedPlacement;
460 use mold_inference::shared_pool::SharedPool;
461
462 fn test_worker(
466 ordinal: usize,
467 total_vram_bytes: u64,
468 ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
469 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
470 let worker = Arc::new(GpuWorker {
471 gpu: DiscoveredGpu {
472 ordinal,
473 name: format!("test-gpu-{ordinal}"),
474 total_vram_bytes,
475 free_vram_bytes: total_vram_bytes,
476 },
477 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
478 active_generation: Arc::new(RwLock::new(None)),
479 model_load_lock: Arc::new(Mutex::new(())),
480 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
481 in_flight: AtomicUsize::new(0),
482 consecutive_failures: AtomicUsize::new(0),
483 degraded_until: RwLock::new(None),
484 job_tx,
485 });
486 (worker, job_rx)
487 }
488
489 #[test]
496 fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
497 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
498 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
499
500 busy.in_flight.store(1, Ordering::SeqCst);
503
504 let pool = GpuPool {
505 workers: vec![busy.clone(), idle.clone()],
506 };
507
508 let picked = pool
509 .select_worker("some-small-model:q4", 6_000_000_000)
510 .expect("a worker should be selected");
511 assert_eq!(
512 picked.gpu.ordinal, 1,
513 "new job for an unloaded model must go to the truly idle GPU, \
514 not to the one whose cache momentarily looks empty because \
515 generation is in progress"
516 );
517 }
518
519 #[test]
523 fn select_worker_respects_active_generation_flag() {
524 let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
525 let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
526
527 *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
528 model: "big-model".to_string(),
529 prompt_sha256: String::new(),
530 started_at_unix_ms: 0,
531 started_at: Instant::now(),
532 });
533
534 let pool = GpuPool {
535 workers: vec![busy.clone(), idle.clone()],
536 };
537
538 let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
539 assert_eq!(picked.gpu.ordinal, 1);
540 }
541
542 #[test]
546 fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
547 let (big, _big_rx) = test_worker(0, 24_000_000_000);
548 let (small, _small_rx) = test_worker(1, 12_000_000_000);
549
550 let pool = GpuPool {
551 workers: vec![big.clone(), small.clone()],
552 };
553
554 let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
556 assert_eq!(picked.gpu.ordinal, 1);
557 }
558
559 #[test]
562 fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
563 let (a, _a_rx) = test_worker(0, 24_000_000_000);
564 let (b, _b_rx) = test_worker(1, 12_000_000_000);
565 a.in_flight.store(1, Ordering::SeqCst);
566 b.in_flight.store(1, Ordering::SeqCst);
567
568 let pool = GpuPool {
569 workers: vec![a.clone(), b.clone()],
570 };
571
572 let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
573 assert_eq!(picked.gpu.ordinal, 0);
575 }
576
577 #[test]
578 fn select_worker_keeps_queueing_behind_busy_warm_worker() {
579 let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
580 let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
581
582 warm_busy.in_flight.store(1, Ordering::SeqCst);
583 *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
584 model: "flux-dev:q4".to_string(),
585 prompt_sha256: String::new(),
586 started_at_unix_ms: 0,
587 started_at: Instant::now(),
588 });
589
590 let pool = GpuPool {
591 workers: vec![warm_busy.clone(), cold_idle.clone()],
592 };
593
594 let picked = pool
595 .select_worker("flux-dev:q4", 6_000_000_000)
596 .expect("warm worker should be preferred");
597 assert_eq!(picked.gpu.ordinal, 0);
598 }
599
600 #[test]
601 fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
602 let (worker, _rx) = test_worker(1, 24_000_000_000);
603 let pool = GpuPool {
604 workers: vec![worker],
605 };
606 let placement = DevicePlacement {
607 text_encoders: DeviceRef::Auto,
608 advanced: Some(AdvancedPlacement {
609 transformer: DeviceRef::gpu(1),
610 ..AdvancedPlacement::default()
611 }),
612 };
613
614 assert_eq!(
615 pool.resolve_explicit_placement_gpu(Some(&placement))
616 .unwrap(),
617 Some(1)
618 );
619 }
620
621 #[test]
622 fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
623 let (worker0, _rx0) = test_worker(0, 24_000_000_000);
624 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
625 let pool = GpuPool {
626 workers: vec![worker0, worker1],
627 };
628 let placement = DevicePlacement {
629 text_encoders: DeviceRef::gpu(0),
630 advanced: Some(AdvancedPlacement {
631 transformer: DeviceRef::gpu(1),
632 ..AdvancedPlacement::default()
633 }),
634 };
635
636 let err = pool
637 .resolve_explicit_placement_gpu(Some(&placement))
638 .unwrap_err();
639 assert!(err.contains("one GPU ordinal per request"), "{err}");
640 }
641
642 #[test]
643 fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
644 let (worker1, _rx1) = test_worker(1, 24_000_000_000);
645 let pool = GpuPool {
646 workers: vec![worker1],
647 };
648 let placement = DevicePlacement {
649 text_encoders: DeviceRef::Auto,
650 advanced: Some(AdvancedPlacement {
651 transformer: DeviceRef::gpu(0),
652 ..AdvancedPlacement::default()
653 }),
654 };
655
656 let err = pool
657 .resolve_explicit_placement_gpu(Some(&placement))
658 .unwrap_err();
659 assert!(err.contains("gpu:0"), "{err}");
660 assert!(err.contains("[1]"), "{err}");
661 }
662
663 #[test]
669 fn is_degraded_clears_counter_when_cooldown_has_expired() {
670 let (worker, _rx) = test_worker(0, 24_000_000_000);
671 worker.consecutive_failures.store(3, Ordering::SeqCst);
672 *worker.degraded_until.write().unwrap() =
674 Some(Instant::now() - std::time::Duration::from_secs(1));
675
676 assert!(
677 !worker.is_degraded(),
678 "expired cooldown must mark the worker as healthy again",
679 );
680 assert_eq!(
681 worker.consecutive_failures.load(Ordering::SeqCst),
682 0,
683 "expired cooldown must lazy-reset the failure counter so a \
684 single post-cooldown failure doesn't immediately re-degrade",
685 );
686 assert!(
687 worker.degraded_until.read().unwrap().is_none(),
688 "expired cooldown must clear the timestamp",
689 );
690 }
691
692 #[test]
693 fn is_degraded_respects_active_cooldown() {
694 let (worker, _rx) = test_worker(0, 24_000_000_000);
695 worker.consecutive_failures.store(3, Ordering::SeqCst);
696 *worker.degraded_until.write().unwrap() =
698 Some(Instant::now() + std::time::Duration::from_secs(60));
699
700 assert!(
701 worker.is_degraded(),
702 "active cooldown must keep the worker degraded",
703 );
704 assert_eq!(
705 worker.consecutive_failures.load(Ordering::SeqCst),
706 3,
707 "active cooldown must NOT reset the counter",
708 );
709 }
710
711 #[test]
712 fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
713 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
714 clear_model_cuda_ooms_for_tests();
715 let model = "flux2-klein-9b:bf16";
716
717 let first = record_model_cuda_oom(model, 0);
718 assert!(
719 !first.is_unschedulable(),
720 "first OOM only records the failed ordinal"
721 );
722 assert!(
723 model_unschedulable_message(model).is_none(),
724 "a single-GPU OOM should not cool down the model yet"
725 );
726
727 let second = record_model_cuda_oom(model, 1);
728 assert!(
729 second.is_unschedulable(),
730 "OOM on a sibling GPU should mark the model unschedulable"
731 );
732 let msg = model_unschedulable_message(model).expect("cooldown message");
733 assert!(msg.contains(model), "{msg}");
734 assert!(msg.contains("temporarily unschedulable"), "{msg}");
735
736 clear_model_cuda_ooms_for_tests();
737 }
738
739 #[test]
740 fn failed_model_ordinals_can_be_skipped_before_cooldown() {
741 let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
742 clear_model_cuda_ooms_for_tests();
743 let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
744 let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
745 let pool = GpuPool {
746 workers: vec![failed, untested.clone()],
747 };
748 let model = "flux2-klein-9b:bf16";
749
750 record_model_cuda_oom(model, 0);
751 let skip = failed_ordinals_for_model(model);
752 let picked = pool
753 .select_worker_excluding(model, 32_000_000_000, &skip)
754 .expect("sibling GPU should be tried before cooldown");
755
756 assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
757 clear_model_cuda_ooms_for_tests();
758 }
759}