Skip to main content

mold_server/
gpu_pool.rs

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
109/// Per-GPU worker state. Each GPU gets its own model cache, load lock, and health tracking.
110pub 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/// Tracks the currently active generation on a GPU worker.
123#[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
131/// A job dispatched to a GPU worker thread for processing.
132pub struct GpuJob {
133    /// Server-assigned UUIDv4 carried over from `GenerationJob.id`. Used by
134    /// the worker to flip the registry entry from `Queued` → `Running` (and
135    /// to remove it when the job finishes), and surfaced to clients via
136    /// `GET /api/queue` for zombie-card reconciliation.
137    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    /// Metadata DB handle so the worker can record a row alongside the
145    /// on-disk save. `Arc<Option<...>>` mirrors `AppState.metadata_db` —
146    /// `None` when the DB failed to open or is disabled.
147    pub metadata_db: Arc<Option<MetadataDb>>,
148    /// Decrement the global queue counter when the worker finishes this job.
149    pub queue: crate::state::QueueHandle,
150    /// Job registry handle so the worker can flip state to Running on pickup
151    /// and remove the entry on completion / error. Cheap clone — registry is
152    /// behind an `Arc<RwLock>` internally.
153    pub registry: crate::job_registry::SharedJobRegistry,
154}
155
156/// Pool of GPU workers with placement strategy.
157pub struct GpuPool {
158    pub workers: Vec<Arc<GpuWorker>>,
159}
160
161impl GpuWorker {
162    /// Check if this worker is in a degraded state (3+ consecutive failures, within cooldown).
163    ///
164    /// When the cooldown has expired we clear the failure counter and the
165    /// `degraded_until` timestamp so the next single failure doesn't
166    /// immediately re-degrade the GPU. Without this lazy reset, a worker
167    /// that has 3 historical failures followed by a long idle period would
168    /// flip back to Degraded on the very first post-cooldown failure
169    /// (because `consecutive_failures` is still >= 3 from before).
170    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            // Lazy clear: cooldown elapsed, treat the worker as healthy
180            // again. A new failure burst still has to reach 3 consecutive
181            // failures before re-degrading.
182            self.consecutive_failures.store(0, Ordering::SeqCst);
183            *self.degraded_until.write().unwrap() = None;
184        }
185        cooldown_active
186    }
187
188    /// Build a status snapshot for this worker.
189    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        // Prefer the active-generation model name — during inflight generation
193        // the cache entry is taken out of the cache (take-and-restore pattern),
194        // so `cache.active_model()` returns None. Falling back to the cache
195        // afterwards handles the idle-but-loaded case.
196        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    /// Return the worker bound to `ordinal`, if present in this pool.
222    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    /// Validate a request/config placement against the active worker pool.
230    ///
231    /// In multi-GPU worker mode a request may explicitly pin components to at
232    /// most one GPU ordinal. Cross-GPU component placement would bypass the
233    /// worker-affinity model entirely, so reject it here instead of letting the
234    /// engines silently allocate on a sibling GPU.
235    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    /// Find a non-degraded worker that already has this model loaded on GPU.
277    /// If multiple workers have it, prefer the one with fewer in-flight requests.
278    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    /// Select the best worker for a model, using the placement strategy
303    /// (checked in order):
304    /// 1. Loaded and idle (model on GPU, no in-flight requests).
305    /// 2. Loaded but busy — queue behind the warm copy instead of reloading.
306    /// 3. Idle GPU with no model (spreads cold loads across free GPUs).
307    /// 4. Non-degraded worker with the most headroom (will evict LRU).
308    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    /// Same as [`select_worker`], but skips workers whose ordinal is in `skip`.
313    /// Used by the dispatcher to retry after a `try_send` failure.
314    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        // Classify each eligible worker.
331        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            // During an in-flight generation the worker thread calls
353            // `cache.take()`, which removes the entry entirely — so
354            // `cache.active_model()` and `cache.get(model).residency == Gpu`
355            // both return None/false for the duration of that generation.
356            // That used to let a busy GPU mid-inference look identical to
357            // a truly empty idle GPU, which meant a new job for a *different*
358            // model could be dispatched to the busy card while a sibling GPU
359            // sat idle. `in_flight > 0` (set by the dispatcher before send)
360            // and `active_generation.is_some()` (set by the worker around
361            // the take-and-restore window) together cover every moment
362            // between "about to pick up a job" and "just finished".
363            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        // 1. Loaded and idle — least in-flight first (should all be 0).
377        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        // 2. Loaded but busy — least in-flight wins.
383        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        // 3. Idle GPU with no model — spread! Prefer smallest GPU that fits.
389        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            // No idle GPU fits — pick the largest idle GPU.
398            return idle_empty.last().map(|w| (*w).clone());
399        }
400
401        // 4. All GPUs busy with other models — most headroom first (evict LRU there).
402        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    /// Collect status from all workers.
412    pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
413        self.workers.iter().map(|w| w.status()).collect()
414    }
415
416    /// Number of GPU workers in the pool.
417    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    /// Build a test GpuWorker with a scratch job channel and everything else
458    /// in neutral defaults. Returns the worker plus the receiver so the test
459    /// can verify what was dispatched.
460    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    /// When GPU 0 is actively generating a different model, the cache
485    /// take-and-restore pattern has already removed its entry — so
486    /// `cache.active_model()` returns None and the worker LOOKS idle
487    /// to the old classifier. The dispatcher must fall back to
488    /// `in_flight > 0` (or `active_generation`) to avoid routing a
489    /// brand-new job to the busy GPU while a sibling sits idle.
490    #[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        // Simulate the dispatcher having incremented in_flight before send,
496        // and the worker thread having called cache.take() → empty cache.
497        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    /// active_generation is set before take() and cleared after restore(),
515    /// so a worker mid-inference should be treated as busy even if the
516    /// dispatcher hasn't yet bumped in_flight (belt-and-suspenders).
517    #[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    /// Regression guard for the happy path — both GPUs are idle and empty.
538    /// The strategy says "prefer the smallest GPU that fits" to spread
539    /// hot models across free cards.
540    #[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        // A 6GB model fits on both — should pick the smaller card.
550        let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
551        assert_eq!(picked.gpu.ordinal, 1);
552    }
553
554    /// If both eligible GPUs are busy with *other* models, fall back to
555    /// the "most headroom" tier instead of deadlocking.
556    #[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        // Both busy → "most headroom" — the larger GPU wins.
569        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    /// `is_degraded()` lazily resets the failure counter when the cooldown
659    /// has expired. Without this, a worker that took 3 historical failures
660    /// would re-degrade on the very first post-cooldown failure (because
661    /// `consecutive_failures` was still ≥ 3 from before, even though the
662    /// time-based gate had already opened back up).
663    #[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        // Simulate "cooldown expired 1 second ago".
668        *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        // Cooldown still active for another 60s.
692        *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}