use std::path::Path;
use mold_core::{GenerateRequest, ModelPaths};
use mold_inference::device::{activation_bytes, activation_family_for, ActivationFamily};
use crate::routes::ApiError;
fn transformer_path_lower(paths: &ModelPaths) -> String {
paths.transformer.to_string_lossy().to_ascii_lowercase()
}
fn transformer_path_looks_flux2(path: &str) -> bool {
path.contains("/flux2/") || path.contains("flux2")
}
fn transformer_path_looks_ltx2(path: &str) -> bool {
path.contains("/ltx2/") || path.contains("ltx2")
}
fn transformer_path_looks_zimage(path: &str) -> bool {
path.contains("/z-image/") || path.contains("zimage")
}
fn transformer_path_is_gguf(paths: &ModelPaths) -> bool {
paths
.transformer
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("gguf"))
}
fn model_component_size(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn transformer_component_size(paths: &ModelPaths) -> u64 {
if paths.transformer_shards.is_empty() {
model_component_size(&paths.transformer)
} else {
paths
.transformer_shards
.iter()
.map(|path| model_component_size(path))
.sum()
}
}
fn large_flux_bf16_should_auto_offload(paths: &ModelPaths, hint: Option<ActivationHint>) -> bool {
const LARGE_FLUX_BF16_TRANSFORMER_BYTES: u64 = 20_000_000_000;
if !hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
|| transformer_path_is_gguf(paths)
{
return false;
}
let transformer_path = transformer_path_lower(paths);
if transformer_path_looks_flux2(&transformer_path)
|| transformer_path_looks_zimage(&transformer_path)
|| transformer_path_looks_ltx2(&transformer_path)
|| transformer_path.contains("nvfp4")
{
return false;
}
transformer_component_size(paths) >= LARGE_FLUX_BF16_TRANSFORMER_BYTES
}
#[derive(Debug, Clone, Copy)]
pub struct ActivationHint {
pub width: u32,
pub height: u32,
pub batch: u32,
pub dtype_bytes: u32,
pub family: ActivationFamily,
}
impl ActivationHint {
pub fn from_request(req: &GenerateRequest, family_slug: &str) -> Self {
let family = activation_family_for(family_slug);
let batch = match family {
ActivationFamily::SdxlUnet | ActivationFamily::Sd3Mmdit if req.guidance > 1.0 => 2,
_ => 1,
};
Self {
width: req.width,
height: req.height,
batch,
dtype_bytes: 2,
family,
}
}
pub fn budget_bytes(&self) -> u64 {
activation_bytes(
self.width,
self.height,
self.batch,
self.dtype_bytes,
self.family,
)
}
}
pub(crate) fn check_model_memory_budget(
model_name: &str,
peak_bytes: u64,
available_bytes: u64,
suggestion: &str,
) -> Result<(), ApiError> {
let hard_limit = available_bytes * 9 / 10; if peak_bytes > hard_limit {
return Err(ApiError::insufficient_memory(format!(
"model '{}' estimated peak ~{:.1} GB exceeds the per-load budget cap ~{:.1} GB \
(90% of {:.1} GB free, with 2 GB activation headroom built into peak estimate; \
encoders are dropped before denoise). {}",
model_name,
peak_bytes as f64 / 1_000_000_000.0,
hard_limit as f64 / 1_000_000_000.0,
available_bytes as f64 / 1_000_000_000.0,
suggestion,
)));
}
let warn_limit = available_bytes * 8 / 10; if peak_bytes > warn_limit {
tracing::warn!(
model = %model_name,
peak_gb = format_args!("{:.1}", peak_bytes as f64 / 1_000_000_000.0),
available_gb = format_args!("{:.1}", available_bytes as f64 / 1_000_000_000.0),
"model is close to memory limit — may trigger page reclamation"
);
}
Ok(())
}
pub(crate) fn rejection_suggestion(hint: Option<ActivationHint>) -> &'static str {
match hint.map(|h| h.family) {
Some(ActivationFamily::LtxVideo) => {
"Try reducing --frames or --width/--height, use a quantized variant \
(e.g. ':q8'), or close other GPU apps."
}
_ => {
"Try lowering --width/--height, reduce --batch, use a smaller/quantized \
variant if available, enable --offload for FLUX, or close other GPU apps."
}
}
}
pub(crate) fn preflight_memory_guard_with_available(
model_name: &str,
paths: &ModelPaths,
active_vram_bytes: u64,
available_bytes: u64,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
let transformer_path = transformer_path_lower(paths);
let streaming = hint
.map(|h| h.family.streaming_transformer())
.unwrap_or_else(|| transformer_path_looks_ltx2(&transformer_path));
let flux_offload = (hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
&& std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1"))
|| large_flux_bf16_should_auto_offload(paths, hint);
let qwen_quantized = hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit)
&& paths
.transformer
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("gguf"));
let peak = base_peak_memory_for_paths(paths, hint, streaming, flux_offload, qwen_quantized);
let activation = activation_memory_for_estimate(hint, qwen_quantized);
let peak_with_activation = peak.saturating_add(activation);
let effective_available = available_bytes.saturating_add(active_vram_bytes);
if qwen_quantized && peak_with_activation <= effective_available {
return Ok(());
}
let suggestion = rejection_suggestion(hint);
check_model_memory_budget(
model_name,
peak_with_activation,
effective_available,
suggestion,
)
}
fn base_peak_memory_for_paths(
paths: &ModelPaths,
hint: Option<ActivationHint>,
streaming: bool,
flux_offload: bool,
qwen_quantized: bool,
) -> u64 {
if streaming {
let gemma_competes = ltx2_encoder_phase_competes_with_transformer_gpu(0);
return streaming_transformer_peak(paths, gemma_competes);
} else if flux_offload {
return streaming_transformer_peak(paths, false);
} else if hint.is_some_and(|h| h.family == ActivationFamily::Sd3Mmdit) {
return sd3_sequential_peak(paths);
} else if qwen_quantized {
return qwen_image_quantized_sequential_peak(paths, hint);
}
mold_inference::device::estimate_peak_memory(paths, mold_inference::LoadStrategy::Sequential)
}
fn activation_memory_for_estimate(hint: Option<ActivationHint>, qwen_quantized: bool) -> u64 {
if qwen_quantized {
0
} else {
hint.map(|h| h.budget_bytes()).unwrap_or(0)
}
}
fn streaming_transformer_peak(
paths: &ModelPaths,
gemma_competes_with_transformer_gpu: bool,
) -> u64 {
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);
let t5_size = paths.t5_encoder.as_ref().map(|p| file_size(p)).unwrap_or(0);
let clip_size = paths
.clip_encoder
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let clip2_size = paths
.clip_encoder_2
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
let encoder_total = if gemma_competes_with_transformer_gpu {
t5_size + clip_size + clip2_size + text_encoder_size
} else {
0
};
let inference_phase = STREAMING_TRANSFORMER_CAP;
std::cmp::max(encoder_total, inference_phase) + HEADROOM
}
fn sd3_sequential_peak(paths: &ModelPaths) -> u64 {
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);
let transformer_size = if !paths.transformer_shards.is_empty() {
paths.transformer_shards.iter().map(|p| file_size(p)).sum()
} else {
file_size(&paths.transformer)
};
let vae_size = file_size(&paths.vae).min(SD3_VAE_RESIDENCY_CAP);
let t5_size = paths.t5_encoder.as_ref().map(|p| file_size(p)).unwrap_or(0);
let clip_size = paths
.clip_encoder
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let clip2_size = paths
.clip_encoder_2
.as_ref()
.map(|p| file_size(p))
.unwrap_or(0);
let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
let encoder_total = t5_size + clip_size + clip2_size + text_encoder_size;
transformer_size.max(vae_size).max(encoder_total) + HEADROOM
}
fn qwen_image_quantized_sequential_peak(paths: &ModelPaths, hint: Option<ActivationHint>) -> u64 {
const QWEN_GGUF_PHASE_HEADROOM: u64 = 128_000_000;
let file_size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let transformer_size = if !paths.transformer_shards.is_empty() {
paths.transformer_shards.iter().map(|p| file_size(p)).sum()
} else {
file_size(&paths.transformer)
};
let text_encoder_size: u64 = paths.text_encoder_files.iter().map(|p| file_size(p)).sum();
let vae_size = file_size(&paths.vae);
let activation = hint
.map(|h| {
mold_inference::device::activation_bytes(
h.width,
h.height,
1,
h.dtype_bytes,
ActivationFamily::QwenImageDit,
)
})
.unwrap_or(0);
transformer_size
.saturating_add(activation)
.saturating_add(QWEN_GGUF_PHASE_HEADROOM)
.max(text_encoder_size)
.max(vae_size)
}
fn ltx2_encoder_phase_competes_with_transformer_gpu(gpu_ordinal: usize) -> bool {
matches!(
mold_inference::device::resolve_ltx2_gemma_device_override(gpu_ordinal),
Some(mold_inference::device::LtxGemmaPlacement::Gpu(ordinal)) if ordinal == gpu_ordinal
)
}
pub(crate) fn preflight_memory_guard(
model_name: &str,
paths: &ModelPaths,
active_vram_bytes: u64,
#[cfg_attr(not(feature = "cuda"), allow(unused_variables))] gpu_ordinal: usize,
hint: Option<ActivationHint>,
) -> Result<(), ApiError> {
#[cfg(feature = "cuda")]
{
if active_vram_bytes > 0 {
if let Some(total) = mold_inference::device::total_vram_bytes(gpu_ordinal) {
return preflight_memory_guard_with_available(model_name, paths, 0, total, hint);
}
}
if let (Some(free), Some(total)) = (
mold_inference::device::free_vram_bytes(gpu_ordinal),
mold_inference::device::total_vram_bytes(gpu_ordinal),
) {
const GHOST_VRAM_THRESHOLD: u64 = 1_500_000_000; if total.saturating_sub(free) > GHOST_VRAM_THRESHOLD {
tracing::info!(
gpu = gpu_ordinal,
free_gb = format_args!("{:.1}", free as f64 / 1e9),
total_gb = format_args!("{:.1}", total as f64 / 1e9),
"no active model on this GPU but VRAM is held — reclaiming primary context",
);
mold_inference::device::reclaim_gpu_memory(gpu_ordinal);
}
let effective_free = mold_inference::device::usable_free_vram_bytes(gpu_ordinal)
.unwrap_or_else(|| {
free.saturating_sub(mold_inference::device::reserved_vram_bytes())
});
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
effective_free,
hint,
);
}
if let Some(free) = mold_inference::device::usable_free_vram_bytes(gpu_ordinal) {
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
free,
hint,
);
}
}
if let Some(available) = mold_inference::device::available_system_memory_bytes() {
if available > 0 {
return preflight_memory_guard_with_available(
model_name,
paths,
active_vram_bytes,
available,
hint,
);
}
}
Ok(())
}
pub(crate) fn effective_load_available_bytes(
active_vram_bytes: u64,
#[cfg_attr(not(feature = "cuda"), allow(unused_variables))] gpu_ordinal: usize,
) -> Option<u64> {
#[cfg(feature = "cuda")]
{
if active_vram_bytes > 0 {
if let Some(total) = mold_inference::device::total_vram_bytes(gpu_ordinal) {
return Some(total);
}
}
if let Some(free) = mold_inference::device::usable_free_vram_bytes(gpu_ordinal) {
return Some(free);
}
}
mold_inference::device::available_system_memory_bytes()
.filter(|available| *available > 0)
.map(|available| available.saturating_add(active_vram_bytes))
}
pub(crate) fn select_server_load_strategy_for_budget(
paths: &ModelPaths,
available_bytes: Option<u64>,
hint: Option<ActivationHint>,
) -> mold_inference::LoadStrategy {
let transformer_is_gguf = transformer_path_is_gguf(paths);
if hint.is_some_and(|h| h.family == ActivationFamily::ZImageDit) && !transformer_is_gguf {
return mold_inference::LoadStrategy::Sequential;
}
if transformer_is_gguf
&& hint.is_some_and(|h| {
matches!(
h.family,
ActivationFamily::Sd3Mmdit | ActivationFamily::ZImageDit
)
})
{
return mold_inference::LoadStrategy::Eager;
}
let qwen_quantized =
hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit) && transformer_is_gguf;
let Some(available_bytes) = available_bytes.filter(|v| *v > 0) else {
return mold_inference::LoadStrategy::Eager;
};
if qwen_quantized {
let peak = qwen_image_quantized_sequential_peak(paths, hint);
if peak <= available_bytes {
return mold_inference::LoadStrategy::Sequential;
}
}
let activation = hint.map(|h| h.budget_bytes()).unwrap_or(0);
let eager_peak =
mold_inference::device::estimate_peak_memory(paths, mold_inference::LoadStrategy::Eager)
.saturating_add(activation);
let sequential_peak = mold_inference::device::estimate_peak_memory(
paths,
mold_inference::LoadStrategy::Sequential,
)
.saturating_add(activation);
let hard_limit = available_bytes.saturating_mul(9) / 10;
if eager_peak > hard_limit && sequential_peak <= hard_limit {
mold_inference::LoadStrategy::Sequential
} else {
mold_inference::LoadStrategy::Eager
}
}
pub(crate) fn select_server_load_strategy_for_device(
paths: &ModelPaths,
available_bytes: Option<u64>,
device_total_bytes: Option<u64>,
hint: Option<ActivationHint>,
) -> mold_inference::LoadStrategy {
let capped_available = match (
available_bytes.filter(|available| *available > 0),
device_total_bytes.filter(|total| *total > 0),
) {
(Some(available), Some(total)) => Some(available.min(total)),
(available, None) => available,
(None, Some(total)) => Some(total),
};
select_server_load_strategy_for_budget(paths, capped_available, hint)
}
pub(crate) fn server_offload_enabled_for_paths(
paths: &ModelPaths,
hint: Option<ActivationHint>,
request_has_lora: bool,
) -> bool {
let forced_offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
let transformer_path = transformer_path_lower(paths);
let transformer_looks_flux2 = transformer_path_looks_flux2(&transformer_path);
let transformer_looks_zimage = transformer_path_looks_zimage(&transformer_path);
let transformer_looks_nvfp4 = transformer_path.contains("nvfp4");
if request_has_lora
&& (transformer_looks_flux2
|| transformer_looks_zimage
|| hint.is_some_and(|h| {
matches!(
h.family,
ActivationFamily::Flux2Dit | ActivationFamily::ZImageDit
)
}))
{
return false;
}
let transformer_is_gguf = transformer_path_is_gguf(paths);
if transformer_looks_nvfp4
&& (transformer_looks_flux2 || hint.is_some_and(|h| h.family == ActivationFamily::Flux2Dit))
{
return false;
}
if transformer_is_gguf
&& hint.is_some_and(|h| {
matches!(
h.family,
ActivationFamily::Sd3Mmdit
| ActivationFamily::ZImageDit
| ActivationFamily::Flux2Dit
)
})
{
return false;
}
forced_offload || large_flux_bf16_should_auto_offload(paths, hint)
}
pub(crate) fn request_requires_fresh_engine_for_offload_policy(
paths: &ModelPaths,
hint: Option<ActivationHint>,
request_has_lora: bool,
) -> bool {
request_has_lora
&& server_offload_enabled_for_paths(paths, hint, false)
&& !server_offload_enabled_for_paths(paths, hint, true)
}
pub(crate) struct GenerationMemoryBudget {
pub(crate) peak_memory_bytes: u64,
pub(crate) activation_memory_bytes: u64,
pub(crate) available_memory_bytes: Option<u64>,
pub(crate) load_strategy: mold_inference::LoadStrategy,
pub(crate) fits_available_memory: Option<bool>,
}
pub(crate) fn estimate_generation_memory_for_request(
req: &GenerateRequest,
paths: &ModelPaths,
hint: Option<ActivationHint>,
) -> GenerationMemoryBudget {
let transformer_path = transformer_path_lower(paths);
let streaming = hint
.map(|h| h.family.streaming_transformer())
.unwrap_or_else(|| transformer_path_looks_ltx2(&transformer_path));
let flux_offload = (hint.is_some_and(|h| h.family == ActivationFamily::FluxDit)
&& std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1"))
|| large_flux_bf16_should_auto_offload(paths, hint);
let qwen_quantized = hint.is_some_and(|h| h.family == ActivationFamily::QwenImageDit)
&& transformer_path_is_gguf(paths);
let base_peak =
base_peak_memory_for_paths(paths, hint, streaming, flux_offload, qwen_quantized);
let activation = request_sensitive_activation_memory(req, hint, qwen_quantized);
let peak = base_peak.saturating_add(activation);
let available = effective_load_available_bytes(0, 0);
let load_strategy = select_server_load_strategy_for_budget(paths, available, hint);
let fits = available.map(|available| peak <= available.saturating_mul(9) / 10);
GenerationMemoryBudget {
peak_memory_bytes: peak,
activation_memory_bytes: activation,
available_memory_bytes: available,
load_strategy,
fits_available_memory: fits,
}
}
fn request_sensitive_activation_memory(
req: &GenerateRequest,
hint: Option<ActivationHint>,
qwen_quantized: bool,
) -> u64 {
let base = activation_memory_for_estimate(hint, qwen_quantized);
let batch = u64::from(req.batch_size.max(1));
let video_frames = u64::from(req.frames.unwrap_or(1).max(1));
let video_factor = if hint.is_some_and(|h| h.family.streaming_transformer()) {
video_frames.div_ceil(25).max(1)
} else {
1
};
let cfg_factor = if req.guidance > 1.0 && req.negative_prompt.is_some() {
2
} else {
1
};
let mut activation = base
.saturating_mul(batch)
.saturating_mul(video_factor)
.saturating_mul(cfg_factor);
let pixel_bytes = u64::from(req.width)
.saturating_mul(u64::from(req.height))
.saturating_mul(4);
if req.source_image.is_some()
|| req
.edit_images
.as_ref()
.is_some_and(|images| !images.is_empty())
{
activation = activation.saturating_add(pixel_bytes.saturating_mul(batch));
}
if req.mask_image.is_some() {
activation = activation.saturating_add(pixel_bytes / 2);
}
if req.control_image.is_some() || req.control_model.as_deref().is_some_and(|m| !m.is_empty()) {
activation = activation.saturating_add(pixel_bytes.saturating_mul(2));
}
if req.upscale_model.as_deref().is_some_and(|m| !m.is_empty()) {
activation = activation.saturating_add(pixel_bytes.saturating_mul(4));
}
let lora_count = req
.loras
.as_ref()
.map(|loras| loras.len())
.unwrap_or_else(|| usize::from(req.lora.is_some())) as u64;
activation.saturating_add(lora_count.saturating_mul(128 * 1024 * 1024))
}