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    /// Server-wide event broadcast so the worker's save path can emit
155    /// `gallery_added` alongside the DB upsert (mirrors `AppState.events`).
156    pub events: Arc<crate::events::EventBroadcaster>,
157}
158
159/// Pool of GPU workers with placement strategy.
160pub struct GpuPool {
161    pub workers: Vec<Arc<GpuWorker>>,
162}
163
164impl GpuWorker {
165    /// Check if this worker is in a degraded state (3+ consecutive failures, within cooldown).
166    ///
167    /// When the cooldown has expired we clear the failure counter and the
168    /// `degraded_until` timestamp so the next single failure doesn't
169    /// immediately re-degrade the GPU. Without this lazy reset, a worker
170    /// that has 3 historical failures followed by a long idle period would
171    /// flip back to Degraded on the very first post-cooldown failure
172    /// (because `consecutive_failures` is still >= 3 from before).
173    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            // Lazy clear: cooldown elapsed, treat the worker as healthy
183            // again. A new failure burst still has to reach 3 consecutive
184            // failures before re-degrading.
185            self.consecutive_failures.store(0, Ordering::SeqCst);
186            *self.degraded_until.write().unwrap() = None;
187        }
188        cooldown_active
189    }
190
191    /// Build a status snapshot for this worker.
192    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        // Prefer the active-generation model name — during inflight generation
196        // the cache entry is taken out of the cache (take-and-restore pattern),
197        // so `cache.active_model()` returns None. Falling back to the cache
198        // afterwards handles the idle-but-loaded case.
199        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    /// Return the worker bound to `ordinal`, if present in this pool.
225    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    /// Validate a request/config placement against the active worker pool.
233    ///
234    /// In multi-GPU worker mode a request may explicitly pin components to at
235    /// most one GPU ordinal. Cross-GPU component placement would bypass the
236    /// worker-affinity model entirely, so reject it here instead of letting the
237    /// engines silently allocate on a sibling GPU.
238    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    /// Find a non-degraded worker that already has this model loaded on GPU.
280    /// If multiple workers have it, prefer the one with fewer in-flight requests.
281    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    /// Select the best worker for a model, using the placement strategy
306    /// (checked in order):
307    /// 1. Loaded and idle (model on GPU, no in-flight requests).
308    /// 2. Loaded but busy — queue behind the warm copy instead of reloading.
309    /// 3. Idle GPU with no model (spreads cold loads across free GPUs).
310    /// 4. Non-degraded worker with the most headroom (will evict LRU).
311    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    /// Same as [`select_worker`], but skips workers whose ordinal is in `skip`.
316    /// Used by the dispatcher to retry after a `try_send` failure.
317    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        // Classify each eligible worker.
334        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            // During an in-flight generation the worker thread calls
356            // `cache.take()`, which removes the entry entirely — so
357            // `cache.active_model()` and `cache.get(model).residency == Gpu`
358            // both return None/false for the duration of that generation.
359            // That used to let a busy GPU mid-inference look identical to
360            // a truly empty idle GPU, which meant a new job for a *different*
361            // model could be dispatched to the busy card while a sibling GPU
362            // sat idle. `in_flight > 0` (set by the dispatcher before send)
363            // and `active_generation.is_some()` (set by the worker around
364            // the take-and-restore window) together cover every moment
365            // between "about to pick up a job" and "just finished".
366            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        // 1. Loaded and idle — least in-flight first (should all be 0).
380        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        // 2. Loaded but busy — least in-flight wins.
386        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        // 3. Idle GPU with no model — spread! Prefer smallest GPU that fits.
392        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            // No idle GPU fits — pick the largest idle GPU.
401            return idle_empty.last().map(|w| (*w).clone());
402        }
403
404        // 4. All GPUs busy with other models — most headroom first (evict LRU there).
405        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    /// Collect status from all workers.
415    pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
416        self.workers.iter().map(|w| w.status()).collect()
417    }
418
419    /// Number of GPU workers in the pool.
420    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    /// Build a test GpuWorker with a scratch job channel and everything else
463    /// in neutral defaults. Returns the worker plus the receiver so the test
464    /// can verify what was dispatched.
465    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    /// When GPU 0 is actively generating a different model, the cache
490    /// take-and-restore pattern has already removed its entry — so
491    /// `cache.active_model()` returns None and the worker LOOKS idle
492    /// to the old classifier. The dispatcher must fall back to
493    /// `in_flight > 0` (or `active_generation`) to avoid routing a
494    /// brand-new job to the busy GPU while a sibling sits idle.
495    #[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        // Simulate the dispatcher having incremented in_flight before send,
501        // and the worker thread having called cache.take() → empty cache.
502        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    /// active_generation is set before take() and cleared after restore(),
520    /// so a worker mid-inference should be treated as busy even if the
521    /// dispatcher hasn't yet bumped in_flight (belt-and-suspenders).
522    #[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    /// Regression guard for the happy path — both GPUs are idle and empty.
543    /// The strategy says "prefer the smallest GPU that fits" to spread
544    /// hot models across free cards.
545    #[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        // A 6GB model fits on both — should pick the smaller card.
555        let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
556        assert_eq!(picked.gpu.ordinal, 1);
557    }
558
559    /// If both eligible GPUs are busy with *other* models, fall back to
560    /// the "most headroom" tier instead of deadlocking.
561    #[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        // Both busy → "most headroom" — the larger GPU wins.
574        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    /// `is_degraded()` lazily resets the failure counter when the cooldown
664    /// has expired. Without this, a worker that took 3 historical failures
665    /// would re-degrade on the very first post-cooldown failure (because
666    /// `consecutive_failures` was still ≥ 3 from before, even though the
667    /// time-based gate had already opened back up).
668    #[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        // Simulate "cooldown expired 1 second ago".
673        *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        // Cooldown still active for another 60s.
697        *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}