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. Idle GPU with no model that FITS the estimate — spread beats
386        //    queueing behind a busy warm card: the cold load is paid once,
387        //    then that GPU is warm and tier 1 takes over for later jobs.
388        //    (Warm-busy used to outrank this, which serialized same-model
389        //    jobs on one card of a multi-GPU box while siblings sat idle.)
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
398        // 3. Loaded but busy — least in-flight wins. Reached only when no
399        //    idle GPU can hold the model: waiting for the warm card beats
400        //    cold-loading onto a card that would have to offload.
401        if !loaded_busy.is_empty() {
402            loaded_busy.sort_by_key(|w| w.in_flight.load(Ordering::SeqCst));
403            return loaded_busy.first().map(|w| (*w).clone());
404        }
405
406        // 4. Idle GPU that doesn't fit — largest wins; block offloading may
407        //    still make the model viable there.
408        if !idle_empty.is_empty() {
409            return idle_empty.last().map(|w| (*w).clone());
410        }
411
412        // 5. Everything left — busy with other models, or idle with a
413        //    different model loaded (evicting a warm sibling has its own
414        //    cost). Most headroom first; the LRU cache evicts there.
415        let mut busy = other;
416        busy.sort_by(|a, b| {
417            let a_headroom = a.gpu.total_vram_bytes.saturating_sub(estimated_vram);
418            let b_headroom = b.gpu.total_vram_bytes.saturating_sub(estimated_vram);
419            b_headroom.cmp(&a_headroom)
420        });
421        busy.first().map(|w| (*w).clone())
422    }
423
424    /// Collect status from all workers.
425    pub fn gpu_status(&self) -> Vec<GpuWorkerStatus> {
426        self.workers.iter().map(|w| w.status()).collect()
427    }
428
429    /// Number of GPU workers in the pool.
430    pub fn worker_count(&self) -> usize {
431        self.workers.len()
432    }
433}
434
435fn placement_gpu_ordinals(placement: &DevicePlacement) -> BTreeSet<usize> {
436    let mut ordinals = BTreeSet::new();
437    collect_gpu_ordinal(placement.text_encoders, &mut ordinals);
438    if let Some(adv) = placement.advanced.as_ref() {
439        collect_gpu_ordinal(adv.transformer, &mut ordinals);
440        collect_gpu_ordinal(adv.vae, &mut ordinals);
441        if let Some(device) = adv.clip_l {
442            collect_gpu_ordinal(device, &mut ordinals);
443        }
444        if let Some(device) = adv.clip_g {
445            collect_gpu_ordinal(device, &mut ordinals);
446        }
447        if let Some(device) = adv.t5 {
448            collect_gpu_ordinal(device, &mut ordinals);
449        }
450        if let Some(device) = adv.qwen {
451            collect_gpu_ordinal(device, &mut ordinals);
452        }
453    }
454    ordinals
455}
456
457fn collect_gpu_ordinal(device: DeviceRef, out: &mut BTreeSet<usize>) {
458    if let DeviceRef::Gpu { ordinal } = device {
459        out.insert(ordinal);
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    static MODEL_CUDA_OOM_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
468    use crate::model_cache::ModelCache;
469    use mold_core::types::AdvancedPlacement;
470    use mold_inference::shared_pool::SharedPool;
471
472    /// Build a test GpuWorker with a scratch job channel and everything else
473    /// in neutral defaults. Returns the worker plus the receiver so the test
474    /// can verify what was dispatched.
475    fn test_worker(
476        ordinal: usize,
477        total_vram_bytes: u64,
478    ) -> (Arc<GpuWorker>, std::sync::mpsc::Receiver<GpuJob>) {
479        let (job_tx, job_rx) = std::sync::mpsc::sync_channel(2);
480        let worker = Arc::new(GpuWorker {
481            gpu: DiscoveredGpu {
482                ordinal,
483                name: format!("test-gpu-{ordinal}"),
484                total_vram_bytes,
485                free_vram_bytes: total_vram_bytes,
486            },
487            model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
488            active_generation: Arc::new(RwLock::new(None)),
489            model_load_lock: Arc::new(Mutex::new(())),
490            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
491            in_flight: AtomicUsize::new(0),
492            consecutive_failures: AtomicUsize::new(0),
493            degraded_until: RwLock::new(None),
494            job_tx,
495        });
496        (worker, job_rx)
497    }
498
499    /// When GPU 0 is actively generating a different model, the cache
500    /// take-and-restore pattern has already removed its entry — so
501    /// `cache.active_model()` returns None and the worker LOOKS idle
502    /// to the old classifier. The dispatcher must fall back to
503    /// `in_flight > 0` (or `active_generation`) to avoid routing a
504    /// brand-new job to the busy GPU while a sibling sits idle.
505    #[test]
506    fn select_worker_prefers_truly_idle_gpu_over_busy_gpu_with_empty_cache() {
507        let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
508        let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
509
510        // Simulate the dispatcher having incremented in_flight before send,
511        // and the worker thread having called cache.take() → empty cache.
512        busy.in_flight.store(1, Ordering::SeqCst);
513
514        let pool = GpuPool {
515            workers: vec![busy.clone(), idle.clone()],
516        };
517
518        let picked = pool
519            .select_worker("some-small-model:q4", 6_000_000_000)
520            .expect("a worker should be selected");
521        assert_eq!(
522            picked.gpu.ordinal, 1,
523            "new job for an unloaded model must go to the truly idle GPU, \
524             not to the one whose cache momentarily looks empty because \
525             generation is in progress"
526        );
527    }
528
529    /// active_generation is set before take() and cleared after restore(),
530    /// so a worker mid-inference should be treated as busy even if the
531    /// dispatcher hasn't yet bumped in_flight (belt-and-suspenders).
532    #[test]
533    fn select_worker_respects_active_generation_flag() {
534        let (busy, _busy_rx) = test_worker(0, 24_000_000_000);
535        let (idle, _idle_rx) = test_worker(1, 24_000_000_000);
536
537        *busy.active_generation.write().unwrap() = Some(ActiveGeneration {
538            model: "big-model".to_string(),
539            prompt_sha256: String::new(),
540            started_at_unix_ms: 0,
541            started_at: Instant::now(),
542        });
543
544        let pool = GpuPool {
545            workers: vec![busy.clone(), idle.clone()],
546        };
547
548        let picked = pool.select_worker("small-model:q4", 6_000_000_000).unwrap();
549        assert_eq!(picked.gpu.ordinal, 1);
550    }
551
552    /// Regression guard for the happy path — both GPUs are idle and empty.
553    /// The strategy says "prefer the smallest GPU that fits" to spread
554    /// hot models across free cards.
555    #[test]
556    fn select_worker_spreads_to_smallest_fitting_idle_gpu() {
557        let (big, _big_rx) = test_worker(0, 24_000_000_000);
558        let (small, _small_rx) = test_worker(1, 12_000_000_000);
559
560        let pool = GpuPool {
561            workers: vec![big.clone(), small.clone()],
562        };
563
564        // A 6GB model fits on both — should pick the smaller card.
565        let picked = pool.select_worker("flux-dev:q4", 6_000_000_000).unwrap();
566        assert_eq!(picked.gpu.ordinal, 1);
567    }
568
569    /// If both eligible GPUs are busy with *other* models, fall back to
570    /// the "most headroom" tier instead of deadlocking.
571    #[test]
572    fn select_worker_falls_back_when_all_gpus_busy_with_other_models() {
573        let (a, _a_rx) = test_worker(0, 24_000_000_000);
574        let (b, _b_rx) = test_worker(1, 12_000_000_000);
575        a.in_flight.store(1, Ordering::SeqCst);
576        b.in_flight.store(1, Ordering::SeqCst);
577
578        let pool = GpuPool {
579            workers: vec![a.clone(), b.clone()],
580        };
581
582        let picked = pool.select_worker("new-model", 6_000_000_000).unwrap();
583        // Both busy → "most headroom" — the larger GPU wins.
584        assert_eq!(picked.gpu.ordinal, 0);
585    }
586
587    /// A second job for the model that's RUNNING on GPU 0 must spread to an
588    /// idle empty sibling instead of queueing behind the warm-but-busy card.
589    /// The cold load is paid once; afterwards that sibling is warm too and
590    /// tier 1 takes over. (Previously warm-busy beat idle-empty, so a 4-GPU
591    /// box serialized same-model jobs on one card while three sat idle.)
592    #[test]
593    fn select_worker_spreads_same_model_to_idle_gpu_over_busy_warm_worker() {
594        let (warm_busy, _warm_busy_rx) = test_worker(0, 24_000_000_000);
595        let (cold_idle, _cold_idle_rx) = test_worker(1, 24_000_000_000);
596
597        warm_busy.in_flight.store(1, Ordering::SeqCst);
598        *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
599            model: "flux-dev:q4".to_string(),
600            prompt_sha256: String::new(),
601            started_at_unix_ms: 0,
602            started_at: Instant::now(),
603        });
604
605        let pool = GpuPool {
606            workers: vec![warm_busy.clone(), cold_idle.clone()],
607        };
608
609        let picked = pool
610            .select_worker("flux-dev:q4", 6_000_000_000)
611            .expect("idle sibling should be preferred");
612        assert_eq!(
613            picked.gpu.ordinal, 1,
614            "same-model job must run in parallel on the idle GPU, not queue \
615             behind the busy warm one"
616        );
617    }
618
619    /// The spread only happens when the idle GPU can actually hold the
620    /// model: if no idle card fits, queueing behind the warm busy worker
621    /// beats cold-loading onto a card that would have to offload.
622    #[test]
623    fn select_worker_queues_behind_warm_worker_when_no_idle_gpu_fits() {
624        let (warm_busy, _warm_busy_rx) = test_worker(0, 48_000_000_000);
625        let (small_idle, _small_idle_rx) = test_worker(1, 12_000_000_000);
626
627        warm_busy.in_flight.store(1, Ordering::SeqCst);
628        *warm_busy.active_generation.write().unwrap() = Some(ActiveGeneration {
629            model: "ltx-2.3-22b-dev:fp8".to_string(),
630            prompt_sha256: String::new(),
631            started_at_unix_ms: 0,
632            started_at: Instant::now(),
633        });
634
635        let pool = GpuPool {
636            workers: vec![warm_busy.clone(), small_idle.clone()],
637        };
638
639        let picked = pool
640            .select_worker("ltx-2.3-22b-dev:fp8", 34_000_000_000)
641            .expect("a worker should be selected");
642        assert_eq!(
643            picked.gpu.ordinal, 0,
644            "a too-small idle GPU must not win over the warm busy worker"
645        );
646    }
647
648    #[test]
649    fn resolve_explicit_placement_gpu_accepts_single_worker_ordinal() {
650        let (worker, _rx) = test_worker(1, 24_000_000_000);
651        let pool = GpuPool {
652            workers: vec![worker],
653        };
654        let placement = DevicePlacement {
655            text_encoders: DeviceRef::Auto,
656            advanced: Some(AdvancedPlacement {
657                transformer: DeviceRef::gpu(1),
658                ..AdvancedPlacement::default()
659            }),
660        };
661
662        assert_eq!(
663            pool.resolve_explicit_placement_gpu(Some(&placement))
664                .unwrap(),
665            Some(1)
666        );
667    }
668
669    #[test]
670    fn resolve_explicit_placement_gpu_rejects_cross_gpu_requests() {
671        let (worker0, _rx0) = test_worker(0, 24_000_000_000);
672        let (worker1, _rx1) = test_worker(1, 24_000_000_000);
673        let pool = GpuPool {
674            workers: vec![worker0, worker1],
675        };
676        let placement = DevicePlacement {
677            text_encoders: DeviceRef::gpu(0),
678            advanced: Some(AdvancedPlacement {
679                transformer: DeviceRef::gpu(1),
680                ..AdvancedPlacement::default()
681            }),
682        };
683
684        let err = pool
685            .resolve_explicit_placement_gpu(Some(&placement))
686            .unwrap_err();
687        assert!(err.contains("one GPU ordinal per request"), "{err}");
688    }
689
690    #[test]
691    fn resolve_explicit_placement_gpu_rejects_ordinals_outside_pool() {
692        let (worker1, _rx1) = test_worker(1, 24_000_000_000);
693        let pool = GpuPool {
694            workers: vec![worker1],
695        };
696        let placement = DevicePlacement {
697            text_encoders: DeviceRef::Auto,
698            advanced: Some(AdvancedPlacement {
699                transformer: DeviceRef::gpu(0),
700                ..AdvancedPlacement::default()
701            }),
702        };
703
704        let err = pool
705            .resolve_explicit_placement_gpu(Some(&placement))
706            .unwrap_err();
707        assert!(err.contains("gpu:0"), "{err}");
708        assert!(err.contains("[1]"), "{err}");
709    }
710
711    /// `is_degraded()` lazily resets the failure counter when the cooldown
712    /// has expired. Without this, a worker that took 3 historical failures
713    /// would re-degrade on the very first post-cooldown failure (because
714    /// `consecutive_failures` was still ≥ 3 from before, even though the
715    /// time-based gate had already opened back up).
716    #[test]
717    fn is_degraded_clears_counter_when_cooldown_has_expired() {
718        let (worker, _rx) = test_worker(0, 24_000_000_000);
719        worker.consecutive_failures.store(3, Ordering::SeqCst);
720        // Simulate "cooldown expired 1 second ago".
721        *worker.degraded_until.write().unwrap() =
722            Some(Instant::now() - std::time::Duration::from_secs(1));
723
724        assert!(
725            !worker.is_degraded(),
726            "expired cooldown must mark the worker as healthy again",
727        );
728        assert_eq!(
729            worker.consecutive_failures.load(Ordering::SeqCst),
730            0,
731            "expired cooldown must lazy-reset the failure counter so a \
732             single post-cooldown failure doesn't immediately re-degrade",
733        );
734        assert!(
735            worker.degraded_until.read().unwrap().is_none(),
736            "expired cooldown must clear the timestamp",
737        );
738    }
739
740    #[test]
741    fn is_degraded_respects_active_cooldown() {
742        let (worker, _rx) = test_worker(0, 24_000_000_000);
743        worker.consecutive_failures.store(3, Ordering::SeqCst);
744        // Cooldown still active for another 60s.
745        *worker.degraded_until.write().unwrap() =
746            Some(Instant::now() + std::time::Duration::from_secs(60));
747
748        assert!(
749            worker.is_degraded(),
750            "active cooldown must keep the worker degraded",
751        );
752        assert_eq!(
753            worker.consecutive_failures.load(Ordering::SeqCst),
754            3,
755            "active cooldown must NOT reset the counter",
756        );
757    }
758
759    #[test]
760    fn model_oom_on_sibling_gpu_marks_model_unschedulable() {
761        let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
762        clear_model_cuda_ooms_for_tests();
763        let model = "flux2-klein-9b:bf16";
764
765        let first = record_model_cuda_oom(model, 0);
766        assert!(
767            !first.is_unschedulable(),
768            "first OOM only records the failed ordinal"
769        );
770        assert!(
771            model_unschedulable_message(model).is_none(),
772            "a single-GPU OOM should not cool down the model yet"
773        );
774
775        let second = record_model_cuda_oom(model, 1);
776        assert!(
777            second.is_unschedulable(),
778            "OOM on a sibling GPU should mark the model unschedulable"
779        );
780        let msg = model_unschedulable_message(model).expect("cooldown message");
781        assert!(msg.contains(model), "{msg}");
782        assert!(msg.contains("temporarily unschedulable"), "{msg}");
783
784        clear_model_cuda_ooms_for_tests();
785    }
786
787    #[test]
788    fn failed_model_ordinals_can_be_skipped_before_cooldown() {
789        let _guard = MODEL_CUDA_OOM_TEST_LOCK.lock().unwrap();
790        clear_model_cuda_ooms_for_tests();
791        let (failed, _failed_rx) = test_worker(0, 24_000_000_000);
792        let (untested, _untested_rx) = test_worker(1, 24_000_000_000);
793        let pool = GpuPool {
794            workers: vec![failed, untested.clone()],
795        };
796        let model = "flux2-klein-9b:bf16";
797
798        record_model_cuda_oom(model, 0);
799        let skip = failed_ordinals_for_model(model);
800        let picked = pool
801            .select_worker_excluding(model, 32_000_000_000, &skip)
802            .expect("sibling GPU should be tried before cooldown");
803
804        assert_eq!(picked.gpu.ordinal, untested.gpu.ordinal);
805        clear_model_cuda_ooms_for_tests();
806    }
807}