1use crate::gpu_pool::{ActiveGeneration, GpuJob, GpuWorker};
2use crate::model_cache::ModelResidency;
3use crate::queue::{
4 apply_output_dimensions_to_metadata, apply_upscale_response_to_image_generation,
5 build_sse_complete_event, clean_error_message, save_image_to_dir, save_video_to_dir,
6};
7use crate::state::{GenerationJobResult, SseMessage};
8use mold_core::{
9 Config, ImageData, ModelPaths, OutputFormat, OutputMetadata, SseErrorEvent, SseProgressEvent,
10};
11use mold_inference::device;
12use sha2::{Digest, Sha256};
13use std::sync::atomic::Ordering;
14use std::sync::Arc;
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16
17pub fn spawn_gpu_thread(
20 worker: Arc<GpuWorker>,
21 job_rx: std::sync::mpsc::Receiver<GpuJob>,
22) -> std::thread::JoinHandle<()> {
23 std::thread::Builder::new()
24 .name(format!("gpu-worker-{}", worker.gpu.ordinal))
25 .spawn(move || {
26 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
30 tracing::info!(
31 gpu = worker.gpu.ordinal,
32 name = %worker.gpu.name,
33 "GPU worker thread started"
34 );
35 for job in job_rx.iter() {
36 process_job(&worker, job);
37 }
38 tracing::info!(gpu = worker.gpu.ordinal, "GPU worker thread exiting");
39 })
40 .expect("failed to spawn GPU worker thread")
41}
42
43fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
45 event.into()
46}
47
48pub(crate) fn is_cuda_oom(e: &anyhow::Error) -> bool {
55 let full = format!("{e:#}");
56 full.contains("CUDA_ERROR_OUT_OF_MEMORY") || full.contains("out of memory")
57}
58
59pub(crate) fn oom_user_message(model_name: &str) -> String {
63 oom_user_message_for_request(model_name, None, None)
64}
65
66pub(crate) fn oom_user_message_for_request(
67 model_name: &str,
68 family_slug: Option<&str>,
69 req: Option<&mold_core::GenerateRequest>,
70) -> String {
71 let requested_size = req
72 .map(|r| format!(" Requested size: {}x{}.", r.width, r.height))
73 .unwrap_or_default();
74 let batch_hint = match req.map(|r| r.batch_size).unwrap_or(1) {
75 0 | 1 => "keep --batch 1".to_string(),
76 n => format!("reduce --batch {n} to --batch 1"),
77 };
78
79 if family_slug.is_some_and(is_video_family) || req.and_then(|r| r.frames).is_some() {
80 let frames_hint = req
81 .and_then(|r| r.frames)
82 .map(|frames| format!("reduce --frames below {frames} (e.g. 17 or 9)"))
83 .unwrap_or_else(|| "reduce --frames (e.g. 17 or 9)".to_string());
84 return format!(
85 "GPU ran out of memory loading or running '{model_name}'.{requested_size} \
86 Try: {frames_hint}, lower --width/--height, use a quantized variant \
87 if available, or close other GPU apps."
88 );
89 }
90
91 let family_note = match family_slug {
92 Some("sd15") => {
93 if req.is_some_and(|r| r.width == 1024 && r.height == 1024) {
94 " SD1.5 defaults to 512x512; 1024x1024 is 4x the pixels and can OOM \
95 even when the checkpoint file is only a few GB."
96 } else {
97 " SD1.5 defaults to 512x512; larger sizes multiply activation and \
98 VAE workspace beyond the checkpoint file size."
99 }
100 }
101 Some("sdxl") => {
102 " SDXL's usual 1024x1024 size still needs activation and VAE workspace \
103 beyond the checkpoint file size."
104 }
105 Some("sd3") => " SD3 needs activation and VAE workspace beyond the checkpoint file size.",
106 Some("flux")
107 | Some("flux2")
108 | Some("qwen-image")
109 | Some("qwen-image-edit")
110 | Some("z-image")
111 | Some("wuerstchen") => {
112 " The checkpoint size is only the weights; peak VRAM also includes \
113 activations, VAE decode workspace, CUDA workspaces, and resident cache."
114 }
115 _ => {
116 " The model file size is only the weights; peak VRAM also includes \
117 activations, decoder workspace, CUDA workspaces, and resident cache."
118 }
119 };
120 let resolution_hint = match family_slug {
121 Some("sd15") => "lower --width/--height (try 768x768 or 512x512)",
122 _ => "lower --width/--height",
123 };
124
125 format!(
126 "GPU ran out of memory loading or running '{model_name}'.{requested_size}{family_note} \
127 Try: {resolution_hint}, {batch_hint}, use a smaller/quantized variant if \
128 this model provides one, run mold unload, or close other GPU apps."
129 )
130}
131
132fn is_video_family(family_slug: &str) -> bool {
133 matches!(family_slug, "ltx-video" | "ltx2" | "ltx-2" | "ltx-2.3")
134}
135
136fn upscale_generated_image_on_worker(
137 worker: &GpuWorker,
138 job: &GpuJob,
139 upscale_model: &str,
140 img: ImageData,
141 response: &mut mold_core::GenerateResponse,
142) -> Result<ImageData, String> {
143 let model_name = mold_core::manifest::resolve_model_name(upscale_model);
144 let weights_path = {
145 let config = job.config.blocking_read();
146 config
147 .models
148 .get(&model_name)
149 .and_then(|c| c.transformer.as_ref())
150 .map(std::path::PathBuf::from)
151 }
152 .ok_or_else(|| format!("upscaler model '{model_name}' is not downloaded"))?;
153
154 if let Some(ref tx) = job.progress_tx {
155 let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
156 name: format!("Loading upscaler {model_name}"),
157 }));
158 }
159 let mut engine = mold_inference::create_upscale_engine(
160 model_name.clone(),
161 weights_path,
162 mold_inference::LoadStrategy::Eager,
163 worker.gpu.ordinal,
164 )
165 .map_err(|e| format!("failed to load upscaler: {e}"))?;
166 if let Some(ref tx) = job.progress_tx {
167 let tx = tx.clone();
168 engine.set_on_progress(Box::new(move |event| {
169 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
170 }));
171 }
172 let req = mold_core::UpscaleRequest {
173 model: model_name,
174 image: img.data.clone(),
175 output_format: img.format,
176 tile_size: None,
177 };
178 let upscaled = engine
179 .upscale(&req)
180 .map_err(|e| format!("upscale failed: {e}"))?;
181 engine.clear_on_progress();
182 apply_upscale_response_to_image_generation(&job.request, response, img, upscaled)
183 .map_err(|e| format!("upscale failed: {e}"))
184}
185
186fn cuda_oom_user_message(
187 worker: &GpuWorker,
188 model_name: &str,
189 family_slug: Option<&str>,
190 req: Option<&mold_core::GenerateRequest>,
191) -> (String, bool) {
192 let base = if family_slug.is_none() && req.is_none() {
193 oom_user_message(model_name)
194 } else {
195 oom_user_message_for_request(model_name, family_slug, req)
196 };
197 let outcome = crate::gpu_pool::record_model_cuda_oom(model_name, worker.gpu.ordinal);
198 if outcome.is_unschedulable() {
199 if let Some(cooldown) = crate::gpu_pool::model_unschedulable_message(model_name) {
200 return (format!("{base} {cooldown}"), false);
201 }
202 }
203 (base, true)
204}
205
206fn process_job(worker: &GpuWorker, job: GpuJob) {
207 let model_name = job.model.clone();
208 let ordinal = worker.gpu.ordinal;
209 let job_id = job.id.clone();
210
211 struct CleanupGuard {
217 queue: crate::state::QueueHandle,
218 registry: crate::job_registry::SharedJobRegistry,
219 id: String,
220 }
221 impl Drop for CleanupGuard {
222 fn drop(&mut self) {
223 self.queue.decrement();
224 self.registry.remove(&self.id);
225 }
226 }
227 let _cleanup = CleanupGuard {
228 queue: job.queue.clone(),
229 registry: job.registry.clone(),
230 id: job_id.clone(),
231 };
232
233 if job.result_tx.is_closed() {
234 tracing::debug!(gpu = ordinal, model = %model_name, "skipping dispatched job — client disconnected");
235 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
236 return;
237 }
238
239 job.registry.mark_running(&job_id, Some(ordinal));
242
243 tracing::info!(gpu = ordinal, model = %model_name, "dispatched job");
244
245 let _load_lock = worker.model_load_lock.lock().unwrap();
247
248 let config_snapshot = job.config.blocking_read().clone();
250 let family_slug = crate::model_manager::family_for_model_sync(&model_name, &config_snapshot);
251 let activation_hint =
252 crate::model_manager::activation_hint_for_request_sync(&config_snapshot, &job.request);
253 let request_has_lora = crate::model_manager::request_has_effective_lora(&job.request);
254 if let Err(e) = ensure_model_ready_sync(
255 worker,
256 &model_name,
257 &config_snapshot,
258 activation_hint,
259 request_has_lora,
260 ) {
261 tracing::error!(gpu = ordinal, model = %model_name, "Failed to load model: {e}");
262 let is_oom = is_cuda_oom(&e);
266 let (err_msg, count_worker_failure) = if is_oom {
267 mold_inference::device::try_synchronize_device(ordinal);
268 cuda_oom_user_message(
269 worker,
270 &model_name,
271 family_slug.as_deref(),
272 Some(&job.request),
273 )
274 } else {
275 (
276 format!("model load error: {}", clean_error_message(&e)),
277 true,
278 )
279 };
280 if let Some(ref tx) = job.progress_tx {
281 let _ = tx.send(SseMessage::Error(SseErrorEvent {
282 message: err_msg.clone(),
283 }));
284 }
285 let _ = job.result_tx.send(Err(err_msg));
286 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
287 if count_worker_failure {
288 record_failure(worker);
289 }
290 return;
291 }
292
293 {
295 let mut gen = worker.active_generation.write().unwrap();
296 *gen = Some(ActiveGeneration {
297 model: model_name.clone(),
298 prompt_sha256: format!("{:x}", Sha256::digest(job.request.prompt.as_bytes())),
299 started_at_unix_ms: SystemTime::now()
300 .duration_since(UNIX_EPOCH)
301 .unwrap_or_default()
302 .as_millis() as u64,
303 started_at: Instant::now(),
304 });
305 }
306
307 if job.result_tx.is_closed() {
308 tracing::debug!(
309 gpu = ordinal,
310 model = %model_name,
311 "skipping generation after model readiness — client disconnected"
312 );
313 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
314 clear_active_generation(worker);
315 return;
316 }
317
318 let taken = {
320 let mut cache = worker.model_cache.lock().unwrap();
321 cache.take(&model_name)
322 };
323
324 let Some(mut cached_engine) = taken else {
325 let err_msg = "engine not found in cache after load".to_string();
326 if let Some(ref tx) = job.progress_tx {
327 let _ = tx.send(SseMessage::Error(SseErrorEvent {
328 message: err_msg.clone(),
329 }));
330 }
331 let _ = job.result_tx.send(Err(err_msg));
332 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
333 clear_active_generation(worker);
334 return;
335 };
336
337 if let Some(ref progress_tx) = job.progress_tx {
339 let tx = progress_tx.clone();
340 cached_engine.engine.set_on_progress(Box::new(move |event| {
341 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
342 }));
343 }
344
345 let rss_before = crate::resources::ram_snapshot().used_by_mold;
349
350 let watchdog_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
355 let watchdog_handle = {
356 let stop = watchdog_stop.clone();
357 let model = model_name.clone();
358 std::thread::Builder::new()
359 .name(format!("rss-watchdog-{ordinal}"))
360 .spawn(move || {
361 let start = Instant::now();
362 while !stop.load(Ordering::SeqCst) {
363 std::thread::sleep(Duration::from_millis(1000));
364 if stop.load(Ordering::SeqCst) {
365 break;
366 }
367 let rss = crate::resources::ram_snapshot().used_by_mold;
368 tracing::info!(
369 gpu = ordinal,
370 model = %model,
371 elapsed_s = start.elapsed().as_secs(),
372 rss_mb = rss / 1_000_000,
373 "rss watchdog"
374 );
375 }
376 })
377 .expect("failed to spawn RSS watchdog")
378 };
379
380 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
382 cached_engine.engine.generate(&job.request)
383 }));
384
385 watchdog_stop.store(true, Ordering::SeqCst);
386 let _ = watchdog_handle.join();
387
388 let trim_enabled = std::env::var("MOLD_MALLOC_TRIM")
394 .map(|v| v != "0")
395 .unwrap_or(true);
396 let rss_pre_trim = if trim_enabled {
397 let v = crate::resources::ram_snapshot().used_by_mold;
398 #[cfg(target_os = "linux")]
399 unsafe {
400 libc::malloc_trim(0);
401 }
402 Some(v)
403 } else {
404 None
405 };
406
407 let rss_after = crate::resources::ram_snapshot().used_by_mold;
408 let rss_delta = rss_after as i64 - rss_before as i64;
409 tracing::info!(
410 gpu = ordinal,
411 model = %model_name,
412 rss_before_mb = rss_before / 1_000_000,
413 rss_after_mb = rss_after / 1_000_000,
414 rss_delta_mb = rss_delta / 1_000_000,
415 rss_pre_trim_mb = rss_pre_trim.map(|v| v / 1_000_000).unwrap_or(0),
416 "generation memory delta"
417 );
418
419 cached_engine.engine.clear_on_progress();
421
422 {
424 let mut cache = worker.model_cache.lock().unwrap();
425 cache.restore(cached_engine);
426 }
427
428 clear_active_generation(worker);
430
431 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
433
434 match result {
435 Ok(Ok(mut response)) => {
436 worker.consecutive_failures.store(0, Ordering::SeqCst);
438 crate::gpu_pool::clear_model_cuda_oom(&model_name);
439
440 response.gpu = Some(ordinal);
442
443 if response.images.is_empty() && response.video.is_none() {
444 let err_msg = "generation error: engine returned no images or video".to_string();
445 if let Some(ref tx) = job.progress_tx {
446 let _ = tx.send(SseMessage::Error(SseErrorEvent {
447 message: err_msg.clone(),
448 }));
449 }
450 let _ = job.result_tx.send(Err(err_msg));
451 return;
452 }
453
454 let mut img = if !response.images.is_empty() {
456 response.images.remove(0)
457 } else if let Some(ref video) = response.video {
458 ImageData {
459 data: video.thumbnail.clone(),
460 format: OutputFormat::Png,
461 width: video.width,
462 height: video.height,
463 index: 0,
464 }
465 } else {
466 unreachable!("checked above");
467 };
468
469 if response.video.is_none() {
470 if let Some(upscale_model) = job
471 .request
472 .upscale_model
473 .as_deref()
474 .map(str::trim)
475 .filter(|m| !m.is_empty())
476 {
477 match upscale_generated_image_on_worker(
478 worker,
479 &job,
480 upscale_model,
481 img.clone(),
482 &mut response,
483 ) {
484 Ok(upscaled) => img = upscaled,
485 Err(err_msg) => {
486 if let Some(ref tx) = job.progress_tx {
487 let _ = tx.send(SseMessage::Error(SseErrorEvent {
488 message: err_msg.clone(),
489 }));
490 }
491 let _ = job.result_tx.send(Err(err_msg));
492 return;
493 }
494 }
495 }
496 }
497
498 if let Some(ref dir) = job.output_dir {
506 let mut metadata = OutputMetadata::from_generate_request(
507 &job.request,
508 response.seed_used,
509 None,
510 mold_core::build_info::version_string(),
511 );
512 if response.video.is_none() {
513 apply_output_dimensions_to_metadata(&mut metadata, &img);
514 }
515 let generation_time_ms = response.generation_time_ms as i64;
516 let db = job.metadata_db.as_ref().as_ref();
517 if let Some(ref video) = response.video {
518 save_video_to_dir(
519 dir,
520 &video.data,
521 &video.gif_preview,
522 video.format,
523 &job.model,
524 &metadata,
525 Some(generation_time_ms),
526 db,
527 );
528 } else {
529 save_image_to_dir(
530 dir,
531 &img,
532 &job.model,
533 job.request.batch_size,
534 Some(&metadata),
535 Some(generation_time_ms),
536 db,
537 );
538 }
539 }
540
541 if let Some(ref tx) = job.progress_tx {
547 let event = build_sse_complete_event(&response, &img);
548 let _ = tx.send(SseMessage::Complete(event));
549 }
550
551 let _ = job.result_tx.send(Ok(GenerationJobResult {
553 image: img,
554 response,
555 }));
556 }
557 Ok(Err(e)) => {
558 tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
559 let is_oom = is_cuda_oom(&e);
563 let (err_msg, count_worker_failure) = if is_oom {
564 mold_inference::device::try_synchronize_device(ordinal);
565 cuda_oom_user_message(
566 worker,
567 &model_name,
568 family_slug.as_deref(),
569 Some(&job.request),
570 )
571 } else {
572 (
573 format!("generation error: {}", clean_error_message(&e)),
574 true,
575 )
576 };
577 if count_worker_failure {
578 record_failure(worker);
579 }
580 if let Some(ref tx) = job.progress_tx {
581 let _ = tx.send(SseMessage::Error(SseErrorEvent {
582 message: err_msg.clone(),
583 }));
584 }
585 let _ = job.result_tx.send(Err(err_msg));
586 }
587 Err(panic_payload) => {
588 tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
589 record_failure(worker);
590 let msg = panic_payload
591 .downcast_ref::<String>()
592 .map(|s| s.as_str())
593 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
594 .unwrap_or("unknown panic");
595 let err_msg = format!("inference panicked: {msg}");
596 if let Some(ref tx) = job.progress_tx {
597 let _ = tx.send(SseMessage::Error(SseErrorEvent {
598 message: err_msg.clone(),
599 }));
600 }
601 let _ = job.result_tx.send(Err(err_msg));
602 }
603 }
604}
605
606fn preflight_memory_guard_with_eviction(
620 cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
621 model_name: &str,
622 paths: &ModelPaths,
623 ordinal: usize,
624 hint: Option<crate::model_manager::ActivationHint>,
625) -> Result<(), crate::routes::ApiError> {
626 loop {
627 let active_vram = cache_lock
628 .lock()
629 .unwrap_or_else(|e| e.into_inner())
630 .active_vram_bytes();
631 let err = match crate::model_manager::preflight_memory_guard(
632 model_name,
633 paths,
634 active_vram,
635 ordinal,
636 hint,
637 ) {
638 Ok(()) => return Ok(()),
639 Err(e) => e,
640 };
641
642 let evicted = {
643 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
644 cache.evict_lru_parked_except(Some(model_name))
645 };
646 let Some((evicted_name, engine)) = evicted else {
647 return Err(err);
648 };
649 tracing::info!(
650 gpu = ordinal,
651 target_model = %model_name,
652 evicted_model = %evicted_name,
653 "evicting LRU parked entry to fit incoming load"
654 );
655 drop(engine);
658
659 let safe_to_reclaim = cache_lock
665 .lock()
666 .unwrap_or_else(|e| e.into_inner())
667 .active_model()
668 .is_none();
669 if safe_to_reclaim {
670 device::reclaim_gpu_memory(ordinal);
671 }
672 }
673}
674
675fn select_load_strategy_for_worker(
676 worker: &GpuWorker,
677 model_name: &str,
678 paths: &ModelPaths,
679 hint: Option<crate::model_manager::ActivationHint>,
680) -> mold_inference::LoadStrategy {
681 let active_vram = worker
682 .model_cache
683 .lock()
684 .unwrap_or_else(|e| e.into_inner())
685 .active_vram_bytes();
686 let available =
687 crate::model_manager::effective_load_available_bytes(active_vram, worker.gpu.ordinal);
688 let strategy = crate::model_manager::select_server_load_strategy_for_device(
689 paths,
690 available,
691 Some(worker.gpu.total_vram_bytes),
692 hint,
693 );
694 if strategy == mold_inference::LoadStrategy::Sequential {
695 tracing::info!(
696 gpu = worker.gpu.ordinal,
697 model = %model_name,
698 "server load strategy degraded to sequential to fit memory budget"
699 );
700 }
701 strategy
702}
703
704pub fn ensure_model_ready_sync(
712 worker: &GpuWorker,
713 model_name: &str,
714 config: &Config,
715 hint: Option<crate::model_manager::ActivationHint>,
716 request_has_lora: bool,
717) -> anyhow::Result<()> {
718 let cache = worker.model_cache.lock().unwrap();
719
720 if let Some(entry) = cache.get(model_name) {
722 if entry.residency == ModelResidency::Gpu {
723 let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
724 crate::model_manager::request_requires_fresh_engine_for_offload_policy(
725 paths,
726 hint,
727 request_has_lora,
728 )
729 });
730 if !must_recreate {
731 return Ok(());
732 }
733 }
734 }
735
736 let has_cached = cache.contains(model_name);
738
739 let cached_paths = if has_cached {
744 cache
745 .get(model_name)
746 .and_then(|e| e.engine.model_paths().cloned())
747 } else {
748 None
749 };
750 drop(cache);
751
752 if has_cached {
753 let load_strategy = cached_paths
754 .as_ref()
755 .map(|paths| select_load_strategy_for_worker(worker, model_name, paths, hint))
756 .unwrap_or(mold_inference::LoadStrategy::Eager);
757
758 if let Some(ref paths) = cached_paths {
763 preflight_memory_guard_with_eviction(
764 &worker.model_cache,
765 model_name,
766 paths,
767 worker.gpu.ordinal,
768 hint,
769 )
770 .map_err(|e| anyhow::anyhow!(e.error))?;
771 }
772
773 {
775 let mut cache = worker.model_cache.lock().unwrap();
776 cache.unload_active();
777 }
778 device::reclaim_gpu_memory(worker.gpu.ordinal);
779
780 if load_strategy == mold_inference::LoadStrategy::Sequential {
781 let paths = cached_paths.ok_or_else(|| {
782 anyhow::anyhow!("cached engine for '{model_name}' does not expose model paths")
783 })?;
784 let old_engine = {
785 let mut cache = worker.model_cache.lock().unwrap();
786 cache
787 .remove(model_name)
788 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
789 };
790
791 let offload = crate::model_manager::server_offload_enabled_for_paths(
792 &paths,
793 hint,
794 request_has_lora,
795 );
796 let resolved_catalog_config =
797 crate::model_manager::resolve_installed_catalog_paths_for_worker(
798 model_name, config,
799 )
800 .map_err(|e| anyhow::anyhow!(e.error))?
801 .map(|(_, config)| config);
802 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
803 let mut engine = match mold_inference::create_engine_with_pool(
804 model_name.to_string(),
805 paths,
806 engine_config,
807 load_strategy,
808 worker.gpu.ordinal,
809 offload,
810 Some(worker.shared_pool.clone()),
811 ) {
812 Ok(engine) => engine,
813 Err(err) => {
814 let evicted = {
815 let mut cache = worker.model_cache.lock().unwrap();
816 cache.insert(old_engine, 0)
817 };
818 drop(evicted);
819 return Err(err);
820 }
821 };
822
823 tracing::info!(
824 gpu = worker.gpu.ordinal,
825 model = %model_name,
826 "recreating cached engine in sequential mode..."
827 );
828 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
829 if let Err(err) = engine.load() {
830 let evicted = {
831 let mut cache = worker.model_cache.lock().unwrap();
832 cache.insert(old_engine, 0)
833 };
834 drop(evicted);
835 return Err(err);
836 }
837 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
838 drop(old_engine);
839 let evicted = {
840 let mut cache = worker.model_cache.lock().unwrap();
841 cache.insert_loaded(model_name.to_string(), engine, vram)
842 };
843 drop(evicted);
844 return Ok(());
845 }
846
847 let mut engine = {
849 let mut cache = worker.model_cache.lock().unwrap();
850 cache
851 .remove(model_name)
852 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
853 };
854
855 tracing::info!(
856 gpu = worker.gpu.ordinal,
857 model = %model_name,
858 "reloading cached engine..."
859 );
860 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
863 engine.load()?;
864
865 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
866 let evicted = {
869 let mut cache = worker.model_cache.lock().unwrap();
870 cache.insert_loaded(model_name.to_string(), engine, vram)
871 };
872 drop(evicted);
873 return Ok(());
874 }
875
876 let mut resolved_catalog_config = None;
879 let paths = if let Some(paths) = ModelPaths::resolve(model_name, config) {
880 paths
881 } else if let Some((paths, config)) =
882 crate::model_manager::resolve_installed_catalog_paths_for_worker(model_name, config)
883 .map_err(|e| anyhow::anyhow!(e.error))?
884 {
885 resolved_catalog_config = Some(config);
886 paths
887 } else {
888 return Err(
889 if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
890 anyhow::anyhow!(
898 "catalog model '{model_name}' has missing required components. \
899 Re-pull the entry from the catalog so its companions \
900 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
901 )
902 } else {
903 anyhow::anyhow!(
904 "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
905 )
906 },
907 );
908 };
909
910 preflight_memory_guard_with_eviction(
913 &worker.model_cache,
914 model_name,
915 &paths,
916 worker.gpu.ordinal,
917 hint,
918 )
919 .map_err(|e| anyhow::anyhow!(e.error))?;
920
921 let load_strategy = select_load_strategy_for_worker(worker, model_name, &paths, hint);
922
923 {
925 let mut cache = worker.model_cache.lock().unwrap();
926 cache.unload_active();
927 }
928 device::reclaim_gpu_memory(worker.gpu.ordinal);
929
930 let offload =
931 crate::model_manager::server_offload_enabled_for_paths(&paths, hint, request_has_lora);
932 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
933 let mut engine = mold_inference::create_engine_with_pool(
934 model_name.to_string(),
935 paths,
936 engine_config,
937 load_strategy,
938 worker.gpu.ordinal,
939 offload,
940 Some(worker.shared_pool.clone()),
941 )?;
942
943 tracing::info!(
944 gpu = worker.gpu.ordinal,
945 model = %model_name,
946 "loading model..."
947 );
948 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
951 engine.load()?;
952
953 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
954 let evicted = {
957 let mut cache = worker.model_cache.lock().unwrap();
958 cache.insert_loaded(model_name.to_string(), engine, vram)
959 };
960 drop(evicted);
961
962 Ok(())
963}
964
965pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
972 let _lock = worker.model_load_lock.lock().unwrap();
973 ensure_model_ready_sync(worker, model_name, config, None, false)
974}
975
976pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
981 let _lock = worker.model_load_lock.lock().unwrap();
982 let unloaded = {
983 let mut cache = worker.model_cache.lock().unwrap();
984 cache.unload_active()
985 };
986 if unloaded.is_some() {
987 device::reclaim_gpu_memory(worker.gpu.ordinal);
988 }
989 unloaded
990}
991
992fn record_failure(worker: &GpuWorker) {
993 let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
994 if failures >= 3 {
995 let mut degraded = worker.degraded_until.write().unwrap();
996 *degraded = Some(Instant::now() + Duration::from_secs(60));
997 tracing::warn!(
998 gpu = worker.gpu.ordinal,
999 "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
1000 );
1001 }
1002}
1003
1004fn clear_active_generation(worker: &GpuWorker) {
1005 let mut gen = worker.active_generation.write().unwrap();
1006 *gen = None;
1007}
1008
1009pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
1015
1016pub fn run_chain_blocking<T, E>(
1036 worker: &GpuWorker,
1037 model_name: &str,
1038 config: &mold_core::Config,
1039 hint: Option<crate::model_manager::ActivationHint>,
1040 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1041) -> ChainPrep<T, E> {
1042 struct ThreadGpuGuard;
1047 impl Drop for ThreadGpuGuard {
1048 fn drop(&mut self) {
1049 mold_inference::device::clear_thread_gpu_ordinal();
1050 }
1051 }
1052 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
1053 let _thread_gpu = ThreadGpuGuard;
1054
1055 let _load_lock = worker
1058 .model_load_lock
1059 .lock()
1060 .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
1061
1062 ensure_model_ready_sync(worker, model_name, config, hint, false)?;
1065
1066 let cached = {
1068 let mut cache = worker
1069 .model_cache
1070 .lock()
1071 .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
1072 cache.take(model_name).ok_or_else(|| {
1073 anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
1074 })?
1075 };
1076
1077 let mut cached = cached;
1089 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1090 with_engine(cached.engine.as_mut())
1091 }));
1092
1093 {
1100 let mut cache = worker
1101 .model_cache
1102 .lock()
1103 .unwrap_or_else(|poisoned| poisoned.into_inner());
1104 cache.restore(cached);
1105 }
1106
1107 match result {
1108 Ok(inner) => Ok(inner),
1109 Err(panic_payload) => std::panic::resume_unwind(panic_payload),
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use crate::job_registry::JobRegistry;
1117 use crate::model_cache::ModelCache;
1118 use crate::state::QueueHandle;
1119 use mold_core::{
1120 Config, GenerateRequest, GenerateResponse, ImageData, ModelConfig, OutputFormat,
1121 };
1122 use mold_inference::device::DiscoveredGpu;
1123 use mold_inference::shared_pool::SharedPool;
1124 use mold_inference::InferenceEngine;
1125 use std::sync::atomic::{AtomicUsize, Ordering};
1126 use std::sync::{Arc, Mutex, RwLock};
1127 use std::time::Duration;
1128
1129 struct FakeSlowEngine {
1132 name: String,
1133 loaded: bool,
1134 load_sleep: Duration,
1135 }
1136
1137 impl FakeSlowEngine {
1138 fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
1139 Box::new(Self {
1140 name: name.to_string(),
1141 loaded: false,
1142 load_sleep,
1143 })
1144 }
1145 }
1146
1147 impl InferenceEngine for FakeSlowEngine {
1148 fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1149 unreachable!("FakeSlowEngine is not used for generation in tests")
1150 }
1151 fn model_name(&self) -> &str {
1152 &self.name
1153 }
1154 fn is_loaded(&self) -> bool {
1155 self.loaded
1156 }
1157 fn load(&mut self) -> anyhow::Result<()> {
1158 std::thread::sleep(self.load_sleep);
1159 self.loaded = true;
1160 Ok(())
1161 }
1162 fn unload(&mut self) {
1163 self.loaded = false;
1164 }
1165 }
1166
1167 fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
1168 let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
1169 let mut cache = ModelCache::new(3);
1170 cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
1173 Arc::new(GpuWorker {
1174 gpu: DiscoveredGpu {
1175 ordinal: 0,
1176 name: "fake-gpu-0".to_string(),
1177 total_vram_bytes: 24_000_000_000,
1178 free_vram_bytes: 24_000_000_000,
1179 },
1180 model_cache: Arc::new(Mutex::new(cache)),
1181 active_generation: Arc::new(RwLock::new(None)),
1182 model_load_lock: Arc::new(Mutex::new(())),
1183 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1184 in_flight: AtomicUsize::new(0),
1185 consecutive_failures: AtomicUsize::new(0),
1186 degraded_until: RwLock::new(None),
1187 job_tx,
1188 })
1189 }
1190
1191 fn fake_upscale_job(config: Config, upscale_model: &str) -> GpuJob {
1192 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
1193 let (queue_tx, _queue_rx) = tokio::sync::mpsc::channel(1);
1194 let mut request: GenerateRequest = serde_json::from_str(
1195 r#"{"prompt":"portrait","model":"flux-dev:q4","width":512,"height":512,"steps":4,"guidance":3.5,"batch_size":1}"#,
1196 )
1197 .unwrap();
1198 request.upscale_model = Some(upscale_model.to_string());
1199 GpuJob {
1200 id: "job-upscale-test".to_string(),
1201 model: request.model.clone(),
1202 request,
1203 progress_tx: None,
1204 result_tx,
1205 output_dir: None,
1206 config: Arc::new(tokio::sync::RwLock::new(config)),
1207 metadata_db: Arc::new(None),
1208 queue: QueueHandle::new(queue_tx),
1209 registry: JobRegistry::new(),
1210 }
1211 }
1212
1213 fn fake_upscale_image() -> ImageData {
1214 ImageData {
1215 data: vec![0x89, 0x50, 0x4E, 0x47],
1216 format: OutputFormat::Png,
1217 width: 512,
1218 height: 512,
1219 index: 0,
1220 }
1221 }
1222
1223 #[test]
1224 fn worker_post_upscale_reports_missing_downloaded_model() {
1225 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1226 let job = fake_upscale_job(Config::default(), "real-esrgan-x4plus:fp16");
1227 let mut response = GenerateResponse {
1228 images: vec![],
1229 video: None,
1230 generation_time_ms: 10,
1231 model: job.request.model.clone(),
1232 seed_used: 7,
1233 gpu: None,
1234 };
1235
1236 let err = upscale_generated_image_on_worker(
1237 &worker,
1238 &job,
1239 "real-esrgan-x4plus:fp16",
1240 fake_upscale_image(),
1241 &mut response,
1242 )
1243 .expect_err("worker should reject a missing upscaler config");
1244
1245 assert!(err.contains("not downloaded"), "got: {err}");
1246 }
1247
1248 #[test]
1249 fn worker_post_upscale_surfaces_missing_weights_path() {
1250 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1251 let tmp = tempfile::TempDir::new().unwrap();
1252 let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1253 let mut config = Config::default();
1254 config.models.insert(
1255 "real-esrgan-x4plus:fp16".to_string(),
1256 ModelConfig {
1257 transformer: Some(missing_weights.display().to_string()),
1258 ..Default::default()
1259 },
1260 );
1261 let job = fake_upscale_job(config, "real-esrgan-x4plus:fp16");
1262 let mut response = GenerateResponse {
1263 images: vec![],
1264 video: None,
1265 generation_time_ms: 10,
1266 model: job.request.model.clone(),
1267 seed_used: 7,
1268 gpu: None,
1269 };
1270
1271 let err = upscale_generated_image_on_worker(
1272 &worker,
1273 &job,
1274 "real-esrgan-x4plus:fp16",
1275 fake_upscale_image(),
1276 &mut response,
1277 )
1278 .expect_err("worker should surface missing weight files before generation completes");
1279
1280 assert!(err.contains("failed to load upscaler"), "got: {err}");
1281 assert!(err.contains("upscaler weights not found"), "got: {err}");
1282 }
1283
1284 #[test]
1289 fn run_chain_blocking_serializes_same_worker() {
1290 let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
1291 let config = Config::default();
1292
1293 let active = Arc::new(AtomicUsize::new(0));
1294 let max_concurrent = Arc::new(AtomicUsize::new(0));
1295
1296 let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
1297 move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
1298 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1299 max_concurrent.fetch_max(now, Ordering::SeqCst);
1300 std::thread::sleep(Duration::from_millis(50));
1301 active.fetch_sub(1, Ordering::SeqCst);
1302 Ok(())
1303 }
1304 };
1305
1306 let worker_a = worker.clone();
1307 let config_a = config.clone();
1308 let a = active.clone();
1309 let m = max_concurrent.clone();
1310 let t_a = std::thread::spawn(move || {
1311 run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
1312 .expect("prep ok")
1313 .expect("closure ok");
1314 });
1315
1316 let worker_b = worker.clone();
1317 let config_b = config.clone();
1318 let a = active.clone();
1319 let m = max_concurrent.clone();
1320 let t_b = std::thread::spawn(move || {
1321 run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
1322 .expect("prep ok")
1323 .expect("closure ok");
1324 });
1325
1326 t_a.join().unwrap();
1327 t_b.join().unwrap();
1328
1329 assert_eq!(
1330 max_concurrent.load(Ordering::SeqCst),
1331 1,
1332 "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
1333 );
1334 }
1335
1336 #[test]
1343 fn is_cuda_oom_detects_driver_error_string() {
1344 let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
1345 assert!(
1346 is_cuda_oom(&oom_err),
1347 "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
1348 );
1349 }
1350
1351 #[test]
1353 fn is_cuda_oom_does_not_trigger_on_regular_errors() {
1354 let reg_err = anyhow::anyhow!("safetensors file not found");
1355 assert!(
1356 !is_cuda_oom(®_err),
1357 "non-OOM error must not be classified as OOM"
1358 );
1359 }
1360
1361 #[test]
1365 fn runtime_oom_message_suggests_offload_and_smaller_frames() {
1366 let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
1367 assert!(
1368 msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
1369 "OOM message must suggest reducing frames, resolution, or using a \
1370 quantized variant; got: {msg}",
1371 );
1372 assert!(
1373 !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
1374 "OOM user message must not expose the raw CUDA driver error string; \
1375 got: {msg}",
1376 );
1377 assert!(
1378 msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
1379 "OOM message must include the model name so the user knows what failed; \
1380 got: {msg}",
1381 );
1382 }
1383
1384 #[test]
1385 fn runtime_oom_message_for_sd15_1024_mentions_resolution_not_frames() {
1386 let req: GenerateRequest = serde_json::from_str(
1387 r#"{"prompt":"portrait","model":"realistic-vision-v5:fp16","width":1024,"height":1024,"steps":25,"guidance":7.5,"batch_size":1}"#,
1388 )
1389 .unwrap();
1390
1391 let msg =
1392 oom_user_message_for_request("realistic-vision-v5:fp16", Some("sd15"), Some(&req));
1393
1394 assert!(
1395 msg.contains("1024x1024"),
1396 "image OOM message should mention the requested resolution; got: {msg}"
1397 );
1398 assert!(
1399 msg.contains("512x512"),
1400 "SD1.5 OOM message should point back to the native/default size; got: {msg}"
1401 );
1402 assert!(
1403 msg.contains("checkpoint") || msg.contains("model file"),
1404 "OOM message should explain why file size is not peak VRAM; got: {msg}"
1405 );
1406 assert!(
1407 !msg.contains("--frames"),
1408 "image OOM message must not suggest video frame-count fixes; got: {msg}"
1409 );
1410 }
1411
1412 #[test]
1413 fn runtime_oom_message_for_ltx_keeps_frame_guidance() {
1414 let req: GenerateRequest = serde_json::from_str(
1415 r#"{"prompt":"camera pan","model":"ltx-video-0.9.8-13b-dev:bf16","width":768,"height":512,"steps":25,"guidance":3.5,"batch_size":1,"frames":25}"#,
1416 )
1417 .unwrap();
1418
1419 let msg = oom_user_message_for_request(
1420 "ltx-video-0.9.8-13b-dev:bf16",
1421 Some("ltx-video"),
1422 Some(&req),
1423 );
1424
1425 assert!(
1426 msg.contains("--frames") && msg.contains("25"),
1427 "video OOM message should keep frame-count guidance; got: {msg}"
1428 );
1429 assert!(
1430 msg.contains("768x512"),
1431 "video OOM message should mention the requested resolution; got: {msg}"
1432 );
1433 }
1434
1435 #[test]
1448 fn failed_load_does_not_leak_into_model_cache() {
1449 struct FailingLoadEngine {
1451 name: String,
1452 }
1453 impl InferenceEngine for FailingLoadEngine {
1454 fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1455 unreachable!()
1456 }
1457 fn model_name(&self) -> &str {
1458 &self.name
1459 }
1460 fn is_loaded(&self) -> bool {
1461 false
1462 }
1463 fn load(&mut self) -> anyhow::Result<()> {
1464 anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1465 }
1466 fn unload(&mut self) {}
1467 }
1468
1469 let cache = ModelCache::new(3);
1470 let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1471
1472 let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1476 name: model_name.to_string(),
1477 });
1478 let load_result = engine.load();
1479
1480 assert!(
1481 load_result.is_err(),
1482 "engine.load() must fail for this test to be meaningful"
1483 );
1484 assert!(
1485 is_cuda_oom(load_result.as_ref().unwrap_err()),
1486 "load error must be classified as OOM"
1487 );
1488
1489 assert!(
1492 !cache.contains(model_name),
1493 "cache must not contain the model after a failed load — \
1494 `insert_loaded` must only be called on success"
1495 );
1496 assert!(
1497 cache.is_empty(),
1498 "cache must be completely empty after a failed load"
1499 );
1500 }
1501}