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 let events = Some(job.events.as_ref());
518 if let Some(ref video) = response.video {
519 save_video_to_dir(
520 dir,
521 &video.data,
522 &video.gif_preview,
523 video.format,
524 &job.model,
525 &metadata,
526 Some(generation_time_ms),
527 db,
528 events,
529 );
530 } else {
531 save_image_to_dir(
532 dir,
533 &img,
534 &job.model,
535 job.request.batch_size,
536 Some(&metadata),
537 Some(generation_time_ms),
538 db,
539 events,
540 );
541 }
542 }
543
544 if let Some(ref tx) = job.progress_tx {
550 let event = build_sse_complete_event(&response, &img);
551 let _ = tx.send(SseMessage::Complete(event));
552 }
553
554 let _ = job.result_tx.send(Ok(GenerationJobResult {
556 image: img,
557 response,
558 }));
559 }
560 Ok(Err(e)) => {
561 tracing::warn!(gpu = ordinal, model = %model_name, "Generation failed: {e}");
562 let is_oom = is_cuda_oom(&e);
566 let (err_msg, count_worker_failure) = if is_oom {
567 mold_inference::device::try_synchronize_device(ordinal);
568 cuda_oom_user_message(
569 worker,
570 &model_name,
571 family_slug.as_deref(),
572 Some(&job.request),
573 )
574 } else {
575 (
576 format!("generation error: {}", clean_error_message(&e)),
577 true,
578 )
579 };
580 if count_worker_failure {
581 record_failure(worker);
582 }
583 if let Some(ref tx) = job.progress_tx {
584 let _ = tx.send(SseMessage::Error(SseErrorEvent {
585 message: err_msg.clone(),
586 }));
587 }
588 let _ = job.result_tx.send(Err(err_msg));
589 }
590 Err(panic_payload) => {
591 tracing::error!(gpu = ordinal, model = %model_name, "Inference panicked");
592 record_failure(worker);
593 let msg = panic_payload
594 .downcast_ref::<String>()
595 .map(|s| s.as_str())
596 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
597 .unwrap_or("unknown panic");
598 let err_msg = format!("inference panicked: {msg}");
599 if let Some(ref tx) = job.progress_tx {
600 let _ = tx.send(SseMessage::Error(SseErrorEvent {
601 message: err_msg.clone(),
602 }));
603 }
604 let _ = job.result_tx.send(Err(err_msg));
605 }
606 }
607}
608
609fn preflight_memory_guard_with_eviction(
623 cache_lock: &std::sync::Mutex<crate::model_cache::ModelCache>,
624 model_name: &str,
625 paths: &ModelPaths,
626 ordinal: usize,
627 hint: Option<crate::model_manager::ActivationHint>,
628) -> Result<(), crate::routes::ApiError> {
629 loop {
630 let active_vram = cache_lock
631 .lock()
632 .unwrap_or_else(|e| e.into_inner())
633 .active_vram_bytes();
634 let err = match crate::model_manager::preflight_memory_guard(
635 model_name,
636 paths,
637 active_vram,
638 ordinal,
639 hint,
640 ) {
641 Ok(()) => return Ok(()),
642 Err(e) => e,
643 };
644
645 let evicted = {
646 let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
647 cache.evict_lru_parked_except(Some(model_name))
648 };
649 let Some((evicted_name, engine)) = evicted else {
650 return Err(err);
651 };
652 tracing::info!(
653 gpu = ordinal,
654 target_model = %model_name,
655 evicted_model = %evicted_name,
656 "evicting LRU parked entry to fit incoming load"
657 );
658 drop(engine);
661
662 let safe_to_reclaim = cache_lock
668 .lock()
669 .unwrap_or_else(|e| e.into_inner())
670 .active_model()
671 .is_none();
672 if safe_to_reclaim {
673 device::reclaim_gpu_memory(ordinal);
674 }
675 }
676}
677
678fn select_load_strategy_for_worker(
679 worker: &GpuWorker,
680 model_name: &str,
681 paths: &ModelPaths,
682 hint: Option<crate::model_manager::ActivationHint>,
683) -> mold_inference::LoadStrategy {
684 let active_vram = worker
685 .model_cache
686 .lock()
687 .unwrap_or_else(|e| e.into_inner())
688 .active_vram_bytes();
689 let available =
690 crate::model_manager::effective_load_available_bytes(active_vram, worker.gpu.ordinal);
691 let strategy = crate::model_manager::select_server_load_strategy_for_device(
692 paths,
693 available,
694 Some(worker.gpu.total_vram_bytes),
695 hint,
696 );
697 if strategy == mold_inference::LoadStrategy::Sequential {
698 tracing::info!(
699 gpu = worker.gpu.ordinal,
700 model = %model_name,
701 "server load strategy degraded to sequential to fit memory budget"
702 );
703 }
704 strategy
705}
706
707pub fn ensure_model_ready_sync(
715 worker: &GpuWorker,
716 model_name: &str,
717 config: &Config,
718 hint: Option<crate::model_manager::ActivationHint>,
719 request_has_lora: bool,
720) -> anyhow::Result<()> {
721 let cache = worker.model_cache.lock().unwrap();
722
723 if let Some(entry) = cache.get(model_name) {
725 if entry.residency == ModelResidency::Gpu {
726 let must_recreate = entry.engine.model_paths().is_some_and(|paths| {
727 crate::model_manager::request_requires_fresh_engine_for_offload_policy(
728 paths,
729 hint,
730 request_has_lora,
731 )
732 });
733 if !must_recreate {
734 return Ok(());
735 }
736 }
737 }
738
739 let has_cached = cache.contains(model_name);
741
742 let cached_paths = if has_cached {
747 cache
748 .get(model_name)
749 .and_then(|e| e.engine.model_paths().cloned())
750 } else {
751 None
752 };
753 drop(cache);
754
755 if has_cached {
756 let load_strategy = cached_paths
757 .as_ref()
758 .map(|paths| select_load_strategy_for_worker(worker, model_name, paths, hint))
759 .unwrap_or(mold_inference::LoadStrategy::Eager);
760
761 if let Some(ref paths) = cached_paths {
766 preflight_memory_guard_with_eviction(
767 &worker.model_cache,
768 model_name,
769 paths,
770 worker.gpu.ordinal,
771 hint,
772 )
773 .map_err(|e| anyhow::anyhow!(e.error))?;
774 }
775
776 {
778 let mut cache = worker.model_cache.lock().unwrap();
779 cache.unload_active();
780 }
781 device::reclaim_gpu_memory(worker.gpu.ordinal);
782
783 if load_strategy == mold_inference::LoadStrategy::Sequential {
784 let paths = cached_paths.ok_or_else(|| {
785 anyhow::anyhow!("cached engine for '{model_name}' does not expose model paths")
786 })?;
787 let old_engine = {
788 let mut cache = worker.model_cache.lock().unwrap();
789 cache
790 .remove(model_name)
791 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
792 };
793
794 let offload = crate::model_manager::server_offload_enabled_for_paths(
795 &paths,
796 hint,
797 request_has_lora,
798 );
799 let resolved_catalog_config =
800 crate::model_manager::resolve_installed_catalog_paths_for_worker(
801 model_name, config,
802 )
803 .map_err(|e| anyhow::anyhow!(e.error))?
804 .map(|(_, config)| config);
805 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
806 let mut engine = match mold_inference::create_engine_with_pool(
807 model_name.to_string(),
808 paths,
809 engine_config,
810 load_strategy,
811 worker.gpu.ordinal,
812 offload,
813 Some(worker.shared_pool.clone()),
814 ) {
815 Ok(engine) => engine,
816 Err(err) => {
817 let evicted = {
818 let mut cache = worker.model_cache.lock().unwrap();
819 cache.insert(old_engine, 0)
820 };
821 drop(evicted);
822 return Err(err);
823 }
824 };
825
826 tracing::info!(
827 gpu = worker.gpu.ordinal,
828 model = %model_name,
829 "recreating cached engine in sequential mode..."
830 );
831 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
832 if let Err(err) = engine.load() {
833 let evicted = {
834 let mut cache = worker.model_cache.lock().unwrap();
835 cache.insert(old_engine, 0)
836 };
837 drop(evicted);
838 return Err(err);
839 }
840 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
841 drop(old_engine);
842 let evicted = {
843 let mut cache = worker.model_cache.lock().unwrap();
844 cache.insert_loaded(model_name.to_string(), engine, vram)
845 };
846 drop(evicted);
847 return Ok(());
848 }
849
850 let mut engine = {
852 let mut cache = worker.model_cache.lock().unwrap();
853 cache
854 .remove(model_name)
855 .ok_or_else(|| anyhow::anyhow!("cache race: model '{model_name}' vanished"))?
856 };
857
858 tracing::info!(
859 gpu = worker.gpu.ordinal,
860 model = %model_name,
861 "reloading cached engine..."
862 );
863 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
866 engine.load()?;
867
868 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
869 let evicted = {
872 let mut cache = worker.model_cache.lock().unwrap();
873 cache.insert_loaded(model_name.to_string(), engine, vram)
874 };
875 drop(evicted);
876 return Ok(());
877 }
878
879 let mut resolved_catalog_config = None;
882 let paths = if let Some(paths) = ModelPaths::resolve(model_name, config) {
883 paths
884 } else if let Some((paths, config)) =
885 crate::model_manager::resolve_installed_catalog_paths_for_worker(model_name, config)
886 .map_err(|e| anyhow::anyhow!(e.error))?
887 {
888 resolved_catalog_config = Some(config);
889 paths
890 } else {
891 return Err(
892 if model_name.starts_with("cv:") || model_name.starts_with("hf:") {
893 anyhow::anyhow!(
901 "catalog model '{model_name}' has missing required components. \
902 Re-pull the entry from the catalog so its companions \
903 (CLIP-L / T5 / VAE) are fetched alongside the primary checkpoint."
904 )
905 } else {
906 anyhow::anyhow!(
907 "model '{model_name}' is not downloaded. Run: mold pull {model_name}"
908 )
909 },
910 );
911 };
912
913 preflight_memory_guard_with_eviction(
916 &worker.model_cache,
917 model_name,
918 &paths,
919 worker.gpu.ordinal,
920 hint,
921 )
922 .map_err(|e| anyhow::anyhow!(e.error))?;
923
924 let load_strategy = select_load_strategy_for_worker(worker, model_name, &paths, hint);
925
926 {
928 let mut cache = worker.model_cache.lock().unwrap();
929 cache.unload_active();
930 }
931 device::reclaim_gpu_memory(worker.gpu.ordinal);
932
933 let offload =
934 crate::model_manager::server_offload_enabled_for_paths(&paths, hint, request_has_lora);
935 let engine_config = resolved_catalog_config.as_ref().unwrap_or(config);
936 let mut engine = mold_inference::create_engine_with_pool(
937 model_name.to_string(),
938 paths,
939 engine_config,
940 load_strategy,
941 worker.gpu.ordinal,
942 offload,
943 Some(worker.shared_pool.clone()),
944 )?;
945
946 tracing::info!(
947 gpu = worker.gpu.ordinal,
948 model = %model_name,
949 "loading model..."
950 );
951 let vram_baseline = device::vram_in_use_bytes(worker.gpu.ordinal);
954 engine.load()?;
955
956 let vram = device::vram_load_delta(worker.gpu.ordinal, vram_baseline);
957 let evicted = {
960 let mut cache = worker.model_cache.lock().unwrap();
961 cache.insert_loaded(model_name.to_string(), engine, vram)
962 };
963 drop(evicted);
964
965 Ok(())
966}
967
968pub fn load_blocking(worker: &GpuWorker, model_name: &str, config: &Config) -> anyhow::Result<()> {
975 let _lock = worker.model_load_lock.lock().unwrap();
976 ensure_model_ready_sync(worker, model_name, config, None, false)
977}
978
979pub fn unload_blocking(worker: &GpuWorker) -> Option<String> {
984 let _lock = worker.model_load_lock.lock().unwrap();
985 let unloaded = {
986 let mut cache = worker.model_cache.lock().unwrap();
987 cache.unload_active()
988 };
989 if unloaded.is_some() {
990 device::reclaim_gpu_memory(worker.gpu.ordinal);
991 }
992 unloaded
993}
994
995fn record_failure(worker: &GpuWorker) {
996 let failures = worker.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
997 if failures >= 3 {
998 let mut degraded = worker.degraded_until.write().unwrap();
999 *degraded = Some(Instant::now() + Duration::from_secs(60));
1000 tracing::warn!(
1001 gpu = worker.gpu.ordinal,
1002 "GPU marked degraded after {failures} consecutive failures (60s cooldown)"
1003 );
1004 }
1005}
1006
1007fn clear_active_generation(worker: &GpuWorker) {
1008 let mut gen = worker.active_generation.write().unwrap();
1009 *gen = None;
1010}
1011
1012pub type ChainPrep<T, E> = Result<Result<T, E>, anyhow::Error>;
1018
1019pub fn run_chain_blocking<T, E>(
1039 worker: &GpuWorker,
1040 model_name: &str,
1041 config: &mold_core::Config,
1042 hint: Option<crate::model_manager::ActivationHint>,
1043 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1044) -> ChainPrep<T, E> {
1045 struct ThreadGpuGuard;
1050 impl Drop for ThreadGpuGuard {
1051 fn drop(&mut self) {
1052 mold_inference::device::clear_thread_gpu_ordinal();
1053 }
1054 }
1055 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
1056 let _thread_gpu = ThreadGpuGuard;
1057
1058 let _load_lock = worker
1061 .model_load_lock
1062 .lock()
1063 .map_err(|e| anyhow::anyhow!("worker.model_load_lock poisoned: {e}"))?;
1064
1065 ensure_model_ready_sync(worker, model_name, config, hint, false)?;
1068
1069 let cached = {
1071 let mut cache = worker
1072 .model_cache
1073 .lock()
1074 .map_err(|e| anyhow::anyhow!("worker.model_cache poisoned: {e}"))?;
1075 cache.take(model_name).ok_or_else(|| {
1076 anyhow::anyhow!("cache race: engine '{model_name}' vanished after ensure_model_ready")
1077 })?
1078 };
1079
1080 let mut cached = cached;
1092 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1093 with_engine(cached.engine.as_mut())
1094 }));
1095
1096 {
1103 let mut cache = worker
1104 .model_cache
1105 .lock()
1106 .unwrap_or_else(|poisoned| poisoned.into_inner());
1107 cache.restore(cached);
1108 }
1109
1110 match result {
1111 Ok(inner) => Ok(inner),
1112 Err(panic_payload) => std::panic::resume_unwind(panic_payload),
1113 }
1114}
1115
1116pub fn run_stage_blocking<T, E>(
1121 worker: &GpuWorker,
1122 model_name: &str,
1123 config: &mold_core::Config,
1124 hint: Option<crate::model_manager::ActivationHint>,
1125 with_engine: impl FnOnce(&mut dyn mold_inference::InferenceEngine) -> Result<T, E>,
1126) -> ChainPrep<T, E> {
1127 run_chain_blocking(worker, model_name, config, hint, with_engine)
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::*;
1135 use crate::job_registry::JobRegistry;
1136 use crate::model_cache::ModelCache;
1137 use crate::state::QueueHandle;
1138 use mold_core::{
1139 Config, GenerateRequest, GenerateResponse, ImageData, ModelConfig, OutputFormat,
1140 };
1141 use mold_inference::device::DiscoveredGpu;
1142 use mold_inference::shared_pool::SharedPool;
1143 use mold_inference::InferenceEngine;
1144 use std::sync::atomic::{AtomicUsize, Ordering};
1145 use std::sync::{Arc, Mutex, RwLock};
1146 use std::time::Duration;
1147
1148 struct FakeSlowEngine {
1151 name: String,
1152 loaded: bool,
1153 load_sleep: Duration,
1154 }
1155
1156 impl FakeSlowEngine {
1157 fn boxed(name: &str, load_sleep: Duration) -> Box<dyn InferenceEngine> {
1158 Box::new(Self {
1159 name: name.to_string(),
1160 loaded: false,
1161 load_sleep,
1162 })
1163 }
1164 }
1165
1166 impl InferenceEngine for FakeSlowEngine {
1167 fn generate(&mut self, _req: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1168 unreachable!("FakeSlowEngine is not used for generation in tests")
1169 }
1170 fn model_name(&self) -> &str {
1171 &self.name
1172 }
1173 fn is_loaded(&self) -> bool {
1174 self.loaded
1175 }
1176 fn load(&mut self) -> anyhow::Result<()> {
1177 std::thread::sleep(self.load_sleep);
1178 self.loaded = true;
1179 Ok(())
1180 }
1181 fn unload(&mut self) {
1182 self.loaded = false;
1183 }
1184 }
1185
1186 fn single_worker_pool_with_parked(model: &str, load_sleep: Duration) -> Arc<GpuWorker> {
1187 let (job_tx, _job_rx) = std::sync::mpsc::sync_channel::<GpuJob>(2);
1188 let mut cache = ModelCache::new(3);
1189 cache.insert(FakeSlowEngine::boxed(model, load_sleep), 0);
1192 Arc::new(GpuWorker {
1193 gpu: DiscoveredGpu {
1194 ordinal: 0,
1195 name: "fake-gpu-0".to_string(),
1196 total_vram_bytes: 24_000_000_000,
1197 free_vram_bytes: 24_000_000_000,
1198 },
1199 model_cache: Arc::new(Mutex::new(cache)),
1200 active_generation: Arc::new(RwLock::new(None)),
1201 model_load_lock: Arc::new(Mutex::new(())),
1202 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1203 in_flight: AtomicUsize::new(0),
1204 consecutive_failures: AtomicUsize::new(0),
1205 degraded_until: RwLock::new(None),
1206 job_tx,
1207 })
1208 }
1209
1210 fn fake_upscale_job(config: Config, upscale_model: &str) -> GpuJob {
1211 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
1212 let (queue_tx, _queue_rx) = tokio::sync::mpsc::channel(1);
1213 let mut request: GenerateRequest = serde_json::from_str(
1214 r#"{"prompt":"portrait","model":"flux-dev:q4","width":512,"height":512,"steps":4,"guidance":3.5,"batch_size":1}"#,
1215 )
1216 .unwrap();
1217 request.upscale_model = Some(upscale_model.to_string());
1218 GpuJob {
1219 id: "job-upscale-test".to_string(),
1220 model: request.model.clone(),
1221 request,
1222 progress_tx: None,
1223 result_tx,
1224 output_dir: None,
1225 config: Arc::new(tokio::sync::RwLock::new(config)),
1226 metadata_db: Arc::new(None),
1227 queue: QueueHandle::new(queue_tx),
1228 registry: JobRegistry::new(),
1229 events: crate::events::EventBroadcaster::new(),
1230 }
1231 }
1232
1233 fn fake_upscale_image() -> ImageData {
1234 ImageData {
1235 data: vec![0x89, 0x50, 0x4E, 0x47],
1236 format: OutputFormat::Png,
1237 width: 512,
1238 height: 512,
1239 index: 0,
1240 }
1241 }
1242
1243 #[test]
1244 fn worker_post_upscale_reports_missing_downloaded_model() {
1245 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1246 let job = fake_upscale_job(Config::default(), "real-esrgan-x4plus:fp16");
1247 let mut response = GenerateResponse {
1248 images: vec![],
1249 video: None,
1250 generation_time_ms: 10,
1251 model: job.request.model.clone(),
1252 seed_used: 7,
1253 gpu: None,
1254 };
1255
1256 let err = upscale_generated_image_on_worker(
1257 &worker,
1258 &job,
1259 "real-esrgan-x4plus:fp16",
1260 fake_upscale_image(),
1261 &mut response,
1262 )
1263 .expect_err("worker should reject a missing upscaler config");
1264
1265 assert!(err.contains("not downloaded"), "got: {err}");
1266 }
1267
1268 #[test]
1269 fn worker_post_upscale_surfaces_missing_weights_path() {
1270 let worker = single_worker_pool_with_parked("flux-dev:q4", Duration::ZERO);
1271 let tmp = tempfile::TempDir::new().unwrap();
1272 let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1273 let mut config = Config::default();
1274 config.models.insert(
1275 "real-esrgan-x4plus:fp16".to_string(),
1276 ModelConfig {
1277 transformer: Some(missing_weights.display().to_string()),
1278 ..Default::default()
1279 },
1280 );
1281 let job = fake_upscale_job(config, "real-esrgan-x4plus:fp16");
1282 let mut response = GenerateResponse {
1283 images: vec![],
1284 video: None,
1285 generation_time_ms: 10,
1286 model: job.request.model.clone(),
1287 seed_used: 7,
1288 gpu: None,
1289 };
1290
1291 let err = upscale_generated_image_on_worker(
1292 &worker,
1293 &job,
1294 "real-esrgan-x4plus:fp16",
1295 fake_upscale_image(),
1296 &mut response,
1297 )
1298 .expect_err("worker should surface missing weight files before generation completes");
1299
1300 assert!(err.contains("failed to load upscaler"), "got: {err}");
1301 assert!(err.contains("upscaler weights not found"), "got: {err}");
1302 }
1303
1304 #[test]
1309 fn run_chain_blocking_serializes_same_worker() {
1310 let worker = single_worker_pool_with_parked("fake-model", Duration::from_millis(30));
1311 let config = Config::default();
1312
1313 let active = Arc::new(AtomicUsize::new(0));
1314 let max_concurrent = Arc::new(AtomicUsize::new(0));
1315
1316 let instrumented = |active: Arc<AtomicUsize>, max_concurrent: Arc<AtomicUsize>| {
1317 move |_engine: &mut dyn InferenceEngine| -> anyhow::Result<()> {
1318 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1319 max_concurrent.fetch_max(now, Ordering::SeqCst);
1320 std::thread::sleep(Duration::from_millis(50));
1321 active.fetch_sub(1, Ordering::SeqCst);
1322 Ok(())
1323 }
1324 };
1325
1326 let worker_a = worker.clone();
1327 let config_a = config.clone();
1328 let a = active.clone();
1329 let m = max_concurrent.clone();
1330 let t_a = std::thread::spawn(move || {
1331 run_chain_blocking(&worker_a, "fake-model", &config_a, None, instrumented(a, m))
1332 .expect("prep ok")
1333 .expect("closure ok");
1334 });
1335
1336 let worker_b = worker.clone();
1337 let config_b = config.clone();
1338 let a = active.clone();
1339 let m = max_concurrent.clone();
1340 let t_b = std::thread::spawn(move || {
1341 run_chain_blocking(&worker_b, "fake-model", &config_b, None, instrumented(a, m))
1342 .expect("prep ok")
1343 .expect("closure ok");
1344 });
1345
1346 t_a.join().unwrap();
1347 t_b.join().unwrap();
1348
1349 assert_eq!(
1350 max_concurrent.load(Ordering::SeqCst),
1351 1,
1352 "two concurrent run_chain_blocking calls must serialize on worker.model_load_lock"
1353 );
1354 }
1355
1356 #[test]
1363 fn is_cuda_oom_detects_driver_error_string() {
1364 let oom_err = anyhow::anyhow!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#);
1365 assert!(
1366 is_cuda_oom(&oom_err),
1367 "must detect CUDA_ERROR_OUT_OF_MEMORY in anyhow error chain"
1368 );
1369 }
1370
1371 #[test]
1373 fn is_cuda_oom_does_not_trigger_on_regular_errors() {
1374 let reg_err = anyhow::anyhow!("safetensors file not found");
1375 assert!(
1376 !is_cuda_oom(®_err),
1377 "non-OOM error must not be classified as OOM"
1378 );
1379 }
1380
1381 #[test]
1385 fn runtime_oom_message_suggests_offload_and_smaller_frames() {
1386 let msg = oom_user_message("ltx-video-0.9.8-13b-dev:bf16");
1387 assert!(
1388 msg.contains("frames") || msg.contains("width") || msg.contains("quantized"),
1389 "OOM message must suggest reducing frames, resolution, or using a \
1390 quantized variant; got: {msg}",
1391 );
1392 assert!(
1393 !msg.contains("CUDA_ERROR_OUT_OF_MEMORY"),
1394 "OOM user message must not expose the raw CUDA driver error string; \
1395 got: {msg}",
1396 );
1397 assert!(
1398 msg.contains("ltx-video-0.9.8-13b-dev:bf16"),
1399 "OOM message must include the model name so the user knows what failed; \
1400 got: {msg}",
1401 );
1402 }
1403
1404 #[test]
1405 fn runtime_oom_message_for_sd15_1024_mentions_resolution_not_frames() {
1406 let req: GenerateRequest = serde_json::from_str(
1407 r#"{"prompt":"portrait","model":"realistic-vision-v5:fp16","width":1024,"height":1024,"steps":25,"guidance":7.5,"batch_size":1}"#,
1408 )
1409 .unwrap();
1410
1411 let msg =
1412 oom_user_message_for_request("realistic-vision-v5:fp16", Some("sd15"), Some(&req));
1413
1414 assert!(
1415 msg.contains("1024x1024"),
1416 "image OOM message should mention the requested resolution; got: {msg}"
1417 );
1418 assert!(
1419 msg.contains("512x512"),
1420 "SD1.5 OOM message should point back to the native/default size; got: {msg}"
1421 );
1422 assert!(
1423 msg.contains("checkpoint") || msg.contains("model file"),
1424 "OOM message should explain why file size is not peak VRAM; got: {msg}"
1425 );
1426 assert!(
1427 !msg.contains("--frames"),
1428 "image OOM message must not suggest video frame-count fixes; got: {msg}"
1429 );
1430 }
1431
1432 #[test]
1433 fn runtime_oom_message_for_ltx_keeps_frame_guidance() {
1434 let req: GenerateRequest = serde_json::from_str(
1435 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}"#,
1436 )
1437 .unwrap();
1438
1439 let msg = oom_user_message_for_request(
1440 "ltx-video-0.9.8-13b-dev:bf16",
1441 Some("ltx-video"),
1442 Some(&req),
1443 );
1444
1445 assert!(
1446 msg.contains("--frames") && msg.contains("25"),
1447 "video OOM message should keep frame-count guidance; got: {msg}"
1448 );
1449 assert!(
1450 msg.contains("768x512"),
1451 "video OOM message should mention the requested resolution; got: {msg}"
1452 );
1453 }
1454
1455 #[test]
1468 fn failed_load_does_not_leak_into_model_cache() {
1469 struct FailingLoadEngine {
1471 name: String,
1472 }
1473 impl InferenceEngine for FailingLoadEngine {
1474 fn generate(&mut self, _: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1475 unreachable!()
1476 }
1477 fn model_name(&self) -> &str {
1478 &self.name
1479 }
1480 fn is_loaded(&self) -> bool {
1481 false
1482 }
1483 fn load(&mut self) -> anyhow::Result<()> {
1484 anyhow::bail!(r#"DriverError(CUDA_ERROR_OUT_OF_MEMORY, "out of memory")"#)
1485 }
1486 fn unload(&mut self) {}
1487 }
1488
1489 let cache = ModelCache::new(3);
1490 let model_name = "ltx-video-0.9.8-13b-dev:bf16";
1491
1492 let mut engine: Box<dyn InferenceEngine> = Box::new(FailingLoadEngine {
1496 name: model_name.to_string(),
1497 });
1498 let load_result = engine.load();
1499
1500 assert!(
1501 load_result.is_err(),
1502 "engine.load() must fail for this test to be meaningful"
1503 );
1504 assert!(
1505 is_cuda_oom(load_result.as_ref().unwrap_err()),
1506 "load error must be classified as OOM"
1507 );
1508
1509 assert!(
1512 !cache.contains(model_name),
1513 "cache must not contain the model after a failed load — \
1514 `insert_loaded` must only be called on success"
1515 );
1516 assert!(
1517 cache.is_empty(),
1518 "cache must be completely empty after a failed load"
1519 );
1520 }
1521}