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:
+1
-1
@@ -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 (явная кодировка при записи)
|
||||
|
||||
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user