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)): ...@@ -1988,6 +1988,81 @@ async def api_accel_presets(username: str = Depends(require_admin)):
raise HTTPException(status_code=500, detail=str(e)) 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") @router.get("/admin/api/turboquant-info", summary="TurboQuant backend availability")
async def api_turboquant_info(username: str = Depends(require_admin)): async def api_turboquant_info(username: str = Depends(require_admin)):
"""Report which TurboQuant embedding-quantization backends are available so """Report which TurboQuant embedding-quantization backends are available so
...@@ -2109,7 +2184,41 @@ async def api_tasks(username: str = Depends(require_admin)): ...@@ -2109,7 +2184,41 @@ async def api_tasks(username: str = Depends(require_admin)):
except Exception: except Exception:
pass 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]: def _read_vram_info() -> Optional[dict]:
...@@ -2336,6 +2445,9 @@ async def api_get_settings(username: str = Depends(require_admin)): ...@@ -2336,6 +2445,9 @@ async def api_get_settings(username: str = Depends(require_admin)):
"gpu_high": c.thermal.gpu_high, "gpu_high": c.thermal.gpu_high,
"gpu_resume": c.thermal.gpu_resume, "gpu_resume": c.thermal.gpu_resume,
"poll_seconds": c.thermal.poll_seconds, "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": { "jobs": {
"resume_on_restart": c.jobs.resume_on_restart, "resume_on_restart": c.jobs.resume_on_restart,
...@@ -2457,6 +2569,9 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -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_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.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.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. # Push to the live global_args so changes apply without a restart.
try: try:
from codai.api.state import get_global_args from codai.api.state import get_global_args
...@@ -2469,6 +2584,9 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -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_high = c.thermal.gpu_high
ga.thermal_gpu_resume = c.thermal.gpu_resume ga.thermal_gpu_resume = c.thermal.gpu_resume
ga.thermal_poll_seconds = c.thermal.poll_seconds 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: except Exception:
pass pass
......
...@@ -730,12 +730,22 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -730,12 +730,22 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</select> </select>
</div> </div>
<div class="form-row" style="max-width:560px"> <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"> <input type="text" id="cfg-accel-lora" class="form-input" placeholder="e.g. ByteDance/SDXL-Lightning:sdxl_lightning_4step_lora.safetensors">
</div> </div>
<div class="form-row" style="max-width:560px"> <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> <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)"> <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)"> <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> <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> </div>
...@@ -2797,15 +2807,103 @@ function onAccelPreset(){ ...@@ -2797,15 +2807,103 @@ function onAccelPreset(){
const p = (_accelPresets || {})[key]; const p = (_accelPresets || {})[key];
if (!p) return; // "custom" — leave fields as-is if (!p) return; // "custom" — leave fields as-is
document.getElementById('cfg-accel-lora').value = p.lora || ''; 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-weight').value = p.lora_weight != null ? p.lora_weight : '';
document.getElementById('cfg-accel-steps').value = p.steps != null ? p.steps : ''; 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-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-flowshift').value = p.flow_shift != null ? p.flow_shift : '';
document.getElementById('cfg-accel-scheduler').value = p.scheduler || ''; 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){ async function _populateAccel(a){
await _loadAccelPresets(); await _loadAccelPresets();
await _loadAccelLoras();
_refreshAccelVisibility(); _refreshAccelVisibility();
_refreshAccelDistill();
a = a || {}; a = a || {};
document.getElementById('cfg-accel-enabled').checked = !!a.enabled; document.getElementById('cfg-accel-enabled').checked = !!a.enabled;
const sel = document.getElementById('cfg-accel-preset'); const sel = document.getElementById('cfg-accel-preset');
...@@ -2818,6 +2916,7 @@ async function _populateAccel(a){ ...@@ -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-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-flowshift').value = a.flow_shift != null ? a.flow_shift : '';
document.getElementById('cfg-accel-scheduler').value = a.scheduler || ''; document.getElementById('cfg-accel-scheduler').value = a.scheduler || '';
_syncAccelDistillFromFields();
onAccelToggle(); onAccelToggle();
} }
function _collectAccel(){ function _collectAccel(){
......
...@@ -146,11 +146,33 @@ ...@@ -146,11 +146,33 @@
</div> </div>
</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> <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"> <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> <span class="form-hint">How often to re-read temperatures while waiting for cooldown.</span>
</div> </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> </div>
<!-- Background jobs --> <!-- Background jobs -->
...@@ -284,6 +306,8 @@ function toggleThermalFields(){ ...@@ -284,6 +306,8 @@ function toggleThermalFields(){
document.getElementById('s-therm-gpu-enabled').checked ? 'grid' : 'none'; document.getElementById('s-therm-gpu-enabled').checked ? 'grid' : 'none';
document.getElementById('therm-cpu-fields').style.display = document.getElementById('therm-cpu-fields').style.display =
document.getElementById('s-therm-cpu-enabled').checked ? 'grid' : 'none'; 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){ function showAlert(type, msg){
...@@ -345,6 +369,9 @@ async function loadSettings(){ ...@@ -345,6 +369,9 @@ async function loadSettings(){
document.getElementById('s-therm-cpu-high').value = therm.cpu_high ?? 90; 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-cpu-resume').value = therm.cpu_resume ?? 87;
document.getElementById('s-therm-poll').value = therm.poll_seconds ?? 5; 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(); toggleThermalFields();
// Background jobs // Background jobs
const jobs = d.jobs || {}; const jobs = d.jobs || {};
...@@ -383,6 +410,9 @@ async function saveSettings(){ ...@@ -383,6 +410,9 @@ async function saveSettings(){
cpu_high: parseFloat(document.getElementById('s-therm-cpu-high').value) || 90, cpu_high: parseFloat(document.getElementById('s-therm-cpu-high').value) || 90,
cpu_resume: parseFloat(document.getElementById('s-therm-cpu-resume').value) || 87, cpu_resume: parseFloat(document.getElementById('s-therm-cpu-resume').value) || 87,
poll_seconds: parseFloat(document.getElementById('s-therm-poll').value) || 5, 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:{ jobs:{
resume_on_restart: document.getElementById('s-jobs-resume').checked, resume_on_restart: document.getElementById('s-jobs-resume').checked,
......
...@@ -19,6 +19,13 @@ ...@@ -19,6 +19,13 @@
— running work is paused until the hardware cools. — running work is paused until the hardware cools.
</div> </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 --> <!-- Live hardware telemetry -->
<div id="sys-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); <div id="sys-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
gap:.75rem;margin:0 0 1.25rem"> gap:.75rem;margin:0 0 1.25rem">
...@@ -165,6 +172,16 @@ async function loadTasks() { ...@@ -165,6 +172,16 @@ async function loadTasks() {
banner.style.display = 'none'; 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'); const tbody = document.getElementById('tasks-body');
if (!tasks.length) { if (!tasks.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No tasks yet</td></tr>'; tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No tasks yet</td></tr>';
...@@ -177,6 +194,10 @@ async function loadTasks() { ...@@ -177,6 +194,10 @@ async function loadTasks() {
if (t.cooling) { if (t.cooling) {
statusCell = `<span class="badge badge-warn">❄ Cooling down</span>` statusCell = `<span class="badge badge-warn">❄ Cooling down</span>`
+ `<div class="dim small">${esc(t.cooling_message || 'paused for thermal cooldown')}</div>`; + `<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) { } else if (t.paused) {
statusCell = `<span class="badge badge-warn">⏸ Paused</span>` statusCell = `<span class="badge badge-warn">⏸ Paused</span>`
+ `<div class="dim small">suspended — click Resume to continue</div>`; + `<div class="dim small">suspended — click Resume to continue</div>`;
......
...@@ -124,6 +124,13 @@ class ThermalConfig: ...@@ -124,6 +124,13 @@ class ThermalConfig:
gpu_high: float = 90.0 # pause when GPU reaches this temperature gpu_high: float = 90.0 # pause when GPU reaches this temperature
gpu_resume: float = 87.0 # resume once GPU drops back to/below this 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 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 @dataclass
...@@ -423,6 +430,9 @@ class ConfigManager: ...@@ -423,6 +430,9 @@ class ConfigManager:
"gpu_high": self.config.thermal.gpu_high, "gpu_high": self.config.thermal.gpu_high,
"gpu_resume": self.config.thermal.gpu_resume, "gpu_resume": self.config.thermal.gpu_resume,
"poll_seconds": self.config.thermal.poll_seconds, "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": { "jobs": {
"resume_on_restart": self.config.jobs.resume_on_restart, "resume_on_restart": self.config.jobs.resume_on_restart,
......
...@@ -204,15 +204,41 @@ def apply_model_entry_live(entry, model_types) -> int: ...@@ -204,15 +204,41 @@ def apply_model_entry_live(entry, model_types) -> int:
mid = entry.get("path") or entry.get("id") or "" mid = entry.get("path") or entry.get("id") or ""
if not mid: if not mid:
return 0 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 updated = 0
for cat in (model_types or []): for cat in (model_types or []):
tp = _CATEGORY_TYPE_PREFIX.get(cat) tp = _CATEGORY_TYPE_PREFIX.get(cat)
if not tp: if not tp:
continue continue
type_str, prefix = tp type_str, prefix = tp
key = f"{prefix}{mid}"
old_cfg = multi_model_manager.config.get(key)
cfg = build_runtime_model_cfg(entry, type_str) cfg = build_runtime_model_cfg(entry, type_str)
multi_model_manager.config[f"{prefix}{mid}"] = cfg multi_model_manager.config[key] = cfg
updated += 1 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") alias = entry.get("alias")
if alias: if alias:
try: try:
...@@ -766,6 +792,9 @@ def main(): ...@@ -766,6 +792,9 @@ def main():
global_args.thermal_gpu_high = config.thermal.gpu_high global_args.thermal_gpu_high = config.thermal.gpu_high
global_args.thermal_gpu_resume = config.thermal.gpu_resume global_args.thermal_gpu_resume = config.thermal.gpu_resume
global_args.thermal_poll_seconds = config.thermal.poll_seconds 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_gpu_layers = config.vulkan.n_gpu_layers
global_args.n_ctx = [config.vulkan.n_ctx] global_args.n_ctx = [config.vulkan.n_ctx]
global_args.vulkan_device = config.vulkan.device_id global_args.vulkan_device = config.vulkan.device_id
......
...@@ -2464,6 +2464,52 @@ class MultiModelManager: ...@@ -2464,6 +2464,52 @@ class MultiModelManager:
_time.sleep(0.25) _time.sleep(0.25)
return True 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): def _evict_models_for_vram(self, needed_gb: float):
"""Unload loaded models (LRU first) until we have at least needed_gb free VRAM. """Unload loaded models (LRU first) until we have at least needed_gb free VRAM.
......
...@@ -63,6 +63,25 @@ def get_cooldown_state() -> dict: ...@@ -63,6 +63,25 @@ def get_cooldown_state() -> dict:
return dict(_cooldown_state) 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: def _cooldown_active() -> bool:
"""True while at least one worker is in the cooldown wait loop. Used so that """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 other parallel workers join the pause (cross-worker hysteresis) instead of
...@@ -423,12 +442,14 @@ class ThermalSettings: ...@@ -423,12 +442,14 @@ class ThermalSettings:
"cpu_enabled", "gpu_enabled", "cpu_enabled", "gpu_enabled",
"cpu_high", "cpu_resume", "gpu_high", "gpu_resume", "cpu_high", "cpu_resume", "gpu_high", "gpu_resume",
"poll_seconds", "poll_seconds",
"soft_enabled", "soft_temp", "soft_max_sleep",
) )
def __init__(self, cpu_enabled=True, gpu_enabled=True, def __init__(self, cpu_enabled=True, gpu_enabled=True,
cpu_high=90.0, cpu_resume=87.0, cpu_high=90.0, cpu_resume=87.0,
gpu_high=90.0, gpu_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.cpu_enabled = bool(cpu_enabled)
self.gpu_enabled = bool(gpu_enabled) self.gpu_enabled = bool(gpu_enabled)
self.cpu_high = float(cpu_high) self.cpu_high = float(cpu_high)
...@@ -436,6 +457,9 @@ class ThermalSettings: ...@@ -436,6 +457,9 @@ class ThermalSettings:
self.gpu_high = float(gpu_high) self.gpu_high = float(gpu_high)
self.gpu_resume = float(gpu_resume) self.gpu_resume = float(gpu_resume)
self.poll_seconds = max(1.0, float(poll_seconds)) 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: def _settings_from_global_args() -> ThermalSettings:
...@@ -456,9 +480,29 @@ 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_high=g("thermal_gpu_high", 90.0),
gpu_resume=g("thermal_gpu_resume", 87.0), gpu_resume=g("thermal_gpu_resume", 87.0),
poll_seconds=g("thermal_poll_seconds", 5.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 = {} _last_checkpoint: dict = {}
...@@ -527,7 +571,18 @@ def wait_until_safe(settings: Optional[ThermalSettings] = None, ...@@ -527,7 +571,18 @@ def wait_until_safe(settings: Optional[ThermalSettings] = None,
(settings.cpu_enabled and cpu_t is not None and cpu_t > settings.cpu_resume): (settings.cpu_enabled and cpu_t is not None and cpu_t > settings.cpu_resume):
joined = True joined = True
if not hot and not joined: if not hot and not joined:
_dbg(f"within safe limits — serving immediately{desc0}") # 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 return
# Enter cooldown: wait until *every* triggered sensor is at/below resume. # Enter cooldown: wait until *every* triggered sensor is at/below resume.
......
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