Files
VoiceStudio/backend/services/model_lifecycle.py
T
velixio 090cc37144 fix(memory): release the model before emptying the cache, not after
The shared voice model's unload emptied the allocator caches and *then*
dropped the reference. That frees nothing: the weights are still reachable
when gc.collect() runs, empty_cache() only returns blocks the allocator
already considered free, and the reference drops a moment later into a cache
nothing will flush again. The unload logs success, the engine leaves the
registry, and nvidia-smi does not move.

Six modules open-coded the same two lines. Exactly one had them inverted --
OmniVoiceBackend.unload, which is the path the engine-registry idle sweep
reaches, which is the sweep a headless worker node runs. So every unload a
user could trigger from the UI worked, and the one that runs unattended on a
machine lending its GPU held 3.6 GB indefinitely. Found on hardware: the
sweep fired on schedule, logged "Released 1 idle engine(s)", and VRAM stayed
flat at 3656 MiB for the next two minutes.

Replace all six with model_manager.unload_shared_model(), which clears the
reference, drops the clone-prompt side cache, then frees -- in that order,
in one place. Two callers gain the side-cache drop they were missing
(/system/flush-memory and the shutdown path), which is the same defect one
step down: an unload that kept the encoded reference tensors belonging to the
model it had just released.

A source guard asserts nothing outside model_manager assigns the shared
reference, so the next caller cannot reintroduce the ordering. It caught the
sixth site while being written.

Also give the AudioSeal watermark models the bargain every other model in the
app already makes: they loaded on the first embed and stayed resident for the
life of the process. CPU-resident, so this is system RAM rather than VRAM,
and the machines that notice are the ones running batches.

The error text on a failing unload changes with the ordering. "Could not be
unloaded, retry after the current generation finishes" was accurate when the
cache flush ran first and aborted before the release; now the release has
already happened and only the flush can fail, so it says that instead of
sending the user to repeat work that is done.
2026-08-12 02:33:04 +05:30

291 lines
12 KiB
Python

"""Single lifecycle surface for loaded models (MM2-04).
Before this, ``GET /model/loaded`` and ``POST /model/unload`` each hand-rolled
enumeration/dispatch across three worlds — the in-process TTS+ASR model
(``model_manager``), the diarization pipeline, and subprocess sidecars
(``subprocess_backend``). This module owns that logic so the routers are thin
delegations and there's one place to reason about model lifecycle.
Response shapes are preserved exactly — the frontend (hooks.ts model status +
the flush dropdown) depends on ``{models, count}`` and
``{unloaded, success, ...}``.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import services.model_manager as mm
from services.model_manager import get_best_device
logger = logging.getLogger("omnivoice.model_lifecycle")
def _tts_vram_mb() -> float:
"""Best-effort allocated VRAM for the in-process model. Accurate on CUDA,
sparse on MPS, 0 elsewhere — degrade gracefully, never raise."""
try:
torch = mm._lazy_torch()
if torch.cuda.is_available():
return torch.cuda.memory_allocated() / (1024 ** 2)
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
driver = getattr(torch.mps, "driver_allocated_memory", None)
if driver:
return driver() / (1024 ** 2)
except Exception:
pass
return 0.0
def _asr_device() -> str:
"""Where the ASR pipe actually lives, rather than a hardcoded 'cpu'."""
pipe = getattr(mm.model, "_asr_pipe", None)
for attr in ("device",):
dev = getattr(pipe, attr, None)
if dev is not None:
return str(dev)
return "cpu"
def _active_tts_id() -> Optional[str]:
"""Configured TTS engine id, or None if it can't be resolved. Attribution
is advisory — a prefs/import hiccup must never break /model/loaded."""
try:
from services.tts_backend import active_backend_id
return active_backend_id()
except Exception:
return None
def _tts_attribution(engine_id: str, active: Optional[str]) -> dict:
"""Per-entry engine attribution for TTS-family models. A model can stay
resident in VRAM after the user switches engines (freed only by unload/
idle-evict), so the panel needs to know which entry synthesis actually
routes to. ``is_active_engine`` is None when the active id is unknown."""
return {
"engine_id": engine_id,
"is_active_engine": (engine_id == active) if active is not None else None,
}
def list_loaded() -> dict:
"""Enumerate every currently-loaded model. Shape: ``{"models": [...],
"count": n}`` with per-model id/name/checkpoint/device/vram_mb/unloadable
(+ optional ``note``)."""
models: list[dict] = []
degraded_sources: list[str] = []
active_tts = _active_tts_id()
# 1. In-process TTS model (VoiceStudio)
if mm.model is not None:
try:
device = str(next(mm.model.parameters()).device) if hasattr(mm.model, "parameters") else get_best_device()
except Exception:
device = get_best_device()
models.append({
"id": "tts",
"name": "VoiceStudio TTS",
"checkpoint": mm.resolve_omnivoice_checkpoint(), # #693: effective checkpoint, not a leaked raw value
"device": device,
"vram_mb": round(_tts_vram_mb(), 1),
"unloadable": True,
**_tts_attribution("omnivoice", active_tts),
})
# 2. ASR (WhisperX) — co-loaded with and released alongside the TTS model.
# Honest reporting (MM2-03): the device is read from the pipe, and the
# dead "unload" button is explained by a note rather than left silent.
if mm.model is not None and getattr(mm.model, "_asr_pipe", None) is not None:
models.append({
"id": "asr",
"name": "WhisperX ASR",
"checkpoint": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
"device": _asr_device(),
"vram_mb": 0,
"unloadable": False,
"note": "released with the TTS model",
})
# 3. Diarization pipeline
if mm._diar_pipeline is not None:
models.append({
"id": "diarization",
"name": "Pyannote Diarization",
"checkpoint": "pyannote/speaker-diarization-3.1",
"device": get_best_device(),
"vram_mb": 0,
"unloadable": True,
})
# 4. Subprocess engine sidecars — each holds a process (and on GPU, VRAM)
# until idle-reaped. VRAM is reported by the child itself when available
# (MM2-08); 0 means CPU-only or not-yet-measured. Enumeration must never
# break the panel.
try:
from services.subprocess_backend import list_live_sidecars
for s in list_live_sidecars():
models.append({
"id": f"sidecar:{s['id']}",
"name": f"{s['id']} (sidecar)",
"checkpoint": s["id"],
"device": get_best_device(),
"vram_mb": round(float(s.get("vram_mb") or 0), 1),
"unloadable": True,
**_tts_attribution(s["id"], active_tts),
})
except Exception:
logger.warning("Loaded-model inventory unavailable for subprocess sidecars")
degraded_sources.append("sidecars")
# 5. In-process engine instances that hold a model (mlx-audio, cosyvoice,
# voxcpm2, kittentts, …). These live in the generate path's instance
# cache, separate from the VoiceStudio core above — and were INVISIBLE here
# until now, so a resident non-VoiceStudio engine (up to a few GB) didn't
# show in the panel at all. Report each that currently holds a model.
# VRAM isn't self-reported by these engines → 0 (unmeasured), same
# convention as a CPU/uninstrumented sidecar. Enumeration is best-effort.
try:
from api.routers.engines import _ENGINE_INSTANCES
from services.tts_backend import OmniVoiceBackend
for cls, inst in list(_ENGINE_INSTANCES.items()):
if cls is OmniVoiceBackend:
continue # the shared core is already section 1 (mm.model)
if not any(getattr(inst, a, None) is not None
for a in getattr(inst, "_MODEL_ATTRS", ("_model", "_tts"))):
continue # instance exists but hasn't loaded its weights
eid = getattr(cls, "id", cls.__name__)
models.append({
"id": f"engine:{eid}",
"name": getattr(inst, "display_name", None) or f"{eid} (engine)",
"checkpoint": eid,
"device": get_best_device(),
"vram_mb": 0, # not self-reported by in-process engines
"unloadable": True,
**_tts_attribution(eid, active_tts),
})
except Exception:
logger.warning("Loaded-model inventory unavailable for in-process engines")
degraded_sources.append("engines")
# 6. The warm capture/dictation ASR singleton — resident until idle-released
# (#1101 class). Held separately from the co-loaded WhisperX ASR above.
try:
import services.asr_backend as ab
cap = getattr(ab, "_capture_backend", None)
if cap is not None:
models.append({
"id": "capture-asr",
"name": f"{type(cap).__name__} (dictation)",
"checkpoint": getattr(ab, "_capture_backend_key", None) or type(cap).__name__,
"device": get_best_device(),
"vram_mb": 0,
"unloadable": True,
"note": "released after the idle timeout",
})
except Exception:
logger.warning("Loaded-model inventory unavailable for dictation")
degraded_sources.append("dictation")
# System memory snapshot — free/total RAM (and VRAM on a dedicated GPU) plus
# a low-memory advisory, so the panel can show pressure instead of leaving
# the 16 GB-Mac OOM class invisible until the backend dies.
system: dict = {}
try:
from services.memory_budget import available_memory, low_memory_warning
system = available_memory()
warn = low_memory_warning()
if warn:
system["warning"] = warn
except Exception:
pass
return {"models": models, "count": len(models), "system": system,
"degraded_sources": degraded_sources}
async def unload(model_id: str) -> dict:
"""Unload one model by id. Preserves the original per-id response shapes.
``tts`` | ``diarization`` | ``sidecar:<id>`` | ``sidecars``. Raises
ValueError for an unknown id (router maps to HTTP 400)."""
if model_id == "sidecars" or model_id.startswith("sidecar:"):
from services.subprocess_backend import unload_all_sidecars, unload_sidecar
n = unload_all_sidecars() if model_id == "sidecars" else unload_sidecar(model_id.split(":", 1)[1])
return {"unloaded": model_id, "success": n > 0, "count": n,
**({} if n > 0 else {"reason": "not running or busy"})}
if model_id == "tts":
async with mm._model_lock:
if mm.unload_shared_model():
return {"unloaded": "tts", "success": True}
return {"unloaded": "tts", "success": False, "reason": "not loaded"}
if model_id == "diarization":
if mm._diar_pipeline is not None:
mm._diar_pipeline = None
mm.free_vram()
return {"unloaded": "diarization", "success": True}
return {"unloaded": "diarization", "success": False, "reason": "not loaded"}
# The warm dictation ASR (#1247, same defect). It is listed with
# ``"unloadable": True`` and had no branch either — found by the contract
# test written for the engine case, which is the whole reason that test
# enumerates the listing instead of hard-coding ids.
if model_id == "capture-asr":
import services.asr_backend as ab
if getattr(ab, "_capture_backend", None) is None:
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
# idle_s=0 → release now. Still declines while a dictation stream holds
# a lease; yanking the model out from under an open session is exactly
# what the lease exists to prevent.
if ab.release_idle_capture_backend(0.0):
return {"unloaded": model_id, "success": True}
return {"unloaded": model_id, "success": False, "reason": "in use by dictation"}
# In-process engines (#1247). `list_loaded_models` has advertised these as
# `engine:<id>` with `"unloadable": True` since they were made visible in
# the panel — but this dispatcher never grew a branch for them, so pressing
# Unload on any of those rows answered `400 Unknown model id:
# engine:kittentts`. The engines already implement `unload()`; only the
# routing was missing.
if model_id.startswith("engine:"):
engine_id = model_id.split(":", 1)[1]
from api.routers.engines import _ENGINE_INSTANCES
for cls, inst in list(_ENGINE_INSTANCES.items()):
if (getattr(cls, "id", cls.__name__)) != engine_id:
continue
held = any(
getattr(inst, attr, None) is not None
for attr in getattr(inst, "_MODEL_ATTRS", ("_model", "_tts"))
)
if not held:
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
inst.unload() # idempotent by contract; frees device caches itself
return {"unloaded": model_id, "success": True}
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
raise ValueError(f"Unknown model id: {model_id}")
async def unload_all() -> dict:
"""Release every releasable model — in-process TTS + diarization + all
sidecars. Convenience for app shutdown / a global flush."""
results = {}
for mid in ("tts", "diarization", "sidecars"):
try:
results[mid] = await unload(mid)
except Exception as exc: # noqa: BLE001
results[mid] = {"unloaded": mid, "success": False, "reason": str(exc)}
return {"unloaded_all": True, "results": results}
def free_vram() -> None:
"""One import surface for callers that just want to drop GPU caches."""
mm.free_vram()