diff --git a/README.md b/README.md index cff9cfd..d792660 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ transcribe *.mp4 --force | `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу | | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu) | | `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU) | Тип вычислений | +| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | | `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--verbose` | `-v` | — | Подробный вывод | diff --git a/docs/gpu.md b/docs/gpu.md index 6fb0dbe..d2be8de 100644 --- a/docs/gpu.md +++ b/docs/gpu.md @@ -105,9 +105,16 @@ CTranslate2 по умолчанию использует 4 потока. На м задать число потоков равным числу **физических ядер** (не виртуальных): ```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. ### Почему CPU бэкенд качественнее OpenVINO @@ -138,21 +145,44 @@ SMT/Hyper-Threading не помогает — 16 потоков на 8-ядер На NVIDIA GPU дефолт — `float16`, и для большинства случаев это оптимальный выбор. -Однако `large-v3` с `int8` на GPU может давать **галлюцинации** на длинных записях -(зафиксировано: повтор фраз ×25, потеря контента). Причина — чистый int8 без float -аккумулятора теряет точность в глубокой модели. +### Результаты тестирования compute_type на GPU (RTX 3060 Laptop, 6 GB) -**Альтернатива для тестирования:** +**Скорость (файл 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` | **Дефолт** — отлично работает | -| `int8_float32` | Потенциально быстрее float16, качество на уровне float32. Не протестировано — ждём feedback | -| `int8_float16` | int8 веса + float16 аккумулятор. Может быть быстрее int8_float32 при приемлемом качестве | -| `int8` | Быстрый, но рискует галлюцинациями на large-v3 и длинных записях | +| medium | **127с** (~22x реалтайм) | +| large-v3 | 271с (~10x реалтайм) | -Если у вас есть NVIDIA GPU и вы протестировали `int8_float32` / `int8_float16` — -поделитесь результатами через [issues](https://github.com/dementev-dev/local-transcriber/issues). +**Качество (файл 16 мин):** + +| Модель | 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): -| Конфигурация | 16 мин файл | 42 мин файл | Отн. скорость | +| Конфигурация | 16 мин файл | 46 мин файл | Отн. скорость | |-------------|-------------|-------------|---------------| -| GPU + medium float16 | ~35с | ~133с | ~19x реалтайм | -| GPU + large-v3 float16 | ~90с | ~350с | ~7x реалтайм | +| GPU + medium float16 | **53с** | **127с** | **~18-22x реалтайм** | +| 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 + medium int8** | **171-205с** | **413с** | **~4-6x реалтайм** | | **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 + 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). -| Конфигурация | Качество (длинная запись, 42 мин) | Проблемы | -|---|---|---| -| large-v3 int8 GPU | Плохо | Галлюцинации (фразы ×25), потеря контента | -| large-v3 float16 GPU | Отлично | — | -| medium float16 GPU | Хорошо | Редкие мелкие ляпы в терминах | -| medium int8_float32 CPU | Отлично | Качество ≈ float32, на уровне облачных сервисов | -| medium float32 CPU | Хорошо | Сопоставимо с large-v3 int8, без галлюцинаций | -| large-v3 int8 CPU | Хорошо | Без галлюцинаций (на коротких файлах) | +| Конфигурация | Качество (короткие, ≤16 мин) | Качество (длинные, >40 мин) | Проблемы | +|---|---|---|---| +| medium float16 GPU | **Отлично** | **Отлично** | Единичные ляпы в терминах ("email" вместо "ML") | +| medium int8_float32 GPU | **Отлично** | — | Качество ≈ float16, но на 20% медленнее | +| medium int8_float16 GPU | Хорошо | — | 1-2 ошибки | +| large-v3 float16 GPU | Хорошо | **Плохо** | Галлюцинации на длинных записях (мусор на 3+ языках) | +| large-v3 int8_float32 GPU | **Плохо** | — | Повтор фраз ("Ага" ×18), искажения | +| 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 может ошибиться и выдать мусор -2. **float16/float32 стабильнее int8** — особенно на записях >20 минут -3. **medium + float16 на GPU — лучший баланс** скорости и качества для повседневного использования -4. **large-v3 + float16 на GPU** — для максимального качества важных записей +2. **medium + float16 на GPU — лучший баланс** скорости и качества для любых записей +3. **large-v3 ненадёжен на длинных записях (>20 мин)** — галлюцинации даже с float16; + medium стабильнее на любой длине +4. **medium устойчив к квантизации** — все варианты compute_type дают приемлемый результат; + large-v3 деградирует катастрофически при любом int8 5. **CPU: int8_float32 — лучший баланс** — 1.5x быстрее float32 при том же качестве 6. **OpenVINO быстрее, но CPU бэкенд качественнее** — разница в pipeline декодирования (temperature fallback, фильтры галлюцинаций), а не в квантизации @@ -235,11 +273,13 @@ VAD) доступен только через CTranslate2, т.е. на CPU и NV не зависящая от inference engine. Это даст Intel Arc / AMD GPU то же качество, что сейчас есть только у CUDA-пользователей. -### CUDA: тестирование int8_float32 / int8_float16 +### CUDA: int8_float32 / int8_float16 — протестировано На CPU `int8_float32` показал качество на уровне float32 при 1.5x ускорении. -На CUDA аналогичный эффект может решить проблему галлюцинаций large-v3 с int8 -при сохранении скорости GPU. Требуется тестирование на реальном NVIDIA GPU. +На GPU (RTX 3060) результат другой: для **medium** int8_float32 даёт сопоставимое +качество, но на 20% медленнее float16 — выигрыша нет. Для **large-v3** любой int8 +вариант (включая int8_float32) вызывает галлюцинации — float32 аккумулятор не спасает +глубокую модель на GPU. Вывод: **float16 остаётся оптимальным дефолтом для CUDA**. ## Troubleshooting diff --git a/src/local_transcriber/backends/base.py b/src/local_transcriber/backends/base.py index 0435064..63f7fa4 100644 --- a/src/local_transcriber/backends/base.py +++ b/src/local_transcriber/backends/base.py @@ -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( diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index 4f22c6d..f44fa24 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -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( diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index b2f7189..0855d63 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -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() diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 0b2b0ea..497cc84 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -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: diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 84c515a..fe90a44 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -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 diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index fd08a63..df44470 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -31,7 +31,7 @@ def test_resolve_repo_explicit_unsupported_pair_raises(): """Явный --compute-type с несуществующей парой → ошибка.""" backend = OpenVINOBackend(compute_type_explicit=True) 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(): diff --git a/tests/test_cli.py b/tests/test_cli.py index 9757b9c..9f6afb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -862,3 +862,62 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path): assert out.exit_code == 0 # detect_device("openvino") resolved to "openvino-gpu", load_model receives it 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 diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index afc60df..e23d360 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -321,6 +321,34 @@ def test_load_model_returns_backend_and_path(mock_get_backend): 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 === @@ -527,6 +555,36 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend): 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") def test_openvino_gpu_strict_device_no_fallback(mock_get_backend): """strict_device=True + OpenVINO GPU ошибка → raise."""