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:
2026-03-24 22:49:16 +03:00
co-authored by Claude Opus 4.6
parent 8ae74d2748
commit 857267763c
10 changed files with 226 additions and 40 deletions
+5 -1
View File
@@ -30,8 +30,12 @@ class Backend(Protocol):
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт модель. Возвращает backend-специфичный объект."""
"""Создаёт модель. Возвращает backend-специфичный объект.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
"""
...
def transcribe(
@@ -84,10 +84,17 @@ class FasterWhisperBackend:
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт WhisperModel."""
"""Создаёт WhisperModel.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
"""
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:
if _is_missing_socksio_error(exc):
raise RuntimeError(
+2 -1
View File
@@ -106,8 +106,9 @@ class OpenVINOBackend:
model_path: str,
device: str,
compute_type: str,
cpu_threads: int = 0,
) -> Any:
"""Создаёт WhisperPipeline."""
"""Создаёт WhisperPipeline. cpu_threads не используется (OpenVINO управляет сам)."""
import openvino_genai as ov_genai
ov_dev = self._resolve_ov_device()
+12 -2
View File
@@ -61,6 +61,10 @@ def main(
None, "--compute-type", show_default=False,
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="Подробный вывод"),
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
) -> None:
@@ -89,9 +93,9 @@ def main(
raise SystemExit(1)
if is_batch:
_run_batch(expanded, defaults, verbose, force, ct_explicit)
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
else:
_run_single(expanded[0], defaults, output, verbose, ct_explicit)
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130)
@@ -129,6 +133,7 @@ def _run_single(
output: Path | None,
verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic()
@@ -148,6 +153,7 @@ def _run_single(
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
console.print(
@@ -174,6 +180,7 @@ def _run_single(
on_segment=on_segment if verbose else None,
on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
)
result = tfr.result
@@ -219,6 +226,7 @@ def _run_batch(
verbose: bool,
force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> None:
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
@@ -256,6 +264,7 @@ def _run_batch(
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
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_status=status.update if not verbose else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
)
if tfr.actual_device != actual_device:
+9 -3
View File
@@ -22,11 +22,13 @@ def load_model(
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
) -> tuple[Any, str, Any, str]:
"""Загружает модель: ensure + create с fallback.
Возвращает (model, actual_device, backend, model_path).
compute_type_explicit: True если пользователь явно указал --compute-type.
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки).
"""
backend = get_backend(device, compute_type_explicit=compute_type_explicit)
actual_device = device
@@ -35,7 +37,7 @@ def load_model(
try:
_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
ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and actual_device != "openvino-gpu":
@@ -55,7 +57,7 @@ def load_model(
backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_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:
raise
@@ -74,6 +76,7 @@ def _transcribe_file(
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeFileResult:
"""Транскрибирует один файл. При mid-stream fallback перезагружает модель."""
lang_arg = language if language and language != "auto" else None
@@ -95,7 +98,7 @@ def _transcribe_file(
backend = get_backend("cpu")
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_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, "Транскрибирую...")
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
@@ -120,16 +123,19 @@ def transcribe(
on_segment: Callable[[Segment], None] | None = None,
on_status: Callable[[str], None] | None = None,
strict_device: bool = False,
cpu_threads: int = 0,
) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model(
model_name, device, compute_type, on_status, strict_device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
cpu_threads=cpu_threads,
)
tfr = _transcribe_file(
model, actual_device, backend, model_path,
file_path, model_name, compute_type,
language, on_segment, on_status, strict_device,
cpu_threads=cpu_threads,
)
return tfr.result