fix: split 'cuda:N' into device+device_index for CTranslate2

Colab A100 run exposed a GPU-only bug: DeviceManager returns
selected_device='cuda:0' and the engine passed it verbatim to
faster-whisper, but CTranslate2 only accepts device='cuda' with a
separate device_index arg -> 'unsupported device cuda:0' on every GPU
transcription. Fix: FasterWhisperEngine._split_device() splits
'cuda:N' -> ('cuda', N) and passes device_index to WhisperModel.

Notebook: server cell now pkills stale servers (old process holds
port 8000 and 401s on new keys since KeyStore loads at startup),
sets LUKESCRIBE_API_KEY_FILE explicitly, and verifies auth with
RAW_KEY before proceeding; worker cell guards read_result() is None.

+ 4 unit tests (device split contract), 131 tests pass, ruff clean.
This commit is contained in:
2026-08-12 17:20:12 +09:00
parent 236ca0acf4
commit 741bce9fc6
4 changed files with 171 additions and 28 deletions
+4 -4
View File
@@ -109,7 +109,7 @@
"execution_count": null, "execution_count": null,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU 폴백니다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n" "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n"
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
@@ -140,7 +140,7 @@
"execution_count": null, "execution_count": null,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\nimport time, urllib.request\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\nif not ok:\n print(open('/content/logs/server.log').read()[-1500:])\n" "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n"
}, },
{ {
"cell_type": "code", "cell_type": "code",
@@ -159,7 +159,7 @@
"execution_count": null, "execution_count": null,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nprint('status:', result.status)\nprint('text:', result.text[:120])\nprint('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n print('job 오류:', getattr(state, 'error', None) or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n"
}, },
{ {
"cell_type": "markdown", "cell_type": "markdown",
@@ -176,7 +176,7 @@
{ {
"cell_type": "markdown", "cell_type": "markdown",
"metadata": {}, "metadata": {},
"source": "## 8) 문제 해결\n\n### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\nColab CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\nCTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n그래도 안 되면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n"
} }
] ]
} }
+64 -20
View File
@@ -28,7 +28,13 @@ def md(source: str) -> dict:
def code(source: str) -> dict: def code(source: str) -> dict:
return {"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": source} return {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": source,
}
def cells() -> list[dict]: def cells() -> list[dict]:
@@ -94,7 +100,9 @@ def cells() -> list[dict]:
"print('작업 디렉터리 →', os.getcwd())\n" "print('작업 디렉터리 →', os.getcwd())\n"
"!git log --oneline -1\n" "!git log --oneline -1\n"
), ),
md("## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."), md(
"## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."
),
code( code(
"# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n" "# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n"
"!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n" "!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n"
@@ -143,15 +151,11 @@ def cells() -> list[dict]:
"CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n" "CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n"
"Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다." "Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다."
), ),
code( code("# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n!luke-scribe detect\n"),
"# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n" md(
"!luke-scribe detect\n" "## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."
),
md("## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."),
code(
"# 5) 테스트 스위트\n"
"!python -m pytest tests/ -q 2>&1 | tail -5\n"
), ),
code("# 5) 테스트 스위트\n!python -m pytest tests/ -q 2>&1 | tail -5\n"),
md( md(
"## 5) 실전 전사 (GPU)\n\n" "## 5) 실전 전사 (GPU)\n\n"
"### 5-1) 샘플 오디오 생성\n\n" "### 5-1) 샘플 오디오 생성\n\n"
@@ -167,7 +171,8 @@ def cells() -> list[dict]:
code( code(
"# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n" "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n"
"# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n" "# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n"
"# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU로 폴백합니다.\n" "# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n"
"# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n"
"import subprocess, json\n" "import subprocess, json\n"
"r = subprocess.run(\n" "r = subprocess.run(\n"
" ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n" " ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n"
@@ -202,10 +207,23 @@ def cells() -> list[dict]:
"print('raw 키 캡처 완료 (표시 안 함)')\n" "print('raw 키 캡처 완료 (표시 안 함)')\n"
), ),
code( code(
"# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n" "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n"
"# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n"
"# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\n"
"import subprocess, os, time, urllib.request, urllib.error\n"
"\n"
"assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n"
"\n"
"subprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\n"
"time.sleep(2)\n"
"\n"
"# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n"
"os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n"
"os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n"
"\n"
"!mkdir -p /content/logs\n" "!mkdir -p /content/logs\n"
"!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n"
"import time, urllib.request\n" "\n"
"ok = False\n" "ok = False\n"
"for _ in range(40):\n" "for _ in range(40):\n"
" try:\n" " try:\n"
@@ -214,9 +232,20 @@ def cells() -> list[dict]:
" break\n" " break\n"
" except Exception:\n" " except Exception:\n"
" time.sleep(1)\n" " time.sleep(1)\n"
"print('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\n"
"if not ok:\n" "if not ok:\n"
" print('서버 기동 실패 — 로그:')\n"
" print(open('/content/logs/server.log').read()[-1500:])\n" " print(open('/content/logs/server.log').read()[-1500:])\n"
" raise SystemExit('서버 기동 실패')\n"
"\n"
"# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n"
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
"try:\n"
" resp = urllib.request.urlopen(req, timeout=5)\n"
" print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\n"
"except urllib.error.HTTPError as e:\n"
" print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n"
" print(open('/content/logs/server.log').read()[-1500:])\n"
" raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n"
), ),
code( code(
"# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n" "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n"
@@ -229,7 +258,7 @@ def cells() -> list[dict]:
" ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n" " ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n"
" '-H', f'X-API-Key: {RAW_KEY}',\n" " '-H', f'X-API-Key: {RAW_KEY}',\n"
" '-F', 'file=@samples/colab-ko-en.mp3',\n" " '-F', 'file=@samples/colab-ko-en.mp3',\n"
" '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n" ' \'-F\', \'options={"language":"ko","formats":["json","srt"]}\'],\n'
" capture_output=True, text=True,\n" " capture_output=True, text=True,\n"
")\n" ")\n"
"job = json.loads(r.stdout)\n" "job = json.loads(r.stdout)\n"
@@ -281,6 +310,13 @@ def cells() -> list[dict]:
"worker.drain()\n" "worker.drain()\n"
"\n" "\n"
"result = store.read_result(job.id)\n" "result = store.read_result(job.id)\n"
"if result is None:\n"
" # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n"
" state = broker.get(job.id)\n"
" print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n"
" if state is not None:\n"
" print('job 오류:', getattr(state, 'error', None) or '없음')\n"
"else:\n"
" print('status:', result.status)\n" " print('status:', result.status)\n"
" print('text:', result.text[:120])\n" " print('text:', result.text[:120])\n"
" print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" " print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n"
@@ -310,11 +346,19 @@ def cells() -> list[dict]:
), ),
md( md(
"## 8) 문제 해결\n\n" "## 8) 문제 해결\n\n"
"### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\n" "### 전사 실패: `unsupported device cuda:0`\n\n"
"Colab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\n" '**근본 원인**: v0.1 엔진이 `device="cuda:0"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\n'
"CTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n" 'CTranslate2는 `device="cuda"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n'
"설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n" "→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n"
"그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" "참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n"
"런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n"
"`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n"
"그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n"
"### API 키 인증 실패 (401)\n\n"
"1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n"
" (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n"
"2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n"
" 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n"
"### 모델 다운로드 실패\n\n" "### 모델 다운로드 실패\n\n"
"- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n"
"- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n"
@@ -86,10 +86,15 @@ class FasterWhisperEngine(TranscriptionEngine):
with self._lock: with self._lock:
model = self._models.get(model_key) model = self._models.get(model_key)
if model is None: if model is None:
# CTranslate2는 device 문자열로 "cuda"만 허용한다 ("cuda:0" 형태는
# "unsupported device cuda:0" 오류). DeviceManager가 내려주는
# "cuda:N"을 device + device_index로 분리해 전달한다.
device_name, device_index = self._split_device(options.device)
try: try:
model = WhisperModel( model = WhisperModel(
options.model, options.model,
device=options.device, device=device_name,
device_index=device_index,
compute_type=options.compute_type, compute_type=options.compute_type,
download_root=self._download_root(), download_root=self._download_root(),
) )
@@ -128,6 +133,20 @@ class FasterWhisperEngine(TranscriptionEngine):
wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False))
return TranscriptionOutcome(wrapped, info=info) return TranscriptionOutcome(wrapped, info=info)
@staticmethod
def _split_device(device: str) -> tuple[str, int]:
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
'cpu'/'cuda' 같은 인덱스 없는 값은 그대로 (device, 0)을 반환한다.
"""
if device.startswith("cuda") and ":" in device:
name, _, idx = device.partition(":")
try:
return name, int(idx)
except ValueError:
pass
return device, 0
def _download_root(self) -> str | None: def _download_root(self) -> str | None:
from ..config import get_settings from ..config import get_settings
+80
View File
@@ -0,0 +1,80 @@
"""FasterWhisperEngine 단위 테스트 — CTranslate2 호출 계약 (device 분리).
faster-whisper/CTranslate2는 ``device`` 문자열로 "cuda"만 허용하며 인덱스는
별도 ``device_index`` 인자로 받는다. DeviceManager가 내려주는 "cuda:N"
제대로 분리해 전달하는지 mock으로 검증한다 (GPU 환경에서만 재현되는 버그
— Colab 실전 테스트에서 ``unsupported device cuda:0``으로 확인됨).
"""
from __future__ import annotations
import sys
import types
from luke_scribe.engine.base import TranscriptionOptions
from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine
class FakeWhisperModel:
"""faster_whisper.WhisperModel 대체 — 생성 인자만 기록한다."""
calls: list[dict] = []
def __init__(self, *args, **kwargs) -> None:
self.kwargs = kwargs
self.__class__.calls.append(kwargs)
def transcribe(self, audio_path, **kwargs):
return iter([]), {}
def _install_fake(monkeypatch) -> None:
mod = types.ModuleType("faster_whisper")
mod.WhisperModel = FakeWhisperModel
monkeypatch.setitem(sys.modules, "faster_whisper", mod)
FakeWhisperModel.calls.clear()
def _opts(**kw) -> TranscriptionOptions:
kw.setdefault("model", "large-v3-turbo")
kw.setdefault("device", "cuda:0")
kw.setdefault("compute_type", "float16")
return TranscriptionOptions(**kw)
def test_cuda_device_index_split(monkeypatch):
"""'cuda:0' → device='cuda', device_index=0 (Colab A100 첫 GPU)."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts())
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cuda"
assert kwargs["device_index"] == 0
assert kwargs["compute_type"] == "float16"
def test_cuda_second_device_index(monkeypatch):
"""'cuda:1' → device='cuda', device_index=1."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cuda:1"))
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cuda"
assert kwargs["device_index"] == 1
def test_cpu_passthrough(monkeypatch):
"""'cpu' → 그대로 device='cpu', device_index=0."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cpu", compute_type="int8"))
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cpu"
assert kwargs["device_index"] == 0
assert kwargs["compute_type"] == "int8"
def test_split_device_helper():
assert FasterWhisperEngine._split_device("cuda:0") == ("cuda", 0)
assert FasterWhisperEngine._split_device("cuda:3") == ("cuda", 3)
assert FasterWhisperEngine._split_device("cpu") == ("cpu", 0)
assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0)
# 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit)
assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0)