Skip to main content

mold_server/
gpu_worker.rs

1use crate::gpu_pool::{ActiveGeneration, GpuJob, GpuWorker};
2use crate::model_cache::ModelResidency;
3use crate::queue::{
4    build_sse_complete_event, clean_error_message, save_image_to_dir, save_video_to_dir,
5};
6use crate::state::{GenerationJobResult, SseMessage};
7use mold_core::{
8    Config, ImageData, ModelPaths, OutputFormat, OutputMetadata, SseErrorEvent, SseProgressEvent,
9};
10use mold_inference::device;
11use sha2::{Digest, Sha256};
12use std::sync::atomic::Ordering;
13use std::sync::Arc;
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16/// Spawn the dedicated OS thread for a GPU worker.
17/// Returns the JoinHandle (caller should keep it alive).
18pub fn spawn_gpu_thread(
19    worker: Arc<GpuWorker>,
20    job_rx: std::sync::mpsc::Receiver<GpuJob>,
21) -> std::thread::JoinHandle<()> {
22    std::thread::Builder::new()
23        .name(format!("gpu-worker-{}", worker.gpu.ordinal))
24        .spawn(move || {
25            // Bind this thread to its GPU ordinal so `create_device` /
26            // `reclaim_gpu_memory` can debug-assert callers don't drift onto
27            // a sibling GPU's context. See device::init_thread_gpu_ordinal.
28            mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
29            tracing::info!(
30                gpu = worker.gpu.ordinal,
31                name = %worker.gpu.name,
32                "GPU worker thread started"
33            );
34            for job in job_rx.iter() {
35                process_job(&worker, job);
36            }
37            tracing::info!(gpu = worker.gpu.ordinal, "GPU worker thread exiting");
38        })
39        .expect("failed to spawn GPU worker thread")
40}
41
42/// Convert an inference-crate progress event to an SSE wire event.
43fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
44    event.into()
45}
46
47/// Detect a CUDA out-of-memory error anywhere in the anyhow cause chain.
48///
49/// Candle surfaces these as `DriverError(CUDA_ERROR_OUT_OF_MEMORY, …)` wrapped
50/// in an `anyhow::Error`. The string representation is the only stable signal
51/// (the cudarc error type doesn't implement `std::error::Error` downcast target
52/// in the candle re-export), so we pattern-match the formatted chain.
53pub(crate) fn is_cuda_oom(e: &anyhow::Error) -> bool {
54    let full = format!("{e:#}");
55    full.contains("CUDA_ERROR_OUT_OF_MEMORY") || full.contains("out of memory")
56}
57
58/// Build a user-friendly error message for a CUDA OOM that occurred during
59/// LTX-Video generation. The raw `DriverError(CUDA_ERROR_OUT_OF_MEMORY, …)`
60/// is opaque; replace it with actionable guidance.
61pub(crate) fn oom_user_message(model_name: &str) -> String {
62    format!(
63        "GPU ran out of memory loading or running '{model_name}'. \
64         Try: reduce --frames (e.g. 17 or 9), lower --width/--height, \
65         use a quantized variant (e.g. ':q8'), or close other GPU apps."
66    )
67}
68
69fn cuda_oom_user_message(worker: &GpuWorker, model_name: &str) -> (String, bool) {
70    let base = oom_user_message(model_name);
71    let outcome = crate::gpu_pool::record_model_cuda_oom(model_name, worker.gpu.ordinal);
72    if outcome.is_unschedulable() {
73        if let Some(cooldown) = crate::gpu_pool::model_unschedulable_message(model_name) {
74            return (format!("{base} {cooldown}"), false);
75        }
76    }
77    (base, true)
78}
79
80fn process_job(worker: &GpuWorker, job: GpuJob) {
81    let model_name = job.model.clone();
82    let ordinal = worker.gpu.ordinal;
83    let job_id = job.id.clone();
84
85    // Release the global queue slot AND the registry entry when this job
86    // finishes, regardless of which exit path runs. The dispatcher only
87    // decrements when it *fails* to dispatch — once we own the GpuJob, we
88    // own both pieces of cleanup. Combining them in one drop guard keeps
89    // the two counters from drifting on early-return paths.
90    struct CleanupGuard {
91        queue: crate::state::QueueHandle,
92        registry: crate::job_registry::SharedJobRegistry,
93        id: String,
94    }
95    impl Drop for CleanupGuard {
96        fn drop(&mut self) {
97            self.queue.decrement();
98            self.registry.remove(&self.id);
99        }
100    }
101    let _cleanup = CleanupGuard {
102        queue: job.queue.clone(),
103        registry: job.registry.clone(),
104        id: job_id.clone(),
105    };
106
107    if job.result_tx.is_closed() {
108        tracing::debug!(gpu = ordinal, model = %model_name, "skipping dispatched job — client disconnected");
109        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
110        return;
111    }
112
113    // Mark the registry entry as running on this specific GPU. The /api/queue
114    // listing now shows this row as `state: "running"` with `gpu: <ordinal>`.
115    job.registry.mark_running(&job_id, Some(ordinal));
116
117    tracing::info!(gpu = ordinal, model = %model_name, "dispatched job");
118
119    // Acquire per-GPU load lock — ensures only one model load at a time per GPU.
120    let _load_lock = worker.model_load_lock.lock().unwrap();
121
122    // Ensure model is loaded on this GPU.
123    let config_snapshot = job.config.blocking_read().clone();
124    let activation_hint =
125        crate::model_manager::activation_hint_for_request_sync(&config_snapshot, &job.request);
126    if let Err(e) = ensure_model_ready_sync(worker, &model_name, &config_snapshot, activation_hint)
127    {
128        tracing::error!(gpu = ordinal, model = %model_name, "Failed to load model: {e}");
129        // Detect CUDA OOM during load: synchronize the device so subsequent
130        // allocations don't inherit a poisoned context, then surface a
131        // user-friendly message instead of the opaque DriverError string.
132        let is_oom = is_cuda_oom(&e);
133        let (err_msg, count_worker_failure) = if is_oom {
134            mold_inference::device::try_synchronize_device(ordinal);
135            cuda_oom_user_message(worker, &model_name)
136        } else {
137            (
138                format!("model load error: {}", clean_error_message(&e)),
139                true,
140            )
141        };
142        if let Some(ref tx) = job.progress_tx {
143            let _ = tx.send(SseMessage::Error(SseErrorEvent {
144                message: err_msg.clone(),
145            }));
146        }
147        let _ = job.result_tx.send(Err(err_msg));
148        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
149        if count_worker_failure {
150            record_failure(worker);
151        }
152        return;
153    }
154
155    // Set active generation state.
156    {
157        let mut gen = worker.active_generation.write().unwrap();
158        *gen = Some(ActiveGeneration {
159            model: model_name.clone(),
160            prompt_sha256: format!("{:x}", Sha256::digest(job.request.prompt.as_bytes())),
161            started_at_unix_ms: SystemTime::now()
162                .duration_since(UNIX_EPOCH)
163                .unwrap_or_default()
164                .as_millis() as u64,
165            started_at: Instant::now(),
166        });
167    }
168
169    if job.result_tx.is_closed() {
170        tracing::debug!(
171            gpu = ordinal,
172            model = %model_name,
173            "skipping generation after model readiness — client disconnected"
174        );
175        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
176        clear_active_generation(worker);
177        return;
178    }
179
180    // Take-and-restore: remove engine from cache, release lock during inference.
181    let taken = {
182        let mut cache = worker.model_cache.lock().unwrap();
183        cache.take(&model_name)
184    };
185
186    let Some(mut cached_engine) = taken else {
187        let err_msg = "engine not found in cache after load".to_string();
188        if let Some(ref tx) = job.progress_tx {
189            let _ = tx.send(SseMessage::Error(SseErrorEvent {
190                message: err_msg.clone(),
191            }));
192        }
193        let _ = job.result_tx.send(Err(err_msg));
194        worker.in_flight.fetch_sub(1, Ordering::SeqCst);
195        clear_active_generation(worker);
196        return;
197    };
198
199    // Set progress callback if SSE streaming.
200    if let Some(ref progress_tx) = job.progress_tx {
201        let tx = progress_tx.clone();
202        cached_engine.engine.set_on_progress(Box::new(move |event| {
203            let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
204        }));
205    }
206
207    // RSS sample taken just before inference; the post-inference sample below
208    // logs the per-job delta so RAM growth can be attributed to a specific
209    // generation rather than tracked at process granularity.
210    let rss_before = crate::resources::ram_snapshot().used_by_mold;
211
212    // Watchdog: log RSS every 1s while inference runs so we can see RAM
213    // growth as it happens. The post-inference summary log can't fire when
214    // a runaway allocation crosses the OOM threshold mid-generation, so we
215    // need a heartbeat to attribute the explosion to a specific phase.
216    let watchdog_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
217    let watchdog_handle = {
218        let stop = watchdog_stop.clone();
219        let model = model_name.clone();
220        std::thread::Builder::new()
221            .name(format!("rss-watchdog-{ordinal}"))
222            .spawn(move || {
223                let start = Instant::now();
224                while !stop.load(Ordering::SeqCst) {
225                    std::thread::sleep(Duration::from_millis(1000));
226                    if stop.load(Ordering::SeqCst) {
227                        break;
228                    }
229                    let rss = crate::resources::ram_snapshot().used_by_mold;
230                    tracing::info!(
231                        gpu = ordinal,
232                        model = %model,
233                        elapsed_s = start.elapsed().as_secs(),
234                        rss_mb = rss / 1_000_000,
235                        "rss watchdog"
236                    );
237                }
238            })
239            .expect("failed to spawn RSS watchdog")
240    };
241
242    // Run inference — cache mutex is FREE during this.
243    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
244        cached_engine.engine.generate(&job.request)
245    }));
246
247    watchdog_stop.store(true, Ordering::SeqCst);
248    let _ = watchdog_handle.join();
249
250    // glibc keeps freed pages in per-arena heaps even after the allocations
251    // are dropped — large transient buffers from GGUF+LoRA rebuilds can leave
252    // tens of GB of unreclaimed RSS. `malloc_trim(0)` walks the arenas and
253    // returns idle pages to the OS via madvise(MADV_DONTNEED). Cheap (~ms),
254    // glibc-only, gated so we can A/B with `MOLD_MALLOC_TRIM=0`.
255    let trim_enabled = std::env::var("MOLD_MALLOC_TRIM")
256        .map(|v| v != "0")
257        .unwrap_or(true);
258    let rss_pre_trim = if trim_enabled {
259        let v = crate::resources::ram_snapshot().used_by_mold;
260        #[cfg(target_os = "linux")]
261        unsafe {
262            libc::malloc_trim(0);
263        }
264        Some(v)
265    } else {
266        None
267    };
268
269    let rss_after = crate::resources::ram_snapshot().used_by_mold;
270    let rss_delta = rss_after as i64 - rss_before as i64;
271    tracing::info!(
272        gpu = ordinal,
273        model = %model_name,
274        rss_before_mb = rss_before / 1_000_000,
275        rss_after_mb = rss_after / 1_000_000,
276        rss_delta_mb = rss_delta / 1_000_000,
277        rss_pre_trim_mb = rss_pre_trim.map(|v| v / 1_000_000).unwrap_or(0),
278        "generation memory delta"
279    );
280
281    // Clear progress callback.
282    cached_engine.engine.clear_on_progress();
283
284    // Restore engine to cache.
285    {
286        let mut cache = worker.model_cache.lock().unwrap();
287        cache.restore(cached_engine);
288    }
289
290    // Clear active generation.
291    clear_active_generation(worker);
292
293    // Decrement in-flight.
294    worker.in_flight.fetch_sub(1, Ordering::SeqCst);
295
296    match result {
297        Ok(Ok(mut response)) => {
298            // Reset failure counter on success.
299            worker.consecutive_failures.store(0, Ordering::SeqCst);
300            crate::gpu_pool::clear_model_cuda_oom(&model_name);
301
302            // Attach GPU ordinal to response.
303            response.gpu = Some(ordinal);
304
305            if response.images.is_empty() && response.video.is_none() {
306                let err_msg = "generation error: engine returned no images or video".to_string();
307                if let Some(ref tx) = job.progress_tx {
308                    let _ = tx.send(SseMessage::Error(SseErrorEvent {
309                        message: err_msg.clone(),
310                    }));
311                }
312                let _ = job.result_tx.send(Err(err_msg));
313                return;
314            }
315
316            // Extract the primary image (or video thumbnail).
317            let img = if !response.images.is_empty() {
318                response.images.remove(0)
319            } else if let Some(ref video) = response.video {
320                ImageData {
321                    data: video.thumbnail.clone(),
322                    format: OutputFormat::Png,
323                    width: video.width,
324                    height: video.height,
325                    index: 0,
326                }
327            } else {
328                unreachable!("checked above");
329            };
330
331            // Save to output directory if configured. Routes through the
332            // shared queue helpers so the metadata-DB upsert (and embedded
333            // chunks, hostname/backend tagging) cannot drift between the
334            // single-GPU and multi-GPU paths — historically this branch
335            // skipped the DB write, which left freshly-generated files
336            // invisible to /api/gallery until the next reconcile on
337            // server restart.
338            if let Some(ref dir) = job.output_dir {
339                let metadata = OutputMetadata::from_generate_request(
340                    &job.request,
341                    response.seed_used,
342                    None,
343                    mold_core::build_info::version_string(),
344                );
345                let generation_time_ms = response.generation_time_ms as i64;
346                let db = job.metadata_db.as_ref().as_ref();
347                if let Some(ref video) = response.video {
348                    save_video_to_dir(
349                        dir,
350                        &video.data,
351                        &video.gif_preview,
352                        video.format,
353                        &job.model,
354                        &metadata,
355                        Some(generation_time_ms),
356                        db,
357                    );
358                } else {
359                    save_image_to_dir(
360                        dir,
361                        &img,
362                        &job.model,
363                        job.request.batch_size,
364                        Some(&metadata),
365                        Some(generation_time_ms),
366                        db,
367                    );
368                }
369            }
370
371            // Send SSE complete event. Video responses carry the actual MP4 /
372            // GIF bytes plus frames / fps / thumbnail / audio metadata so the
373            // SSE client can reconstruct a `VideoData` — without this the
374            // Discord bot silently degraded every LTX-Video / LTX-2 response
375            // into an image attachment (the synthesized thumbnail PNG).
376            if let Some(ref tx) = job.progress_tx {
377                let event = build_sse_complete_event(&response, &img);
378                let _ = tx.send(SseMessage::Complete(event));
379            }
380
381            // Send result through oneshot.
382            let _ = job.result_tx.send(Ok(GenerationJobResult {
383                image: img,
384                response,
385            }));
386        }
387        Ok(Err(e)) => {
388            tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
389            // Detect CUDA OOM during inference: synchronize so subsequent
390            // allocations start from a clean CUDA context state, then surface
391            // a user-friendly message instead of the opaque DriverError string.
392            let is_oom = is_cuda_oom(&e);
393            let (err_msg, count_worker_failure) = if is_oom {
394                mold_inference::device::try_synchronize_device(ordinal);
395                cuda_oom_user_message(worker, &model_name)
396            } else {
397                (
398                    format!("generation error: {}", clean_error_message(&e)),
399                    true,
400                )
401            };
402            if count_worker_failure {
403                record_failure(worker);
404            }
405            if let Some(ref tx) = job.progress_tx {
406                let _ = tx.send(SseMessage::Error(SseErrorEvent {
407                    message: err_msg.clone(),
408                }));
409            }
410            let _ = job.result_tx.send(Err(err_msg));
411        }
412        Err(panic_payload) => {
413            tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
414            record_failure(worker);
415            let msg = panic_payload
416                .downcast_ref::<String>()
417                .map(|s| s.as_str())
418                .or_else(|| panic_payload.downcast_ref::<&str>().copied())
419                .unwrap_or("unknown panic");
420            let err_msg = format!("inference panicked: {msg}");
421            if let Some(ref tx) = job.progress_tx {
422                let _ = tx.send(SseMessage::Error(SseErrorEvent {
423                    message: err_msg.clone(),
424                }));
425            }
426            let _ = job.result_tx.send(Err(err_msg));
427        }
428    }
429}
430
431/// Preflight memory check with evict-to-fit recovery.
432///
433/// Wraps `model_manager::preflight_memory_guard`. On a budget failure, drops
434/// the LRU parked entry (skipping `model_name` so a parked-reload doesn't
435/// evict its own target), reclaims the GPU's CUDA pool when no engine remains
436/// resident, and retries. Loops until the preflight passes or the cache has
437/// no parked entries left to surrender — at which point the original
438/// insufficient-memory error is returned.
439///
440/// Holds the cache lock only for the brief eviction step; the engine drop and
441/// `reclaim_gpu_memory` run outside it. The caller is expected to hold
442/// `worker.model_load_lock`, which keeps a concurrent generation from slotting
443/// a fresh load into the context between our reclaim and the actual load.
444fn preflight_memory_guard_with_eviction(
445    cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
446    model_name: &str,
447    paths: &ModelPaths,
448    ordinal: usize,
449    hint: Option<crate::model_manager::ActivationHint>,
450) -> Result<(), crate::routes::ApiError> {
451    loop {
452        let active_vram = cache_lock
453            .lock()
454            .unwrap_or_else(|e| e.into_inner())
455            .active_vram_bytes();
456        let err = match crate::model_manager::preflight_memory_guard(
457            model_name,
458            paths,
459            active_vram,
460            ordinal,
461            hint,
462        ) {
463            Ok(()) => return Ok(()),
464            Err(e) => e,
465        };
466
467        let evicted = {
468            let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
469            cache.evict_lru_parked_except(Some(model_name))
470        };
471        let Some((evicted_name, engine)) = evicted else {
472            return Err(err);
473        };
474        tracing::info!(
475            gpu = ordinal,
476            target_model = %model_name,
477            evicted_model = %evicted_name,
478            "evicting LRU parked entry to fit incoming load"
479        );
480        // Drop outside the cache lock — `cuMemFree` and safetensor unmap
481        // can block other cache users during the drop.
482        drop(engine);
483
484        // Reclaim only when no GPU-resident engine remains. The parked-reload
485        // case has the active model still on GPU at preflight time, so we can
486        // free CPU caches via the eviction but must not nuke the primary
487        // context. The fresh-load case usually has no active model when this
488        // is hit, so the reclaim can run.
489        let safe_to_reclaim = cache_lock
490            .lock()
491            .unwrap_or_else(|e| e.into_inner())
492            .active_model()
493            .is_none();
494        if safe_to_reclaim {
495            device::reclaim_gpu_memory(ordinal);
496        }
497    }
498}
499
500/// Ensure a model is loaded on this worker's GPU.
501///
502/// Holds `worker.model_load_lock` implicitly via the caller for generation
503/// jobs; the admin API path acquires it explicitly via `load_blocking`.
504///
505/// `hint` carries the per-request activation budget (resolution + family).
506/// Pass `None` for admin / cache-prewarm loads with no resolution context.
507pub fn ensure_model_ready_sync(
508    worker: &GpuWorker,
509    model_name: &str,
510    config: &Config,
511    hint: Option<crate::model_manager::ActivationHint>,
512) -> anyhow::Result<()> {
513    let cache = worker.model_cache.lock().unwrap();
514
515    // Already loaded?
516    if let Some(entry) = cache.get(model_name) {
517        if entry.residency == ModelResidency::Gpu {
518            return Ok(());
519        }
520    }
521
522    // Check if we have it cached but not on GPU (Parked).
523    let has_cached = cache.contains(model_name);
524
525    // Snapshot the cached engine's paths (if any) for the preflight before
526    // dropping the lock. Cloning ModelPaths keeps the borrow scoped to this
527    // block. Active-VRAM is sampled inside the preflight helper itself so
528    // each retry sees fresh state.
529    let cached_paths = if has_cached {
530        cache
531            .get(model_name)
532            .and_then(|e| e.engine.model_paths().cloned())
533    } else {
534        None
535    };
536    drop(cache);
537
538    if has_cached {
539        // Preflight before unloading the active model — the active model's
540        // footprint counts toward effective availability since we're about
541        // to free it. On budget failure, evict-to-fit drops parked entries
542        // (other than `model_name` itself) and retries.
543        if let Some(ref paths) = cached_paths {
544            preflight_memory_guard_with_eviction(
545                &worker.model_cache,
546                model_name,
547                paths,
548                worker.gpu.ordinal,
549                hint,
550            )
551            .map_err(|e| anyhow::anyhow!(e.error))?;
552        }
553
554        // Unload active model first.
555        {
556            let mut cache = worker.model_cache.lock().unwrap();
557            cache.unload_active();
558        }
559        device::reclaim_gpu_memory(worker.gpu.ordinal);
560
561        // Take the engine out and reload it.
562        let mut engine = {
563            let mut cache = worker.model_cache.lock().unwrap();
564            cache
565                .remove(model_name)
566                .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
567        };
568
569        tracing::info!(
570            gpu = worker.gpu.ordinal,
571            model = %model_name,
572            "reloading cached engine..."
573        );
574        // Sample VRAM baseline before load so we can record the new model's
575        // per-load delta rather than the device-global usage.
576        let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
577        engine.load()?;
578
579        let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
580        // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
581        // safetensor unmap during the drop can block other cache users.
582        let evicted = {
583            let mut cache = worker.model_cache.lock().unwrap();
584            cache.insert_loaded(model_name.to_string(), engine, vram)
585        };
586        drop(evicted);
587        return Ok(());
588    }
589
590    // Not in cache — need to create from scratch.
591    // Resolve model paths.
592    let paths = ModelPaths::resolve(model_name, config).ok_or_else(|| {
593        // Catalog IDs (cv:/hf:) reach this path through the bridge in
594        // `model_manager::install_catalog_model`, which can synthesize a
595        // ModelConfig that's missing a required field (notably `vae`)
596        // when a canonical companion was never pulled. The legacy
597        // "Run: mold pull <id>" message is misleading there because the
598        // primary checkpoint IS on disk — the companion is what's
599        // missing. Surface the catalog-specific guidance instead.
600        if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
601            anyhow::anyhow!(
602                "catalog model '{model_name}' has missing required components. \
603                 Re-pull the entry from the catalog so its companions \
604                 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
605            )
606        } else {
607            anyhow::anyhow!("model '{model_name}' is not downloaded. Run: mold pull {model_name}")
608        }
609    })?;
610
611    // Preflight before unloading the active model. Evict-to-fit drops parked
612    // entries on budget failure and retries before giving up.
613    preflight_memory_guard_with_eviction(
614        &worker.model_cache,
615        model_name,
616        &paths,
617        worker.gpu.ordinal,
618        hint,
619    )
620    .map_err(|e| anyhow::anyhow!(e.error))?;
621
622    // Unload active model first.
623    {
624        let mut cache = worker.model_cache.lock().unwrap();
625        cache.unload_active();
626    }
627    device::reclaim_gpu_memory(worker.gpu.ordinal);
628
629    let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
630    let mut engine = mold_inference::create_engine_with_pool(
631        model_name.to_string(),
632        paths,
633        config,
634        mold_inference::LoadStrategy::Eager,
635        worker.gpu.ordinal,
636        offload,
637        Some(worker.shared_pool.clone()),
638    )?;
639
640    tracing::info!(
641        gpu = worker.gpu.ordinal,
642        model = %model_name,
643        "loading model..."
644    );
645    // Sample VRAM baseline before load so we can record the new model's
646    // per-load delta rather than the device-global usage.
647    let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
648    engine.load()?;
649
650    let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
651    // Drop any evicted engine OUTSIDE the cache lock — `cuMemFree` and
652    // safetensor unmap during the drop can block other cache users.
653    let evicted = {
654        let mut cache = worker.model_cache.lock().unwrap();
655        cache.insert_loaded(model_name.to_string(), engine, vram)
656    };
657    drop(evicted);
658
659    Ok(())
660}
661
662/// Synchronously load a model on this GPU worker for the admin API.
663///
664/// Acquires the per-GPU load lock, then delegates to `ensure_model_ready_sync`.
665/// Intended to be called inside `tokio::task::spawn_blocking`. Uses the
666/// size-only peak (no resolution context) for the preflight — admin loads
667/// don't carry a request shape.
668pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
669    let _lock = worker.model_load_lock.lock().unwrap();
670    ensure_model_ready_sync(worker, model_name, config, None)
671}
672
673/// Synchronously unload the currently active model on this GPU worker.
674///
675/// Returns the name of the model that was unloaded, or `None` if the GPU was
676/// already idle.
677pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
678    let _lock = worker.model_load_lock.lock().unwrap();
679    let unloaded = {
680        let mut cache = worker.model_cache.lock().unwrap();
681        cache.unload_active()
682    };
683    if unloaded.is_some() {
684        device::reclaim_gpu_memory(worker.gpu.ordinal);
685    }
686    unloaded
687}
688
689fn record_failure(worker: &GpuWorker) {
690    let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
691    if failures >= 3 {
692        let mut degraded = worker.degraded_until.write().unwrap();
693        *degraded = Some(Instant::now() + Duration::from_secs(60));
694        tracing::warn!(
695            gpu = worker.gpu.ordinal,
696            "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
697        );
698    }
699}
700
701fn clear_active_generation(worker: &GpuWorker) {
702    let mut gen = worker.active_generation.write().unwrap();
703    *gen = None;
704}
705
706/// Return type for [`run_chain_blocking`]. The outer `Result` carries
707/// helper-prep errors (ensure_model_ready + cache take); the inner `Result`
708/// is whatever the caller's closure returned. Closure errors pass through
709/// unchanged so the caller can distinguish orchestrator-specific failures
710/// (StageFailed, Invalid) from prep failures (ensure/cache).
711pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
712
713/// Run a blocking chain operation on a specific GPU worker.
714///
715/// Acquires `worker.model_load_lock` for the full duration, binds the current
716/// thread to `worker.gpu.ordinal` (so `reclaim_gpu_memory` debug asserts are
717/// satisfied), ensures the model is loaded on GPU, takes the engine out of
718/// the worker's cache, passes it to `with_engine`, and restores the engine
719/// unconditionally on both success and closure failure.
720///
721/// Safe to call from inside `tokio::task::spawn_blocking`. The calling thread
722/// can be any thread — the `ThreadGpuGuard` clears the thread-local on return.
723///
724/// # Errors
725///
726/// Returns `Err(anyhow::Error)` from the outer Result if:
727/// - `ensure_model_ready_sync` fails (bad config, disk IO, load error).
728/// - The engine vanishes from the cache between ensure and take (cache race).
729///
730/// Returns `Ok(Err(E))` if the closure itself returned an error — caller
731/// preserves the closure's typed error for precise HTTP status mapping.
732pub fn run_chain_blocking<T, E>(
733    worker: &GpuWorker,
734    model_name: &str,
735    config: &mold_core::Config,
736    hint: Option<crate::model_manager::ActivationHint>,
737    with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
738) -> ChainPrep<T, E> {
739    // Bind the thread to this worker's ordinal for the duration of the call.
740    // `reclaim_gpu_memory` inside ensure_model_ready_sync debug-asserts this
741    // matches its ordinal argument; without it, a stray caller on an unbound
742    // thread would panic in debug builds.
743    struct ThreadGpuGuard;
744    impl Drop for ThreadGpuGuard {
745        fn drop(&mut self) {
746            mold_inference::device::clear_thread_gpu_ordinal();
747        }
748    }
749    mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
750    let _thread_gpu = ThreadGpuGuard;
751
752    // Acquire the per-worker load lock. Held for the entire chain duration —
753    // single-clip generations on this worker queue behind us on the same lock.
754    let _load_lock = worker
755        .model_load_lock
756        .lock()
757        .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
758
759    // Ensure the model is GPU-resident on this worker. Handles load-from-disk,
760    // parked-reload, and the reclaim-on-swap path using worker.gpu.ordinal.
761    ensure_model_ready_sync(worker, model_name, config, hint)?;
762
763    // Take the engine out of the worker's cache so the closure can mutate it.
764    let cached = {
765        let mut cache = worker
766            .model_cache
767            .lock()
768            .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
769        cache.take(model_name).ok_or_else(|| {
770            anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
771        })?
772    };
773
774    // Run the closure. Capture panics so we can still restore the engine
775    // before propagating — otherwise a panic leaks the engine out of the cache.
776    //
777    // `AssertUnwindSafe` suppresses the compiler's UnwindSafe check because
778    // `&mut dyn InferenceEngine` (across Box + trait object) isn't unwind-safe
779    // by default. This is acceptable here: we only promise to prevent the
780    // CUDA primary-context reset SEGV race, not to guarantee engine internal
781    // state is pristine after a mid-generation panic. A panicked engine will
782    // surface as a bad generation result to the next caller — not a crash.
783    // `catch_unwind` + `resume_unwind` exists solely so the engine is
784    // restored to the cache before the panic propagates up.
785    let mut cached = cached;
786    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
787        with_engine(cached.engine.as_mut())
788    }));
789
790    // Restore unconditionally — the comment above promises this, so we must
791    // honour it even if the cache mutex is poisoned. Taking the inner guard
792    // from a poisoned lock is safe here: restoring an engine reference into
793    // a HashMap entry cannot worsen an already-corrupt state, and silently
794    // dropping the engine (the alternative) would leak it out of the cache
795    // and leave every future request for this model looking at a stale hole.
796    {
797        let mut cache = worker
798            .model_cache
799            .lock()
800            .unwrap_or_else(|poisoned| poisoned.into_inner());
801        cache.restore(cached);
802    }
803
804    match result {
805        Ok(inner) => Ok(inner),
806        Err(panic_payload) => std::panic::resume_unwind(panic_payload),
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use crate::model_cache::ModelCache;
814    use mold_core::{Config, GenerateRequest, GenerateResponse};
815    use mold_inference::device::DiscoveredGpu;
816    use mold_inference::shared_pool::SharedPool;
817    use mold_inference::InferenceEngine;
818    use std::sync::atomic::{AtomicUsize, Ordering};
819    use std::sync::{Arc, Mutex, RwLock};
820    use std::time::Duration;
821
822    /// Weight-free engine that sleeps in `load()` to widen the critical-section
823    /// window during concurrency tests.
824    struct FakeSlowEngine {
825        name: String,
826        loaded: bool,
827        load_sleep: Duration,
828    }
829
830    impl FakeSlowEngine {
831        fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
832            Box::new(Self {
833                name: name.to_string(),
834                loaded: false,
835                load_sleep,
836            })
837        }
838    }
839
840    impl InferenceEngine for FakeSlowEngine {
841        fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
842            unreachable!("FakeSlowEngine is not used for generation in tests")
843        }
844        fn model_name(&self) -> &str {
845            &self.name
846        }
847        fn is_loaded(&self) -> bool {
848            self.loaded
849        }
850        fn load(&mut self) -> anyhow::Result<()> {
851            std::thread::sleep(self.load_sleep);
852            self.loaded = true;
853            Ok(())
854        }
855        fn unload(&mut self) {
856            self.loaded = false;
857        }
858    }
859
860    fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
861        let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
862        let mut cache = ModelCache::new(3);
863        // Seed as Parked so `ensure_model_ready_sync` hits its reload path
864        // and calls `engine.load()` — that's where the sleep widens the window.
865        cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
866        Arc::new(GpuWorker {
867            gpu: DiscoveredGpu {
868                ordinal: 0,
869                name: "fake-gpu-0".to_string(),
870                total_vram_bytes: 24_000_000_000,
871                free_vram_bytes: 24_000_000_000,
872            },
873            model_cache: Arc::new(Mutex::new(cache)),
874            active_generation: Arc::new(RwLock::new(None)),
875            model_load_lock: Arc::new(Mutex::new(())),
876            shared_pool: Arc::new(Mutex::new(SharedPool::new())),
877            in_flight: AtomicUsize::new(0),
878            consecutive_failures: AtomicUsize::new(0),
879            degraded_until: RwLock::new(None),
880            job_tx,
881        })
882    }
883
884    /// Two concurrent callers into `run_chain_blocking` on the same worker
885    /// must serialize — `MAX_CONCURRENT` must never exceed 1.
886    ///
887    /// Fails to compile until `run_chain_blocking` is implemented in Task 2.
888    #[test]
889    fn run_chain_blocking_serializes_same_worker() {
890        let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
891        let config = Config::default();
892
893        let active = Arc::new(AtomicUsize::new(0));
894        let max_concurrent = Arc::new(AtomicUsize::new(0));
895
896        let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
897            move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
898                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
899                max_concurrent.fetch_max(now, Ordering::SeqCst);
900                std::thread::sleep(Duration::from_millis(50));
901                active.fetch_sub(1, Ordering::SeqCst);
902                Ok(())
903            }
904        };
905
906        let worker_a = worker.clone();
907        let config_a = config.clone();
908        let a = active.clone();
909        let m = max_concurrent.clone();
910        let t_a = std::thread::spawn(move || {
911            run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
912                .expect("prep ok")
913                .expect("closure ok");
914        });
915
916        let worker_b = worker.clone();
917        let config_b = config.clone();
918        let a = active.clone();
919        let m = max_concurrent.clone();
920        let t_b = std::thread::spawn(move || {
921            run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
922                .expect("prep ok")
923                .expect("closure ok");
924        });
925
926        t_a.join().unwrap();
927        t_b.join().unwrap();
928
929        assert_eq!(
930            max_concurrent.load(Ordering::SeqCst),
931            1,
932            "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
933        );
934    }
935
936    // ── OOM detection + message rewriting (Part 2) ────────────────────────────
937
938    /// `is_cuda_oom` detects the canonical `CUDA_ERROR_OUT_OF_MEMORY` error
939    /// string. This pattern-match is the only stable signal available from
940    /// the candle/cudarc error chain since the cudarc error type is not
941    /// downcasted via std::error::Error in the candle re-export.
942    #[test]
943    fn is_cuda_oom_detects_driver_error_string() {
944        let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
945        assert!(
946            is_cuda_oom(&oom_err),
947            "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
948        );
949    }
950
951    /// A regular (non-OOM) error must not trigger the OOM path.
952    #[test]
953    fn is_cuda_oom_does_not_trigger_on_regular_errors() {
954        let reg_err = anyhow::anyhow!("safetensors file not found");
955        assert!(
956            !is_cuda_oom(&reg_err),
957            "non-OOM error must not be classified as OOM"
958        );
959    }
960
961    /// `oom_user_message` produces a message that mentions actionable
962    /// mitigations — frames, resolution, or quantized variants. It must
963    /// NOT contain the opaque CUDA driver error string.
964    #[test]
965    fn runtime_oom_message_suggests_offload_and_smaller_frames() {
966        let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
967        assert!(
968            msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
969            "OOM message must suggest reducing frames, resolution, or using a \
970             quantized variant; got: {msg}",
971        );
972        assert!(
973            !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
974            "OOM user message must not expose the raw CUDA driver error string; \
975             got: {msg}",
976        );
977        assert!(
978            msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
979            "OOM message must include the model name so the user knows what failed; \
980             got: {msg}",
981        );
982    }
983
984    /// A failed `engine.load()` must NOT leave a phantom entry in the cache.
985    ///
986    /// `ensure_model_ready_sync` calls `create_engine_with_pool` then
987    /// `engine.load()`, and only calls `cache.insert_loaded()` after success.
988    /// This test confirms that a load failure on a fresh (non-cached) engine
989    /// leaves the cache empty — `contains()` returns false and `in_flight`
990    /// is clean.
991    ///
992    /// We can't exercise the full `ensure_model_ready_sync` path without real
993    /// model files, so we test the cache contract directly: a failed
994    /// `insert_loaded` attempt (via the engine's failing load) leaves the
995    /// cache exactly as it was before.
996    #[test]
997    fn failed_load_does_not_leak_into_model_cache() {
998        // Engine that always fails to load.
999        struct FailingLoadEngine {
1000            name: String,
1001        }
1002        impl InferenceEngine for FailingLoadEngine {
1003            fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1004                unreachable!()
1005            }
1006            fn model_name(&self) -> &str {
1007                &self.name
1008            }
1009            fn is_loaded(&self) -> bool {
1010                false
1011            }
1012            fn load(&mut self) -> anyhow::Result<()> {
1013                anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1014            }
1015            fn unload(&mut self) {}
1016        }
1017
1018        let cache = ModelCache::new(3);
1019        let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1020
1021        // Simulate the load path: create the engine, attempt load, only
1022        // insert on success. This mirrors the exact control flow in
1023        // `ensure_model_ready_sync`.
1024        let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1025            name: model_name.to_string(),
1026        });
1027        let load_result = engine.load();
1028
1029        assert!(
1030            load_result.is_err(),
1031            "engine.load() must fail for this test to be meaningful"
1032        );
1033        assert!(
1034            is_cuda_oom(load_result.as_ref().unwrap_err()),
1035            "load error must be classified as OOM"
1036        );
1037
1038        // Crucially: we do NOT call cache.insert_loaded() on failure.
1039        // The cache must remain empty.
1040        assert!(
1041            !cache.contains(model_name),
1042            "cache must not contain the model after a failed load — \
1043             `insert_loaded` must only be called on success"
1044        );
1045        assert!(
1046            cache.is_empty(),
1047            "cache must be completely empty after a failed load"
1048        );
1049    }
1050}