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:
@@ -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
|
||||
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}с")
|
||||
|
||||
|
||||
|
||||
@@ -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 и повтор.",
|
||||
|
||||
Reference in New Issue
Block a user