feat(cuda): добавлен optional runtime для Linux и Windows
This commit is contained in:
1 parent
937eafe8db
commit
99ed5ed92f
10 files changed
+282
-36
No files matched your search
+8
-1
@@ -8,16 +8,23 @@ dependencies = [
|
|||||||
"typer>=0.24.1,<1",
|
"typer>=0.24.1,<1",
|
||||||
"rich>=14.3.3,<15",
|
"rich>=14.3.3,<15",
|
||||||
"faster-whisper>=1.2.1,<2",
|
"faster-whisper>=1.2.1,<2",
|
||||||
|
# CUDA-зависимости Windows проверены для этой сборки; см. ADR-001.
|
||||||
|
"ctranslate2==4.8.1",
|
||||||
"httpx>=0.28,<1",
|
"httpx>=0.28,<1",
|
||||||
"socksio>=1.0.0,<2",
|
"socksio>=1.0.0,<2",
|
||||||
# При обновлении сверить блок tool.uv.dependency-metadata ниже.
|
# При обновлении сверить блок tool.uv.dependency-metadata ниже.
|
||||||
"sherpa-onnx==1.13.6",
|
"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')",
|
"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",
|
"onnx-asr[cpu,hub]>=0.12,<0.13",
|
||||||
"onnxruntime>=1.28,<2",
|
"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]
|
[project.scripts]
|
||||||
transcribe = "local_transcriber.cli:app"
|
transcribe = "local_transcriber.cli:app"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Preload CUDA-библиотек из pip-пакетов до импорта ctranslate2.
|
Подготовка CUDA-библиотек из extra перед созданием CUDA-модели.
|
||||||
|
|
||||||
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
|
Проблема: ctranslate2 на Linux делает dlopen("libcublas.so.12"),
|
||||||
но не знает, что библиотека лежит внутри pip-пакета nvidia-cublas-cu12.
|
но не знает, что библиотека лежит внутри 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
|
Решение: загружаем libcublas.so.12 по полному пути через ctypes.CDLL
|
||||||
с флагом RTLD_GLOBAL до первого import ctranslate2. Динамический линкер
|
с флагом RTLD_GLOBAL до создания CUDA-модели. Динамический линкер
|
||||||
кеширует загруженные библиотеки по soname — когда ctranslate2 потом
|
кеширует загруженные библиотеки по soname — когда ctranslate2 потом
|
||||||
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
|
вызовет dlopen("libcublas.so.12"), линкер вернёт уже загруженный handle.
|
||||||
|
|
||||||
@@ -21,20 +23,24 @@ import glob
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# Закрытие регистрации удаляет каталог из поиска DLL, поэтому храним handles.
|
||||||
|
_dll_directories: dict[str, object] = {}
|
||||||
|
_dll_libraries: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
def ensure_cublas_loadable() -> None:
|
def ensure_cublas_loadable() -> None:
|
||||||
"""Загружает libcublas из nvidia-cublas-cu12 в адресное пространство процесса.
|
"""Загружает cuBLAS из nvidia-cublas-cu12 в адресное пространство процесса.
|
||||||
|
|
||||||
Вызывать ДО первого import ctranslate2.
|
Вызывать перед созданием CUDA-модели; CPU-путь не требует bootstrap.
|
||||||
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
|
Безопасно вызывать многократно и на платформах без nvidia-cublas-cu12.
|
||||||
"""
|
"""
|
||||||
if sys.platform != "linux":
|
if sys.platform not in ("linux", "win32"):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nvidia.cublas # type: ignore[import-untyped]
|
import nvidia.cublas # type: ignore[import-untyped]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# nvidia-cublas-cu12 не установлен (Windows, macOS, или CPU-only setup)
|
# Optional-пакет не установлен; системная cuBLAS всё ещё может работать.
|
||||||
return
|
return
|
||||||
|
|
||||||
# nvidia.cublas может быть namespace package (__file__ == None),
|
# nvidia.cublas может быть namespace package (__file__ == None),
|
||||||
@@ -42,6 +48,21 @@ def ensure_cublas_loadable() -> None:
|
|||||||
cublas_paths = getattr(nvidia.cublas, "__path__", None)
|
cublas_paths = getattr(nvidia.cublas, "__path__", None)
|
||||||
if not cublas_paths:
|
if not cublas_paths:
|
||||||
return
|
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")
|
cublas_lib_dir = os.path.join(cublas_paths[0], "lib")
|
||||||
if not os.path.isdir(cublas_lib_dir):
|
if not os.path.isdir(cublas_lib_dir):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,16 +6,11 @@ from collections.abc import Callable
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from local_transcriber._cuda_bootstrap import ensure_cublas_loadable
|
||||||
|
from local_transcriber.types import (
|
||||||
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
|
|
||||||
Segment,
|
Segment,
|
||||||
TranscribeResult,
|
TranscribeResult,
|
||||||
Word,
|
Word,
|
||||||
@@ -94,6 +89,11 @@ class FasterWhisperBackend:
|
|||||||
|
|
||||||
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
||||||
"""
|
"""
|
||||||
|
if device == "cuda":
|
||||||
|
ensure_cublas_loadable()
|
||||||
|
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return WhisperModel(
|
return WhisperModel(
|
||||||
model_path,
|
model_path,
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
|
|||||||
from .transcriber import (
|
from .transcriber import (
|
||||||
Segment,
|
Segment,
|
||||||
TranscribeResult,
|
TranscribeResult,
|
||||||
_is_cuda_error,
|
|
||||||
_transcribe_file,
|
_transcribe_file,
|
||||||
|
cuda_error_hint,
|
||||||
load_model,
|
load_model,
|
||||||
)
|
)
|
||||||
from .types import (
|
from .types import (
|
||||||
@@ -65,6 +65,13 @@ app = typer.Typer()
|
|||||||
console = Console(stderr=True)
|
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(
|
def _show_cli_warning(
|
||||||
message: Warning | str,
|
message: Warning | str,
|
||||||
*_args: object,
|
*_args: object,
|
||||||
@@ -398,21 +405,15 @@ def main(
|
|||||||
except SystemExit:
|
except SystemExit:
|
||||||
raise
|
raise
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
_print_cuda_hint(exc)
|
||||||
console.print(f"Ошибка: {exc}", style="red bold")
|
console.print(f"Ошибка: {exc}", style="red bold")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
except (FileNotFoundError,) as exc:
|
except (FileNotFoundError,) as exc:
|
||||||
|
_print_cuda_hint(exc)
|
||||||
console.print(f"Ошибка: {exc}", style="red bold")
|
console.print(f"Ошибка: {exc}", style="red bold")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_cuda_error(exc) and sys.platform == "win32":
|
_print_cuda_hint(exc)
|
||||||
console.print(
|
|
||||||
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
|
|
||||||
"Установите одним из способов:\n"
|
|
||||||
" choco install cuda\n"
|
|
||||||
" winget install -e --id Nvidia.CUDA\n"
|
|
||||||
"После установки перезапустите терминал.",
|
|
||||||
style="yellow",
|
|
||||||
)
|
|
||||||
if verbose:
|
if verbose:
|
||||||
console.print_exception()
|
console.print_exception()
|
||||||
else:
|
else:
|
||||||
@@ -758,6 +759,7 @@ def _run_batch(
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
_print_cuda_hint(exc)
|
||||||
if verbose:
|
if verbose:
|
||||||
console.print_exception()
|
console.print_exception()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -190,11 +190,52 @@ def ensure_model_available(
|
|||||||
|
|
||||||
|
|
||||||
def _is_cuda_error(exc: BaseException) -> bool:
|
def _is_cuda_error(exc: BaseException) -> bool:
|
||||||
"""Проверка CUDA ошибок — используется в cli.py для Windows-диагностики."""
|
"""Проверяет, относится ли ошибка к CUDA-бэкенду."""
|
||||||
msg = str(exc).lower()
|
msg = str(exc).lower()
|
||||||
return any(k in msg for k in ("cuda", "cublas", "cudnn", "out of memory"))
|
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:
|
def _is_backend_error(exc: BaseException, device: str) -> bool:
|
||||||
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
|
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
|
||||||
if isinstance(exc, WordTimestampsUnavailableError):
|
if isinstance(exc, WordTimestampsUnavailableError):
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ from local_transcriber.backends.faster_whisper import FasterWhisperBackend
|
|||||||
from local_transcriber.types import Word
|
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):
|
def test_transcribe_returns_canonical_words(tmp_path):
|
||||||
audio = tmp_path / "audio.wav"
|
audio = tmp_path / "audio.wav"
|
||||||
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
|
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
|
||||||
|
|||||||
+74
-3
@@ -688,7 +688,7 @@ def test_cli_load_model_called_with_model_name(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_windows_cuda_diagnostic(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 = tmp_path / "test.mp3"
|
||||||
audio.write_bytes(b"fake")
|
audio.write_bytes(b"fake")
|
||||||
model = _make_model()
|
model = _make_model()
|
||||||
@@ -712,8 +712,10 @@ def test_cli_windows_cuda_diagnostic(tmp_path):
|
|||||||
out = runner.invoke(app, [str(audio), "--device", "cuda"])
|
out = runner.invoke(app, [str(audio), "--device", "cuda"])
|
||||||
|
|
||||||
assert out.exit_code == 1
|
assert out.exit_code == 1
|
||||||
assert "choco install cuda" in out.output
|
assert "драйвер" in out.output
|
||||||
assert "winget install" 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):
|
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
|
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):
|
def test_cli_device_fallback_warning(tmp_path):
|
||||||
"""When auto-detected device differs from actual, show fallback warning."""
|
"""When auto-detected device differs from actual, show fallback warning."""
|
||||||
audio = tmp_path / "test.mp3"
|
audio = tmp_path / "test.mp3"
|
||||||
|
|||||||
@@ -48,12 +48,73 @@ def test_ensure_cublas_loads_library(monkeypatch, tmp_path):
|
|||||||
assert calls[0][1] == ctypes.RTLD_GLOBAL
|
assert calls[0][1] == ctypes.RTLD_GLOBAL
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_cublas_skips_non_linux(monkeypatch):
|
def test_ensure_cublas_skips_macos(monkeypatch):
|
||||||
"""На не-Linux платформах -- no-op."""
|
"""На macOS bootstrap не загружает CUDA."""
|
||||||
monkeypatch.setattr(sys, "platform", "win32")
|
monkeypatch.setattr(sys, "platform", "darwin")
|
||||||
ensure_cublas_loadable() # не должно бросать исключений
|
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:
|
def _nvidia_cublas_installed() -> bool:
|
||||||
"""Проверяет, что pip-пакет nvidia-cublas-cu12 установлен."""
|
"""Проверяет, что pip-пакет nvidia-cublas-cu12 установлен."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from local_transcriber.transcriber import (
|
|||||||
Segment,
|
Segment,
|
||||||
TranscribeResult,
|
TranscribeResult,
|
||||||
_transcribe_file,
|
_transcribe_file,
|
||||||
|
cuda_error_hint,
|
||||||
ensure_model_available,
|
ensure_model_available,
|
||||||
load_model,
|
load_model,
|
||||||
transcribe,
|
transcribe,
|
||||||
@@ -16,6 +17,21 @@ from local_transcriber.types import WordTimestampsUnavailableError
|
|||||||
# === Helpers ===
|
# === 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(
|
def _make_result(
|
||||||
count: int = 2,
|
count: int = 2,
|
||||||
language: str = "ru",
|
language: str = "ru",
|
||||||
|
|||||||
@@ -288,9 +288,9 @@ name = "local-transcriber"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "ctranslate2" },
|
||||||
{ name = "faster-whisper" },
|
{ name = "faster-whisper" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
|
||||||
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
{ name = "onnx-asr", extra = ["cpu", "hub"] },
|
||||||
{ name = "onnxruntime" },
|
{ name = "onnxruntime" },
|
||||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
|
{ 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" },
|
{ 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]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "kaldi-native-fbank" },
|
{ name = "kaldi-native-fbank" },
|
||||||
@@ -311,9 +316,10 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "ctranslate2", specifier = "==4.8.1" },
|
||||||
{ name = "faster-whisper", specifier = ">=1.2.1,<2" },
|
{ name = "faster-whisper", specifier = ">=1.2.1,<2" },
|
||||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
{ 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 = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
|
||||||
{ name = "onnxruntime", specifier = ">=1.28,<2" },
|
{ 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" },
|
{ 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 = "socksio", specifier = ">=1.0.0,<2" },
|
||||||
{ name = "typer", specifier = ">=0.24.1,<1" },
|
{ name = "typer", specifier = ">=0.24.1,<1" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["cuda"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -445,6 +452,7 @@ version = "12.9.1.4"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
wheels = [
|
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/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]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user