feat(openvino): добавлена поддержка Intel GPU через OpenVINO

- Зачем:
  - OpenVINO backend хардкодил "CPU", хотя Intel Arc GPU доступен и даёт ~2x ускорение.
- Что:
  - новые device modes: --device openvino-gpu, openvino-cpu; openvino — авто-детект GPU/CPU.
  - detect_device() проверяет Intel GPU через OpenVINO Core API (с fail-safe).
  - OpenVINOBackend передаёт "GPU"/"CPU" в WhisperPipeline вместо хардкода "CPU".
  - подсказка "Совет: --model large-v3" при наличии GPU и модели не large-v3.
  - .gitattributes для нормализации line endings (eol=lf).
  - 150 тестов, включая GPU detection, routing, fallback, CLI device info.
  - README, docs/gpu.md, docs/PRD.md обновлены для Intel GPU.
- Проверка:
  - uv run pytest (150 passed).
  - uv run transcribe --device openvino-gpu file.mp4 на Intel Arc 140T GPU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dmitry Dementiev
2026-03-22 15:45:27 +03:00
co-authored by Claude Opus 4.6
parent dc59d2a620
commit 617181f2dc
17 changed files with 522 additions and 66 deletions
+90 -7
View File
@@ -1,14 +1,16 @@
import subprocess
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from local_transcriber.utils import (
_is_openvino_gpu_available,
build_output_path,
detect_device,
expand_globs,
get_gpu_name,
get_intel_gpu_name,
has_existing_transcript,
validate_input_file,
)
@@ -55,26 +57,38 @@ def test_build_output_path_custom():
assert result == custom
def test_detect_device_explicit():
def test_detect_device_explicit_passthrough():
"""Явные device strings проходят без изменений."""
assert detect_device("cpu") == "cpu"
assert detect_device("cuda") == "cuda"
assert detect_device("openvino") == "openvino"
assert detect_device("openvino-gpu") == "openvino-gpu"
assert detect_device("openvino-cpu") == "openvino-cpu"
def test_detect_device_auto_openvino():
"""Нет nvidia-smi, есть openvino_genai, x86_64 → openvino."""
def test_detect_device_auto_openvino_gpu():
"""auto + нет nvidia-smi + есть OpenVINO GPU → openvino-gpu."""
with (
patch("local_transcriber.utils.shutil.which", return_value=None),
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=True),
):
assert detect_device("auto") == "openvino-gpu"
def test_detect_device_auto_openvino_cpu():
"""auto + нет nvidia-smi + есть OpenVINO, нет GPU → openvino-cpu."""
with (
patch("local_transcriber.utils.shutil.which", return_value=None),
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=False),
patch("local_transcriber.utils._is_openvino_available", return_value=True),
):
assert detect_device("auto") == "openvino"
assert detect_device("auto") == "openvino-cpu"
def test_detect_device_cuda_over_openvino():
"""nvidia-smi доступен и openvino тоже → cuda побеждает."""
with (
patch("local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"),
patch("local_transcriber.utils._is_openvino_available", return_value=True),
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=True),
):
assert detect_device("auto") == "cuda"
@@ -83,11 +97,80 @@ def test_detect_device_auto_cpu_fallback():
"""Ни nvidia-smi, ни openvino → cpu."""
with (
patch("local_transcriber.utils.shutil.which", return_value=None),
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=False),
patch("local_transcriber.utils._is_openvino_available", return_value=False),
):
assert detect_device("auto") == "cpu"
def test_detect_device_openvino_resolves_to_gpu():
"""--device openvino + GPU доступен → openvino-gpu."""
with patch("local_transcriber.utils._is_openvino_gpu_available", return_value=True):
assert detect_device("openvino") == "openvino-gpu"
def test_detect_device_openvino_resolves_to_cpu():
"""--device openvino + GPU недоступен → openvino-cpu."""
with patch("local_transcriber.utils._is_openvino_gpu_available", return_value=False):
assert detect_device("openvino") == "openvino-cpu"
# === _is_openvino_gpu_available ===
def test_is_openvino_gpu_available_true():
mock_core = MagicMock()
mock_core.return_value.available_devices = ["CPU", "GPU"]
with (
patch("local_transcriber.utils._is_openvino_available", return_value=True),
patch("local_transcriber.utils.Core", mock_core, create=True),
):
# Need to patch the import inside the function
with patch.dict("sys.modules", {"openvino": MagicMock(Core=mock_core)}):
assert _is_openvino_gpu_available() is True
def test_is_openvino_gpu_available_no_gpu():
mock_core = MagicMock()
mock_core.return_value.available_devices = ["CPU"]
with (
patch("local_transcriber.utils._is_openvino_available", return_value=True),
patch.dict("sys.modules", {"openvino": MagicMock(Core=mock_core)}),
):
assert _is_openvino_gpu_available() is False
def test_is_openvino_gpu_available_no_openvino():
"""OpenVINO не установлен → False."""
with patch("local_transcriber.utils._is_openvino_available", return_value=False):
assert _is_openvino_gpu_available() is False
def test_is_openvino_gpu_available_runtime_error():
"""Runtime error от OpenVINO → False (fail-safe)."""
with (
patch("local_transcriber.utils._is_openvino_available", return_value=True),
patch.dict("sys.modules", {"openvino": MagicMock(Core=MagicMock(side_effect=RuntimeError("broken")))}),
):
assert _is_openvino_gpu_available() is False
# === get_intel_gpu_name ===
def test_get_intel_gpu_name_success():
mock_core = MagicMock()
mock_core.return_value.get_property.return_value = "Intel(R) Arc(TM) 140T GPU (16GB) (iGPU)"
with patch.dict("sys.modules", {"openvino": MagicMock(Core=mock_core)}):
assert get_intel_gpu_name() == "Intel(R) Arc(TM) 140T GPU (16GB) (iGPU)"
def test_get_intel_gpu_name_error():
"""Ошибка → None (fail-safe)."""
with patch.dict("sys.modules", {"openvino": MagicMock(Core=MagicMock(side_effect=Exception("no gpu")))}):
assert get_intel_gpu_name() is None
def test_get_gpu_name_no_nvidia_smi():
with patch("shutil.which", return_value=None):
with patch("subprocess.run", side_effect=FileNotFoundError):