unsloth/studio/backend/routes/training_vram.py
oobabooga 5f1f30ec82
Studio: GPU memory configuration for GGUF models (#6414)
* Studio: GPU memory dropdown — llama.cpp --fit on and manual gpu-layers/cpu-moe

* Studio: simplify GPU memory changes (reuse ParamSlider, GPU_LAYERS_ALL, loadedGpuMemoryFields helper)

* Studio: GPU picker — choose which GPUs a GGUF model loads on (gpu_ids)

* Studio: simplify GPU picker (share /api/system fetch, validate gpu_ids)

* Studio: GPU picker review fixes (gate relative indices, no cross-model leak, validate, types)

* Studio: group GPU controls under a collapsible GPU section

* Studio: GPU feature review fixes (fix fit-ctx test, behavior-test the floor, comment accuracy)

* Studio: make GPU a top-level settings section (not nested under Model)

* Studio: flatten GPU controls into the Model section, group by GPU/context/generation

* Studio: move GPU Memory to the bottom of Model with its dependent controls beneath it

* Studio: move GPU Memory below Tensor Parallelism and GPUs below GPU Memory

* Studio: tighten GPU Memory and GPU Layers tooltip copy

* Studio: fix fit-mode context slider track-click, restore GPU Memory tooltip, shorten fit dropdown label

* Studio: GPU Memory tooltip one mode per line, briefer

* Studio: note HIP_VISIBLE_DEVICES (ROCm) in the GPUs picker tooltip

* Studio: narrow the GPU Memory dropdown to fit the shortened label

* Studio: use 'llama.cpp --fit' in the GPU Memory tooltip for consistency

* Studio: allow Tensor Parallelism in Manual GPU mode

* Studio: graduated MoE-on-CPU offload (--n-cpu-moe) replacing the all-or-nothing toggle

* Studio: size the MoE-offload slider for staged (deferred-load) models

* Studio: share one GGUF header walk for the context-length and MoE-count readers

* Studio: size the GPU Layers slider for staged models (one staged-header read)

* Studio: move Tensor Parallelism below the GPUs picker

* Studio: GPU split (--tensor-split) per-GPU model share in Manual mode

* Studio: tolerate whitespace in GPU split input, move it below GPU Layers

* Studio: rename the GPU split control to "Split ratio"

* Studio: Split ratio sends explicit even input; fix blank=free-VRAM (not even) copy

* Studio: tighten llama.cpp --fit VRAM margin with --fit-target 512

* Studio: GPU memory review fixes (rollback re-baseline, single-GPU TP gate, accurate copy)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: move Split ratio below MoE Layers on CPU

* Studio: address PR review (fix GPU-info hydration race, share fit context-length across load paths)

* Studio: address codex review (manual single-GPU TP guard, GPU-aware spec defaults in fit/manual, GGUF-only context/preference)

* Studio: address codex review round 2 (gpu_present seed, single-GPU tensor-split guard, staged manual-knob reset, strip inherited offload flags)

* Studio: address codex review round 3 (strip inherited --n-cpu-moe, CPU-fallback warning in Manual mode)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address codex review round 4 (preserve pinned fit context across a later Apply)

* Studio: address codex review round 5 (honor GPU picker for diffusion GGUFs, clear fit pin on cross-model switch)

* Studio: preserve the pending GPU Memory mode when staging a model

* Studio: pin diffusion GPU device order and reset GPU-memory state for diffusion loads

* Studio: address codex review round 6 (fit-Auto rollback context, preserve manual non-tensor split modes, persist GPU mode on load not select)

* Studio: persist the applied GPU Memory mode, not the requested one (skip diffusion loads)

* Studio: replace Manual-mode split-ratio field with per-GPU layer sliders

* Studio: clarify per-GPU layer split hint for tensor-parallel mode

* Studio: address codex review round 7 (allow GGUF gpu_ids past the legacy guard, replay GPU-memory fields on respawn)

* Studio: address codex review round 8 (size the validate preflight like the load in fit mode, across both load paths)

* Studio: skip the training-OOM guard for llama.cpp --fit GGUF loads (they spill to RAM)

* Studio: drop the now-redundant compare-path validate sizing (the --fit guard skip makes it moot)

* Studio: address codex review round 9 (keep the training guard for fit loads, forward gpu_ids to validate, strip inherited manual tensor-split)

* Studio: address codex review round 10 (gate GPU-memory adoption on is_gguf, record manual knobs only in Manual mode)

* Studio: handle diffusion GGUFs symmetrically in the GPU Memory controls (preserve the standing mode preference, hide the inapplicable mode/TP controls)

* Studio: remember the GPU Memory settings per model

* Studio: consolidate --fit mode and Manual mode into a single Manual mode

* Studio: preserve the per-GPU layer split across GPU Layers changes

* Studio: trim overly long GPU Memory comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* address GPU memory config review comments

* trim redundant GPU memory tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reconcile manual-mode TP drops with the #6659 drop-site invariants

* Preserve quantized KV in manual --fit, charge GGUF companions in full, reconcile GPU pick on load

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Clear stale GPU baseline on non-GGUF loads so it can't read as dirty

* Fix no-context-shift test for the conditional -c flag

* Credit manual GPU-layer offload for cached HF GGUFs

* Reset per-model load knobs on GGUF quant switch

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Strip inherited tensor-split when manual ratio is cleared

* Match auto-load validation to safetensors placement

* Reset editable manual knobs after Auto GGUF loads

* Record a single device for diffusion GPU picks

* Reset per-model GPU knobs before applying saved settings

* Address review comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Guard manual tensor splits and keep remembered context on auto-load

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Snapshot compare knobs, seed splits from free VRAM, flag zero-offload loads

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Exempt CPU-only loads from the guard floor and harden compare and reseed paths

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reach full offload from the layers slider and charge extras drafters in the guard

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Warm the GPU device cache before pick reconciles and disable staged GPU controls

* Align the training guard with inherited extras, spec mode, and compare targets

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Hide GPUs from companion-less zero-offload loads

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Size diffusion picks per device, own manual offload flags, reject XPU picks

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop tensor flags at zero layers and exempt CPU-pinned drafters

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Allowlist the zero-layer tensor parallel drop site

* Keep validate and load guards on the same extras and refresh stale baselines

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop mismatched manual tensor splits before launch

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Gate XPU picks on the real backend field and harden split and hydration paths

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Weight full GPUs as zero, clamp split shares, and refine the zero-layer mask gate

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Carry fit context across mode changes and align drafter and picker gates

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Catch variant switches, uncached diffusion repos, and text-only mmproj skips

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Check companions on the first device and size native and remote zero-layer loads

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replace the training guard's precise VRAM modeling with a conservative bound

* Baseline context pins on non-GGUF hydration and reprobe list-seeded staged GGUFs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Size manual splits by their largest share and preserve resolved context from Default

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Default-deny unsized required companions and price KV at the effective cache dtype

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reserve MTP draft KV and MLA target-copy in the training guard

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Size tensor-parallel loads per device and show GPU controls for native GGUFs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reserve MTP overhead for uncached remote GGUFs and the mmproj runtime factor

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop the training-coexistence VRAM estimation this PR added

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Gate remembered load settings to GGUF picks

* Lock the remaining load-time controls during a staged load

* Clear the stale native-path token on compare loads

* Drop a stale guard reference from the zero-offload masking comment

* Seed GPU baselines from the rollback response and drop never-emitted offload flags

* Match validate's training guard to load and keep the native reload token

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trim verbose GPU-memory comments

* Thread the variants header walk off the event loop, honor device pins on zero-offload, and hold staged GPU edits

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honor manual placement and classify pinned zero-offload loads

* Close diffusion admission and status hydration gaps

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Check the actual diffusion GPU during training

* Align staged baselines and manual reload dedupe

* Fix GGUF placement and rollback state

* Harden manual GGUF placement boundaries

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove unused resolve_tensor_parallel import in llama_cpp.py

The name is used only in llama_server_args.py, routes/inference.py, and tests,
not in llama_cpp.py; the unused hoisted import trips the import-hoist verifier
in the source-lint CI job.

* Fix diffusion GPU dedup and training guard for non-numeric device tokens

The diffusion runner drives only its single lowest device and the backend
records that one device (self._gpu_ids = [sorted(gpu_ids)[0]]), but the reload
dedupe compared it against the full requested list, so a multi-GPU pick that
resolves to the same device forced a needless reload. Normalize the request the
same way for a loaded diffusion model in both _already_in_target_state and the
route _request_matches_loaded_settings.

The chat-during-training coexistence guard called int() on the single-device
token and hard-rejected when it could not parse. A non-numeric token (a CUDA
UUID / MIG handle) now sizes against the whole visible pool like the GGUF guard
instead of falsely blocking the load, and an empty token (a CPU-only runner such
as a CPU diffusion GGUF) is allowed outright since it uses no GPU VRAM.

* Tighten comments added by the GPU memory config changes

* Harden GGUF placement from independent review: VRAM sizing, diffusion TP reset, tensor_split validation

- Training coexistence guard: a single-device runner pinned through an
  unresolvable UUID/MIG token was sized against the aggregate visible-VRAM pool,
  so a load could pass on capacity it cannot use and then OOM active training.
  Size against the worst-case visible device (min free) instead, keeping the
  guard's documented default-deny contract. The empty-token (CPU-only runner)
  allow path is unchanged.
- Diffusion startup: _start_diffusion_server now resets self._tensor_parallel to
  False alongside the other placement resets. A prior tensor-parallel chat load
  (process killed but not fully unload-reset) otherwise left /status misreporting
  tensor parallelism and made an identical diffusion re-Apply reload against the
  stale state.
- tensor_split: reject negative / non-finite / all-zero splits up front. They
  were dropped at launch but still compared raw in the reload dedupe, so an
  identical Apply reloaded indefinitely.
- Tests: the shared httpx stub was incomplete and, installed via setdefault
  before real httpx loaded, broke a combined pytest run (collection errors on
  httpx.Response). Import the real installed httpx instead.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-19 05:46:22 -07:00

368 lines
15 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""VRAM coordination between chat/inference and training.
Decides, from live free VRAM, whether a resident chat model can stay loaded
during training or must be unloaded, and unloads it across all backends
(HF/MLX orchestrator + llama.cpp GGUF server). In the route layer because the
GGUF accessor lives in routes/inference.py; backends are imported lazily.
"""
from typing import Any, Dict, List, Optional, Tuple
from loggers import get_logger
logger = get_logger(__name__)
# keep iff usable_gb >= required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB. Conservative:
# the probe sees only the chat model's current footprint, so reserve headroom for
# estimate error + KV-cache growth (KEEP_FLOOR_GB ~= 2 GB load buffer + 2 GB chat).
SAFETY_MARGIN = 1.15
KEEP_FLOOR_GB = 4.0
# Each extra GPU contributes less than its raw free memory (sharding overhead).
_MULTI_GPU_OVERHEAD = 0.85
def _free_vram_by_index(devices: List[Dict[str, Any]]) -> Dict[int, float]:
"""Map GPU index -> free VRAM (GB) from a get_visible_gpu_utilization() device list."""
free_by_index: Dict[int, float] = {}
for device in devices:
total_gb = device.get("vram_total_gb")
used_gb = device.get("vram_used_gb")
if total_gb is None or used_gb is None:
continue
free_by_index[device["index"]] = max(total_gb - used_gb, 0.0)
return free_by_index
def summarize_resident_chat() -> Dict[str, Any]:
"""Report which chat models hold GPU memory (resident even while loading). Never raises."""
hf_name: Optional[str] = None
gguf_name: Optional[str] = None
loading: bool = False
try:
from core.inference import get_inference_backend
inf = get_inference_backend()
# active_model_name is set only on success; a mid-load model sits in
# loading_models while already holding VRAM -> both count as resident.
if inf.active_model_name or inf.loading_models:
hf_name = inf.active_model_name or next(iter(inf.loading_models), None)
# Any in-flight load (incl. a replacement while the old model is still
# active) can't be sized -> flag it so the caller frees instead of keeps.
if inf.loading_models:
loading = True
except Exception as e:
logger.warning("Could not inspect inference backend: %s", e)
try:
from routes.inference import get_llama_cpp_backend
llama = get_llama_cpp_backend()
# is_active (not is_loaded): a mid-start server already allocates VRAM.
# A confirmed CPU-only server (_gpu_offload_active is False) holds no VRAM.
if llama.is_active and getattr(llama, "_gpu_offload_active", None) is not False:
gguf_name = llama.model_identifier or "gguf"
if not getattr(llama, "is_loaded", False): # still loading -> size unknown
loading = True
except Exception as e:
logger.warning("Could not inspect GGUF backend: %s", e)
return {
"hf": hf_name,
"gguf": gguf_name,
"loading": loading,
"any": bool(hf_name or gguf_name),
}
def can_keep_chat_during_training(
*,
model_name: str,
hf_token: Optional[str],
training_type: str,
load_in_4bit: bool,
batch_size: int,
max_seq_length: int,
lora_rank: int,
target_modules: Optional[List[str]],
gradient_checkpointing: str,
optimizer: str,
gpu_ids: Optional[List[int]],
) -> Tuple[bool, Dict[str, Any]]:
"""Decide if a resident chat model can coexist with training given free VRAM.
Reuses training's own estimator/selector so the decision matches later
placement. Default-deny: anything we can't size returns False (unload).
"""
try:
from utils.hardware import (
DeviceType,
auto_select_gpu_ids,
estimate_required_model_memory_gb,
get_device,
get_visible_gpu_utilization,
resolve_requested_gpu_ids,
)
if get_device() != DeviceType.CUDA:
return False, {"mode": "non_cuda", "reason": "non_cuda"}
# Full finetuning runs in 16-bit, so ignore the 4-bit request or we under-count.
effective_4bit = False if training_type == "Full Finetuning" else load_in_4bit
hf_token_arg = hf_token or None
est_kwargs = dict(
hf_token = hf_token_arg,
training_type = training_type,
load_in_4bit = effective_4bit,
batch_size = batch_size,
max_seq_length = max_seq_length,
lora_rank = lora_rank,
target_modules = target_modules,
gradient_checkpointing = gradient_checkpointing,
optimizer = optimizer,
)
if gpu_ids:
# Explicit GPUs: the selector does no VRAM math, so size it here.
try:
resolved = resolve_requested_gpu_ids(gpu_ids)
except ValueError:
# Invalid ids -> start_training will 400 first, so don't unload.
return True, {"mode": "explicit", "reason": "invalid_gpu_ids"}
required_gb, est_meta = estimate_required_model_memory_gb(model_name, **est_kwargs)
if required_gb is None:
return False, {"mode": "explicit", "reason": "estimate_unavailable"}
free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", []))
# A requested GPU missing from the device list contributes 0.
free_vals = [free_by_index.get(i, 0.0) for i in resolved]
ranked = sorted(free_vals, reverse = True)
usable_gb = (
ranked[0] + sum(f * _MULTI_GPU_OVERHEAD for f in ranked[1:]) if ranked else 0.0
)
aggregate_fits = usable_gb >= required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB
# Activations don't shard: enforce a per-GPU floor so an uneven split
# (e.g. free [45, 10]) can't be kept into an OOM the aggregate misses.
per_gpu_fits = True
min_free_gb = min(free_vals) if free_vals else 0.0
if len(resolved) > 1:
min_per_gpu_gb = est_meta.get("vram_breakdown", {}).get(
f"min_per_gpu_{len(resolved)}"
)
if min_per_gpu_gb is not None:
per_gpu_fits = min_free_gb >= min_per_gpu_gb
keep = aggregate_fits and per_gpu_fits
return keep, {
"mode": "explicit",
"required_gb": required_gb,
"usable_gb": round(usable_gb, 3),
"min_free_gb": round(min_free_gb, 3),
}
# Auto: same call start_training makes later; reuse its sizing metadata.
_selected, meta = auto_select_gpu_ids(model_name, **est_kwargs)
mode = meta.get("selection_mode")
required_gb = meta.get("required_gb")
usable_gb = meta.get("usable_gb")
keep = (
mode == "auto"
and required_gb is not None
and usable_gb is not None
and usable_gb >= required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB
)
return keep, {
"mode": mode,
"required_gb": required_gb,
"usable_gb": usable_gb,
}
except Exception as e:
# Never let a sizing failure keep a chat model loaded into a training OOM.
logger.warning("Chat-coexistence probe failed; will unload: %s", e)
return False, {"reason": "probe_error", "error": str(e)}
def can_load_chat_during_training(
*,
model_name: str,
hf_token: Optional[str],
load_in_4bit: bool,
max_seq_length: int,
requested_gpu_ids: Optional[List[int]],
is_gguf: bool = False,
required_override_gb: Optional[float] = None,
single_device_gpu: Optional[str] = None,
) -> Tuple[bool, Dict[str, Any]]:
"""Decide if a NEW chat model can load without OOMing active training (inverse
of can_keep_chat_during_training: training is already resident, so size the
chat model against the free VRAM that remains). Sizes/places it the same way
the loader will: HF auto reuses auto_select_gpu_ids; HF explicit requires an
even-share per-GPU floor for device_map="balanced"; GGUF sizes from
required_override_gb over the visible pool. ``single_device_gpu`` is the
exact physical device token selected by a single-device runner.
`load_in_4bit` must be effective (LoRA can flip 4-bit -> 16-bit). Non-CUDA
allows the load; default-deny on any CUDA case it can't size, so a load never
OOMs training."""
try:
from utils.hardware import (
DeviceType,
auto_select_gpu_ids,
estimate_required_model_memory_gb,
get_device,
get_visible_gpu_utilization,
resolve_requested_gpu_ids,
)
if get_device() != DeviceType.CUDA:
return True, {"mode": "non_cuda", "reason": "non_cuda"}
est_kwargs = dict(
hf_token = hf_token or None,
training_type = None, # inference sizing of the chat model itself
load_in_4bit = load_in_4bit,
max_seq_length = max_seq_length or 2048,
)
# HF auto: reuse the loader's selector; fits iff its pick clears the margin.
if not requested_gpu_ids and not is_gguf:
_selected, meta = auto_select_gpu_ids(model_name, **est_kwargs)
mode = meta.get("selection_mode")
required_gb = meta.get("required_gb")
usable_gb = meta.get("usable_gb")
needed_gb = (
round(required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB, 3)
if required_gb is not None
else None
)
fits = (
mode == "auto"
and required_gb is not None
and usable_gb is not None
and usable_gb >= needed_gb
)
return fits, {
"mode": mode,
"required_gb": required_gb,
"usable_gb": usable_gb,
"needed_gb": needed_gb,
}
# Explicit GPUs, or GGUF: size directly and check live free VRAM.
if single_device_gpu is not None:
mode = "single_device"
elif is_gguf:
mode = "gguf"
else:
mode = "explicit"
required_gb = required_override_gb
if required_gb is None:
required_gb, _meta = estimate_required_model_memory_gb(model_name, **est_kwargs)
if required_gb is None:
return False, {"mode": mode, "reason": "estimate_unavailable"}
free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", []))
if single_device_gpu is not None:
token = str(single_device_gpu).strip()
if not token:
# Empty token = a CPU-only single-device runner (e.g. a CPU
# diffusion GGUF): it uses no GPU VRAM, so it never threatens
# active training and can always load.
return True, {"mode": "single_device", "reason": "cpu_only"}
try:
selected_gpu = int(token)
if selected_gpu < 0:
raise ValueError
except (TypeError, ValueError):
# A non-numeric device token (e.g. a CUDA UUID / MIG handle)
# can't be mapped to a free-VRAM index, but the runner still
# drives ONE device. Size against the worst-case visible device
# (min free), never the aggregate pool, so a single-device load
# is never OK'd on capacity it can't use and OOMs training.
free_vals = [min(free_by_index.values())] if free_by_index else []
else:
free_vals = [free_by_index.get(selected_gpu, 0.0)]
elif requested_gpu_ids:
# Invalid ids -> load_model 400s first, so don't block; missing id = 0.
try:
resolved = resolve_requested_gpu_ids(requested_gpu_ids)
except ValueError:
return True, {"mode": mode, "reason": "invalid_gpu_ids"}
free_vals = [free_by_index.get(i, 0.0) for i in resolved]
else:
# GGUF: llama.cpp picks the GPU(s); any visible GPU is a candidate.
free_vals = list(free_by_index.values())
if not free_vals:
return False, {"mode": mode, "reason": "no_visible_gpus"}
ranked = sorted(free_vals, reverse = True)
usable_gb = ranked[0] + sum(f * _MULTI_GPU_OVERHEAD for f in ranked[1:])
needed_gb = required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB
aggregate_fits = usable_gb >= needed_gb
# device_map="balanced" shards across GPUs: an even-share floor stops one
# near-full GPU hiding behind aggregate capacity. GGUF self-places, no floor.
min_free_gb = min(free_vals)
per_gpu_fits = True
if mode == "explicit" and len(free_vals) > 1:
per_gpu_fits = min_free_gb >= needed_gb / len(free_vals)
return aggregate_fits and per_gpu_fits, {
"mode": mode,
"required_gb": round(required_gb, 3),
"usable_gb": round(usable_gb, 3),
"needed_gb": round(needed_gb, 3),
"min_free_gb": round(min_free_gb, 3),
}
except Exception as e:
# Never let a sizing failure load a chat model into a training OOM.
logger.warning("Chat-load coexistence probe failed; will refuse: %s", e)
return False, {"reason": "probe_error", "error": str(e)}
def free_chat_models_for_training(reason: str) -> List[str]:
"""Unload every resident chat model (HF/MLX orchestrator + GGUF server) to free
VRAM for training. Each backend isolated. Returns labels of what was freed."""
freed: List[str] = []
try:
from core.inference import get_inference_backend
inf = get_inference_backend()
if inf.active_model_name or inf.loading_models:
name = inf.active_model_name or next(iter(inf.loading_models), None)
logger.info(
"Unloading inference model '%s' to free GPU memory for training (%s)",
name,
reason,
)
inf._shutdown_subprocess()
inf.active_model_name = None
inf.models.clear()
inf.loading_models.clear()
freed.append(f"hf:{name}")
except Exception as e:
logger.warning("Could not unload inference model: %s", e)
try:
from routes.inference import get_llama_cpp_backend
llama = get_llama_cpp_backend()
# CPU-only GGUF holds no VRAM, so killing it can't help (see summarize).
if llama.is_active and getattr(llama, "_gpu_offload_active", None) is not False:
name = llama.model_identifier or "gguf"
logger.info(
"Unloading GGUF chat model '%s' to free GPU memory for training (%s)",
name,
reason,
)
llama.unload_model()
freed.append(f"gguf:{name}")
except Exception as e:
logger.warning("Could not unload GGUF chat model: %s", e)
return freed