diff --git a/pyproject.toml b/pyproject.toml index 6cf9279..8524638 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,16 +8,23 @@ dependencies = [ "typer>=0.24.1,<1", "rich>=14.3.3,<15", "faster-whisper>=1.2.1,<2", + # CUDA-зависимости Windows проверены для этой сборки; см. ADR-001. + "ctranslate2==4.8.1", "httpx>=0.28,<1", "socksio>=1.0.0,<2", # При обновлении сверить блок tool.uv.dependency-metadata ниже. "sherpa-onnx==1.13.6", - "nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'", "openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')", "onnx-asr[cpu,hub]>=0.12,<0.13", "onnxruntime>=1.28,<2", ] +[project.optional-dependencies] +cuda = [ + # В 12.9.2 добавилась NVRTC: обновлять после проверки DLL и bootstrap. + "nvidia-cublas-cu12==12.9.1.4; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and (platform_machine == 'AMD64' or platform_machine == 'x86_64'))", +] + [project.scripts] transcribe = "local_transcriber.cli:app" diff --git a/src/local_transcriber/_cuda_bootstrap.py b/src/local_transcriber/_cuda_bootstrap.py index 4914252..bed21bd 100644 --- a/src/local_transcriber/_cuda_bootstrap.py +++ b/src/local_transcriber/_cuda_bootstrap.py @@ -1,12 +1,14 @@ """ -Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2. +Подготовка CUDA-библиотек из extra перед созданием CUDA-модели. Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"), но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12. -На Windows ctranslate2 решает это сам через os.add_dll_directory. +На Windows ctranslate2 не добавляет nvidia/cublas/bin в поиск DLL. +Регистрируем каталог и загружаем cuBLAS Lt, затем cuBLAS по полному пути. +Храним DLL и регистрацию каталога до завершения процесса. Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL -с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер +с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер кеширует загруженные библиотеки по soname — когда ctranslate2 потом вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle. @@ -21,20 +23,24 @@ import glob import os import sys +# Закрытие регистрации удаляет каталог из поиска DLL, поэтому храним handles. +_dll_directories: dict[str, object] = {} +_dll_libraries: dict[str, object] = {} + def ensure_cublas_loadable() -> None: - """Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса. + """Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса. - Вызывать ДО первого import ctranslate2. + Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap. Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12. """ - if sys.platform != "linux": + if sys.platform not in ("linux", "win32"): return try: import nvidia.cublas # type: ignore[import-untyped] except ImportError: - # nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup) + # Optional-пакет не установлен; системная cuBLAS всё ещё может работать. return # nvidia.cublas может быть namespace package (__file__ == None), @@ -42,6 +48,21 @@ def ensure_cublas_loadable() -> None: cublas_paths = getattr(nvidia.cublas, "__path__", None) if not cublas_paths: return + if sys.platform == "win32": + for package_path in cublas_paths: + bin_dir = os.path.join(package_path, "bin") + if not os.path.isdir(bin_dir): + continue + if bin_dir not in _dll_directories: + _dll_directories[bin_dir] = os.add_dll_directory(bin_dir) + # cuBLAS зависит от Lt; явный preload также обслуживает LoadLibrary + # внутри CTranslate2. Порядок здесь существенен. + for filename in ("cublasLt64_12.dll", "cublas64_12.dll"): + dll_path = os.path.join(bin_dir, filename) + if dll_path not in _dll_libraries: + _dll_libraries[dll_path] = ctypes.WinDLL(dll_path) + return + return cublas_lib_dir = os.path.join(cublas_paths[0], "lib") if not os.path.isdir(cublas_lib_dir): return diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index 35ff2d1..deddc81 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -6,16 +6,11 @@ from collections.abc import Callable from pathlib import Path from typing import Any -# CUDA bootstrap — должен быть ДО импорта faster_whisper / ctranslate2 +from huggingface_hub import snapshot_download +from huggingface_hub.errors import LocalEntryNotFoundError + from local_transcriber._cuda_bootstrap import ensure_cublas_loadable - -ensure_cublas_loadable() - -from faster_whisper import WhisperModel # noqa: E402 -from huggingface_hub import snapshot_download # noqa: E402 -from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402 - -from local_transcriber.types import ( # noqa: E402 +from local_transcriber.types import ( Segment, TranscribeResult, Word, @@ -94,6 +89,11 @@ class FasterWhisperBackend: cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4). """ + if device == "cuda": + ensure_cublas_loadable() + + from faster_whisper import WhisperModel + try: return WhisperModel( model_path, diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 5a6a04a..b6541bd 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -41,8 +41,8 @@ from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer from .transcriber import ( Segment, TranscribeResult, - _is_cuda_error, _transcribe_file, + cuda_error_hint, load_model, ) from .types import ( @@ -65,6 +65,13 @@ app = typer.Typer() console = Console(stderr=True) +def _print_cuda_hint(exc: BaseException) -> None: + """Показывает подсказку, не заменяя исходную ошибку.""" + hint = cuda_error_hint(exc) + if hint: + console.print(hint, style="yellow", markup=False) + + def _show_cli_warning( message: Warning | str, *_args: object, @@ -398,21 +405,15 @@ def main( except SystemExit: raise except ValueError as exc: + _print_cuda_hint(exc) console.print(f"Ошибка: {exc}", style="red bold") raise SystemExit(1) except (FileNotFoundError,) as exc: + _print_cuda_hint(exc) console.print(f"Ошибка: {exc}", style="red bold") raise SystemExit(1) except Exception as exc: - if _is_cuda_error(exc) and sys.platform == "win32": - console.print( - "GPU на Windows требует CUDA toolkit (включает cuBLAS).\n" - "Установите одним из способов:\n" - " choco install cuda\n" - " winget install -e --id Nvidia.CUDA\n" - "После установки перезапустите терминал.", - style="yellow", - ) + _print_cuda_hint(exc) if verbose: console.print_exception() else: @@ -758,6 +759,7 @@ def _run_batch( except KeyboardInterrupt: raise except Exception as exc: + _print_cuda_hint(exc) if verbose: console.print_exception() else: diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 1521835..12e647b 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -190,11 +190,52 @@ def ensure_model_available( def _is_cuda_error(exc: BaseException) -> bool: - """Проверка CUDA ошибок — используется в cli.py для Windows-диагностики.""" + """Проверяет, относится ли ошибка к CUDA-бэкенду.""" msg = str(exc).lower() return any(k in msg for k in ("cuda", "cublas", "cudnn", "out of memory")) +def cuda_error_hint(exc: BaseException) -> str | None: + """Подсказывает действие только для распознанной причины CUDA-ошибки.""" + msg = str(exc).lower() + if any(k in msg for k in ( + "no kernel image", "invalid device function", "unsupported gpu", + "cublas_status_arch_mismatch", "cuda_error_no_binary_for_gpu", + )): + return ( + "GPU несовместим с выбранным CUDA runtime. Установка extra не исправит " + "аппаратную несовместимость. Используйте --device onnx или --device cpu." + ) + if any(k in msg for k in ( + "driver version is insufficient", "no cuda-capable device", + "cuda error: no device", "cuda_error_no_device", "libcuda.so", "nvcuda.dll", + )): + return ( + "CUDA не видит совместимого GPU/драйвера. Проверьте драйвер NVIDIA " + "и совместимость устройства с runtime. Extra не устанавливает драйвер." + ) + if any(k in msg for k in ("cublas", "cudnn", "cudart")) and any( + k in msg for k in ( + "not found", "cannot be loaded", "could not load", "could not find", + "cannot open shared object", "no such file", "winerror 126", + ) + ): + return ( + "Не найдены CUDA-библиотеки или их зависимости. Подключите extra cuda " + "в окружении приложения:\n" + " Из клона: uv tool install --force '.[cuda]'\n" + " Для разработки: uv sync --extra cuda\n" + "При установке из Git используйте инструкцию CUDA в README. " + "На Windows также нужен Visual C++ Runtime x64." + ) + if "out of memory" in msg and any(k in msg for k in ("cuda", "cublas", "cudnn")): + return ( + "Недостаточно памяти для выбранного CUDA-пути. Выберите меньшую модель " + "или явно используйте --device onnx / --device cpu." + ) + return None + + def _is_backend_error(exc: BaseException, device: str) -> bool: """Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными).""" if isinstance(exc, WordTimestampsUnavailableError): diff --git a/tests/test_backend_faster_whisper.py b/tests/test_backend_faster_whisper.py index 36503d8..f889c0e 100644 --- a/tests/test_backend_faster_whisper.py +++ b/tests/test_backend_faster_whisper.py @@ -7,6 +7,25 @@ from local_transcriber.backends.faster_whisper import FasterWhisperBackend from local_transcriber.types import Word +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_bootstrap_only_for_explicit_cuda(monkeypatch, device): + """CPU-модель не загружает optional CUDA-библиотеки.""" + bootstrap = MagicMock() + model_factory = MagicMock() + monkeypatch.setattr( + "local_transcriber.backends.faster_whisper.ensure_cublas_loadable", bootstrap + ) + monkeypatch.setattr("faster_whisper.WhisperModel", model_factory) + + model = FasterWhisperBackend().create_model("/model", device, "float32") + + assert model is model_factory.return_value + assert bootstrap.call_count == (1 if device == "cuda" else 0) + model_factory.assert_called_once_with( + "/model", device=device, compute_type="float32", cpu_threads=0 + ) + + def test_transcribe_returns_canonical_words(tmp_path): audio = tmp_path / "audio.wav" raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет") diff --git a/tests/test_cli.py b/tests/test_cli.py index add639e..1586a41 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -688,7 +688,7 @@ def test_cli_load_model_called_with_model_name(tmp_path): def test_cli_windows_cuda_diagnostic(tmp_path): - """CUDA error on Windows prints choco/winget install hint.""" + """При проблеме драйвера Windows не предлагает установку Toolkit.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") model = _make_model() @@ -712,8 +712,10 @@ def test_cli_windows_cuda_diagnostic(tmp_path): out = runner.invoke(app, [str(audio), "--device", "cuda"]) assert out.exit_code == 1 - assert "choco install cuda" in out.output - assert "winget install" in out.output + assert "драйвер" in out.output + assert "CUDA error: no device" in out.output + assert "winget install" not in out.output + assert "uv sync --extra" not in out.output def test_cli_linux_cuda_error_no_windows_hint(tmp_path): @@ -744,6 +746,75 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path): assert "choco install cuda" not in out.output +@pytest.mark.parametrize("from_config", [False, True]) +@pytest.mark.parametrize("phase", ["load", "single", "batch"]) +@pytest.mark.parametrize( + ("message", "hint", "install_hint"), + [ + ("Library cublas64_12.dll is not found or cannot be loaded", "Не найдены", True), + ("libcublas.so.12: cannot open shared object file", "Не найдены", True), + ("CUDA error: no kernel image is available", "GPU несовместим", False), + ("CUDA driver version is insufficient for CUDA runtime version", "драйвер", False), + ("CUDA out of memory", "Недостаточно памяти", False), + ], +) +def test_explicit_cuda_errors_preserve_cause_without_fallback( + tmp_path, monkeypatch, from_config, phase, message, hint, install_hint +): + """CLI/TOML сохраняют CUDA при загрузке, обработке одного файла и батча.""" + monkeypatch.chdir(tmp_path) + files = [tmp_path / "one.wav"] + if phase == "batch": + files.append(tmp_path / "two.wav") + for file in files: + file.write_bytes(b"audio") + if from_config: + (tmp_path / ".transcriber.toml").write_text('device = "cuda"\n') + args = [str(file) for file in files] + if not from_config: + args += ["--device", "cuda"] + backend = _make_backend() + error = RuntimeError(message) + if phase == "load": + backend.create_model.side_effect = error + else: + backend.transcribe.side_effect = error + + with patch("local_transcriber.transcriber.get_backend", return_value=backend) as get_backend: + out = runner.invoke(app, args) + + assert out.exit_code == 1 + assert message in out.output + assert hint in out.output + assert ("uv sync --extra cuda" in out.output) is install_hint + assert "winget install" not in out.output + assert "Переключение на CPU" not in out.output + assert all(call.args[0] == "cuda" for call in get_backend.call_args_list) + + +def test_default_cli_stays_on_onnx_with_nvidia_driver(tmp_path, monkeypatch): + """Обычный запуск с nvidia-smi использует прежнюю CPU-модель ONNX.""" + audio = tmp_path / "test.wav" + audio.write_bytes(b"audio") + backend = _make_backend() + backend.actual_ov_device = None + backend.actual_compute_type = "int8" + backend.transcribe.return_value = _make_result(device_used="onnx") + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("shutil.which", return_value="/usr/bin/nvidia-smi"), + patch("local_transcriber.transcriber.get_backend", return_value=backend) as get_backend, + patch("local_transcriber.cli.write_transcript"), + patch("local_transcriber._cuda_bootstrap.ensure_cublas_loadable") as bootstrap, + ): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 0, out.output + get_backend.assert_called_once_with("onnx", compute_type_explicit=False) + assert backend.ensure_model_available.call_args.args[:2] == ("gigaam-v3-e2e-rnnt", "int8") + bootstrap.assert_not_called() + + def test_cli_device_fallback_warning(tmp_path): """When auto-detected device differs from actual, show fallback warning.""" audio = tmp_path / "test.mp3" diff --git a/tests/test_cuda_bootstrap.py b/tests/test_cuda_bootstrap.py index 0638c0f..4d63a94 100644 --- a/tests/test_cuda_bootstrap.py +++ b/tests/test_cuda_bootstrap.py @@ -48,12 +48,73 @@ def test_ensure_cublas_loads_library(monkeypatch, tmp_path): assert calls[0][1] == ctypes.RTLD_GLOBAL -def test_ensure_cublas_skips_non_linux(monkeypatch): - """На не-Linux платформах -- no-op.""" - monkeypatch.setattr(sys, "platform", "win32") +def test_ensure_cublas_skips_macos(monkeypatch): + """На macOS bootstrap не загружает CUDA.""" + monkeypatch.setattr(sys, "platform", "darwin") ensure_cublas_loadable() # не должно бросать исключений +def test_windows_optional_package_absent(monkeypatch): + """Windows работает без extra и не меняет поиск DLL.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "nvidia.cublas", None) + ensure_cublas_loadable() + + +@pytest.mark.parametrize("broken", [False, True]) +def test_windows_preloads_dlls_and_keeps_handles(monkeypatch, tmp_path, broken): + """DLL загружаются по порядку один раз, ошибки не скрываются.""" + from local_transcriber import _cuda_bootstrap as bootstrap + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + nvidia = types.ModuleType("nvidia") + nvidia.__path__ = [] + cublas = types.ModuleType("nvidia.cublas") + cublas.__path__ = [str(tmp_path)] + nvidia.cublas = cublas + monkeypatch.setitem(sys.modules, "nvidia", nvidia) + monkeypatch.setitem(sys.modules, "nvidia.cublas", cublas) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(bootstrap, "_dll_directories", {}) + monkeypatch.setattr(bootstrap, "_dll_libraries", {}) + calls = [] + directory_handle = object() + library_handle = object() + failure = OSError("cublas64_12.dll: dependency not found") + + def add_directory(path): + calls.append(("directory", path)) + return directory_handle + + def load_library(path): + calls.append(("load", path)) + if broken: + raise failure + return library_handle + + monkeypatch.setattr(os, "add_dll_directory", add_directory, raising=False) + monkeypatch.setattr(ctypes, "WinDLL", load_library, raising=False) + original_path = os.environ.get("PATH") + + if broken: + with pytest.raises(OSError) as caught: + ensure_cublas_loadable() + assert caught.value is failure + assert bootstrap._dll_libraries == {} + else: + ensure_cublas_loadable() + ensure_cublas_loadable() + assert calls == [ + ("directory", str(bin_dir)), + ("load", str(bin_dir / "cublasLt64_12.dll")), + ("load", str(bin_dir / "cublas64_12.dll")), + ] + assert list(bootstrap._dll_libraries.values()) == [library_handle] * 2 + assert bootstrap._dll_directories[str(bin_dir)] is directory_handle + assert os.environ.get("PATH") == original_path + + def _nvidia_cublas_installed() -> bool: """Проверяет, что pip-пакет nvidia-cublas-cu12 установлен.""" try: diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 70d1f6d..4212e6a 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -7,6 +7,7 @@ from local_transcriber.transcriber import ( Segment, TranscribeResult, _transcribe_file, + cuda_error_hint, ensure_model_available, load_model, transcribe, @@ -16,6 +17,21 @@ from local_transcriber.types import WordTimestampsUnavailableError # === Helpers === +@pytest.mark.parametrize( + "error", + [RuntimeError("out of memory"), RuntimeError("CUDA unknown error")], +) +def test_unknown_error_does_not_suggest_cuda_installation(error): + """CPU OOM и неизвестная причина не получают совет по установке CUDA.""" + assert cuda_error_hint(error) is None + + +def test_windows_loader_error_suggests_extra(): + """OSError загрузчика DLL распознаётся без обёртки RuntimeError.""" + error = OSError("[WinError 126] Could not find module 'cublas64_12.dll'") + assert "uv sync --extra cuda" in cuda_error_hint(error) + + def _make_result( count: int = 2, language: str = "ru", diff --git a/uv.lock b/uv.lock index 1cafa88..76ebb05 100644 --- a/uv.lock +++ b/uv.lock @@ -288,9 +288,9 @@ name = "local-transcriber" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "ctranslate2" }, { name = "faster-whisper" }, { name = "httpx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "onnx-asr", extra = ["cpu", "hub"] }, { name = "onnxruntime" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" }, @@ -300,6 +300,11 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +cuda = [ + { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, +] + [package.dev-dependencies] dev = [ { name = "kaldi-native-fbank" }, @@ -311,9 +316,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "ctranslate2", specifier = "==4.8.1" }, { name = "faster-whisper", specifier = ">=1.2.1,<2" }, { name = "httpx", specifier = ">=0.28,<1" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" }, + { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'cuda') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'cuda') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'cuda')", specifier = "==12.9.1.4" }, { name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" }, { name = "onnxruntime", specifier = ">=1.28,<2" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.3.0.0,<2026.4" }, @@ -322,6 +328,7 @@ requires-dist = [ { name = "socksio", specifier = ">=1.0.0,<2" }, { name = "typer", specifier = ">=0.24.1,<1" }, ] +provides-extras = ["cuda"] [package.metadata.requires-dev] dev = [ @@ -445,6 +452,7 @@ version = "12.9.1.4" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/3c/aa88abe01f3be3d1f8f787d1d33dc83e76fec05945f9a28fbb41cfb99cd5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:453611eb21a7c1f2c2156ed9f3a45b691deda0440ec550860290dc901af5b4c2", size = 581242350, upload-time = "2025-06-05T20:04:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/a17fade6567c57452cfc8f967a40d1035bb9301db52f27808167fbb2be2f/nvidia_cublas_cu12-12.9.1.4-py3-none-win_amd64.whl", hash = "sha256:1e5fee10662e6e52bd71dec533fbbd4971bb70a5f24f3bc3793e5c2e9dc640bf", size = 553153899, upload-time = "2025-06-05T20:13:35.556Z" }, ] [[package]]