Thermal soft-throttle, accel hot-swap + UI, township progress & concat fixes

coderai:
- Thermal: configurable proactive CPU soft-throttle (engage temp + max
  per-step sleep) that gently slows generation in a warm band so it rarely hits
  the hard pause; CPU-only, hard pause always takes precedence. Tasks page shows
  a soft-throttle banner + per-task badge (live, gated on a running task).
- Acceleration hot-swap: toggling/changing a model's acceleration now evicts the
  loaded model (manager.unload_model) so the next request reloads with the new
  setting — no restart. (acceleration is fused at load time.)
- Models UI: cascading distill-LoRA pickers — new /admin/api/accel-loras scans
  the cache for distill repos; pick the distill model, then its high/low (or
  single) LoRA from dropdowns. Presets now also fill the high/low fields.
- Tasks queue summary now reflects ALL model activity (derived from the unified
  task list), not just queue-manager requests — fixes the stuck "0 active".
- images.py: proactive eviction no longer skipped by a NameError (model_key).

township (tools/gen_township_fighters.py):
- Per-clip/outcome/keyframe progress now shows real diffusion-step progress
  (polls /v1/{images,video}/progress) on the CLI spinner and the web step bars,
  including "shot part N/total" for chained single shots.
- Chained-shot concat re-encodes (CFR) instead of stream-copy, fixing the
  "first half is a static image" freeze at the part boundary.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent c8ace9d8
