Every ASR/TTS worker (faster-whisper, qwen3, vibevoice, xtts) kept every model it ever loaded resident in GPU memory forever, and switching to a different model (e.g. a different whisper size) just added another one alongside it rather than freeing the old one. Combined with the shared 24GB GPU, this made memory pressure only ever go up. Now: only one model stays resident per worker at a time (loading a different model_id evicts the previous one first), and the whole cache (plus, for faster-whisper, the diarization pipeline) is dropped after 2 minutes of no requests. Verified end-to-end: the idle timer actually fires and reclaims memory, and switching qwen3 models evicts the old one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
226 lines
7.1 KiB
Python
226 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import gc
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
|
|
import sys
|
|
sys.path.insert(0, "/app")
|
|
from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs
|
|
|
|
import os
|
|
# 컨테이너는 983 유저로 실행되는데 HOME(/app)이 root 소유라 numba/matplotlib/torch가
|
|
# 각자 ~/.cache, ~/.config 아래에 쓰려다 실패한다. HOME을 쓰기 가능한 곳으로 돌린다.
|
|
os.environ["HOME"] = "/tmp" # 컨테이너 기본 HOME=/app은 983 유저가 쓰기 불가 (setdefault로는 덮어쓰기 안 됨)
|
|
os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
|
|
|
|
app = FastAPI(title="ASR VibeVoice Worker")
|
|
|
|
_MODEL_CACHE: Dict[str, Any] = {}
|
|
|
|
# Idle-unload / model-switch eviction: only one model stays resident at a
|
|
# time, and the whole cache is dropped after IDLE_UNLOAD_SECONDS of no
|
|
# requests, so this backend doesn't permanently hog GPU memory shared with
|
|
# the other ASR workers.
|
|
IDLE_UNLOAD_SECONDS = 120
|
|
_last_used: float = 0.0
|
|
_active_requests: int = 0
|
|
|
|
|
|
def _log(msg: str) -> None:
|
|
print(f"[vibevoice] {msg}", flush=True)
|
|
|
|
|
|
def _free_gpu() -> None:
|
|
gc.collect()
|
|
try:
|
|
import torch
|
|
if DEVICE == "cuda" and torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _unload_models() -> None:
|
|
if not _MODEL_CACHE:
|
|
return
|
|
_log(f"unloading {list(_MODEL_CACHE.keys())}")
|
|
_MODEL_CACHE.clear()
|
|
_free_gpu()
|
|
|
|
|
|
async def _idle_unload_loop() -> None:
|
|
while True:
|
|
await asyncio.sleep(30)
|
|
if _active_requests == 0 and _last_used and (time.monotonic() - _last_used) >= IDLE_UNLOAD_SECONDS:
|
|
_unload_models()
|
|
|
|
|
|
def _load_model(model_id: str) -> Any:
|
|
if model_id in _MODEL_CACHE:
|
|
return _MODEL_CACHE[model_id]
|
|
|
|
if _MODEL_CACHE:
|
|
# Only one model resident at a time — switching models frees the old one.
|
|
_unload_models()
|
|
|
|
import torch
|
|
from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration
|
|
from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
|
|
|
|
_log(f"loading model {model_id}")
|
|
processor = VibeVoiceASRProcessor.from_pretrained(
|
|
model_id,
|
|
language_model_pretrained_name="Qwen/Qwen2.5-1.5B",
|
|
cache_dir=str(MODEL_CACHE),
|
|
)
|
|
model = VibeVoiceASRForConditionalGeneration.from_pretrained(
|
|
model_id,
|
|
dtype=torch.bfloat16 if DEVICE == "cuda" else torch.float32,
|
|
attn_implementation="sdpa",
|
|
trust_remote_code=True,
|
|
cache_dir=str(MODEL_CACHE),
|
|
)
|
|
model.to(DEVICE)
|
|
model.eval()
|
|
|
|
_MODEL_CACHE[model_id] = (model, processor)
|
|
_log(f"model {model_id} loaded")
|
|
return _MODEL_CACHE[model_id]
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup() -> None:
|
|
ensure_runtime_dirs()
|
|
asyncio.create_task(_idle_unload_loop())
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> Dict[str, Any]:
|
|
return {"status": "ok", "device": DEVICE, "loaded_models": list(_MODEL_CACHE.keys())}
|
|
|
|
|
|
@app.post("/transcribe")
|
|
async def transcribe(
|
|
file: UploadFile = File(...),
|
|
model: str = Form("microsoft/VibeVoice-ASR"),
|
|
max_new_tokens: int = Form(4096),
|
|
) -> JSONResponse:
|
|
ensure_runtime_dirs()
|
|
suffix = Path(file.filename or "audio.bin").suffix or ".wav"
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
tmp.write(await file.read())
|
|
tmp_path = Path(tmp.name)
|
|
|
|
global _active_requests, _last_used
|
|
_active_requests += 1
|
|
try:
|
|
import torch
|
|
|
|
vv_model, processor = _load_model(model)
|
|
|
|
inputs = processor(
|
|
audio=[str(tmp_path)],
|
|
sampling_rate=None,
|
|
return_tensors="pt",
|
|
padding=True,
|
|
add_generation_prompt=True,
|
|
)
|
|
inputs = {k: v.to(DEVICE) if isinstance(v, torch.Tensor) else v for k, v in inputs.items()}
|
|
|
|
gen_config = {
|
|
"max_new_tokens": max_new_tokens,
|
|
"pad_token_id": processor.pad_id,
|
|
"eos_token_id": processor.tokenizer.eos_token_id,
|
|
"do_sample": False,
|
|
}
|
|
|
|
_log(f"transcribing model={model}")
|
|
with torch.no_grad():
|
|
output_ids = vv_model.generate(**inputs, **gen_config)
|
|
|
|
input_length = inputs["input_ids"].shape[1]
|
|
generated_ids = output_ids[0, input_length:]
|
|
raw_text = processor.decode(generated_ids, skip_special_tokens=True)
|
|
|
|
try:
|
|
raw_segments = processor.post_process_transcription(raw_text)
|
|
except Exception as e:
|
|
_log(f"post_process_transcription failed: {e}")
|
|
raw_segments = []
|
|
|
|
segments: List[Dict[str, Any]] = []
|
|
full_text_parts: List[str] = []
|
|
for i, seg in enumerate(raw_segments):
|
|
text = (seg.get("text") or "").strip()
|
|
speaker_id = seg.get("speaker_id")
|
|
start = seg.get("start_time")
|
|
end = seg.get("end_time")
|
|
segments.append({
|
|
"id": i,
|
|
"start": round(float(start), 3) if start is not None else None,
|
|
"end": round(float(end), 3) if end is not None else None,
|
|
"text": text,
|
|
"speaker": f"SPEAKER_{speaker_id:02d}" if speaker_id is not None else "UNKNOWN",
|
|
})
|
|
if text:
|
|
full_text_parts.append(text)
|
|
|
|
duration = segments[-1]["end"] if segments and segments[-1]["end"] is not None else None
|
|
full_text = " ".join(full_text_parts)
|
|
|
|
return JSONResponse({
|
|
"backend": "vibevoice",
|
|
"model": model,
|
|
"language": None,
|
|
"duration": duration,
|
|
"text": full_text,
|
|
"segments": segments,
|
|
"diarized": True,
|
|
})
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
_log(f"error: {type(e).__name__}: {e}")
|
|
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
# Long files blow up KV-cache/activation memory; without this, that
|
|
# memory stays reserved by this process and starves the other backends
|
|
# sharing the same GPU until the container restarts. (del locals()[...]
|
|
# does NOT work in CPython, hence the explicit names.)
|
|
try:
|
|
del inputs
|
|
except NameError:
|
|
pass
|
|
try:
|
|
del output_ids
|
|
except NameError:
|
|
pass
|
|
try:
|
|
del generated_ids
|
|
except NameError:
|
|
pass
|
|
_free_gpu()
|
|
_active_requests -= 1
|
|
_last_used = time.monotonic()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--port", type=int, default=8006)
|
|
args = parser.parse_args()
|
|
_log(f"starting host={args.host} port={args.port} device={DEVICE}")
|
|
uvicorn.run(app, host=args.host, port=args.port)
|