diff --git a/docs/PRD.md b/docs/PRD.md index 243a5f5..100682e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -120,7 +120,7 @@ transcribe <путь_к_файлу> [опции] - Python ≥ 3.10 - ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов) -- Для GPU: CUDA toolkit (cuBLAS, cuDNN) — ставится автоматически через CTranslate2 +- Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001) - Дисковое пространство для моделей: ~3 GB (large-v3) - Выходные файлы: UTF-8 (явная кодировка при записи) diff --git a/docs/adr/001-cuda-bootstrap.md b/docs/adr/001-cuda-bootstrap.md new file mode 100644 index 0000000..f303306 --- /dev/null +++ b/docs/adr/001-cuda-bootstrap.md @@ -0,0 +1,78 @@ +# ADR-001: Preload libcublas из pip-пакета для GPU на Linux/WSL2 + +**Статус**: Принято +**Дата**: 2026-03-18 + +## Контекст + +ctranslate2 (backend faster-whisper) в runtime делает `dlopen("libcublas.so.12")`, +но не бандлит эту библиотеку в свой wheel — ожидает её в системе. +Без установленного CUDA toolkit `uv run transcribe --device cuda` падает с ошибкой. + +Ключевые факты: +- `libcuda.so.1` приходит от NVIDIA driver (всегда есть, если GPU есть) +- `libcublas.so.12` отсутствует в wheel ctranslate2 на обеих платформах +- **cuDNN не нужен** — в `libctranslate2.so` ноль символов cudnn (проверено `nm -D` и `strings`) +- На Windows ctranslate2 делает `os.add_dll_directory` в своём `__init__.py`, + но только для DLL внутри пакета; `cublas64_12.dll` тоже не бандлится + +## Решение + +### nvidia-cublas-cu12 как pip-зависимость + +В `pyproject.toml`: +``` +"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'" +``` + +Нижняя граница `>=12.4` — ctranslate2 собран с CUDA 12.4. Пакет доступен только +для Linux x86_64 (на Windows и macOS не устанавливается по platform marker). + +### ctypes.CDLL preload вместо LD_LIBRARY_PATH + +`_cuda_bootstrap.py` загружает `libcublas.so.12` по полному пути через +`ctypes.CDLL(path, mode=RTLD_GLOBAL)` **до** первого `import ctranslate2`. + +Почему не `os.environ["LD_LIBRARY_PATH"]`: на Linux/glibc динамический линкер (`ld.so`) +кеширует пути поиска при старте процесса и **не перечитывает** `LD_LIBRARY_PATH` +из environ в рамках уже запущенного процесса. + +Почему `RTLD_GLOBAL`: без этого флага символы cublas не видны другим `.so`, +загруженным позже (в т.ч. `libctranslate2.so`). + +Динамический линкер кеширует загруженные библиотеки по soname — когда ctranslate2 +потом вызовет `dlopen("libcublas.so.12")`, линкер вернёт уже загруженный handle. + +### strict_device для явного --device + +`--device cuda` / `--device cpu` → `strict_device=True` → CUDA-ошибка = raise, без fallback. +`--device auto` → `strict_device=False` → текущее поведение с fallback на CPU. + +Мотивация: silent fallback для часового файла = 60 минут вместо 5. + +## Последствия и tradeoffs + +**~400 MB на CPU-only Linux x86_64**: nvidia-cublas-cu12 ставится на все Linux x86_64, +включая машины без GPU. Bootstrap при этом preload'ит libcublas (overhead ~1 ms), +но она не используется, т.к. ctranslate2 не получит запрос на CUDA device. + +Альтернатива — `[project.optional-dependencies]` + `uv sync --extra cuda`, +но тогда теряется zero-config UX. Для v1 оставляем как обязательную зависимость. + +**Windows GPU**: nvidia-cublas-cu12 недоступен как pip-пакет для Windows. +Единственный путь — системный CUDA toolkit (`choco install cuda` / `winget install -e --id Nvidia.CUDA`). +CLI выводит эту подсказку при CUDA-ошибке на `sys.platform == "win32"`. + +**Namespace package**: `nvidia.cublas` — namespace package (`__file__` is `None`), +для определения директории используется `__path__[0]`, а не `__file__`. + +## Отклонённые альтернативы + +| Альтернатива | Почему отклонена | +|---|---| +| `nvidia-cudnn-cu12` в зависимостях | ctranslate2 не использует cuDNN — ноль символов, проверено через `nm -D` | +| Self-reexec с `LD_LIBRARY_PATH` | ctypes.CDLL решает задачу без перезапуска процесса; self-reexec создаёт проблемы с сигналами, tty, fd | +| `os.environ["LD_LIBRARY_PATH"] += ...` | glibc кеширует пути при старте, не перечитывает environ | +| `DeviceResolution` dataclass + `resolve_device()` | Один `bool strict_device` решает ту же задачу проще | +| ctranslate2 preflight (`get_supported_compute_types`) | Латентность; try/catch при загрузке модели не хуже | +| Bootstrap console_scripts entrypoint | Не нужен — достаточно вызова в начале `transcriber.py` | diff --git a/docs/plan.md b/docs/plan.md index f0777f5..8137b9e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -241,6 +241,20 @@ **Критерий готовности**: `uv run transcribe test.mp3` — создаёт корректный .md файл (проверить на локальной машине с реальным файлом). +--- + +## Шаг 5.1: GPU runtime — прозрачная работа CUDA на Linux/WSL2 + +> Детали решения и обоснование: [ADR-001](adr/001-cuda-bootstrap.md) + +- [x] `nvidia-cublas-cu12>=12.4` в dependencies (Linux x86_64) +- [x] `_cuda_bootstrap.py` — preload libcublas через `ctypes.CDLL(RTLD_GLOBAL)` до импорта ctranslate2 +- [x] `strict_device` в transcriber — `--device cuda/cpu` без silent fallback +- [x] CLI: диагностика requested vs resolved device, Windows CUDA-подсказка +- [x] Тесты: bootstrap (unit + интеграционный), strict_device, Windows-диагностика + +**Критерий готовности**: `uv run pytest -v` — 52 passed, 1 skipped. На GPU-машине без системного CUDA toolkit: `uv run transcribe test.mp3 --device cuda` работает. + --- ## Шаг 6: Error handling и UX polish diff --git a/docs/review-stages-1-3.md b/docs/review-stages-1-3.md deleted file mode 100644 index 5b829ac..0000000 --- a/docs/review-stages-1-3.md +++ /dev/null @@ -1,141 +0,0 @@ -# Review: Stages 1-3 - -## Executive Summary - -| Severity | Count | -|----------|-------| -| CRITICAL | 0 | -| HIGH | 0 | -| MEDIUM | 2 | -| LOW | 1 | - -**Overall Risk:** MEDIUM -**Recommendation:** CONDITIONAL - -**Key Metrics:** -- Files analyzed: 10/10 -- Lines changed: +427 / -0 -- Test coverage gaps: 3 behaviors -- High blast radius changes: 0 -- Security regressions detected: 0 - -## What Changed - -**Commit Range:** `e0fcf43..WORKTREE` -**Commits:** `ade3b23`, `510d6cc`, plus uncommitted stage 3 changes - -| File | +Lines | -Lines | Risk | Notes | -|------|--------|--------|------|-------| -| pyproject.toml | 28 | 0 | LOW | Project scaffold and dependency wiring | -| src/local_transcriber/cli.py | 14 | 0 | LOW | Step 1 placeholder CLI | -| src/local_transcriber/formatter.py | 23 | 0 | LOW | Step 1 placeholder formatter | -| src/local_transcriber/transcriber.py | 99 | 0 | MEDIUM | Core transcription flow and fallback logic | -| src/local_transcriber/utils.py | 68 | 0 | MEDIUM | Environment checks and input validation | -| tests/test_transcriber.py | 121 | 0 | LOW | Mock tests for step 3 | -| tests/test_utils.py | 74 | 0 | LOW | Unit tests for step 2 | - -## Findings - -### MEDIUM: CUDA fallback does not trigger when `model.transcribe()` fails before returning a generator - -**File:** `src/local_transcriber/transcriber.py:51` -**Test Coverage:** PARTIAL - -`transcribe()` wraps exceptions from `model.transcribe(...)` into `RuntimeError`, but the CPU fallback exists only in the later generator-iteration branch. If `faster-whisper` raises a CUDA or OOM error during `model.transcribe(...)` itself, the function exits instead of retrying on CPU. - -This is a direct mismatch with the step 3 requirement to fall back on CUDA/OOM failures. - -**Reproduction:** -- Mock `WhisperModel(...).transcribe` to raise `RuntimeError("CUDA kernel launch failed")` -- Current result: `RuntimeError("Ошибка при транскрипции файла ...")` -- Expected result: warning + retry on CPU - -**Recommendation:** -- Apply the same `_is_cuda_error()` fallback path around `model.transcribe(...)`, not only around iteration of the returned generator -- Add a test for CUDA failure raised directly by `model.transcribe(...)` - -### MEDIUM: `get_gpu_name()` crashes on empty successful `nvidia-smi` output - -**File:** `src/local_transcriber/utils.py:42` -**Test Coverage:** NO - -When `subprocess.run(...)` returns `returncode == 0` with empty `stdout`, `splitlines()[0]` raises `IndexError`. The plan explicitly allows `get_gpu_name()` to return `None`, so this path should degrade gracefully instead of crashing. - -This will surface later in CLI/device formatting and turn a non-critical metadata lookup into a hard failure. - -**Reproduction:** -- Mock `subprocess.run` to return `CompletedProcess(..., returncode=0, stdout="")` -- Current result: `IndexError: list index out of range` -- Expected result: `None` - -**Recommendation:** -- Check `stdout.strip()` before indexing the first line -- Add a unit test for empty `stdout` - -### LOW: `on_segment` emits duplicate segments after mid-stream CUDA fallback - -**File:** `src/local_transcriber/transcriber.py:63` -**Test Coverage:** NO - -If the GPU generator yields one or more segments and then fails with a CUDA error, `on_segment` is called for the partial GPU output and then called again for the full CPU retry. The returned `segments` list is reset correctly, but callback side effects are not. - -For the planned `--verbose` flow this means duplicated stderr output such as: - -```text -first -first -second -``` - -while the final transcript contains only `first`, `second`. - -**Recommendation:** -- Buffer callback output until the segment stream completes successfully, or -- suppress callback invocation during the first failed attempt and only emit after a successful run - -## Test Coverage Analysis - -**Observed coverage:** targeted unit tests exist for step 2 and step 3 happy paths, but not for several failure paths. - -**Untested Changes:** - -| Function | Risk | Gap | -|----------|------|-----| -| `transcribe()` | MEDIUM | No test for CUDA failure raised by `model.transcribe(...)` | -| `transcribe()` | LOW | No test for duplicate callback behavior after generator fallback | -| `get_gpu_name()` | MEDIUM | No test for empty successful stdout | - -## Blast Radius Analysis - -The current blast radius is low because stage 1-3 code is only consumed by placeholder CLI wiring and tests. The highest-impact function is `transcribe()`, which will become user-facing once stage 5 is implemented. - -| Function | Current Callers | Risk | Priority | -|----------|-----------------|------|----------| -| `transcribe()` | tests only | MEDIUM | P1 | -| `get_gpu_name()` | not yet wired into CLI | MEDIUM | P1 | -| `validate_input_file()` | tests only | LOW | P2 | - -## Historical Context - -- `ade3b23` introduced the scaffold and placeholder module layout for step 1 -- `510d6cc` added environment validation and output-path logic for step 2 -- Stage 3 is currently in the working tree and introduces the first non-trivial runtime behavior, including fallback and callback logic - -The defects above are all newly introduced in the step 3 working tree implementation, not legacy behavior. - -## Recommendations - -### Immediate - -- [ ] Fix CUDA fallback for errors raised by `model.transcribe(...)` -- [ ] Make `get_gpu_name()` return `None` on empty stdout instead of raising -- [ ] Add regression tests for both cases - -### Before Stage 5 - -- [ ] Decide how `on_segment` should behave across retries and make the behavior explicit -- [ ] Add one test that covers generator failure after partial output - -### Residual Risk - -- CLI acceptance for `uv run transcribe --help` was not reproducible in this environment because `uv run transcribe` hit a local `snap-confine` execution issue unrelated to the project code diff --git a/docs/review-stages-4-5-rerun.md b/docs/review-stages-4-5-rerun.md deleted file mode 100644 index 16a1f4c..0000000 --- a/docs/review-stages-4-5-rerun.md +++ /dev/null @@ -1,43 +0,0 @@ -# Review: Stages 4-5 (Rerun) - -## Executive Summary - -| Severity | Count | -|----------|-------| -| CRITICAL | 0 | -| HIGH | 0 | -| MEDIUM | 0 | -| LOW | 0 | - -**Overall Risk:** LOW -**Recommendation:** APPROVE - -## What Was Rechecked - -- `src/local_transcriber/formatter.py` -- `src/local_transcriber/cli.py` -- `tests/test_formatter.py` -- `tests/test_cli.py` - -## Result - -No new findings. - -Previously reported issues for stages 4-5 are addressed: - -- centisecond carry in `format_timestamp()` is fixed -- transcript formatting no longer depends on leading whitespace in `seg.text` -- CLI now has automated tests for happy path, options, empty speech warning, output path handling, and error exit code - -## Verification - -- `uv run pytest` -> 31 passed -- `.venv/bin/transcribe --help` -> works -- spot checks: - - `format_timestamp(0.995)` -> `00:01.00` - - `format_timestamp(59.995)` -> `01:00.00` - - `format_timestamp(3599.995, use_hours=True)` -> `01:00:00.00` - -## Residual Risk - -- Step 6 error-handling polish is still not implemented, so user-facing error formatting remains intentionally incomplete at this stage diff --git a/docs/review-stages-4-5.md b/docs/review-stages-4-5.md deleted file mode 100644 index bf7ca51..0000000 --- a/docs/review-stages-4-5.md +++ /dev/null @@ -1,138 +0,0 @@ -# Review: Stages 4-5 - -## Executive Summary - -| Severity | Count | -|----------|-------| -| CRITICAL | 0 | -| HIGH | 0 | -| MEDIUM | 2 | -| LOW | 1 | - -**Overall Risk:** MEDIUM -**Recommendation:** CONDITIONAL - -**Key Metrics:** -- Files analyzed: 4 -- Verified commands: `pytest`, CLI help, mocked CLI happy path -- Test coverage gaps: 1 user-facing module (`cli.py`) -- High blast radius changes: 0 -- Security regressions detected: 0 - -## What Changed - -**Commit Range:** `dfc5f46..WORKTREE` -**Commits:** `0d1a734`, plus uncommitted step 5 changes - -| File | Risk | Notes | -|------|------|-------| -| `src/local_transcriber/formatter.py` | MEDIUM | Output contract and markdown formatting | -| `tests/test_formatter.py` | LOW | Unit coverage for formatter | -| `src/local_transcriber/cli.py` | MEDIUM | Main user-facing flow and file writing | -| `docs/plan.md` | LOW | Checkbox updates for step 5 | - -## Findings - -### MEDIUM: `format_timestamp()` can emit invalid centiseconds like `.100` - -**File:** `src/local_transcriber/formatter.py:7` -**Test Coverage:** NO - -The implementation rounds centiseconds independently from the integral seconds: - -```python -total_seconds = int(seconds) -centiseconds = int(round((seconds - total_seconds) * 100)) -``` - -For values such as `0.995`, this produces `00:00.100` instead of carrying into the next second. - -**Reproduction:** -- `format_timestamp(0.995)` returns `00:00.100` - -**Impact:** -- Breaks the PRD timestamp format contract (`SS.ss` must always have exactly two fractional digits) -- Can produce malformed transcript timestamps on real segment boundaries - -**Recommendation:** -- Round the full timestamp first and then split into components, or normalize `centiseconds == 100` by incrementing seconds -- Add a regression test for `0.995` - -### MEDIUM: Transcript formatting depends on segment text already containing a leading space - -**File:** `src/local_transcriber/formatter.py:62` -**Test Coverage:** PARTIAL - -Segment lines are written as: - -```python -f"[{start} - {end}]{seg.text}" -``` - -This only matches the PRD format if `seg.text` already starts with a space. The current tests mask that dependency by building fixtures with leading spaces. - -**Reproduction:** -- A mocked CLI run with a segment text of `"Hello"` writes: - - `[00:00.00 - 00:01.00]Hello` -- Expected: - - `[00:00.00 - 00:01.00] Hello` - -**Impact:** -- Output format becomes model-dependent instead of being guaranteed by the formatter -- Any future normalization in `transcriber.py` will immediately break transcript formatting - -**Recommendation:** -- Normalize segment text inside the formatter, e.g. `seg.text.strip()` plus an explicit single space after `]` -- Add a test case where segment text has no leading whitespace - -### LOW: Step 5 has no automated tests for the public CLI contract - -**File:** `src/local_transcriber/cli.py:16` -**Test Coverage:** NO - -The user-facing entrypoint is now wired end to end, but there is still no `test_cli.py` coverage for: - -- option parsing -- warning path for empty speech -- default output-path generation -- exit-code behavior on failure - -This already matters because the mocked CLI happy path is what exposed the missing-space formatting bug above. - -## Test Coverage Analysis - -**Executed checks:** -- `uv run pytest` -> 22 tests passed -- `.venv/bin/transcribe --help` -> works -- mocked `CliRunner` happy path -> exit code 0 and output file written - -**Coverage gaps:** - -| Area | Gap | Risk | -|------|-----|------| -| `formatter.py` | No edge-case test for centisecond carry | MEDIUM | -| `formatter.py` | No test for segment text without leading whitespace | MEDIUM | -| `cli.py` | No automated tests at all | LOW | - -## Blast Radius Analysis - -The blast radius is still low because this is a small CLI project, but `cli.py` is now the single public entrypoint. Any formatting or wiring defect directly affects all users. - -| Function | Exposure | Risk | Priority | -|----------|----------|------|----------| -| `main()` | All CLI invocations | MEDIUM | P1 | -| `format_transcript()` | All saved transcript files | MEDIUM | P1 | -| `format_timestamp()` | Every segment line | MEDIUM | P1 | - -## Recommendations - -### Immediate - -- [ ] Fix centisecond carry handling in `format_timestamp()` -- [ ] Stop relying on leading whitespace in `seg.text` -- [ ] Add formatter regression tests for both cases - -### Before Step 6 - -- [ ] Add `tests/test_cli.py` with a mocked happy path and one error path -- [ ] Assert output file contents through the CLI layer, not only through direct formatter calls