feat(cuda): добавлен прозрачный GPU runtime для Linux/WSL2
- Зачем:
- ctranslate2 требует libcublas.so.12 для CUDA, но не бандлит её в wheel —
без системного CUDA toolkit GPU не работает из коробки.
- Что:
- добавлена зависимость nvidia-cublas-cu12 (Linux x86_64).
- создан _cuda_bootstrap.py: preload libcublas через ctypes.CDLL(RTLD_GLOBAL)
до импорта ctranslate2 (LD_LIBRARY_PATH не работает — glibc кеширует пути).
- добавлен strict_device в transcriber: --device cuda/cpu не делает silent fallback.
- CLI: диагностика requested vs resolved device, Windows CUDA-подсказка.
- Проверка:
- uv run pytest -v (52 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ dependencies = [
|
|||||||
"rich",
|
"rich",
|
||||||
"faster-whisper>=1.2.1",
|
"faster-whisper>=1.2.1",
|
||||||
"socksio>=1.0.0",
|
"socksio>=1.0.0",
|
||||||
|
"nvidia-cublas-cu12>=12.4; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ from rich.console import Console
|
|||||||
from rich.status import Status
|
from rich.status import Status
|
||||||
|
|
||||||
from .formatter import format_transcript, write_transcript
|
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
|
from .utils import build_output_path, check_ffmpeg, detect_device, get_gpu_name, validate_input_file
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
@@ -27,7 +28,9 @@ def main(
|
|||||||
|
|
||||||
check_ffmpeg()
|
check_ffmpeg()
|
||||||
validated_file = validate_input_file(file)
|
validated_file = validate_input_file(file)
|
||||||
|
requested_device = device
|
||||||
resolved_device = detect_device(device)
|
resolved_device = detect_device(device)
|
||||||
|
strict = requested_device != "auto"
|
||||||
output_path = build_output_path(validated_file, output)
|
output_path = build_output_path(validated_file, output)
|
||||||
|
|
||||||
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
|
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
|
||||||
@@ -38,19 +41,45 @@ def main(
|
|||||||
def on_segment(seg: Segment) -> None:
|
def on_segment(seg: Segment) -> None:
|
||||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||||
|
|
||||||
with Status("Подготавливаю запуск...", console=console) as status:
|
try:
|
||||||
result = transcribe(
|
with Status("Подготавливаю запуск...", console=console) as status:
|
||||||
file_path=validated_file,
|
result = transcribe(
|
||||||
model_name=model_path,
|
file_path=validated_file,
|
||||||
device=resolved_device,
|
model_name=model_path,
|
||||||
compute_type=compute_type,
|
device=resolved_device,
|
||||||
language=language if language != "auto" else None,
|
compute_type=compute_type,
|
||||||
on_segment=on_segment if verbose else None,
|
language=language if language != "auto" else None,
|
||||||
on_status=status.update,
|
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:
|
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":
|
if result.device_used == "cuda":
|
||||||
gpu_name = get_gpu_name()
|
gpu_name = get_gpu_name()
|
||||||
@@ -70,7 +99,7 @@ def main(
|
|||||||
write_transcript(content, output_path)
|
write_transcript(content, output_path)
|
||||||
|
|
||||||
elapsed = time.monotonic() - start
|
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}с")
|
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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 import snapshot_download
|
||||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ def transcribe(
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
on_segment: Callable[[Segment], None] | None = None,
|
on_segment: Callable[[Segment], None] | None = None,
|
||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
|
strict_device: bool = False,
|
||||||
) -> TranscribeResult:
|
) -> TranscribeResult:
|
||||||
actual_device = device
|
actual_device = device
|
||||||
lang_arg = language if language and language != "auto" else None
|
lang_arg = language if language and language != "auto" else None
|
||||||
@@ -64,6 +70,8 @@ def transcribe(
|
|||||||
model = _create_model(model_name, device, compute_type)
|
model = _create_model(model_name, device, compute_type)
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
if device != "cpu" and _is_cuda_error(exc):
|
if device != "cpu" and _is_cuda_error(exc):
|
||||||
|
if strict_device:
|
||||||
|
raise
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"Не удалось загрузить модель на {device}: {exc}. "
|
f"Не удалось загрузить модель на {device}: {exc}. "
|
||||||
"Переключение на CPU.",
|
"Переключение на CPU.",
|
||||||
@@ -80,6 +88,8 @@ def transcribe(
|
|||||||
segments, info = _run_transcription(model, file_path, lang_arg, on_segment)
|
segments, info = _run_transcription(model, file_path, lang_arg, on_segment)
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
if actual_device != "cpu" and _is_cuda_error(exc):
|
if actual_device != "cpu" and _is_cuda_error(exc):
|
||||||
|
if strict_device:
|
||||||
|
raise
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"CUDA ошибка при транскрипции: {exc}. "
|
f"CUDA ошибка при транскрипции: {exc}. "
|
||||||
"Переключение на CPU и повтор.",
|
"Переключение на CPU и повтор.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from local_transcriber.cli import app
|
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()
|
mock_ensure_model.assert_called_once()
|
||||||
call_kwargs = mock_transcribe.call_args[1]
|
call_kwargs = mock_transcribe.call_args[1]
|
||||||
assert call_kwargs["model_name"] == "/models/large-v3"
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -355,3 +355,61 @@ def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path):
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="Неполная локальная модель"):
|
with pytest.raises(ValueError, match="Неполная локальная модель"):
|
||||||
ensure_model_available(str(model_dir))
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "faster-whisper" },
|
{ name = "faster-whisper" },
|
||||||
|
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "socksio" },
|
{ name = "socksio" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
@@ -313,6 +314,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "faster-whisper", specifier = ">=1.2.1" },
|
{ 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 = "rich" },
|
||||||
{ name = "socksio", specifier = ">=1.0.0" },
|
{ name = "socksio", specifier = ">=1.0.0" },
|
||||||
{ name = "typer" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "onnxruntime"
|
name = "onnxruntime"
|
||||||
version = "1.24.3"
|
version = "1.24.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user