docs: добавлен ADR-001 (CUDA bootstrap), обновлены PRD и plan

- Зачем:
  - зафиксировать архитектурное решение по GPU runtime и rejected alternatives,
    чтобы не переизобретать отклонённые подходы в будущем.
- Что:
  - создан docs/adr/001-cuda-bootstrap.md (контекст, решение, tradeoffs, альтернативы).
  - PRD 4.2: исправлено описание CUDA-зависимостей (cuDNN не нужен, cuBLAS из pip).
  - plan.md: добавлен выполненный шаг 5.1 со ссылкой на ADR.
  - удалены docs/plan-gpu-runtime.md и отчёты ревью (review-stages-*).
- Проверка:
  - cat docs/adr/001-cuda-bootstrap.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:53:41 +03:00
co-authored by Claude Opus 4.6
parent bdb9b06855
commit 18115ec7fd
6 changed files with 93 additions and 323 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ transcribe <путь_к_файлу> [опции]
- Python ≥ 3.10 - Python ≥ 3.10
- ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов) - 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) - Дисковое пространство для моделей: ~3 GB (large-v3)
- Выходные файлы: UTF-8 (явная кодировка при записи) - Выходные файлы: UTF-8 (явная кодировка при записи)
+78
View File
@@ -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` |
+14
View File
@@ -241,6 +241,20 @@
**Критерий готовности**: `uv run transcribe test.mp3` — создаёт корректный .md файл (проверить на локальной машине с реальным файлом). **Критерий готовности**: `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 ## Шаг 6: Error handling и UX polish
-141
View File
@@ -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
-43
View File
@@ -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
-138
View File
@@ -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