1use std::path::Path;
2
3use mold_core::{GenerateRequest, ModelPaths};
4use mold_inference::device::{activation_bytes, activation_family_for, ActivationFamily};
5
6use crate::routes::ApiError;
7
8fn transformer_path_lower(paths: &ModelPaths) -> String {
9 paths.transformer.to_string_lossy().to_ascii_lowercase()
10}
11
12fn transformer_path_looks_flux2(path: &str) -> bool {
13 path.contains("/flux2/") || path.contains("flux2")
14}
15
16fn transformer_path_looks_ltx2(path: &str) -> bool {
17 path.contains("/ltx2/") || path.contains("ltx2")
18}
19
20fn transformer_path_looks_zimage(path: &str) -> bool {
21 path.contains("/z-image/") || path.contains("zimage")
22}
23
24fn transformer_path_is_gguf(paths: &ModelPaths) -> bool {
25 paths
26 .transformer
27 .extension()
28 .and_then(|e| e.to_str())
29 .is_some_and(|e| e.eq_ignore_ascii_case("gguf"))
30}
31
32fn model_component_size(path: &Path) -> u64 {
33 std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
34}
35
36fn transformer_component_size(paths: &ModelPaths) -> u64 {
37 if paths.transformer_shards.is_empty() {
38 model_component_size(&paths.transformer)
39 } else {
40 paths
41 .transformer_shards
42 .iter()
43 .map(|path| model_component_size(path))
44 .sum()
45 }
46}
47
48fn large_flux_bf16_should_auto_offload(paths: &ModelPaths, hint: Option<ActivationHint>) -> bool {
49 const LARGE_FLUX_BF16_TRANSFORMER_BYTES: u64 = 20_000_000_000;
50
51 if !hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
52 || transformer_path_is_gguf(paths)
53 {
54 return false;
55 }
56
57 let transformer_path = transformer_path_lower(paths);
58 if transformer_path_looks_flux2(&transformer_path)
59 || transformer_path_looks_zimage(&transformer_path)
60 || transformer_path_looks_ltx2(&transformer_path)
61 || transformer_path.contains("nvfp4")
62 {
63 return false;
64 }
65
66 transformer_component_size(paths) >= LARGE_FLUX_BF16_TRANSFORMER_BYTES
67}
68
69#[derive(Debug, Clone, Copy)]
80pub struct ActivationHint {
81 pub width: u32,
83 pub height: u32,
85 pub batch: u32,
87 pub dtype_bytes: u32,
89 pub family: ActivationFamily,
92}
93
94impl ActivationHint {
95 pub fn from_request(req: &GenerateRequest, family_slug: &str) -> Self {
101 let family = activation_family_for(family_slug);
104 let batch = match family {
105 ActivationFamily::SdxlUnet | ActivationFamily::Sd3Mmdit if req.guidance > 1.0 => 2,
106 _ => 1,
107 };
108 Self {
109 width: req.width,
110 height: req.height,
111 batch,
112 dtype_bytes: 2,
115 family,
116 }
117 }
118
119 pub fn budget_bytes(&self) -> u64 {
121 activation_bytes(
122 self.width,
123 self.height,
124 self.batch,
125 self.dtype_bytes,
126 self.family,
127 )
128 }
129}
130
131pub(crate) fn check_model_memory_budget(
141 model_name: &str,
142 peak_bytes: u64,
143 available_bytes: u64,
144 suggestion: &str,
145) -> Result<(), ApiError> {
146 let hard_limit = available_bytes * 9 / 10; if peak_bytes > hard_limit {
148 return Err(ApiError::insufficient_memory(format!(
149 "model '{}' estimated peak ~{:.1} GB exceeds the per-load budget cap ~{:.1} GB \
150 (90% of {:.1} GB free, with 2 GB activation headroom built into peak estimate; \
151 encoders are dropped before denoise). {}",
152 model_name,
153 peak_bytes as f64 / 1_000_000_000.0,
154 hard_limit as f64 / 1_000_000_000.0,
155 available_bytes as f64 / 1_000_000_000.0,
156 suggestion,
157 )));
158 }
159
160 let warn_limit = available_bytes * 8 / 10; if peak_bytes > warn_limit {
162 tracing::warn!(
163 model = %model_name,
164 peak_gb = format_args!("{:.1}", peak_bytes as f64 / 1_000_000_000.0),
165 available_gb = format_args!("{:.1}", available_bytes as f64 / 1_000_000_000.0),
166 "model is close to memory limit — may trigger page reclamation"
167 );
168 }
169
170 Ok(())
171}
172
173pub(crate) fn rejection_suggestion(hint: Option<ActivationHint>) -> &'static str {
179 match hint.map(|h| h.family) {
180 Some(ActivationFamily::LtxVideo) => {
181 "Try reducing --frames or --width/--height, use a quantized variant \
182 (e.g. ':q8'), or close other GPU apps."
183 }
184 _ => {
185 "Try lowering --width/--height, reduce --batch, use a smaller/quantized \
186 variant if available, enable --offload for FLUX, or close other GPU apps."
187 }
188 }
189}
190
191pub(crate) fn preflight_memory_guard_with_available(
211 model_name: &str,
212 paths: &ModelPaths,
213 active_vram_bytes: u64,
214 available_bytes: u64,
215 hint: Option<ActivationHint>,
216) -> Result<(), ApiError> {
217 let transformer_path = transformer_path_lower(paths);
227 let streaming = hint
228 .map(|h| h.family.streaming_transformer())
229 .unwrap_or_else(|| transformer_path_looks_ltx2(&transformer_path));
230 let flux_offload = (hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
231 && std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1"))
232 || large_flux_bf16_should_auto_offload(paths, hint);
233 let qwen_quantized = hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit)
234 && paths
235 .transformer
236 .extension()
237 .and_then(|e| e.to_str())
238 .is_some_and(|e| e.eq_ignore_ascii_case("gguf"));
239 let peak = base_peak_memory_for_paths(paths, hint, streaming, flux_offload, qwen_quantized);
240 let activation = activation_memory_for_estimate(hint, qwen_quantized);
245 let peak_with_activation = peak.saturating_add(activation);
246 let effective_available = available_bytes.saturating_add(active_vram_bytes);
247 if qwen_quantized && peak_with_activation <= effective_available {
248 return Ok(());
249 }
250 let suggestion = rejection_suggestion(hint);
251
252 check_model_memory_budget(
253 model_name,
254 peak_with_activation,
255 effective_available,
256 suggestion,
257 )
258}
259
260fn base_peak_memory_for_paths(
261 paths: &ModelPaths,
262 hint: Option<ActivationHint>,
263 streaming: bool,
264 flux_offload: bool,
265 qwen_quantized: bool,
266) -> u64 {
267 if streaming {
268 let gemma_competes = ltx2_encoder_phase_competes_with_transformer_gpu(0);
275 return streaming_transformer_peak(paths, gemma_competes);
276 } else if flux_offload {
277 return streaming_transformer_peak(paths, false);
278 } else if hint.is_some_and(|h| h.family == ActivationFamily::Sd3Mmdit) {
279 return sd3_sequential_peak(paths);
280 } else if qwen_quantized {
281 return qwen_image_quantized_sequential_peak(paths, hint);
282 }
283
284 mold_inference::device::estimate_peak_memory(paths, mold_inference::LoadStrategy::Sequential)
285}
286
287fn activation_memory_for_estimate(hint: Option<ActivationHint>, qwen_quantized: bool) -> u64 {
288 if qwen_quantized {
289 0
290 } else {
291 hint.map(|h| h.budget_bytes()).unwrap_or(0)
292 }
293}
294
295fn streaming_transformer_peak(
313 paths: &ModelPaths,
314 gemma_competes_with_transformer_gpu: bool,
315) -> u64 {
316 const STREAMING_TRANSFORMER_CAP: u64 = 6_000_000_000; const HEADROOM: u64 = 2_000_000_000; let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
320 let t5_size = paths.t5_encoder.as_ref().map(|p| file_size(p)).unwrap_or(0);
321 let clip_size = paths
322 .clip_encoder
323 .as_ref()
324 .map(|p| file_size(p))
325 .unwrap_or(0);
326 let clip2_size = paths
327 .clip_encoder_2
328 .as_ref()
329 .map(|p| file_size(p))
330 .unwrap_or(0);
331 let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
332 let encoder_total = if gemma_competes_with_transformer_gpu {
333 t5_size + clip_size + clip2_size + text_encoder_size
334 } else {
335 0
336 };
337
338 let inference_phase = STREAMING_TRANSFORMER_CAP;
339 std::cmp::max(encoder_total, inference_phase) + HEADROOM
340}
341
342fn sd3_sequential_peak(paths: &ModelPaths) -> u64 {
348 const SD3_VAE_RESIDENCY_CAP: u64 = 1_000_000_000; const HEADROOM: u64 = 2_000_000_000; let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
352 let transformer_size = if !paths.transformer_shards.is_empty() {
353 paths.transformer_shards.iter().map(|p| file_size(p)).sum()
354 } else {
355 file_size(&paths.transformer)
356 };
357 let vae_size = file_size(&paths.vae).min(SD3_VAE_RESIDENCY_CAP);
358 let t5_size = paths.t5_encoder.as_ref().map(|p| file_size(p)).unwrap_or(0);
359 let clip_size = paths
360 .clip_encoder
361 .as_ref()
362 .map(|p| file_size(p))
363 .unwrap_or(0);
364 let clip2_size = paths
365 .clip_encoder_2
366 .as_ref()
367 .map(|p| file_size(p))
368 .unwrap_or(0);
369 let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
370 let encoder_total = t5_size + clip_size + clip2_size + text_encoder_size;
371
372 transformer_size.max(vae_size).max(encoder_total) + HEADROOM
373}
374
375fn qwen_image_quantized_sequential_peak(paths: &ModelPaths, hint: Option<ActivationHint>) -> u64 {
380 const QWEN_GGUF_PHASE_HEADROOM: u64 = 128_000_000;
381
382 let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
383 let transformer_size = if !paths.transformer_shards.is_empty() {
384 paths.transformer_shards.iter().map(|p| file_size(p)).sum()
385 } else {
386 file_size(&paths.transformer)
387 };
388 let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
389 let vae_size = file_size(&paths.vae);
390 let activation = hint
391 .map(|h| {
392 mold_inference::device::activation_bytes(
393 h.width,
394 h.height,
395 1,
396 h.dtype_bytes,
397 ActivationFamily::QwenImageDit,
398 )
399 })
400 .unwrap_or(0);
401
402 transformer_size
403 .saturating_add(activation)
404 .saturating_add(QWEN_GGUF_PHASE_HEADROOM)
405 .max(text_encoder_size)
406 .max(vae_size)
407}
408
409fn ltx2_encoder_phase_competes_with_transformer_gpu(gpu_ordinal: usize) -> bool {
413 matches!(
414 mold_inference::device::resolve_ltx2_gemma_device_override(gpu_ordinal),
415 Some(mold_inference::device::LtxGemmaPlacement::Gpu(ordinal)) if ordinal == gpu_ordinal
416 )
417}
418
419pub(crate) fn preflight_memory_guard(
437 model_name: &str,
438 paths: &ModelPaths,
439 active_vram_bytes: u64,
440 #[cfg_attr(not(feature = "cuda"), allow(unused_variables))] gpu_ordinal: usize,
441 hint: Option<ActivationHint>,
442) -> Result<(), ApiError> {
443 #[cfg(feature = "cuda")]
446 {
447 if active_vram_bytes > 0 {
448 if let Some(total) = mold_inference::device::total_vram_bytes(gpu_ordinal) {
449 return preflight_memory_guard_with_available(model_name, paths, 0, total, hint);
450 }
451 }
452 if let (Some(free), Some(total)) = (
460 mold_inference::device::free_vram_bytes(gpu_ordinal),
461 mold_inference::device::total_vram_bytes(gpu_ordinal),
462 ) {
463 const GHOST_VRAM_THRESHOLD: u64 = 1_500_000_000; if total.saturating_sub(free) > GHOST_VRAM_THRESHOLD {
465 tracing::info!(
466 gpu = gpu_ordinal,
467 free_gb = format_args!("{:.1}", free as f64 / 1e9),
468 total_gb = format_args!("{:.1}", total as f64 / 1e9),
469 "no active model on this GPU but VRAM is held — reclaiming primary context",
470 );
471 mold_inference::device::reclaim_gpu_memory(gpu_ordinal);
472 }
473 let effective_free = mold_inference::device::usable_free_vram_bytes(gpu_ordinal)
474 .unwrap_or_else(|| {
475 free.saturating_sub(mold_inference::device::reserved_vram_bytes())
476 });
477 return preflight_memory_guard_with_available(
478 model_name,
479 paths,
480 active_vram_bytes,
481 effective_free,
482 hint,
483 );
484 }
485 if let Some(free) = mold_inference::device::usable_free_vram_bytes(gpu_ordinal) {
488 return preflight_memory_guard_with_available(
489 model_name,
490 paths,
491 active_vram_bytes,
492 free,
493 hint,
494 );
495 }
496 }
497
498 if let Some(available) = mold_inference::device::available_system_memory_bytes() {
500 if available > 0 {
501 return preflight_memory_guard_with_available(
502 model_name,
503 paths,
504 active_vram_bytes,
505 available,
506 hint,
507 );
508 }
509 }
510
511 Ok(())
513}
514
515pub(crate) fn effective_load_available_bytes(
522 active_vram_bytes: u64,
523 #[cfg_attr(not(feature = "cuda"), allow(unused_variables))] gpu_ordinal: usize,
524) -> Option<u64> {
525 #[cfg(feature = "cuda")]
526 {
527 if active_vram_bytes > 0 {
528 if let Some(total) = mold_inference::device::total_vram_bytes(gpu_ordinal) {
529 return Some(total);
530 }
531 }
532 if let Some(free) = mold_inference::device::usable_free_vram_bytes(gpu_ordinal) {
533 return Some(free);
534 }
535 }
536
537 mold_inference::device::available_system_memory_bytes()
538 .filter(|available| *available > 0)
539 .map(|available| available.saturating_add(active_vram_bytes))
540}
541
542pub(crate) fn select_server_load_strategy_for_budget(
551 paths: &ModelPaths,
552 available_bytes: Option<u64>,
553 hint: Option<ActivationHint>,
554) -> mold_inference::LoadStrategy {
555 let transformer_is_gguf = transformer_path_is_gguf(paths);
556
557 if hint.is_some_and(|h| h.family == ActivationFamily::ZImageDit) && !transformer_is_gguf {
558 return mold_inference::LoadStrategy::Sequential;
559 }
560 if transformer_is_gguf
561 && hint.is_some_and(|h| {
562 matches!(
563 h.family,
564 ActivationFamily::Sd3Mmdit | ActivationFamily::ZImageDit
565 )
566 })
567 {
568 return mold_inference::LoadStrategy::Eager;
569 }
570 let qwen_quantized =
571 hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit) && transformer_is_gguf;
572
573 let Some(available_bytes) = available_bytes.filter(|v| *v > 0) else {
574 return mold_inference::LoadStrategy::Eager;
575 };
576
577 if qwen_quantized {
578 let peak = qwen_image_quantized_sequential_peak(paths, hint);
579 if peak <= available_bytes {
580 return mold_inference::LoadStrategy::Sequential;
581 }
582 }
583
584 let activation = hint.map(|h| h.budget_bytes()).unwrap_or(0);
585 let eager_peak =
586 mold_inference::device::estimate_peak_memory(paths, mold_inference::LoadStrategy::Eager)
587 .saturating_add(activation);
588 let sequential_peak = mold_inference::device::estimate_peak_memory(
589 paths,
590 mold_inference::LoadStrategy::Sequential,
591 )
592 .saturating_add(activation);
593 let hard_limit = available_bytes.saturating_mul(9) / 10;
594
595 if eager_peak > hard_limit && sequential_peak <= hard_limit {
596 mold_inference::LoadStrategy::Sequential
597 } else {
598 mold_inference::LoadStrategy::Eager
599 }
600}
601
602pub(crate) fn select_server_load_strategy_for_device(
603 paths: &ModelPaths,
604 available_bytes: Option<u64>,
605 device_total_bytes: Option<u64>,
606 hint: Option<ActivationHint>,
607) -> mold_inference::LoadStrategy {
608 let capped_available = match (
609 available_bytes.filter(|available| *available > 0),
610 device_total_bytes.filter(|total| *total > 0),
611 ) {
612 (Some(available), Some(total)) => Some(available.min(total)),
613 (available, None) => available,
614 (None, Some(total)) => Some(total),
615 };
616
617 select_server_load_strategy_for_budget(paths, capped_available, hint)
618}
619
620pub(crate) fn server_offload_enabled_for_paths(
621 paths: &ModelPaths,
622 hint: Option<ActivationHint>,
623 request_has_lora: bool,
624) -> bool {
625 let forced_offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
626 let transformer_path = transformer_path_lower(paths);
627 let transformer_looks_flux2 = transformer_path_looks_flux2(&transformer_path);
628 let transformer_looks_zimage = transformer_path_looks_zimage(&transformer_path);
629 let transformer_looks_nvfp4 = transformer_path.contains("nvfp4");
630
631 if request_has_lora
632 && (transformer_looks_flux2
633 || transformer_looks_zimage
634 || hint.is_some_and(|h| {
635 matches!(
636 h.family,
637 ActivationFamily::Flux2Dit | ActivationFamily::ZImageDit
638 )
639 }))
640 {
641 return false;
642 }
643
644 let transformer_is_gguf = transformer_path_is_gguf(paths);
645
646 if transformer_looks_nvfp4
647 && (transformer_looks_flux2 || hint.is_some_and(|h| h.family == ActivationFamily::Flux2Dit))
648 {
649 return false;
650 }
651
652 if transformer_is_gguf
653 && hint.is_some_and(|h| {
654 matches!(
655 h.family,
656 ActivationFamily::Sd3Mmdit
657 | ActivationFamily::ZImageDit
658 | ActivationFamily::Flux2Dit
659 )
660 })
661 {
662 return false;
663 }
664
665 forced_offload || large_flux_bf16_should_auto_offload(paths, hint)
666}
667
668pub(crate) fn request_requires_fresh_engine_for_offload_policy(
669 paths: &ModelPaths,
670 hint: Option<ActivationHint>,
671 request_has_lora: bool,
672) -> bool {
673 request_has_lora
674 && server_offload_enabled_for_paths(paths, hint, false)
675 && !server_offload_enabled_for_paths(paths, hint, true)
676}
677
678pub(crate) struct GenerationMemoryBudget {
679 pub(crate) peak_memory_bytes: u64,
680 pub(crate) activation_memory_bytes: u64,
681 pub(crate) available_memory_bytes: Option<u64>,
682 pub(crate) load_strategy: mold_inference::LoadStrategy,
683 pub(crate) fits_available_memory: Option<bool>,
684}
685
686pub(crate) fn estimate_generation_memory_for_request(
687 req: &GenerateRequest,
688 paths: &ModelPaths,
689 hint: Option<ActivationHint>,
690) -> GenerationMemoryBudget {
691 let transformer_path = transformer_path_lower(paths);
692 let streaming = hint
693 .map(|h| h.family.streaming_transformer())
694 .unwrap_or_else(|| transformer_path_looks_ltx2(&transformer_path));
695 let flux_offload = (hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
696 && std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1"))
697 || large_flux_bf16_should_auto_offload(paths, hint);
698 let qwen_quantized = hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit)
699 && transformer_path_is_gguf(paths);
700 let base_peak =
701 base_peak_memory_for_paths(paths, hint, streaming, flux_offload, qwen_quantized);
702 let activation = request_sensitive_activation_memory(req, hint, qwen_quantized);
703 let peak = base_peak.saturating_add(activation);
704 let available = effective_load_available_bytes(0, 0);
705 let load_strategy = select_server_load_strategy_for_budget(paths, available, hint);
706 let fits = available.map(|available| peak <= available.saturating_mul(9) / 10);
707
708 GenerationMemoryBudget {
709 peak_memory_bytes: peak,
710 activation_memory_bytes: activation,
711 available_memory_bytes: available,
712 load_strategy,
713 fits_available_memory: fits,
714 }
715}
716
717fn request_sensitive_activation_memory(
718 req: &GenerateRequest,
719 hint: Option<ActivationHint>,
720 qwen_quantized: bool,
721) -> u64 {
722 let base = activation_memory_for_estimate(hint, qwen_quantized);
723 let batch = u64::from(req.batch_size.max(1));
724 let video_frames = u64::from(req.frames.unwrap_or(1).max(1));
725 let video_factor = if hint.is_some_and(|h| h.family.streaming_transformer()) {
726 video_frames.div_ceil(25).max(1)
731 } else {
732 1
733 };
734 let cfg_factor = if req.guidance > 1.0 && req.negative_prompt.is_some() {
735 2
736 } else {
737 1
738 };
739
740 let mut activation = base
741 .saturating_mul(batch)
742 .saturating_mul(video_factor)
743 .saturating_mul(cfg_factor);
744
745 let pixel_bytes = u64::from(req.width)
746 .saturating_mul(u64::from(req.height))
747 .saturating_mul(4);
748 if req.source_image.is_some()
749 || req
750 .edit_images
751 .as_ref()
752 .is_some_and(|images| !images.is_empty())
753 {
754 activation = activation.saturating_add(pixel_bytes.saturating_mul(batch));
755 }
756 if req.mask_image.is_some() {
757 activation = activation.saturating_add(pixel_bytes / 2);
758 }
759 if req.control_image.is_some() || req.control_model.as_deref().is_some_and(|m| !m.is_empty()) {
760 activation = activation.saturating_add(pixel_bytes.saturating_mul(2));
761 }
762 if req.upscale_model.as_deref().is_some_and(|m| !m.is_empty()) {
763 activation = activation.saturating_add(pixel_bytes.saturating_mul(4));
764 }
765 let lora_count = req
766 .loras
767 .as_ref()
768 .map(|loras| loras.len())
769 .unwrap_or_else(|| usize::from(req.lora.is_some())) as u64;
770 activation.saturating_add(lora_count.saturating_mul(128 * 1024 * 1024))
771}