fix(sidecar): fall back to CPU when the CUDA runtime is unusable
This commit is contained in:
parent
96b24e279c
commit
4588b65dfa
3 changed files with 605 additions and 37 deletions
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue