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::{AtomicBool, 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    /// Fatal CUDA errors poison a process-owned context permanently. A poisoned
119    /// worker is quarantined until the server process is restarted.
120    pub poisoned: AtomicBool,
121    /// Shared process-level signal. A fatal context requires process teardown;
122    /// supervised servers restart after `run_server` returns an error.
123    pub fatal_cuda_error: Arc<AtomicBool>,
124    pub fatal_cuda_shutdown: Arc<tokio::sync::Notify>,
125    pub degraded_until: RwLock<Option<Instant>>,
126    pub job_tx: std::sync::mpsc::SyncSender<GpuJob>,
127}
128
129/// Tracks the currently active generation on a GPU worker.
130#[derive(Debug)]
131pub struct ActiveGeneration {
132    pub model: String,
133    pub prompt_sha256: String,
134    pub started_at_unix_ms: u64,
135    pub started_at: Instant,
136}
137
138/// A job dispatched to a GPU worker thread for processing.
139pub struct GpuJob {
140    /// Server-assigned UUIDv4 carried over from `GenerationJob.id`. Used by
141    /// the worker to flip the registry entry from `Queued` → `Running` (and
142    /// to remove it when the job finishes), and surfaced to clients via
143    /// `GET /api/queue` for zombie-card reconciliation.
144    pub id: String,
145    pub model: String,
146    pub request: mold_core::GenerateRequest,
147    pub completion_payload: crate::state::SseCompletionPayload,
148    pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::state::SseMessage>>,
149    pub result_tx: tokio::sync::oneshot::Sender<Result<crate::state::GenerationJobResult, String>>,
150    pub output_dir: Option<std::path::PathBuf>,
151    pub config: Arc<tokio::sync::RwLock<mold_core::Config>>,
152    /// Metadata DB handle so the worker can record a row alongside the
153    /// on-disk save. `Arc<Option<...>>` mirrors `AppState.metadata_db` —
154    /// `None` when the DB failed to open or is disabled.
155    pub metadata_db: Arc<Option<MetadataDb>>,
156    /// Decrement the global queue counter when the worker finishes this job.
157    pub queue: crate::state::QueueHandle,
158    /// Job registry handle so the worker can flip state to Running on pickup
159    /// and remove the entry on completion / error. Cheap clone — registry is
160    /// behind an `Arc<RwLock>` internally.
161    pub registry: crate::job_registry::SharedJobRegistry,
162    /// Server-wide event broadcast so the worker's save path can emit
163    /// `gallery_added` alongside the DB upsert (mirrors `AppState.events`).
164    pub events: Arc<crate::events::EventBroadcaster>,
165}
166
167/// Pool of GPU workers with placement strategy.
168pub struct GpuPool {
169    pub workers: Vec<Arc<GpuWorker>>,
170}
171
172impl GpuWorker {
173    /// Check if this worker is in a degraded state (3+ consecutive failures, within cooldown).
174    ///
175    /// When the cooldown has expired we clear the failure counter and the
176    /// `degraded_until` timestamp so the next single failure doesn't
177    /// immediately re-degrade the GPU. Without this lazy reset, a worker
178    /// that has 3 historical failures followed by a long idle period would
179    /// flip back to Degraded on the very first post-cooldown failure
180    /// (because `consecutive_failures` is still >= 3 from before).
181    pub fn is_degraded(&self) -> bool {
182        if self.poisoned.load(Ordering::SeqCst) {
183            return true;
184        }
185        if self.consecutive_failures.load(Ordering::SeqCst) < 3 {
186            return false;
187        }
188        let cooldown_active = match *self.degraded_until.read().unwrap() {
189            Some(until) => Instant::now() < until,
190            None => false,
191        };
192        if !cooldown_active {
193            // Lazy clear: cooldown elapsed, treat the worker as healthy
194            // again. A new failure burst still has to reach 3 consecutive
195            // failures before re-degrading.
196            self.consecutive_failures.store(0, Ordering::SeqCst);
197            *self.degraded_until.write().unwrap() = None;
198        }
199        cooldown_active
200    }
201
202    /// Build a status snapshot for this worker.
203    pub fn status(&self) -> GpuWorkerStatus {
204        let active_gen = self.active_generation.read().unwrap();
205        let in_flight = self.in_flight.load(Ordering::SeqCst);
206        // Prefer the active-generation model name — during inflight generation
207        // the cache entry is taken out of the cache (take-and-restore pattern),
208        // so `cache.active_model()` returns None. Falling back to the cache
209        // afterwards handles the idle-but-loaded case.
210        let loaded_model = active_gen.as_ref().map(|g| g.model.clone()).or_else(|| {
211            let cache = self.model_cache.lock().unwrap();
212            cache.active_model().map(|s| s.to_string())
213        });
214
215        let state = if self.is_degraded() {
216            GpuWorkerState::Degraded
217        } else if active_gen.is_some() || in_flight > 0 {
218            GpuWorkerState::Generating
219        } else {
220            GpuWorkerState::Idle
221        };
222
223        GpuWorkerStatus {
224            ordinal: self.gpu.ordinal,
225            name: self.gpu.name.clone(),
226            vram_total_bytes: self.gpu.total_vram_bytes,
227            vram_used_bytes: mold_inference::device::vram_in_use_bytes(self.gpu.ordinal),
228            loaded_model,
229            state,
230        }
231    }
232}
233
234impl GpuPool {
235    /// Return the worker bound to `ordinal`, if present in this pool.
236    pub fn worker_by_ordinal(&self, ordinal: usize) -> Option<Arc<GpuWorker>> {
237        self.workers
238            .iter()
239            .find(|w| w.gpu.ordinal == ordinal)
240            .cloned()
241    }
242
243    /// Validate a request/config placement against the active worker pool.
244    ///
245    /// In multi-GPU worker mode a request may explicitly pin components to at
246    /// most one GPU ordinal. Cross-GPU component placement would bypass the
247    /// worker-affinity model entirely, so reject it here instead of letting the
248    /// engines silently allocate on a sibling GPU.
249    pub fn resolve_explicit_placement_gpu(
250        &self,
251        placement: Option<&DevicePlacement>,
252    ) -> Result<Option<usize>, String> {
253        if self.workers.is_empty() {
254            return Ok(None);
255        }
256        let Some(placement) = placement else {
257            return Ok(None);
258        };
259
260        let ordinals = placement_gpu_ordinals(placement);
261        if ordinals.is_empty() {
262            return Ok(None);
263        }
264        if ordinals.len() > 1 {
265            let rendered = ordinals
266                .iter()
267                .map(|o| format!("gpu:{o}"))
268                .collect::<Vec<_>>()
269                .join(", ");
270            return Err(format!(
271                "multi-GPU worker mode only supports placement on one GPU ordinal per request; got {rendered}"
272            ));
273        }
274
275        let ordinal = *ordinals.iter().next().expect("checked non-empty");
276        if self.worker_by_ordinal(ordinal).is_none() {
277            let available = self
278                .workers
279                .iter()
280                .map(|w| w.gpu.ordinal.to_string())
281                .collect::<Vec<_>>()
282                .join(", ");
283            return Err(format!(
284                "gpu:{ordinal} is not available in this server's worker pool [{available}]"
285            ));
286        }
287        Ok(Some(ordinal))
288    }
289
290    /// Find a non-degraded worker that already has this model loaded on GPU.
291    /// If multiple workers have it, prefer the one with fewer in-flight requests.
292    pub fn find_loaded(&self, model_name: &str) -> Option<Arc<GpuWorker>> {
293        let mut candidates: Vec<_> = self
294            .workers
295            .iter()
296            .filter(|w| {
297                if w.is_degraded() {
298                    return false;
299                }
300                let active_gen = w.active_generation.read().unwrap();
301                if active_gen.as_ref().is_some_and(|g| g.model == model_name) {
302                    return true;
303                }
304                let cache = w.model_cache.lock().unwrap();
305                cache
306                    .get(model_name)
307                    .map(|e| e.residency == ModelResidency::Gpu)
308                    .unwrap_or(false)
309            })
310            .collect();
311
312        candidates.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
313        candidates.into_iter().next().cloned()
314    }
315
316    /// Select the best worker for a model, using the placement strategy
317    /// (checked in order):
318    /// 1. Loaded and idle (model on GPU, no in-flight requests).
319    /// 2. Loaded but busy — queue behind the warm copy instead of reloading.
320    /// 3. Idle GPU with no model (spreads cold loads across free GPUs).
321    /// 4. Non-degraded worker with the most headroom (will evict LRU).
322    pub fn select_worker(&self, model_name: &str, estimated_vram: u64) -> Option<Arc<GpuWorker>> {
323        self.select_worker_excluding(model_name, estimated_vram, &[])
324    }
325
326    /// Same as [`select_worker`], but skips workers whose ordinal is in `skip`.
327    /// Used by the dispatcher to retry after a `try_send` failure.
328    pub fn select_worker_excluding(
329        &self,
330        model_name: &str,
331        estimated_vram: u64,
332        skip: &[usize],
333    ) -> Option<Arc<GpuWorker>> {
334        let eligible: Vec<&Arc<GpuWorker>> = self
335            .workers
336            .iter()
337            .filter(|w| !w.is_degraded() && !skip.contains(&w.gpu.ordinal))
338            .collect();
339
340        if eligible.is_empty() {
341            return None;
342        }
343
344        // Classify each eligible worker.
345        let mut loaded_idle: Vec<&Arc<GpuWorker>> = Vec::new();
346        let mut loaded_busy: Vec<&Arc<GpuWorker>> = Vec::new();
347        let mut idle_empty: Vec<&Arc<GpuWorker>> = Vec::new();
348        let mut other: Vec<&Arc<GpuWorker>> = Vec::new();
349
350        for w in &eligible {
351            let active_gen = w.active_generation.read().unwrap();
352            let active_model = active_gen.as_ref().map(|g| g.model.as_str());
353            let (has_model, has_any_loaded) = {
354                let cache = w.model_cache.lock().unwrap();
355                let has_model = active_model == Some(model_name)
356                    || cache
357                        .get(model_name)
358                        .map(|e| e.residency == ModelResidency::Gpu)
359                        .unwrap_or(false);
360                (
361                    has_model,
362                    active_model.is_some() || cache.active_model().is_some(),
363                )
364            };
365            let in_flight = w.in_flight.load(Ordering::SeqCst);
366            // During an in-flight generation the worker thread calls
367            // `cache.take()`, which removes the entry entirely — so
368            // `cache.active_model()` and `cache.get(model).residency == Gpu`
369            // both return None/false for the duration of that generation.
370            // That used to let a busy GPU mid-inference look identical to
371            // a truly empty idle GPU, which meant a new job for a *different*
372            // model could be dispatched to the busy card while a sibling GPU
373            // sat idle. `in_flight > 0` (set by the dispatcher before send)
374            // and `active_generation.is_some()` (set by the worker around
375            // the take-and-restore window) together cover every moment
376            // between "about to pick up a job" and "just finished".
377            let is_busy = in_flight > 0 || active_model.is_some();
378
379            if has_model && !is_busy {
380                loaded_idle.push(w);
381            } else if has_model {
382                loaded_busy.push(w);
383            } else if !has_any_loaded && !is_busy {
384                idle_empty.push(w);
385            } else {
386                other.push(w);
387            }
388        }
389
390        // 1. Loaded and idle — least in-flight first (should all be 0).
391        if !loaded_idle.is_empty() {
392            loaded_idle.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
393            return loaded_idle.first().map(|w| (*w).clone());
394        }
395
396        // 2. Idle GPU with no model that FITS the estimate — spread beats
397        //    queueing behind a busy warm card: the cold load is paid once,
398        //    then that GPU is warm and tier 1 takes over for later jobs.
399        //    (Warm-busy used to outrank this, which serialized same-model
400        //    jobs on one card of a multi-GPU box while siblings sat idle.)
401        idle_empty.sort_by_key(|w| w.gpu.total_vram_bytes);
402        if let Some(w) = idle_empty
403            .iter()
404            .find(|w| w.gpu.total_vram_bytes >= estimated_vram)
405        {
406            return Some((*w).clone());
407        }
408
409        // 3. Loaded but busy — least in-flight wins. Reached only when no
410        //    idle GPU can hold the model: waiting for the warm card beats
411        //    cold-loading onto a card that would have to offload.
412        if !loaded_busy.is_empty() {
413            loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
414            return loaded_busy.first().map(|w| (*w).clone());
415        }
416
417        // 4. Idle GPU that doesn't fit — largest wins; block offloading may
418        //    still make the model viable there.
419        if !idle_empty.is_empty() {
420            return idle_empty.last().map(|w| (*w).clone());
421        }
422
423        // 5. Everything left — busy with other models, or idle with a
424        //    different model loaded (evicting a warm sibling has its own
425        //    cost). Most headroom first; the LRU cache evicts there.
426        let mut busy = other;
427        busy.sort_by(|a, b| {
428            let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
429            let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
430            b_headroom.cmp(&a_headroom)
431        });
432        busy.first().map(|w| (*w).clone())
433    }
434
435    /// Collect status from all workers.
436    pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
437        self.workers.iter().map(|w| w.status()).collect()
438    }
439
440    /// Number of GPU workers in the pool.
441    pub fn worker_count(&self) -> usize {
442        self.workers.len()
443    }
444}
445
446fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
447    let mut ordinals = BTreeSet::new();
448    collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
449    if let Some(adv) = placement.advanced.as_ref() {
450        collect_gpu_ordinal(adv.transformer, &mut ordinals);
451        collect_gpu_ordinal(adv.vae, &mut ordinals);
452        if let Some(device) = adv.clip_l {
453            collect_gpu_ordinal(device, &mut ordinals);
454        }
455        if let Some(device) = adv.clip_g {
456            collect_gpu_ordinal(device, &mut ordinals);
457        }
458        if let Some(device) = adv.t5 {
459            collect_gpu_ordinal(device, &mut ordinals);
460        }
461        if let Some(device) = adv.qwen {
462            collect_gpu_ordinal(device, &mut ordinals);
463        }
464    }
465    ordinals
466}
467
468fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
469    if let DeviceRef::Gpu { ordinal } = device {
470        out.insert(ordinal);
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
479    use crate::model_cache::ModelCache;
480    use mold_core::types::AdvancedPlacement;
481    use mold_inference::shared_pool::SharedPool;
482
483    /// Build a test GpuWorker with a scratch job channel and everything else
484    /// in neutral defaults. Returns the worker plus the receiver so the test
485    /// can verify what was dispatched.
486    fn test_worker(
487        ordinal: usize,
488        total_vram_bytes: u64,
489    ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
490        let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
491        let worker = Arc::new(GpuWorker {
492            gpu: DiscoveredGpu {
493                ordinal,
494                name: format!("test-gpu-{ordinal}"),
495                total_vram_bytes,
496                free_vram_bytes: total_vram_bytes,
497            },
498            model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
499            active_generation: Arc::new(RwLock::new(None)),
500            model_load_lock: Arc::new(Mutex::new(())),
501            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
502            in_flight: AtomicUsize::new(0),
503            consecutive_failures: AtomicUsize::new(0),
504            poisoned: AtomicBool::new(false),
505            fatal_cuda_error: Arc::new(AtomicBool::new(false)),
506            fatal_cuda_shutdown: Arc::new(tokio::sync::Notify::new()),
507            degraded_until: RwLock::new(None),
508            job_tx,
509        });
510        (worker, job_rx)
511    }
512
513    #[test]
514    fn poisoned_worker_stays_degraded_after_cooldown_expiry() {
515        let (worker, _rx) = test_worker(0, 24_000_000_000);
516        worker.poisoned.store(true, Ordering::SeqCst);
517        worker.consecutive_failures.store(3, Ordering::SeqCst);
518        *worker.degraded_until.write().unwrap() = Some(Instant::now() - Duration::from_secs(1));
519
520        assert!(worker.is_degraded());
521        assert_eq!(worker.consecutive_failures.load(Ordering::SeqCst), 3);
522        assert!(worker.degraded_until.read().unwrap().is_some());
523    }
524
525    /// When GPU 0 is actively generating a different model, the cache
526    /// take-and-restore pattern has already removed its entry — so
527    /// `cache.active_model()` returns None and the worker LOOKS idle
528    /// to the old classifier. The dispatcher must fall back to
529    /// `in_flight > 0` (or `active_generation`) to avoid routing a
530    /// brand-new job to the busy GPU while a sibling sits idle.
531    #[test]
532    fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
533        let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
534        let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
535
536        // Simulate the dispatcher having incremented in_flight before send,
537        // and the worker thread having called cache.take() → empty cache.
538        busy.in_flight.store(1, Ordering::SeqCst);
539
540        let pool = GpuPool {
541            workers: vec![busy.clone(), idle.clone()],
542        };
543
544        let picked = pool
545            .select_worker("some-small-model:q4", 6_000_000_000)
546            .expect("a worker should be selected");
547        assert_eq!(
548            picked.gpu.ordinal, 1,
549            "new job for an unloaded model must go to the truly idle GPU, \
550             not to the one whose cache momentarily looks empty because \
551             generation is in progress"
552        );
553    }
554
555    /// active_generation is set before take() and cleared after restore(),
556    /// so a worker mid-inference should be treated as busy even if the
557    /// dispatcher hasn't yet bumped in_flight (belt-and-suspenders).
558    #[test]
559    fn select_worker_respects_active_generation_flag() {
560        let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
561        let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
562
563        *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
564            model: "big-model".to_string(),
565            prompt_sha256: String::new(),
566            started_at_unix_ms: 0,
567            started_at: Instant::now(),
568        });
569
570        let pool = GpuPool {
571            workers: vec![busy.clone(), idle.clone()],
572        };
573
574        let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
575        assert_eq!(picked.gpu.ordinal, 1);
576    }
577
578    /// Regression guard for the happy path — both GPUs are idle and empty.
579    /// The strategy says "prefer the smallest GPU that fits" to spread
580    /// hot models across free cards.
581    #[test]
582    fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
583        let (big, _big_rx) = test_worker(0, 24_000_000_000);
584        let (small, _small_rx) = test_worker(1, 12_000_000_000);
585
586        let pool = GpuPool {
587            workers: vec![big.clone(), small.clone()],
588        };
589
590        // A 6GB model fits on both — should pick the smaller card.
591        let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
592        assert_eq!(picked.gpu.ordinal, 1);
593    }
594
595    /// If both eligible GPUs are busy with *other* models, fall back to
596    /// the "most headroom" tier instead of deadlocking.
597    #[test]
598    fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
599        let (a, _a_rx) = test_worker(0, 24_000_000_000);
600        let (b, _b_rx) = test_worker(1, 12_000_000_000);
601        a.in_flight.store(1, Ordering::SeqCst);
602        b.in_flight.store(1, Ordering::SeqCst);
603
604        let pool = GpuPool {
605            workers: vec![a.clone(), b.clone()],
606        };
607
608        let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
609        // Both busy → "most headroom" — the larger GPU wins.
610        assert_eq!(picked.gpu.ordinal, 0);
611    }
612
613    /// A second job for the model that's RUNNING on GPU 0 must spread to an
614    /// idle empty sibling instead of queueing behind the warm-but-busy card.
615    /// The cold load is paid once; afterwards that sibling is warm too and
616    /// tier 1 takes over. (Previously warm-busy beat idle-empty, so a 4-GPU
617    /// box serialized same-model jobs on one card while three sat idle.)
618    #[test]
619    fn select_worker_spreads_same_model_to_idle_gpu_over_busy_warm_worker() {
620        let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
621        let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
622
623        warm_busy.in_flight.store(1, Ordering::SeqCst);
624        *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
625            model: "flux-dev:q4".to_string(),
626            prompt_sha256: String::new(),
627            started_at_unix_ms: 0,
628            started_at: Instant::now(),
629        });
630
631        let pool = GpuPool {
632            workers: vec![warm_busy.clone(), cold_idle.clone()],
633        };
634
635        let picked = pool
636            .select_worker("flux-dev:q4", 6_000_000_000)
637            .expect("idle sibling should be preferred");
638        assert_eq!(
639            picked.gpu.ordinal, 1,
640            "same-model job must run in parallel on the idle GPU, not queue \
641             behind the busy warm one"
642        );
643    }
644
645    /// The spread only happens when the idle GPU can actually hold the
646    /// model: if no idle card fits, queueing behind the warm busy worker
647    /// beats cold-loading onto a card that would have to offload.
648    #[test]
649    fn select_worker_queues_behind_warm_worker_when_no_idle_gpu_fits() {
650        let (warm_busy, _warm_busy_rx) = test_worker(0, 48_000_000_000);
651        let (small_idle, _small_idle_rx) = test_worker(1, 12_000_000_000);
652
653        warm_busy.in_flight.store(1, Ordering::SeqCst);
654        *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
655            model: "ltx-2.3-22b-dev:fp8".to_string(),
656            prompt_sha256: String::new(),
657            started_at_unix_ms: 0,
658            started_at: Instant::now(),
659        });
660
661        let pool = GpuPool {
662            workers: vec![warm_busy.clone(), small_idle.clone()],
663        };
664
665        let picked = pool
666            .select_worker("ltx-2.3-22b-dev:fp8", 34_000_000_000)
667            .expect("a worker should be selected");
668        assert_eq!(
669            picked.gpu.ordinal, 0,
670            "a too-small idle GPU must not win over the warm busy worker"
671        );
672    }
673
674    #[test]
675    fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
676        let (worker, _rx) = test_worker(1, 24_000_000_000);
677        let pool = GpuPool {
678            workers: vec![worker],
679        };
680        let placement = DevicePlacement {
681            text_encoders: DeviceRef::Auto,
682            advanced: Some(AdvancedPlacement {
683                transformer: DeviceRef::gpu(1),
684                ..AdvancedPlacement::default()
685            }),
686        };
687
688        assert_eq!(
689            pool.resolve_explicit_placement_gpu(Some(&placement))
690                .unwrap(),
691            Some(1)
692        );
693    }
694
695    #[test]
696    fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
697        let (worker0, _rx0) = test_worker(0, 24_000_000_000);
698        let (worker1, _rx1) = test_worker(1, 24_000_000_000);
699        let pool = GpuPool {
700            workers: vec![worker0, worker1],
701        };
702        let placement = DevicePlacement {
703            text_encoders: DeviceRef::gpu(0),
704            advanced: Some(AdvancedPlacement {
705                transformer: DeviceRef::gpu(1),
706                ..AdvancedPlacement::default()
707            }),
708        };
709
710        let err = pool
711            .resolve_explicit_placement_gpu(Some(&placement))
712            .unwrap_err();
713        assert!(err.contains("one GPU ordinal per request"), "{err}");
714    }
715
716    #[test]
717    fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
718        let (worker1, _rx1) = test_worker(1, 24_000_000_000);
719        let pool = GpuPool {
720            workers: vec![worker1],
721        };
722        let placement = DevicePlacement {
723            text_encoders: DeviceRef::Auto,
724            advanced: Some(AdvancedPlacement {
725                transformer: DeviceRef::gpu(0),
726                ..AdvancedPlacement::default()
727            }),
728        };
729
730        let err = pool
731            .resolve_explicit_placement_gpu(Some(&placement))
732            .unwrap_err();
733        assert!(err.contains("gpu:0"), "{err}");
734        assert!(err.contains("[1]"), "{err}");
735    }
736
737    /// `is_degraded()` lazily resets the failure counter when the cooldown
738    /// has expired. Without this, a worker that took 3 historical failures
739    /// would re-degrade on the very first post-cooldown failure (because
740    /// `consecutive_failures` was still ≥ 3 from before, even though the
741    /// time-based gate had already opened back up).
742    #[test]
743    fn is_degraded_clears_counter_when_cooldown_has_expired() {
744        let (worker, _rx) = test_worker(0, 24_000_000_000);
745        worker.consecutive_failures.store(3, Ordering::SeqCst);
746        // Simulate "cooldown expired 1 second ago".
747        *worker.degraded_until.write().unwrap() =
748            Some(Instant::now() - std::time::Duration::from_secs(1));
749
750        assert!(
751            !worker.is_degraded(),
752            "expired cooldown must mark the worker as healthy again",
753        );
754        assert_eq!(
755            worker.consecutive_failures.load(Ordering::SeqCst),
756            0,
757            "expired cooldown must lazy-reset the failure counter so a \
758             single post-cooldown failure doesn't immediately re-degrade",
759        );
760        assert!(
761            worker.degraded_until.read().unwrap().is_none(),
762            "expired cooldown must clear the timestamp",
763        );
764    }
765
766    #[test]
767    fn is_degraded_respects_active_cooldown() {
768        let (worker, _rx) = test_worker(0, 24_000_000_000);
769        worker.consecutive_failures.store(3, Ordering::SeqCst);
770        // Cooldown still active for another 60s.
771        *worker.degraded_until.write().unwrap() =
772            Some(Instant::now() + std::time::Duration::from_secs(60));
773
774        assert!(
775            worker.is_degraded(),
776            "active cooldown must keep the worker degraded",
777        );
778        assert_eq!(
779            worker.consecutive_failures.load(Ordering::SeqCst),
780            3,
781            "active cooldown must NOT reset the counter",
782        );
783    }
784
785    #[test]
786    fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
787        let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
788        clear_model_cuda_ooms_for_tests();
789        let model = "flux2-klein-9b:bf16";
790
791        let first = record_model_cuda_oom(model, 0);
792        assert!(
793            !first.is_unschedulable(),
794            "first OOM only records the failed ordinal"
795        );
796        assert!(
797            model_unschedulable_message(model).is_none(),
798            "a single-GPU OOM should not cool down the model yet"
799        );
800
801        let second = record_model_cuda_oom(model, 1);
802        assert!(
803            second.is_unschedulable(),
804            "OOM on a sibling GPU should mark the model unschedulable"
805        );
806        let msg = model_unschedulable_message(model).expect("cooldown message");
807        assert!(msg.contains(model), "{msg}");
808        assert!(msg.contains("temporarily unschedulable"), "{msg}");
809
810        clear_model_cuda_ooms_for_tests();
811    }
812
813    #[test]
814    fn failed_model_ordinals_can_be_skipped_before_cooldown() {
815        let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
816        clear_model_cuda_ooms_for_tests();
817        let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
818        let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
819        let pool = GpuPool {
820            workers: vec![failed, untested.clone()],
821        };
822        let model = "flux2-klein-9b:bf16";
823
824        record_model_cuda_oom(model, 0);
825        let skip = failed_ordinals_for_model(model);
826        let picked = pool
827            .select_worker_excluding(model, 32_000_000_000, &skip)
828            .expect("sibling GPU should be tried before cooldown");
829
830        assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
831        clear_model_cuda_ooms_for_tests();
832    }
833}