commit e5a6694aa460bafea2eca438f17ac22a11701b24 Author: paruparu <25531141+paruparu@users.noreply.github.com> Date: Tue Dec 23 23:53:33 2025 +0900 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dec2aef --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +model_cache/ +docker/__pycache__/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecc2fc3 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# whisper-test (faster-whisper on DGX Spark/ThinkStation PGX) + +NVIDIA DGX Spark 相当環境(Lenovo ThinkStation PGX)で、`faster-whisper` を CUDA で確実に動かすための最小構成をまとめたリポジトリです。ホスト側 CUDA 13 が既に入っており変更しづらい研究用マシンでも、Docker 上で CUDA 12.4 + cuDNN 9 でビルドした CTranslate2 を用いて GPU 推論を再現できます。 + +公式 faster-whisper リポジトリ: https://github.com/SYSTRAN/faster-whisper + +## なぜこの構成が必要か +- `faster-whisper` は CTranslate2 を利用し、ビルド時の CUDA/cuDNN と実行環境が一致していないと GPU 版が動作しない。 +- ThinkStation PGX ではホストに CUDA 13 が入っており、`pip install ctranslate2` すると CPU 版や CUDA 非対応ビルドが入ることがある。 +- 公式手順だけでは CUDA 版が入らず、`ValueError: This CTranslate2 package was not compiled with CUDA support` となるケースがあった。 +- そこで CUDA 12.4 + cuDNN 9 を明示した Docker で CTranslate2 をソースビルドし、Python バインディングを明示的に再インストールすることで再現性を確保。 + +## 提供物 +- `docker-compose.yml` と `docker/Dockerfile` による GPU 対応コンテナ構成 + - ベース: `nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04` + - CTranslate2 v4.6.2 を CUDA 有効でソースビルド + - `pip` で入る CPU 版 `ctranslate2` を一度アンインストールし、ビルド済み Python バインディングを再インストール +- FastAPI + Uvicorn の簡易サーバ (`docker/server.py`) + - `POST /transcribe` で音声を受け取り、Whisper 推論結果を返却 + - `GET /health` でモデル名・デバイス・compute_type を返却 +- サンプル音声とテストスクリプト(`test/`) + +## 仕組みのポイント +- CUDA 12.x + cuDNN 9 で CTranslate2 をビルドし、実行時も同一バージョンを保証 +- `entrypoint.sh` で `LD_LIBRARY_PATH` を明示設定し、`nvidia.cublas.lib` / `nvidia.cudnn.lib` のパスを動的に追加 +- ホスト CUDA 13 と切り離すため Docker を必須とし、NVIDIA Container Toolkit を前提に GPU を渡す + +## 前提条件 +- NVIDIA Container Toolkit 導入済み +- GPU ドライバがホストに正しく入っていること +- ホスト側 CUDA は 13 系のままで可(変更不要) + +### ローカルCPU実行(任意) +Docker を使わず手元の Python (.venv) で CPU 推論だけ試す場合の簡易手順: +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r docker/requirements.txt + +# CPU でサンプル音声を実行 +FW_DEVICE=cpu FW_COMPUTE_TYPE=int8 python3 test/transcribe_cpu.py +``` +※ CPU のみの確認用。GPU を使う場合は Docker 構成を推奨。 + +## セットアップ +```bash +# ビルド +docker compose build + +# 起動(GPU 必須) +docker compose up -d + +# ヘルスチェック +curl http://localhost:8002/health +``` + +## 推論APIの使い方 +```bash +curl -X POST http://localhost:8002/transcribe \ + -F "file=@test/003.wav" \ + -F "language=ja" \ + -F "beam_size=5" \ + -F "vad_filter=true" +``` +レスポンスには `text` と `segments`(start/end/text)、`language`, `language_probability` が含まれます。 + +環境変数でモデルや実行設定を変更できます: +- `FW_MODEL` (デフォルト `large-v3`) +- `FW_DEVICE` (デフォルト `cuda`) +- `FW_COMPUTE_TYPE` (デフォルト `float16`) + +### Docker コンテナ内で `test/` のスクリプトを実行する例 +ホストに Python を入れずに検証したい場合、起動済みコンテナ内でテストスクリプトを実行できます。 +```bash +# 1) コンテナに入る(compose が起動している前提) +docker compose exec whisper bash + +# 2) GPU 版サンプル(デフォルトの large-v3 / float16) +python3 test/transcribe.py + +# 3) CPU で試したい場合(compute_type を切り替え) +FW_DEVICE=cpu FW_COMPUTE_TYPE=int8 python3 test/transcribe_cpu.py +``` +いずれも `test/003.wav` を入力にし、標準出力へテキストと各セグメントを表示します。自分の音声で試す場合は `python3 test/transcribe.py /path/to/your.wav` のようにパスを指定してください。 + +## ディレクトリ構成(抜粋) +- `docker-compose.yml` — サービス定義(GPU リクエスト含む) +- `docker/Dockerfile` — CUDA 12.4 + cuDNN 9 ベースで CTranslate2 をソースビルド +- `docker/entrypoint.sh` — CUDA ライブラリパスを動的設定し uvicorn 起動 +- `docker/server.py` — FastAPI サーバ(/health, /transcribe) +- `docker/requirements.txt` — 最小依存: `faster-whisper`, `fastapi`, `uvicorn[standard]`, `python-multipart` +- `test/` — サンプル音声とローカル実行スクリプト + +## 再現のための注意点 +- `pip install faster-whisper` だけでは CUDA 版 `ctranslate2` は保証されない +- CUDA バージョン不一致だとインストール成功しても実行時に GPU を掴まない +- 本 Dockerfile は一度 CPU 版をアンインストールし、ビルド済み CUDA 版を再インストールして整合性を取る + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5c5fb3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + whisper: + build: + context: . + dockerfile: docker/Dockerfile + image: whisper-test-whisper + command: uvicorn docker.server:app --host 0.0.0.0 --port 8000 + entrypoint: ["/app/docker/entrypoint.sh"] + ports: + - "8002:8000" + volumes: + - ./:/app + deploy: + resources: + reservations: + devices: + - capabilities: [gpu] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..d2ee68c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,48 @@ +FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04 + + +ENV DEBIAN_FRONTEND=noninteractive +WORKDIR /app + +# build deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-dev \ + git cmake ninja-build build-essential pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# python basics +RUN python3 -m pip install -U pip setuptools wheel + +# Build CTranslate2 from source with CUDA ON (and submodules!) +ARG CT2_VERSION=v4.6.2 +RUN git clone --depth 1 --branch v4.6.2 --recurse-submodules \ + https://github.com/OpenNMT/CTranslate2.git /tmp/ctranslate2 && \ + cd /tmp/ctranslate2 && \ + mkdir build && cd build && \ + cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_CUDA=ON \ + -DWITH_CUDNN=ON \ + -DWITH_MKL=OFF \ + -DOPENMP_RUNTIME=COMP \ + -DCMAKE_CUDA_ARCHITECTURES=native \ + -GNinja && \ + ninja -j$(nproc) && \ + ninja install && \ + ldconfig + + + +# app deps +COPY docker/requirements.txt /app/requirements.txt +RUN python3 -m pip install -r /app/requirements.txt +RUN python3 -m pip uninstall -y ctranslate2 && \ + cd /tmp/ctranslate2/python && \ + python3 -m pip install . + +COPY docker/server.py /app/server.py +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..200190d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[entrypoint] Python:" "$(python3 --version || true)" +echo "[entrypoint] pip packages (nvidia-*):" +python3 - <<'PY' || true +import pkgutil +names = sorted({m.name for m in pkgutil.iter_modules() if m.name.startswith("nvidia")}) +print("\n".join(names) if names else "(none)") +PY + +NVPATHS="$(python3 - <<'PY' +import os, sys + +def try_mod(modname): + try: + m = __import__(modname, fromlist=["*"]) + f = getattr(m, "__file__", None) + print(f"[entrypoint] {modname}.__file__ = {f}") + if not f: + return None + return os.path.dirname(f) + except Exception as e: + print(f"[entrypoint] import failed: {modname}: {e}", file=sys.stderr) + return None + +paths = [] +for mod in ("nvidia.cublas.lib", "nvidia.cudnn.lib"): + d = try_mod(mod) + if d: + paths.append(d) + +print(":".join(paths)) +PY +)" + +BASE="/usr/local/cuda/lib64:/usr/local/lib" +if [[ -n "${NVPATHS}" ]]; then + export LD_LIBRARY_PATH="${BASE}:${NVPATHS}:${LD_LIBRARY_PATH:-}" + echo "[entrypoint] LD_LIBRARY_PATH set with NVPATHS" +else + export LD_LIBRARY_PATH="${BASE}:${LD_LIBRARY_PATH:-}" + echo "[entrypoint] NVPATHS empty; using CUDA base paths only" +fi + +echo "[entrypoint] LD_LIBRARY_PATH=${LD_LIBRARY_PATH}" + +exec "$@" diff --git a/docker/requirements.txt b/docker/requirements.txt new file mode 100644 index 0000000..16424af --- /dev/null +++ b/docker/requirements.txt @@ -0,0 +1,5 @@ +faster-whisper +fastapi +uvicorn[standard] +python-multipart + diff --git a/docker/server.py b/docker/server.py new file mode 100644 index 0000000..57c505a --- /dev/null +++ b/docker/server.py @@ -0,0 +1,61 @@ +import os +import tempfile +from typing import Optional + +from fastapi import FastAPI, UploadFile, File, Form +from fastapi.responses import JSONResponse +from faster_whisper import WhisperModel + +MODEL_NAME = os.getenv("FW_MODEL", "large-v3") +DEVICE = os.getenv("FW_DEVICE", "cuda") +COMPUTE_TYPE = os.getenv("FW_COMPUTE_TYPE", "float16") + +app = FastAPI() + +# 起動時にモデルをロードして常駐(ここが重要:毎回ロードしない) +model = WhisperModel(MODEL_NAME, device=DEVICE, compute_type=COMPUTE_TYPE) + +@app.get("/health") +def health(): + return {"status": "ok", "model": MODEL_NAME, "device": DEVICE, "compute_type": COMPUTE_TYPE} + +@app.post("/transcribe") +async def transcribe( + file: UploadFile = File(...), + language: Optional[str] = Form(default=None), # "ja"など + beam_size: int = Form(default=5), + vad_filter: bool = Form(default=True), +): + # アップロードを一時ファイルに保存(PyAVがファイルパスを扱いやすい) + suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp_path = tmp.name + tmp.write(await file.read()) + + try: + segments, info = model.transcribe( + tmp_path, + language=language, + beam_size=beam_size, + vad_filter=vad_filter, + ) + + out_segments = [] + text_all = [] + for s in segments: + out_segments.append({"start": s.start, "end": s.end, "text": s.text}) + text_all.append(s.text) + + return JSONResponse( + { + "language": info.language, + "language_probability": info.language_probability, + "text": "".join(text_all).strip(), + "segments": out_segments, + } + ) + finally: + try: + os.remove(tmp_path) + except OSError: + pass diff --git a/test/003.wav b/test/003.wav new file mode 100644 index 0000000..79922cf Binary files /dev/null and b/test/003.wav differ diff --git a/test/transcribe.py b/test/transcribe.py new file mode 100644 index 0000000..8b2d75d --- /dev/null +++ b/test/transcribe.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# test/transcribe_test.py + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +from faster_whisper import WhisperModel + + +# === ここを自分のwavに合わせて書き換え === +WAV_PATH = Path(__file__).resolve().parent / "003.wav" +MODEL_NAME = "large-v3" +LANGUAGE = "ja" +BEAM_SIZE = 5 +COMPUTE_TYPE = "float16" # GPU想定 +# ======================================= + + +def pick_device(): + return "cuda", COMPUTE_TYPE + + +def main() -> int: + wav_path = WAV_PATH + if len(sys.argv) >= 2: + wav_path = Path(sys.argv[1]).expanduser().resolve() + + if not wav_path.exists(): + print(f"[ERROR] wav not found: {wav_path}", file=sys.stderr) + return 2 + + device, compute_type = pick_device() + print(f"[INFO] model={MODEL_NAME} device={device} compute_type={compute_type}") + print(f"[INFO] input={wav_path}") + + model = WhisperModel( + MODEL_NAME, + device=device, + compute_type=compute_type, + ) + + # ==== 計測開始 ==== + t0 = time.perf_counter() + + segments, info = model.transcribe( + str(wav_path), + language=LANGUAGE, + beam_size=BEAM_SIZE, + # vad_filter=True, + ) + + segments = list(segments) # ここで実際に推論が走る + + # ==== 計測終了 ==== + t1 = time.perf_counter() + elapsed = t1 - t0 + + print(f"\n[INFO] detected_language={info.language} (p={info.language_probability:.3f})") + print(f"[INFO] transcription_time={elapsed:.3f} sec") + print("[RESULT] --- transcript ---") + for seg in segments: + print(f"[{seg.start:7.2f}-{seg.end:7.2f}] {seg.text}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/test/transcribe_cpu.py b/test/transcribe_cpu.py new file mode 100644 index 0000000..274b0c6 --- /dev/null +++ b/test/transcribe_cpu.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# test/transcribe_test.py + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +from faster_whisper import WhisperModel + + +# === ここを自分のwavに合わせて書き換え === +WAV_PATH = Path(__file__).resolve().parent / "003.wav" +MODEL_NAME = "large-v3" +LANGUAGE = "ja" +BEAM_SIZE = 5 +COMPUTE_TYPE = "float32" # GPU想定 +# ======================================= + + +def pick_device(): + return "cpu", COMPUTE_TYPE + + +def main() -> int: + wav_path = WAV_PATH + if len(sys.argv) >= 2: + wav_path = Path(sys.argv[1]).expanduser().resolve() + + if not wav_path.exists(): + print(f"[ERROR] wav not found: {wav_path}", file=sys.stderr) + return 2 + + device, compute_type = pick_device() + print(f"[INFO] model={MODEL_NAME} device={device} compute_type={compute_type}") + print(f"[INFO] input={wav_path}") + + model = WhisperModel( + MODEL_NAME, + device=device, + compute_type=compute_type, + ) + + # ==== 計測開始 ==== + t0 = time.perf_counter() + + segments, info = model.transcribe( + str(wav_path), + language=LANGUAGE, + beam_size=BEAM_SIZE, + # vad_filter=True, + ) + + segments = list(segments) # ここで実際に推論が走る + + # ==== 計測終了 ==== + t1 = time.perf_counter() + elapsed = t1 - t0 + + print(f"\n[INFO] detected_language={info.language} (p={info.language_probability:.3f})") + print(f"[INFO] transcription_time={elapsed:.3f} sec") + print("[RESULT] --- transcript ---") + for seg in segments: + print(f"[{seg.start:7.2f}-{seg.end:7.2f}] {seg.text}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file