......@@ -1988,6 +1988,81 @@ async def api_accel_presets(username: str = Depends(require_admin)):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/admin/api/accel-loras", summary="List cached distill-LoRA files for acceleration")
async def api_accel_loras(username: str = Depends(require_admin)):
"""Scan the HF cache for distill / step-distillation LoRA repos and return each
repo's ``.safetensors`` files, pre-classified into high-noise / low-noise (for
Wan2.2's two experts) so the model-config UI can offer cascading dropdowns:
pick the distill model first, then the high/low (or single) LoRA from it.
A repo qualifies if its id matches a distill keyword (lightning/lightx2v/lcm/
hyper/turbo/distill/dmd) or is referenced by an ACCEL_PRESETS entry."""
import os
import re as _re
out: list = []
try:
from codai.models.acceleration import ACCEL_PRESETS
from codai.models.cache import get_all_cache_dirs
from huggingface_hub import scan_cache_dir
# Repos named by presets are always relevant (the `repo:file` head before ':').
preset_repos = set()
for p in (ACCEL_PRESETS or {}).values():
for k in ("lora", "lora_high", "lora_low"):
ref = p.get(k)
if ref and ":" in str(ref):
preset_repos.add(str(ref).split(":", 1)[0])
elif ref:
preset_repos.add(str(ref))
kw = _re.compile(r"light(ning|x2v)|lcm|hyper[-_ ]?sd|turbo|distill|dmd|seko",
_re.IGNORECASE)
_hi = _re.compile(r"high[-_ ]?noise", _re.IGNORECASE)
_lo = _re.compile(r"low[-_ ]?noise", _re.IGNORECASE)
# A file is a distill LoRA (vs a full model's component weights like
# vae/transformer/text_encoder) if its path names a LoRA or noise level.
_loraname = _re.compile(r"lora|noise|light(ning|x2v)|lcm|hyper|distill|dmd",
_re.IGNORECASE)
hf_dir = (get_all_cache_dirs() or {}).get("huggingface")
info = scan_cache_dir(hf_dir) if hf_dir else scan_cache_dir()
for repo in info.repos:
rid = repo.repo_id
in_preset = rid in preset_repos
if not (in_preset or kw.search(rid)):
continue
# Newest revision's snapshot → relative .safetensors paths. Keep only
# LoRA-looking files, unless the repo is a curated preset repo (then
# trust all its safetensors). This drops full-model component weights
# from repos that merely match a keyword (e.g. "*-Turbo" base models).
rev = max(repo.revisions, key=lambda r: (r.last_modified or 0), default=None)
if rev is None:
continue
snap = str(rev.snapshot_path)
files = []
for f in rev.files:
fp = str(f.file_path)
if not fp.endswith(".safetensors"):
continue
rel = os.path.relpath(fp, snap).replace(os.sep, "/")
if in_preset or _loraname.search(rel):
files.append(rel)
if not files:
continue
files.sort()
out.append({
"repo": rid,
"files": files,
"high": [f for f in files if _hi.search(f)],
"low": [f for f in files if _lo.search(f)],
})
out.sort(key=lambda m: m["repo"].lower())
except Exception as e:
# Cache scan is best-effort; the UI falls back to the free-text fields.
return {"models": [], "error": str(e)}
return {"models": out}
@router.get("/admin/api/turboquant-info", summary="TurboQuant backend availability")
async def api_turboquant_info(username: str = Depends(require_admin)):
"""Report which TurboQuant embedding-quantization backends are available so
......@@ -2109,7 +2184,41 @@ async def api_tasks(username: str = Depends(require_admin)):
except Exception:
pass
return {"tasks": tasks, "queue": queue_manager.get_metrics(), "thermal": cooling}
# Proactive CPU soft-throttle (distinct from the hard cooldown): generations
# are being gently slowed because the CPU is in its warm band. Computed live
# from the CPU temp, and only shown when something is actually running (idle
# warmth isn't being throttled). Suppressed during a hard cooldown.
soft = {"active": False}
try:
from codai.models import thermal as _therm
_any_running = any(t.get("status") == "running" for t in tasks)
ss = _therm.soft_throttle_status()
if ss.get("active") and _any_running and not cooling.get("active"):
_cpu = ss.get("cpu")
_slp = ss.get("sleep") or 0
label = "CPU soft-throttle"
if _cpu is not None:
label += f" — CPU {_cpu:.0f}°C"
if _slp:
label += f" (+{_slp:.1f}s/step)"
soft = {"active": True, "message": label, "cpu": _cpu, "sleep": _slp}
for t in tasks:
if (t.get("active") and t.get("status") == "running"
and not t.get("cooling")):
t["throttling"] = True
t["throttle_message"] = label
except Exception:
pass
# The queue-summary header must reflect ALL model activity, not just requests
# that flow through queue_manager (text/pipelines/training). Image/video/audio
# generations run their own paths and live only in the task registry, so derive
# active/waiting from the unified `tasks` list; keep max_parallel from the
# queue manager.
queue = dict(queue_manager.get_metrics())
queue["active"] = sum(1 for t in tasks if t.get("status") == "running")
queue["waiting"] = sum(1 for t in tasks if t.get("status") == "queued")
return {"tasks": tasks, "queue": queue, "thermal": cooling, "soft_throttle": soft}
def _read_vram_info() -> Optional[dict]:
......@@ -2336,6 +2445,9 @@ async def api_get_settings(username: str = Depends(require_admin)):
"gpu_high": c.thermal.gpu_high,
"gpu_resume": c.thermal.gpu_resume,
"poll_seconds": c.thermal.poll_seconds,
"soft_throttle_enabled": c.thermal.soft_throttle_enabled,
"soft_throttle_temp": c.thermal.soft_throttle_temp,
"soft_throttle_max_sleep": c.thermal.soft_throttle_max_sleep,
},
"jobs": {
"resume_on_restart": c.jobs.resume_on_restart,
......@@ -2457,6 +2569,9 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
c.thermal.gpu_high = float(th.get("gpu_high", c.thermal.gpu_high))
c.thermal.gpu_resume = float(th.get("gpu_resume", c.thermal.gpu_resume))
c.thermal.poll_seconds = max(1.0, float(th.get("poll_seconds", c.thermal.poll_seconds)))
c.thermal.soft_throttle_enabled = bool(th.get("soft_throttle_enabled", c.thermal.soft_throttle_enabled))
c.thermal.soft_throttle_temp = float(th.get("soft_throttle_temp", c.thermal.soft_throttle_temp))
c.thermal.soft_throttle_max_sleep = max(0.0, float(th.get("soft_throttle_max_sleep", c.thermal.soft_throttle_max_sleep)))
# Push to the live global_args so changes apply without a restart.
try:
from codai.api.state import get_global_args
......@@ -2469,6 +2584,9 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
ga.thermal_gpu_high = c.thermal.gpu_high
ga.thermal_gpu_resume = c.thermal.gpu_resume
ga.thermal_poll_seconds = c.thermal.poll_seconds
ga.thermal_soft_throttle_enabled = c.thermal.soft_throttle_enabled
ga.thermal_soft_throttle_temp = c.thermal.soft_throttle_temp
ga.thermal_soft_throttle_max_sleep = c.thermal.soft_throttle_max_sleep
except Exception:
pass
......
......@@ -730,12 +730,22 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</select>
</div>
<div class="form-row" style="max-width:560px">
<label class="form-label">Distill LoRA <span class="muted">(path or HF repo, optionally repo:weight_name.safetensors; blank for turbo full-models)</span></label>
<label class="form-label">Distill model <span class="muted">(cached — pick to fill the LoRA fields below)</span></label>
<select id="cfg-accel-distill" class="form-input" onchange="onAccelDistill()">
<option value="">— manual / pick a preset —</option>
</select>
<span class="form-hint" id="cfg-accel-distill-hint">Lists distill-LoRA repos found in the local cache. Choose one, then pick its high/low (or single) LoRA below.</span>
</div>
<div class="form-row" style="max-width:560px">
<label class="form-label">Distill LoRA <span class="muted">(single — path or repo:weight_name.safetensors; blank for turbo full-models)</span></label>
<select id="cfg-accel-lora-sel" class="form-input" style="display:none;margin-bottom:.4rem" onchange="onAccelLoraPick('lora')"></select>
<input type="text" id="cfg-accel-lora" class="form-input" placeholder="e.g. ByteDance/SDXL-Lightning:sdxl_lightning_4step_lora.safetensors">
</div>
<div class="form-row" style="max-width:560px">
<label class="form-label">Distill LoRA — high/low noise <span class="muted">(Wan2.2 A14B two-expert only; overrides the single LoRA per expert)</span></label>
<select id="cfg-accel-lora-high-sel" class="form-input" style="display:none;margin-bottom:.3rem" onchange="onAccelLoraPick('high')"></select>
<input type="text" id="cfg-accel-lora-high" class="form-input" placeholder="high-noise → transformer (e.g. repo:..._high_noise_..._4step.safetensors)">
<select id="cfg-accel-lora-low-sel" class="form-input" style="display:none;margin:.4rem 0 .3rem" onchange="onAccelLoraPick('low')"></select>
<input type="text" id="cfg-accel-lora-low" class="form-input" style="margin-top:.4rem" placeholder="low-noise → transformer_2 (e.g. repo:..._low_noise_..._4step.safetensors)">
<span class="form-hint">Wan2.2 A14B has two experts; the distill LoRA must be fused into <b>both</b> or the clip collapses to a solid colour at 4 steps. Leave blank to apply the single LoRA above to both.</span>
</div>
......@@ -2797,15 +2807,103 @@ function onAccelPreset(){
const p = (_accelPresets || {})[key];
if (!p) return; // "custom" — leave fields as-is
document.getElementById('cfg-accel-lora').value = p.lora || '';
document.getElementById('cfg-accel-lora-high').value = p.lora_high || '';
document.getElementById('cfg-accel-lora-low').value = p.lora_low || '';
document.getElementById('cfg-accel-weight').value = p.lora_weight != null ? p.lora_weight : '';
document.getElementById('cfg-accel-steps').value = p.steps != null ? p.steps : '';
document.getElementById('cfg-accel-guidance').value = p.guidance_scale != null ? p.guidance_scale : '';
document.getElementById('cfg-accel-flowshift').value = p.flow_shift != null ? p.flow_shift : '';
document.getElementById('cfg-accel-scheduler').value = p.scheduler || '';
_syncAccelDistillFromFields();
}
// ---- Cascading distill-LoRA pickers (cached files) ----
let _accelLoras = null;
async function _loadAccelLoras(){
if (_accelLoras) return _accelLoras;
try {
const r = await fetch(ROOT_PATH + '/admin/api/accel-loras');
const d = await r.json();
_accelLoras = d.models || [];
} catch(e){ _accelLoras = []; }
return _accelLoras;
}
function _refreshAccelDistill(){
const sel = document.getElementById('cfg-accel-distill');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">— manual / pick a preset —</option>';
(_accelLoras || []).forEach(m=>{
const o = document.createElement('option');
o.value = m.repo;
o.textContent = m.repo + ' (' + m.files.length + ' file' + (m.files.length===1?'':'s') + ')';
sel.appendChild(o);
});
if ([...sel.options].some(o=>o.value===cur)) sel.value = cur;
const hint = document.getElementById('cfg-accel-distill-hint');
if (hint && !(_accelLoras || []).length){
hint.textContent = 'No distill-LoRA repos found in the local cache — use a preset or type the path/repo manually below.';
}
}
function _fillAccelSel(selId, repo, files, keep){
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = '<option value="">— choose —</option>';
(files || []).forEach(f=>{
const o = document.createElement('option');
o.value = repo + ':' + f; o.textContent = f;
sel.appendChild(o);
});
sel.style.display = (files && files.length) ? '' : 'none';
if (keep && [...sel.options].some(o=>o.value===keep)) sel.value = keep;
}
function onAccelDistill(){
const repo = document.getElementById('cfg-accel-distill').value;
const m = (_accelLoras || []).find(x=>x.repo===repo);
if (!m){
['cfg-accel-lora-sel','cfg-accel-lora-high-sel','cfg-accel-lora-low-sel'].forEach(id=>{
const s=document.getElementById(id); if(s){ s.innerHTML=''; s.style.display='none'; }
});
return;
}
// Single-LoRA dropdown = all files; high/low = classified subsets (fall back to
// all files when the repo doesn't name them by noise level).
_fillAccelSel('cfg-accel-lora-sel', repo, m.files);
_fillAccelSel('cfg-accel-lora-high-sel', repo, (m.high && m.high.length) ? m.high : m.files);
_fillAccelSel('cfg-accel-lora-low-sel', repo, (m.low && m.low.length) ? m.low : m.files);
}
function onAccelLoraPick(kind){
const tmap = {lora:'cfg-accel-lora', high:'cfg-accel-lora-high', low:'cfg-accel-lora-low'};
const smap = {lora:'cfg-accel-lora-sel', high:'cfg-accel-lora-high-sel', low:'cfg-accel-lora-low-sel'};
const v = document.getElementById(smap[kind]).value;
if (v) document.getElementById(tmap[kind]).value = v;
}
// On load (or after a preset fill), preselect the distill model + dropdowns from
// whatever repo the current high/single LoRA field points at.
function _syncAccelDistillFromFields(){
const sel = document.getElementById('cfg-accel-distill');
if (!sel) return;
const refHigh = (document.getElementById('cfg-accel-lora-high').value || '').trim();
const refLow = (document.getElementById('cfg-accel-lora-low').value || '').trim();
const refOne = (document.getElementById('cfg-accel-lora').value || '').trim();
const ref = refHigh || refOne || refLow;
const repo = ref.includes(':') ? ref.split(':')[0] : ref;
if (repo && [...sel.options].some(o=>o.value===repo)){
sel.value = repo;
onAccelDistill();
const m = (_accelLoras || []).find(x=>x.repo===repo);
if (m){
_fillAccelSel('cfg-accel-lora-sel', repo, m.files, refOne);
_fillAccelSel('cfg-accel-lora-high-sel', repo, (m.high&&m.high.length)?m.high:m.files, refHigh);
_fillAccelSel('cfg-accel-lora-low-sel', repo, (m.low&&m.low.length)?m.low:m.files, refLow);
}
}
}
async function _populateAccel(a){
await _loadAccelPresets();
await _loadAccelLoras();
_refreshAccelVisibility();
_refreshAccelDistill();
a = a || {};
document.getElementById('cfg-accel-enabled').checked = !!a.enabled;
const sel = document.getElementById('cfg-accel-preset');
......@@ -2818,6 +2916,7 @@ async function _populateAccel(a){
document.getElementById('cfg-accel-guidance').value = a.guidance_scale != null ? a.guidance_scale : '';
document.getElementById('cfg-accel-flowshift').value = a.flow_shift != null ? a.flow_shift : '';
document.getElementById('cfg-accel-scheduler').value = a.scheduler || '';
_syncAccelDistillFromFields();
onAccelToggle();
}
function _collectAccel(){
......
......@@ -146,11 +146,33 @@
</div>
</div>
<div class="form-row" style="margin:0">
<div class="form-row" style="margin:0 0 .75rem">
<label class="form-label">Re-check interval while cooling down (seconds)</label>
<input type="number" id="s-therm-poll" class="form-input" style="max-width:200px" min="1" max="120" step="1" placeholder="5">
<span class="form-hint">How often to re-read temperatures while waiting for cooldown.</span>
</div>
<div class="form-row" style="margin:0;border-top:1px solid var(--border,#2a2a2a);padding-top:.75rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-therm-soft-enabled" onchange="toggleThermalFields()">
<span style="font-size:13px;font-weight:500">Enable CPU soft-throttle</span>
</label>
<span class="form-hint" style="display:block;margin-top:.35rem">
Before the hard pause, gently slow generation (short per-step sleeps) once the
CPU enters the warm band, so its temperature climbs slower and the full
cooldown is rarely hit. CPU only — GPU is unaffected.
</span>
</div>
<div id="therm-soft-fields" class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:.5rem">
<div>
<label class="form-label">Engage above CPU temp (°C)</label>
<input type="number" id="s-therm-soft-temp" class="form-input" min="40" max="120" step="1" placeholder="80">
</div>
<div>
<label class="form-label">Max slow-down per step (seconds)</label>
<input type="number" id="s-therm-soft-sleep" class="form-input" min="0" max="30" step="0.5" placeholder="3">
</div>
</div>
</div>
<!-- Background jobs -->
......@@ -284,6 +306,8 @@ function toggleThermalFields(){
document.getElementById('s-therm-gpu-enabled').checked ? 'grid' : 'none';
document.getElementById('therm-cpu-fields').style.display =
document.getElementById('s-therm-cpu-enabled').checked ? 'grid' : 'none';
document.getElementById('therm-soft-fields').style.display =
document.getElementById('s-therm-soft-enabled').checked ? 'grid' : 'none';
}
function showAlert(type, msg){
......@@ -345,6 +369,9 @@ async function loadSettings(){
document.getElementById('s-therm-cpu-high').value = therm.cpu_high ?? 90;
document.getElementById('s-therm-cpu-resume').value = therm.cpu_resume ?? 87;
document.getElementById('s-therm-poll').value = therm.poll_seconds ?? 5;
document.getElementById('s-therm-soft-enabled').checked = !!therm.soft_throttle_enabled;
document.getElementById('s-therm-soft-temp').value = therm.soft_throttle_temp ?? 80;
document.getElementById('s-therm-soft-sleep').value = therm.soft_throttle_max_sleep ?? 3;
toggleThermalFields();
// Background jobs
const jobs = d.jobs || {};
......@@ -383,6 +410,9 @@ async function saveSettings(){
cpu_high: parseFloat(document.getElementById('s-therm-cpu-high').value) || 90,
cpu_resume: parseFloat(document.getElementById('s-therm-cpu-resume').value) || 87,
poll_seconds: parseFloat(document.getElementById('s-therm-poll').value) || 5,
soft_throttle_enabled: document.getElementById('s-therm-soft-enabled').checked,
soft_throttle_temp: parseFloat(document.getElementById('s-therm-soft-temp').value) || 80,
soft_throttle_max_sleep: parseFloat(document.getElementById('s-therm-soft-sleep').value) || 0,
},
jobs:{
resume_on_restart: document.getElementById('s-jobs-resume').checked,
......
......@@ -19,6 +19,13 @@
— running work is paused until the hardware cools.
</div>
<div id="throttle-banner" style="display:none;margin:0 0 1rem;padding:.6rem .85rem;border-radius:8px;
background:rgba(56,189,248,.10);border:1px solid rgba(56,189,248,.4);color:#38bdf8;font-size:13px">
<span style="font-weight:600">🐢 CPU soft-throttle</span>
<span id="throttle-banner-msg" class="mono"></span>
— generation is being gently slowed to keep the CPU below its pause limit.
</div>
<!-- Live hardware telemetry -->
<div id="sys-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
gap:.75rem;margin:0 0 1.25rem">
......@@ -165,6 +172,16 @@ async function loadTasks() {
banner.style.display = 'none';
}
const soft = data.soft_throttle || {};
const sbanner = document.getElementById('throttle-banner');
// Hide the soft-throttle banner during a hard cooldown (the pause supersedes it).
if (soft.active && !therm.active) {
document.getElementById('throttle-banner-msg').textContent = ' ' + (soft.message || '');
sbanner.style.display = '';
} else {
sbanner.style.display = 'none';
}
const tbody = document.getElementById('tasks-body');
if (!tasks.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No tasks yet</td></tr>';
......@@ -177,6 +194,10 @@ async function loadTasks() {
if (t.cooling) {
statusCell = `<span class="badge badge-warn">❄ Cooling down</span>`
+ `<div class="dim small">${esc(t.cooling_message || 'paused for thermal cooldown')}</div>`;
} else if (t.throttling) {
statusCell = `<span class="badge ${badge}">${esc(t.status)}</span>`
+ ` <span class="badge badge-user">🐢 throttling</span>`
+ `<div class="dim small">${esc(t.throttle_message || 'CPU soft-throttle')}</div>`;
} else if (t.paused) {
statusCell = `<span class="badge badge-warn">⏸ Paused</span>`
+ `<div class="dim small">suspended — click Resume to continue</div>`;
......
......@@ -124,6 +124,13 @@ class ThermalConfig:
gpu_high: float = 90.0 # pause when GPU reaches this temperature
gpu_resume: float = 87.0 # resume once GPU drops back to/below this
poll_seconds: float = 5.0 # how often to re-check while cooling down
# Proactive soft-throttle: before a hard pause, when a sensor enters the warm
# band [soft_throttle_temp, *_high) insert a short per-step sleep (scaled by
# how close to the pause threshold) so the temperature climbs slower and the
# hard cooldown is rarely hit. Caps the heat-rate of a single pegged core.
soft_throttle_enabled: bool = False
soft_throttle_temp: float = 80.0 # engage at/above this temperature (°C)
soft_throttle_max_sleep: float = 3.0 # max seconds to sleep/checkpoint at the limit
@dataclass
......@@ -423,6 +430,9 @@ class ConfigManager:
"gpu_high": self.config.thermal.gpu_high,
"gpu_resume": self.config.thermal.gpu_resume,
"poll_seconds": self.config.thermal.poll_seconds,
"soft_throttle_enabled": self.config.thermal.soft_throttle_enabled,
"soft_throttle_temp": self.config.thermal.soft_throttle_temp,
"soft_throttle_max_sleep": self.config.thermal.soft_throttle_max_sleep,
},
"jobs": {
"resume_on_restart": self.config.jobs.resume_on_restart,
......
......@@ -204,15 +204,41 @@ def apply_model_entry_live(entry, model_types) -> int:
mid = entry.get("path") or entry.get("id") or ""
if not mid:
return 0
def _accel_sig(c):
"""Stable signature of a config's acceleration so we can tell whether a
save toggled/changed it. Returns 'off' when acceleration is absent."""
try:
from codai.models.acceleration import resolve_acceleration
import json as _json
a = resolve_acceleration(c)
return _json.dumps(a, sort_keys=True, default=str) if a else "off"
except Exception:
return "off"
updated = 0
for cat in (model_types or []):
tp = _CATEGORY_TYPE_PREFIX.get(cat)
if not tp:
continue
type_str, prefix = tp
key = f"{prefix}{mid}"
old_cfg = multi_model_manager.config.get(key)
cfg = build_runtime_model_cfg(entry, type_str)
multi_model_manager.config[f"{prefix}{mid}"] = cfg
multi_model_manager.config[key] = cfg
updated += 1
# Acceleration (Lightning/Lightx2v/LCM distill LoRA + scheduler) is FUSED
# into the pipeline at load time, so it can't be toggled on an already
# loaded model. If the save changed it and the model is loaded, evict it
# so the NEXT request for this model reloads with the new acceleration —
# applied immediately, no server restart.
try:
if (key in multi_model_manager.models
and _accel_sig(old_cfg) != _accel_sig(cfg)):
if multi_model_manager.unload_model(key):
print(f" [admin] acceleration changed for {key} — model "
f"evicted; next request reloads with the new setting")
except Exception as _e:
print(f" [admin] accel-evict skipped for {key}: {_e}")
alias = entry.get("alias")
if alias:
try:
......@@ -766,6 +792,9 @@ def main():
global_args.thermal_gpu_high = config.thermal.gpu_high
global_args.thermal_gpu_resume = config.thermal.gpu_resume
global_args.thermal_poll_seconds = config.thermal.poll_seconds
global_args.thermal_soft_throttle_enabled = config.thermal.soft_throttle_enabled
global_args.thermal_soft_throttle_temp = config.thermal.soft_throttle_temp
global_args.thermal_soft_throttle_max_sleep = config.thermal.soft_throttle_max_sleep
global_args.n_gpu_layers = config.vulkan.n_gpu_layers
global_args.n_ctx = [config.vulkan.n_ctx]
global_args.vulkan_device = config.vulkan.device_id
......
......@@ -2464,6 +2464,52 @@ class MultiModelManager:
_time.sleep(0.25)
return True
def unload_model(self, key: str) -> bool:
"""Fully unload ONE model by key: drop it from the cache + instance pool and
free its VRAM/host RAM. Used when a config change (e.g. acceleration, which
is fused at load time) needs the next request to reload the model fresh.
Waits briefly if the model is mid-request; returns True if a model was
actually unloaded."""
if key not in self.models and key not in self.model_pools:
return False
if self._is_key_busy(key):
if not self._wait_until_idle(key):
print(f" unload_model: '{key}' still busy — not unloaded")
return False
model_obj = self.models.pop(key, None)
self.models_in_vram.discard(key)
if self.current_model_key == key:
self.current_model_key = None
if self.active_in_vram == key:
self.active_in_vram = None
pool = self.model_pools.pop(key, None)
if pool is not None:
try:
pool.cleanup_all()
except Exception as e:
print(f" Warning cleaning pool for '{key}': {e}")
if model_obj is not None:
try:
if hasattr(model_obj, 'cleanup'):
model_obj.cleanup()
elif hasattr(model_obj, 'to'):
model_obj.to('cpu')
except Exception as e:
print(f" Warning during unload of '{key}': {e}")
del model_obj
for _ in range(3):
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.synchronize()
torch.cuda.empty_cache()
except Exception:
pass
_trim_cpu_ram()
return True
def _evict_models_for_vram(self, needed_gb: float):
"""Unload loaded models (LRU first) until we have at least needed_gb free VRAM.
......
......@@ -63,6 +63,25 @@ def get_cooldown_state() -> dict:
return dict(_cooldown_state)
def soft_throttle_status() -> dict:
"""Current proactive CPU soft-throttle status, computed LIVE from the CPU temp
and settings (so the Tasks page reflects reality regardless of when the last
per-step checkpoint fired — video steps can be tens of seconds apart).
``active`` is True when soft-throttle is enabled and the CPU sits in the warm
band [soft_temp, cpu_high). The caller (Tasks API) additionally gates this on
there being a running generation, since throttling only happens during work.
CPU-only by design."""
s = _settings_from_global_args()
if not s.soft_enabled or not s.cpu_enabled or s.soft_max_sleep <= 0:
return {"active": False}
cpu_t = read_cpu_temp()
if cpu_t is None or cpu_t < s.soft_temp or cpu_t >= s.cpu_high:
return {"active": False}
return {"active": True, "cpu": cpu_t, "sleep": _soft_throttle_sleep(s, cpu_t),
"engage": s.soft_temp, "cpu_high": s.cpu_high}
def _cooldown_active() -> bool:
"""True while at least one worker is in the cooldown wait loop. Used so that
other parallel workers join the pause (cross-worker hysteresis) instead of
......@@ -423,12 +442,14 @@ class ThermalSettings:
"cpu_enabled", "gpu_enabled",
"cpu_high", "cpu_resume", "gpu_high", "gpu_resume",
"poll_seconds",
"soft_enabled", "soft_temp", "soft_max_sleep",
)
def __init__(self, cpu_enabled=True, gpu_enabled=True,
cpu_high=90.0, cpu_resume=87.0,
gpu_high=90.0, gpu_resume=87.0,
poll_seconds=5.0):
poll_seconds=5.0,
soft_enabled=False, soft_temp=80.0, soft_max_sleep=3.0):
self.cpu_enabled = bool(cpu_enabled)
self.gpu_enabled = bool(gpu_enabled)
self.cpu_high = float(cpu_high)
......@@ -436,6 +457,9 @@ class ThermalSettings:
self.gpu_high = float(gpu_high)
self.gpu_resume = float(gpu_resume)
self.poll_seconds = max(1.0, float(poll_seconds))
self.soft_enabled = bool(soft_enabled)
self.soft_temp = float(soft_temp)
self.soft_max_sleep = max(0.0, float(soft_max_sleep))
def _settings_from_global_args() -> ThermalSettings:
......@@ -456,9 +480,29 @@ def _settings_from_global_args() -> ThermalSettings:
gpu_high=g("thermal_gpu_high", 90.0),
gpu_resume=g("thermal_gpu_resume", 87.0),
poll_seconds=g("thermal_poll_seconds", 5.0),
soft_enabled=g("thermal_soft_throttle_enabled", False),
soft_temp=g("thermal_soft_throttle_temp", 80.0),
soft_max_sleep=g("thermal_soft_throttle_max_sleep", 3.0),
)
def _soft_throttle_sleep(settings: "ThermalSettings", cpu_t) -> float:
"""Seconds to sleep at this checkpoint for proactive CPU soft-throttling.
0 unless the CPU is in its warm band [soft_temp, cpu_high). Scales linearly
from 0 at soft_temp to soft_max_sleep just below the CPU pause threshold.
CPU-ONLY by design — GPU heat is left entirely to the hard cooldown."""
if (not settings.soft_enabled or settings.soft_max_sleep <= 0
or not settings.cpu_enabled or cpu_t is None):
return 0.0
t0 = settings.soft_temp
if cpu_t < t0:
return 0.0
span = max(1.0, settings.cpu_high - t0)
frac = min(1.0, max(0.0, (cpu_t - t0) / span))
return settings.soft_max_sleep * frac
_last_checkpoint: dict = {}
......@@ -527,6 +571,17 @@ def wait_until_safe(settings: Optional[ThermalSettings] = None,
(settings.cpu_enabled and cpu_t is not None and cpu_t > settings.cpu_resume):
joined = True
if not hot and not joined:
# Proactive soft-throttle (CPU only): not hot enough to hard-pause, but if
# the CPU is in the warm band [soft_temp, cpu_high) sleep a little (scaled
# by how close to the pause threshold) so its temperature climbs slower and
# we rarely hit the full cooldown. Caps the heat-rate of a single pegged
# core. GPU heat is handled solely by the hard cooldown.
_sleep = _soft_throttle_sleep(settings, cpu_t)
if _sleep > 0:
_dbg(f"soft-throttle{desc0}: sleeping {_sleep:.2f}s "
f"(GPU {_fmt(gpu_t)} CPU {_fmt(cpu_t)}, engage>={settings.soft_temp:.0f})")
time.sleep(_sleep)
else:
_dbg(f"within safe limits — serving immediately{desc0}")
return
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment