diff --git a/pyproject.toml b/pyproject.toml index bc1700b..e55289d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "rich", "faster-whisper>=1.2.1", "socksio>=1.0.0", + "nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'", ] [project.scripts] diff --git a/src/local_transcriber/_cuda_bootstrap.py b/src/local_transcriber/_cuda_bootstrap.py new file mode 100644 index 0000000..4914252 --- /dev/null +++ b/src/local_transcriber/_cuda_bootstrap.py @@ -0,0 +1,71 @@ +""" +Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2. + +Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"), +но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12. +На Windows ctranslate2 решает это сам через os.add_dll_directory. + +Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL +с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер +кеширует загруженные библиотеки по soname — когда ctranslate2 потом +вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle. + +Почему нельзя просто os.environ["LD_LIBRARY_PATH"] = ...: +На Linux/glibc динамический линкер (ld.so) кеширует пути поиска +при первом вызове и НЕ перечитывает LD_LIBRARY_PATH из environ +в рамках уже запущенного процесса. +""" + +import ctypes +import glob +import os +import sys + + +def ensure_cublas_loadable() -> None: + """Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса. + + Вызывать ДО первого import ctranslate2. + Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12. + """ + if sys.platform != "linux": + return + + try: + import nvidia.cublas # type: ignore[import-untyped] + except ImportError: + # nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup) + return + + # nvidia.cublas может быть namespace package (__file__ == None), + # используем __path__ для определения директории пакета + cublas_paths = getattr(nvidia.cublas, "__path__", None) + if not cublas_paths: + return + cublas_lib_dir = os.path.join(cublas_paths[0], "lib") + if not os.path.isdir(cublas_lib_dir): + return + + # Ищем libcublas.so.12* (например libcublas.so.12, libcublas.so.12.4.2.1) + # Загружаем с RTLD_GLOBAL чтобы символы были видны ctranslate2 + for so_path in sorted(glob.glob(os.path.join(cublas_lib_dir, "libcublas.so.12*"))): + try: + ctypes.CDLL(so_path, mode=ctypes.RTLD_GLOBAL) + except OSError: + continue + break # достаточно загрузить одну versioned .so + + +def is_cublas_available() -> bool: + """Проверяет, что libcublas.so.12 реально резолвится через dlopen. + + Используется в тестах для проверки, что bootstrap сработал. + На платформах без CUDA возвращает False. + """ + if sys.platform != "linux": + return False + try: + ctypes.CDLL("libcublas.so.12") + return True + except OSError: + return False diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 0548379..5d617b3 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -1,3 +1,4 @@ +import sys import time from pathlib import Path @@ -6,7 +7,7 @@ from rich.console import Console from rich.status import Status from .formatter import format_transcript, write_transcript -from .transcriber import Segment, ensure_model_available, transcribe +from .transcriber import Segment, _is_cuda_error, ensure_model_available, transcribe from .utils import build_output_path, check_ffmpeg, detect_device, get_gpu_name, validate_input_file app = typer.Typer() @@ -27,7 +28,9 @@ def main( check_ffmpeg() validated_file = validate_input_file(file) + requested_device = device resolved_device = detect_device(device) + strict = requested_device != "auto" output_path = build_output_path(validated_file, output) console.print(f"Файл: [bold]{validated_file.name}[/bold]") @@ -38,19 +41,45 @@ def main( def on_segment(seg: Segment) -> None: console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") - with Status("Подготавливаю запуск...", console=console) as status: - result = transcribe( - file_path=validated_file, - model_name=model_path, - device=resolved_device, - compute_type=compute_type, - language=language if language != "auto" else None, - on_segment=on_segment if verbose else None, - on_status=status.update, - ) + try: + with Status("Подготавливаю запуск...", console=console) as status: + result = transcribe( + file_path=validated_file, + model_name=model_path, + device=resolved_device, + compute_type=compute_type, + language=language if language != "auto" else None, + on_segment=on_segment if verbose else None, + on_status=status.update, + strict_device=strict, + ) + except (RuntimeError, ValueError) 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", + ) + raise + + if result.device_used != resolved_device: + if requested_device == "auto": + console.print( + f"Определено устройство {resolved_device}, " + f"но использовано {result.device_used} (fallback)", + style="yellow", + ) + else: + console.print( + f"Запрошено {requested_device}, использовано {result.device_used}", + style="yellow", + ) if len(result.segments) == 0: - console.print(f"⚠ Речь не обнаружена в файле {validated_file.name}", style="yellow") + console.print(f"Речь не обнаружена в файле {validated_file.name}", style="yellow") if result.device_used == "cuda": gpu_name = get_gpu_name() @@ -70,7 +99,7 @@ def main( write_transcript(content, output_path) elapsed = time.monotonic() - start - console.print(f"✓ Транскрипт сохранён: [bold]{output_path}[/bold]", style="green") + console.print(f"Транскрипт сохранён: [bold]{output_path}[/bold]", style="green") console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с") diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 032d841..da7e7a0 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -3,7 +3,12 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from faster_whisper import WhisperModel +# Должен быть ДО импорта faster_whisper / ctranslate2 +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 from huggingface_hub.errors import LocalEntryNotFoundError @@ -55,6 +60,7 @@ def transcribe( language: str | None = None, on_segment: Callable[[Segment], None] | None = None, on_status: Callable[[str], None] | None = None, + strict_device: bool = False, ) -> TranscribeResult: actual_device = device lang_arg = language if language and language != "auto" else None @@ -64,6 +70,8 @@ def transcribe( model = _create_model(model_name, device, compute_type) except (RuntimeError, ValueError) as exc: if device != "cpu" and _is_cuda_error(exc): + if strict_device: + raise warnings.warn( f"Не удалось загрузить модель на {device}: {exc}. " "Переключение на CPU.", @@ -80,6 +88,8 @@ def transcribe( segments, info = _run_transcription(model, file_path, lang_arg, on_segment) except (RuntimeError, ValueError) as exc: if actual_device != "cpu" and _is_cuda_error(exc): + if strict_device: + raise warnings.warn( f"CUDA ошибка при транскрипции: {exc}. " "Переключение на CPU и повтор.", diff --git a/tests/test_cli.py b/tests/test_cli.py index 814563b..cc75201 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest from typer.testing import CliRunner from local_transcriber.cli import app @@ -234,3 +235,101 @@ def test_cli_resolves_model_before_transcribe(tmp_path): mock_ensure_model.assert_called_once() call_kwargs = mock_transcribe.call_args[1] assert call_kwargs["model_name"] == "/models/large-v3" + + +def test_cli_windows_cuda_diagnostic(tmp_path): + """CUDA error on Windows prints choco/winget install hint.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cuda"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("CUDA error: no device")), + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "win32" + 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 + + +def test_cli_linux_cuda_error_no_windows_hint(tmp_path): + """CUDA error on Linux does NOT print Windows-specific hint.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cuda"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("CUDA error: no device")), + patch("local_transcriber.cli.sys") as mock_sys, + ): + mock_sys.platform = "linux" + out = runner.invoke(app, [str(audio), "--device", "cuda"]) + + assert out.exit_code == 1 + assert "choco install cuda" not in out.output + + +def test_cli_device_fallback_warning(tmp_path): + """When auto-detected device differs from actual, show fallback warning.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + result = _make_result(device_used="cpu") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cuda"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", return_value=result), + patch("local_transcriber.cli.write_transcript"), + ): + # --device auto (default) -> detect_device returns "cuda" but result is "cpu" + out = runner.invoke(app, [str(audio)]) + + assert "fallback" in out.output + + +def test_cli_strict_device_passed_to_transcribe(tmp_path): + """--device cuda passes strict_device=True; default auto passes False.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + result = _make_result(device_used="cuda") + mock_transcribe = MagicMock(return_value=result) + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cuda"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", mock_transcribe), + patch("local_transcriber.cli.write_transcript"), + patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), + ): + runner.invoke(app, [str(audio), "--device", "cuda"]) + + assert mock_transcribe.call_args[1]["strict_device"] is True + + mock_transcribe.reset_mock() + result_cpu = _make_result(device_used="cpu") + mock_transcribe.return_value = result_cpu + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", mock_transcribe), + patch("local_transcriber.cli.write_transcript"), + ): + runner.invoke(app, [str(audio)]) + + assert mock_transcribe.call_args[1]["strict_device"] is False diff --git a/tests/test_cuda_bootstrap.py b/tests/test_cuda_bootstrap.py new file mode 100644 index 0000000..0638c0f --- /dev/null +++ b/tests/test_cuda_bootstrap.py @@ -0,0 +1,107 @@ +import ctypes +import glob +import os +import sys +import types + +import pytest + +from local_transcriber._cuda_bootstrap import ensure_cublas_loadable, is_cublas_available + + +def test_ensure_cublas_no_nvidia_package(monkeypatch): + """Без nvidia-cublas-cu12 -- ничего не падает.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setitem(sys.modules, "nvidia.cublas", None) + + ensure_cublas_loadable() # не должно бросать исключений + + +def test_ensure_cublas_loads_library(monkeypatch, tmp_path): + """С nvidia.cublas -- вызывает ctypes.CDLL с полным путём и RTLD_GLOBAL.""" + monkeypatch.setattr(sys, "platform", "linux") + + # Создаём фейковый nvidia.cublas с lib/libcublas.so.12 + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + fake_so = lib_dir / "libcublas.so.12" + fake_so.touch() + + # Мокаем родительский пакет nvidia (иначе import nvidia.cublas упадёт) + fake_nvidia = types.ModuleType("nvidia") + fake_nvidia.__path__ = [str(tmp_path)] + + fake_cublas = types.ModuleType("nvidia.cublas") + fake_cublas.__path__ = [str(tmp_path)] + fake_nvidia.cublas = fake_cublas + + monkeypatch.setitem(sys.modules, "nvidia", fake_nvidia) + monkeypatch.setitem(sys.modules, "nvidia.cublas", fake_cublas) + + calls = [] + monkeypatch.setattr(ctypes, "CDLL", lambda path, mode=0: calls.append((path, mode))) + + ensure_cublas_loadable() + + assert len(calls) == 1 + assert calls[0][0] == str(fake_so) + assert calls[0][1] == ctypes.RTLD_GLOBAL + + +def test_ensure_cublas_skips_non_linux(monkeypatch): + """На не-Linux платформах -- no-op.""" + monkeypatch.setattr(sys, "platform", "win32") + ensure_cublas_loadable() # не должно бросать исключений + + +def _nvidia_cublas_installed() -> bool: + """Проверяет, что pip-пакет nvidia-cublas-cu12 установлен.""" + try: + import nvidia.cublas # type: ignore[import-untyped] + + cublas_paths = getattr(nvidia.cublas, "__path__", None) + if not cublas_paths: + return False + lib_dir = os.path.join(cublas_paths[0], "lib") + return any(glob.glob(os.path.join(lib_dir, "libcublas.so.12*"))) + except ImportError: + return False + + +def _system_cublas_available() -> bool: + """Проверяет, что libcublas.so.12 доступна через системный линкер (без bootstrap).""" + try: + ctypes.CDLL("libcublas.so.12") + return True + except OSError: + return False + + +@pytest.mark.skipif( + sys.platform != "linux", + reason="CUDA bootstrap только для Linux", +) +@pytest.mark.skipif( + not _nvidia_cublas_installed(), + reason="nvidia-cublas-cu12 не установлен", +) +def test_bootstrap_makes_cublas_resolvable(): + """Bootstrap из pip-пакета делает libcublas.so.12 резолвимой. + + Тест проходит ТОЛЬКО если: + 1. nvidia-cublas-cu12 установлен (иначе skip) + 2. libcublas НЕ доступна через системный линкер до bootstrap + (иначе skip -- тест не может доказать, что сработал именно bootstrap) + 3. После ensure_cublas_loadable() -- libcublas доступна + """ + if _system_cublas_available(): + pytest.skip( + "libcublas.so.12 уже доступна через системный линкер -- " + "невозможно проверить, что сработал именно bootstrap" + ) + + ensure_cublas_loadable() + assert is_cublas_available(), ( + "nvidia-cublas-cu12 установлен, но после bootstrap " + "libcublas.so.12 всё ещё не резолвится через dlopen" + ) diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 9e5f1ff..a23d3eb 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -355,3 +355,61 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path): with pytest.raises(ValueError, match="Неполная локальная модель"): ensure_model_available(str(model_dir)) + + +@patch("local_transcriber.transcriber.WhisperModel") +def test_transcribe_strict_cuda_error(mock_model_cls): + """strict_device=True + CUDA error -> raise, без fallback.""" + mock_model_cls.side_effect = RuntimeError("CUDA out of memory") + + with pytest.raises(RuntimeError, match="CUDA out of memory"): + transcribe( + file_path=Path("test.mp3"), + model_name="tiny", + device="cuda", + strict_device=True, + ) + + +@patch("local_transcriber.transcriber.WhisperModel") +def test_transcribe_non_strict_cuda_fallback(mock_model_cls): + """strict_device=False + CUDA error -> fallback на CPU.""" + raw_segments = _make_raw_segments(2) + info = _make_info() + + cpu_instance = MagicMock() + cpu_instance.transcribe.return_value = (iter(raw_segments), info) + + def model_side_effect(model_name, device, compute_type): + if device == "cuda": + raise RuntimeError("CUDA out of memory") + return cpu_instance + + mock_model_cls.side_effect = model_side_effect + + with pytest.warns(UserWarning, match="Переключение на CPU"): + result = transcribe( + file_path=Path("test.mp3"), + model_name="tiny", + device="cuda", + strict_device=False, + ) + + assert result.device_used == "cpu" + assert len(result.segments) == 2 + + +@patch("local_transcriber.transcriber.WhisperModel") +def test_transcribe_strict_cuda_error_during_transcription(mock_model_cls): + """strict_device=True + CUDA error during transcription -> raise.""" + cuda_instance = MagicMock() + cuda_instance.transcribe.side_effect = RuntimeError("CUDA error during transcription") + mock_model_cls.return_value = cuda_instance + + with pytest.raises(RuntimeError, match="CUDA error during transcription"): + transcribe( + file_path=Path("test.mp3"), + model_name="tiny", + device="cuda", + strict_device=True, + ) diff --git a/uv.lock b/uv.lock index b6c8429..b89bc09 100644 --- a/uv.lock +++ b/uv.lock @@ -300,6 +300,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "faster-whisper" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "rich" }, { name = "socksio" }, { name = "typer" }, @@ -313,6 +314,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "faster-whisper", specifier = ">=1.2.1" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4" }, { name = "rich" }, { name = "socksio", specifier = ">=1.0.0" }, { name = "typer" }, @@ -498,6 +500,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, ] +[[package]] +name = "nvidia-cublas-cu12" +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" }, +] + [[package]] name = "onnxruntime" version = "1.24.3"