* 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>
368 lines
15 KiB
Python
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
|