feat(cli): --threads для управления CPU-потоками + бенчмарк CUDA compute_type
- Зачем:
- CTranslate2 по умолчанию использует 4 потока; на многоядерных CPU (8+ ядер)
это неоптимально — --threads 8 даёт +13% ускорения.
- Не было данных по int8_float32/int8_float16 на NVIDIA GPU.
- Что:
- --threads / -t: новый CLI-флаг, пробрасывается через load_model →
backend.create_model(cpu_threads=...) → WhisperModel(cpu_threads=...).
- Валидация min=0 на входе (typer), Backend протокол синхронизирован.
- docs/gpu.md: результаты бенчмарка 6 комбинаций CUDA compute_type
(medium/large-v3 × float16/int8_float32/int8_float16) на двух файлах
(16 мин и 46 мин). Ключевой вывод: float16 — оптимальный дефолт;
large-v3 ненадёжен на длинных записях.
- README: --threads добавлен в таблицу опций.
- Фикс теста: test_resolve_repo_explicit_unsupported_pair_raises обновлён
под добавление medium fp16 модели.
- Проверка:
- uv run pytest: 157 passed.
- transcribe file.mp4 --device cpu --threads 8: 277с vs 320с (дефолт).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,7 @@ transcribe *.mp4 --force
|
|||||||
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
||||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) |
|
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) |
|
||||||
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений |
|
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений |
|
||||||
|
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
|
||||||
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
||||||
| `--verbose` | `-v` | — | Подробный вывод |
|
| `--verbose` | `-v` | — | Подробный вывод |
|
||||||
|
|
||||||
|
|||||||
+70
-30
@@ -105,9 +105,16 @@ CTranslate2 по умолчанию использует 4 потока. На м
|
|||||||
задать число потоков равным числу **физических ядер** (не виртуальных):
|
задать число потоков равным числу **физических ядер** (не виртуальных):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OMP_NUM_THREADS=8 transcribe meeting.mp4 --device cpu --compute-type int8_float32
|
transcribe meeting.mp4 --device cpu --compute-type int8_float32 --threads 8
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Замеры (Intel i7, 8 ядер / 16 потоков, medium int8_float32, 16 мин файл):**
|
||||||
|
|
||||||
|
| --threads | Время | Ускорение |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 (дефолт = 4) | 320с | baseline |
|
||||||
|
| 8 (физ. ядра) | **277с** | **+13%** |
|
||||||
|
|
||||||
SMT/Hyper-Threading не помогает — 16 потоков на 8-ядерном CPU медленнее, чем 8.
|
SMT/Hyper-Threading не помогает — 16 потоков на 8-ядерном CPU медленнее, чем 8.
|
||||||
|
|
||||||
### Почему CPU бэкенд качественнее OpenVINO
|
### Почему CPU бэкенд качественнее OpenVINO
|
||||||
@@ -138,21 +145,44 @@ SMT/Hyper-Threading не помогает — 16 потоков на 8-ядер
|
|||||||
|
|
||||||
На NVIDIA GPU дефолт — `float16`, и для большинства случаев это оптимальный выбор.
|
На NVIDIA GPU дефолт — `float16`, и для большинства случаев это оптимальный выбор.
|
||||||
|
|
||||||
Однако `large-v3` с `int8` на GPU может давать **галлюцинации** на длинных записях
|
### Результаты тестирования compute_type на GPU (RTX 3060 Laptop, 6 GB)
|
||||||
(зафиксировано: повтор фраз ×25, потеря контента). Причина — чистый int8 без float
|
|
||||||
аккумулятора теряет точность в глубокой модели.
|
|
||||||
|
|
||||||
**Альтернатива для тестирования:**
|
**Скорость (файл 16 мин, русский язык):**
|
||||||
|
|
||||||
| compute_type | Ожидаемый эффект |
|
| Модель | float16 | int8_float32 | int8_float16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| medium | **53с** (~18x) | 64с (~15x) | 54с (~18x) |
|
||||||
|
| large-v3 | **86с** (~11x) | 113с (~8.5x) | 125с (~7.7x) |
|
||||||
|
|
||||||
|
**Скорость (файл 46 мин, русский язык):**
|
||||||
|
|
||||||
|
| Модель | float16 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `float16` | **Дефолт** — отлично работает |
|
| medium | **127с** (~22x реалтайм) |
|
||||||
| `int8_float32` | Потенциально быстрее float16, качество на уровне float32. Не протестировано — ждём feedback |
|
| large-v3 | 271с (~10x реалтайм) |
|
||||||
| `int8_float16` | int8 веса + float16 аккумулятор. Может быть быстрее int8_float32 при приемлемом качестве |
|
|
||||||
| `int8` | Быстрый, но рискует галлюцинациями на large-v3 и длинных записях |
|
|
||||||
|
|
||||||
Если у вас есть NVIDIA GPU и вы протестировали `int8_float32` / `int8_float16` —
|
**Качество (файл 16 мин):**
|
||||||
поделитесь результатами через [issues](https://github.com/dementev-dev/local-transcriber/issues).
|
|
||||||
|
| Модель | float16 | int8_float32 | int8_float16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| medium | **Отлично** | **Отлично** (≈float16) | Хорошо (1-2 ошибки) |
|
||||||
|
| large-v3 | Хорошо (ед. галлюцинации) | **Плохо** (повтор "Ага" ×18) | **Неприемлемо** (мусор, иероглифы) |
|
||||||
|
|
||||||
|
**Качество (файл 46 мин):**
|
||||||
|
|
||||||
|
| Модель | float16 | Проблемы |
|
||||||
|
|---|---|---|
|
||||||
|
| medium | **Отлично** | Единичные ляпы ("email" вместо "ML"), стабилен |
|
||||||
|
| large-v3 | **Плохо** | Галлюцинации на 3+ языках (китайский, арабский), повторы фраз, "Аминь" вместо "Угу" |
|
||||||
|
|
||||||
|
### Выводы по CUDA compute_type
|
||||||
|
|
||||||
|
- **`float16` — оптимальный дефолт**: самый быстрый и стабильный
|
||||||
|
- **`int8_float32` не дал выигрыша**: на 20% медленнее float16, качество для medium сопоставимо, для large-v3 — деградация
|
||||||
|
- **`int8_float16`**: по скорости ≈ float16 для medium, но large-v3 даёт мусор
|
||||||
|
- **`int8`**: рискует галлюцинациями на large-v3 и длинных записях (повтор фраз ×25)
|
||||||
|
- **medium устойчивее к квантизации**, чем large-v3 — все варианты compute_type дают приемлемый результат
|
||||||
|
- **large-v3 на длинных записях (>20 мин)**: галлюцинации даже с float16 — medium надёжнее
|
||||||
|
|
||||||
## Настройка по платформам
|
## Настройка по платформам
|
||||||
|
|
||||||
@@ -185,10 +215,12 @@ winget install -e --id Nvidia.CUDA --version 12.9 # требует запус
|
|||||||
|
|
||||||
Замеры на RTX 3060 Laptop (6 GB) и Intel CPU (WSL2):
|
Замеры на RTX 3060 Laptop (6 GB) и Intel CPU (WSL2):
|
||||||
|
|
||||||
| Конфигурация | 16 мин файл | 42 мин файл | Отн. скорость |
|
| Конфигурация | 16 мин файл | 46 мин файл | Отн. скорость |
|
||||||
|-------------|-------------|-------------|---------------|
|
|-------------|-------------|-------------|---------------|
|
||||||
| GPU + medium float16 | ~35с | ~133с | ~19x реалтайм |
|
| GPU + medium float16 | **53с** | **127с** | **~18-22x реалтайм** |
|
||||||
| GPU + large-v3 float16 | ~90с | ~350с | ~7x реалтайм |
|
| GPU + large-v3 float16 | 86с | 271с | ~10-11x реалтайм |
|
||||||
|
| GPU + medium int8_float32 | 64с | — | ~15x реалтайм |
|
||||||
|
| GPU + medium int8_float16 | 54с | — | ~18x реалтайм |
|
||||||
| **OpenVINO + small int8** | **93с** | **153с** | **~10-16x реалтайм** |
|
| **OpenVINO + small int8** | **93с** | **153с** | **~10-16x реалтайм** |
|
||||||
| **OpenVINO + medium int8** | **171-205с** | **413с** | **~4-6x реалтайм** |
|
| **OpenVINO + medium int8** | **171-205с** | **413с** | **~4-6x реалтайм** |
|
||||||
| **OpenVINO + large-v3 fp16** | **416с** | — | **~2.3x реалтайм** |
|
| **OpenVINO + large-v3 fp16** | **416с** | — | **~2.3x реалтайм** |
|
||||||
@@ -196,28 +228,34 @@ winget install -e --id Nvidia.CUDA --version 12.9 # требует запус
|
|||||||
| CPU + medium float32 | 658с (11 мин) | ~26 мин | ~1.5x реалтайм |
|
| CPU + medium float32 | 658с (11 мин) | ~26 мин | ~1.5x реалтайм |
|
||||||
| CPU + large-v3 int8 | 839с (14 мин) | ~37 мин | ~1:1 реалтайм |
|
| CPU + large-v3 int8 | 839с (14 мин) | ~37 мин | ~1:1 реалтайм |
|
||||||
|
|
||||||
\* Замер на AMD Ryzen 7 8845H (8 потоков), файл 14:41. На эталонном 16-мин файле — TBD.
|
\* Замер CPU int8_float32 на AMD Ryzen 7 8845H (8 потоков), файл 14:41.
|
||||||
|
|
||||||
## Результаты тестирования качества
|
## Результаты тестирования качества
|
||||||
|
|
||||||
Тесты проведены на реальных записях рабочих созвонов (русский язык, технические термины:
|
Тесты проведены на реальных записях рабочих созвонов (русский язык, технические термины:
|
||||||
SQL, PostgreSQL, Greenplum, Airflow, ClickHouse, Docker, CDR, GTP, MAP).
|
SQL, PostgreSQL, Greenplum, Airflow, ClickHouse, Docker, CDR, GTP, MAP).
|
||||||
|
|
||||||
| Конфигурация | Качество (длинная запись, 42 мин) | Проблемы |
|
| Конфигурация | Качество (короткие, ≤16 мин) | Качество (длинные, >40 мин) | Проблемы |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| large-v3 int8 GPU | Плохо | Галлюцинации (фразы ×25), потеря контента |
|
| medium float16 GPU | **Отлично** | **Отлично** | Единичные ляпы в терминах ("email" вместо "ML") |
|
||||||
| large-v3 float16 GPU | Отлично | — |
|
| medium int8_float32 GPU | **Отлично** | — | Качество ≈ float16, но на 20% медленнее |
|
||||||
| medium float16 GPU | Хорошо | Редкие мелкие ляпы в терминах |
|
| medium int8_float16 GPU | Хорошо | — | 1-2 ошибки |
|
||||||
| medium int8_float32 CPU | Отлично | Качество ≈ float32, на уровне облачных сервисов |
|
| large-v3 float16 GPU | Хорошо | **Плохо** | Галлюцинации на длинных записях (мусор на 3+ языках) |
|
||||||
| medium float32 CPU | Хорошо | Сопоставимо с large-v3 int8, без галлюцинаций |
|
| large-v3 int8_float32 GPU | **Плохо** | — | Повтор фраз ("Ага" ×18), искажения |
|
||||||
| large-v3 int8 CPU | Хорошо | Без галлюцинаций (на коротких файлах) |
|
| large-v3 int8_float16 GPU | **Неприемлемо** | — | Мусор, китайские/арабские символы, потеря текста |
|
||||||
|
| large-v3 int8 GPU | **Плохо** | **Плохо** | Галлюцинации (фразы ×25), потеря контента |
|
||||||
|
| medium int8_float32 CPU | **Отлично** | — | Качество ≈ float32, на уровне облачных сервисов |
|
||||||
|
| medium float32 CPU | **Отлично** | Хорошо | Эталон качества |
|
||||||
|
| large-v3 int8 CPU | Хорошо | — | Без галлюцинаций (на коротких файлах) |
|
||||||
|
|
||||||
### Ключевые выводы
|
### Ключевые выводы
|
||||||
|
|
||||||
1. **Указание языка (`--language ru`) критично** — auto-detect может ошибиться и выдать мусор
|
1. **Указание языка (`--language ru`) критично** — auto-detect может ошибиться и выдать мусор
|
||||||
2. **float16/float32 стабильнее int8** — особенно на записях >20 минут
|
2. **medium + float16 на GPU — лучший баланс** скорости и качества для любых записей
|
||||||
3. **medium + float16 на GPU — лучший баланс** скорости и качества для повседневного использования
|
3. **large-v3 ненадёжен на длинных записях (>20 мин)** — галлюцинации даже с float16;
|
||||||
4. **large-v3 + float16 на GPU** — для максимального качества важных записей
|
medium стабильнее на любой длине
|
||||||
|
4. **medium устойчив к квантизации** — все варианты compute_type дают приемлемый результат;
|
||||||
|
large-v3 деградирует катастрофически при любом int8
|
||||||
5. **CPU: int8_float32 — лучший баланс** — 1.5x быстрее float32 при том же качестве
|
5. **CPU: int8_float32 — лучший баланс** — 1.5x быстрее float32 при том же качестве
|
||||||
6. **OpenVINO быстрее, но CPU бэкенд качественнее** — разница в pipeline декодирования
|
6. **OpenVINO быстрее, но CPU бэкенд качественнее** — разница в pipeline декодирования
|
||||||
(temperature fallback, фильтры галлюцинаций), а не в квантизации
|
(temperature fallback, фильтры галлюцинаций), а не в квантизации
|
||||||
@@ -235,11 +273,13 @@ VAD) доступен только через CTranslate2, т.е. на CPU и NV
|
|||||||
не зависящая от inference engine. Это даст Intel Arc / AMD GPU то же качество,
|
не зависящая от inference engine. Это даст Intel Arc / AMD GPU то же качество,
|
||||||
что сейчас есть только у CUDA-пользователей.
|
что сейчас есть только у CUDA-пользователей.
|
||||||
|
|
||||||
### CUDA: тестирование int8_float32 / int8_float16
|
### CUDA: int8_float32 / int8_float16 — протестировано
|
||||||
|
|
||||||
На CPU `int8_float32` показал качество на уровне float32 при 1.5x ускорении.
|
На CPU `int8_float32` показал качество на уровне float32 при 1.5x ускорении.
|
||||||
На CUDA аналогичный эффект может решить проблему галлюцинаций large-v3 с int8
|
На GPU (RTX 3060) результат другой: для **medium** int8_float32 даёт сопоставимое
|
||||||
при сохранении скорости GPU. Требуется тестирование на реальном NVIDIA GPU.
|
качество, но на 20% медленнее float16 — выигрыша нет. Для **large-v3** любой int8
|
||||||
|
вариант (включая int8_float32) вызывает галлюцинации — float32 аккумулятор не спасает
|
||||||
|
глубокую модель на GPU. Вывод: **float16 остаётся оптимальным дефолтом для CUDA**.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ class Backend(Protocol):
|
|||||||
model_path: str,
|
model_path: str,
|
||||||
device: str,
|
device: str,
|
||||||
compute_type: str,
|
compute_type: str,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Создаёт модель. Возвращает backend-специфичный объект."""
|
"""Создаёт модель. Возвращает backend-специфичный объект.
|
||||||
|
|
||||||
|
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def transcribe(
|
def transcribe(
|
||||||
|
|||||||
@@ -84,10 +84,17 @@ class FasterWhisperBackend:
|
|||||||
model_path: str,
|
model_path: str,
|
||||||
device: str,
|
device: str,
|
||||||
compute_type: str,
|
compute_type: str,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Создаёт WhisperModel."""
|
"""Создаёт WhisperModel.
|
||||||
|
|
||||||
|
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return WhisperModel(model_path, device=device, compute_type=compute_type)
|
return WhisperModel(
|
||||||
|
model_path, device=device, compute_type=compute_type,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
|
)
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
if _is_missing_socksio_error(exc):
|
if _is_missing_socksio_error(exc):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ class OpenVINOBackend:
|
|||||||
model_path: str,
|
model_path: str,
|
||||||
device: str,
|
device: str,
|
||||||
compute_type: str,
|
compute_type: str,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Создаёт WhisperPipeline."""
|
"""Создаёт WhisperPipeline. cpu_threads не используется (OpenVINO управляет сам)."""
|
||||||
import openvino_genai as ov_genai
|
import openvino_genai as ov_genai
|
||||||
|
|
||||||
ov_dev = self._resolve_ov_device()
|
ov_dev = self._resolve_ov_device()
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ def main(
|
|||||||
None, "--compute-type", show_default=False,
|
None, "--compute-type", show_default=False,
|
||||||
help="Тип вычислений [по умолч.: float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU)]"
|
help="Тип вычислений [по умолч.: float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU)]"
|
||||||
),
|
),
|
||||||
|
threads: int = typer.Option(
|
||||||
|
0, "--threads", "-t", show_default=False, min=0,
|
||||||
|
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)"
|
||||||
|
),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
|
||||||
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
|
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -89,9 +93,9 @@ def main(
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
if is_batch:
|
if is_batch:
|
||||||
_run_batch(expanded, defaults, verbose, force, ct_explicit)
|
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
|
||||||
else:
|
else:
|
||||||
_run_single(expanded[0], defaults, output, verbose, ct_explicit)
|
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nПрервано пользователем.", style="yellow")
|
console.print("\nПрервано пользователем.", style="yellow")
|
||||||
raise SystemExit(130)
|
raise SystemExit(130)
|
||||||
@@ -129,6 +133,7 @@ def _run_single(
|
|||||||
output: Path | None,
|
output: Path | None,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
compute_type_explicit: bool = False,
|
compute_type_explicit: bool = False,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
|
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
@@ -148,6 +153,7 @@ def _run_single(
|
|||||||
defaults["model"], resolved_device, defaults["compute_type"],
|
defaults["model"], resolved_device, defaults["compute_type"],
|
||||||
on_status=lambda msg: console.print(msg), strict_device=strict,
|
on_status=lambda msg: console.print(msg), strict_device=strict,
|
||||||
compute_type_explicit=compute_type_explicit,
|
compute_type_explicit=compute_type_explicit,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
|
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
|
||||||
console.print(
|
console.print(
|
||||||
@@ -174,6 +180,7 @@ def _run_single(
|
|||||||
on_segment=on_segment if verbose else None,
|
on_segment=on_segment if verbose else None,
|
||||||
on_status=status.update,
|
on_status=status.update,
|
||||||
strict_device=strict,
|
strict_device=strict,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = tfr.result
|
result = tfr.result
|
||||||
@@ -219,6 +226,7 @@ def _run_batch(
|
|||||||
verbose: bool,
|
verbose: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
compute_type_explicit: bool = False,
|
compute_type_explicit: bool = False,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
|
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
|
||||||
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
|
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
|
||||||
@@ -256,6 +264,7 @@ def _run_batch(
|
|||||||
defaults["model"], resolved_device, defaults["compute_type"],
|
defaults["model"], resolved_device, defaults["compute_type"],
|
||||||
on_status=lambda msg: console.print(msg), strict_device=strict,
|
on_status=lambda msg: console.print(msg), strict_device=strict,
|
||||||
compute_type_explicit=compute_type_explicit,
|
compute_type_explicit=compute_type_explicit,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
|
||||||
@@ -306,6 +315,7 @@ def _run_batch(
|
|||||||
on_segment=on_segment if verbose else None,
|
on_segment=on_segment if verbose else None,
|
||||||
on_status=status.update if not verbose else lambda msg: console.print(msg),
|
on_status=status.update if not verbose else lambda msg: console.print(msg),
|
||||||
strict_device=strict,
|
strict_device=strict,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
|
|
||||||
if tfr.actual_device != actual_device:
|
if tfr.actual_device != actual_device:
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ def load_model(
|
|||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
strict_device: bool = False,
|
strict_device: bool = False,
|
||||||
compute_type_explicit: bool = False,
|
compute_type_explicit: bool = False,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> tuple[Any, str, Any, str]:
|
) -> tuple[Any, str, Any, str]:
|
||||||
"""Загружает модель: ensure + create с fallback.
|
"""Загружает модель: ensure + create с fallback.
|
||||||
|
|
||||||
Возвращает (model, actual_device, backend, model_path).
|
Возвращает (model, actual_device, backend, model_path).
|
||||||
compute_type_explicit: True если пользователь явно указал --compute-type.
|
compute_type_explicit: True если пользователь явно указал --compute-type.
|
||||||
|
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
|
||||||
"""
|
"""
|
||||||
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
|
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
|
||||||
actual_device = device
|
actual_device = device
|
||||||
@@ -35,7 +37,7 @@ def load_model(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||||
model = backend.create_model(model_path, device, compute_type)
|
model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads)
|
||||||
# Резолвим actual_device по реальному OpenVINO device
|
# Резолвим actual_device по реальному OpenVINO device
|
||||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||||
if ov_dev == "GPU" and actual_device != "openvino-gpu":
|
if ov_dev == "GPU" and actual_device != "openvino-gpu":
|
||||||
@@ -55,7 +57,7 @@ def load_model(
|
|||||||
backend = get_backend("cpu")
|
backend = get_backend("cpu")
|
||||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||||
model = backend.create_model(model_path, "cpu", compute_type)
|
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ def _transcribe_file(
|
|||||||
on_segment: Callable[[Segment], None] | None = None,
|
on_segment: Callable[[Segment], None] | None = None,
|
||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
strict_device: bool = False,
|
strict_device: bool = False,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> TranscribeFileResult:
|
) -> TranscribeFileResult:
|
||||||
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
|
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
|
||||||
lang_arg = language if language and language != "auto" else None
|
lang_arg = language if language and language != "auto" else None
|
||||||
@@ -95,7 +98,7 @@ def _transcribe_file(
|
|||||||
backend = get_backend("cpu")
|
backend = get_backend("cpu")
|
||||||
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
|
||||||
_notify_status(on_status, "Инициализирую модель на cpu...")
|
_notify_status(on_status, "Инициализирую модель на cpu...")
|
||||||
model = backend.create_model(model_path, "cpu", compute_type)
|
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
|
||||||
_notify_status(on_status, "Транскрибирую...")
|
_notify_status(on_status, "Транскрибирую...")
|
||||||
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
|
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
|
||||||
result.device_used = actual_device
|
result.device_used = actual_device
|
||||||
@@ -120,16 +123,19 @@ def transcribe(
|
|||||||
on_segment: Callable[[Segment], None] | None = None,
|
on_segment: Callable[[Segment], None] | None = None,
|
||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
strict_device: bool = False,
|
strict_device: bool = False,
|
||||||
|
cpu_threads: int = 0,
|
||||||
) -> TranscribeResult:
|
) -> TranscribeResult:
|
||||||
"""High-level API: загрузка модели + транскрипция за один вызов."""
|
"""High-level API: загрузка модели + транскрипция за один вызов."""
|
||||||
model, actual_device, backend, model_path = load_model(
|
model, actual_device, backend, model_path = load_model(
|
||||||
model_name, device, compute_type, on_status, strict_device,
|
model_name, device, compute_type, on_status, strict_device,
|
||||||
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
|
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
tfr = _transcribe_file(
|
tfr = _transcribe_file(
|
||||||
model, actual_device, backend, model_path,
|
model, actual_device, backend, model_path,
|
||||||
file_path, model_name, compute_type,
|
file_path, model_name, compute_type,
|
||||||
language, on_segment, on_status, strict_device,
|
language, on_segment, on_status, strict_device,
|
||||||
|
cpu_threads=cpu_threads,
|
||||||
)
|
)
|
||||||
return tfr.result
|
return tfr.result
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def test_resolve_repo_explicit_unsupported_pair_raises():
|
|||||||
"""Явный --compute-type с несуществующей парой → ошибка."""
|
"""Явный --compute-type с несуществующей парой → ошибка."""
|
||||||
backend = OpenVINOBackend(compute_type_explicit=True)
|
backend = OpenVINOBackend(compute_type_explicit=True)
|
||||||
with pytest.raises(ValueError, match="недоступна с compute_type='fp16'"):
|
with pytest.raises(ValueError, match="недоступна с compute_type='fp16'"):
|
||||||
backend._resolve_repo("medium", "fp16")
|
backend._resolve_repo("small", "fp16")
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_repo_explicit_unknown_model_raises():
|
def test_resolve_repo_explicit_unknown_model_raises():
|
||||||
|
|||||||
@@ -862,3 +862,62 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
|
|||||||
assert out.exit_code == 0
|
assert out.exit_code == 0
|
||||||
# detect_device("openvino") resolved to "openvino-gpu", load_model receives it
|
# detect_device("openvino") resolved to "openvino-gpu", load_model receives it
|
||||||
assert mock_load_model.call_args[0][1] == "openvino-gpu"
|
assert mock_load_model.call_args[0][1] == "openvino-gpu"
|
||||||
|
|
||||||
|
|
||||||
|
# === --threads ===
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_threads_passed_to_load_model(tmp_path):
|
||||||
|
"""--threads передаётся в load_model как cpu_threads."""
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.write_bytes(b"fake")
|
||||||
|
result = _make_result()
|
||||||
|
model = _make_model()
|
||||||
|
backend = _make_backend()
|
||||||
|
tfr = _make_tfr(result=result, model=model, backend=backend)
|
||||||
|
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("local_transcriber.cli.load_config", return_value={}),
|
||||||
|
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||||
|
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||||
|
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||||
|
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||||
|
patch("local_transcriber.cli.write_transcript"),
|
||||||
|
):
|
||||||
|
out = runner.invoke(app, [str(audio), "--threads", "8"])
|
||||||
|
|
||||||
|
assert out.exit_code == 0
|
||||||
|
assert mock_load_model.call_args.kwargs["cpu_threads"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_threads_default_zero(tmp_path):
|
||||||
|
"""Без --threads load_model получает cpu_threads=0."""
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.write_bytes(b"fake")
|
||||||
|
result = _make_result()
|
||||||
|
model = _make_model()
|
||||||
|
backend = _make_backend()
|
||||||
|
tfr = _make_tfr(result=result, model=model, backend=backend)
|
||||||
|
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/medium"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("local_transcriber.cli.load_config", return_value={}),
|
||||||
|
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||||
|
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||||
|
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||||
|
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||||
|
patch("local_transcriber.cli.write_transcript"),
|
||||||
|
):
|
||||||
|
out = runner.invoke(app, [str(audio)])
|
||||||
|
|
||||||
|
assert out.exit_code == 0
|
||||||
|
assert mock_load_model.call_args.kwargs["cpu_threads"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_threads_negative_rejected(tmp_path):
|
||||||
|
"""--threads с отрицательным значением отклоняется typer (min=0)."""
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.write_bytes(b"fake")
|
||||||
|
out = runner.invoke(app, [str(audio), "--threads", "-1"])
|
||||||
|
assert out.exit_code != 0
|
||||||
|
|||||||
@@ -321,6 +321,34 @@ def test_load_model_returns_backend_and_path(mock_get_backend):
|
|||||||
assert actual_device == "cpu"
|
assert actual_device == "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("local_transcriber.transcriber.get_backend")
|
||||||
|
def test_load_model_passes_cpu_threads_to_backend(mock_get_backend):
|
||||||
|
backend = _make_backend(model_path="/mock/model/path")
|
||||||
|
mock_get_backend.return_value = backend
|
||||||
|
|
||||||
|
load_model("tiny", "cpu", "int8", cpu_threads=8)
|
||||||
|
|
||||||
|
assert backend.create_model.call_args.kwargs["cpu_threads"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
@patch("local_transcriber.transcriber.get_backend")
|
||||||
|
def test_load_model_fallback_preserves_cpu_threads(mock_get_backend):
|
||||||
|
cuda_backend = _make_backend(create_model_error=RuntimeError("CUDA out of memory"))
|
||||||
|
cpu_model = MagicMock()
|
||||||
|
cpu_backend = _make_backend(model=cpu_model, model_path="/mock/cpu/model")
|
||||||
|
|
||||||
|
def backend_for_device(device, **kwargs):
|
||||||
|
return cuda_backend if device == "cuda" else cpu_backend
|
||||||
|
|
||||||
|
mock_get_backend.side_effect = backend_for_device
|
||||||
|
|
||||||
|
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||||
|
load_model("tiny", "cuda", "int8", cpu_threads=6)
|
||||||
|
|
||||||
|
assert cuda_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||||
|
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||||
|
|
||||||
|
|
||||||
# === _transcribe_file() tests ===
|
# === _transcribe_file() tests ===
|
||||||
|
|
||||||
|
|
||||||
@@ -527,6 +555,36 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
|
|||||||
assert tfr.model_path == "/mock/cpu/model"
|
assert tfr.model_path == "/mock/cpu/model"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("local_transcriber.transcriber.get_backend")
|
||||||
|
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
|
||||||
|
ov_backend = _make_backend(
|
||||||
|
transcribe_error=RuntimeError("OpenVINO inference error"),
|
||||||
|
)
|
||||||
|
cpu_backend = _make_backend(
|
||||||
|
transcribe_result=_make_result(count=2, device_used="cpu"),
|
||||||
|
model_path="/mock/cpu/model",
|
||||||
|
)
|
||||||
|
|
||||||
|
def backend_for_device(device, **kwargs):
|
||||||
|
return ov_backend if device.startswith("openvino") else cpu_backend
|
||||||
|
|
||||||
|
mock_get_backend.side_effect = backend_for_device
|
||||||
|
|
||||||
|
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||||
|
_transcribe_file(
|
||||||
|
model=MagicMock(),
|
||||||
|
actual_device="openvino-gpu",
|
||||||
|
backend=ov_backend,
|
||||||
|
model_path="/mock/ov/model",
|
||||||
|
file_path=Path("test.mp3"),
|
||||||
|
model_name="medium",
|
||||||
|
compute_type="fp16",
|
||||||
|
cpu_threads=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 6
|
||||||
|
|
||||||
|
|
||||||
@patch("local_transcriber.transcriber.get_backend")
|
@patch("local_transcriber.transcriber.get_backend")
|
||||||
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
|
def test_openvino_gpu_strict_device_no_fallback(mock_get_backend):
|
||||||
"""strict_device=True + OpenVINO GPU ошибка → raise."""
|
"""strict_device=True + OpenVINO GPU ошибка → raise."""
|
||||||
|
|||||||
Reference in New Issue
Block a user