Skip to main content

mold_server/
memory_preflight.rs

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/// Per-request shape hint passed into [`preflight_memory_guard`] so the
70/// activation budget can scale with resolution / dtype / arch. `None`
71/// degrades to the previous fixed-headroom approximation (the
72/// `MEMORY_BUDGET_HEADROOM` baked into `estimate_peak_memory`'s 2 GB
73/// constant), which keeps behavior identical for callers that don't yet
74/// have a request in scope (e.g. admin-API model loads with no resolution
75/// context).
76///
77/// Public because `gpu_worker::ensure_model_ready_sync` and
78/// `gpu_worker::run_chain_blocking` (both `pub`) take it as a parameter.
79#[derive(Debug, Clone, Copy)]
80pub struct ActivationHint {
81    /// Image-space width.
82    pub width: u32,
83    /// Image-space height.
84    pub height: u32,
85    /// CFG-doubled forwards typically pass `2`; non-CFG passes `1`.
86    pub batch: u32,
87    /// Bytes per element (`2` for bf16/fp16, `4` for f32).
88    pub dtype_bytes: u32,
89    /// Architecture family — drives the per-arch factor in
90    /// `mold_inference::device::activation_bytes`.
91    pub family: ActivationFamily,
92}
93
94impl ActivationHint {
95    /// Build a hint from a [`GenerateRequest`] and the manifest family slug
96    /// (e.g. `"flux"`, `"sdxl"`). The family slug is what
97    /// [`activation_family_for`] expects — when the caller doesn't have a
98    /// strong family signal (catalog ID without an installed manifest, etc.)
99    /// passing the empty string falls back to `ActivationFamily::FluxDit`.
100    pub fn from_request(req: &GenerateRequest, family_slug: &str) -> Self {
101        // CFG-doubled forwards: SDXL/SD3 batch=2 when guidance ≈/> 1.0; FLUX,
102        // Z-Image, Flux.2 are guidance-distilled and run a single forward.
103        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            // Server-side preflight assumes bf16/fp16 activations — every
113            // diffusion family in this repo runs in bf16/fp16 on GPU.
114            dtype_bytes: 2,
115            family,
116        }
117    }
118
119    /// Compute the activation budget bytes from this hint.
120    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
131// ── MPS memory guard ────────────────────────────────────────────────────────
132
133/// Pure logic for the server memory guard, factored out for testing.
134///
135/// Hard-fails if peak > 90% of available (model won't fit even with page reclamation).
136/// Warns if peak > 80% of available (tight but feasible).
137///
138/// `suggestion` is appended to the rejection message so call sites can surface
139/// arch-specific remediation (e.g. reduce `--frames` / `--width` for LTX-Video).
140pub(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; // 90%
147    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; // 80%
161    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
173/// Build the suggestion text appended to preflight rejection messages.
174/// For LTX-Video (non-streaming full-weight load) the dominant knob is
175/// reducing `frames` or `width`/`height`; for image families, resolution and
176/// batch size are usually the first levers because activation and VAE
177/// workspace can dominate the checkpoint size.
178pub(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
191/// Pure inner: given an `available_bytes` budget and the active model's
192/// reclaimable VRAM, decide whether the new model fits. Adding
193/// `active_vram_bytes` to `available_bytes` accounts for the currently-loaded
194/// model that will be unloaded before the new one loads — without this, a
195/// swap of two near-equal-size models would be falsely rejected even though
196/// the swap is feasible.
197///
198/// Peak is estimated under `LoadStrategy::Sequential` because every diffusion
199/// family in this repo (FLUX, SD3, Z-Image, Flux.2, Qwen-Image, LTX) drops
200/// text encoders from GPU after encoding before the transformer denoises.
201/// The Eager sum (`transformer + vae + all_encoders`) overcounts by the
202/// encoder weight on every load — enough to false-reject a quantized FLUX on
203/// a 24 GB card even when the swap would actually fit.
204///
205/// `hint` adds a resolution-scaled activation budget on top of the
206/// component-size peak so a 2048² generation isn't under-budgeted. When
207/// `None` the inner peak retains the existing 2 GB
208/// `MEMORY_BUDGET_HEADROOM` constant from `estimate_peak_memory` and no
209/// extra is added — equivalent to the pre-Tier-2.3 behavior.
210pub(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    // Streaming-transformer families (LTX-Video / LTX-2) load only a couple
218    // of transformer blocks onto GPU at a time via `new_streaming` — the
219    // file-size-based estimate (which assumes the whole transformer becomes
220    // GPU-resident) over-counts by ~40+ GB for the 22B LTX-2 preset and
221    // false-rejects on 24 GB cards. When the hint marks the family as
222    // streaming, we replace the file-size transformer component with a
223    // generous fixed cap that covers `streaming_prefetch_count` blocks
224    // plus the always-resident top-level weights (proj_in / proj_out /
225    // time_embed / caption_projection / scale_shift_table / norms).
226    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    // Add the per-request activation budget on top of the file-size peak.
241    // The 2 GB `MEMORY_BUDGET_HEADROOM` already inside `estimate_peak_memory`
242    // is a generic "kernels + small state" constant that doesn't scale; the
243    // hint is the resolution/dtype/arch-aware delta on top.
244    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        // LTX-2 also pays for a Gemma 3 12B prompt encoder. Auto placement
269        // may try GPU first, but the runtime catches prompt-encoder CUDA OOMs
270        // and retries on CPU before loading the streamed transformer. Preflight
271        // must not reject that recoverable path. Only an explicit same-GPU pin
272        // (`MOLD_LTX2_GEMMA_DEVICE=gpu`) is counted against this GPU because
273        // the runtime will surface that OOM instead of rewriting the request.
274        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
295/// Peak GPU residency for streaming-transformer families. Mirrors the
296/// Sequential strategy in `device::estimate_peak_memory` but replaces the
297/// `transformer_size + vae_size` term with a `STREAMING_TRANSFORMER_CAP`
298/// that bounds "block-streaming overhead, fully-resident top-level weights,
299/// and VAE."
300///
301/// When `gemma_on_cpu` is true, encoder_total is dropped from the max because
302/// the prompt encoder won't compete for VRAM at all — it lives in system RAM
303/// and pipes its conditioning across to the transformer GPU at encode time.
304/// When false, the encoder phase still pays full encoder_total (text encoders
305/// load whole; the runtime drops them before denoise but during the encode
306/// phase they're co-resident with allocations made earlier in the request).
307///
308/// The cap is conservative: at 22B BF16 with `streaming_prefetch_count=2`,
309/// two blocks ≈ 1.83 GB + non-block fragments ≈ 200 MB + VAE ≈ 200 MB
310/// ≈ 2.3 GB. The 6 GB cap leaves room for activation workspace, OS
311/// fragmentation, and future LTX presets without revisiting this file.
312fn 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; // 6 GB
317    const HEADROOM: u64 = 2_000_000_000; // 2 GB, mirrors device::MEMORY_BUDGET_HEADROOM
318
319    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
342/// Peak GPU residency for SD3's staged sequential runtime. SD3 loads the
343/// triple text encoder, drops it, optionally VAE-encodes the source image,
344/// drops VAE, loads MMDiT for denoise, drops it, then loads VAE again for
345/// decode. GGUF SD3 models use the monolithic Stability safetensors file as
346/// the VAE source, but only VAE tensors are materialized by the runtime.
347fn sd3_sequential_peak(paths: &ModelPaths) -> u64 {
348    const SD3_VAE_RESIDENCY_CAP: u64 = 1_000_000_000; // VAE portion is ~300 MB; keep slack.
349    const HEADROOM: u64 = 2_000_000_000; // mirrors device::MEMORY_BUDGET_HEADROOM
350
351    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
375/// Peak GPU residency for Qwen-Image GGUF under its low-memory sequential
376/// runtime. The quantized CUDA path disables CFG batching under pressure, so
377/// the transformer phase is the quantized transformer plus a single-forward
378/// activation reserve. Text encoder and VAE run in separate phases.
379fn 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
409/// Whether preflight should count the LTX-2 Gemma prompt encoder against the
410/// transformer's GPU budget. Auto placement can recover from CUDA OOM by
411/// retrying the prompt path on CPU; explicit same-GPU placement cannot.
412fn 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
419/// Check whether estimated peak memory fits before committing to a model load.
420///
421/// Budgeting strategy on CUDA:
422/// - **No active model on this GPU** — the new load lands in whatever is
423///   currently free, so use `free_vram_bytes(gpu_ordinal)`.
424/// - **Active model present** — the call site unloads it and runs
425///   `cuDevicePrimaryCtxReset_v2`, which releases *every* allocation on the
426///   device (transformer, leftover activation buffers, fragmentation in the
427///   caching pool). The realistic post-reclaim budget is total VRAM, not
428///   `free + recorded active_vram`. Using the latter under-counts whatever
429///   the cache forgot to track (notably the encoder churn during the
430///   previous generation) and produces false rejections.
431///
432/// On macOS (unified memory) we keep the additive `available + active_vram`
433/// budget because Metal has no equivalent device-wide context reset; tensors
434/// freed during `unload()` simply return to the system page cache.
435/// On other platforms with no memory query available, the guard is a no-op.
436pub(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    // CUDA branch: when an active model will be reclaimed via primary-context
444    // reset, the post-reclaim budget is the device total, not free+active.
445    #[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        // Ghost-VRAM case: no active model in our cache, but the device
453        // reports `free` significantly below `total` because cuBLAS / cuDNN /
454        // kernel modules from a previous load are still squatting on
455        // workspace allocations. Reclaim the primary context — we have
456        // nothing live to lose — and re-query before deciding. After reclaim,
457        // re-query through `usable_free_vram_bytes` so the OS reserve
458        // (T2-B) is respected on the post-reclaim reading too.
459        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; // 1.5 GB
464            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        // Fallback if total_vram is unavailable: still go through the
486        // reserve-adjusted reading.
487        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    // macOS unified memory: query system memory and add reclaimable footprint.
499    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    // No memory info available on this platform — skip the guard.
512    Ok(())
513}
514
515/// Effective memory budget to use when deciding whether a server engine can
516/// stay eager-loaded or should degrade to load-use-drop sequential mode.
517///
518/// This mirrors the budget shape in [`preflight_memory_guard`]: CUDA swaps can
519/// reclaim the whole primary context when an active model exists, while Metal
520/// uses unified system memory and adds the active footprint as reclaimable.
521pub(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
542/// Choose the server load strategy for the current memory budget.
543///
544/// The server normally prefers eager engines so the active model stays hot.
545/// When eager residency would exceed the same 90% cap used by preflight but
546/// the model fits under sequential load-use-drop, degrade to Sequential. This
547/// keeps preflight and the actual load path consistent: a model admitted only
548/// because text encoders can be dropped should not then OOM during eager
549/// startup before it gets a chance to generate.
550pub(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 runtimes denoise multiple latent frames but do not keep every
727        // frame's full activation workspace resident at once. Scale
728        // sublinearly so longer clips still move the estimate without
729        // turning it into a file-size guess.
730        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}