unsloth/studio/backend/tests/test_llama_cpp_windows_nvidia_path.py
Daniel Han 379f5a5aa6
Studio: add torch's pip nvidia DLL dirs to PATH on Windows (#5324)
* Studio: add torch's pip nvidia DLL dirs to PATH on Windows

Studio's install_python_stack bundles torch with matching CUDA
wheels (nvidia-cuda-runtime-cu13, nvidia-cublas-cu13, etc.) which
ship cudart64_X.dll, cublas64_X.dll, and cublasLt64_X.dll under
the prefix's Lib/site-packages/nvidia/<pkg>/(bin|Library/bin)/
tree. The Linux runtime env block in start_llama_server already
pulls the equivalent nvidia/cu*/lib paths into LD_LIBRARY_PATH,
but the Windows block did not do this, so the prebuilt
llama-server.exe could not resolve cudart64_X.dll at runtime
unless the user had a matching system CUDA toolkit on PATH. That
is the root cause of the Windows reports in
unslothai/unsloth#5106 ("GPU detected but model loaded entirely
on RAM/CPU"), and matches Roland's repeated workaround in that
issue: install matching CUDA toolkit version.

Brings the Windows env block in line with the Linux pattern:

* New LlamaCppBackend._windows_pip_nvidia_dll_dirs resolver
  globs <prefix>/Lib/site-packages/nvidia/<pkg>/bin and
  <prefix>/Lib/site-packages/nvidia/<pkg>/Library/bin. Both
  layouts are seen in the wild across cuda_runtime / cublas /
  cudnn / nvjitlink wheels.

* The Windows env block now extends path_dirs with the
  resolver's output before falling back to CUDA_PATH/bin, so
  pip-installed wheels are the canonical source (mirroring the
  Linux LD_LIBRARY_PATH ordering). System CUDA toolkit remains a
  valid fallback.

Tests: 7 new cases in
studio/backend/tests/test_llama_cpp_windows_nvidia_path.py:

* empty resolver when no nvidia wheels installed
* nvidia/<pkg>/bin layout resolved
* nvidia/<pkg>/Library/bin layout resolved
* mixed bin and Library/bin layouts both resolved
* unrelated site-packages contents not walked
* non-directory entries skipped
* missing prefix does not raise

110 backend tests pass. No regressions.

Refs #5106

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: also scan torch/lib in Windows pip nvidia DLL resolver

PyTorch's Windows CUDA wheels frequently bundle cudart64_X.dll and
cublas64_X.dll directly under Lib/site-packages/torch/lib/ instead of
shipping separate nvidia-cuda-runtime-cuXX / nvidia-cublas-cuXX wheels.
On those installs _windows_pip_nvidia_dll_dirs previously returned
nothing useful, and llama-server.exe fell back to needing a system CUDA
toolkit on PATH -- the original #5106 failure mode.

The install-side equivalent python_runtime_dirs in
install_llama_prebuilt.py already treats torch/lib as a Python runtime
DLL source for the same reason. Bring the runtime resolver in parity
so torch-bundled-CUDA installs find their cudart at llama-server start.

Updates the existing test that codified the bug (asserted torch/lib was
excluded), and adds three new cases: pickup, combined-with-nvidia, and
the must-be-a-directory guard.

* Studio: cover cu13 bin/x86_64 layout in Windows DLL resolver

Three follow-ups from a 12-reviewer batch over c1c8a074 (PR #5324):

1. The current nvidia-cuda-runtime (unsuffixed) 13.2.75 and
   nvidia-cublas 13.4.0.1 Windows wheels on PyPI ship under
   nvidia/cu13/bin/x86_64/cudart64_13.dll etc, not under
   nvidia/PKG/bin/. The previous resolver matched only one
   directory level past nvidia/PKG/ and silently missed the
   actual cu13 DLL location, leaving CUDA 13 users on the same
   failure mode as before #5106. Verified against:
       pip download nvidia-cuda-runtime --platform win_amd64
   which produces nvidia/cu13/bin/x86_64/cudart64_13.dll.

2. glob.glob over sys.prefix interprets [ and ] as a
   character class. Valid Windows usernames / install paths can
   contain those characters (for example C:\Users\alice[work]\studio),
   so the previous resolver silently returned an empty list for such
   prefixes even when DLL dirs were present.

3. The resolver only ever returned nvidia/PKG/bin -- if both
   bin and bin/x86_64 exist (current wheels do), Windows
   DLL search should land on the arch-specific subdir first so the
   explicit cudart64_X.dll location wins.

Rewritten as a pathlib.Path.iterdir walk to fix all three:
no glob escaping needed, arch-specific subdirs added explicitly,
and ordering puts bin/x86_64 before bin. Conda-style
Library/bin/x86_64 and Library/bin/x64 are also covered for
parity. A seen set dedupes when wheels happen to expose the
same directory through multiple layouts.

New tests:
 - test_picks_up_cu13_bin_x86_64_layout (the actual real-world cu13 case)
 - test_picks_up_bin_x64_layout
 - test_mixed_cu12_and_cu13_layouts
 - test_glob_meta_in_prefix_is_safe (bracket repro)
 - test_arch_subdir_listed_before_parent_bin (ordering)

Verified empirically against PyPI:
       nvidia-cuda-runtime 13.2.75 -> nvidia/cu13/bin/x86_64/cudart64_13.dll
       nvidia-cublas       13.4.0.1 -> nvidia/cu13/bin/x86_64/cublas64_13.dll
                                       nvidia/cu13/bin/x86_64/cublasLt64_13.dll
       nvidia-cudnn-cu13   9.22.0.52 -> nvidia/cudnn/bin/cudnn64_9.dll (already covered)

Refs #5106

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-11 05:42:09 -07:00

259 lines
10 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the Windows pip-nvidia DLL dir resolver.
Studio installs torch with bundled CUDA wheels (nvidia-cuda-runtime-cu13,
nvidia-cublas-cu13, etc.) and the prebuilt llama-server.exe must find
those DLLs at runtime to load CUDA. Mirrors the Linux LD_LIBRARY_PATH
block. See unslothai/unsloth#5106.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub heavy deps before importing the module under test.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
_httpx_stub = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
_httpx_stub.Timeout = _FakeTimeout
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
def _make_nvidia_layout(prefix: Path, pkgs_with_layout: dict[str, str]):
"""Build a fake <prefix>/Lib/site-packages/nvidia/<pkg>/{bin|Library/bin}
tree with a stub DLL inside each leaf so isdir() picks them up."""
nv = prefix / "Lib" / "site-packages" / "nvidia"
for pkg, layout in pkgs_with_layout.items():
if layout == "bin":
d = nv / pkg / "bin"
elif layout == "library_bin":
d = nv / pkg / "Library" / "bin"
else:
raise ValueError(layout)
d.mkdir(parents = True, exist_ok = True)
(d / "stub.dll").write_bytes(b"")
class TestWindowsPipNvidiaDllDirs:
def test_returns_empty_when_no_nvidia_wheels(self, tmp_path):
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_picks_up_bin_layout(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "bin",
"cudnn": "bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 3
assert all(Path(p).is_dir() for p in result)
assert all(Path(p).name == "bin" for p in result)
names = {Path(p).parent.name for p in result}
assert names == {"cuda_runtime", "cublas", "cudnn"}
def test_picks_up_library_bin_layout(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "library_bin",
"cublas": "library_bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 2
for p in result:
assert Path(p).is_dir()
assert Path(p).parent.name == "Library"
assert Path(p).parent.parent.name in {"cuda_runtime", "cublas"}
def test_mixed_layouts_all_resolved(self, tmp_path):
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "library_bin",
"cudnn": "bin",
"nvjitlink": "library_bin",
},
)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 4
def test_does_not_walk_outside_known_paths(self, tmp_path):
# Only nvidia/<pkg>/{bin,Library/bin} and torch/lib are picked
# up. Unrelated site-packages contents (numpy, scipy, ...) must
# be ignored.
site = tmp_path / "Lib" / "site-packages"
(site / "numpy").mkdir(parents = True)
(site / "scipy" / "linalg").mkdir(parents = True)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_picks_up_torch_lib(self, tmp_path):
# PyTorch's Windows CUDA wheel bundles cudart64_X.dll /
# cublas64_X.dll directly under Lib/site-packages/torch/lib/
# instead of as separate nvidia-* wheels. Without this, users
# on torch-bundled-CUDA installs still hit #5106.
torch_lib = tmp_path / "Lib" / "site-packages" / "torch" / "lib"
torch_lib.mkdir(parents = True)
(torch_lib / "cudart64_12.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 1
assert Path(result[0]) == torch_lib
def test_torch_lib_combined_with_nvidia_wheels(self, tmp_path):
# Both modular nvidia-* wheels and torch/lib are returned when
# present together.
_make_nvidia_layout(
tmp_path,
{
"cuda_runtime": "bin",
"cublas": "bin",
},
)
torch_lib = tmp_path / "Lib" / "site-packages" / "torch" / "lib"
torch_lib.mkdir(parents = True)
(torch_lib / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert len(result) == 3
names = {Path(p).name for p in result}
assert names == {"bin", "lib"}
assert any(Path(p) == torch_lib for p in result)
def test_torch_lib_must_be_a_directory(self, tmp_path):
# If torch/lib exists as a file (broken install), it is
# ignored, not returned.
site = tmp_path / "Lib" / "site-packages" / "torch"
site.mkdir(parents = True)
(site / "lib").write_bytes(b"not a dir")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_skips_non_directories(self, tmp_path):
nv = tmp_path / "Lib" / "site-packages" / "nvidia"
(nv / "cuda_runtime").mkdir(parents = True)
# Create a regular file at the path where 'bin' would normally be a dir
(nv / "cuda_runtime" / "bin").write_bytes(b"not a dir")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert result == []
def test_missing_prefix_does_not_raise(self):
# If sys.prefix points to a path that doesn't exist (unusual,
# but possible during test setup), the resolver must just
# return [] rather than raising.
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(
"/this/path/does/not/exist/anywhere"
)
assert result == []
def test_picks_up_cu13_bin_x86_64_layout(self, tmp_path):
# Current ``nvidia-cuda-runtime`` 13.x and ``nvidia-cublas``
# 13.x Windows wheels ship DLLs under
# ``nvidia/cu13/bin/x86_64/`` instead of ``nvidia/<pkg>/bin/``.
# Without this, users on the new CUDA 13 wheel generation hit
# the original #5106 failure mode.
dll_dir = (
tmp_path / "Lib" / "site-packages" / "nvidia" / "cu13" / "bin" / "x86_64"
)
dll_dir.mkdir(parents = True)
for name in ("cudart64_13.dll", "cublas64_13.dll", "cublasLt64_13.dll"):
(dll_dir / name).write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert str(dll_dir) in result, f"cu13 bin/x86_64 not in {result}"
def test_picks_up_bin_x64_layout(self, tmp_path):
# Some repackaged wheels use ``bin/x64`` (Windows-x64 convention)
# instead of ``bin/x86_64`` (NVIDIA-internal convention).
dll_dir = tmp_path / "Lib" / "site-packages" / "nvidia" / "cu13" / "bin" / "x64"
dll_dir.mkdir(parents = True)
(dll_dir / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
assert str(dll_dir) in result
def test_mixed_cu12_and_cu13_layouts(self, tmp_path):
# A venv could have both the modular cu12 wheels (legacy) and
# the unsuffixed cu13 wheel installed side by side. Both must
# be reachable.
site = tmp_path / "Lib" / "site-packages"
cu12_bin = site / "nvidia" / "cuda_runtime" / "bin"
cu13_arch = site / "nvidia" / "cu13" / "bin" / "x86_64"
cu12_bin.mkdir(parents = True)
cu13_arch.mkdir(parents = True)
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
result_set = {Path(p) for p in result}
assert cu12_bin in result_set
assert cu13_arch in result_set
def test_glob_meta_in_prefix_is_safe(self, tmp_path):
# Windows usernames / install paths can contain ``[`` or ``]``.
# A glob-based resolver would interpret these as a character
# class and silently return [] even when DLL dirs exist. The
# iterdir-based implementation must work on such paths.
prefix = tmp_path / "studio_[gpu]_install"
dll_dir = prefix / "Lib" / "site-packages" / "nvidia" / "cuda_runtime" / "bin"
dll_dir.mkdir(parents = True)
(dll_dir / "cudart64_12.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(prefix))
assert str(dll_dir) in result, f"bracket-prefixed path returned empty: {result}"
def test_arch_subdir_listed_before_parent_bin(self, tmp_path):
# When both ``nvidia/<pkg>/bin/`` and
# ``nvidia/<pkg>/bin/x86_64/`` exist, the arch-specific subdir
# must be listed first so Windows DLL search picks up the
# cudart64_X.dll location even if the parent ``bin`` is empty.
site = tmp_path / "Lib" / "site-packages"
outer_bin = site / "nvidia" / "cu13" / "bin"
arch_bin = outer_bin / "x86_64"
arch_bin.mkdir(parents = True)
(arch_bin / "cudart64_13.dll").write_bytes(b"")
result = LlamaCppBackend._windows_pip_nvidia_dll_dirs(str(tmp_path))
# outer_bin exists as a directory (it contains arch_bin); the
# arch-specific subdir should come first in the list.
result_paths = [Path(p) for p in result]
assert arch_bin in result_paths
assert outer_bin in result_paths
assert result_paths.index(arch_bin) < result_paths.index(outer_bin)