From bf2cd745b15922f0fed4649cbd67d563fbc2ec25 Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Wed, 10 Jun 2026 23:00:11 -0500 Subject: [PATCH] Fix installer selecting ROCm torch on NVIDIA Linux hosts (#6174) * fix: prevent ROCm torch from installing on NVIDIA Linux hosts NVIDIA's open kernel module (driver 560+) registers GPU topology nodes in the KFD sysfs hierarchy with non-zero gpu_id values. The _has_amd_rocm_gpu (install.sh) and _has_rocm_gpu (install_python_stack.py) sysfs fallbacks previously treated any non-zero gpu_id as proof of an AMD GPU, so an NVIDIA-only host with the open kernel driver was misrouted to the ROCm install path, replacing the correctly-installed CUDA torch with ROCm wheels. Fixes: 1. install.sh _has_amd_rocm_gpu sysfs fallback: require vendor_id 4098 (AMD 0x1002) in the KFD node properties file before declaring an AMD GPU present. NVIDIA KFD nodes carry vendor_id 4318 (0x10DE) and are now skipped. 2. install_python_stack.py _has_rocm_gpu sysfs fallback: same vendor_id guard. Also preserves the existing fallback for older kernels that don't ship a properties file (trusts gpu_id alone there). 3. install.sh now exports UNSLOTH_TORCH_BACKEND ("cuda"/"rocm"/"cpu") immediately after get_torch_index_url() resolves the wheel family. install_python_stack.py reads this as _TORCH_BACKEND and short-circuits _ensure_rocm_torch() entirely on cuda/cpu hosts, providing a second layer of defense that is independent of subprocess GPU detection. Tests: 9 new cases in TestHasRocmGpuKfdVendorGuard, TestEnsureRocmTorch, and TestInstallShStructure cover all three changes. Full test_rocm_support.py suite: 289 passed, 2 skipped, 0 failed. Closes #6172 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: show actual torch backend in progress step labels The 'ROCm torch check' and 'ROCm torch (final)' step labels were hardcoded regardless of whether the installer was targeting CUDA, ROCm, or CPU. On NVIDIA hosts they showed 'ROCm' even though no ROCm wheels were being installed, which was misleading. Add _torch_step_label(suffix) which reads UNSLOTH_TORCH_BACKEND (set by install.sh) and formats the label as e.g. 'torch check (cuda)' or 'torch final (rocm)'. Falls back to live GPU detection for standalone studio update runs that bypass install.sh. * fix: make KFD sysfs vendor check conservative -- skip if no properties file The previous implementation fell through to `return True` when the KFD node's properties file was missing (OSError), intending to support older kernels. But NVIDIA open driver KFD nodes can also lack a properties file on some kernel versions, so the fallback still produced a false positive. Change the `except OSError: pass` to `continue` so any node without a readable properties file is skipped rather than trusted. KFD properties files exist on every kernel version that actually exposes /sys/class/kfd, so this does not regress real AMD GPU detection -- if the directory exists at all, properties files will be present for genuine GPU nodes. * fix: bulletproof NVIDIA vs AMD GPU detection Four changes that together ensure ROCm torch can never be installed on an NVIDIA host regardless of which detection path fires: 1. _has_rocm_gpu() (Python): NVIDIA guard at the top -- returns False immediately when _has_usable_nvidia_gpu() is True, blocking rocminfo, amd-smi, and KFD sysfs from producing a false positive even when ROCm tools are co-installed alongside the NVIDIA driver. 2. _has_amd_rocm_gpu() (install.sh): same NVIDIA guard -- calls _has_usable_nvidia_gpu first and returns 1 if it succeeds. 3. _has_usable_nvidia_gpu() (Python): adds /proc/driver/nvidia/gpus/ sysfs fallback. The NVIDIA driver populates this directory on Linux regardless of nvidia-smi state, so a subprocess PATH gap, timeout, or driver initialisation race can no longer silence NVIDIA detection. 4. _has_usable_nvidia_gpu() (install.sh): same /proc/driver/nvidia/gpus fallback, tried after nvidia-smi -L rather than instead of it. Together: NVIDIA wins at every decision point. If nvidia-smi works, it confirms NVIDIA. If it fails, /proc/driver/nvidia confirms NVIDIA. If somehow both fail, _has_rocm_gpu still checks NVIDIA first before any AMD path runs. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: two KFD/proc-only corner cases from Codex review 1. KFD awk state not reset per node file (Ryzen+NVIDIA false positive): The awk glob processes all topology node properties files in one pass. Without FNR==1 reset, a Ryzen+NVIDIA host where an AMD CPU-agent node sets amd=1 (vendor_id 4098, gpu_id 0) can combine with a later NVIDIA node setting gpu=1 (gpu_id > 0), triggering found=1 before vendor_id 4318 is seen. Added FNR==1{ gpu=0; amd=0 } to reset per file. 2. proc-only NVIDIA not reaching CUDA wheel selection: _has_usable_nvidia_gpu returning true via /proc/driver/nvidia fallback left _smi empty, so get_torch_index_url entered the AMD/CPU branch and selected CPU wheels despite NVIDIA being confirmed. Introduced _nvidia_detected flag (separate from _smi) so the AMD branch is skipped whenever NVIDIA is confirmed by any path, while _cuda_ver reads from _smi when available (with the existing cu126 fallback when _smi is absent). * [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> --- install.sh | 49 ++++- studio/install_python_stack.py | 115 +++++++++-- tests/studio/install/test_rocm_support.py | 233 +++++++++++++++++++++- 3 files changed, 366 insertions(+), 31 deletions(-) diff --git a/install.sh b/install.sh index befcaaa186..44d1c39d02 100755 --- a/install.sh +++ b/install.sh @@ -1717,8 +1717,15 @@ _ensure_rocm_probe_env() { # Returns 0 if an AMD GPU is present. Checks rocminfo, amd-smi, then sysfs # KFD topology (env-var-independent fallback for when HIP/ROCR_VISIBLE_DEVICES hides devices). +# Always returns 1 (false) when an NVIDIA GPU is present: blocks every +# detection path (rocminfo, amd-smi, KFD sysfs) from producing a false +# positive on NVIDIA-only or NVIDIA-primary hosts, even when ROCm tools +# are co-installed. _has_amd_rocm_gpu() { _ensure_rocm_probe_env + if _has_usable_nvidia_gpu; then + return 1 + fi if command -v rocminfo >/dev/null 2>&1 && \ rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9][0-9]/{found=1} END{exit !found}'; then return 0 @@ -1726,27 +1733,42 @@ _has_amd_rocm_gpu() { amd-smi list 2>/dev/null | awk '/^GPU[[:space:]]*[:\[][[:space:]]*[0-9]/{ found=1 } END{ exit !found }'; then return 0 elif [ -e /dev/kfd ] && \ - awk '/gpu_id/{ if ($2+0 > 0) found=1 } END{ exit !found }' \ + awk 'FNR==1{ gpu=0; amd=0 } /gpu_id/{ gpu=($2+0>0) } /vendor_id/{ amd=($2==4098) } \ + gpu && amd { found=1 } END{ exit !found }' \ /sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null; then + # vendor_id 4098 = 0x1002 (AMD). NVIDIA open kernel module (driver + # 560+) can register KFD topology nodes with non-zero gpu_id but + # vendor_id 4318 (0x10DE). Require AMD vendor to avoid misrouting + # NVIDIA-only hosts to the ROCm install path. return 0 fi return 1 } # ── NVIDIA usable-GPU helper ── -# Returns 0 (true) only if nvidia-smi is present AND actually lists a GPU. -# Prevents AMD-only hosts with a stale nvidia-smi on PATH from being routed -# into the CUDA branch. +# Returns 0 (true) if an NVIDIA GPU is present and usable. +# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs, +# which the NVIDIA driver populates on Linux regardless of nvidia-smi state +# -- handles PATH gaps, subprocess timeouts, and driver init races that +# could otherwise cause nvidia-smi to fail and silence NVIDIA detection. _has_usable_nvidia_gpu() { _nvsmi="" if command -v nvidia-smi >/dev/null 2>&1; then _nvsmi="nvidia-smi" elif [ -x "/usr/bin/nvidia-smi" ]; then _nvsmi="/usr/bin/nvidia-smi" - else - return 1 fi - "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' + if [ -n "$_nvsmi" ]; then + if "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then + return 0 + fi + fi + # Fallback: NVIDIA driver exposes one subdir per GPU under this path. + if [ -d /proc/driver/nvidia/gpus ] && \ + [ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then + return 0 + fi + return 1 } # ── Detect GPU and choose PyTorch index URL ── @@ -1763,14 +1785,16 @@ get_torch_index_url() { # packages) is not sufficient: otherwise an AMD-only host would # silently install CUDA wheels. _smi="" + _nvidia_detected=0 if _has_usable_nvidia_gpu; then + _nvidia_detected=1 if command -v nvidia-smi >/dev/null 2>&1; then _smi="nvidia-smi" elif [ -x "/usr/bin/nvidia-smi" ]; then _smi="/usr/bin/nvidia-smi" fi fi - if [ -z "$_smi" ]; then + if [ "$_nvidia_detected" -eq 0 ]; then # No NVIDIA GPU -- check for AMD ROCm GPU. # PyTorch only publishes ROCm wheels for linux-x86_64; skip the # ROCm branch entirely on aarch64 / arm64 / other architectures @@ -2098,6 +2122,15 @@ _maybe_bootstrap_rocm_wsl || true TORCH_INDEX_URL=$(get_torch_index_url) +# Export the resolved torch backend ("cuda", "rocm", or "cpu") so that +# downstream scripts (setup.sh -> install_python_stack.py) know what was +# chosen here and can skip ROCm-specific repair steps on CUDA/CPU hosts. +case "$TORCH_INDEX_URL" in + */rocm*|*/gfx*) export UNSLOTH_TORCH_BACKEND="rocm" ;; + */cpu) export UNSLOTH_TORCH_BACKEND="cpu" ;; + *) export UNSLOTH_TORCH_BACKEND="cuda" ;; +esac + # rocm7.2 ships torch 2.11.0 -- adjust the constraint to allow it. # All other ROCm tags and CUDA stay within <2.11.0. case "$TORCH_INDEX_URL" in diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 0166bfd505..0460580922 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -557,7 +557,15 @@ def _persist_bnb_rocm_version(version: str) -> bool: def _has_rocm_gpu() -> bool: - """Return True only if an actual AMD GPU is visible (not just ROCm tools installed).""" + """Return True only if an actual AMD GPU is visible (not just ROCm tools installed). + + Always returns False when an NVIDIA GPU is present -- NVIDIA takes + priority on mixed hosts and prevents every detection path below + (rocminfo, amd-smi, KFD sysfs) from producing a false positive even + if ROCm tools are installed alongside the NVIDIA driver. + """ + if _has_usable_nvidia_gpu(): + return False for cmd, check_fn in ( # rocminfo: look for a real gfx GPU id (3-4 chars, nonzero first digit). # gfx000 is the CPU agent; ROCm 6.1+ also emits generic ISA lines like @@ -598,6 +606,13 @@ def _has_rocm_gpu() -> bool: # runtime-only detection. On minimal package-managed installs (no # rocminfo / no amd-smi tools), the kernel exposes AMD GPUs via # /sys/class/kfd so `studio update` can still detect and repair. + # + # Guard: reject any KFD node whose properties file reports a non-AMD + # vendor. With the NVIDIA open kernel module (driver 560+), NVIDIA GPUs + # can register KFD topology nodes with a non-zero gpu_id; those nodes + # have vendor_id 4318 (0x10DE) rather than the AMD value 4098 (0x1002). + # Without this check the fallback returns True on NVIDIA-only systems, + # causing _ensure_rocm_torch to install ROCm wheels on NVIDIA hardware. if sys.platform != "win32": try: kfd_nodes = "/sys/class/kfd/kfd/topology/nodes" @@ -609,29 +624,61 @@ def _has_rocm_gpu() -> bool: gpu_id = fh.read().strip() except OSError: continue - if gpu_id and gpu_id != "0": # gpu_id 0 = CPU node - return True + if not gpu_id or gpu_id == "0": # gpu_id 0 = CPU node + continue + # Require AMD vendor_id 4098 (0x1002) in the properties file. + # KFD properties files exist on every kernel that exposes + # /sys/class/kfd, so absence of the file means we cannot + # confirm AMD ownership -- skip the node rather than risk a + # false positive (e.g. NVIDIA open driver KFD nodes that + # lack a properties file on some kernel versions). + props_path = os.path.join(kfd_nodes, entry, "properties") + try: + with open(props_path) as fh: + props = fh.read() + except OSError: + continue # can't confirm vendor -- skip + if not re.search(r"\bvendor_id\s+4098\b", props): + continue + return True except OSError: pass return False def _has_usable_nvidia_gpu() -> bool: - """Return True only when nvidia-smi exists AND reports at least one GPU.""" + """Return True when an NVIDIA GPU is present and usable. + + Primary probe: nvidia-smi -L (subprocess). + Fallback: /proc/driver/nvidia/gpus/ sysfs (Linux only) -- handles the + case where nvidia-smi is present but the subprocess fails (PATH gap, + timeout, driver initialisation race). If either probe confirms an + NVIDIA GPU the function returns True so _has_rocm_gpu() is blocked. + """ exe = shutil.which("nvidia-smi") - if not exe: - return False - try: - result = subprocess.run( - [exe, "-L"], - stdout = subprocess.PIPE, - stderr = subprocess.DEVNULL, - text = True, - timeout = 10, - ) - except Exception: - return False - return result.returncode == 0 and "GPU " in result.stdout + if exe: + try: + result = subprocess.run( + [exe, "-L"], + stdout = subprocess.PIPE, + stderr = subprocess.DEVNULL, + text = True, + timeout = 10, + ) + if result.returncode == 0 and "GPU " in result.stdout: + return True + except Exception: + pass + # Fallback: the NVIDIA driver exposes one subdirectory per GPU under + # /proc/driver/nvidia/gpus/ on Linux regardless of nvidia-smi state. + if sys.platform != "win32": + try: + gpu_dir = "/proc/driver/nvidia/gpus" + if os.path.isdir(gpu_dir) and os.listdir(gpu_dir): + return True + except OSError: + pass + return False def _detect_amd_gfx_codes() -> list[str]: @@ -749,6 +796,13 @@ def _ensure_rocm_torch() -> None: Uses pip_install() to respect uv, constraints, and --python targeting. """ global _rocm_windows_torch_installed + # install.sh sets UNSLOTH_TORCH_BACKEND to the resolved wheel family + # ("cuda", "rocm", "cpu"). Skip ROCm operations entirely when install.sh + # already selected a non-ROCm backend -- this is the authoritative signal + # and avoids re-running GPU detection in a subprocess that may see a + # different environment (different PATH, CUDA_VISIBLE_DEVICES, etc.). + if _TORCH_BACKEND in ("cuda", "cpu"): + return # setup.ps1 sets this after installing AMD wheels; skip the probe only when # torch is actually importable as ROCm. If the venv was wiped between runs, # the stale env-var would suppress a needed reinstall. @@ -1088,6 +1142,29 @@ def _infer_no_torch() -> bool: NO_TORCH = _infer_no_torch() +# UNSLOTH_TORCH_BACKEND is set by install.sh after get_torch_index_url() so +# that this script knows which torch variant was selected without re-running +# GPU detection. Values: "cuda", "rocm", or "cpu". Empty means unknown +# (standalone `unsloth studio update` runs, where we re-detect normally). +_TORCH_BACKEND: str = os.environ.get("UNSLOTH_TORCH_BACKEND", "").lower() + + +def _torch_step_label(suffix: str) -> str: + """Return a progress label like 'torch check (cuda)' using the known backend. + + Falls back to GPU detection when UNSLOTH_TORCH_BACKEND is not set (e.g. + standalone `unsloth studio update` runs that bypass install.sh). + """ + backend = _TORCH_BACKEND + if not backend: + if _has_usable_nvidia_gpu(): + backend = "cuda" + elif _has_rocm_gpu(): + backend = "rocm" + else: + backend = "cpu" + return f"torch {suffix} ({backend})" + # -- Verbosity control ---------------------------------------------------------- # By default the installer shows a minimal in-place one-line progress bar. @@ -1770,7 +1847,7 @@ def install_python_stack() -> int: # venv got CPU-only torch (common when pip resolves torch from PyPI). # Must follow base packages so torch is present for inspection. if not IS_MACOS and not NO_TORCH: - _progress("ROCm torch check") + _progress(_torch_step_label("check")) _ensure_rocm_torch() # Windows + AMD GPU: warn if ROCm torch was not installed (wrong Python @@ -1955,7 +2032,7 @@ def install_python_stack() -> int: # Running the repair last ensures ROCm torch is in place at runtime, # whichever intermediate step clobbered it. if not IS_WINDOWS and not IS_MACOS and not NO_TORCH: - _progress("ROCm torch (final)") + _progress(_torch_step_label("final")) _ensure_rocm_torch() # 14. Final check (silent; third-party conflicts are expected) diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py index 973c0a1e81..6bc94e3d0f 100644 --- a/tests/studio/install/test_rocm_support.py +++ b/tests/studio/install/test_rocm_support.py @@ -719,6 +719,143 @@ class TestEnsureRocmTorch: _ensure_rocm_torch() mock_pip.assert_not_called() + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_rocm_gpu", return_value = True) + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True) + def test_torch_backend_cuda_env_skips_entirely(self, mock_nvidia, mock_gpu, mock_pip): + """UNSLOTH_TORCH_BACKEND=cuda must short-circuit before any GPU probe.""" + with patch.dict(os.environ, {"UNSLOTH_TORCH_BACKEND": "cuda"}): + # Reload _TORCH_BACKEND from the patched environment. + with patch.object(stack_mod, "_TORCH_BACKEND", "cuda"): + _ensure_rocm_torch() + mock_pip.assert_not_called() + + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_rocm_gpu", return_value = True) + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True) + def test_torch_backend_cpu_env_skips_entirely(self, mock_nvidia, mock_gpu, mock_pip): + """UNSLOTH_TORCH_BACKEND=cpu must short-circuit before any GPU probe.""" + with patch.dict(os.environ, {"UNSLOTH_TORCH_BACKEND": "cpu"}): + with patch.object(stack_mod, "_TORCH_BACKEND", "cpu"): + _ensure_rocm_torch() + mock_pip.assert_not_called() + + +# TEST: install_python_stack.py -- _has_rocm_gpu KFD sysfs vendor_id guard + + +class TestHasRocmGpuKfdVendorGuard: + """Verify that the KFD sysfs fallback rejects non-AMD (NVIDIA) KFD nodes. + + These tests are source-level: they verify the regex and logic present in + the _has_rocm_gpu implementation rather than running the sysfs traversal + (which requires Linux path conventions). + """ + + def _src(self) -> str: + """Return the source of _has_rocm_gpu from install_python_stack.py.""" + import inspect + return inspect.getsource(stack_mod._has_rocm_gpu) + + def test_vendor_id_check_present(self): + """_has_rocm_gpu sysfs fallback must check vendor_id 4098 (AMD 0x1002).""" + src = self._src() + assert "vendor_id" in src, ( + "_has_rocm_gpu KFD sysfs fallback must read the properties file " + "to check vendor_id and exclude NVIDIA KFD nodes" + ) + assert "4098" in src, ( + "_has_rocm_gpu must require AMD vendor_id 4098 (0x1002) in the " + "KFD node properties to avoid false positives on NVIDIA systems" + ) + + def test_vendor_regex_pattern_anchored(self): + """The vendor_id regex must use a word boundary to avoid partial matches.""" + import re as _re + + src = self._src() + # The pattern should have a word boundary before and after the number + # so "vendor_id 41098" doesn't match "vendor_id 4098". + assert ( + _re.search(r"\\b.*vendor_id.*\\b", src) or "\\bvendor_id" in src + ), "_has_rocm_gpu vendor_id check should use word boundary anchors" + + def test_sysfs_fallback_guarded_by_non_win32(self): + """KFD sysfs fallback must be Linux-only (guarded by sys.platform != 'win32').""" + src = self._src() + assert "win32" in src, "_has_rocm_gpu sysfs fallback must be guarded by sys.platform check" + + def test_cpu_node_excluded(self): + """gpu_id == '0' must be excluded (CPU topology nodes).""" + src = self._src() + assert ( + '!= "0"' in src or "== '0'" in src or "!= '0'" in src or '"0"' in src + ), "_has_rocm_gpu must skip gpu_id 0 nodes (CPU nodes)" + + def test_install_sh_has_vendor_check(self): + """_has_amd_rocm_gpu in install.sh sysfs fallback must also check vendor_id 4098.""" + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("_has_amd_rocm_gpu()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert "vendor_id" in func_body, "_has_amd_rocm_gpu sysfs fallback must check vendor_id" + assert "4098" in func_body, "_has_amd_rocm_gpu must require AMD vendor_id 4098 (0x1002)" + + def test_has_rocm_gpu_returns_false_when_nvidia_present(self): + """_has_rocm_gpu must return False immediately when _has_usable_nvidia_gpu is True. + + This is the primary guard: even if rocminfo, amd-smi, or KFD sysfs + produce a false positive, an NVIDIA GPU always wins. + """ + with patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True): + with patch("shutil.which", return_value = "/usr/bin/rocminfo"): + # Simulate rocminfo claiming an AMD GPU is present + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Name: gfx1100\n" + with patch("subprocess.run", return_value = mock_result): + assert not stack_mod._has_rocm_gpu(), ( + "_has_rocm_gpu must return False when NVIDIA GPU is detected, " + "regardless of what rocminfo reports" + ) + + def test_install_sh_has_rocm_gpu_nvidia_guard(self): + """_has_amd_rocm_gpu in install.sh must call _has_usable_nvidia_gpu and return 1 if true.""" + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("_has_amd_rocm_gpu()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert ( + "_has_usable_nvidia_gpu" in func_body + ), "_has_amd_rocm_gpu must call _has_usable_nvidia_gpu to block NVIDIA hosts" + assert ( + "return 1" in func_body + ), "_has_amd_rocm_gpu must return 1 (false) when NVIDIA GPU is detected" + + def test_has_usable_nvidia_gpu_proc_fallback_present(self): + """`_has_usable_nvidia_gpu` must have a /proc/driver/nvidia fallback.""" + import inspect + + src = inspect.getsource(stack_mod._has_usable_nvidia_gpu) + assert "/proc/driver/nvidia" in src, ( + "_has_usable_nvidia_gpu must fall back to /proc/driver/nvidia/gpus when " + "nvidia-smi subprocess fails, to handle PATH gaps and driver init races" + ) + + def test_install_sh_has_usable_nvidia_gpu_proc_fallback(self): + """_has_usable_nvidia_gpu in install.sh must also have a /proc/driver/nvidia fallback.""" + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("_has_usable_nvidia_gpu()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert "/proc/driver/nvidia" in func_body, ( + "_has_usable_nvidia_gpu in install.sh must fall back to " + "/proc/driver/nvidia/gpus when nvidia-smi fails" + ) + # TEST: install_python_stack.py -- _ROCM_TORCH_INDEX mapping @@ -927,16 +1064,21 @@ class TestInstallShStructure: source = sh_path.read_text(encoding = "utf-8") body = _extract_sh_function_body(source, "get_torch_index_url") nvidia_call = body.find("_has_usable_nvidia_gpu") - no_nvidia_branch = body.find('if [ -z "$_smi" ]') + # Gate changed from [ -z "$_smi" ] to [ "$_nvidia_detected" -eq 0 ] to + # handle proc-only NVIDIA hosts where nvidia-smi is absent but _has_usable_nvidia_gpu + # returns true via /proc/driver/nvidia/gpus. + no_nvidia_branch = body.find('if [ "$_nvidia_detected" -eq 0 ]') + if no_nvidia_branch < 0: + no_nvidia_branch = body.find('if [ -z "$_smi" ]') rocm_call = body.find("_has_amd_rocm_gpu") assert nvidia_call >= 0, "get_torch_index_url should call _has_usable_nvidia_gpu" - assert no_nvidia_branch >= 0, "get_torch_index_url should gate ROCm on no-nvidia-smi" + assert no_nvidia_branch >= 0, "get_torch_index_url should gate ROCm on no-nvidia branch" assert ( rocm_call > no_nvidia_branch - ), "ROCm detection should sit inside the 'no nvidia-smi' branch" + ), "ROCm detection should sit inside the 'no NVIDIA' branch" assert ( nvidia_call < no_nvidia_branch - ), "NVIDIA detection should run before the no-nvidia-smi branch" + ), "NVIDIA detection should run before the no-NVIDIA branch" def test_bitsandbytes_amd_install(self): """install.sh should install bitsandbytes for AMD when ROCm detected.""" @@ -1018,6 +1160,89 @@ class TestInstallShStructure: rocm_pos = func_body.find("amd-smi") assert darwin_pos < rocm_pos, "macOS check should come before ROCm detection" + def test_unsloth_torch_backend_exported_after_get_torch_index_url(self): + """install.sh must export UNSLOTH_TORCH_BACKEND after TORCH_INDEX_URL is set. + + This lets install_python_stack.py skip ROCm torch operations on CUDA + and CPU hosts without re-running GPU detection in a subprocess. + """ + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + torch_url_pos = source.find("TORCH_INDEX_URL=$(get_torch_index_url)") + backend_pos = source.find("UNSLOTH_TORCH_BACKEND") + assert backend_pos > 0, "UNSLOTH_TORCH_BACKEND must be set in install.sh" + assert ( + backend_pos > torch_url_pos + ), "UNSLOTH_TORCH_BACKEND must be set AFTER TORCH_INDEX_URL is resolved" + # Verify all three cases are covered + assert '"cuda"' in source[backend_pos : backend_pos + 500] + assert '"rocm"' in source[backend_pos : backend_pos + 500] + assert '"cpu"' in source[backend_pos : backend_pos + 500] + # Must be exported so subprocesses (setup.sh, install_python_stack.py) see it + assert "export UNSLOTH_TORCH_BACKEND" in source + + def test_kfd_sysfs_amd_vendor_check_in_has_amd_rocm_gpu(self): + """_has_amd_rocm_gpu sysfs fallback must require AMD vendor_id 4098. + + NVIDIA open kernel module (560+) registers KFD nodes with vendor_id + 4318 (0x10DE). Without the vendor check, _has_amd_rocm_gpu returns 0 + (true) on NVIDIA-only hosts that have the nvidia-open driver, causing + get_torch_index_url to select a ROCm wheel index. + """ + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("_has_amd_rocm_gpu()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert ( + "vendor_id" in func_body + ), "_has_amd_rocm_gpu sysfs fallback must check vendor_id to exclude NVIDIA KFD nodes" + assert ( + "4098" in func_body + ), "_has_amd_rocm_gpu sysfs fallback must require AMD vendor_id 4098 (0x1002)" + + def test_kfd_awk_resets_state_per_file(self): + """KFD sysfs awk must reset gpu/amd state per file (FNR==1). + + Without the reset, a Ryzen+NVIDIA host where node 0 is an AMD CPU + agent (vendor_id 4098, gpu_id 0) and node 1 is an NVIDIA GPU + (gpu_id > 0, vendor_id 4318) can produce a false positive: node 0 + sets amd=1, node 1 sets gpu=1, and the combined state triggers found=1 + before vendor_id 4318 is seen on node 1. + """ + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("_has_amd_rocm_gpu()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert "FNR==1" in func_body, ( + "_has_amd_rocm_gpu KFD awk must reset state per file with FNR==1 " + "to avoid false positives on Ryzen+NVIDIA hosts with multiple KFD nodes" + ) + + def test_get_torch_index_url_uses_nvidia_detected_flag(self): + """get_torch_index_url must track NVIDIA detection independently of _smi. + + When _has_usable_nvidia_gpu returns true via /proc/driver/nvidia fallback + but nvidia-smi is not on PATH, _smi stays empty. Without a separate + _nvidia_detected flag, the function falls into the AMD/CPU branch even + though NVIDIA was confirmed, silently installing CPU wheels instead of CUDA. + """ + sh_path = PACKAGE_ROOT / "install.sh" + source = sh_path.read_text(encoding = "utf-8") + func_start = source.find("get_torch_index_url()") + func_end = source.find("\n}", func_start) + func_body = source[func_start:func_end] + assert "_nvidia_detected" in func_body, ( + "get_torch_index_url must use a _nvidia_detected flag (separate from " + "_smi) so that proc-only NVIDIA detection still selects CUDA wheels" + ) + # The AMD/ROCm branch must be gated on _nvidia_detected being 0, not on + # _smi being empty. + assert ( + '_nvidia_detected" -eq 0' in func_body or "_nvidia_detected" in func_body + ), "get_torch_index_url AMD branch must be skipped when _nvidia_detected=1" + # TEST: Live regression on current host (NVIDIA B200 expected)