fix(sidecar): fall back to CPU when the CUDA runtime is unusable

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 96b24e279c
commit 4588b65dfa
3 changed files with 605 additions and 37 deletions

View file

@ -39,6 +39,14 @@ import uvicorn
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import JSONResponse
from device_policy import (
CPU_CHOICE,
DeviceChoice,
choose_gpu_device,
is_gpu_runtime_error,
load_with_fallback,
)
# ── 로깅 설정 ──────────────────────────────────────────────
# Windows에서 파이프로 연결되면 Python이 로케일(cp949) 인코딩으로 출력해
@ -64,6 +72,10 @@ _model_id: str | None = None
# 보조 모델 (실시간 자막 등 받아쓰기와 다른 모델). 최대 1개.
_aux_models: "dict[str, WhisperModel]" = {}
_gpu_available: bool = False
# GPU 로 올릴 때 쓸 장치/정밀도. GPU 가 없거나 검증에 실패하면 None.
_gpu_choice: DeviceChoice | None = None
# 로딩된 모델별 실제 장치 (primary/aux 는 같은 model_id 를 동시에 들지 않는다).
_model_devices: dict[str, DeviceChoice] = {}
_server: uvicorn.Server | None = None
_models_dir: Path | None = None
@ -118,21 +130,44 @@ def _detect_gpu() -> None:
torch 의존 제거를 위해 ctranslate2의 네이티브 CUDA 감지를 사용한다.
ctranslate2는 faster-whisper의 백엔드이므로 항상 함께 설치된다.
"""
global _gpu_available
global _gpu_available, _gpu_choice
try:
import ctranslate2
cuda_count = ctranslate2.get_cuda_device_count()
_gpu_available = cuda_count > 0
if _gpu_available:
logger.info("GPU 감지: CUDA 디바이스 %d개", cuda_count)
supported: set[str] = set()
if cuda_count > 0:
try:
supported = set(ctranslate2.get_supported_compute_types("cuda"))
except Exception as exc:
logger.info("CUDA compute type 조회 실패: %s", exc)
_gpu_choice = choose_gpu_device(cuda_count, supported)
_gpu_available = _gpu_choice is not None
if _gpu_choice is not None:
logger.info(
"GPU 감지: CUDA 디바이스 %d개 (compute=%s)",
cuda_count,
_gpu_choice.compute_type,
)
elif cuda_count > 0:
logger.info("CUDA 디바이스는 있으나 지원 compute type 없음, CPU 모드로 동작")
else:
logger.info("GPU 미감지, CPU 모드로 동작")
except Exception as exc:
_gpu_available = False
_gpu_choice = None
logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc)
def _disable_gpu(reason: BaseException) -> None:
"""GPU 경로가 실제로 동작하지 않음이 확인되면 이후 모델은 모두 CPU 로 올린다."""
global _gpu_available, _gpu_choice
if _gpu_available:
logger.warning("GPU 사용 불가 → CPU(int8) 모드로 전환: %s", reason)
_gpu_available = False
_gpu_choice = None
def _cpu_threads() -> int:
"""CPU 추론에 사용할 스레드 수 (과도한 점유 방지 위해 8로 상한)."""
return max(1, min(8, os.cpu_count() or 4))
@ -313,6 +348,46 @@ def _build_transcribe_kwargs(
return kwargs
def _run_transcription(
model: "WhisperModel",
audio_array: np.ndarray,
transcribe_kwargs: dict,
) -> tuple[list[dict], str, str]:
"""모델로 전사하고 세그먼트를 끝까지 소비한다.
faster-whisper 는 세그먼트를 지연 생성하므로 디코더 오류(GPU 런타임 포함)는
반복 중에 난다. 호출부가 전체를 한 단위로 재시도할 수 있도록 여기서 모두 소비한다.
Returns: (segments, full_text, detected_language)
"""
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
try:
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
except ValueError as ve:
if "empty sequence" in str(ve) and transcribe_kwargs.get("vad_filter"):
logger.warning("VAD가 전체 오디오를 제거함 → VAD 없이 재시도")
transcribe_kwargs["vad_filter"] = False
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
else:
raise
segments_list: list[dict] = []
full_text_parts: list[str] = []
for segment in segments_iter:
seg_dict = {
"text": segment.text.strip(),
"start": round(segment.start, 3),
"end": round(segment.end, 3),
"avg_logprob": round(segment.avg_logprob, 4),
}
segments_list.append(seg_dict)
full_text_parts.append(segment.text.strip())
full_text = " ".join(full_text_parts).strip()
detected_language = info.language if info.language else "unknown"
return segments_list, full_text, detected_language
# ── 엔드포인트 ─────────────────────────────────────────────
@ -331,26 +406,86 @@ async def health() -> JSONResponse:
)
def _create_model(model_id: str) -> "WhisperModel":
def _create_model(model_id: str, choice: DeviceChoice) -> "WhisperModel":
"""모델을 올린다. /download 로 받아 둔 로컬 디렉토리가 있으면 그것을 쓴다."""
from faster_whisper import WhisperModel
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
local_dir = _local_model_dir(model_id)
model_source = str(local_dir) if local_dir else model_id
if local_dir:
logger.info("로컬 모델 디렉토리 사용: %s", local_dir)
logger.info("모델 생성: %s (device=%s, compute=%s)", model_id, device, compute_type)
logger.info(
"모델 생성: %s (device=%s, compute=%s)", model_id, choice.device, choice.compute_type
)
return WhisperModel(
model_source,
device=device,
compute_type=compute_type,
device=choice.device,
compute_type=choice.compute_type,
cpu_threads=_cpu_threads(),
num_workers=1,
)
# 0.5초 @16kHz 무음. GPU 모델이 cuBLAS 등을 실제로 로드할 수 있는지 확인하는 데 쓴다.
_PROBE_SAMPLES = 8000
def _probe_model(model: "WhisperModel") -> None:
"""짧은 무음 전사로 인코더·디코더를 한 번 돌려 본다 (실패 시 예외)."""
silence = np.zeros(_PROBE_SAMPLES, dtype=np.float32)
segments, _info = model.transcribe(
silence,
language="en",
beam_size=1,
temperature=0.0,
vad_filter=False,
condition_on_previous_text=False,
without_timestamps=True,
)
# faster-whisper 는 세그먼트를 지연 생성하므로 끝까지 소비해야 디코더가 돈다.
for _ in segments:
pass
def _load_on_best_device(model_id: str) -> "WhisperModel":
"""GPU 후보가 있으면 GPU 로 올려 검증하고, 안 되면 CPU(int8)로 올린다."""
def on_fallback(failed: DeviceChoice, exc: BaseException) -> None:
logger.warning(
"GPU 모델 검증 실패 (%s, compute=%s) → CPU 로 재로딩: %s",
model_id,
failed.compute_type,
exc,
)
_disable_gpu(exc)
preferred = _gpu_choice if _gpu_available else None
model, choice = load_with_fallback(
build=lambda c: _create_model(model_id, c),
verify=_probe_model,
preferred=preferred,
on_fallback=on_fallback,
)
_model_devices[model_id] = choice
return model
def _reload_on_cpu(model_id: str, is_primary: bool) -> "WhisperModel":
"""전사 중 GPU 런타임 오류가 난 모델을 CPU 로 다시 올려 같은 자리에 둔다."""
global _model
if is_primary:
_model = None
else:
_aux_models.pop(model_id, None)
model = _create_model(model_id, CPU_CHOICE)
_model_devices[model_id] = CPU_CHOICE
if is_primary:
_model = model
else:
_aux_models[model_id] = model
return model
@app.post("/load")
async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
"""Whisper 모델을 로딩한다.
@ -386,11 +521,11 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
if slot == "aux":
# 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다.
_aux_models.clear()
_aux_models[model_id] = _create_model(model_id)
_aux_models[model_id] = _load_on_best_device(model_id)
else:
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
_model = None
_model = _create_model(model_id)
_model = _load_on_best_device(model_id)
_model_id = model_id
# 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다.
_aux_models.pop(model_id, None)
@ -433,7 +568,8 @@ async def transcribe(
partial - 녹음 중 미리보기 모드 ('true'면 greedy 디코딩 + 컨텍스트 미사용)
model_id - 쓸 모델 (비우면 기본 모델). 올라가 있지 않으면 409
"""
if model_id and model_id != _model_id:
is_primary = not (model_id and model_id != _model_id)
if not is_primary:
model = _aux_models.get(model_id)
if model is None:
return JSONResponse(
@ -442,6 +578,7 @@ async def transcribe(
)
else:
model = _model
resolved_model_id = model_id if not is_primary else _model_id
if model is None:
return JSONResponse(
status_code=503,
@ -482,35 +619,34 @@ async def transcribe(
is_partial=is_partial,
)
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
try:
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
except ValueError as ve:
if "empty sequence" in str(ve) and transcribe_kwargs.get("vad_filter"):
logger.warning("VAD가 전체 오디오를 제거함 → VAD 없이 재시도")
transcribe_kwargs["vad_filter"] = False
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
else:
segments_list, full_text, detected_language = _run_transcription(
model, audio_array, dict(transcribe_kwargs)
)
except Exception as exc:
# cuBLAS 미설치 PC 등: 검증을 통과했더라도 실제 전사에서 GPU 런타임 오류가
# 나면 이 모델을 CPU 로 다시 올려 한 번만 재시도한다.
device = _model_devices.get(resolved_model_id or "")
if not (
resolved_model_id
and device is not None
and device.is_gpu
and is_gpu_runtime_error(exc)
):
raise
logger.warning(
"전사 중 GPU 런타임 오류 → CPU 로 재로딩 후 재시도 (%s): %s",
resolved_model_id,
exc,
)
_disable_gpu(exc)
model = _reload_on_cpu(resolved_model_id, is_primary)
segments_list, full_text, detected_language = _run_transcription(
model, audio_array, dict(transcribe_kwargs)
)
segments_list: list[dict] = []
full_text_parts: list[str] = []
for segment in segments_iter:
seg_dict = {
"text": segment.text.strip(),
"start": round(segment.start, 3),
"end": round(segment.end, 3),
"avg_logprob": round(segment.avg_logprob, 4),
}
segments_list.append(seg_dict)
full_text_parts.append(segment.text.strip())
full_text = " ".join(full_text_parts).strip()
processing_time = int((time.monotonic() - start_time) * 1000)
detected_language = info.language if info.language else "unknown"
logger.info(
"전사 완료: '%s' (lang=%s, %.1f초, %dms)",
full_text[:80],