로컬 whisper STT 사이드카 디바이스 폴백과 deprecated audioop 제거
cuDNN 부재 환경에서 ctranslate2 는 Python 예외가 아니라 네이티브 크래시로 죽어 프로세스가 통째로 사라진다. 같은 프로세스의 try/except 로는 절대 잡을 수 없어 디바이스 확인을 버릴 수 있는 자식 프로세스(--self-check)로 분리했다. auto 는 CUDA 프로브 실패 시 CPU(int8)로 폴백하고, 명시적 --device cuda 는 조용히 강등하지 않는다. 함께 고친 것: - warmup PCM 생성의 연산자 우선순위 버그(b"\x00\x00" * N // 2 가 바이트열 정수 나눗셈이 되어 TypeError). warmup_pcm() 으로 분리하고 테스트로 고정했다. - audioop 은 Python 3.13 에서 제거되므로 array + 정수 연산으로 RMS 를 직접 구한다. 실측(저장소 무참조 synthetic seed 8.72초, CPU int8 small): interim 9 · final 5 · word timestamp 12개 present, 전사는 원문과 한 글자 차이. 사이드카 회귀 37/37.
This commit is contained in:
parent
16e791e044
commit
05aa7b312e
9 changed files with 252 additions and 30 deletions
|
|
@ -29,6 +29,7 @@ import math
|
|||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Protocol
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
|
@ -447,31 +448,66 @@ def resolve_device(requested: str, *, cuda_available: bool | None = None) -> tup
|
|||
return ("cuda", "float16") if cuda_available else ("cpu", "int8")
|
||||
|
||||
|
||||
def probe_device(model: str, device: str, *, python: str | None = None) -> bool:
|
||||
"""별도 프로세스에서 디바이스를 실제로 한 번 돌려 본다.
|
||||
|
||||
cuDNN 이 없으면 ctranslate2 는 Python 예외가 아니라 **네이티브 크래시**로
|
||||
죽는다. 같은 프로세스의 try/except 로는 절대 잡을 수 없으므로, 살릴 수 없는
|
||||
실패는 버릴 수 있는 자식 프로세스에서 먼저 확인한다.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
argv = [
|
||||
python or sys.executable,
|
||||
"-X",
|
||||
"utf8",
|
||||
str(Path(__file__).resolve()),
|
||||
"--self-check",
|
||||
"--model",
|
||||
model,
|
||||
"--device",
|
||||
device,
|
||||
]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
shell=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
def build_transcriber(
|
||||
model: str,
|
||||
requested_device: str,
|
||||
*,
|
||||
factory: Callable[[str, str, str], Any],
|
||||
cuda_available: bool | None = None,
|
||||
prober: Callable[[str, str], bool] | None = None,
|
||||
) -> Any:
|
||||
"""디바이스를 해석하고 warmup 까지 통과한 transcriber 만 돌려준다.
|
||||
"""디바이스를 해석하고 실제로 추론이 되는 transcriber 만 돌려준다.
|
||||
|
||||
`auto` 는 CUDA 가 보이면 먼저 시도하되, 첫 추론이 실패하면 CPU 로 내려간다.
|
||||
명시적 `--device cuda` 는 조용히 강등하지 않고 그대로 실패시킨다.
|
||||
`auto` 는 CUDA 가 보이면 먼저 자식 프로세스로 확인하고, 거기서 실패하면
|
||||
CPU 로 내려간다. 명시적 `--device cuda` 는 조용히 강등하지 않는다.
|
||||
"""
|
||||
|
||||
device, compute_type = resolve_device(requested_device, cuda_available=cuda_available)
|
||||
check = prober if prober is not None else (lambda m, d: probe_device(m, d))
|
||||
if device == "cuda" and not check(model, "cuda"):
|
||||
if requested_device != "auto":
|
||||
raise TranscriptionError("device_unusable:cuda")
|
||||
device, compute_type = "cpu", "int8"
|
||||
try:
|
||||
transcriber = factory(model, device, compute_type)
|
||||
transcriber.warmup()
|
||||
return transcriber
|
||||
except Exception as exc:
|
||||
if requested_device != "auto" or device == "cpu":
|
||||
raise TranscriptionError(f"device_unusable:{device}") from exc
|
||||
device, compute_type = "cpu", "int8"
|
||||
transcriber = factory(model, device, compute_type)
|
||||
transcriber.warmup()
|
||||
return transcriber
|
||||
raise TranscriptionError(f"device_unusable:{device}") from exc
|
||||
|
||||
|
||||
async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진입점
|
||||
|
|
@ -573,11 +609,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
parser.add_argument("--model", default=DEFAULT_MODEL, choices=list(ALLOWED_MODELS))
|
||||
parser.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"])
|
||||
parser.add_argument("--enable", action="store_true")
|
||||
parser.add_argument(
|
||||
"--self-check",
|
||||
action="store_true",
|
||||
help="load the model on --device once and exit; used as a crash-safe probe",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run_self_check(args: argparse.Namespace) -> int: # pragma: no cover - 서브프로세스
|
||||
device, compute_type = resolve_device(
|
||||
"cpu" if args.device == "cpu" else args.device
|
||||
)
|
||||
transcriber = FasterWhisperTranscriber(
|
||||
args.model, device=device, compute_type=compute_type
|
||||
)
|
||||
transcriber.warmup()
|
||||
print(json.dumps({"ok": True, "device": device}, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int: # pragma: no cover - CLI
|
||||
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
||||
if args.self_check:
|
||||
try:
|
||||
return run_self_check(args)
|
||||
except Exception as exc:
|
||||
print(json.dumps({"ok": False, "error": type(exc).__name__}), file=sys.stderr)
|
||||
return 3
|
||||
if not args.enable:
|
||||
print("local whisper STT server is disabled; pass --enable", file=sys.stderr)
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -326,42 +326,72 @@ class _FakeTranscriberFactory:
|
|||
|
||||
|
||||
class TranscriberBuildTest(unittest.TestCase):
|
||||
def test_auto_falls_back_to_cpu_when_cuda_warmup_fails(self) -> None:
|
||||
"""cuDNN 부재처럼 '장치는 보이지만 추론이 죽는' 경우를 잡는다."""
|
||||
def test_auto_falls_back_to_cpu_when_the_cuda_probe_fails(self) -> None:
|
||||
"""cuDNN 부재는 네이티브 크래시라 자식 프로세스 프로브로만 잡힌다."""
|
||||
|
||||
factory = _FakeTranscriberFactory()
|
||||
probes: list[tuple[str, str]] = []
|
||||
|
||||
def prober(model: str, device: str) -> bool:
|
||||
probes.append((model, device))
|
||||
return False
|
||||
|
||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
||||
transcriber = MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=True
|
||||
"small", "auto", factory=factory, cuda_available=True, prober=prober
|
||||
)
|
||||
self.assertEqual(probes, [("small", "cuda")])
|
||||
self.assertEqual(transcriber.device, "cpu")
|
||||
self.assertEqual(transcriber.compute_type, "int8")
|
||||
self.assertEqual(
|
||||
factory.built, [("small", "cuda", "float16"), ("small", "cpu", "int8")]
|
||||
)
|
||||
# CUDA 로는 아예 모델을 올리지 않는다. 올렸다면 그 자리에서 죽는다.
|
||||
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||
|
||||
def test_auto_keeps_cuda_when_warmup_succeeds(self) -> None:
|
||||
def test_auto_keeps_cuda_when_the_probe_succeeds(self) -> None:
|
||||
factory = _FakeTranscriberFactory()
|
||||
transcriber = MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=True
|
||||
"small",
|
||||
"auto",
|
||||
factory=factory,
|
||||
cuda_available=True,
|
||||
prober=lambda model, device: True,
|
||||
)
|
||||
self.assertEqual(transcriber.device, "cuda")
|
||||
self.assertEqual(len(factory.built), 1)
|
||||
|
||||
def test_explicit_cuda_never_downgrades_silently(self) -> None:
|
||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
MODULE.build_transcriber(
|
||||
"small", "cuda", factory=factory, cuda_available=True
|
||||
)
|
||||
self.assertEqual(factory.built, [("small", "cuda", "float16")])
|
||||
|
||||
def test_cpu_warmup_failure_is_not_retried(self) -> None:
|
||||
def test_explicit_cuda_never_downgrades_silently(self) -> None:
|
||||
factory = _FakeTranscriberFactory()
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
MODULE.build_transcriber(
|
||||
"small",
|
||||
"cuda",
|
||||
factory=factory,
|
||||
cuda_available=True,
|
||||
prober=lambda model, device: False,
|
||||
)
|
||||
self.assertEqual(factory.built, [])
|
||||
|
||||
def test_cpu_path_is_not_probed(self) -> None:
|
||||
factory = _FakeTranscriberFactory()
|
||||
probes: list[tuple[str, str]] = []
|
||||
MODULE.build_transcriber(
|
||||
"small",
|
||||
"cpu",
|
||||
factory=factory,
|
||||
cuda_available=True,
|
||||
prober=lambda model, device: probes.append((model, device)) or True,
|
||||
)
|
||||
self.assertEqual(probes, [])
|
||||
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||
|
||||
def test_python_level_warmup_failure_still_fails_closed(self) -> None:
|
||||
factory = _FakeTranscriberFactory(failing_device="cpu")
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=False
|
||||
"small",
|
||||
"auto",
|
||||
factory=factory,
|
||||
cuda_available=False,
|
||||
prober=lambda model, device: True,
|
||||
)
|
||||
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||
|
||||
|
||||
class CliTest(unittest.TestCase):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue