fix(model): detect CUDA toolkit outside PATH and rank by installed build
cuda_toolkit_ready() only accepted nvcc on PATH, so a session started before the CUDA profile snippet ranked Vulkan above a CUDA-compiled build. A complete toolkit is now also recognised under CUDA_HOME, CUDA_PATH or /usr/local/cuda*, requiring nvcc, include/cuda_runtime.h and libcudart in the architecture-matching target. The backend order only names backends the installed build compiled in, so a Vulkan-only build never lists CUDA. Refs #20
This commit is contained in:
@@ -8,8 +8,12 @@ protocol headroom. Replacing them with a recorded measurement is a
|
||||
deliberate change to this test.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux.speech_recognition import model_ladder
|
||||
from vocalinux.speech_recognition.model_ladder import (
|
||||
DEFAULT_RUNG,
|
||||
@@ -130,6 +134,146 @@ class TestCudaToolkitReadiness:
|
||||
assert cuda_toolkit_ready_from_probe(True, True, None) is False
|
||||
|
||||
|
||||
NVCC_VERSION_OUTPUT = "Cuda compilation tools, release 13.2, V13.2.51\n"
|
||||
NVIDIA_SMI_OUTPUT = "3711, 8.6\n"
|
||||
|
||||
|
||||
def _write_toolkit(
|
||||
root: Path,
|
||||
*,
|
||||
nvcc: bool = True,
|
||||
header: bool = True,
|
||||
libcudart: bool = True,
|
||||
target: str = "x86_64-linux",
|
||||
) -> Path:
|
||||
"""Lay out a CUDA toolkit tree shaped like the NVIDIA runfile install."""
|
||||
if nvcc:
|
||||
(root / "bin").mkdir(parents=True, exist_ok=True)
|
||||
binary = root / "bin" / "nvcc"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
binary.chmod(0o755)
|
||||
if header:
|
||||
(root / "include").mkdir(parents=True, exist_ok=True)
|
||||
(root / "include" / "cuda_runtime.h").write_text("/* header */\n")
|
||||
if libcudart:
|
||||
lib = root / "targets" / target / "lib"
|
||||
lib.mkdir(parents=True, exist_ok=True)
|
||||
(lib / "libcudart.so.13").write_text("")
|
||||
if target == "x86_64-linux":
|
||||
(root / "lib64").symlink_to(lib, target_is_directory=True)
|
||||
return root
|
||||
|
||||
|
||||
def _probe_stub(nvcc_output: str | None):
|
||||
"""Stand in for ``_run_probe``: nvidia-smi sees an RTX-class GPU."""
|
||||
|
||||
def probe(argv, timeout=10):
|
||||
if argv[0] == "nvidia-smi":
|
||||
return NVIDIA_SMI_OUTPUT
|
||||
if argv[0] == "nvcc":
|
||||
return nvcc_output
|
||||
return None
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_env_toolkit(tmp_path: Path):
|
||||
"""Isolate detection from the host: no CUDA env vars, empty /usr/local."""
|
||||
usr_local = tmp_path / "usr-local"
|
||||
usr_local.mkdir()
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(model_ladder, "_CUDA_USR_LOCAL_ROOT", usr_local),
|
||||
patch.object(model_ladder.platform, "machine", return_value="x86_64"),
|
||||
):
|
||||
yield usr_local
|
||||
|
||||
|
||||
class TestCudaToolkitDetection:
|
||||
"""A toolkit counts from PATH, CUDA_HOME, CUDA_PATH or /usr/local/cuda*/."""
|
||||
|
||||
def test_toolkit_found_via_path(self, no_env_toolkit: Path) -> None:
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(NVCC_VERSION_OUTPUT)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
@pytest.mark.parametrize("variable", ["CUDA_HOME", "CUDA_PATH"])
|
||||
def test_toolkit_found_via_cuda_env_root(
|
||||
self, tmp_path: Path, no_env_toolkit: Path, variable: str
|
||||
) -> None:
|
||||
root = _write_toolkit(tmp_path / "opt" / "cuda")
|
||||
with (
|
||||
patch.dict(os.environ, {variable: str(root)}, clear=True),
|
||||
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
||||
):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
def test_toolkit_found_only_under_usr_local_cuda(self, no_env_toolkit: Path) -> None:
|
||||
"""The real target: /usr/local/cuda -> cuda-13.2 with nvcc off PATH."""
|
||||
real = _write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(real, target_is_directory=True)
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
def test_no_toolkit_anywhere_is_not_ready(self, no_env_toolkit: Path) -> None:
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{"nvcc": False},
|
||||
{"header": False},
|
||||
{"libcudart": False},
|
||||
{"target": "sbsa-linux"},
|
||||
],
|
||||
ids=["no-nvcc", "no-cuda-runtime-header", "no-libcudart", "other-architecture"],
|
||||
)
|
||||
def test_incomplete_toolkit_is_rejected(
|
||||
self, tmp_path: Path, no_env_toolkit: Path, layout: dict
|
||||
) -> None:
|
||||
root = _write_toolkit(tmp_path / "partial", **layout)
|
||||
with (
|
||||
patch.dict(os.environ, {"CUDA_HOME": str(root)}, clear=True),
|
||||
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
||||
):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
def test_driver_without_toolkit_stays_not_ready_with_partial_tree(
|
||||
self, no_env_toolkit: Path
|
||||
) -> None:
|
||||
"""A /usr/local/cuda with headers only is not a toolkit."""
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2", nvcc=False, libcudart=False)
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
|
||||
class TestBuildAwareBackendOrder:
|
||||
"""The order names only backends the installed build compiled in."""
|
||||
|
||||
def test_vulkan_only_build_never_picks_cuda_even_with_toolkit(
|
||||
self, no_env_toolkit: Path
|
||||
) -> None:
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
assert preferred_backend_order("vulkan") == ("vulkan", "cpu")
|
||||
|
||||
def test_cuda_build_picks_cuda_with_nvcc_off_path(self, no_env_toolkit: Path) -> None:
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert preferred_backend_order("cuda") == ("cuda", "cpu")
|
||||
|
||||
def test_cuda_build_without_toolkit_has_no_cuda_rung(self, no_env_toolkit: Path) -> None:
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert preferred_backend_order("cuda") == ("cpu",)
|
||||
|
||||
def test_cpu_build_is_cpu_only(self) -> None:
|
||||
assert preferred_backend_order("cpu") == ("cpu",)
|
||||
|
||||
|
||||
class TestPreferredBackendOrder:
|
||||
"""CUDA ranks first only with a complete toolkit."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user