* install: let UNSLOTH_TORCH_INDEX_FAMILY / _URL override CUDA wheel detection
get_torch_index_url (and the studio-update mirror _detect_cuda_torch_index_url)
chose the torch wheel family solely by probing the host GPU, with no override.
In a headless / container / CI build the host driver is visible via the
/proc/driver/nvidia/gpus fallback but nvidia-smi cannot report a CUDA version,
so the function fell back to its cu126 default and installed the wrong wheels
(e.g. a cu128 image got cu126 torch).
Add an explicit override checked before any probing, in both the shell installer
and the Python studio-update path:
- UNSLOTH_TORCH_INDEX_URL full index URL, used verbatim (wins)
- UNSLOTH_TORCH_INDEX_FAMILY family (cpu, cu128, rocm6.4, ...) appended to the
mirror base (UNSLOTH_PYTORCH_MIRROR still honoured)
This matches how the published GPU images select CUDA -- vLLM and SGLang take the
CUDA version from an explicit build ARG rather than detecting it, and the Unsloth
Docker base image already pins the cu128 index directly. Desktop installs are
unchanged: with no override set, detection runs exactly as before.
Adds test_get_torch_index_url.sh cases for the override (family, full URL,
precedence, mirror base, trailing-slash strip, empty-ignored).
* install: make the torch-index override authoritative across ROCm paths
Address review feedback on the override added in this PR so a pinned index is
honoured everywhere, not just in get_torch_index_url:
- Skip the WSL ROCm bootstrap (root privilege + large downloads, probes
/dev/dxg) when UNSLOTH_TORCH_INDEX_URL / _FAMILY is set; it previously ran
before the override was consulted.
- Skip the Radeon/Strix rerouting (which re-probes the GPU and overwrites the
resolved URL with repo.radeon.com / repo.amd.com) when the index is pinned, so
an explicit ROCm override (e.g. UNSLOTH_TORCH_INDEX_FAMILY=rocm6.4) is kept.
- install_python_stack.py: derive _TORCH_BACKEND from the override when
UNSLOTH_TORCH_BACKEND is unset (standalone studio update), so _ensure_rocm_torch
/ _ensure_cuda_torch repair to the requested family instead of re-detecting.
- Strip ALL leading/trailing slashes in the shell override to match the Python
side (avoids 404s on strict pip proxies).
Adds test cases for double-slash and leading/trailing-slash overrides.
* install: honor pinned torch index in CUDA/ROCm repair paths
Follow-up to the override work in this PR: the get_torch_index_url / install.sh
reroute already respect a pinned UNSLOTH_TORCH_INDEX_URL / _FAMILY, but the
Python repair helpers in install_python_stack.py still re-probed the GPU and
could overwrite the pinned family. Make the pin authoritative there too:
- _ensure_cuda_torch: an explicit cu* pin commits to CUDA wheels, so repair a
ROCm-poisoned venv even when no NVIDIA GPU is visible here (headless /
container / CI cross-install), instead of bailing on the GPU-presence gate.
- _ensure_rocm_torch: skip the AMD per-gfx (Strix) reroute when a ROCm index is
pinned, and in the generic reinstall path install from the pinned URL verbatim
rather than re-detecting the host ROCm version. gfx*/rocm7.2 indexes serve
torch 2.11+, so select the 2.11 package specs for a gfx leaf.
- install.sh: raise the torch constraint to 2.11 for */gfx* indexes too, matching
rocm7.2, so a pinned full-URL/family override that returns early keeps a valid
constraint.
Add _explicit_torch_index_url / _explicit_rocm_torch_index_url helpers and tests
covering the no-GPU CUDA pin repair and the explicit gfx index honored verbatim.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: honor torch-index override on the Windows installers too
The pinned-index work landed for install.sh and install_python_stack.py, but the
Windows installers still picked the wheel index from GPU probing. Extend the same
UNSLOTH_TORCH_INDEX_URL / _FAMILY contract so a pinned index wins on every platform:
- install.ps1: Get-TorchIndexUrl returns the pinned URL/family before nvidia-smi
probing; the AMD ROCm reroute is skipped when the index is pinned, so an explicit
cpu/cu* pin on an AMD host is not overwritten.
- studio/setup.ps1: add shared Get-PinnedTorchIndexUrl / Get-TorchIndexLeaf helpers;
the stale-venv check, the install selection and the AMD reroute all honor the pin,
and the CPU/CUDA install pulls from the resolved index URL.
- tests: parity test that all four installers read both override vars and the two
Windows installers gate the AMD reroute on the pinned flag.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: complete pinned-index handling for ROCm/Windows edge cases
Follow-ups to the override work flagged in review:
- install.ps1: a pinned gfx*/rocm>=7.2 index previously skipped the AMD reroute
that sets the torch>=2.11 floor, so the generic install used torch>=2.4,<2.11
and could resolve the known-bad _grouped_mm wheel. Route a pinned ROCm index
through the ROCm install path with the 2.11 floor + companions, and guard the
companion-spec lookup so a skipped reroute block cannot null-deref.
- studio/setup.ps1: the stale-venv check compared the installed flavor (cuXXX/cpu,
with +rocm misread as cpu) against the raw pinned leaf (gfx1151 / rocm6.4), so a
correct pinned ROCm venv was always marked stale. Classify +rocm wheels as the
generic 'rocm' flavor and normalize a pinned rocm*/gfx* leaf to 'rocm' before
comparing (cu* stays specific so cu126-vs-cu128 still rebuilds).
- install_python_stack.py: _ensure_cuda_torch now also reinstalls from a pinned
CUDA index when the venv carries a CPU wheel (headless CPU-venv-to-CUDA
cross-install via 'studio update'), not only when it finds a ROCm build.
- tests: parity assertions already cover all four installers honoring the override.
* install: finish pinned ROCm/CUDA edge cases on Windows + repair path
Follow-ups to the previous round:
- studio/setup.ps1: a pinned gfx*/rocm>=7.2 index now routes through the ROCm
install path with the 2.11 floor + companions (it previously fell through to the
CUDA branch with bare torch/torchvision/torchaudio against the ROCm index). The
CPU/CUDA fallback index is forced to the CPU wheel index when a ROCm index is
active, so a failed pinned-ROCm install does not retry the ROCm mirror.
- studio/setup.ps1: the stale-venv check no longer treats an unrecognized pinned
URL leaf (e.g. a PEP 503 mirror ending in /simple) as a torch flavor tag, which
was marking a correct venv stale; cu*/cpu/rocm/gfx leaves are still compared.
- install.ps1: the post-failure CPU fallback uses an explicit CPU index instead of
, which for a pinned ROCm index was the ROCm mirror itself (so the
'fallback' just retried the failing index and aborted the installer).
- install_python_stack.py: _ensure_cuda_torch now also reinstalls when the venv's
CUDA family differs from a pinned one (installed cu126 vs pinned cu128), not only
CPU->CUDA; the probe reports the installed cuXXX tag for the comparison.
* install: keep the ROCm to CPU fallback install inside the retry-helper window
The pinned-ROCm CPU fallback computes an explicit CPU index, but the comment
explaining why it cannot reuse $TorchIndexUrl pushed the actual
Invoke-InstallCommandRetry / --force-reinstall call more than 600 chars past the
"ROCm PyTorch install failed" message, so test_pr5940_followups's window check
no longer saw the retry helper. Move the CPU-index computation and its comment
above the failure substep so the retrying force-reinstall stays adjacent to the
message. No behavior change: same explicit CPU index, same retry, same
--force-reinstall.
* install: address #6692 review round 5 (ROCm/CPU pin edge cases)
setup.ps1:
- Stale-venv check: treat an AMD/ROCm host (HasROCm or a resolved gfx arch) with
no explicit pin as expecting "rocm", not "cpu", so a healthy +rocm venv is not
flagged stale (which made installer-managed setup exit and direct update rebuild).
- Pinned-ROCm install failure now routes into the force-reinstall CPU branch:
CuTag stays the rocm/gfx leaf on failure, so the condition also checks
ROCmCpuFallback; otherwise the CUDA branch installed from the CPU index without
--force-reinstall and kept the partial ROCm torch.
- Explicit ROCm pin compare no longer collapses gfx*/rocm* to a generic "rocm":
it compares the +rocmX.Y version (and the torch 2.11 line for gfx pins) so
changing the pinned family (e.g. rocm6.4 -> gfx1151) rebuilds and applies it.
install_python_stack.py:
- _ensure_rocm_torch: an explicit ROCm wheel-index pin now bypasses the
NVIDIA-present / no-AMD-GPU / unreadable-ROCm gates (headless/container/CI
cross-install), mirroring the explicit-CUDA-pin bypass in _ensure_cuda_torch.
- Add _ensure_cpu_torch: an explicit CPU pin (FAMILY=cpu or /cpu URL) now has a
repair path that reinstalls CPU torch over an existing CUDA/ROCm build on a
standalone update (which skips install.sh's flavor enforcement).
install.sh:
- Pin torchvision/torchaudio companions alongside torch for the rocm7.2 / per-gfx
index and the Strix reroute (those AMD indexes publish companions independently
and a bare name can resolve a torch-2.12-built wheel, an ABI mismatch).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* torch-index override: classify CUDA pin by leaf; trim blank shell overrides
_ensure_cuda_torch only overrode the NVIDIA-presence gate for *any* pinned index,
so a non-CUDA mirror URL (or a ROCm/CPU pin) on a non-NVIDIA host with ROCm torch
could force a CUDA reinstall over a working ROCm venv. Add
_explicit_cuda_torch_index_url() (leaf cu*), matching the ROCm/CPU helpers, and
gate on it instead.
install.sh::get_torch_index_url treated a whitespace-only UNSLOTH_TORCH_INDEX_URL
/ _FAMILY as authoritative (yielding an invalid index), unlike the Python .strip()
and PowerShell IsNullOrWhiteSpace paths; trim leading/trailing whitespace first.
* install: honor pinned torch index over CVD/GPU gates and fix leaf-based ROCm classification
- install_python_stack.py: an explicit cu* pin now clears the CUDA_VISIBLE_DEVICES
empty/-1 hide gate as well as the NVIDIA-presence gate, so
CVD=-1 UNSLOTH_TORCH_INDEX_FAMILY=cu128 studio update repairs to CUDA wheels
(parity with install.sh's get_torch_index_url override, which skips all GPU
probing). Unpinned CVD=-1 still skips.
- install_python_stack.py: _ensure_cpu_torch installs the bounded _CPU_TORCH_PKG_SPEC
instead of a bare torch/torchvision/torchaudio trio; the /cpu index now also
serves torch 2.11+, which is outside the supported <2.11 range.
- install.sh: the torch>=2.11 constraint case matches the index leaf (rocm7.2|gfx*)
instead of the whole URL, so a mirror base path containing a gfx/rocm7.2 segment
with a cu*/cpu family is not false-matched onto the 2.11 line.
- setup.ps1: the stale-venv check expects rocm torch only for arches the install
path maps to a repo.amd.com wheel index; an unmapped/unreadable arch installs
CPU, so a correct CPU venv is no longer marked stale.
- Tests for each of the above.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: tighten pinned torch-index override edge cases
- install.sh: trim whitespace-only UNSLOTH_TORCH_INDEX_URL/_FAMILY before the
_torch_index_pinned guard, matching get_torch_index_url, so a blank override no
longer skips the WSL bootstrap and Radeon/Strix reroutes while detection still
picks the normal index.
- install.sh / install.ps1 / setup.ps1 / install_python_stack.py: force the torch
2.11 floor only for the gfx families with the <2.11 _grouped_mm bug (gfx120X-all,
gfx1151, gfx1150). A pinned override to gfx110X-all/gfx90a/gfx908 stays on the
default range, matching the automatic AMD path.
- install_python_stack.py _ensure_cuda_torch: treat an untagged CUDA build under a
CUDA pin as a family mismatch (reinstall), and match cuXXX pins narrowly (cu +
digits) so a custom/current mirror leaf no longer forces CUDA over a CPU/ROCm venv.
- install_python_stack.py _ensure_rocm_torch: reinstall when an explicit ROCm pin
names a different ROCm family than the already-installed ROCm torch (the ROCm
analogue of the CUDA cuXXX mismatch repair).
Adds tests for each case.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: fix second-order edge cases in pinned torch-index ROCm/CUDA handling
Parse the ROCm torch probe positionally so an empty HIP marker is kept:
CPU/CUDA torch no longer reads as HIP, so the ROCm reinstall is not skipped.
Emit one "<marker>|<version>" line (like the CUDA probe) for a robust parse.
Limit the gfx torch 2.11 expectation to the install allowlist
(gfx120X-all/gfx1151/gfx1150). A pinned gfx110X-all/gfx90a/gfx908 index stays
on the default <2.11 specs, so a correct 2.10+rocm wheel is no longer judged a
mismatch and force-reinstalled every update.
Distinguish an AMD per-arch wheel (three-part +rocmA.B.C) from a generic
pytorch.org wheel (two-part +rocmA.B): a gfx per-arch pin over a generic 2.11
wheel now reinstalls the per-arch wheel, while an already-installed per-arch
wheel is not re-flagged (no reinstall loop).
Mirror all of the above in setup.ps1 via new Test-RocmGfx211Leaf /
Test-CudaFamilyLeaf / Get-RocmPinStaleTags helpers, reused by both the
install-spec path and the stale-venv check so they cannot diverge again.
Require a digit after "cu" (^cu[0-9]) in setup.ps1, install.ps1 and install.sh
so a mirror leaf like /custom or /current is not branded CUDA and does not
rebuild the venv every run.
Add tests: CPU/CUDA probe -> has_hip_torch False; gfx110X-all pin + 2.10 wheel
not stale; gfx1151 pin + generic 2.11 wheel stale; gfx1151 pin + per-arch wheel
not stale; /custom and /current not CUDA; plus cross-language allowlist and
cu-digit parity guards, and a PowerShell unit test for the new setup.ps1 helpers.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix ROCm/gfx pin case normalization, ROCm-tag requirement, and CUDA-leaf classification
Normalize torch-index leaves to lowercase before the gfx*/rocm*/cu* allowlist
matches so the canonical gfx120X-all (capital X) gets the torch 2.11 floor in
install.sh (leaf, flavor and repairable helpers). Require an installed +rocm
local tag before a rocmX.Y or non-2.11 gfx pin is judged satisfied in
setup.ps1 Get-RocmPinStaleTags and the Python _rocm_pin_family_mismatch, so an
untagged CPU/CUDA wheel never leaves the pin unapplied. Classify a leaf as CUDA
only via ^cu[0-9]: the Python _TORCH_BACKEND derivation now uses
_is_cuda_family_leaf, and install.sh brands cuda only on cu[0-9]* (unset on an
unknown /current /custom mirror leaf) so the stack probes the GPU instead of
skipping ROCm repair. Add bash, Python and PowerShell tests for capital
gfx120X-all floor, current/custom not-cuda, and untagged-wheel ROCm pins.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: converge torch-index pin detection via a per-venv marker
Introduce a torch-index MARKER that records the exact wheel --index-url used
after each successful torch install, so `unsloth studio update` / repair makes
the "did the pinned index change?" decision by an EXACT string compare rather
than inferring it from the wheel +rocm/+cu version tag. The tag cannot encode
the AMD per-arch gfx family (two 2.11 gfx indexes both install +rocm7.13.0), so
the tag heuristic missed a gfx1151 -> gfx120X-all switch and a custom-URL swap.
Marker path is per-venv (.unsloth-torch-index), one line = the resolved index
URL, written atomically (temp + rename). Path, format and normalization are
shared across all four installers (install.sh, install_python_stack.py,
setup.ps1, install.ps1).
- Reapply gfx pins on a per-arch target change: the marker's exact compare
reinstalls when the pinned index differs, even when both wheels share a tag.
- Honor custom ROCm URL pins during repair: an explicit index whose leaf is not
rocm/gfx/cu/cpu (e.g. simple, current) now reinstalls torch VERBATIM from the
pin when it differs from the marker ("URL wins verbatim").
- Align the KNOWN-2.11 rocm/gfx set to exactly rocm7.2 plus the gfx allowlist
gfx120x-all/gfx1151/gfx1150 in every language; stop treating an unknown newer
rocm (rocm7.3, which does not exist) as the 2.11 line speculatively.
Backward compatible: with no marker (old venvs, torch installed out-of-band) the
existing +rocm/version-tag heuristics still decide, and a matching marker never
reinstall-loops. A cu128 CUDA pin stays a CUDA pin; custom and current leaves are
not CUDA. Adds marker tests (py/sh/ps) plus cross-installer parity checks.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: keep the torch-index marker additive to flavor validation
Three narrow fixes in the marker-based stale-venv detection:
- setup.ps1: a matching marker no longer overwrites the detected installed
flavor. The marker compare is now an additional rebuild trigger, so a stale
wheel (torch swapped to a +cpu build while the marker still records a cuXXX
pin) is still caught by the flavor check instead of being masked as up to date.
- setup.ps1: a supported AMD arch carrying CPU torch is no longer marked stale
and wiped. The downstream AMD Windows ROCm override upgrades CPU torch to ROCm
in place, so wiping first would delete the venv and abort with "Virtual
environment not found". Only a genuinely wrong CUDA wheel still rebuilds.
- install.sh: the Radeon --find-links path records its repo.radeon.com base in
the marker instead of the generic pytorch.org ROCm fallback index, so a later
pin to that generic family correctly reinstalls rather than comparing equal.
Mirrors install.ps1/setup.ps1, which already record the real AMD index.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: honor custom pins and repair pinned venvs in place
Four follow-ups to the torch-index marker work:
- install_python_stack.py: _ensure_cuda_torch/_ensure_rocm_torch now bail when an
explicit custom-index pin names no known torch family, so a verbatim URL override
(a private/simple mirror) is not clobbered by auto-detected CUDA/ROCm wheels
before _ensure_verbatim_torch_index applies it.
- install_python_stack.py: the ROCm marker is additive, not a substitute -- a
matching marker still runs the family/version check so a wheel swapped after the
marker was written is caught. Mirrors setup.ps1.
- setup.ps1: a stale venv under an explicit pin, whose torch still imports, is
repaired in place (force-reinstall torch from the pin in the dependency pass)
instead of wiped. The wipe path only delegates to install.ps1, so on a direct
update it stranded the user at "Virtual environment not found" instead of
applying the new pin. A broken venv or unpinned drift still wipes/delegates.
- install.ps1: when a pinned ROCm install fails over to a CPU base, the marker now
records the CPU index actually used instead of the ROCm pin, so the next managed
setup does not see CPU torch under a ROCm pin and abort as stale.
* setup.ps1: keep the ROCm CPU-fallback force line the pr5940 test guards
5c93ffd4 folded the pin-change force-reinstall into the ROCm CPU-fallback
condition on one line, so the exact literal that test_pr5940_followups.py checks
(if ($ROCmCpuFallback) { $cpuForce = @("--force-reinstall") }) no longer appeared
and the test failed. Split the two conditions into separate if lines: the ROCm
fallback line is restored verbatim and the pin-change force is its own line. Both
still set $cpuForce to the array, so @splat passes one arg.
* install: honor exact CUDA/custom index URL pins in the torch-index marker
Address three Codex review findings on the torch-index marker mechanism:
- install.sh: after the ROCm CPU repair reinstalls torch from the generic
$TORCH_INDEX_URL, record that as the marker source. A Radeon --find-links
install set _TORCH_MARKER_INDEX_URL to its repo.radeon.com base earlier, so
leaving it made the marker misreport Radeon wheels and a later Radeon pin would
compare equal and skip a needed reinstall.
- install_python_stack.py: _ensure_cuda_torch now consults the exact-URL marker
(_marker_pin_mismatch) when the installed +cuXXX tag matches the pinned leaf,
so a same-leaf CUDA mirror change (official cu128 to an internal cu128 mirror)
is reinstalled and re-recorded instead of skipped.
- _normalize_index_url / _normalize_family_leaf (install.sh, setup.ps1,
install_python_stack.py): lowercase only KNOWN wheel-family leaves (rocm/gfx/
cpu/cuXXX) so gfx120X-all still matches gfx120x-all, while a custom
(unknown-family) leaf keeps its case so a verbatim URL pin like /Current does
not compare equal to /current. Tests updated to assert the refined behavior.
* install: fix 3 torch-index marker edge cases (CPU mirror pin, Radeon leaf, migrated venv)
Addresses three review findings on the torch-index override path:
1. CPU index URL change on an already-CPU venv. _ensure_cpu_torch returned
early whenever torch was already a CPU build, so a standalone update that
moved the pin (official /cpu -> a private UNSLOTH_PYTORCH_MIRROR /cpu, same
+cpu tag) never reinstalled. It now consults the exact-URL marker and
reinstalls only when _marker_pin_mismatch reports a different index,
mirroring the CUDA/ROCm same-family handling. A matching marker (or none)
still leaves CPU torch untouched, so there is no reinstall loop.
2. Radeon find-links directory misclassified as a pip ROCm family. A
repo.radeon.com/.../rocm-rel-7.2.1 leaf starts with "rocm" but is a
find-links listing, not a pip --index-url. The old startswith(("rocm",
"gfx")) test routed it into a --index-url reinstall that fails against
find-links. New _is_pip_rocm_family_leaf gates on ^rocm\d / gfx (matching
install.sh's rocm[0-9]* and setup.ps1's ^(rocm[0-9]|gfx)), so a Radeon URL
routes to the verbatim/marker path instead.
3. Migrated venv rewriting its marker to a pin it did not install. install.sh
and install.ps1 write the marker unconditionally, so a migration that
preserves existing torch recorded the newly requested pin and a later
update then found a matching marker and skipped the reinstall the pin
needs (e.g. a per-arch gfx1151 -> gfx120X-all switch, identical +rocm tag).
Both now track _TORCH_INSTALLED_THIS_RUN and write the marker only when
torch was actually installed or repaired this run.
Also add Get-NormalizedFamilyLeaf to the setup.ps1 helper-extraction list in
test_torch_index_marker.ps1 (it was added to setup.ps1 and the shell test in an
earlier round but missed here) and add two unit tests covering findings 1 and 2.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: keep pinned torch repairs on the pinned index
Two fixes for explicit index pins (UNSLOTH_TORCH_INDEX_FAMILY / _URL):
1. install_python_stack.py's repair paths ran uv without clearing the
inherited uv index env vars. uv resolves the default index (--index-url
or --default-index) at the LOWEST priority, so a UV_INDEX or
UV_EXTRA_INDEX_URL mirror in the environment won for any package it
served: a cu128-pinned repair could install torch from the mirror and
then record the cu128 marker it never used. Verified empirically: with
UV_EXTRA_INDEX_URL=.../cu126 exported, uv pip install torch
--index-url .../cu128 resolves torch 2.13.0+cu126. Strip the four uv
index env vars for pinned-index commands only, mirroring the gate
install.sh, install.ps1 and setup.ps1 already have; non-pinned installs
keep the user's mirror.
2. install.ps1 routed any pinned leaf matching rocm* through the ROCm
--default-index path, so a custom find-links leaf like rocm-rel-7.2.1
was treated as a PEP 503 ROCm index and could silently fall back to CPU
torch on resolution failure. Require a digit after rocm, matching
install.sh's rocm[0-9]* and install_python_stack.py's ^rocm\d.
Adds parity + unit tests for both (11 new tests).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: keep pinned repairs off UV_TORCH_BACKEND and narrow setup.ps1's rocm pin match
Round 2 of the pinned-index hardening:
1. _build_uv_cmd converted UV_TORCH_BACKEND into --torch-backend before the
new env isolation could act, and uv's torch backend redirects torch
resolution to its own per-backend index even when --index-url is given
(verified: a cu128-pinned dry run with UV_TORCH_BACKEND=cpu resolves
torch 2.13.0+cpu). Pinned-index commands now never receive the flag and
UV_TORCH_BACKEND joins the stripped env vars, so uv cannot re-read it.
2. setup.ps1's pinned reroute had the same bare rocm* glob install.ps1 had:
a custom find-links leaf like rocm-rel-7.2.1 was routed through the ROCm
--index-url path instead of the verbatim unknown-pin path. Now requires
a digit after rocm, matching install.ps1, install.sh and
_is_pip_rocm_family_leaf.
3. The marker test's case-normalization checks used -eq, which is
case-insensitive in PowerShell, making them vacuous, and the unknown-leaf
expectation was written lowercased while the implementation deliberately
preserves custom-leaf case. Tightened to -ceq with the case-preserving
expected value.
Adds unit + parity tests for 1 and 2 (5 new tests).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: extend the pinned-index guards to every remaining surface
Round 3 of the pinned-index hardening, closing the same holes on the
surfaces the earlier rounds missed:
1. install.sh's pinned-install env scrub now clears UV_TORCH_BACKEND (uv's
torch backend redirects torch resolution to its own per-backend index
even against --default-index), and both PowerShell wrappers clear it in
their pinned-install scrubs, matching install_python_stack.py.
2. setup.ps1's marker stale check still classified any rocm* leaf as a
PyTorch ROCm family while the install selection is digit-gated, so a
custom rocm-current / rocm-rel-7.2.1 pin stale-compared as
not-rocm vs rocm and force-reinstalled on every studio update. The
stale check now uses the same ^rocm\d gate.
3. install_python_stack.py's pinned-command scrub also strips
PIP_EXTRA_INDEX_URL for the pip fallback: pip adds the env extra index
in addition to --index-url, so an inherited mirror could satisfy torch
off the pin while the marker recorded the pinned URL. PIP_INDEX_URL
needs no strip since the explicit --index-url flag overrides it.
Parity + unit tests extended (4 new tests).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: scrub find-links and carry the pinned scrub through pip fallbacks
Round 4 of the pinned-index hardening:
1. UV_FIND_LINKS joins every pinned-install scrub (install.sh, install.ps1,
setup.ps1, install_python_stack.py): uv's --find-links locations can
satisfy torch off the pinned index the same way an extra index does.
2. setup.ps1's Fast-Install restored the scrubbed vars in its finally
BEFORE the pip fallback ran, and never touched the pip env vars at all,
so a failed uv attempt fell back to python -m pip with an inherited
PIP_EXTRA_INDEX_URL / PIP_FIND_LINKS able to win over the pinned
--index-url. The scrub now wraps the whole function (uv attempt + pip
fallback) and includes the pip vars; restore happens after both.
3. install_python_stack.py's scrub also strips PIP_FIND_LINKS for its own
pip fallback, completing the PIP_EXTRA_INDEX_URL fix from round 3.
Parity tests extended (2 new tests).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: digit-gate rocm leaves in marker normalization and ROCm side effects
Round 5 of the pinned-index hardening (three custom-rocm-leaf edge cases):
1. _normalize_family_leaf lowercased every leaf starting with rocm, so a
custom mirror leaf like rocm-Current compared equal to its lowercase form
and a case-only pin change was skipped. URL paths can be case-sensitive.
The rocm prefix is now digit-gated (rocm[0-9]*, matching
_is_pip_rocm_family_leaf) in install.sh, setup.ps1 and
install_python_stack.py, so only true family leaves (rocm7.2) are
lowercased; a custom rocm-* leaf keeps its case.
2. setup.ps1 Test-MarkerPinMismatch compared normalized URLs with -ne, which
is case-insensitive in PowerShell, so a case-only marker change (Simple
vs simple) was treated as matching and the reinstall skipped. Now -cne.
3. install.sh gated the AMD bitsandbytes install and the "repair ROCm torch"
--default-index reinstall on a bare whole-URL rocm glob, so a custom
CPU/CUDA/private index whose leaf merely starts with rocm (rocm-current)
was force-repaired from the wrong ROCm-only path whenever torch.version.hip
was empty. Both now gate on _torch_index_is_rocm_family, computed once from
the digit-gated leaf (rocm[0-9]*/gfx*).
Tests: 4 new parity assertions plus 2 case-sensitivity marker checks.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: apply an explicit custom torch-index pin on the first update
Round 6: an explicitly-set custom (unknown-family) UNSLOTH_TORCH_INDEX_URL
was silently ignored on the first `studio update` of a venv that predates
the marker feature, on both platforms, because the no-marker case was
treated as "do nothing" and the version-tag heuristics cannot judge an
unknown leaf.
1. install_python_stack.py _ensure_verbatim_torch_index now reinstalls
verbatim when the marker is ABSENT (None), not only when it differs, and
short-circuits only when the marker already records this exact pin. It
then writes the marker, so every later update is a no-op. A user who did
not set the override gets pin=None and is untouched, so an out-of-band
torch install is never clobbered.
2. setup.ps1: for an unknown-family pin on a marker-less venv the stale-venv
check now sets PinChangedForceReinstall so the torch block reinstalls in
place from the pin. It deliberately does NOT set shouldRebuild, which
would wipe the venv and strand a direct `studio update`.
3. setup.sh (the Linux `studio update` entry point) skipped
install_python_stack.py entirely when unsloth was already current, so the
marker-driven reinstall (both the verbatim custom pin and the cu/rocm
flavor and family-change repair, e.g. gfx1151 to gfx120X-all) never ran.
It now forces the dependency pass when a torch-index pin env var is set;
the pass is idempotent and no-ops when the marker already matches. This
mirrors setup.ps1's stale-venv pre-check.
Tests: 3 new parity assertions.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* test: expect first-update reinstall for a no-marker custom index pin
Follow-up to d671d8fb2: _ensure_verbatim_torch_index now applies an
explicit unknown-family URL pin verbatim on the first update when the
marker is absent (instead of no-op), so the old
test_verbatim_custom_url_no_marker_is_noop assertion was stale. Rewritten
as test_verbatim_custom_url_no_marker_reinstalls_once: asserts the one
verbatim reinstall from the pinned URL, that the marker is written, and
that a second call with the pin still set is idempotent (no reinstall
loop).
* install: gate the pinned update pass on the marker and record a pin baseline
Round 8, two follow-ups to the round-6 first-update pin fix:
1. setup.sh forced the full dependency pass on EVERY `studio update` while a
torch-index pin stayed exported, even after the marker already recorded the
same pin, turning quick updates into the expensive pass every time. It now
probes install_python_stack.py --torch-pin-needs-apply (which reuses the
exact marker normalization) and forces the pass only when the pin is not yet
applied (marker absent or different); an already-applied persistent pin keeps
the fast path. A probe error fails safe toward running the pass. setup.ps1
gets the same probe in its fast path for parity.
2. A known-family full-URL pin on a venv predating the marker (e.g. an installed
cu128 build and UNSLOTH_TORCH_INDEX_URL pointing at a same-family mirror) left
the marker absent forever: the _ensure_* helpers deliberately do not force a
multi-GB reinstall of identical-family wheels on an old venv, so nothing
recorded the pin and every update re-entered the pass. _record_torch_index_pin_baseline
now records the resolved pin as a baseline after the ensure sequence when the
family already matches and no marker exists, so the pin is tracked (a later
genuine change is detected and applied) and the update loop is broken, without
the redundant reinstall.
Tests: 3 new baseline unit tests, 4 new parity assertions, and the CLI probe.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* setup.sh: keep the pin probe's exit 1 from killing the update under set -e
The --torch-pin-needs-apply probe deliberately exits 1 for the common
steady-state answer (pin already recorded, keep the fast path), but it ran
as a bare command under set -euo pipefail, so the whole studio update
aborted before the exit code was even captured. Absorb the status with
|| _PIN_NEEDS_APPLY=$? and pre-seed 0 so all three outcomes route as
documented: 0 runs the pass, 1 keeps the fast path, anything else fails
safe into the pass. Parity test asserts the guard.
* install: strip pin credentials, disable uv config discovery, bound verbatim installs
Four verified fix groups from a 12-reviewer audit of the torch-index
override feature, each reproduced before fixing:
1. Credential persistence: all four marker writers stored the raw pin URL,
so an authenticated pin (https://user:token@mirror/simple) persisted its
credentials in .unsloth-torch-index (mode 0644 under a default POSIX
umask) and install_python_stack.py printed pin URLs verbatim in repair
messages. Userinfo is now stripped before persisting and in every
log/substep that interpolates a pin, via lockstep helpers
(_strip_index_url_credentials in install.sh / install_python_stack.py,
Remove-IndexUrlCredentials in install.ps1 / setup.ps1). The three
normalizers strip too, so an OLD marker that already carries credentials
still compares equal to the same pin: no reinstall loop on upgrade.
Query strings deliberately stay in the marker; two indexes distinguished
only by query must not compare equal.
2. uv configuration discovery beat the explicit pin: with a discovered
uv.toml declaring torch-backend = "cpu" or a [[index]] entry, uv 0.10.12
resolves torch 2.13.0+cpu against an explicit --index-url/.../cu126 pin;
UV_NO_CONFIG=1 restores +cu126 (reproduced both ways). The pinned-install
scrub in all four installers now sets UV_NO_CONFIG=1 and drops
UV_CONFIG_FILE.
3. The verbatim custom-index update path installed a bare, unconstrained
torch trio while fresh installs from the same unknown-leaf pin apply the
supported range; _ensure_verbatim_torch_index now installs the bounded
trio spec, closing the fresh-vs-update asymmetry.
4. Query-bearing pins (.../cu128?token=x) classified by raw leaf split and
force-reinstalled on every update (the installed cu128 never equals
cu128?token=x). Query/fragment are now stripped before leaf
classification in all four implementations; the marker comparison keeps
the query per (1).
Rejected after verification (no change): the pin-baseline record cannot
produce a wrong later decision (every pin change still mismatches and
reinstalls from the new pin); the venv temp-file symlink scenarios require
an attacker who already owns the environment; pathological inputs like
" / cu128 / " have no realistic caller and fail loudly.
Parity, stack, rocm-support, marker (sh + ps1), pin-stale, index-url and
flavor suites all pass (455 python + full shell/ps1 batteries).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: harden custom-pin repair against clobber, broken torch, and pip config
Four follow-ups to the pinned-index audit fixes:
1. setup.ps1 routed an unknown-leaf custom pin through the CUDA branch with
a bare torch trio while install.ps1 (fresh) and the Python verbatim path
bound the supported range; the pinned unknown-leaf route now applies the
same torch>=2.4,<2.11.0 bound. Known cu* leaves and unpinned runs are
unchanged.
2. The final torch safety pass could not repair a clobbered unknown-family
pin: intermediate dependency steps can pull torch from PyPI (the pass
exists for exactly that reason), but the verbatim helper short-circuited
on marker==pin and no flavor tag exists to probe. The helper now keeps a
per-run snapshot of the installed trio (taken after a verbatim reinstall
or on the first matching-marker pass) and reinstalls from the pin when
the final pass sees the trio drifted. Probe failure skips the
comparison; a reinstall refreshes the snapshot, so no loop.
3. _record_torch_index_pin_baseline could freeze a known-family pin as
applied on a venv whose torch is missing or broken (every family helper
returns without reinstalling when its probe fails), making
--torch-pin-needs-apply report done forever. The baseline now probes the
installed flavor and records only on a match: a cuXXX pin requires the
matching +cuXXX tag, cpu requires a cpu build, rocm/gfx requires hip;
probe failure records nothing.
4. The pinned pip fallback stripped PIP_* env vars but user/site pip config
files still applied (a configured global.extra-index-url can satisfy
torch off the pin). PIP_CONFIG_FILE is now pointed at the null device
for pinned commands (pip loads no config files then), in
_install_env_for_cmd and setup.ps1's Fast-Install pinned scrub.
install.sh / install.ps1 have no pip fallback (uv-only), verified.
Tests: 7 new rocm_support tests (snapshot reset fixture), 1 stack test,
2 parity tests. Full battery green (464 python, sh and ps1 suites).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: complete the pin-repair coverage across the fast path and platforms
Three cross-platform follow-ups to the round-2 pin-repair fixes:
1. The --torch-pin-needs-apply probe only compared marker==pin, so a torch
trio clobbered to the wrong family (a cpu wheel replacing cu128 via a
later pip install) with a still-matching marker reported "already
applied" and the _ensure_{cuda,rocm,cpu} repair never ran on the Linux
fast path. The probe is now a testable _torch_pin_needs_apply() that also
checks the installed flavor against a known-family pin (via a shared
_torch_flavor_matches_pin() helper, so the baseline and the probe cannot
drift). An unknown-family pin has no flavor to validate and a failed
probe cannot prove drift, so both keep the fast path.
2. macOS ARM (real CPU/MPS torch, not NO_TORCH) never applied an unknown-
family custom pin on update: both the verbatim path and the baseline
returned on IS_MACOS while fresh install.sh honors the pin, so the marker
was never written and setup.sh forced the dependency pass on every update
forever. The guards are now IS_MAC_INTEL (Intel mac is already NO_TORCH),
and the final pass applies the pin on macOS ARM.
3. The round-2 final verbatim repair sat in the step-13 sequence guarded
not IS_WINDOWS, so on Windows a dependency step that clobbered torch after
the pin was applied was masked by the matching marker (setup.ps1 does not
re-validate the main venv's torch after calling this script -- verified).
Step 13 now runs the verbatim snapshot-drift repair on Windows and macOS
ARM too; the Linux-oriented cuda/rocm/cpu family helpers stay Linux-only.
Tests: 13 new rocm_support cases (flavor drift, macOS ARM, Windows repair),
parity updates. Full battery green (475 python, sh and ps1 suites).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: strip query tokens from the marker and tighten the pin-drift probe
Four follow-ups to the round-3 pin-repair fixes:
1. The credential stripper feeding the torch-index marker and the logged repair
messages dropped only user:pass@ userinfo, so a private feed that carries its
auth token in the query string (.../simple?token=SECRET) persisted the token
in the world-readable marker (mode 0644 under a default umask) and printed it
in substep output. All four strippers (install.sh, install.ps1,
studio/setup.ps1, install_python_stack.py) now drop the query and fragment
before building the sanitized URL. A query is not part of a PEP 503 index's
identity, so this also stops a rotated token from spuriously mismatching the
marker and forcing a needless reinstall.
2. The --torch-pin-needs-apply fast-path probe accepted an untagged CUDA build
(no +cuXXX local tag) under a specific cuXXX pin, but _ensure_cuda_torch
reinstalls exactly that build to enforce the pin. The probe was more lenient
than the repair, so the repair pass was skipped on the fast path.
_torch_flavor_matches_pin now reports a mismatch for an untagged build under a
cuXXX pin, forcing the pass.
3. The probe's ROCm branch accepted any HIP build for a rocm/gfx pin, while
_ensure_rocm_torch decides a reinstall with the per-arch
_rocm_pin_family_mismatch predicate (a generic +rocm7.2 wheel under a per-arch
gfx pin, or a wrong ROCm version, is a mismatch). The probe now reuses that
predicate, so it is as strict as the repair. This needs the installed torch
version, so _probe_torch_flavor now returns (marker, cutag, version) and
_torch_flavor_matches_pin takes the pin URL (extracting the leaf internally).
4. On Windows a known-family cu*/cpu pin is applied to the main venv by setup.ps1
before install_python_stack.py runs; a later dependency step can clobber it,
and the GPU-aware _ensure_{cuda,cpu}_torch self-skip on Windows while the
verbatim helper handles only unknown-family pins, so nothing repaired the
clobber (setup.ps1 does not re-validate the main venv's torch afterward,
verified). New _ensure_pinned_known_family_torch reinstalls a drifted cu*/cpu
pin in the step-13 Windows/macOS-ARM branch; rocm/gfx per-arch specs stay owned
by setup.ps1, unknown-family by the verbatim helper.
A speculative ROCm 2.11 floor was also raised but is unreachable: the rocm7.2
index publishes no 2.x wheel below 2.11.0, and an unknown newer rocm is not
floored speculatively.
Tests: query/fragment strip cases in the sh + ps1 marker suites and the Python
strip/marker tests; the tri-state helper and the probe/baseline harnesses moved
to the (marker, cutag, version) flavor with matching versions; new probe cases
(untagged CUDA, generic-rocm-under-gfx) and 8 _ensure_pinned_known_family_torch
tests; a four-way query-strip parity assertion. Full battery green (1150 python,
sh 26/26 marker, ps1 marker/flavor/pin-stale).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: reinstall markerless gfx pins and cap custom-index updates at torch 2.11
Two follow-ups from the pin-marker audit:
1. A markerless venv with a gfx per-arch 2.11 pin trusted the wheel version
tag, which is byte-identical (+rocm7.13.0) across gfx120X-all / gfx1151 /
gfx1150. A pre-marker install holding one gfx arch's wheel that is now
pinned to a DIFFERENT gfx index was therefore never switched:
_rocm_pin_family_mismatch returns no-mismatch for any three-part +rocm
2.11 wheel, and _ensure_rocm_torch's absent-marker branch fell through to
that heuristic. _ensure_rocm_torch now forces a one-time reinstall when the
marker is absent AND the pin leaf is a 2.11 gfx per-arch index; the reinstall
writes the marker, so the next update compares exactly and does not loop
(the correctly-pinned no-reinstall guarantee then comes from the exact marker
compare, not the ambiguous tag). Non-gfx-2.11 pins (rocmX.Y, non-2.11 gfx)
stay on the tag heuristic -- their tags are distinguishable.
2. The verbatim custom-index update path used _CUDA_TORCH_PKG_SPEC (torch
<2.12.0) while a FRESH install of the same unknown leaf caps torch at
<2.11.0 (install.sh's default TORCH_CONSTRAINT, and setup.ps1's custom-pin
branch), so a private /simple mirror publishing torch 2.11 could upgrade a
`studio update` to a state the fresh installer never produces. Added
_CUSTOM_INDEX_TORCH_PKG_SPEC (torch>=2.4,<2.11.0), used only by the verbatim
path; companions stay pinned for the same exclusive --index-url ABI reason
as _CUDA_TORCH_PKG_SPEC (a bare name could pull a torch-2.12-built
torchvision). _CUDA_TORCH_PKG_SPEC is unchanged (known-family cu/cpu repair
correctly tracks install.sh's widened cu ceiling).
Tests: 2 new markerless-gfx cases (one-time reinstall + marker write + no-loop
second run, and the rocmX.Y absent-marker no-op), the pre-existing markerless
gfx no-reinstall test flipped to assert the one-time reinstall (it had encoded
the old tag-trusting behavior), and the custom-index bound assertions. 488
passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: a matching marker must not mask a broken, clobbered, or misclassified torch
Four round-6 follow-ups, all closing cases where a matching torch-index
marker wrongly vouched for a torch that is not actually the pinned one:
1. _is_cuda_family_leaf matched cu+digits by PREFIX (^cu[0-9]), so a custom
mirror leaf like cu128-private classified as CUDA family; the flavor check
then compared the installed cu128 tag to the whole leaf cu128-private and
forced a reinstall on EVERY update (never converging). The cu family is
now matched EXACTLY (re.fullmatch cu[0-9]+), so a cu-suffixed custom leaf
routes through the verbatim/unknown path with a stable marker. Mirrored in
install.sh (_normalize_family_leaf: strip cu, require an all-digit
remainder) and setup.ps1 / install.ps1 (^cu[0-9]+$).
2. _torch_pin_needs_apply returned False on a failed torch probe (missing or
unimportable) under a matching marker, so setup.sh kept the fast path and
a broken torch was never repaired. A failed probe now forces the pass: the
marker cannot vouch for a torch that does not import, forcing is idempotent,
and once torch imports again the probe succeeds and the forcing stops
(self-resolving). Reverses the round-4 conservative choice for this case.
3. _ensure_verbatim_torch_index snapshotted the installed trio on the first
pass with a matching marker and treated an unimportable torch (snapshot
None) as "no drift, skip", so a torch clobbered to a broken state before
the run was masked. A None snapshot now reapplies the pin. A torch
clobbered to a WORKING-but-wrong build under an unknown-family pin remains
undetectable from metadata (no flavor tag; reinstalling every update would
be the loop this avoids) and is documented as a known limitation.
4. The step-13 Windows final repair reran only the verbatim (unknown-family)
and known-family cu*/cpu paths, so a clobbered explicit rocm/gfx pin (the
wheel setup.ps1 installed from AMD's per-arch index) was left in place. The
branch now also runs _ensure_rocm_torch on Windows for an explicit rocm/gfx
pin; it has a Windows path and no-ops when torch already links HIP, so it
only reinstalls a genuinely clobbered ROCm venv (loop-safe).
Tests: the round-4 failed-probe-trusts-marker test flipped to force the pass;
new cases for the cu-suffix no-loop, the broken-torch verbatim reinstall, and
the Windows rocm final-repair structure; item-2 exact-cu parity assertions.
490 passed. sh/ps1 marker + flavor + pin-stale suites all green.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: repair Windows ROCm pins from the pinned URL and honor NO_TORCH
Four round-7 review items, two of them regressions in the round-6 work:
1. _torch_pin_needs_apply ignored UNSLOTH_NO_TORCH. With a torch-index env
var set and no marker, the failed-probe branch forced the dependency pass
on every `studio update`, and the pass (which also honors NO_TORCH) never
installs torch or writes a marker, so nothing could ever stop the forcing.
It now returns False immediately under NO_TORCH: the pin only matters once
torch is actually installed.
2. The step-13 Windows final repair (round-6) restored a clobbered explicit
rocm/gfx pin by calling _ensure_rocm_torch, whose Windows path reinstalls
from the arch AUTO-DETECTED via hipinfo, not from the pin. A user pinning a
different gfx family or a private mirror was restored from the wrong source
(and the wrong marker written), and a headless box was skipped entirely
(the arch probe returns nothing). The repair now goes through
_ensure_pinned_known_family_torch, which reinstalls from the PINNED url with
the same per-arch floor setup.ps1 uses (2.11-line gfx leaves) or a bare trio
(older arches, rocmN mirrors). It is gated on IS_WINDOWS since macOS ARM has
no ROCm, and the existing flavor check keeps it loop-safe (a matching HIP
wheel is left alone).
3. _ensure_verbatim_torch_index's broken-torch check (round-6) used
"_installed_trio_snapshot() is None", but that helper reports a REMOVED torch
as "torch==absent" (a non-None tuple) and a broken import as the stale
on-disk version, so a missing or unimportable torch under a matching marker
was read as "no drift" and skipped. The matching-marker path now confirms
torch health with an import probe (_probe_torch_flavor): a torch that does
not import reapplies the pin, while a healthy torch keeps the snapshot-based
intra-run drift detection.
4. A unit test for _ensure_cpu_torch did not pin NO_TORCH False like its
siblings, so a suite run with UNSLOTH_NO_TORCH=1 in the environment made the
guard return early and the reinstall assertions fail spuriously.
Tests: the round-6 broken-torch verbatim test re-encodes the non-None
"torch==absent" snapshot case (the exact state the old "is None" check missed);
new Windows-ROCm pinned-repair cases (reinstall from the pin, per-arch floor vs
bare spec, matching-wheel no-op, off-Windows no-op); a NO_TORCH fast-path probe
case; the parity test now asserts the Windows final branch does not auto-detect
the ROCm index and that the helper reinstalls from the explicit pin. 494 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: floor the rocm7.2 index in the Windows pin repair; isolate marker tests
Three round-8 review items, two of them downstream of the round-7 changes:
1. _ensure_pinned_known_family_torch gave a rocm<d> index leaf a bare
torch/torchvision/torchaudio trio while flooring only gfx* leaves, so a
Windows venv clobbered under an explicit rocm7.2 pin could reinstall an
unbounded or ABI-mismatched trio from that exclusive --index-url. It now
mirrors the spec the initial ROCm paths pin: the rocm7.2 floor for 2.11-line
gfx leaves and rocm<d> leaves that serve torch 2.11, the <2.11 default for
older rocm versions, and a bare trio only for older gfx per-arch leaves
(which publish no floor), matching _ROCM_TORCH_PKG_SPECS / _ensure_rocm_torch.
2. test_verbatim_custom_url_no_marker_reinstalls_once called
_ensure_verbatim_torch_index twice; the second call now hits the
matching-marker health probe, and with pip_install mocked torch never becomes
importable, so in a no-torch environment _probe_torch_flavor returned None and
forced another reinstall, failing the idempotence assertion. The test now pins
a healthy flavor so the idempotence check is about the marker, not ambient
torch.
3. The TestEnsureRocmTorchMarker fixture patched os.environ per test but not
_TORCH_BACKEND, which install_python_stack.py computes once at import from
UNSLOTH_TORCH_BACKEND. A runner starting with a cuda/cpu backend made
_ensure_rocm_torch early-return and skip the mocked repair these tests
exercise. The fixture now neutralizes _TORCH_BACKEND so the marker tests are
independent of the caller's installer-pin environment.
Tests: the Windows floor-spec test now asserts a rocm7.2 mirror pin uses the
rocm7.2 floor (not bare), plus a new rocm7.1 case that must fall back to the
<2.11 default; the marker suite passes under a hostile
UNSLOTH_TORCH_BACKEND=cuda / UNSLOTH_TORCH_INDEX_URL env. 495 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: apply same-flavor pin repoints, keep ROCm fallback nonfatal, bound custom companions
Four round-9 review items, two of them regressions in the round-7 pin helper:
1. _ensure_pinned_known_family_torch returned as satisfied whenever the installed
flavor matched the pin, so a same-flavor SOURCE change (one /cpu or /cu128
mirror to another, or a gfx1151 -> gfx120x-all per-arch switch, both carrying
the same wheel tag) was never applied, while _torch_pin_needs_apply kept forcing
the pass on the marker mismatch forever. It now also reinstalls when the marker
records a DIFFERENT index of the same flavor, rewriting the marker so the next
update matches (no loop), exactly as the Linux _ensure_{cuda,cpu}_torch helpers
do. An absent marker on an already-matching venv is still left to the baseline
recorder (no forced reinstall of a correct pre-marker venv).
2. That helper reinstalled a Windows ROCm pin with the FATAL pip_install, so when
setup.ps1 had taken its CPU fallback (the pinned AMD index unavailable), the
final repair re-hit the same missing index and aborted the whole install. The
ROCm reinstall is now nonfatal (pip_install_try): on failure it leaves the CPU
base in place and writes no ROCm marker, so the install completes -- matching
_ensure_rocm_torch's Windows path. cu*/cpu pins stay fatal (authoritative source).
3. install.sh left torchvision/torchaudio bare for a pinned custom/unknown-leaf
index (a private /simple mirror), unlike the Python update path's
_CUSTOM_INDEX_TORCH_PKG_SPEC, so a mirror also exposing newer companion wheels
could resolve a torch-2.12-built torchvision against the capped <2.11 torch. It
now bounds the companions (torchvision>=0.19,<0.26.0 / torchaudio>=2.4,<2.11.0)
for a custom leaf, gated on an empty _expected_torch_flavor_tag so known families
keep their curated bare/floored companions.
4. install.sh's _expected_torch_flavor_tag matched cu[0-9]* by prefix, so a custom
leaf like cu128-private classified as the cu128 family and force-reinstalled a
correct +cu128 wheel on every run. It now requires exact cu+digits (routing the
suffixed leaf to the custom path), matching the Python re.fullmatch(cu[0-9]+) and
PowerShell, and feeding item 3's custom-leaf detection.
Tests: new cases for the same-flavor marker-change reinstall, the nonfatal ROCm
fallback (no marker on failure), the rocm7.2/older-rocm floor selection now split
across the nonfatal path, cu-suffixed custom leaves in test_torch_flavor.sh, and the
custom-leaf companion bounds in test_torch_constraint.sh. 497 python + 143 shell
assertions pass; the marker suite still passes under a hostile
UNSLOTH_TORCH_BACKEND=cuda env.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: bound custom-pin companions on the Windows setup path; isolate pin-probe tests
Two round-10 review items:
1. setup.ps1's custom/unknown-leaf pin branch capped only torch ($cudaTorchSpec)
and still asked the exclusive index for bare torchvision/torchaudio, so a
private mirror that also serves newer companion wheels could install a
torch<2.11 wheel alongside a torchvision>=0.26 / torchaudio>=2.11 built for a
newer torch ABI, after which the marker records the pin as applied. It now
bounds the whole trio (torch>=2.4,<2.11.0 / torchvision>=0.19,<0.26.0 /
torchaudio>=2.4,<2.11.0) for a pinned non-cu-family leaf, matching install.sh,
install.ps1's fresh pinned install, and install_python_stack.py's
_CUSTOM_INDEX_TORCH_PKG_SPEC. This completes the companion-bounds fix across all
three installers; known cu* leaves keep bare specs (the family index bounds them).
2. The _torch_pin_needs_apply probe tests did not pin NO_TORCH False, so a test
process launched with UNSLOTH_NO_TORCH=1 short-circuited the probe (the round-7
guard) and returned False for cases that expect the pass to run. The _needs_apply
helper now patches NO_TORCH (default False) around the call, and the dedicated
no-torch case passes no_torch=True explicitly.
Tests: the cross-platform parity test now asserts setup.ps1 bounds the full trio
(not just torch) for a custom leaf; the pin-probe suite passes under a hostile
UNSLOTH_NO_TORCH=1 environment. setup.ps1 parses clean; 497 python + shell suites
green.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: bound custom rocm-* pins, redact diag tokens, snapshot custom pins before base update
Three round-11 review items, all reproduced before fixing:
1. install.sh's custom-index companion bounds gated on _expected_torch_flavor_tag
returning empty, but that helper returned "rocm" for ANY rocm* leaf, so a custom
mirror whose leaf starts with rocm but is not a pip family (a private rocm-current
mirror, a Radeon find-links rocm-rel-7.2.1) escaped the bounds and installed bare
torchvision/torchaudio. It now digit-gates rocm to rocm[0-9]* (matching the Python
_is_pip_rocm_family_leaf ^rocm\d), so those custom leaves return "" and the <2.11
companion caps apply; real rocm7.2 / gfx per-arch indexes still classify as rocm.
2. _tauri_torch_index_family classified by the raw last path segment, so a pinned URL
carrying auth in the query (.../rocm7.2?token=SECRET) had the token echoed verbatim
into the emitted [TAURI:DIAG] line. It now strips query/fragment before classifying
(mirroring the marker/log credential stripping), so no token reaches the diagnostic
output; as a side effect .../cu128?token=x now classifies as cu128 instead of auto.
3. On studio update, the core package step (a newer unsloth can require a torch the
custom pin does not satisfy, pulling a default PyPI trio) runs BEFORE the step-2b
verbatim check, which then recorded the already-clobbered trio as the baseline for a
matching marker and left the pin unapplied. A new _capture_verbatim_baseline() records
the pre-clobber trio before the core step, so the verbatim pass detects the drift and
reapplies the pin. Captures only for a matching custom pin with importable torch; a
mismatched/absent marker or broken torch is left to _ensure_verbatim_torch_index.
Tests: _expected_torch_flavor_tag rocm-current / rocm-rel cases; _tauri_torch_index_family
token/fragment redaction with a no-leak regression guard; _capture_verbatim_baseline
record/skip cases plus an end-to-end clobber-detection scenario; a structural guard that
the capture runs before the core step. 501 python + shell suites pass; install.sh bash -n
clean, shellcheck unchanged from base.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: match rocm family leaves exactly, enforce the rocm7.2 torch line, repair a broken pinned torch
A pinned index is a pip ROCm --index-url family only when its leaf is an exact
rocm<digits> / rocm<digits>.<digits> (rocm7.2) or a gfx* per-arch leaf. The prior
^rocm[0-9] prefix match also caught suffixed private-mirror leaves (rocm7.2-private,
rocm7-current), routing them through the ROCm/companion-family path instead of the
verbatim pin: the companion bounds were skipped and, on a pre-marker venv with a
compatible +rocm wheel, the pin was never applied. Match the family exactly through one
shared helper at every site:
- install_python_stack.py: _is_pip_rocm_family_leaf (re.fullmatch), plus the two other
loose gates it feeds (_normalize_family_leaf, _torch_flavor_matches_pin).
- install.sh: a new _is_pip_rocm_family_leaf routes _expected_torch_flavor_tag,
_torch_index_repairable, _normalize_family_leaf and the ROCm side-effect gate.
- setup.ps1: a new Test-PipRocmFamilyLeaf routes Get-NormalizedFamilyLeaf and both
pinned reroutes; install.ps1 anchors its reroute regex.
_rocm_pin_family_mismatch (and its setup.ps1 mirror Get-RocmPinStaleTags) compared only
the ROCm version, so a +rocm7.2 wheel whose torch release drifted off the 2.11 line
(2.12/2.13 from an out-of-band upgrade or a custom rocm7.2 mirror) satisfied the family
check while violating _ROCM_TORCH_PKG_SPECS['rocm7.2'] (torch>=2.11,<2.12). Flag it stale
so the repair reinstalls to floor; >=2.11 alone is not enough, so the release is compared
exactly against the 2.11 line for a KNOWN-2.11 rocm pin.
_ensure_pinned_known_family_torch returned on a failed import probe, but
_torch_pin_needs_apply forces the dependency pass on that same failed probe: a broken
torch under a known-family pin was left in place and the pass was forced on every update.
Treat an unimportable torch as drift and reinstall the pinned trio (the spec and marker
derive from the pinned leaf, not the absent flavor); once it lands the probe succeeds and
the fast path returns.
Tests: exact-match cases across test_torch_flavor.sh, test_rocm_support.py,
test_cross_platform_parity.py and the two .ps1 helper suites; the rocm7.2 release-line
and broken-probe-reinstall cases; extraction lists updated for the new helpers.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: anchor the PS pinned-ROCm floor gate and bound install.ps1 custom-pin companions
Round 12 made every family CLASSIFIER exact, but the Windows install-flow floor gate reads
$_pinRocm211 directly from the raw pinned leaf with an unanchored -match '^rocm(\d+)\.(\d+)'
BEFORE any exact classification runs. A suffixed custom leaf (rocm7.2-private) matches that
rocm7.2 prefix, so it takes the 2.11-floor branch and is force-routed through the ROCm
install path before the exact-match elseif can send it to the verbatim install. Anchor the
match ($) in both install.ps1 and setup.ps1 so only an exact rocmX.Y leaf is floored; a
suffixed or newer-suffix leaf falls through to the verbatim path. The Python floor
selection is already exact (dict lookups gated on _is_pip_rocm_family_leaf), so only the two
PS scripts needed this.
install.ps1's custom (non-cu-family) pinned-torch install bounded torch>=2.4,<2.11.0 but
left torchvision/torchaudio bare, so a private mirror serving newer companions could pull a
wheel built for a newer torch ABI while the marker records the pin as applied. Bound both
companions (torchvision>=0.19,<0.26.0 / torchaudio>=2.4,<2.11.0) when the leaf is not a
cu<digits> family index (a cu index bounds its own resolution), matching setup.ps1's
Test-CudaFamilyLeaf gate and _CUSTOM_INDEX_TORCH_PKG_SPEC.
Tests: parity guards for the anchored floor gate in both PS scripts and for install.ps1's
bounded custom-pin companions.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: tighten comments in the torch-index-override paths
Collapse the verbose comment and docstring blocks added across the installer
scripts and their tests to fewer, clearer lines without changing behaviour.
Remove a duplicated CUDA-spec comment block. Comments/docstrings only; no code
changes (AST-verified).
* install: repair a broken pinned torch on Linux, strip trailing slash in tauri family, count the final step
_ensure_cuda_torch / _ensure_cpu_torch returned on a failed import probe (torch present but
unimportable). With an explicit CUDA/CPU pin, _torch_pin_needs_apply forces the dependency
pass on that same failed probe, and the base package update does not force-reinstall an
already-installed torch distribution, so the broken torch was left in place and the pass
reran every update without repairing it. Treat a failed probe under a pin as drift and
reinstall from the pinned index (the reinstall rewrites the marker and the next probe
imports, so no loop). This is the Linux counterpart of the known-family repair fix.
_tauri_torch_index_family stripped the query/fragment before classifying but not a trailing
slash, so a token-authenticated pin like .../cu128/?token=x collapsed to .../cu128/ and fell
through the exact-suffix */cu128 and */cpu arms to "auto". Strip a trailing slash too,
mirroring _torch_index_url_leaf.
The Windows / macOS-ARM final torch-repair step (_ensure_pinned_known_family_torch) runs a
progress step that base_total never counted (the final-step increment was gated to Linux),
so _STEP ran one past _TOTAL on those platforms. Add the missing increment.
Tests: broken-probe reinstall for the CUDA (family and URL pins) and CPU paths; trailing
slash / slash+token cases for _tauri_torch_index_family; a full-flow progress-count guard
asserting _STEP == _TOTAL on Windows and Linux.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: tighten comments in the torch-index-override paths
* install: harden the torch-index pin across all four installers
Redact index-URL credentials from captured install logs before they print on
failure. uv/pip failure text embeds the failing --index-url verbatim, so a
user:token@ or ?token= secret could leak into the console. Add a shared
redaction pass (_redact_install_output / Redact-InstallOutput) wired into the
error-output dump in install.sh, install.ps1, setup.ps1 and
install_python_stack.py. Verbose mode still streams live uncaptured output, so
it is intentionally left unredacted (developer opt-in).
Trim trailing slashes on the PATH only for a verbatim UNSLOTH_TORCH_INDEX_URL
override, preserving a ?query/#fragment token. A whole-URL rstrip corrupted a
base64 token ending in "/", and a single-slash strip left .../cu128//
classifying as an empty leaf. Add _trim_index_path_slashes /
Trim-IndexPathSlashes and route the override through it; strip ALL trailing
slashes in the backend-branding leaf classifier so a double slash still yields
the real leaf.
Reject a trailing-dot ROCm leaf (rocm7.) in the bash family validator so it
matches Python re.fullmatch(rocm\d+(?:\.\d+)?) and the PowerShell regex: both the
major and the minor must be non-empty digits, so rocm7. is a custom verbatim pin,
not a pip ROCm family.
Scrub PIP_NO_INDEX and PIP_INDEX_URL for a pinned install in the two installers
that have a plain-pip fallback (install_python_stack.py, setup.ps1):
PIP_NO_INDEX=1 makes the fallback ignore every index including the pinned
--index-url, and PIP_INDEX_URL replaces it. install.sh and install.ps1 install
via uv --default-index (which ignores pip config/env), so they are unaffected.
Add unit tests (bash, Python, PowerShell) and cross-platform parity tests
covering credential redaction, path-only slash trimming, the rocm7. validator,
the double-slash leaf, and the PIP_NO_INDEX/PIP_INDEX_URL scrub.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: redact captured torch-install output and warn on a failed pinned ROCm repair
Close a redaction gap the earlier pass missed: setup.ps1's direct
`Fast-Install ... | Out-String` branches (ROCm from $ROCmIndexUrl, CPU/CUDA from
$TorchInstallIndexUrl, plus the Triton and T5 sub-venv installs) printed the
captured $output verbatim on failure, bypassing Redact-InstallOutput. A private
index carrying userinfo or a ?token= in the pin could leak into Windows Studio
setup logs. Route every `Write-Host $output` through Redact-InstallOutput.
Warn on a failed pinned Windows ROCm reinstall in
_ensure_pinned_known_family_torch: the branch printed "reinstalling from it" then
called pip_install_try, but had no else, so a failure continued silently and left
the user believing the pin was applied while the old CPU/wrong torch survived.
Mirror the auto-ROCm Windows path and warn, telling the user to retry.
* install: redact captured output on the pip fallback and optional-install failure paths
The uv install path already redacted its captured output, but pip_install's pip
fallback runs through run(), which printed result.stdout verbatim on failure, and
_print_optional_install_failure did the same. A pinned --index-url carrying
userinfo or a ?token= could still leak there when uv is unavailable or the pip
fallback also fails. Route both through _redact_install_output. The verbose
pip_install_try path stays raw (developer opt-in), matching the other installers.
* install: split the survive-updates marker subsystem into a follow-up
The torch-index override PR grew a persisted per-venv marker plus repair
machinery (stale-pin detection, verbatim re-apply, update-time reinstall
triggers) that roughly doubled it. That subsystem is orthogonal to the core
feature and is being reworked in a follow-up (versioned/hashed marker,
full-URL pin baseline), so it moves there wholesale instead of shipping
twice.
What this PR still does: UNSLOTH_TORCH_INDEX_URL / UNSLOTH_TORCH_INDEX_FAMILY
pick the torch wheel index at install time in all four installers, with the
exact rocm/gfx/cpu/cu leaf classification, the torch 2.11 floor for the
per-arch AMD indexes, bounded companions for custom leaves, credential
redaction of captured installer output, path-only slash trimming, and the
uv/pip index env scrubs. Flavor-based repair keeps honoring the pin: a wrong
family under an explicit pin still reinstalls from the pinned URL, and
setup.ps1 repairs a pinned stale venv in place instead of wiping it.
What moves to the follow-up: the .unsloth-torch-index marker file and its
writers/readers/normalizers, exact-URL pin-change detection on update
(same-tag gfx switches, custom-mirror repoints), the verbatim trio snapshot
and clobber re-apply, the pin-baseline recorder, and the
--torch-pin-needs-apply fast-path probe in setup.sh / setup.ps1. Their tests
(the marker sh/ps1 suites, the stale-pin suite, and the marker classes in the
rocm/cuda/parity suites) move with them; the removed code is preserved on a
local archive branch to seed that PR.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: re-apply a ROCm pin over an existing HIP wheel via the version tag
The subsystem split left an explicit ROCm/gfx pin unenforced on `studio
update` whenever the venv already imported ANY ROCm torch: the pinned
reinstall lived inside the `elif not has_hip_torch` branch, so a rocm6.4 to
rocm7.2 switch, a gfx1151 pin over a generic +rocm7.2 wheel, or a broken
2.12+rocm7.2 drift never re-applied the pin.
Restore the markerless half of that detection: _rocm_pin_family_mismatch
compares the pinned leaf against the installed wheel tag (exact rocmX.Y
compare, the 2.11 gfx per-arch allowlist, the untagged-wheel rule), the HIP
probe emits "<hip_marker>|<version>" again so the installed tag is available,
and _ensure_rocm_torch reinstalls from the pinned URL when the tag mismatches
even though HIP torch is present. setup.ps1 mirrors it: the stale-venv check
routes a pinned rocm/gfx leaf through Get-RocmPinStaleTags instead of
collapsing it to a generic "rocm" flavor, and the existing pinned in-place
repair (no wipe) applies the change.
What still waits for the follow-up marker PR, by design: pin changes the
wheel tag cannot see -- a per-arch switch between two 2.11 gfx indexes
(identical +rocm7.13.0 tag), a custom-mirror URL repoint under the same
family leaf, and unknown-family verbatim pins. Those need the persisted
index record.
Tests restored with the code: the _rocm_pin_family_mismatch table, the five
update-path cases (older-rocm reinstall, gfx-over-pre-2.11 reinstall,
matching-pin no-reinstall, non-2.11 gfx no-reinstall, gfx-over-generic-2.11
reinstall), the "|" probe-format guards, and the AST-extracted
Get-RocmPinStaleTags suite for setup.ps1.
* install: compare major-only rocm pins, redact URL fragments, bound pinned CPU trio
Three review fixes on the restored pin-repair path.
The family classifier accepts a major-only rocm<d> leaf (rocm7), but the
mismatch comparators only parsed rocmX.Y, so a rocm7 pin fell through to the
2.11-line fallback and INVERTED both verdicts: an installed +rocm6.4 wheel
compared as satisfied (pin never re-applied) while a matching +rocm7.2 wheel
compared as stale (reinstall loop). Major-only pins now compare on the major
alone in _rocm_pin_family_mismatch and Get-RocmPinStaleTags: rocm6.x under a
rocm7 pin is a mismatch, any rocm7.x satisfies it, an untagged wheel never
does, and a bare +rocm tag with an unreadable version is accepted (matching
the existing lenient unreadable fallback).
The output redactors scrubbed userinfo and ?query= values but not #fragments,
so a pin like https://mirror/whl/cu128#token=secret leaked the secret in
captured uv/pip failure text -- inconsistent with the URL handling itself,
which already treats fragments as sensitive. All four redactors gain a
URL-anchored fragment rule (anchored so a bare "# comment" line in tool
output is never touched).
setup.ps1's CPU branch installed a bare torch/torchvision/torchaudio trio;
fine for the unpinned host default, but a PINNED cpu index routes through the
same branch and the /cpu index serves newer torch, so a fresh pinned CPU
install could land an unsupported trio that _ensure_cpu_torch then keeps
(it accepts any CPU build). Under a pin the branch now installs the bounded
trio mirroring _CPU_TORCH_PKG_SPEC (torch>=2.4,<2.12.0 and matching
companions); the unpinned path is unchanged.
Tests: major-only rows in the Python mismatch table and the AST-extracted
setup.ps1 suite; fragment + query-plus-fragment + bare-hash-comment cases in
all four redactor suites; a parity check that the pinned CPU trio bounds
exist, are gated on the pin, and mirror the Python repair spec.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install: tighten comments in the torch index override paths
* tests: track the moved pass-through inheritance in the gguf order check
Main moved the llama_extra_args pass-through inheritance out of the
GGUF branch into _resolve_inherited_extra_args, which runs before it,
so the source-order assertion's "if request.llama_extra_args is None"
anchor no longer exists inside the branch and the check failed after
the main merge. The test now asserts the same property in the current
shape: inheritance before the GGUF branch (a carried --no-mmproj still
shapes the hub guard's companion requirement), and marker, hub guard,
unload in order within the branch. Full file passes (32 tests).
* tests: anchor the inheritance order check on the call, not the definition
source.index("_resolve_inherited_extra_args(") matched the function
definition, which always precedes the endpoint, so the ordering
assertion was vacuously true. Anchoring on "= _resolve_inherited_
extra_args(" pins the first call site inside the load endpoint (line
4505), which is the statement whose position relative to the GGUF
branch the test is meant to guard. 32 tests pass.
* tests: align the gguf order test with main
Main fixed the stale ordering assertion in PR 7252; adopting its
version verbatim removes this file from the branch diff entirely and
avoids a conflict on the next main merge. 32 tests pass.
* install: bound the companion constraints to torch's window everywhere
A full platform x vendor validation matrix over this branch surfaced a
real trio mismatch on the cpu/mac paths: torch is capped <2.11 (installs
2.10.0+cpu) but the bare torchaudio companion resolves 2.11.0+cpu,
because torchaudio 2.11 dropped its exact torch pin. Reproduced in a
sandboxed end to end cpu install. torchvision still exact-pins torch and
self-corrected.
The default companion constraints are now bounded to torch's window
(<0.26 / <2.11) and widen together with the cu* torch window (<0.27 /
<2.12), so every leaf resolves a paired trio. Verified with uv dry-runs
on the cpu, cu130, and rocm6.4 leaves (2.10.0/0.25.0/2.10.0,
2.11.0/0.26.0/2.11.0, 2.9.1/0.24.1/2.9.1) and a rerun of the sandboxed
cpu install, which now lands torch 2.10.0+cpu with torchaudio
2.10.0+cpu.
The Strix WSL reroute now also forwards UNSLOTH_TORCH_INDEX_URL and
UNSLOTH_TORCH_INDEX_FAMILY into the rerouted 24.04 distro; dropping
them silently reverted the child install to auto-detection, defeating
the pin this branch introduces.
test_torch_constraint.sh updated: the bounded companions must appear at
the defaults and the custom-leaf block, no bare companion may remain,
and the cu* widen must carry the companions with it.
* install: harden the override path against reroute drift and credential leaks
Review sweep focused on default-path idempotency found no defects on the
unset path; these fixes cover the override path and failure reporting.
install.sh:
- The early WSL Strix Halo distro reroute now honors an explicit index
pin (UNSLOTH_TORCH_INDEX_URL / _FAMILY): the pin is used in the current
distro instead of probing the GPU and re-entering another distribution,
matching the contract of the later Radeon and Strix guards. Whitespace
only values do not gate, in parity with get_torch_index_url.
- Verbose mode now streams installer output through the credential
redactor; it previously bypassed the redaction the quiet path applies.
The exit code survives the pipe via an rc file since the script runs
under plain sh with no pipefail.
- The kept-release fallback warning now strips credentials from the
index URL before printing it.
install.ps1:
- Bounded torchvision and torchaudio next to every capped torch install
(custom pin, ROCm CPU fallback, CUDA flavor repair). torchaudio 2.11
dropped its exact torch pin from the wheel metadata, so a bare
companion beside torch<2.11 can resolve a mismatched 2.11.0 build,
cu family indexes included. Mirrors the install.sh companion bounds.
studio/install_python_stack.py:
- The verbose failure path now redacts index URLs in pip and uv output
before printing, matching every other output site in the file.
All sh, ps1 and python installer test suites pass (the host-defaults
suite has a known pre-existing failure unrelated to this change).
* install: redact verbose Windows installer output and repair the parity tests
Follow-ups to the override-hardening commit, from review:
- install.ps1 Invoke-InstallCommand and setup.ps1 Invoke-SetupCommand now
pipe verbose output through Redact-InstallOutput per record, and the
three verbose Fast-Install torch call sites (ROCm, CPU, CUDA) do the
same: uv and pip echo the pinned index URL, credentials included, in
their errors, and verbose mode previously bypassed the redaction the
quiet paths apply. ForEach-Object and Out-Host leave $LASTEXITCODE
untouched, verified with a native command exiting 7 behind the pipe.
- test_cross_platform_parity.py: the install.ps1 companion-bounds
assertion now matches the implemented behavior (bounds on every index,
no cu-family exemption, since torchaudio 2.11 dropped its exact torch
pin) instead of requiring the removed $_pinCuLeaf gate.
- test_rocm_support.py: the WSL reroute guard test slices the whole
function body to its closing brace instead of a fixed 1200-character
window, which the new pin-gate preamble had outgrown.
428 tests pass across the parity, install stack and rocm support suites;
the sh and ps1 installer suites pass unchanged.
* install: tighten comments in the torch-index and ROCm/CUDA repair paths
* install: digit-gate the gfx family leaf and honor ROCm pins in the Windows repair
Two review follow-ups on the override path:
- The pip ROCm family predicate accepted ANY gfx-prefixed leaf, so a
custom verbatim pin like /gfx-private classified as a ROCm family and
enabled the ROCm-only side effects (AMD bitsandbytes, ROCm torch
repair) on a mirror that may serve CPU/CUDA wheels. gfx now requires a
following digit (gfx90a, gfx1151, gfx120X-all), consistently in
install.sh, install_python_stack.py, install.ps1 (family gate and
expected-flavor classifier) and setup.ps1, matching the strictness the
rocm side already had (rocm7.2-private stays verbatim). The broader
backend BRANDING globs are unchanged on purpose: radeon repo leaves
(rocm-rel-X.Y) must still brand the rocm backend without being
force-repaired as a family.
- The Windows branch of the ROCm torch repair always installed from the
public per-arch index, ignoring an explicit ROCm-family pin: after a
pinned setup.ps1 install failed to a CPU base, the repair retried
repo.amd.com instead of the pinned index. The branch now resolves
_explicit_rocm_torch_index_url() first, uses it as the install index
when set, and mirrors the Linux pin contract by skipping the NVIDIA
and gfx-detection gates a pin is documented to override.
Source-assertion tests updated to the tightened predicate and the new
repair label. 1165 tests pass across the parity, install stack and
studio install suites; the sh and ps1 suites pass; both PowerShell
installers parse clean.
* Remove scratch archives accidentally committed with the comment pass
The temp/ archive copies of installer and test files were working
scratch, not PR content, and inflated the diff by about nine thousand
lines.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
3883 lines
180 KiB
Python
3883 lines
180 KiB
Python
"""AMD ROCm support tests across install pathways (all mocked, no AMD HW)."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, mock_open, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
|
|
# ── Load modules under test ──────────────────────────────────────────────────
|
|
|
|
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
# install_llama_prebuilt.py
|
|
_PREBUILT_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py"
|
|
_PREBUILT_SPEC = importlib.util.spec_from_file_location(
|
|
"studio_install_llama_prebuilt", _PREBUILT_PATH
|
|
)
|
|
assert _PREBUILT_SPEC is not None and _PREBUILT_SPEC.loader is not None
|
|
prebuilt_mod = importlib.util.module_from_spec(_PREBUILT_SPEC)
|
|
sys.modules[_PREBUILT_SPEC.name] = prebuilt_mod
|
|
_PREBUILT_SPEC.loader.exec_module(prebuilt_mod)
|
|
|
|
HostInfo = prebuilt_mod.HostInfo
|
|
AssetChoice = prebuilt_mod.AssetChoice
|
|
PrebuiltFallback = prebuilt_mod.PrebuiltFallback
|
|
resolve_upstream_asset_choice = prebuilt_mod.resolve_upstream_asset_choice
|
|
runtime_patterns_for_choice = prebuilt_mod.runtime_patterns_for_choice
|
|
_apply_host_overrides = prebuilt_mod._apply_host_overrides
|
|
_normalize_forwarded_gfx = prebuilt_mod._normalize_forwarded_gfx
|
|
|
|
# install_python_stack.py
|
|
_STACK_PATH = PACKAGE_ROOT / "studio" / "install_python_stack.py"
|
|
_STACK_SPEC = importlib.util.spec_from_file_location("studio_install_python_stack", _STACK_PATH)
|
|
assert _STACK_SPEC is not None and _STACK_SPEC.loader is not None
|
|
stack_mod = importlib.util.module_from_spec(_STACK_SPEC)
|
|
sys.modules[_STACK_SPEC.name] = stack_mod
|
|
_STACK_SPEC.loader.exec_module(stack_mod)
|
|
|
|
_detect_rocm_version = stack_mod._detect_rocm_version
|
|
_ensure_rocm_torch = stack_mod._ensure_rocm_torch
|
|
_has_rocm_gpu = stack_mod._has_rocm_gpu
|
|
_has_usable_nvidia_gpu = stack_mod._has_usable_nvidia_gpu
|
|
_ROCM_TORCH_INDEX = stack_mod._ROCM_TORCH_INDEX
|
|
_windows_rocm_index_url = stack_mod._windows_rocm_index_url
|
|
_detect_windows_gfx_arch = stack_mod._detect_windows_gfx_arch
|
|
_install_bnb_windows_rocm = stack_mod._install_bnb_windows_rocm
|
|
|
|
|
|
def _extract_sh_function_body(source: str, name: str) -> str:
|
|
"""Return a shell function body from `source` by brace matching."""
|
|
needle = f"{name}() {{"
|
|
start = source.find(needle)
|
|
if start < 0:
|
|
return ""
|
|
depth = 0
|
|
i = start + len(needle) - 1 # land on the opening brace
|
|
n = len(source)
|
|
while i < n:
|
|
ch = source[i]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source[start : i + 1]
|
|
i += 1
|
|
return source[start:]
|
|
|
|
|
|
# ── Helper: build HostInfo for different scenarios ──────────────────────────
|
|
|
|
|
|
def nvidia_host(**overrides) -> HostInfo:
|
|
"""NVIDIA Linux x86_64 host."""
|
|
defaults = dict(
|
|
system = "Linux",
|
|
machine = "x86_64",
|
|
is_windows = False,
|
|
is_linux = True,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = "/usr/bin/nvidia-smi",
|
|
driver_cuda_version = (12, 6),
|
|
compute_caps = ["89"],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = True,
|
|
has_usable_nvidia = True,
|
|
has_rocm = False,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
def rocm_host(**overrides) -> HostInfo:
|
|
"""AMD ROCm Linux x86_64 host (no NVIDIA)."""
|
|
defaults = dict(
|
|
system = "Linux",
|
|
machine = "x86_64",
|
|
is_windows = False,
|
|
is_linux = True,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = True,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
def cpu_host(**overrides) -> HostInfo:
|
|
"""CPU-only Linux x86_64 host."""
|
|
defaults = dict(
|
|
system = "Linux",
|
|
machine = "x86_64",
|
|
is_windows = False,
|
|
is_linux = True,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = False,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
def macos_host(**overrides) -> HostInfo:
|
|
"""macOS arm64 host."""
|
|
defaults = dict(
|
|
system = "Darwin",
|
|
machine = "arm64",
|
|
is_windows = False,
|
|
is_linux = False,
|
|
is_macos = True,
|
|
is_x86_64 = False,
|
|
is_arm64 = True,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = False,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
def windows_host(**overrides) -> HostInfo:
|
|
"""Windows x86_64 host."""
|
|
defaults = dict(
|
|
system = "Windows",
|
|
machine = "amd64",
|
|
is_windows = True,
|
|
is_linux = False,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = False,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
def windows_rocm_host(**overrides) -> HostInfo:
|
|
"""Windows x86_64 host with ROCm."""
|
|
defaults = dict(
|
|
system = "Windows",
|
|
machine = "amd64",
|
|
is_windows = True,
|
|
is_linux = False,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = True,
|
|
)
|
|
defaults.update(overrides)
|
|
return HostInfo(**defaults)
|
|
|
|
|
|
# ── Upstream asset fixture ───────────────────────────────────────────────────
|
|
|
|
LLAMA_TAG = "b8508"
|
|
|
|
UPSTREAM_ASSETS = {
|
|
f"llama-{LLAMA_TAG}-bin-ubuntu-x64.tar.gz": f"https://example.com/{LLAMA_TAG}-linux-cpu.tar.gz",
|
|
f"llama-{LLAMA_TAG}-bin-ubuntu-rocm-7.2-x64.tar.gz": f"https://example.com/{LLAMA_TAG}-linux-rocm.tar.gz",
|
|
f"llama-{LLAMA_TAG}-bin-win-cpu-x64.zip": f"https://example.com/{LLAMA_TAG}-win-cpu.zip",
|
|
f"llama-{LLAMA_TAG}-bin-win-cuda-12.4-x64.zip": f"https://example.com/{LLAMA_TAG}-win-cuda.zip",
|
|
f"llama-{LLAMA_TAG}-bin-win-hip-radeon-x64.zip": f"https://example.com/{LLAMA_TAG}-win-hip.zip",
|
|
f"llama-{LLAMA_TAG}-bin-macos-arm64.tar.gz": f"https://example.com/{LLAMA_TAG}-macos-arm64.tar.gz",
|
|
f"llama-{LLAMA_TAG}-bin-macos-x64.tar.gz": f"https://example.com/{LLAMA_TAG}-macos-x64.tar.gz",
|
|
}
|
|
|
|
|
|
# TEST: install_llama_prebuilt.py -- resolve_upstream_asset_choice
|
|
|
|
|
|
class TestResolveUpstreamAssetChoice:
|
|
"""Verify that the asset selection logic picks the right binary for each platform."""
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_nvidia_linux_gets_cpu_asset(self, mock_assets):
|
|
"""NVIDIA host should NOT hit the ROCm path -- gets CPU asset (CUDA handled elsewhere)."""
|
|
host = nvidia_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "linux-cpu"
|
|
assert "ubuntu-x64" in choice.name
|
|
assert "rocm" not in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_rocm_linux_gets_rocm_prebuilt(self, mock_assets):
|
|
"""AMD ROCm Linux host should get the ROCm prebuilt."""
|
|
host = rocm_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "linux-rocm"
|
|
assert "rocm" in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_cpu_linux_gets_cpu_asset(self, mock_assets):
|
|
"""CPU-only Linux host should get CPU asset."""
|
|
host = cpu_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "linux-cpu"
|
|
assert "ubuntu-x64" in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_macos_arm64_gets_macos_asset(self, mock_assets):
|
|
"""macOS arm64 host should get macOS asset."""
|
|
host = macos_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "macos-arm64"
|
|
assert "macos-arm64" in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_windows_cpu_gets_cpu_asset(self, mock_assets):
|
|
"""Windows CPU-only host should get Windows CPU asset."""
|
|
host = windows_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "windows-cpu"
|
|
assert "win-cpu" in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_windows_rocm_gets_hip_asset(self, mock_assets):
|
|
"""Windows ROCm host should get Windows HIP asset."""
|
|
host = windows_rocm_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "windows-hip"
|
|
assert "hip" in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_mixed_nvidia_rocm_prefers_nvidia(self, mock_assets):
|
|
"""Host with both NVIDIA and ROCm should use NVIDIA (CPU path here, CUDA elsewhere)."""
|
|
host = nvidia_host(has_rocm = True)
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "linux-cpu"
|
|
assert "rocm" not in choice.name
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets")
|
|
def test_rocm_linux_no_prebuilt_falls_back(self, mock_assets):
|
|
"""AMD ROCm host should fall back to source build when no ROCm prebuilt exists."""
|
|
assets_without_rocm = {k: v for k, v in UPSTREAM_ASSETS.items() if "rocm" not in k}
|
|
mock_assets.return_value = assets_without_rocm
|
|
host = rocm_host()
|
|
with pytest.raises(PrebuiltFallback, match = "ROCm detected"):
|
|
resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets")
|
|
def test_windows_rocm_no_hip_falls_to_cpu(self, mock_assets):
|
|
"""Windows+ROCm with HIP prebuilt missing should fall through to CPU."""
|
|
assets_no_hip = {k: v for k, v in UPSTREAM_ASSETS.items() if "hip" not in k}
|
|
mock_assets.return_value = assets_no_hip
|
|
host = windows_rocm_host()
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "windows-cpu"
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_macos_rocm_impossible_has_rocm_false(self, mock_assets):
|
|
"""macOS host should never have has_rocm=True in practice; verify it gets macOS asset."""
|
|
host = macos_host(has_rocm = True)
|
|
choice = resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
assert choice.install_kind == "macos-arm64"
|
|
|
|
@patch.object(prebuilt_mod, "github_release_assets", return_value = UPSTREAM_ASSETS)
|
|
def test_linux_aarch64_rocm_gets_prebuilt_fallback(self, mock_assets):
|
|
"""Linux aarch64 with ROCm -- no x86_64 match, should raise PrebuiltFallback."""
|
|
host = rocm_host(machine = "aarch64", is_x86_64 = False, is_arm64 = True)
|
|
with pytest.raises(PrebuiltFallback):
|
|
resolve_upstream_asset_choice(host, LLAMA_TAG)
|
|
|
|
|
|
# TEST: install_llama_prebuilt.py -- runtime_patterns_for_choice
|
|
|
|
|
|
class TestRuntimePatterns:
|
|
"""Verify runtime file patterns for all install kinds."""
|
|
|
|
def test_linux_cpu_patterns(self):
|
|
choice = AssetChoice(
|
|
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = "linux-cpu"
|
|
)
|
|
patterns = runtime_patterns_for_choice(choice)
|
|
assert "llama-server" in patterns
|
|
assert "llama-quantize" in patterns
|
|
# lib*.so* covers libllama/libggml/libmtmd plus the libllama-*-impl.so
|
|
# split from ggml-org/llama.cpp #23462 (between b9279 and b9283).
|
|
assert "lib*.so*" in patterns
|
|
|
|
def test_linux_cuda_patterns(self):
|
|
choice = AssetChoice(
|
|
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = "linux-cuda"
|
|
)
|
|
patterns = runtime_patterns_for_choice(choice)
|
|
assert "lib*.so*" in patterns
|
|
|
|
def test_linux_rocm_patterns(self):
|
|
choice = AssetChoice(
|
|
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = "linux-rocm"
|
|
)
|
|
patterns = runtime_patterns_for_choice(choice)
|
|
assert "lib*.so*" in patterns
|
|
assert "llama-server" in patterns
|
|
|
|
def test_windows_hip_patterns(self):
|
|
choice = AssetChoice(
|
|
repo = "",
|
|
tag = "",
|
|
name = "",
|
|
url = "",
|
|
source_label = "",
|
|
install_kind = "windows-hip",
|
|
)
|
|
patterns = runtime_patterns_for_choice(choice)
|
|
# Narrowed from "*.exe" to the two binaries Unsloth actually invokes.
|
|
assert "llama-server.exe" in patterns
|
|
assert "llama-quantize.exe" in patterns
|
|
assert "*.dll" in patterns
|
|
|
|
def test_macos_patterns(self):
|
|
choice = AssetChoice(
|
|
repo = "",
|
|
tag = "",
|
|
name = "",
|
|
url = "",
|
|
source_label = "",
|
|
install_kind = "macos-arm64",
|
|
)
|
|
patterns = runtime_patterns_for_choice(choice)
|
|
assert "lib*.dylib" in patterns
|
|
|
|
def test_diffusion_visual_server_kept(self):
|
|
# The DiffusionGemma visual-server must survive the prune so Unsloth can
|
|
# serve DiffusionGemma GGUFs natively.
|
|
for kind, name in (
|
|
("linux-cuda", "llama-diffusion-gemma-visual-server"),
|
|
("macos-arm64", "llama-diffusion-gemma-visual-server"),
|
|
("windows-cuda", "llama-diffusion-gemma-visual-server.exe"),
|
|
):
|
|
choice = AssetChoice(
|
|
repo = "", tag = "", name = "", url = "", source_label = "", install_kind = kind
|
|
)
|
|
assert name in runtime_patterns_for_choice(choice)
|
|
|
|
|
|
# TEST: install_llama_prebuilt.py -- HostInfo.has_rocm field
|
|
|
|
|
|
class TestHostInfoRocm:
|
|
"""Verify has_rocm field does not affect other HostInfo behavior."""
|
|
|
|
def test_has_rocm_default_false(self):
|
|
host = HostInfo(
|
|
system = "Linux",
|
|
machine = "x86_64",
|
|
is_windows = False,
|
|
is_linux = True,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
)
|
|
assert host.has_rocm is False
|
|
|
|
def test_has_rocm_explicit_true(self):
|
|
host = rocm_host()
|
|
assert host.has_rocm is True
|
|
|
|
def test_nvidia_host_no_rocm(self):
|
|
host = nvidia_host()
|
|
assert host.has_rocm is False
|
|
assert host.has_usable_nvidia is True
|
|
|
|
def test_detect_host_has_rocm_detection_logic(self):
|
|
"""detect_host() should have ROCm GPU detection logic."""
|
|
import inspect
|
|
|
|
source = inspect.getsource(prebuilt_mod.detect_host)
|
|
# Must probe for actual GPU, not just tool presence.
|
|
assert "rocminfo" in source or "amd-smi" in source
|
|
|
|
def test_detect_host_windows_rocm_detection(self):
|
|
"""detect_host() source should have Windows-specific ROCm GPU detection."""
|
|
import inspect
|
|
|
|
source = inspect.getsource(prebuilt_mod.detect_host)
|
|
assert "hipinfo" in source or "amd-smi" in source
|
|
|
|
|
|
# TEST: install_python_stack.py -- _detect_rocm_version
|
|
|
|
|
|
class TestDetectRocmVersion:
|
|
"""Verify ROCm version detection from various sources."""
|
|
|
|
def test_no_rocm_returns_none(self, tmp_path):
|
|
"""No ROCm installed should return None."""
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path / "nonexistent")}):
|
|
with patch("shutil.which", return_value = None):
|
|
result = _detect_rocm_version()
|
|
assert result is None
|
|
|
|
def test_version_from_file(self, tmp_path):
|
|
"""Reads version from /opt/rocm/.info/version."""
|
|
info_dir = tmp_path / ".info"
|
|
info_dir.mkdir()
|
|
(info_dir / "version").write_text("7.1.0-12345\n")
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path)}):
|
|
result = _detect_rocm_version()
|
|
assert result == (7, 1)
|
|
|
|
def test_version_62(self, tmp_path):
|
|
"""Reads ROCm 6.2 version."""
|
|
info_dir = tmp_path / ".info"
|
|
info_dir.mkdir()
|
|
(info_dir / "version").write_text("6.2.0\n")
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path)}):
|
|
result = _detect_rocm_version()
|
|
assert result == (6, 2)
|
|
|
|
def test_hipconfig_fallback(self, tmp_path):
|
|
"""Falls back to hipconfig --version when file not found."""
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path / "nonexistent")}):
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = b"6.3.21234.2\n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipconfig"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = _detect_rocm_version()
|
|
assert result == (6, 3)
|
|
|
|
def test_dpkg_fallback_without_hipconfig(self, tmp_path):
|
|
"""dpkg rocm-core fallback works when amd-smi and hipconfig are absent
|
|
(regression: a shadowing local re import raised UnboundLocalError)."""
|
|
|
|
def which(cmd):
|
|
return "/usr/bin/dpkg-query" if cmd == "dpkg-query" else None
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = "1:6.3.0-1\n"
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path / "nonexistent")}):
|
|
with patch("shutil.which", side_effect = which):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
assert _detect_rocm_version() == (6, 3)
|
|
|
|
def test_empty_version_file(self, tmp_path):
|
|
"""Empty version file should return None."""
|
|
info_dir = tmp_path / ".info"
|
|
info_dir.mkdir()
|
|
(info_dir / "version").write_text("")
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path)}):
|
|
with patch("shutil.which", return_value = None):
|
|
result = _detect_rocm_version()
|
|
assert result is None
|
|
|
|
def test_version_with_epoch_prefix(self, tmp_path):
|
|
"""Debian epoch prefix (2:6.2.0) -- version file has no epoch, so should parse."""
|
|
info_dir = tmp_path / ".info"
|
|
info_dir.mkdir()
|
|
(info_dir / "version").write_text("6.2.0\n")
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path)}):
|
|
result = _detect_rocm_version()
|
|
assert result == (6, 2)
|
|
|
|
def test_multiple_version_sources_first_wins(self, tmp_path):
|
|
"""When both .info/version and lib/rocm_version exist, first found wins."""
|
|
info_dir = tmp_path / ".info"
|
|
info_dir.mkdir()
|
|
(info_dir / "version").write_text("7.1.0\n")
|
|
lib_dir = tmp_path / "lib"
|
|
lib_dir.mkdir()
|
|
(lib_dir / "rocm_version").write_text("6.3.0\n")
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path)}):
|
|
result = _detect_rocm_version()
|
|
assert result == (7, 1) # .info/version checked first
|
|
|
|
def test_hipconfig_multiline_output(self, tmp_path):
|
|
"""hipconfig with multi-line output -- should use first line."""
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path / "nonexistent")}):
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = b"6.3.21234.2\nSome extra info\n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipconfig"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = _detect_rocm_version()
|
|
assert result == (6, 3)
|
|
|
|
def test_hipconfig_timeout(self, tmp_path):
|
|
"""hipconfig that times out should return None."""
|
|
with patch.dict(os.environ, {"ROCM_PATH": str(tmp_path / "nonexistent")}):
|
|
with patch("shutil.which", return_value = "/usr/bin/hipconfig"):
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect = subprocess.TimeoutExpired("hipconfig", 5),
|
|
):
|
|
result = _detect_rocm_version()
|
|
assert result is None
|
|
|
|
|
|
# TEST: install_python_stack.py -- _ensure_rocm_torch
|
|
|
|
|
|
class TestEnsureRocmTorch:
|
|
"""Verify ROCm torch reinstall logic."""
|
|
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
def test_no_rocm_skips(self, mock_nvidia, mock_pip):
|
|
"""No ROCm toolchain should skip entirely."""
|
|
# Pin _detect_windows_gfx_arch to None so a real AMD test host's WMI
|
|
# fallback can't defeat the "no ROCm anywhere" premise.
|
|
with patch.object(stack_mod, "_detect_windows_gfx_arch", return_value = None):
|
|
with patch("os.path.isdir", return_value = False):
|
|
with patch("shutil.which", return_value = None):
|
|
_ensure_rocm_torch()
|
|
mock_pip.assert_not_called()
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1))
|
|
def test_cuda_torch_on_amd_host_reinstalls(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A CUDA-only torch build is unusable on an AMD-only host, so it must be
|
|
reinstalled to ROCm (has_hip_torch is driven by the empty HIP marker, not
|
|
by treating the CUDA version string as a HIP marker)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
# Single-line probe: empty HIP marker before "|" for a CUDA build.
|
|
mock_probe.stdout = b"|2.10.0+cu126\n"
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
assert mock_pip.call_count == 1
|
|
assert "rocm7.1" in str(mock_pip.call_args_list[0])
|
|
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1))
|
|
def test_torch_already_has_hip_skips(self, mock_ver, mock_gpu, mock_nvidia, mock_pip):
|
|
"""If torch already has HIP, should skip ROCm reinstall."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"7.1.12345|2.10.0+rocm7.1\n" # HIP marker + version
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
mock_pip.assert_not_called()
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1))
|
|
def test_cpu_torch_probe_line_not_read_as_hip(self, mock_ver, mock_gpu, mock_nvidia, mock_pip):
|
|
"""A CPU build's probe line ("|2.10.0+cpu") must not read as HIP: the version
|
|
after the "|" separator is data, not a HIP marker, so has_hip_torch stays False
|
|
and the reinstall fires."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"|2.10.0+cpu\n"
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
_ensure_rocm_torch()
|
|
assert mock_pip.call_count == 1
|
|
assert "rocm7.1" in str(mock_pip.call_args_list[0])
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1))
|
|
def test_cpu_torch_gets_rocm_reinstall(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""CPU-only torch on ROCm host should trigger reinstall."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"\n" # empty = no GPU backend
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
assert mock_pip.call_count == 1
|
|
assert "rocm7.1" in str(mock_pip.call_args_list[0])
|
|
assert mock_pip_try.call_count >= 1
|
|
assert "bitsandbytes" in str(mock_pip_try.call_args_list[0])
|
|
assert mock_pip_try.call_args.kwargs["force_pip"] is True
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (6, 3))
|
|
def test_rocm_63_selects_correct_tag(self, mock_ver, mock_gpu, mock_nvidia, mock_pip):
|
|
"""ROCm 6.3 should select rocm6.3 tag."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"\n"
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
torch_call = mock_pip.call_args_list[0]
|
|
assert "rocm6.3" in str(torch_call)
|
|
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (5, 0))
|
|
def test_old_rocm_skips(self, mock_ver, mock_gpu, mock_nvidia, mock_pip):
|
|
"""ROCm version too old (below 6.0) should skip."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"\n"
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
mock_pip.assert_not_called()
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = None)
|
|
def test_version_unreadable_prints_warning(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, capsys
|
|
):
|
|
"""ROCm detected but version unreadable should print warning and skip."""
|
|
with patch("os.path.isdir", return_value = True):
|
|
_ensure_rocm_torch()
|
|
mock_pip.assert_not_called()
|
|
captured = capsys.readouterr()
|
|
assert "unreadable" in captured.out
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 2))
|
|
def test_rocm_72_selects_72_tag(self, mock_ver, mock_gpu, mock_nvidia, mock_pip):
|
|
"""ROCm 7.2 should select rocm7.2 tag (now in mapping with torch 2.11.0)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"\n"
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
torch_call = mock_pip.call_args_list[0]
|
|
assert "rocm7.2" in str(torch_call)
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (6, 4))
|
|
def test_explicit_gfx_index_honored_and_skips_strix_reroute(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""An explicit gfx wheel-index pin is authoritative: install from it verbatim
|
|
with torch 2.11, and never re-probe gfx codes to second-guess it (host ROCm 6.4
|
|
would otherwise pick the rocm6.4 wheel / trigger the Strix re-route)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"\n" # cpu torch -> reinstall
|
|
env = {"UNSLOTH_TORCH_INDEX_URL": "https://repo.amd.com/rocm/whl/gfx1151"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
# Would raise if the Strix block ran (it is skipped on an explicit pin).
|
|
with patch.object(
|
|
stack_mod, "_detect_amd_gfx_codes", side_effect = AssertionError
|
|
):
|
|
_ensure_rocm_torch()
|
|
assert mock_pip.call_count == 1
|
|
torch_call = str(mock_pip.call_args_list[0])
|
|
assert "gfx1151" in torch_call
|
|
assert "torch>=2.11.0,<2.12.0" in torch_call
|
|
|
|
def test_rocm_pin_family_mismatch_helper(self):
|
|
"""_rocm_pin_family_mismatch: exact rocm compare, else the 2.11 line."""
|
|
f = stack_mod._rocm_pin_family_mismatch
|
|
base = "https://download.pytorch.org/whl"
|
|
amd = "https://repo.amd.com/rocm/whl"
|
|
# Exact rocm version comparison.
|
|
assert f(f"{base}/rocm7.2", "2.11.0+rocm7.2") is False
|
|
assert f(f"{base}/rocm7.2", "2.10.0+rocm6.4") is True
|
|
assert f(f"{base}/rocm6.4", "2.10.0+rocm6.4") is False
|
|
# rocm7.2 is KNOWN-2.11. A +rocm7.2 wheel whose RELEASE drifted off 2.11 shares the
|
|
# tag but violates the spec -> mismatch (a plain version compare would accept it).
|
|
assert f(f"{base}/rocm7.2", "2.12.0+rocm7.2") is True
|
|
assert f(f"{base}/rocm7.2", "2.13.0+rocm7.2") is True
|
|
assert f(f"{base}/rocm7.2", "2.11.5+rocm7.2") is False # patch on 2.11 is in-spec
|
|
# An UNKNOWN newer rocm (not on the 2.11 allowlist) is not floored to 2.11, so a
|
|
# matching rocm version at any release line is NOT a mismatch on this branch.
|
|
assert f(f"{base}/rocm8.0", "2.12.0+rocm8.0") is False
|
|
# gfx pin (2.11 line) vs installed release line.
|
|
assert f(f"{amd}/gfx1151", "2.10.0+rocm6.4") is True
|
|
assert f(f"{amd}/gfx1151", "2.11.0+rocm7.13.0") is False
|
|
# rocm7.2 pin vs an untagged (no +rocm) wheel: a CPU/CUDA build never
|
|
# satisfies a ROCm pin, regardless of its release line -> always a mismatch.
|
|
assert f(f"{base}/rocm7.2", "2.10.0") is True
|
|
assert f(f"{base}/rocm7.2", "2.11.0") is True
|
|
assert f(f"{base}/rocm6.4", "2.10.0") is True
|
|
# A 2.11-allowlist gfx pin over a GENERIC (two-part +rocm7.2) 2.11 wheel mismatches:
|
|
# the user wants AMD's per-arch (three-part) wheel, not the generic one.
|
|
assert f(f"{amd}/gfx1151", "2.11.0+rocm7.2") is True
|
|
assert f(f"{amd}/gfx120X-all", "2.11.0+rocm7.2") is True
|
|
# ...but an already-installed per-arch (three-part) wheel is NOT re-flagged
|
|
# (no reinstall loop once the correct gfx wheel is present).
|
|
assert f(f"{amd}/gfx120X-all", "2.11.0+rocm7.13.0") is False
|
|
assert f(f"{amd}/gfx1150", "2.11.0+rocm7.13.0") is False
|
|
# A NON-2.11 gfx pin (gfx110X-all/gfx90a/gfx908) tracks the default <2.11 spec: a
|
|
# correct 2.10+rocm wheel is NOT a mismatch, a 2.11 build is.
|
|
assert f(f"{amd}/gfx110X-all", "2.10.0+rocm6.4") is False
|
|
assert f(f"{amd}/gfx90a", "2.10.0+rocm6.3") is False
|
|
assert f(f"{amd}/gfx908", "2.10.0+rocm7.0") is False
|
|
assert f(f"{amd}/gfx110X-all", "2.11.0+rocm7.2") is True
|
|
# A non-2.11 gfx pin over an untagged (no +rocm) wheel is a mismatch even
|
|
# when torch is already <2.11: a CPU/CUDA build never satisfies the ROCm pin.
|
|
assert f(f"{amd}/gfx110X-all", "2.10.0") is True
|
|
assert f(f"{amd}/gfx90a", "2.10.0") is True
|
|
# A major-only rocm pin (rocm7) compares on the major alone: rocm6.x mismatches,
|
|
# any rocm7.x satisfies it, an untagged wheel never does, a bare +rocm is lenient.
|
|
assert f(f"{base}/rocm7", "2.10.0+rocm6.4") is True
|
|
assert f(f"{base}/rocm7", "2.11.0+rocm7.2") is False
|
|
assert f(f"{base}/rocm7", "2.11.0+rocm7.13.0") is False
|
|
assert f(f"{base}/rocm7", "2.10.0") is True
|
|
assert f(f"{base}/rocm7", "2.10.0+rocm") is False
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 2))
|
|
def test_rocm_pin_mismatch_over_installed_rocm_reinstalls(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A rocm7.2 pin over an already-installed OLDER +rocm6.4 build must reinstall,
|
|
even though has_hip_torch is True (the ROCm analogue of the CUDA cuXXX mismatch)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
# HIP marker present (has_hip_torch=True) + installed +rocm6.4 wheel.
|
|
mock_probe.stdout = b"6.4.12345|2.10.0+rocm6.4\n"
|
|
env = {"UNSLOTH_TORCH_INDEX_FAMILY": "rocm7.2"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_URL", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
torch_call = str(mock_pip.call_args_list[0])
|
|
assert "rocm7.2" in torch_call
|
|
assert "torch>=2.11.0,<2.12.0" in torch_call
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (6, 4))
|
|
def test_gfx_pin_over_installed_pre211_rocm_reinstalls(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A gfx* pin (2.11 line) over an installed pre-2.11 +rocm6.4 build reinstalls."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"6.4.12345|2.10.0+rocm6.4\n"
|
|
env = {"UNSLOTH_TORCH_INDEX_URL": "https://repo.amd.com/rocm/whl/gfx1151"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
with patch.object(
|
|
stack_mod, "_detect_amd_gfx_codes", side_effect = AssertionError
|
|
):
|
|
_ensure_rocm_torch()
|
|
torch_call = str(mock_pip.call_args_list[0])
|
|
assert "gfx1151" in torch_call
|
|
assert "torch>=2.11.0,<2.12.0" in torch_call
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 2))
|
|
def test_rocm_pin_matches_installed_no_torch_reinstall(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A rocm7.2 pin over an already-matching +rocm7.2 build must NOT reinstall torch
|
|
(no false reinstall of a correct ROCm venv)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"7.2.12345|2.11.0+rocm7.2\n"
|
|
env = {"UNSLOTH_TORCH_INDEX_FAMILY": "rocm7.2"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_URL", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
# No torch reinstall: any pip_install call must not target a torch index.
|
|
for _call in mock_pip.call_args_list:
|
|
_args = [str(a) for a in _call.args]
|
|
if "--index-url" in _args:
|
|
_url = _args[_args.index("--index-url") + 1]
|
|
assert "rocm7.2" not in _url or "torch" not in " ".join(
|
|
_args
|
|
), "torch must not be reinstalled when the pin already matches"
|
|
# A torch reinstall would pass torch>=... as a positional; assert none did.
|
|
assert not any(
|
|
any(str(a).startswith("torch") for a in _c.args) for _c in mock_pip.call_args_list
|
|
)
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (6, 4))
|
|
def test_non211_gfx_pin_over_210_rocm_no_reinstall(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A gfx110X-all pin (NOT in the 2.11 allowlist) over a correct 2.10+rocm
|
|
wheel must NOT be flagged stale -- the install path uses the default <2.11
|
|
specs for that arch, so re-flagging would reinstall-loop on every update."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"6.4.12345|2.10.0+rocm6.4\n"
|
|
env = {"UNSLOTH_TORCH_INDEX_URL": "https://repo.amd.com/rocm/whl/gfx110X-all"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
_ensure_rocm_torch()
|
|
# has_hip_torch True + no mismatch -> torch must NOT be reinstalled.
|
|
assert not any(
|
|
any(str(a).startswith("torch") for a in _c.args) for _c in mock_pip.call_args_list
|
|
)
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 2))
|
|
def test_gfx_pin_over_generic_rocm211_reinstalls(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""A gfx1151 pin over a GENERIC (two-part +rocm7.2) 2.11 wheel must reinstall
|
|
the AMD per-arch wheel -- even though both are torch 2.11, the generic wheel
|
|
is not the per-arch build the user pinned (Strix stays off the generic wheel)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 0
|
|
mock_probe.stdout = b"7.2.12345|2.11.0+rocm7.2\n"
|
|
env = {"UNSLOTH_TORCH_INDEX_URL": "https://repo.amd.com/rocm/whl/gfx1151"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
with patch.object(
|
|
stack_mod, "_detect_amd_gfx_codes", side_effect = AssertionError
|
|
):
|
|
_ensure_rocm_torch()
|
|
torch_call = str(mock_pip.call_args_list[0])
|
|
assert "gfx1151" in torch_call
|
|
assert "torch>=2.11.0,<2.12.0" in torch_call
|
|
|
|
def test_radeon_url_not_classified_as_pip_rocm_family(self):
|
|
"""A repo.radeon.com find-links dir (leaf rocm-rel-7.2.1) starts with "rocm" but is
|
|
NOT a pip --index-url ROCm family: it must route to the verbatim path, not a
|
|
--index-url reinstall that fails against a find-links listing."""
|
|
leaf_f = stack_mod._is_pip_rocm_family_leaf
|
|
# Real pip ROCm families (download.pytorch.org/whl/rocmX.Y, repo.amd.com gfx).
|
|
assert leaf_f("rocm7.2") is True
|
|
assert leaf_f("rocm6.4") is True
|
|
assert leaf_f("gfx120x-all") is True
|
|
assert leaf_f("gfx1151") is True
|
|
# A bare rocm<digits> (no minor) is still an exact family.
|
|
assert leaf_f("rocm7") is True
|
|
# A Radeon find-links dir leaf, a custom mirror, cpu and cuda are NOT pip rocm.
|
|
assert leaf_f("rocm-rel-7.2.1") is False
|
|
assert leaf_f("simple") is False
|
|
assert leaf_f("current") is False
|
|
assert leaf_f("cpu") is False
|
|
assert leaf_f("cu128") is False
|
|
# A rocm<digit>-SUFFIX private mirror shares the family prefix but is a custom pin
|
|
# the verbatim path owns: a ^rocm\d PREFIX match would wrongly treat it as a
|
|
# --index-url family. Match EXACTLY.
|
|
assert leaf_f("rocm7.2-private") is False
|
|
assert leaf_f("rocm7-current") is False
|
|
assert leaf_f("rocm7.2.1") is False # two-part local suffix -> custom, not rocm7.2
|
|
|
|
radeon = "https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2.1"
|
|
pip_rocm = "https://download.pytorch.org/whl/rocm7.2"
|
|
amd_gfx = "https://repo.amd.com/rocm/whl/gfx120X-all"
|
|
|
|
def _classify(url, fn):
|
|
with patch.dict(stack_mod.os.environ, {"UNSLOTH_TORCH_INDEX_URL": url}, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
return fn()
|
|
|
|
rocm_fn = stack_mod._explicit_rocm_torch_index_url
|
|
unk_fn = stack_mod._explicit_unknown_family_torch_index_url
|
|
# Real pip rocm/gfx pins ARE a ROCm family (reinstallable via --index-url) and
|
|
# are NOT "unknown".
|
|
assert _classify(pip_rocm, rocm_fn) == pip_rocm
|
|
assert _classify(amd_gfx, rocm_fn) == amd_gfx
|
|
assert _classify(pip_rocm, unk_fn) is None
|
|
assert _classify(amd_gfx, unk_fn) is None
|
|
# The Radeon find-links URL is NOT a pip ROCm family (so _ensure_rocm_torch skips
|
|
# it) and IS unknown, so the family repair helpers leave it alone.
|
|
assert _classify(radeon, rocm_fn) is None
|
|
assert _classify(radeon, unk_fn) == radeon
|
|
|
|
# A rocm<digit>-suffix private mirror routes the same way: NOT a pip rocm family,
|
|
# IS an unknown-family (verbatim) pin.
|
|
suffixed = "https://co.internal/whl/rocm7.2-private"
|
|
assert _classify(suffixed, rocm_fn) is None
|
|
assert _classify(suffixed, unk_fn) == suffixed
|
|
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_ensure_cpu_torch_broken_probe_reinstalls(self, mock_pip):
|
|
"""_ensure_cpu_torch: torch present but unimportable (probe exit != 0) under an
|
|
explicit CPU pin must reinstall from the pin, not return -- the base update does
|
|
not repair a broken installed torch, so returning would strand it (Codex P2)."""
|
|
mock_probe = MagicMock()
|
|
mock_probe.returncode = 1 # torch present but cannot import
|
|
mock_probe.stdout = b""
|
|
env = {"UNSLOTH_TORCH_INDEX_URL": "https://mirror.local/cpu"}
|
|
with patch.dict(stack_mod.os.environ, env, clear = False):
|
|
stack_mod.os.environ.pop("UNSLOTH_TORCH_INDEX_FAMILY", None)
|
|
with patch("subprocess.run", return_value = mock_probe):
|
|
with patch.object(stack_mod, "NO_TORCH", False):
|
|
stack_mod._ensure_cpu_torch()
|
|
assert mock_pip.call_count == 1
|
|
assert "https://mirror.local/cpu" in str(mock_pip.call_args)
|
|
|
|
@patch.object(stack_mod, "IS_WINDOWS", False)
|
|
@patch.object(stack_mod, "pip_install_try", return_value = True)
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = True)
|
|
@patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1))
|
|
def test_probe_timeout_triggers_reinstall(
|
|
self, mock_ver, mock_gpu, mock_nvidia, mock_pip, mock_pip_try
|
|
):
|
|
"""Probe subprocess timeout should not crash; should proceed to reinstall."""
|
|
with patch("os.path.isdir", return_value = True):
|
|
with patch("subprocess.run", side_effect = subprocess.TimeoutExpired("python", 30)):
|
|
_ensure_rocm_torch()
|
|
# Probe timeout: treat torch as unusable and reinstall torch + bitsandbytes.
|
|
assert mock_pip.call_count == 1
|
|
assert "rocm7.1" in str(mock_pip.call_args_list[0])
|
|
assert mock_pip_try.call_count >= 1
|
|
assert mock_pip_try.call_args.kwargs["force_pip"] is True
|
|
|
|
@patch.object(stack_mod, "pip_install")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False)
|
|
@patch.object(stack_mod, "_has_rocm_gpu", return_value = False)
|
|
def test_no_gpu_with_rocm_tools_skips(self, mock_gpu, mock_nvidia, mock_pip):
|
|
"""ROCm tools present but no actual AMD GPU should skip entirely."""
|
|
# Pin the Windows arch probe to None so a real AMD host's WMI fallback
|
|
# can't defeat the "no actual GPU" premise.
|
|
with patch.object(stack_mod, "_detect_windows_gfx_arch", return_value = None):
|
|
with patch("os.path.isdir", return_value = True):
|
|
_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"}):
|
|
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 -- torch-index MARKER mechanism (PR #6692)
|
|
|
|
|
|
class TestHasRocmGpuKfdVendorGuard:
|
|
"""KFD sysfs fallback rejects non-AMD (NVIDIA) KFD nodes (source-level checks)."""
|
|
|
|
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()
|
|
# Word boundary 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 returns False when _has_usable_nvidia_gpu is True (NVIDIA always wins)."""
|
|
with patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True):
|
|
with patch("shutil.which", return_value = "/usr/bin/rocminfo"):
|
|
# rocminfo claims 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
|
|
|
|
|
|
class TestRocmTorchIndex:
|
|
"""Verify the ROCm version -> torch index tag mapping."""
|
|
|
|
def test_mapping_is_sorted_descending(self):
|
|
"""Keys should be in descending order for the next() iteration to work."""
|
|
keys = list(_ROCM_TORCH_INDEX.keys())
|
|
assert keys == sorted(keys, reverse = True)
|
|
|
|
def test_rocm_72_in_mapping(self):
|
|
"""ROCm 7.2 should be in the active mapping (torch 2.11.0 now supported)."""
|
|
assert (7, 2) in _ROCM_TORCH_INDEX
|
|
assert _ROCM_TORCH_INDEX[(7, 2)] == "rocm7.2"
|
|
|
|
def test_rocm_71_maps_correctly(self):
|
|
assert _ROCM_TORCH_INDEX[(7, 1)] == "rocm7.1"
|
|
|
|
def test_rocm_63_maps_correctly(self):
|
|
assert _ROCM_TORCH_INDEX[(6, 3)] == "rocm6.3"
|
|
|
|
def test_rocm_60_maps_correctly(self):
|
|
assert _ROCM_TORCH_INDEX[(6, 0)] == "rocm6.0"
|
|
|
|
def test_all_tags_use_download_pytorch(self):
|
|
"""All tags should be for download.pytorch.org, not repo.radeon.com."""
|
|
for tag in _ROCM_TORCH_INDEX.values():
|
|
assert tag.startswith("rocm")
|
|
assert "radeon" not in tag
|
|
|
|
def test_newer_rocm_selects_best_match(self):
|
|
"""ROCm 7.2 (now in map) should select rocm7.2 directly."""
|
|
ver = (7, 2)
|
|
tag = next(
|
|
(
|
|
t
|
|
for (maj, mn), t in sorted(_ROCM_TORCH_INDEX.items(), reverse = True)
|
|
if ver >= (maj, mn)
|
|
),
|
|
None,
|
|
)
|
|
assert tag == "rocm7.2"
|
|
|
|
def test_rocm_64_selects_64(self):
|
|
ver = (6, 4)
|
|
tag = next(
|
|
(
|
|
t
|
|
for (maj, mn), t in sorted(_ROCM_TORCH_INDEX.items(), reverse = True)
|
|
if ver >= (maj, mn)
|
|
),
|
|
None,
|
|
)
|
|
assert tag == "rocm6.4"
|
|
|
|
|
|
# TEST: hardware.py -- IS_ROCM flag and detect_hardware
|
|
|
|
|
|
class TestHardwareRocmFlag:
|
|
"""Verify IS_ROCM flag behavior without importing the full hardware module."""
|
|
|
|
def test_hardware_py_has_is_rocm(self):
|
|
"""hardware.py should define IS_ROCM."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert "IS_ROCM: bool" in source and "False" in source
|
|
|
|
def test_hardware_py_sets_is_rocm_on_hip(self):
|
|
"""detect_hardware() should set IS_ROCM when torch.version.hip is set."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert 'torch.version, "hip"' in source or "torch.version.hip" in source
|
|
|
|
def test_hardware_py_still_returns_cuda_for_rocm(self):
|
|
"""DeviceType should remain CUDA even on ROCm -- no DeviceType.ROCM."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
enum_section = source.split("class DeviceType")[1].split("\n\n")[0]
|
|
assert "ROCM" not in enum_section
|
|
|
|
def test_hardware_py_has_rocm_in_package_versions(self):
|
|
"""get_package_versions() should include 'rocm' key."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert '"rocm"' in source
|
|
|
|
def test_hardware_py_device_type_cuda_references_intact(self):
|
|
"""All existing DeviceType.CUDA references should still be present."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert "DeviceType.CUDA" in source
|
|
assert "DEVICE = DeviceType.CUDA" in source
|
|
|
|
def test_is_rocm_exported_from_init(self):
|
|
"""IS_ROCM should be exported from hardware __init__.py."""
|
|
init_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "__init__.py"
|
|
source = init_path.read_text(encoding = "utf-8")
|
|
assert "IS_ROCM" in source
|
|
|
|
def test_is_rocm_in_all_list(self):
|
|
"""IS_ROCM should be in __all__ list in __init__.py."""
|
|
init_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "__init__.py"
|
|
source = init_path.read_text(encoding = "utf-8")
|
|
assert '"IS_ROCM"' in source
|
|
|
|
def test_get_package_versions_returns_rocm_key(self):
|
|
"""get_package_versions() source should return both 'cuda' and 'rocm' keys."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def get_package_versions")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert '"cuda"' in func_body
|
|
assert '"rocm"' in func_body
|
|
|
|
def test_distributed_stubs_cover_is_torchelastic_launched(self):
|
|
"""Must stub is_torchelastic_launched (Windows ROCm torch.distributed lacks it)."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert "is_torchelastic_launched" in source
|
|
|
|
def test_distributed_stubs_cover_core_helpers(self):
|
|
"""_determine_attention_impl_for_gpu_estimate must stub the four core distributed helpers."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
for attr in ("is_initialized", "is_available", "get_rank", "get_world_size"):
|
|
assert attr in source, f"distributed stub for '{attr}' missing from hardware.py"
|
|
|
|
|
|
# TEST: tokenizer_utils.py -- error message
|
|
|
|
|
|
class TestTokenizerErrorMessage:
|
|
"""Verify the AMD error message is updated."""
|
|
|
|
def test_no_old_amd_message(self):
|
|
"""Old 'We do not support AMD' message should be gone."""
|
|
tu_path = PACKAGE_ROOT / "unsloth" / "tokenizer_utils.py"
|
|
source = tu_path.read_text(encoding = "utf-8")
|
|
assert "We do not support AMD" not in source
|
|
|
|
def test_new_message_has_docs_link(self):
|
|
"""New message should point to Unsloth AMD docs."""
|
|
tu_path = PACKAGE_ROOT / "unsloth" / "tokenizer_utils.py"
|
|
source = tu_path.read_text(encoding = "utf-8")
|
|
assert "docs.unsloth.ai" in source or "No GPU detected" in source
|
|
|
|
|
|
# TEST: install.sh -- structural checks
|
|
|
|
|
|
class TestInstallShStructure:
|
|
"""Verify install.sh structural properties without running it."""
|
|
|
|
def test_no_here_strings(self):
|
|
"""install.sh must not use the bash-only `<<<` here-string operator (breaks dash)."""
|
|
import re
|
|
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
for i, line in enumerate(source.splitlines(), 1):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("#"):
|
|
continue
|
|
# Strip quoted literals so `<<<` inside them is ignored.
|
|
unquoted = re.sub(r"'[^']*'", "", line)
|
|
unquoted = re.sub(r'"[^"]*"', "", unquoted)
|
|
assert "<<<" not in unquoted, f"install.sh:{i} uses non-POSIX <<< here-string"
|
|
|
|
def test_rocm_detection_present(self):
|
|
"""install.sh should have ROCm detection in get_torch_index_url."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert "amd-smi" in source
|
|
assert "rocm" in source.lower()
|
|
|
|
def test_cuda_precedence(self):
|
|
"""ROCm detection runs only when NVIDIA is absent (check runtime ordering in get_torch_index_url)."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
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")
|
|
# Gate uses _nvidia_detected (not -z "$_smi") to handle proc-only NVIDIA
|
|
# hosts where nvidia-smi is absent but the GPU is found via /proc.
|
|
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 branch"
|
|
assert (
|
|
rocm_call > no_nvidia_branch
|
|
), "ROCm detection should sit inside the 'no NVIDIA' branch"
|
|
assert (
|
|
nvidia_call < no_nvidia_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."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert "bitsandbytes" in source
|
|
assert "rocm*)" in source # case pattern for ROCm URLs
|
|
|
|
def test_cpu_hint_mentions_amd(self):
|
|
"""CPU-only hint should mention AMD ROCm."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert "ROCm" in source
|
|
|
|
def test_rocm72_supported_future_capped(self):
|
|
"""ROCm 7.2 should pass through directly; 7.3+ falls back to rocm7.2."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert 'echo "$_base/rocm7.2"' in source # fallback for unknown future versions
|
|
assert "rocm6.*" in source
|
|
assert "rocm7.0" in source
|
|
assert "rocm7.1" in source
|
|
assert "rocm7.2" in source
|
|
|
|
def test_rocm_tag_validation_guard_exists(self):
|
|
"""install.sh should validate _rocm_tag with a case guard."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert "rocm[1-9]*.[0-9]*)" in source
|
|
assert '_rocm_tag=""' in source # rejection path
|
|
|
|
def test_dpkg_epoch_handling(self):
|
|
"""install.sh should strip Debian epoch prefix from dpkg-query output."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
assert "sed 's/^[0-9]*://' " in source or "sed 's/^[0-9]*://'" in source
|
|
|
|
def test_no_double_bracket_in_rocm_block(self):
|
|
"""ROCm block must not use bash-only [[ ]] (POSIX char classes [[:space:]] are fine)."""
|
|
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]
|
|
import re
|
|
|
|
for i, line in enumerate(func_body.splitlines(), 1):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("#"):
|
|
continue
|
|
# Strip POSIX char classes [[:foo:]] before checking for [[ ]].
|
|
cleaned = re.sub(r"\[\[:[a-z]+:\]\]", "", line)
|
|
assert "[[" not in cleaned, f"get_torch_index_url line {i} uses non-POSIX [["
|
|
|
|
def test_no_arithmetic_expansion_in_rocm_block(self):
|
|
"""ROCm detection block should not use (( )) (bash-only)."""
|
|
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]
|
|
for i, line in enumerate(func_body.splitlines(), 1):
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("#"):
|
|
continue
|
|
assert (
|
|
"((" not in line or "))" not in line or "$(()" in line
|
|
), f"get_torch_index_url line {i} may use non-POSIX (( ))"
|
|
|
|
def test_macos_returns_cpu_before_rocm_check(self):
|
|
"""macOS should return CPU immediately (before any ROCm check)."""
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
source = sh_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("get_torch_index_url()")
|
|
func_body = source[func_start:]
|
|
darwin_pos = func_body.find("Darwin")
|
|
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 exports UNSLOTH_TORCH_BACKEND after TORCH_INDEX_URL (lets the stack skip GPU re-detection)."""
|
|
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"
|
|
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 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 registers KFD nodes too)."""
|
|
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) to avoid Ryzen+NVIDIA false positives."""
|
|
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 via _nvidia_detected (proc-only NVIDIA still picks 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"
|
|
)
|
|
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)
|
|
|
|
|
|
class TestLiveRegression:
|
|
"""Live checks that run on the actual host -- skip if no NVIDIA GPU."""
|
|
|
|
def test_get_torch_index_url_returns_cuda_on_nvidia(self):
|
|
"""On an NVIDIA machine, get_torch_index_url should return a CUDA URL."""
|
|
import shutil
|
|
|
|
if not shutil.which("nvidia-smi"):
|
|
pytest.skip("No nvidia-smi available")
|
|
# Skip if nvidia-smi exists but lists no GPU (binary without driver).
|
|
check = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"nvidia-smi -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{f=1} END{exit !f}'",
|
|
],
|
|
capture_output = True,
|
|
)
|
|
if check.returncode != 0:
|
|
pytest.skip("nvidia-smi is on PATH but no GPU is listed")
|
|
|
|
sh_path = PACKAGE_ROOT / "install.sh"
|
|
# All three helper definitions must be in scope when we eval the extract.
|
|
extract_cmd = (
|
|
f"sed -n '/^_has_amd_rocm_gpu()/,/^}}$/p; "
|
|
f"/^_has_usable_nvidia_gpu()/,/^}}$/p; "
|
|
f"/^get_torch_index_url()/,/^}}$/p' '{sh_path}'"
|
|
)
|
|
result = subprocess.run(
|
|
["bash", "-c", f'eval "$({extract_cmd})"; get_torch_index_url'],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
)
|
|
if result.returncode != 0:
|
|
pytest.skip("Could not extract get_torch_index_url for live test")
|
|
url = result.stdout.strip()
|
|
assert "cu1" in url or "cuda" in url.lower(), f"Expected CUDA URL, got: {url}"
|
|
|
|
|
|
# TEST: worker.py -- ROCm Mamba/SSM source build path
|
|
|
|
_WORKER_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "training" / "worker.py"
|
|
_EXPORT_WORKER_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "export" / "worker.py"
|
|
# Shared torchao Windows-ROCm stub used by both workers.
|
|
_TORCHAO_STUB_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "_torchao_stub.py"
|
|
# RAG embedder -- runs in the main backend process and also needs the stub.
|
|
_EMBEDDINGS_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "rag" / "embeddings.py"
|
|
# Wheel-probe script literal lives in wheel_utils after the resolver refactor.
|
|
_WHEEL_UTILS_PATH = PACKAGE_ROOT / "studio" / "backend" / "utils" / "wheel_utils.py"
|
|
|
|
|
|
class TestWorkerRocmMambaSsm:
|
|
"""Verify worker.py Mamba/SSM install logic on ROCm."""
|
|
|
|
def test_probe_returns_hip_version_field(self):
|
|
"""The wheel probe should include hip_version, and worker.py consumes it."""
|
|
assert "hip_version" in _WHEEL_UTILS_PATH.read_text(encoding = "utf-8")
|
|
assert "hip_version" in _WORKER_PATH.read_text(encoding = "utf-8")
|
|
|
|
def test_probe_script_has_getattr_hip(self):
|
|
"""Probe script should use getattr for torch.version.hip (safe on CUDA)."""
|
|
source = _WHEEL_UTILS_PATH.read_text(encoding = "utf-8")
|
|
assert "getattr(torch.version, 'hip', None)" in source
|
|
|
|
def test_direct_wheel_url_returns_none_without_cuda_major(self, monkeypatch):
|
|
"""_direct_wheel_url should return None when cuda_major is empty (ROCm)."""
|
|
_worker_spec = importlib.util.spec_from_file_location("test_worker", _WORKER_PATH)
|
|
assert _worker_spec is not None and _worker_spec.loader is not None
|
|
worker_mod = importlib.util.module_from_spec(_worker_spec)
|
|
|
|
# Stub worker.py imports via monkeypatch so the fake "utils" is undone
|
|
# and doesn't break later tests importing the real utils.* package.
|
|
loggers_mock = MagicMock()
|
|
loggers_mock.get_logger = MagicMock(return_value = MagicMock())
|
|
monkeypatch.setitem(sys.modules, "structlog", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "loggers", loggers_mock)
|
|
monkeypatch.setitem(sys.modules, "utils", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "utils.hardware", MagicMock())
|
|
|
|
try:
|
|
_worker_spec.loader.exec_module(worker_mod)
|
|
except Exception:
|
|
pytest.skip("Could not load worker module in test environment")
|
|
|
|
env_rocm = {
|
|
"python_tag": "cp312",
|
|
"torch_mm": "2.6",
|
|
"cuda_major": "",
|
|
"hip_version": "7.1.12345",
|
|
"cxx11abi": "TRUE",
|
|
}
|
|
result = worker_mod._direct_wheel_url(
|
|
filename_prefix = "causal_conv1d",
|
|
package_version = "1.6.1",
|
|
release_tag = "v1.6.1.post4",
|
|
release_base_url = "https://github.com/Dao-AILab/causal-conv1d/releases/download",
|
|
env = env_rocm,
|
|
)
|
|
assert result is None
|
|
|
|
def test_hipcc_check_exists_in_source(self):
|
|
"""worker.py should check for hipcc before ROCm source builds."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "hipcc" in source
|
|
|
|
def test_rocm_source_build_status_message(self):
|
|
"""worker.py should send a specific status for ROCm source compilation."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "Compiling" in source and "from source for ROCm" in source
|
|
|
|
def test_rocm_build_failure_message(self):
|
|
"""worker.py should send a clear error on ROCm build failure."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "Failed to compile" in source and "for ROCm" in source
|
|
|
|
def test_timeout_on_install(self):
|
|
"""worker.py should have a timeout on pip install subprocess."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "TimeoutExpired" in source
|
|
assert "timeout" in source
|
|
|
|
|
|
# TEST: amd.py -- AMD GPU monitoring
|
|
|
|
|
|
class TestAmdGpuMonitoring:
|
|
"""Verify amd.py module structure and mock behavior."""
|
|
|
|
def test_amd_py_exists(self):
|
|
"""amd.py should exist in the hardware directory."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
assert amd_path.exists()
|
|
|
|
def test_amd_py_has_required_functions(self):
|
|
"""amd.py should export the same function signatures as nvidia.py."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
source = amd_path.read_text(encoding = "utf-8")
|
|
assert "def get_physical_gpu_count" in source
|
|
assert "def get_primary_gpu_utilization" in source
|
|
assert "def get_visible_gpu_utilization" in source
|
|
|
|
def test_amd_smi_json_parsing(self, monkeypatch):
|
|
"""Verify _extract_gpu_metrics parses amd-smi JSON correctly."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
_amd_spec = importlib.util.spec_from_file_location("test_amd", amd_path)
|
|
assert _amd_spec is not None and _amd_spec.loader is not None
|
|
amd_mod = importlib.util.module_from_spec(_amd_spec)
|
|
|
|
loggers_mock = MagicMock()
|
|
loggers_mock.get_logger = MagicMock(return_value = MagicMock())
|
|
monkeypatch.setitem(sys.modules, "loggers", loggers_mock)
|
|
|
|
try:
|
|
_amd_spec.loader.exec_module(amd_mod)
|
|
except Exception:
|
|
pytest.skip("Could not load amd module in test environment")
|
|
|
|
gpu_data = {
|
|
"usage": {"gfx_activity": "85"},
|
|
"temperature": {"edge": "72"},
|
|
"power": {
|
|
"current_socket_power": "200.5",
|
|
"power_cap": "300",
|
|
},
|
|
"vram": {
|
|
"vram_used": 8192, # MB
|
|
"vram_total": 16384, # MB
|
|
},
|
|
}
|
|
metrics = amd_mod._extract_gpu_metrics(gpu_data)
|
|
assert metrics["gpu_utilization_pct"] == 85.0
|
|
assert metrics["temperature_c"] == 72.0
|
|
assert metrics["power_draw_w"] == 200.5
|
|
assert metrics["power_limit_w"] == 300.0
|
|
assert metrics["vram_used_gb"] == round(8192 / 1024, 2)
|
|
assert metrics["vram_total_gb"] == round(16384 / 1024, 2)
|
|
assert metrics["vram_utilization_pct"] is not None
|
|
assert metrics["power_utilization_pct"] is not None
|
|
|
|
def test_amd_primary_gpu_with_mock(self, monkeypatch):
|
|
"""get_primary_gpu_utilization returns correct dict with mocked amd-smi."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
_amd_spec = importlib.util.spec_from_file_location("test_amd2", amd_path)
|
|
assert _amd_spec is not None and _amd_spec.loader is not None
|
|
amd_mod = importlib.util.module_from_spec(_amd_spec)
|
|
|
|
loggers_mock = MagicMock()
|
|
loggers_mock.get_logger = MagicMock(return_value = MagicMock())
|
|
monkeypatch.setitem(sys.modules, "loggers", loggers_mock)
|
|
|
|
try:
|
|
_amd_spec.loader.exec_module(amd_mod)
|
|
except Exception:
|
|
pytest.skip("Could not load amd module")
|
|
|
|
# _first_visible_amd_gpu_id() returns None if HIP/ROCR/CUDA_VISIBLE_DEVICES
|
|
# is "" or "-1"; CI often sets CUDA_VISIBLE_DEVICES="", so clear them.
|
|
for var in (
|
|
"HIP_VISIBLE_DEVICES",
|
|
"ROCR_VISIBLE_DEVICES",
|
|
"CUDA_VISIBLE_DEVICES",
|
|
):
|
|
monkeypatch.delenv(var, raising = False)
|
|
|
|
# amd-smi is gated off on Windows w/o a HIP SDK; opt in so the mock is
|
|
# allowed on every platform.
|
|
monkeypatch.setenv("UNSLOTH_ENABLE_AMD_SMI", "1")
|
|
|
|
mock_json = json.dumps(
|
|
[
|
|
{
|
|
"usage": {"gfx_activity": "50"},
|
|
"temperature": {"edge": "65"},
|
|
"power": {"current_socket_power": "150", "power_cap": "250"},
|
|
"vram": {"vram_used": 4096, "vram_total": 16384},
|
|
}
|
|
]
|
|
)
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = mock_json
|
|
|
|
# Premise is "amd-smi exists and answers": the guard which()-checks
|
|
# before spawning, so mock which too for hosts lacking a real amd-smi.
|
|
with patch.object(amd_mod.shutil, "which", return_value = "/usr/bin/amd-smi"):
|
|
with patch.object(subprocess, "run", return_value = mock_result):
|
|
result = amd_mod.get_primary_gpu_utilization()
|
|
assert result["available"] is True
|
|
assert result["gpu_utilization_pct"] == 50.0
|
|
assert result["temperature_c"] == 65.0
|
|
|
|
def test_amd_smi_not_found_returns_unavailable(self, monkeypatch):
|
|
"""get_primary_gpu_utilization returns available=False when amd-smi is missing."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
_amd_spec = importlib.util.spec_from_file_location("test_amd3", amd_path)
|
|
assert _amd_spec is not None and _amd_spec.loader is not None
|
|
amd_mod = importlib.util.module_from_spec(_amd_spec)
|
|
|
|
loggers_mock = MagicMock()
|
|
loggers_mock.get_logger = MagicMock(return_value = MagicMock())
|
|
monkeypatch.setitem(sys.modules, "loggers", loggers_mock)
|
|
|
|
try:
|
|
_amd_spec.loader.exec_module(amd_mod)
|
|
except Exception:
|
|
pytest.skip("Could not load amd module")
|
|
|
|
# Opt in so the call reaches subprocess.run (testing OSError handling).
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ENABLE_AMD_SMI": "1"}),
|
|
patch.object(subprocess, "run", side_effect = OSError("amd-smi not found")),
|
|
):
|
|
result = amd_mod.get_primary_gpu_utilization()
|
|
assert result["available"] is False
|
|
|
|
def test_amd_timeout_returns_unavailable(self, monkeypatch):
|
|
"""get_primary_gpu_utilization handles timeout gracefully."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
_amd_spec = importlib.util.spec_from_file_location("test_amd4", amd_path)
|
|
assert _amd_spec is not None and _amd_spec.loader is not None
|
|
amd_mod = importlib.util.module_from_spec(_amd_spec)
|
|
|
|
loggers_mock = MagicMock()
|
|
loggers_mock.get_logger = MagicMock(return_value = MagicMock())
|
|
monkeypatch.setitem(sys.modules, "loggers", loggers_mock)
|
|
|
|
try:
|
|
_amd_spec.loader.exec_module(amd_mod)
|
|
except Exception:
|
|
pytest.skip("Could not load amd module")
|
|
|
|
# Opt in so the call reaches subprocess.run (testing timeout handling).
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ENABLE_AMD_SMI": "1"}),
|
|
patch.object(
|
|
subprocess,
|
|
"run",
|
|
side_effect = subprocess.TimeoutExpired("amd-smi", 5),
|
|
),
|
|
):
|
|
result = amd_mod.get_primary_gpu_utilization()
|
|
assert result["available"] is False
|
|
|
|
|
|
# TEST: hardware.py -- IS_ROCM branching to amd.py
|
|
|
|
|
|
class TestHardwareAmdBranching:
|
|
"""Verify hardware.py branches to amd.py when IS_ROCM is True."""
|
|
|
|
def test_hardware_imports_amd_module(self):
|
|
"""hardware.py should import from amd module when IS_ROCM."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
assert "from . import amd" in source
|
|
|
|
def test_hardware_branches_on_is_rocm_for_utilization(self):
|
|
"""get_gpu_utilization dispatches visible metrics through amd.py on ROCm."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def get_gpu_utilization")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "_smi_query(" in func_body
|
|
assert '"get_visible_gpu_utilization"' in func_body
|
|
assert "_reconcile_rocm_unified_memory" in func_body
|
|
smi = source[
|
|
source.find("def _smi_query") : source.find("\ndef ", source.find("def _smi_query") + 1)
|
|
]
|
|
assert "IS_ROCM" in smi
|
|
assert "from . import amd" in smi
|
|
|
|
def test_hardware_branches_on_is_rocm_for_visible(self):
|
|
"""get_visible_gpu_utilization dispatches to amd.py via _smi_query when IS_ROCM."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def get_visible_gpu_utilization")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
# The dispatcher call may wrap; allow whitespace before the func name arg.
|
|
import re as _re
|
|
|
|
assert _re.search(r'_smi_query\(\s*"get_visible_gpu_utilization"', func_body)
|
|
smi = source[
|
|
source.find("def _smi_query") : source.find("\ndef ", source.find("def _smi_query") + 1)
|
|
]
|
|
assert "IS_ROCM" in smi
|
|
assert "from . import amd" in smi
|
|
|
|
def test_hardware_branches_on_is_rocm_for_physical_count(self):
|
|
"""get_physical_gpu_count should try amd.py when IS_ROCM."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def get_physical_gpu_count")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "IS_ROCM" in func_body
|
|
assert "from . import amd" in func_body
|
|
|
|
|
|
# TEST: hardware.py -- apply_gpu_ids ROCm fallback (issue #5180)
|
|
|
|
|
|
class TestApplyGpuIdsRocmFallback:
|
|
"""apply_gpu_ids sets HIP_VISIBLE_DEVICES on ROCm hosts even when IS_ROCM is still False (issue #5180)."""
|
|
|
|
def test_apply_gpu_ids_falls_back_to_torch_version_hip(self):
|
|
"""apply_gpu_ids probes torch.version.hip when IS_ROCM is False and no ROCm env vars set."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def apply_gpu_ids")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert 'getattr(_torch.version, "hip", None)' in func_body
|
|
|
|
def test_apply_gpu_ids_sets_hip_but_not_rocr_visible_devices(self):
|
|
"""apply_gpu_ids sets HIP_VISIBLE_DEVICES but leaves ROCR_VISIBLE_DEVICES inherited (HSA indexing; issue #6118)."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def apply_gpu_ids")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert 'os.environ["HIP_VISIBLE_DEVICES"] = value' in func_body
|
|
assert 'os.environ["ROCR_VISIBLE_DEVICES"] = value' not in func_body
|
|
|
|
def test_apply_gpu_ids_rocm_fallback_is_guarded_by_try_except(self):
|
|
"""torch import in apply_gpu_ids must be wrapped in try/except so a missing torch never crashes."""
|
|
hw_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
source = hw_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def apply_gpu_ids")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "import torch as _torch" in func_body
|
|
assert "except Exception" in func_body
|
|
|
|
|
|
# TEST: install_python_stack.py -- Windows AMD warning
|
|
|
|
|
|
class TestWindowsRocmWarning:
|
|
"""Verify Windows AMD GPU detection and warning message."""
|
|
|
|
def test_windows_amd_warning_in_source(self):
|
|
"""install_python_stack.py should warn Windows AMD users."""
|
|
source = _STACK_PATH.read_text(encoding = "utf-8")
|
|
assert "AMD GPU detected" in source
|
|
|
|
def test_windows_amd_warning_checks_hipinfo_or_amdsmi(self):
|
|
"""Warning should check for hipinfo or amd-smi."""
|
|
source = _STACK_PATH.read_text(encoding = "utf-8")
|
|
assert "hipinfo" in source
|
|
assert "amd-smi" in source
|
|
|
|
def test_windows_amd_warning_has_docs_link(self):
|
|
"""Warning should include AMD docs link."""
|
|
source = _STACK_PATH.read_text(encoding = "utf-8")
|
|
assert "docs.unsloth.ai/get-started/install-and-update/amd" in source
|
|
|
|
|
|
# TEST: unsloth/kernels/utils.py -- is_rdna() expansion
|
|
|
|
|
|
class TestIsRdnaExpansion:
|
|
"""Verify is_rdna() covers RDNA2, RDNA3, RDNA3.5, RDNA4 architectures."""
|
|
|
|
def test_is_rdna_source_has_rdna2(self):
|
|
"""is_rdna() should include RDNA2 architectures."""
|
|
utils_path = PACKAGE_ROOT / "unsloth" / "kernels" / "utils.py"
|
|
source = utils_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def is_rdna()")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "gfx1030" in func_body
|
|
assert "gfx1031" in func_body
|
|
assert "gfx1032" in func_body
|
|
assert "gfx1033" in func_body
|
|
assert "gfx1034" in func_body
|
|
assert "gfx1035" in func_body
|
|
assert "gfx1036" in func_body
|
|
|
|
def test_is_rdna_source_has_rdna3(self):
|
|
"""is_rdna() should include RDNA3 architectures."""
|
|
utils_path = PACKAGE_ROOT / "unsloth" / "kernels" / "utils.py"
|
|
source = utils_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def is_rdna()")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "gfx1100" in func_body
|
|
assert "gfx1101" in func_body
|
|
assert "gfx1102" in func_body
|
|
assert "gfx1103" in func_body
|
|
|
|
def test_is_rdna_source_has_rdna35(self):
|
|
"""is_rdna() should include RDNA3.5 architectures."""
|
|
utils_path = PACKAGE_ROOT / "unsloth" / "kernels" / "utils.py"
|
|
source = utils_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def is_rdna()")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "gfx1150" in func_body
|
|
assert "gfx1151" in func_body
|
|
assert "gfx1152" in func_body
|
|
|
|
def test_is_rdna_source_has_rdna4(self):
|
|
"""is_rdna() should include RDNA4 architectures."""
|
|
utils_path = PACKAGE_ROOT / "unsloth" / "kernels" / "utils.py"
|
|
source = utils_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def is_rdna()")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "gfx1200" in func_body
|
|
assert "gfx1201" in func_body
|
|
|
|
def test_is_cdna_not_changed(self):
|
|
"""is_cdna() should remain unchanged (no RDNA architectures added)."""
|
|
utils_path = PACKAGE_ROOT / "unsloth" / "kernels" / "utils.py"
|
|
source = utils_path.read_text(encoding = "utf-8")
|
|
func_start = source.find("def is_cdna()")
|
|
func_body = source[func_start : source.find("\ndef ", func_start + 1)]
|
|
assert "gfx940" in func_body
|
|
assert "gfx941" in func_body
|
|
assert "gfx942" in func_body
|
|
assert "gfx950" in func_body
|
|
# RDNA architectures should NOT be in is_cdna
|
|
assert "gfx1030" not in func_body
|
|
assert "gfx1100" not in func_body
|
|
|
|
|
|
# TEST: install_python_stack.py -- _windows_rocm_index_url arch mapping
|
|
|
|
|
|
class TestWindowsRocmIndexUrl:
|
|
"""Verify GPU arch → AMD pip index URL mapping."""
|
|
|
|
def test_gfx1200_maps_to_gfx120x_all(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1200")
|
|
assert url is not None
|
|
assert "gfx120X-all" in url
|
|
|
|
def test_gfx1201_maps_to_gfx120x_all(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1201")
|
|
assert url is not None
|
|
assert "gfx120X-all" in url
|
|
|
|
def test_gfx1151_maps_to_gfx1151(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1151")
|
|
assert url is not None
|
|
assert "gfx1151" in url
|
|
|
|
def test_gfx1150_maps_to_gfx1150(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1150")
|
|
assert url is not None
|
|
assert "gfx1150" in url
|
|
|
|
def test_gfx1100_maps_to_gfx110x_all(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1100")
|
|
assert url is not None
|
|
assert "gfx110X-all" in url
|
|
|
|
def test_unknown_arch_returns_none(self):
|
|
assert stack_mod._windows_rocm_index_url("gfx9999") is None
|
|
|
|
def test_none_arch_returns_none(self):
|
|
assert stack_mod._windows_rocm_index_url(None) is None
|
|
|
|
def test_url_ends_with_slash(self):
|
|
"""AMD pip index URLs must end with / for --index-url compatibility."""
|
|
url = stack_mod._windows_rocm_index_url("gfx1200")
|
|
assert url is not None
|
|
assert url.endswith("/")
|
|
|
|
def test_base_url_uses_repo_amd_com_by_default(self):
|
|
url = stack_mod._windows_rocm_index_url("gfx1200")
|
|
assert url is not None
|
|
assert "repo.amd.com" in url
|
|
|
|
def test_mirror_env_var_overrides_base(self, monkeypatch):
|
|
monkeypatch.setenv("UNSLOTH_ROCM_WINDOWS_MIRROR", "https://my-mirror.example.com/rocm/whl")
|
|
# Reload module-level constant by calling helper directly
|
|
url = stack_mod._windows_rocm_index_url("gfx1200")
|
|
# The env var is read at module load time for _ROCM_WINDOWS_INDEX_BASE,
|
|
# so just verify the helper itself doesn't error.
|
|
assert url is not None
|
|
|
|
|
|
# TEST: install_python_stack.py -- _detect_windows_gfx_arch
|
|
|
|
|
|
class TestDetectWindowsGfxArch:
|
|
"""Verify hipinfo parsing for GPU arch detection on Windows."""
|
|
|
|
def test_returns_none_when_hipinfo_not_on_path(self):
|
|
# Neutralise the venv-hipInfo and WMI-name fallbacks too, since the
|
|
# suite may run on a real AMD host where WMI would answer.
|
|
with patch("shutil.which", return_value = None):
|
|
with patch("os.path.isfile", return_value = False):
|
|
with patch("subprocess.run", side_effect = FileNotFoundError):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result is None
|
|
|
|
def test_parses_gcnarchname_from_hipinfo_output(self):
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = b"gcnArchName : gfx1200\nsome other line\n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result == "gfx1200"
|
|
|
|
def test_returns_arch_on_crash_with_gcnarchname_in_output(self):
|
|
# Regression #6043: hipinfo may crash (0xC0000005 on RDNA 4) after printing
|
|
# gcnArchName. Accept the arch whenever gcnArchName is in stdout, any exit code.
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = -1073741819 # 0xC0000005 STATUS_ACCESS_VIOLATION
|
|
mock_result.stdout = b"gcnArchName : gfx1200\nsome other line\n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result == "gfx1200"
|
|
|
|
def test_returns_none_on_nonzero_returncode_without_gcnarchname(self):
|
|
# Non-zero exit without gcnArchName must return None (fall through to amd-smi/WMI).
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 1
|
|
mock_result.stdout = b"HIP runtime error: no device detected\n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result is None
|
|
|
|
def test_returns_none_when_no_gcnarchname_in_output(self):
|
|
# hipinfo answers without a gcnArchName line. The WMI fallback must get
|
|
# nothing (FileNotFoundError) so the mocked name can't resolve via the table.
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = b"deviceName : SomeUnknownDevice\n"
|
|
|
|
def _run(cmd, **kwargs):
|
|
if cmd and "powershell" in str(cmd[0]).lower():
|
|
raise FileNotFoundError(cmd[0])
|
|
return mock_result
|
|
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch("subprocess.run", side_effect = _run):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result is None
|
|
|
|
def test_returns_none_on_timeout(self):
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect = subprocess.TimeoutExpired("hipinfo", 10),
|
|
):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result is None
|
|
|
|
def test_strips_whitespace_from_arch(self):
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = b" gcnArchName : gfx1201 \n"
|
|
with patch("shutil.which", return_value = "/usr/bin/hipinfo"):
|
|
with patch("subprocess.run", return_value = mock_result):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result == "gfx1201"
|
|
|
|
|
|
# TEST: install_python_stack.py -- GPU-name / WMI fallback (no amd-smi, no hipinfo)
|
|
|
|
|
|
class TestGfxArchNameFallback:
|
|
"""With no amd-smi/hipinfo on Windows, arch must resolve from the GPU name via WMI (mirrors setup.ps1)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"name, expected",
|
|
[
|
|
("AMD Radeon(TM) 8060S Graphics", "gfx1151"),
|
|
("AMD Ryzen AI MAX+ 395 w/ Radeon 8060S", "gfx1151"),
|
|
("AMD Radeon(TM) 890M", "gfx1150"),
|
|
("AMD Ryzen AI 9 HX 370 w/ Radeon 890M", "gfx1150"),
|
|
("AMD Radeon RX 9070 XT", "gfx1201"),
|
|
("AMD Radeon RX 9070", "gfx1200"),
|
|
("AMD Radeon RX 7700S", "gfx1102"), # (?!S) lookahead must not hit gfx1100
|
|
("AMD Radeon RX 7700 XT", "gfx1100"),
|
|
("AMD Radeon(TM) 780M", "gfx1103"),
|
|
("NVIDIA GeForce RTX 4090", None),
|
|
("Microsoft Basic Display Adapter", None),
|
|
("", None),
|
|
],
|
|
)
|
|
def test_name_to_arch_mapping(self, name, expected):
|
|
assert stack_mod._gfx_arch_from_gpu_name(name) == expected
|
|
|
|
def test_wmi_fallback_resolves_arch_without_any_tools(self):
|
|
"""hipinfo absent everywhere + amd-smi absent -> WMI name fallback."""
|
|
ps_result = MagicMock()
|
|
ps_result.returncode = 0
|
|
ps_result.stdout = b"AMD Radeon(TM) 8060S Graphics\r\nMicrosoft Basic Display Adapter\r\n"
|
|
|
|
def _run(cmd, **kwargs):
|
|
if cmd and "powershell.exe" in str(cmd[0]).lower():
|
|
return ps_result
|
|
raise FileNotFoundError(cmd[0])
|
|
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
for _v in (
|
|
"HIP_PATH",
|
|
"ROCM_PATH",
|
|
"UNSLOTH_ROCM_GFX_ARCH",
|
|
"UNSLOTH_ENABLE_AMD_SMI",
|
|
):
|
|
os.environ.pop(_v, None)
|
|
with patch("shutil.which", return_value = None):
|
|
with patch("os.path.isfile", return_value = False):
|
|
with patch("subprocess.run", side_effect = _run):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result == "gfx1151"
|
|
|
|
def test_wmi_fallback_returns_none_for_non_amd_hosts(self):
|
|
ps_result = MagicMock()
|
|
ps_result.returncode = 0
|
|
ps_result.stdout = b"NVIDIA GeForce RTX 4090\r\n"
|
|
|
|
def _run(cmd, **kwargs):
|
|
if cmd and "powershell.exe" in str(cmd[0]).lower():
|
|
return ps_result
|
|
raise FileNotFoundError(cmd[0])
|
|
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
for _v in ("HIP_PATH", "ROCM_PATH", "UNSLOTH_ROCM_GFX_ARCH"):
|
|
os.environ.pop(_v, None)
|
|
with patch("shutil.which", return_value = None):
|
|
with patch("os.path.isfile", return_value = False):
|
|
with patch("subprocess.run", side_effect = _run):
|
|
result = stack_mod._detect_windows_gfx_arch()
|
|
assert result is None
|
|
|
|
def test_stack_probes_venv_hipinfo(self):
|
|
"""venv Scripts hipInfo.exe (from AMD torch wheels) must be a probe candidate for driver-only hosts."""
|
|
source = _STACK_PATH.read_text(encoding = "utf-8")
|
|
assert 'os.path.join(os.path.dirname(sys.executable), "hipInfo.exe")' in source
|
|
|
|
def test_prebuilt_resolve_exe_probes_venv_dir(self):
|
|
"""_resolve_exe must include the venv Scripts candidate for driver-only standalone reruns."""
|
|
source = _PREBUILT_PATH.read_text(encoding = "utf-8")
|
|
assert "_venv_candidate" in source
|
|
|
|
def test_runtime_monitor_guards_amd_smi_absence(self):
|
|
"""amd.py must which()-check amd-smi before spawning (absence disables the poller)."""
|
|
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
|
|
source = amd_path.read_text(encoding = "utf-8")
|
|
assert 'shutil.which("amd-smi") is None' in source
|
|
|
|
|
|
# TEST: install_python_stack.py -- _install_bnb_windows_rocm
|
|
|
|
|
|
class TestInstallBnbWindowsRocm:
|
|
"""Verify AMD Windows BNB wheel install helper."""
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _isolate_sitecustomize_persistence(self, monkeypatch, request):
|
|
"""Keep helper tests from writing to the active interpreter site-packages."""
|
|
if request.node.name.startswith("test_persist"):
|
|
return
|
|
monkeypatch.setattr(
|
|
stack_mod,
|
|
"_persist_bnb_rocm_version",
|
|
lambda version: True,
|
|
)
|
|
|
|
def test_calls_pip_install_try_with_win_amd64_url(self):
|
|
"""Should call pip_install_try with the win_amd64 wheel URL via plain pip."""
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True) as mock_pip:
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert mock_pip.call_count == 1
|
|
call_args = str(mock_pip.call_args_list[0])
|
|
assert "bitsandbytes" in call_args
|
|
assert "win_amd64" in call_args
|
|
# Force plain pip (uv mangles the bitsandbytes wheel) -- see
|
|
# https://unsloth.ai/docs/get-started/install/amd/amd-hackathon
|
|
assert mock_pip.call_args.kwargs.get("force_pip") is True
|
|
|
|
def test_forces_plain_pip_not_uv(self):
|
|
"""The bnb wheel must be installed with plain pip, never uv."""
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True) as mock_pip:
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert mock_pip.call_args.kwargs.get("force_pip") is True
|
|
|
|
def test_does_not_touch_uv_skip_env_var(self):
|
|
"""The UV_SKIP_WHEEL_FILENAME_CHECK hack is gone; the env must be untouched."""
|
|
observed = {}
|
|
|
|
def _capture(*args, **kwargs):
|
|
observed["during"] = os.environ.get("UV_SKIP_WHEEL_FILENAME_CHECK")
|
|
return True
|
|
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("UV_SKIP_WHEEL_FILENAME_CHECK", None)
|
|
with patch.object(stack_mod, "pip_install_try", side_effect = _capture):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert observed.get("during") is None
|
|
assert "UV_SKIP_WHEEL_FILENAME_CHECK" not in os.environ
|
|
|
|
def test_returns_false_on_pip_failure(self):
|
|
"""A failed pip_install_try must surface as a False return, not BNB_ROCM_VERSION."""
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("BNB_ROCM_VERSION", None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = False):
|
|
result = stack_mod._install_bnb_windows_rocm()
|
|
assert result is False
|
|
assert "BNB_ROCM_VERSION" not in os.environ
|
|
|
|
def test_no_op_when_win_amd64_url_missing(self):
|
|
"""Should be silent no-op if win_amd64 key absent from _BNB_ROCM_PRERELEASE_URLS."""
|
|
with patch.object(stack_mod, "_BNB_ROCM_PRERELEASE_URLS", {}):
|
|
with patch.object(stack_mod, "pip_install_try") as mock_pip:
|
|
stack_mod._install_bnb_windows_rocm()
|
|
mock_pip.assert_not_called()
|
|
|
|
def test_sets_bnb_rocm_version_from_detected_dll(self):
|
|
"""BNB_ROCM_VERSION is set from the DLL detected after install."""
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("BNB_ROCM_VERSION", None)
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver", return_value = "72"):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "72"
|
|
|
|
def test_sets_bnb_rocm_version_from_newer_dll(self):
|
|
"""If AMD ships a newer DLL (e.g. rocm713.dll), that version is used."""
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("BNB_ROCM_VERSION", None)
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver", return_value = "713"):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "713"
|
|
|
|
def test_falls_back_to_72_when_detection_fails(self):
|
|
"""Falls back to '72' when DLL detection returns None."""
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("BNB_ROCM_VERSION", None)
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver", return_value = None):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "72"
|
|
|
|
def test_does_not_override_existing_bnb_rocm_version(self):
|
|
"""An explicit BNB_ROCM_VERSION in the caller's env must not be clobbered."""
|
|
with patch.dict(os.environ, {"BNB_ROCM_VERSION": "60"}):
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "60"
|
|
|
|
def test_does_not_persist_existing_bnb_rocm_version(self):
|
|
"""A caller override must not become the venv's managed default."""
|
|
with patch.dict(os.environ, {"BNB_ROCM_VERSION": "60"}):
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver") as mock_detect:
|
|
with patch.object(
|
|
stack_mod, "_persist_bnb_rocm_version", return_value = True
|
|
) as mock_persist:
|
|
stack_mod._install_bnb_windows_rocm()
|
|
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "60"
|
|
mock_detect.assert_not_called()
|
|
mock_persist.assert_not_called()
|
|
|
|
def test_redetects_when_bnb_rocm_version_came_from_sitecustomize(self):
|
|
"""Persisted defaults should not mask a newer DLL suffix after reinstall."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"BNB_ROCM_VERSION": "72",
|
|
stack_mod._BNB_ROCM_VERSION_SOURCE_ENV: (
|
|
stack_mod._BNB_ROCM_VERSION_SOURCE_SITECUSTOMIZE
|
|
),
|
|
},
|
|
):
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver", return_value = "713"):
|
|
with patch.object(
|
|
stack_mod, "_persist_bnb_rocm_version", return_value = True
|
|
) as mock_persist:
|
|
stack_mod._install_bnb_windows_rocm()
|
|
|
|
assert os.environ.get("BNB_ROCM_VERSION") == "713"
|
|
assert (
|
|
os.environ.get(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV)
|
|
== stack_mod._BNB_ROCM_VERSION_SOURCE_DETECTED
|
|
)
|
|
mock_persist.assert_called_once_with("713")
|
|
|
|
def test_persists_bnb_rocm_version_for_direct_venv_python(self, tmp_path):
|
|
"""BNB_ROCM_VERSION must apply to a fresh Python process in the venv."""
|
|
site_packages = tmp_path / "site-packages"
|
|
|
|
with patch.dict(os.environ, {}, clear = False):
|
|
os.environ.pop("BNB_ROCM_VERSION", None)
|
|
os.environ.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
with patch.object(stack_mod, "pip_install_try", return_value = True):
|
|
with patch.object(stack_mod, "_detect_bnb_rocm_dll_ver", return_value = "72"):
|
|
with patch.object(
|
|
stack_mod.sysconfig, "get_path", return_value = str(site_packages)
|
|
):
|
|
stack_mod._install_bnb_windows_rocm()
|
|
|
|
sitecustomize = site_packages / "sitecustomize.py"
|
|
source = sitecustomize.read_text(encoding = "utf-8")
|
|
assert "BNB_ROCM_VERSION" in source
|
|
assert stack_mod._BNB_ROCM_VERSION_SOURCE_ENV in source
|
|
assert "'72'" in source
|
|
|
|
probe_env = os.environ.copy()
|
|
probe_env.pop("BNB_ROCM_VERSION", None)
|
|
probe_env.pop(stack_mod._BNB_ROCM_VERSION_SOURCE_ENV, None)
|
|
probe_env["PYTHONPATH"] = str(site_packages)
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"import os; "
|
|
"print(os.environ.get('BNB_ROCM_VERSION', ''), "
|
|
"os.environ.get('UNSLOTH_BNB_ROCM_VERSION_SOURCE', ''))"
|
|
),
|
|
],
|
|
env = probe_env,
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.PIPE,
|
|
text = True,
|
|
check = True,
|
|
)
|
|
assert result.stdout.strip() == "72 sitecustomize"
|
|
|
|
def test_persist_bnb_rocm_version_replaces_existing_managed_block(self, tmp_path):
|
|
"""Updating sitecustomize.py must not duplicate the managed BNB block."""
|
|
site_packages = tmp_path / "site-packages"
|
|
site_packages.mkdir()
|
|
sitecustomize = site_packages / "sitecustomize.py"
|
|
sitecustomize.write_text(
|
|
"EXISTING = True\n"
|
|
"# BEGIN Unsloth BNB_ROCM_VERSION\n"
|
|
"import os as _unsloth_os\n"
|
|
"_unsloth_os.environ.setdefault('BNB_ROCM_VERSION', '72')\n"
|
|
"# END Unsloth BNB_ROCM_VERSION\n",
|
|
encoding = "utf-8",
|
|
)
|
|
|
|
with patch.object(stack_mod.sysconfig, "get_path", return_value = str(site_packages)):
|
|
assert stack_mod._persist_bnb_rocm_version("713") is True
|
|
|
|
source = sitecustomize.read_text(encoding = "utf-8")
|
|
assert source.count("# BEGIN Unsloth BNB_ROCM_VERSION") == 1
|
|
assert "EXISTING = True" in source
|
|
assert "'713'" in source
|
|
assert "'72'" not in source
|
|
|
|
def test_persist_bnb_rocm_version_handles_non_utf8_sitecustomize(self, tmp_path):
|
|
"""A legacy non-UTF-8 sitecustomize.py should not abort installation."""
|
|
site_packages = tmp_path / "site-packages"
|
|
site_packages.mkdir()
|
|
sitecustomize = site_packages / "sitecustomize.py"
|
|
sitecustomize.write_bytes(b"\xff\xfe\x00")
|
|
|
|
with patch.object(stack_mod.sysconfig, "get_path", return_value = str(site_packages)):
|
|
assert stack_mod._persist_bnb_rocm_version("72") is False
|
|
|
|
def test_persist_bnb_rocm_version_repairs_truncated_block(self, tmp_path):
|
|
"""A managed block missing its END marker is replaced, not duplicated."""
|
|
site_packages = tmp_path / "site-packages"
|
|
site_packages.mkdir()
|
|
sitecustomize = site_packages / "sitecustomize.py"
|
|
sitecustomize.write_text(
|
|
"EXISTING = True\n"
|
|
"# BEGIN Unsloth BNB_ROCM_VERSION\n"
|
|
"import os as _unsloth_os\n"
|
|
"_unsloth_os.environ.setdefault('BNB_ROCM_VERSION', '72')\n",
|
|
encoding = "utf-8",
|
|
)
|
|
|
|
with patch.object(stack_mod.sysconfig, "get_path", return_value = str(site_packages)):
|
|
assert stack_mod._persist_bnb_rocm_version("713") is True
|
|
|
|
source = sitecustomize.read_text(encoding = "utf-8")
|
|
assert source.count("# BEGIN Unsloth BNB_ROCM_VERSION") == 1
|
|
assert source.count("# END Unsloth BNB_ROCM_VERSION") == 1
|
|
assert "EXISTING = True" in source
|
|
assert "'713'" in source
|
|
assert "'72'" not in source
|
|
|
|
def test_persist_bnb_rocm_version_dedupes_duplicate_blocks(self, tmp_path):
|
|
"""Multiple managed blocks collapse to one while preserving user content."""
|
|
site_packages = tmp_path / "site-packages"
|
|
site_packages.mkdir()
|
|
sitecustomize = site_packages / "sitecustomize.py"
|
|
block = (
|
|
"# BEGIN Unsloth BNB_ROCM_VERSION\n"
|
|
"import os as _unsloth_os\n"
|
|
"_unsloth_os.environ.setdefault('BNB_ROCM_VERSION', '72')\n"
|
|
"# END Unsloth BNB_ROCM_VERSION\n"
|
|
)
|
|
sitecustomize.write_text(block + "USER_MID = 1\n" + block, encoding = "utf-8")
|
|
|
|
with patch.object(stack_mod.sysconfig, "get_path", return_value = str(site_packages)):
|
|
assert stack_mod._persist_bnb_rocm_version("713") is True
|
|
|
|
source = sitecustomize.read_text(encoding = "utf-8")
|
|
assert source.count("# BEGIN Unsloth BNB_ROCM_VERSION") == 1
|
|
assert source.count("# END Unsloth BNB_ROCM_VERSION") == 1
|
|
assert "USER_MID = 1" in source
|
|
assert "'713'" in source
|
|
assert "'72'" not in source
|
|
|
|
def test_persist_bnb_rocm_version_atomic_no_leftover_tmp(self, tmp_path):
|
|
"""The write-then-rename path must not leave its temp file behind."""
|
|
site_packages = tmp_path / "site-packages"
|
|
site_packages.mkdir()
|
|
|
|
with patch.object(stack_mod.sysconfig, "get_path", return_value = str(site_packages)):
|
|
assert stack_mod._persist_bnb_rocm_version("72") is True
|
|
|
|
leftovers = [p.name for p in site_packages.iterdir() if "unsloth-tmp" in p.name]
|
|
assert leftovers == []
|
|
assert (site_packages / "sitecustomize.py").exists()
|
|
|
|
|
|
class TestRuntimeBnbRocmSourceGuards:
|
|
"""Runtime entrypoints redetect managed defaults but keep caller overrides."""
|
|
|
|
_MAIN_PATH = PACKAGE_ROOT / "studio" / "backend" / "main.py"
|
|
_TRAINING_WORKER_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "training" / "worker.py"
|
|
|
|
def test_main_gate_redetects_persisted_default(self):
|
|
source = self._MAIN_PATH.read_text(encoding = "utf-8")
|
|
assert 'os.environ.get("UNSLOTH_BNB_ROCM_VERSION_SOURCE") == "sitecustomize"' in source
|
|
assert 'os.environ["UNSLOTH_BNB_ROCM_VERSION_SOURCE"] = "detected"' in source
|
|
|
|
def test_worker_gate_redetects_persisted_default(self):
|
|
source = self._TRAINING_WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert 'os.environ.get("UNSLOTH_BNB_ROCM_VERSION_SOURCE") == "sitecustomize"' in source
|
|
assert 'os.environ["UNSLOTH_BNB_ROCM_VERSION_SOURCE"] = "detected"' in source
|
|
|
|
def test_fallback_prefers_seeded_value_over_hardcoded_72(self):
|
|
"""A failed redetect must not downgrade a persisted suffix to '72'."""
|
|
for path in (self._MAIN_PATH, self._TRAINING_WORKER_PATH):
|
|
source = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
'_bnb_rocm_ver or os.environ.get("BNB_ROCM_VERSION") or "72"' in source
|
|
), path.name
|
|
|
|
def test_main_requires_found_rocm_dll(self):
|
|
"""HIP_PATH/ROCM_PATH alone (HIP SDK on a CUDA/CPU box) must not force
|
|
a ROCm backend onto a non-ROCm bitsandbytes."""
|
|
source = self._MAIN_PATH.read_text(encoding = "utf-8")
|
|
assert "if _found_rocm_bnb:" in source
|
|
assert "_hip_env" not in source
|
|
|
|
def test_worker_requires_found_rocm_dll(self):
|
|
"""No DLL found: the worker must not write any override or touch the
|
|
seeded marker (later import fixes must still see sitecustomize)."""
|
|
source = self._TRAINING_WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "if _found_rocm_bnb:" in source
|
|
|
|
|
|
class TestDetectBnbRocmDllVer:
|
|
"""Unit tests for _detect_bnb_rocm_dll_ver()."""
|
|
|
|
def test_returns_none_when_bnb_not_installed(self):
|
|
"""Returns None if bitsandbytes is not importable."""
|
|
import importlib.util
|
|
with patch.object(importlib.util, "find_spec", return_value = None):
|
|
assert stack_mod._detect_bnb_rocm_dll_ver() is None
|
|
|
|
def test_detects_rocm72_dll(self, tmp_path):
|
|
"""Returns '72' when libbitsandbytes_rocm72.dll is present."""
|
|
(tmp_path / "libbitsandbytes_rocm72.dll").write_text("")
|
|
mock_spec = MagicMock()
|
|
mock_spec.submodule_search_locations = [str(tmp_path)]
|
|
import importlib.util
|
|
|
|
with patch.object(importlib.util, "find_spec", return_value = mock_spec):
|
|
assert stack_mod._detect_bnb_rocm_dll_ver() == "72"
|
|
|
|
def test_detects_rocm713_dll(self, tmp_path):
|
|
"""Returns '713' when libbitsandbytes_rocm713.dll is present."""
|
|
(tmp_path / "libbitsandbytes_rocm713.dll").write_text("")
|
|
mock_spec = MagicMock()
|
|
mock_spec.submodule_search_locations = [str(tmp_path)]
|
|
import importlib.util
|
|
|
|
with patch.object(importlib.util, "find_spec", return_value = mock_spec):
|
|
assert stack_mod._detect_bnb_rocm_dll_ver() == "713"
|
|
|
|
def test_returns_none_when_only_cuda_dlls(self, tmp_path):
|
|
"""Returns None when only CUDA DLLs are present (no ROCm DLL)."""
|
|
(tmp_path / "libbitsandbytes_cuda121.dll").write_text("")
|
|
mock_spec = MagicMock()
|
|
mock_spec.submodule_search_locations = [str(tmp_path)]
|
|
import importlib.util
|
|
|
|
with patch.object(importlib.util, "find_spec", return_value = mock_spec):
|
|
assert stack_mod._detect_bnb_rocm_dll_ver() is None
|
|
|
|
def test_picks_highest_suffix_when_multiple_dlls(self, tmp_path):
|
|
"""Returns the highest numeric suffix across ROCm DLL variants (glob order is not guaranteed)."""
|
|
(tmp_path / "libbitsandbytes_rocm72.dll").write_text("")
|
|
(tmp_path / "libbitsandbytes_rocm713.dll").write_text("")
|
|
mock_spec = MagicMock()
|
|
mock_spec.submodule_search_locations = [str(tmp_path)]
|
|
import importlib.util
|
|
|
|
with patch.object(importlib.util, "find_spec", return_value = mock_spec):
|
|
assert stack_mod._detect_bnb_rocm_dll_ver() == "713"
|
|
|
|
|
|
# TEST: install_python_stack.py -- UNSLOTH_ROCM_TORCH_INSTALLED early-return path
|
|
|
|
|
|
class TestRocmTorchInstalledEnvVar:
|
|
"""Verify UNSLOTH_ROCM_TORCH_INSTALLED=1 skips main install but still installs BNB."""
|
|
|
|
@staticmethod
|
|
def _ok_torch_probe(*a, **kw):
|
|
# Probe returns 0 when torch imports as ROCm.
|
|
rv = MagicMock()
|
|
rv.returncode = 0
|
|
return rv
|
|
|
|
@patch.object(stack_mod, "_install_bnb_windows_rocm")
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_env_var_skips_main_pip_install(self, mock_pip, mock_bnb):
|
|
"""UNSLOTH_ROCM_TORCH_INSTALLED=1 should not trigger torch pip_install."""
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ROCM_TORCH_INSTALLED": "1"}),
|
|
patch.object(stack_mod.subprocess, "run", side_effect = self._ok_torch_probe),
|
|
):
|
|
stack_mod._ensure_rocm_torch()
|
|
mock_pip.assert_not_called()
|
|
|
|
@patch.object(stack_mod, "_install_bnb_windows_rocm")
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_env_var_calls_bnb_install(self, mock_pip, mock_bnb):
|
|
"""UNSLOTH_ROCM_TORCH_INSTALLED=1 should still call _install_bnb_windows_rocm."""
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ROCM_TORCH_INSTALLED": "1"}),
|
|
patch.object(stack_mod.subprocess, "run", side_effect = self._ok_torch_probe),
|
|
):
|
|
stack_mod._ensure_rocm_torch()
|
|
mock_bnb.assert_called_once()
|
|
|
|
@patch.object(stack_mod, "_install_bnb_windows_rocm")
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_env_var_sets_rocm_windows_flag(self, mock_pip, mock_bnb):
|
|
"""UNSLOTH_ROCM_TORCH_INSTALLED=1 should set _rocm_windows_torch_installed."""
|
|
stack_mod._rocm_windows_torch_installed = False
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ROCM_TORCH_INSTALLED": "1"}),
|
|
patch.object(stack_mod.subprocess, "run", side_effect = self._ok_torch_probe),
|
|
):
|
|
stack_mod._ensure_rocm_torch()
|
|
assert stack_mod._rocm_windows_torch_installed is True
|
|
|
|
@patch.object(stack_mod, "_install_bnb_windows_rocm")
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_env_var_falls_through_when_torch_missing(self, mock_pip, mock_bnb):
|
|
"""If the venv was wiped between runs, the stale env-var must not suppress reinstall."""
|
|
stack_mod._rocm_windows_torch_installed = False
|
|
|
|
def _bad_probe(*a, **kw):
|
|
rv = MagicMock()
|
|
rv.returncode = 1
|
|
return rv
|
|
|
|
with (
|
|
patch.dict(os.environ, {"UNSLOTH_ROCM_TORCH_INSTALLED": "1"}),
|
|
patch.object(stack_mod.subprocess, "run", side_effect = _bad_probe),
|
|
patch.object(stack_mod, "IS_WINDOWS", False),
|
|
patch.object(stack_mod, "IS_MACOS", True),
|
|
):
|
|
stack_mod._ensure_rocm_torch()
|
|
# macOS branch is the next exit; the point is the early-return did NOT fire.
|
|
mock_bnb.assert_not_called()
|
|
|
|
|
|
class TestWindowsRocmTorchaoGuard:
|
|
"""Verify the torchao skip can detect an installed Windows ROCm torch build."""
|
|
|
|
def test_installed_torch_is_windows_rocm_accepts_rocm_probe(self):
|
|
rv = MagicMock()
|
|
rv.returncode = 0
|
|
rv.stdout = "yes"
|
|
with (
|
|
patch.object(stack_mod, "IS_WINDOWS", True),
|
|
patch.object(stack_mod.subprocess, "run", return_value = rv),
|
|
):
|
|
assert stack_mod._installed_torch_is_windows_rocm() is True
|
|
|
|
def test_installed_torch_is_windows_rocm_rejects_non_rocm_probe(self):
|
|
rv = MagicMock()
|
|
rv.returncode = 0
|
|
rv.stdout = ""
|
|
with (
|
|
patch.object(stack_mod, "IS_WINDOWS", True),
|
|
patch.object(stack_mod.subprocess, "run", return_value = rv),
|
|
):
|
|
assert stack_mod._installed_torch_is_windows_rocm() is False
|
|
|
|
def test_installed_torch_is_windows_rocm_is_non_windows_noop(self):
|
|
with patch.object(stack_mod, "IS_WINDOWS", False):
|
|
assert stack_mod._installed_torch_is_windows_rocm() is False
|
|
|
|
@patch.object(stack_mod, "_repair_bad_anyio")
|
|
@patch.object(stack_mod, "_ensure_rocm_torch")
|
|
@patch.object(stack_mod, "_ensure_cuda_torch")
|
|
@patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True)
|
|
@patch.object(stack_mod, "run")
|
|
@patch.object(stack_mod, "pip_install")
|
|
def test_install_python_stack_skips_torchao_when_windows_rocm_torch_is_installed(
|
|
self, mock_pip, mock_run, mock_has_nvidia, mock_cuda, mock_rocm, mock_anyio, tmp_path
|
|
):
|
|
unstructured_plugin = tmp_path / "unstructured"
|
|
github_plugin = tmp_path / "github"
|
|
unstructured_plugin.mkdir()
|
|
github_plugin.mkdir()
|
|
|
|
subprocess_result = MagicMock()
|
|
subprocess_result.returncode = 0
|
|
subprocess_result.stdout = ""
|
|
|
|
with (
|
|
patch.dict(os.environ, {"SKIP_STUDIO_BASE": "1"}),
|
|
patch.object(stack_mod, "IS_WINDOWS", True),
|
|
patch.object(stack_mod, "IS_MACOS", False),
|
|
patch.object(stack_mod, "IS_MAC_ARM", False),
|
|
patch.object(stack_mod, "NO_TORCH", False),
|
|
patch.object(stack_mod, "_rocm_windows_torch_installed", False),
|
|
patch.object(stack_mod, "_bootstrap_uv", return_value = False),
|
|
patch.object(stack_mod, "_installed_torch_is_windows_rocm", return_value = True),
|
|
patch.object(stack_mod, "LOCAL_DD_UNSTRUCTURED_PLUGIN", unstructured_plugin),
|
|
patch.object(stack_mod, "LOCAL_DD_GITHUB_PLUGIN", github_plugin),
|
|
patch.object(stack_mod.subprocess, "run", return_value = subprocess_result),
|
|
):
|
|
assert stack_mod.install_python_stack() == 0
|
|
|
|
installed_specs = [str(arg) for call in mock_pip.call_args_list for arg in call.args]
|
|
assert not any("torchao" in arg for arg in installed_specs)
|
|
|
|
|
|
class TestProgressStepCountMatchesTotal:
|
|
"""The progress bar must reach exactly _TOTAL: every _progress() step is counted in
|
|
base_total. Regression for a repair step added without incrementing base_total,
|
|
which pushed _STEP past _TOTAL (Codex P2)."""
|
|
|
|
def _run_stack(self, tmp_path, *, is_windows, is_macos, is_mac_arm):
|
|
unstructured_plugin = tmp_path / "unstructured"
|
|
github_plugin = tmp_path / "github"
|
|
unstructured_plugin.mkdir()
|
|
github_plugin.mkdir()
|
|
sub = MagicMock()
|
|
sub.returncode = 0
|
|
sub.stdout = ""
|
|
with (
|
|
patch.dict(os.environ, {"SKIP_STUDIO_BASE": "1"}),
|
|
patch.object(stack_mod, "IS_WINDOWS", is_windows),
|
|
patch.object(stack_mod, "IS_MACOS", is_macos),
|
|
patch.object(stack_mod, "IS_MAC_ARM", is_mac_arm),
|
|
patch.object(stack_mod, "NO_TORCH", False),
|
|
patch.object(stack_mod, "_rocm_windows_torch_installed", False),
|
|
patch.object(stack_mod, "_bootstrap_uv", return_value = False),
|
|
patch.object(stack_mod, "_installed_torch_is_windows_rocm", return_value = False),
|
|
patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = True),
|
|
patch.object(stack_mod, "_repair_bad_anyio"),
|
|
patch.object(stack_mod, "_ensure_cuda_torch"),
|
|
patch.object(stack_mod, "_ensure_rocm_torch"),
|
|
patch.object(stack_mod, "_ensure_cpu_torch"),
|
|
patch.object(stack_mod, "LOCAL_DD_UNSTRUCTURED_PLUGIN", unstructured_plugin),
|
|
patch.object(stack_mod, "LOCAL_DD_GITHUB_PLUGIN", github_plugin),
|
|
patch.object(stack_mod.subprocess, "run", return_value = sub),
|
|
):
|
|
assert stack_mod.install_python_stack() == 0
|
|
return stack_mod._STEP, stack_mod._TOTAL
|
|
|
|
def test_windows_progress_reaches_total(self, tmp_path):
|
|
step, total = self._run_stack(tmp_path, is_windows = True, is_macos = False, is_mac_arm = False)
|
|
assert step == total, f"Windows progress {step} != total {total} (final step uncounted)"
|
|
|
|
def test_linux_progress_reaches_total(self, tmp_path):
|
|
step, total = self._run_stack(tmp_path, is_windows = False, is_macos = False, is_mac_arm = False)
|
|
assert step == total, f"Linux progress {step} != total {total}"
|
|
|
|
|
|
# TEST: worker.py -- Windows ROCm patches (source-level checks)
|
|
|
|
|
|
class TestWorkerWindowsRocmPatches:
|
|
"""Verify worker.py contains the required Windows ROCm runtime patches."""
|
|
|
|
def test_grouped_mm_dispatch_patch_present(self):
|
|
"""worker.py must register a _grouped_mm CUDA dispatch override."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert '_gm_lib.impl("_grouped_mm"' in source
|
|
|
|
def test_grouped_mm_patch_targets_cuda_dispatch_key(self):
|
|
"""The dispatch override must target the CUDA key (not CompositeImplicitAutograd)."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert '"_grouped_mm", _grouped_mm_safe_impl, "CUDA"' in source
|
|
|
|
def test_grouped_mm_lib_kept_alive(self):
|
|
"""The Library object must be stored to prevent GC clearing the registration."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "_WINDOWS_ROCM_GROUPED_MM_LIB" in source
|
|
|
|
def test_grouped_mm_handles_offs_grouped_case(self):
|
|
"""_grouped_mm fallback must handle the grouped (offs!=None) variant."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "offs_list" in source
|
|
assert "offs.tolist()" in source
|
|
|
|
def test_worker_calls_shared_torchao_stub(self):
|
|
"""worker.py must invoke the shared torchao stub entrypoint."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "install_torchao_windows_rocm_stub()" in source
|
|
|
|
def test_export_worker_calls_shared_torchao_stub(self):
|
|
"""export/worker.py must invoke the same shared torchao stub entrypoint."""
|
|
source = _EXPORT_WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "install_torchao_windows_rocm_stub()" in source
|
|
|
|
def test_embedder_calls_shared_torchao_stub(self):
|
|
"""embeddings.py must install the stub before importing sentence-transformers:
|
|
it runs in the main process (not a stubbed worker), so otherwise transformers
|
|
-> torchao crashes on Windows ROCm and the embedder drops to llama-server."""
|
|
source = _EMBEDDINGS_PATH.read_text(encoding = "utf-8")
|
|
assert "install_torchao_windows_rocm_stub()" in source
|
|
|
|
def test_torchao_stub_uses_stub_type_meta(self):
|
|
"""Torchao stub must use _StubTypeMeta so isinstance() returns False not TypeError."""
|
|
source = _TORCHAO_STUB_PATH.read_text(encoding = "utf-8")
|
|
assert "_StubTypeMeta" in source
|
|
|
|
def test_stub_type_meta_has_instancecheck(self):
|
|
"""_StubTypeMeta must define __instancecheck__ returning False."""
|
|
source = _TORCHAO_STUB_PATH.read_text(encoding = "utf-8")
|
|
assert "__instancecheck__" in source
|
|
|
|
def test_stub_subpackage_finder_registered(self):
|
|
"""_StubSubpackageFinder must be appended to sys.meta_path."""
|
|
source = _TORCHAO_STUB_PATH.read_text(encoding = "utf-8")
|
|
assert "sys.meta_path.append(_StubSubpackageFinder())" in source
|
|
|
|
def test_torchao_key_submodules_pre_stubbed(self):
|
|
"""Key torchao submodules (dtypes, quantization) must be pre-stubbed."""
|
|
source = _TORCHAO_STUB_PATH.read_text(encoding = "utf-8")
|
|
assert "torchao.dtypes" in source
|
|
assert "torchao.quantization" in source
|
|
|
|
def test_torchdynamo_disabled_on_windows_rocm(self):
|
|
"""worker.py should disable dynamo on Windows ROCm as belt-and-suspenders."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "TORCHDYNAMO_DISABLE" in source
|
|
|
|
def test_bnb_rocm_version_set_on_windows_rocm(self):
|
|
"""worker.py must set BNB_ROCM_VERSION from the detected DLL suffix (BNB's auto-detect can mismatch)."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "BNB_ROCM_VERSION" in source
|
|
assert "_detect_bnb_rocm_dll_ver" in source or "libbitsandbytes_rocm" in source
|
|
# Falls back to the seeded value, never a blind "72".
|
|
assert '_bnb_rocm_ver or os.environ.get("BNB_ROCM_VERSION")' in source
|
|
|
|
def test_bnb_rocm_version_set_before_ml_imports(self):
|
|
"""BNB_ROCM_VERSION must appear in section 1f, before section 2 ML imports."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
idx_bnb = source.find("BNB_ROCM_VERSION")
|
|
# Use the entry-point section-2 marker (not the trainer helper's own "# ── 2.").
|
|
idx_sec2 = source.find("# ── 2. Now import ML libraries")
|
|
assert idx_bnb != -1, "BNB_ROCM_VERSION not found in worker.py"
|
|
assert idx_sec2 != -1, "'# ── 2. Now import ML libraries' marker not found in worker.py"
|
|
assert idx_bnb < idx_sec2, (
|
|
"BNB_ROCM_VERSION must be set before section 2 ML imports "
|
|
f"(found at {idx_bnb}, section 2 at {idx_sec2})"
|
|
)
|
|
|
|
def test_grouped_mm_patch_guarded_by_windows_and_hip_check(self):
|
|
"""_grouped_mm patch must only apply on Windows + HIP torch."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert 'sys.platform == "win32"' in source
|
|
# Gates on HIP version via a getattr chain ("version", "hip").
|
|
assert '"version"' in source and '"hip"' in source
|
|
|
|
def test_hip_ver_at_least_helper_defined(self):
|
|
"""_hip_ver_at_least helper must be defined inside the Windows ROCm block."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "def _hip_ver_at_least(major: int, minor: int)" in source
|
|
|
|
def test_grouped_mm_patch_gated_on_hip_lt_713(self):
|
|
"""_grouped_mm patch must be skipped on HIP >= 7.13 (AMD fixed the bug in ROCm 7.13)."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "_hip_ver_at_least(7, 13)" in source
|
|
# Patch must be inside the negated `if not` guard.
|
|
assert "if not _hip_ver_at_least(7, 13):" in source
|
|
|
|
def test_grouped_mm_hip_713_skip_message_present(self):
|
|
"""worker.py must log a message when skipping the patch on HIP >= 7.13."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP >= 7.13" in source
|
|
assert "7.13" in source
|
|
|
|
def test_grouped_mm_patch_else_branch_present(self):
|
|
"""An else branch must follow the _hip_ver_at_least gate (skip path for 7.13+)."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
gate_idx = source.find("if not _hip_ver_at_least(7, 13):")
|
|
assert gate_idx != -1, "Version gate not found in worker.py"
|
|
else_idx = source.find("else:", gate_idx)
|
|
assert else_idx != -1, "else: branch after _hip_ver_at_least gate not found"
|
|
|
|
def test_hip_ver_at_least_handles_amd_version_format(self):
|
|
"""_hip_ver_at_least must split on '.' and compare only major.minor (handles '7.13.99004')."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert 'split(".")[:2]' in source or ".split('.')[:2]" in source
|
|
|
|
|
|
# TEST: install_python_stack.py -- _ROCM_TORCH_PKG_SPECS mapping
|
|
|
|
|
|
class TestRocmTorchPkgSpecs:
|
|
"""Verify per-tag torch version specs are correct."""
|
|
|
|
def test_rocm72_has_torch_211(self):
|
|
"""rocm7.2 should specify torch 2.11.x."""
|
|
specs = stack_mod._ROCM_TORCH_PKG_SPECS.get("rocm7.2")
|
|
assert specs is not None
|
|
torch_spec = specs[0]
|
|
assert "2.11" in torch_spec
|
|
|
|
def test_default_caps_below_211(self):
|
|
"""Default spec (rocm7.1 and earlier) should cap below 2.11."""
|
|
specs = stack_mod._ROCM_TORCH_PKG_SPECS.get("_default")
|
|
assert specs is not None
|
|
torch_spec = specs[0]
|
|
assert "<2.11" in torch_spec
|
|
|
|
def test_specs_have_torch_vision_audio(self):
|
|
"""Each entry should be a 3-tuple: torch, torchvision, torchaudio."""
|
|
for tag, specs in stack_mod._ROCM_TORCH_PKG_SPECS.items():
|
|
assert len(specs) == 3, f"{tag}: expected (torch, torchvision, torchaudio)"
|
|
assert "torch" in specs[0]
|
|
assert "torchvision" in specs[1]
|
|
assert "torchaudio" in specs[2]
|
|
|
|
def test_gfx_to_amd_index_covers_rdna4(self):
|
|
"""_GFX_TO_AMD_INDEX_ARCH must cover gfx1200 and gfx1201 (RDNA 4)."""
|
|
mapping = stack_mod._GFX_TO_AMD_INDEX_ARCH
|
|
assert mapping.get("gfx1200") == "gfx120X-all"
|
|
assert mapping.get("gfx1201") == "gfx120X-all"
|
|
|
|
def test_gfx_to_amd_index_covers_strix_halo(self):
|
|
"""_GFX_TO_AMD_INDEX_ARCH must cover gfx1151 and gfx1150 (RDNA 3.5)."""
|
|
mapping = stack_mod._GFX_TO_AMD_INDEX_ARCH
|
|
assert mapping.get("gfx1151") == "gfx1151"
|
|
assert mapping.get("gfx1150") == "gfx1150"
|
|
|
|
def test_gfx_to_amd_index_covers_rdna3(self):
|
|
"""_GFX_TO_AMD_INDEX_ARCH must cover gfx1100-gfx1103 (RDNA 3)."""
|
|
mapping = stack_mod._GFX_TO_AMD_INDEX_ARCH
|
|
for arch in ("gfx1100", "gfx1101", "gfx1102", "gfx1103"):
|
|
assert mapping.get(arch) == "gfx110X-all", f"{arch} missing from mapping"
|
|
|
|
|
|
# TEST: setup.ps1 / install.ps1 -- Strix Halo gfx arch detection
|
|
|
|
_SETUP_PS1_PATH = PACKAGE_ROOT / "studio" / "setup.ps1"
|
|
_INSTALL_PS1_PATH = PACKAGE_ROOT / "install.ps1"
|
|
|
|
|
|
class TestStrixHaloGfxArchDetection:
|
|
"""setup.ps1 / install.ps1 gfx arch detection for Strix Halo / iGPU (HIP runtime only, no hipinfo)."""
|
|
|
|
def test_amd_smi_static_asic_attempted_in_setup(self):
|
|
"""setup.ps1 must try 'amd-smi static --asic' when list output lacks gfx arch."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "static --asic" in source
|
|
|
|
def test_amd_smi_static_asic_attempted_in_install(self):
|
|
"""install.ps1 must try 'amd-smi static --asic' when list output lacks gfx arch."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "static --asic" in source
|
|
|
|
def test_env_var_override_in_setup(self):
|
|
"""setup.ps1 must honour UNSLOTH_ROCM_GFX_ARCH as a manual arch override."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "UNSLOTH_ROCM_GFX_ARCH" in source
|
|
|
|
def test_env_var_override_in_install(self):
|
|
"""install.ps1 must honour UNSLOTH_ROCM_GFX_ARCH as a manual arch override."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "UNSLOTH_ROCM_GFX_ARCH" in source
|
|
|
|
def test_name_arch_table_covers_strix_halo_in_setup(self):
|
|
"""setup.ps1 name→arch table must map 890M / Strix Halo to gfx1151."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1151" in source
|
|
assert "890M" in source or "Strix Halo" in source
|
|
|
|
def test_name_arch_table_covers_strix_halo_in_install(self):
|
|
"""install.ps1 name→arch table must map 890M / Strix Halo to gfx1151."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1151" in source
|
|
assert "890M" in source or "Strix Halo" in source
|
|
|
|
def test_name_arch_table_covers_strix_point_in_setup(self):
|
|
"""setup.ps1 name→arch table must map 880M / Strix Point to gfx1150."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1150" in source
|
|
assert "880M" in source or "Strix Point" in source
|
|
|
|
def test_name_arch_table_covers_strix_point_in_install(self):
|
|
"""install.ps1 name→arch table must map 880M / Strix Point to gfx1150."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1150" in source
|
|
assert "880M" in source or "Strix Point" in source
|
|
|
|
def test_name_arch_table_covers_rdna3_phoenix_in_setup(self):
|
|
"""setup.ps1 name→arch table must map 780M / Phoenix to gfx1103."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1103" in source
|
|
assert "780M" in source or "Phoenix" in source
|
|
|
|
def test_wmi_does_not_set_hasrocm_in_setup(self):
|
|
"""WMI block in setup.ps1 must NOT set $HasROCm = $true (no runtime confirmation)."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
wmi_idx = source.find("Win32_VideoController")
|
|
assert wmi_idx != -1, "WMI block not found in setup.ps1"
|
|
# $HasROCm = $true must not appear within 300 chars of the WMI call.
|
|
wmi_context = source[wmi_idx : wmi_idx + 300]
|
|
assert "$HasROCm = $true" not in wmi_context
|
|
|
|
def test_gfx_arch_regex_parses_from_amd_smi_output(self):
|
|
"""Both files must use the gfx\\d+[a-z]? regex to parse arch from amd-smi output."""
|
|
for path in (_SETUP_PS1_PATH, _INSTALL_PS1_PATH):
|
|
source = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"gfx\\d+" in source or r"gfx\d+" in source
|
|
), f"gfx arch regex not found in {path.name}"
|
|
|
|
|
|
# TEST: HIP SDK tool path resolution via HIP_PATH / ROCM_PATH env vars
|
|
|
|
|
|
class TestHipSdkEnvPathResolution:
|
|
"""Both install scripts resolve hipinfo/hipconfig via HIP_PATH/ROCM_PATH off $PATH, and warn."""
|
|
|
|
@staticmethod
|
|
def _assert_accepts_partial_hipinfo_output(source: str):
|
|
hipout_idx = source.find("$hipOut = & $hipinfoExe.Source")
|
|
assert hipout_idx != -1
|
|
hipinfo_block = source[hipout_idx : hipout_idx + 1600]
|
|
assert 'if ($hipOut -match "(?i)gcnArchName")' in hipinfo_block
|
|
assert "$LASTEXITCODE -eq 0 -and $hipOut -match" not in hipinfo_block
|
|
assert "but reported gcnArchName" in hipinfo_block
|
|
|
|
# ── hipinfo resolution ────────────────────────────────────────────────────
|
|
|
|
def test_setup_checks_hip_path_for_hipinfo(self):
|
|
"""setup.ps1 must reference HIP_PATH when resolving hipinfo."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP_PATH" in source
|
|
assert "hipinfo" in source
|
|
|
|
def test_install_checks_hip_path_for_hipinfo(self):
|
|
"""install.ps1 must reference HIP_PATH when resolving hipinfo."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP_PATH" in source
|
|
assert "hipinfo" in source
|
|
|
|
def test_setup_checks_rocm_path_as_hipinfo_fallback(self):
|
|
"""setup.ps1 must also check ROCM_PATH as a secondary hipinfo fallback."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCM_PATH" in source
|
|
assert "ROCM_PATH" in source and "HIP_PATH" in source
|
|
|
|
def test_install_checks_rocm_path_as_hipinfo_fallback(self):
|
|
"""install.ps1 must also check ROCM_PATH as a secondary hipinfo fallback."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCM_PATH" in source
|
|
assert "ROCM_PATH" in source and "HIP_PATH" in source
|
|
|
|
def test_setup_resolves_hipinfo_via_bin_subdir(self):
|
|
"""setup.ps1 must join the env var root with 'bin\\hipinfo.exe'."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert r"bin\hipinfo.exe" in source
|
|
|
|
def test_install_resolves_hipinfo_via_bin_subdir(self):
|
|
"""install.ps1 must join the env var root with 'bin\\hipinfo.exe'."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert r"bin\hipinfo.exe" in source
|
|
|
|
# ── hipinfo not-on-PATH warning ───────────────────────────────────────────
|
|
|
|
def test_setup_warns_when_hipinfo_not_on_path(self):
|
|
"""setup.ps1 must warn when hipinfo is found via env var but not on PATH."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "hipinfo not on PATH" in source
|
|
|
|
def test_install_warns_when_hipinfo_not_on_path(self):
|
|
"""install.ps1 must warn when hipinfo is found via env var but not on PATH."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "hipinfo not on PATH" in source
|
|
|
|
# ── warn when HIP_PATH set but exe missing ────────────────────────────────
|
|
|
|
def test_setup_warns_when_hip_path_set_but_exe_missing(self):
|
|
"""setup.ps1 must warn when HIP_PATH is set but hipinfo.exe is not present."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "incomplete" in source or "not found at" in source
|
|
|
|
def test_install_warns_when_hip_path_set_but_exe_missing(self):
|
|
"""install.ps1 must warn when HIP_PATH is set but hipinfo.exe is not present."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "incomplete" in source or "not found at" in source
|
|
|
|
# ── hipinfo runtime error warning ─────────────────────────────────────────
|
|
|
|
def test_setup_warns_on_hipinfo_nonzero_exit(self):
|
|
"""setup.ps1 must warn when hipinfo runs but returns a non-zero exit code."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP runtime error" in source or "runtime error" in source.lower()
|
|
|
|
def test_install_warns_on_hipinfo_nonzero_exit(self):
|
|
"""install.ps1 must warn when hipinfo runs but returns a non-zero exit code."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP runtime error" in source or "runtime error" in source.lower()
|
|
|
|
def test_setup_accepts_hipinfo_gcnarchname_on_nonzero_exit(self):
|
|
"""setup.ps1 must accept partial hipinfo output from the #6043 crash path."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
self._assert_accepts_partial_hipinfo_output(source)
|
|
|
|
def test_install_accepts_hipinfo_gcnarchname_on_nonzero_exit(self):
|
|
"""install.ps1 must accept partial hipinfo output from the #6043 crash path."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
self._assert_accepts_partial_hipinfo_output(source)
|
|
|
|
# ── hipconfig resolution ──────────────────────────────────────────────────
|
|
|
|
def test_setup_resolves_hipconfig_via_bin_subdir(self):
|
|
"""setup.ps1 must also fall back to HIP_PATH/bin/hipconfig.exe for version detection."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert r"bin\hipconfig.exe" in source
|
|
|
|
def test_install_resolves_hipconfig_via_bin_subdir(self):
|
|
"""install.ps1 must also fall back to HIP_PATH/bin/hipconfig.exe for version detection."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert r"bin\hipconfig.exe" in source
|
|
|
|
def test_setup_warns_when_hipconfig_not_on_path(self):
|
|
"""setup.ps1 must warn when hipconfig is found via env var but not on PATH."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "hipconfig not on PATH" in source
|
|
|
|
def test_install_warns_when_hipconfig_not_on_path(self):
|
|
"""install.ps1 must warn when hipconfig is found via env var but not on PATH."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "hipconfig not on PATH" in source
|
|
|
|
# ── PATH fix hint ─────────────────────────────────────────────────────────
|
|
|
|
def test_setup_provides_path_fix_hint(self):
|
|
"""setup.ps1 must tell the user how to add the HIP bin dir to PATH."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "PATH" in source and ("SetEnvironmentVariable" in source or "Add" in source)
|
|
|
|
def test_install_provides_path_fix_hint(self):
|
|
"""install.ps1 must tell the user how to add the HIP bin dir to PATH."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "PATH" in source and ("SetEnvironmentVariable" in source or "Add" in source)
|
|
|
|
|
|
# TEST: HIP SDK detected substep -- path + hipconfig version shown in terminal
|
|
|
|
|
|
class TestHipSdkDetectedSubstep:
|
|
"""Both scripts print HIP SDK path and full hipconfig version as substeps when ROCm is detected."""
|
|
|
|
def test_setup_prints_hip_sdk_path_substep(self):
|
|
"""setup.ps1 must print an 'HIP SDK:' substep showing the resolved path."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP SDK:" in source
|
|
|
|
def test_install_prints_hip_sdk_path_substep(self):
|
|
"""install.ps1 must print an 'HIP SDK:' substep showing the resolved path."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP SDK:" in source
|
|
|
|
def test_setup_shows_hipconfig_full_version(self):
|
|
"""setup.ps1 must capture and display the full hipconfig version string."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCmVersionFull" in source or "hipconfig:" in source
|
|
|
|
def test_install_shows_hipconfig_full_version(self):
|
|
"""install.ps1 must capture and display the full hipconfig version string."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCmVersionFull" in source or "hipconfig:" in source
|
|
|
|
def test_setup_captures_full_version_not_just_major_minor(self):
|
|
"""setup.ps1 must store the raw hipconfig output line, not just major.minor."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCmVersionFull" in source
|
|
|
|
def test_install_captures_full_version_not_just_major_minor(self):
|
|
"""install.ps1 must store the raw hipconfig output line, not just major.minor."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCmVersionFull" in source
|
|
|
|
def test_setup_uses_hip_path_or_rocm_path_for_sdk_display(self):
|
|
"""setup.ps1 HIP SDK path substep must check HIP_PATH then ROCM_PATH."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP_PATH" in source and "ROCM_PATH" in source
|
|
|
|
def test_install_uses_hip_path_or_rocm_path_for_sdk_display(self):
|
|
"""install.ps1 HIP SDK path substep must check HIP_PATH then ROCM_PATH."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HIP_PATH" in source and "ROCM_PATH" in source
|
|
|
|
def test_setup_rocm_step_uses_full_version(self):
|
|
"""setup.ps1 'rocm' step label must prefer the full version string."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "ROCmVersionFull" in source and "rocm" in source
|
|
|
|
|
|
# TEST: install.sh -- Strix Halo rocm7.1 → rocm7.2 override
|
|
|
|
_INSTALL_SH_PATH = PACKAGE_ROOT / "install.sh"
|
|
_SETUP_SH_PATH = PACKAGE_ROOT / "studio" / "setup.sh"
|
|
|
|
|
|
class TestStrixRocm71Override:
|
|
"""install.sh routes gfx1151/gfx1150 to AMD's arch index instead of ROCm 7.1 (_grouped_mm segfault)."""
|
|
|
|
def test_strix_gfx_detection_in_install_sh(self):
|
|
"""install.sh must detect gfx1151 and gfx1150 for the override."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "gfx1151" in source and "gfx1150" in source
|
|
|
|
def test_rocm71_override_to_amd_arch_index_in_install_sh(self):
|
|
"""install.sh must override TORCH_INDEX_URL to AMD arch-specific index for Strix."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "repo.amd.com/rocm/whl" in source
|
|
assert "_strix_gfx" in source
|
|
# URL must incorporate the detected gfx arch (gfx1151 -> .../gfx1151/).
|
|
strix_idx = source.find("_amd_strix_base")
|
|
assert strix_idx != -1
|
|
ctx = source[strix_idx : strix_idx + 500]
|
|
assert "_strix_gfx" in ctx
|
|
|
|
def test_radeon_repo_bypassed_for_strix_in_install_sh(self):
|
|
"""install.sh must set _amd_gpu_radeon=false when Strix + ROCm 7.1 detected."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "_amd_gpu_radeon=false" in source
|
|
|
|
def test_strix_override_warns_with_moe_utils_reference(self):
|
|
"""install.sh must emit a [WARN] mentioning the moe_utils segfault."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "moe_utils" in source or "_grouped_mm" in source
|
|
|
|
def test_strix_override_only_fires_on_rocm71(self):
|
|
"""install.sh must scope the Strix override to rocm7.1 only (not rocm7.2+)."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
strix_idx = source.find("_strix_gfx")
|
|
assert strix_idx != -1
|
|
context_before = source[max(0, strix_idx - 2400) : strix_idx]
|
|
assert "rocm7.1" in context_before
|
|
|
|
def test_torch_constraint_updated_for_strix_amd_index(self):
|
|
"""install.sh must set TORCH_CONSTRAINT>=2.11 when routing Strix to AMD index."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "TORCH_CONSTRAINT" in source and "2.11" in source
|
|
|
|
def test_torch_constraint_211_matches_leaf_not_whole_url(self):
|
|
"""The 2.11 constraint case must match the index LEAF, not the whole URL.
|
|
|
|
A custom UNSLOTH_PYTORCH_MIRROR whose base path contains a gfx/rocm7.2
|
|
segment (e.g. https://mirror.local/gfx-cache) with a cu*/cpu family must
|
|
not be pushed to the torch 2.11 line -- same leaf-only reasoning the
|
|
UNSLOTH_TORCH_BACKEND classification uses.
|
|
"""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
# The 2.11 constraint block must switch on $_torch_index_leaf, not the full
|
|
# $TORCH_INDEX_URL (a */gfx* match false-positives on a mirror base path). Only the
|
|
# _grouped_mm-bug gfx families (gfx120X-all / gfx1151 / gfx1150) are pushed to 2.11;
|
|
# a bare gfx* would also floor gfx110X-all/gfx90a/gfx908, left bare on purpose.
|
|
assert 'case "$_torch_index_leaf" in\n rocm7.2|gfx120x-all|gfx1151|gfx1150)' in source, (
|
|
"the torch>=2.11 constraint must match the specific gfx leaves that need "
|
|
"it (rocm7.2|gfx120x-all|gfx1151|gfx1150), not a bare gfx* or the whole URL"
|
|
)
|
|
|
|
def test_amd_rocm_mirror_env_var_respected(self):
|
|
"""install.sh must honour UNSLOTH_AMD_ROCM_MIRROR for air-gapped installs."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "UNSLOTH_AMD_ROCM_MIRROR" in source
|
|
|
|
def test_tauri_family_recognises_amd_arch_url(self):
|
|
"""_tauri_torch_index_family must return a rocm* family for AMD arch-specific URLs."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "rocm/whl/gfx" in source
|
|
|
|
|
|
# TEST: setup.sh -- gcc-install-dir fix for Ubuntu 24.04 + ROCm 7.x clang-20
|
|
|
|
|
|
class TestSetupShGccInstallDir:
|
|
"""setup.sh applies --gcc-install-dir for HIP builds on Ubuntu 24.04+ (ROCm 7.x clang-20 header bug)."""
|
|
|
|
def test_gcc_install_dir_search_loop_present(self):
|
|
"""setup.sh must iterate gcc versions 14→11 to find one with C++ headers."""
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "_GCC_INSTALL_DIR" in source
|
|
assert "/usr/lib/gcc/x86_64-linux-gnu" in source
|
|
|
|
def test_gcc_install_dir_checks_include_dir(self):
|
|
"""setup.sh must check that the gcc dir has an 'include' subdirectory."""
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "include" in source and "_GCC_INSTALL_DIR" in source
|
|
|
|
def test_gcc_install_dir_appended_to_cmake_hip_flags(self):
|
|
"""setup.sh must pass --gcc-install-dir via CMAKE_HIP_FLAGS."""
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "CMAKE_HIP_FLAGS" in source
|
|
assert "gcc-install-dir" in source
|
|
|
|
def test_gcc_install_dir_only_applied_in_hip_build_block(self):
|
|
"""The --gcc-install-dir fix must only apply in the HIP/ROCm build branch."""
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
hip_idx = source.find("GGML_HIP=ON")
|
|
gcc_idx = source.find("gcc-install-dir")
|
|
assert hip_idx != -1 and gcc_idx != -1
|
|
assert hip_idx < gcc_idx
|
|
|
|
def test_gcc_install_dir_logs_substep(self):
|
|
"""setup.sh must print a substep when the gcc install dir is resolved."""
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "gcc install dir" in source or "GCC_INSTALL_DIR" in source
|
|
|
|
|
|
# TEST: main.py -- BNB_ROCM_VERSION server startup + distributed stubs
|
|
|
|
_MAIN_PY_PATH = PACKAGE_ROOT / "studio" / "backend" / "main.py"
|
|
_HARDWARE_PY_PATH = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "hardware.py"
|
|
|
|
|
|
class TestServerStartupRocmFixes:
|
|
"""main.py sets BNB_ROCM_VERSION pre-bnb-import; hardware.py stubs _distributed_c10d pre-torch.distributed."""
|
|
|
|
# ── BNB_ROCM_VERSION in server process ────────────────────────────────────
|
|
|
|
def test_main_py_sets_bnb_rocm_version(self):
|
|
"""main.py must set BNB_ROCM_VERSION in the server process before imports."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "BNB_ROCM_VERSION" in source
|
|
|
|
def test_main_py_bnb_detection_scoped_to_win32(self):
|
|
"""main.py BNB_ROCM_VERSION logic must be inside the win32 platform guard."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
win32_idx = source.find('sys.platform == "win32"')
|
|
bnb_idx = source.find("BNB_ROCM_VERSION")
|
|
assert win32_idx != -1 and bnb_idx != -1
|
|
assert win32_idx < bnb_idx
|
|
|
|
def test_main_py_bnb_dll_detection_uses_glob(self):
|
|
"""main.py must scan for libbitsandbytes_rocm*.dll to find the right version."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "libbitsandbytes_rocm" in source
|
|
|
|
def test_main_py_bnb_falls_back_to_72(self):
|
|
"""main.py must fall back to BNB_ROCM_VERSION='72' when no DLL is found."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert '"72"' in source or "'72'" in source
|
|
|
|
def test_main_py_bnb_only_set_when_not_already_in_env(self):
|
|
"""main.py must not override an existing BNB_ROCM_VERSION env var."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert '"BNB_ROCM_VERSION" not in os.environ' in source
|
|
|
|
# ── hipInfo.exe PATH prepend (bitsandbytes arch-probe fix) ────────────────
|
|
# bnb's get_rocm_gpu_arch() runs hipinfo.exe via PATH at import; the AMD wheel ships it
|
|
# in venv Scripts (on PATH only for activated venvs), so without the prepend bnb logs
|
|
# "[WinError 2]" when launched directly.
|
|
|
|
def test_main_py_prepends_hipinfo_dir_to_path(self):
|
|
"""main.py must make hipInfo.exe resolvable before bnb imports."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "hipInfo.exe" in source
|
|
# Prepend must precede the BNB_ROCM_VERSION block so bnb sees the fixed PATH.
|
|
assert source.find("hipInfo.exe") < source.find("BNB_ROCM_VERSION")
|
|
|
|
def test_main_py_hipinfo_prepend_gated_on_file_presence(self):
|
|
"""Prepend must check hipInfo.exe exists first (only AMD wheels ship it; leave NVIDIA/CPU untouched)."""
|
|
source = _MAIN_PY_PATH.read_text(encoding = "utf-8")
|
|
assert 'os.path.isfile(os.path.join(_scripts_dir, "hipInfo.exe"))' in source
|
|
|
|
def test_worker_py_prepends_hipinfo_dir_to_path(self):
|
|
"""worker.py must mirror the prepend for standalone-spawned workers."""
|
|
source = _WORKER_PATH.read_text(encoding = "utf-8")
|
|
assert "hipInfo.exe" in source
|
|
|
|
def test_install_stack_prepends_hipinfo_dir_to_path(self):
|
|
"""install_python_stack.py must prepend so child import checks inherit a PATH where bnb's probe works."""
|
|
source = _STACK_PATH.read_text(encoding = "utf-8")
|
|
assert "hipInfo.exe" in source
|
|
|
|
# ── torch._C._distributed_c10d stubs in hardware.py ──────────────────────
|
|
|
|
def test_hardware_py_injects_distributed_c10d_stub(self):
|
|
"""hardware.py must inject torch._C._distributed_c10d into sys.modules."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "_distributed_c10d" in source
|
|
|
|
def test_hardware_py_stub_injected_before_distributed_import(self):
|
|
"""The sys.modules stub must be injected BEFORE import torch.distributed."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
c10d_idx = source.find("_distributed_c10d")
|
|
dist_idx = source.find("import torch.distributed")
|
|
assert c10d_idx != -1 and dist_idx != -1
|
|
assert c10d_idx < dist_idx
|
|
|
|
def test_hardware_py_stub_uses_types_moduletype(self):
|
|
"""hardware.py must create the stub with types.ModuleType."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "ModuleType" in source
|
|
|
|
def test_hardware_py_stub_scoped_to_win32(self):
|
|
"""hardware.py distributed stub injection must be gated on win32."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert 'platform == "win32"' in source or "win32" in source
|
|
|
|
def test_hardware_py_stub_exposes_fake_process_group(self):
|
|
"""hardware.py stub must set FakeProcessGroup so torch.distributed doesn't raise AttributeError."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "FakeProcessGroup" in source
|
|
|
|
def test_hardware_py_stub_exposes_process_group(self):
|
|
"""hardware.py stub must set ProcessGroup on the c10d stub."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "ProcessGroup" in source
|
|
|
|
def test_hardware_py_stub_uses_setattr_for_symbols(self):
|
|
"""hardware.py must use setattr to populate stub symbols dynamically."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "setattr" in source
|
|
|
|
def test_hardware_py_stub_all_c10d_siblings_covered(self):
|
|
"""hardware.py must stub all three torch._C._distributed_* submodules."""
|
|
source = _HARDWARE_PY_PATH.read_text(encoding = "utf-8")
|
|
assert "_distributed_c10d" in source
|
|
assert "_distributed_autograd" in source
|
|
assert "_distributed_rpc" in source
|
|
|
|
|
|
# TEST: install.ps1 / setup.ps1 -- HipSdkInstalled flag (SDK found, device inaccessible)
|
|
|
|
|
|
class TestHipSdkInstalledButDeviceInaccessible:
|
|
"""When hipinfo is found but exits non-zero, both scripts distinguish device-inaccessible from SDK-not-found."""
|
|
|
|
def test_install_ps1_has_hip_sdk_installed_flag(self):
|
|
"""install.ps1 must track HipSdkInstalled separately from HasROCm."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HipSdkInstalled" in source
|
|
|
|
def test_setup_ps1_has_hip_sdk_installed_flag(self):
|
|
"""setup.ps1 must track HipSdkInstalled separately from HasROCm."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HipSdkInstalled" in source
|
|
|
|
def test_install_ps1_sets_flag_when_hipinfo_binary_found(self):
|
|
"""install.ps1 must set HipSdkInstalled=true inside the 'if ($hipinfoExe)' block."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
hipinfo_block_idx = source.find("if ($hipinfoExe)")
|
|
sdk_flag_idx = source.find("$HipSdkInstalled = $true", hipinfo_block_idx)
|
|
assert hipinfo_block_idx != -1 and sdk_flag_idx != -1
|
|
assert sdk_flag_idx > hipinfo_block_idx
|
|
|
|
def test_setup_ps1_sets_flag_when_hipinfo_binary_found(self):
|
|
"""setup.ps1 must set HipSdkInstalled=true inside the 'if ($hipinfoExe)' block."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
hipinfo_block_idx = source.find("if ($hipinfoExe)")
|
|
sdk_flag_idx = source.find("$HipSdkInstalled = $true", hipinfo_block_idx)
|
|
assert hipinfo_block_idx != -1 and sdk_flag_idx != -1
|
|
assert sdk_flag_idx > hipinfo_block_idx
|
|
|
|
def test_install_ps1_version_capture_runs_when_sdk_installed(self):
|
|
"""install.ps1 must capture hipconfig version when HipSdkInstalled even if HasROCm is false."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HasROCm -or $HipSdkInstalled" in source or "$HipSdkInstalled" in source
|
|
|
|
def test_setup_ps1_version_capture_runs_when_sdk_installed(self):
|
|
"""setup.ps1 must capture hipconfig version when HipSdkInstalled even if HasROCm is false."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "HasROCm -or $HipSdkInstalled" in source or "$HipSdkInstalled" in source
|
|
|
|
def test_install_ps1_distinct_message_for_sdk_found_but_device_inaccessible(self):
|
|
"""install.ps1 must show 'not ROCm-accessible' message (not 'HIP SDK not found') when SDK present."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "not ROCm-accessible" in source
|
|
|
|
def test_setup_ps1_distinct_message_for_sdk_found_but_device_inaccessible(self):
|
|
"""setup.ps1 must show 'not ROCm-accessible' message (not 'HIP SDK not found') when SDK present."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "not ROCm-accessible" in source
|
|
|
|
def test_install_ps1_driver_guidance_in_sdk_found_branch(self):
|
|
"""install.ps1 must tell user this is a driver issue, not an SDK issue."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "driver issue" in source
|
|
|
|
def test_setup_ps1_driver_guidance_in_sdk_found_branch(self):
|
|
"""setup.ps1 must tell user this is a driver issue, not an SDK issue."""
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "driver issue" in source
|
|
|
|
def test_install_ps1_cpu_hint_distinguishes_driver_vs_no_sdk(self):
|
|
"""install.ps1 CPU-only hint must say 'GPU not ROCm-accessible' not 'require the HIP SDK' when SDK found."""
|
|
source = _INSTALL_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "GPU not ROCm-accessible" in source
|
|
|
|
|
|
# TEST: --rocm-gfx forwarding -- setup.sh/setup.ps1 forward their resolved gfx
|
|
# arch to install_llama_prebuilt.py so the per-gfx prebuilt is picked.
|
|
|
|
_SETUP_SH_PATH = PACKAGE_ROOT / "studio" / "setup.sh"
|
|
|
|
|
|
class TestNormalizeForwardedGfx:
|
|
"""A forwarded gfx string is reduced to a single clean gfx token."""
|
|
|
|
def test_plain_token(self):
|
|
assert _normalize_forwarded_gfx("gfx1151") == "gfx1151"
|
|
|
|
def test_uppercase_normalized(self):
|
|
assert _normalize_forwarded_gfx("GFX1151") == "gfx1151"
|
|
|
|
def test_extracts_from_noise(self):
|
|
assert _normalize_forwarded_gfx("gcnArchName: gfx942") == "gfx942"
|
|
|
|
def test_malformed_is_ignored(self):
|
|
assert _normalize_forwarded_gfx("not-a-gpu") is None
|
|
|
|
def test_empty_and_none(self):
|
|
assert _normalize_forwarded_gfx("") is None
|
|
assert _normalize_forwarded_gfx(None) is None
|
|
|
|
|
|
class TestApplyHostOverrides:
|
|
"""Forwarded ROCm detection is folded into the host profile correctly."""
|
|
|
|
def test_forwarded_gfx_fills_empty_probe(self):
|
|
# Installer probe found no gfx (amd-smi-only / name-inferred host).
|
|
host = rocm_host(rocm_gfx_target = None)
|
|
out = _apply_host_overrides(host, override_rocm_gfx = "gfx1151")
|
|
assert out.has_rocm is True
|
|
assert out.rocm_gfx_target == "gfx1151"
|
|
|
|
def test_forwarded_gfx_implies_rocm(self):
|
|
# A CPU-looking host with a forwarded gfx is an AMD host.
|
|
out = _apply_host_overrides(cpu_host(), override_rocm_gfx = "gfx1200")
|
|
assert out.has_rocm is True
|
|
assert out.rocm_gfx_target == "gfx1200"
|
|
|
|
def test_forwarded_gfx_is_authoritative(self):
|
|
# setup already applied visible-device selection; its value wins.
|
|
host = rocm_host(rocm_gfx_target = "gfx1100")
|
|
out = _apply_host_overrides(host, override_rocm_gfx = "gfx1151")
|
|
assert out.rocm_gfx_target == "gfx1151"
|
|
|
|
def test_has_rocm_only_keeps_probe_gfx(self):
|
|
out = _apply_host_overrides(cpu_host(), override_has_rocm = True)
|
|
assert out.has_rocm is True
|
|
assert out.rocm_gfx_target is None
|
|
|
|
def test_malformed_forwarded_gfx_falls_back_to_has_rocm(self):
|
|
out = _apply_host_overrides(cpu_host(), override_has_rocm = True, override_rocm_gfx = "junk")
|
|
assert out.has_rocm is True
|
|
assert out.rocm_gfx_target is None
|
|
|
|
def test_no_overrides_leaves_host_unchanged(self):
|
|
host = nvidia_host()
|
|
assert _apply_host_overrides(host) is host
|
|
|
|
|
|
class TestRocmGfxForwarding:
|
|
"""setup.sh / setup.ps1 forward their resolved gfx; the installer accepts it."""
|
|
|
|
def test_installer_exposes_rocm_gfx_arg(self):
|
|
source = _PREBUILT_PATH.read_text(encoding = "utf-8")
|
|
assert '"--rocm-gfx"' in source
|
|
# Defaults to the env override for standalone runs.
|
|
assert 'os.environ.get("UNSLOTH_ROCM_GFX_ARCH")' in source
|
|
|
|
def test_setup_sh_forwards_rocm_gfx(self):
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "--rocm-gfx" in source
|
|
assert '"$_setup_gfx"' in source
|
|
|
|
def test_setup_sh_forwards_has_rocm(self):
|
|
# If AMD is detected but gfx resolution fails, --has-rocm is still forwarded.
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "--has-rocm" in source
|
|
assert "_setup_amd_detected" in source
|
|
|
|
def test_setup_ps1_forwards_rocm_gfx(self):
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert "--rocm-gfx" in source
|
|
assert "$script:ROCmGfxArch" in source
|
|
|
|
def test_setup_sh_routes_unconditionally_to_fork(self):
|
|
# CPU-only hosts no longer fall back to ggml-org -- the release-repo
|
|
# decision is an unconditional fork assignment now. Pin the line text.
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
assert '_HELPER_RELEASE_REPO="unslothai/llama.cpp"' in source
|
|
assert '_HELPER_RELEASE_REPO="ggml-org/llama.cpp"' not in source
|
|
|
|
def test_setup_ps1_routes_unconditionally_to_fork(self):
|
|
# Same on Windows: the fork now ships the windows-cpu / windows-arm64
|
|
# bundles, so $HelperReleaseRepo is an unconditional fork assignment.
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
assert '$HelperReleaseRepo = "unslothai/llama.cpp"' in source
|
|
assert "$HelperReleaseRepo = if (" not in source
|
|
|
|
# The text pins above guard the literal. The tests below execute the real routing line
|
|
# from setup.sh / setup.ps1 and assert the resolved release repo, so a refactor that
|
|
# reintroduces a conditional (or a ggml-org branch) is still caught. Inputs vary
|
|
# (CPU-only, inferred/forwarded gfx, usable NVIDIA) to prove no host hits ggml-org.
|
|
|
|
@staticmethod
|
|
def _resolve_setup_sh_repo(
|
|
host_machine,
|
|
nvidia_usable,
|
|
setup_gfx,
|
|
rocm_gfx_arch_env = "",
|
|
):
|
|
"""Run setup.sh's release-repo routing block under bash and return the
|
|
resolved _HELPER_RELEASE_REPO. PATH is emptied so any stray tooling probe
|
|
misses; routing is unconditional, so the GPU inputs only prove no branch
|
|
reroutes a host to ggml-org."""
|
|
import shutil
|
|
|
|
bash = shutil.which("bash")
|
|
if bash is None:
|
|
pytest.skip("bash not available")
|
|
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
|
|
start = source.index('\n_HELPER_RELEASE_REPO="unslothai/llama.cpp"\n') + 1
|
|
end = source.index("\n_LLAMA_PR=", start)
|
|
block = source[start:end]
|
|
assert "_HELPER_RELEASE_REPO" in block, "setup.sh routing anchors not found"
|
|
env = {
|
|
"PATH": "", # no ROCm tooling discoverable
|
|
"ROUTING_BLOCK": block,
|
|
"_HOST_SYSTEM": "Linux",
|
|
"_HOST_MACHINE": host_machine,
|
|
"_setup_nvidia_usable": "true" if nvidia_usable else "false",
|
|
"_setup_gfx": setup_gfx,
|
|
"UNSLOTH_ROCM_GFX_ARCH": rocm_gfx_arch_env,
|
|
}
|
|
result = subprocess.run(
|
|
[bash, "-c", 'eval "$ROUTING_BLOCK"; printf "%s" "$_HELPER_RELEASE_REPO"'],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
env = env,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout.strip()
|
|
|
|
@pytest.mark.parametrize(
|
|
"machine, nvidia_usable, setup_gfx, env_gfx",
|
|
[
|
|
("x86_64", False, "", ""), # plain CPU host (used to take ggml-org)
|
|
("aarch64", False, "", ""), # plain CPU arm64 host (used to take ggml-org)
|
|
("x86_64", False, "gfx1100", ""), # name-inferred gfx
|
|
("x86_64", False, "", "gfx1100"), # env-forwarded gfx
|
|
("x86_64", True, "", ""), # usable NVIDIA
|
|
],
|
|
)
|
|
def test_setup_sh_routing_block_always_resolves_to_fork(
|
|
self, machine, nvidia_usable, setup_gfx, env_gfx
|
|
):
|
|
assert (
|
|
self._resolve_setup_sh_repo(
|
|
machine, nvidia_usable, setup_gfx, rocm_gfx_arch_env = env_gfx
|
|
)
|
|
== "unslothai/llama.cpp"
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_setup_ps1_repo():
|
|
"""Run setup.ps1's $HelperReleaseRepo assignment under pwsh and return the
|
|
resolved repo. The assignment is unconditional now, so there are no host
|
|
inputs to vary."""
|
|
import shutil
|
|
|
|
pwsh = shutil.which("pwsh")
|
|
if pwsh is None:
|
|
pytest.skip("pwsh not available")
|
|
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
|
|
line = next(
|
|
(ln for ln in source.splitlines() if ln.strip().startswith("$HelperReleaseRepo =")),
|
|
None,
|
|
)
|
|
assert line is not None, "$HelperReleaseRepo selection not found in setup.ps1"
|
|
harness = f"{line}\nWrite-Output $HelperReleaseRepo"
|
|
result = subprocess.run(
|
|
[pwsh, "-NoProfile", "-Command", harness],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout.strip()
|
|
|
|
def test_setup_ps1_routing_resolves_to_fork(self):
|
|
# Windows routing is unconditional now: CPU-only Windows (x64 and arm64)
|
|
# uses the fork's windows-cpu / windows-arm64 bundles, not ggml-org.
|
|
assert self._resolve_setup_ps1_repo() == "unslothai/llama.cpp"
|
|
|
|
|
|
# TEST: _pick_rocm_gfx_target -- visible-device selection from rocminfo output.
|
|
# Honours CUDA/HIP_VISIBLE_DEVICES so a mixed-arch host installs the prebuilt for the
|
|
# selected GPU, not GPU 0.
|
|
|
|
_pick_rocm_gfx_target = prebuilt_mod._pick_rocm_gfx_target
|
|
|
|
|
|
def test_pick_rocm_gfx_target_honors_cuda_visible_devices(monkeypatch):
|
|
"""CUDA_VISIBLE_DEVICES=1 must select gfx1100 on a gfx1151 + gfx1100 host (HIP honours CUDA var)."""
|
|
# rocminfo reports each token twice (as in the real tool output).
|
|
probe_out = "gfx1151\ngfx1151\ngfx1100\ngfx1100"
|
|
monkeypatch.delenv("HIP_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.delenv("ROCR_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "1")
|
|
assert _pick_rocm_gfx_target(probe_out) == "gfx1100"
|
|
|
|
|
|
def test_pick_rocm_gfx_target_cuda_visible_devices_minus_one_returns_none(monkeypatch):
|
|
"""CUDA_VISIBLE_DEVICES=-1 means no GPU visible; resolver must return None."""
|
|
probe_out = "gfx1151\ngfx1100"
|
|
monkeypatch.delenv("HIP_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.delenv("ROCR_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "-1")
|
|
assert _pick_rocm_gfx_target(probe_out) is None
|
|
|
|
|
|
def test_pick_rocm_gfx_target_same_arch_multi_gpu(monkeypatch):
|
|
"""Regression: [gfx1100, gfx1100, gfx1151] with HIP_VISIBLE_DEVICES=2 must return gfx1151 (no dict.fromkeys collapse)."""
|
|
# rocminfo output for 3 GPUs (2x gfx1100 + 1x gfx1151), one Agent section each.
|
|
probe_out = (
|
|
"***\nAgent 1\n***\n gfx1100 some info\n gfx1100\n"
|
|
"***\nAgent 2\n***\n gfx1100 some info\n gfx1100\n"
|
|
"***\nAgent 3\n***\n gfx1151 some info\n gfx1151\n"
|
|
)
|
|
monkeypatch.delenv("ROCR_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising = False)
|
|
monkeypatch.setenv("HIP_VISIBLE_DEVICES", "2")
|
|
assert _pick_rocm_gfx_target(probe_out) == "gfx1151"
|
|
|
|
|
|
# TEST: WSL ROCDXG fixes -- drop-in persistence + system-HIP-before-bundle
|
|
|
|
|
|
_INSTALL_SH_PATH = PACKAGE_ROOT / "install.sh"
|
|
_LLAMA_CPP_PATH = PACKAGE_ROOT / "studio" / "backend" / "core" / "inference" / "llama_cpp.py"
|
|
|
|
|
|
class TestWslSystemRocmLibDirs:
|
|
"""_wsl_system_rocm_lib_dirs: no-op off a ROCDXG WSL host; else returns the system ROCm lib dir for binary_env."""
|
|
|
|
def test_empty_without_dev_dxg(self):
|
|
with patch("os.path.exists", return_value = False):
|
|
assert prebuilt_mod._wsl_system_rocm_lib_dirs() == []
|
|
|
|
def test_empty_on_bare_metal_linux(self):
|
|
# /dev/dxg present but /proc/version is not a WSL kernel.
|
|
with patch("os.path.exists", lambda p: p == "/dev/dxg"):
|
|
with patch(
|
|
"builtins.open",
|
|
mock_open(read_data = "Linux version 6.8.0-generic"),
|
|
):
|
|
assert prebuilt_mod._wsl_system_rocm_lib_dirs() == []
|
|
|
|
def test_returns_system_lib_on_wsl_with_librocdxg(self):
|
|
# Normalize separators: os.path.join uses "\" on the Windows test host.
|
|
def _exists(p):
|
|
p = str(p).replace("\\", "/")
|
|
return p in ("/dev/dxg", "/opt/rocm/lib/librocdxg.so")
|
|
|
|
with patch("os.path.exists", _exists):
|
|
with patch(
|
|
"builtins.open",
|
|
mock_open(read_data = "Linux version 5.15.0-microsoft-standard-WSL2"),
|
|
):
|
|
assert prebuilt_mod._wsl_system_rocm_lib_dirs() == ["/opt/rocm/lib"]
|
|
|
|
def test_empty_on_wsl_without_librocdxg(self):
|
|
# WSL kernel + /dev/dxg but no librocdxg -> not a ROCDXG ROCm install.
|
|
with patch("os.path.exists", lambda p: p == "/dev/dxg"):
|
|
with patch(
|
|
"builtins.open",
|
|
mock_open(read_data = "microsoft-standard-WSL2"),
|
|
):
|
|
assert prebuilt_mod._wsl_system_rocm_lib_dirs() == []
|
|
|
|
|
|
class TestBinaryEnvWslOrdering:
|
|
"""binary_env puts system ROCm lib ahead of the bundle dir + sets HSA_ENABLE_DXG_DETECTION on WSL; no-op bare-metal."""
|
|
|
|
@staticmethod
|
|
def _linux_host():
|
|
return HostInfo(
|
|
system = "Linux",
|
|
machine = "x86_64",
|
|
is_windows = False,
|
|
is_linux = True,
|
|
is_macos = False,
|
|
is_x86_64 = True,
|
|
is_arm64 = False,
|
|
nvidia_smi = None,
|
|
driver_cuda_version = None,
|
|
compute_caps = [],
|
|
visible_cuda_devices = None,
|
|
has_physical_nvidia = False,
|
|
has_usable_nvidia = False,
|
|
has_rocm = True,
|
|
)
|
|
|
|
def test_wsl_prepends_system_rocm_and_sets_hsa(self, tmp_path):
|
|
binary = tmp_path / "bundle" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("")
|
|
# dedupe_existing_dirs drops non-existent dirs, so use a real dir.
|
|
sys_rocm = tmp_path / "sysrocm"
|
|
sys_rocm.mkdir()
|
|
with patch.object(prebuilt_mod, "_wsl_system_rocm_lib_dirs", return_value = [str(sys_rocm)]):
|
|
with patch.dict(os.environ, {}, clear = True):
|
|
env = prebuilt_mod.binary_env(binary, tmp_path, self._linux_host())
|
|
ld = env["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
# Compare resolved paths (dedupe_existing_dirs calls Path.resolve()).
|
|
ld_resolved = [str(Path(p).resolve()) for p in ld]
|
|
assert ld_resolved[0] == str(sys_rocm.resolve())
|
|
assert str(binary.parent.resolve()) in ld_resolved
|
|
assert ld_resolved.index(str(sys_rocm.resolve())) < ld_resolved.index(
|
|
str(binary.parent.resolve())
|
|
)
|
|
assert env.get("HSA_ENABLE_DXG_DETECTION") == "1"
|
|
|
|
def test_bare_metal_linux_unchanged(self, tmp_path):
|
|
binary = tmp_path / "bundle" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("")
|
|
with patch.object(prebuilt_mod, "_wsl_system_rocm_lib_dirs", return_value = []):
|
|
with patch.dict(os.environ, {}, clear = True):
|
|
env = prebuilt_mod.binary_env(binary, tmp_path, self._linux_host())
|
|
ld = env["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
assert ld[0] == str(binary.parent) # bundle dir first, as before
|
|
assert "HSA_ENABLE_DXG_DETECTION" not in env
|
|
|
|
|
|
class TestInstallShDropinPersistence:
|
|
"""install.sh persists the ROCm-on-WSL drop-in even when rocminfo already enumerates the GPU (reinstall safety)."""
|
|
|
|
def test_has_persist_helper(self):
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
assert "_persist_rocm_wsl_dropin()" in source
|
|
|
|
def test_gate5_early_return_persists_dropin(self):
|
|
"""The rocminfo-already-works early return must call the persist helper before returning."""
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
# The persist call must precede `return 0` at the rocminfo GPU-agent gate
|
|
# (uniquely identified by the `!/generic/` clause the other probes lack).
|
|
gate = source.find("Name:[[:space:]]*gfx[1-9]/ && !/generic/")
|
|
assert gate != -1
|
|
window = source[gate : gate + 900]
|
|
assert "_persist_rocm_wsl_dropin" in window
|
|
assert window.find("_persist_rocm_wsl_dropin") < window.find("return 0")
|
|
|
|
def test_persist_helper_gated_on_librocdxg(self):
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
body_start = source.find("_persist_rocm_wsl_dropin()")
|
|
body = source[body_start : body_start + 1200]
|
|
assert "librocdxg.so" in body
|
|
assert "profile.d/unsloth-rocm-wsl.sh" in body
|
|
|
|
|
|
_STRIXHALO_WSL_PATH = PACKAGE_ROOT / "scripts" / "install_rocm_wsl_strixhalo.sh"
|
|
|
|
|
|
class TestWslRerouteNvidiaGuard:
|
|
"""_maybe_reroute_strixhalo_to_2404 must skip the AMD reroute on hybrid AMD+NVIDIA hosts by
|
|
reusing _has_usable_nvidia_gpu (CUDA_VISIBLE_DEVICES-aware + /proc/driver/nvidia fallback),
|
|
which must be defined before the reroute's call site so it is actually available."""
|
|
|
|
def test_reroute_calls_nvidia_helper_before_amd_signal(self):
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
start = source.find("_maybe_reroute_strixhalo_to_2404()")
|
|
assert start != -1
|
|
# Slice the WHOLE function body (to its closing brace at column 0), not a
|
|
# fixed-length window: preamble growth must not push the signals out of view.
|
|
end = source.find("\n}", start)
|
|
assert end != -1
|
|
body = source[start:end]
|
|
nv = body.find("_has_usable_nvidia_gpu")
|
|
wmi = body.find("_wsl_amd_gpu_name")
|
|
assert nv != -1, "reroute must consult _has_usable_nvidia_gpu before deciding to reroute"
|
|
assert wmi != -1
|
|
# The NVIDIA guard must precede the AMD/WMI signal and return early.
|
|
assert nv < wmi
|
|
assert body.find("return 0", nv) < wmi
|
|
|
|
def test_nvidia_helper_and_deps_defined_before_reroute_callsite(self):
|
|
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
|
|
call = source.find("\n_maybe_reroute_strixhalo_to_2404 || true")
|
|
assert call != -1
|
|
for fn in ("_run_bounded() {", "_cvd_hides_nvidia() {", "_has_usable_nvidia_gpu() {"):
|
|
idx = source.find(fn)
|
|
assert idx != -1 and idx < call, f"{fn} must be defined before the reroute call"
|
|
|
|
|
|
class TestStrixhaloGfxOverridePipefail:
|
|
"""The UNSLOTH_WSL_GFX override check must use a consuming grep, not grep -q: under
|
|
`set -o pipefail` an early -q exit SIGPIPEs printf and misreports the arch on large output."""
|
|
|
|
def test_gfx_override_uses_consuming_grep(self):
|
|
source = _STRIXHALO_WSL_PATH.read_text(encoding = "utf-8")
|
|
idx = source.find('grep -E "Name:[[:space:]]*${GFX}')
|
|
assert idx != -1, "GFX override must use a consuming grep -E (not grep -q)"
|
|
line = source[idx : source.find("\n", idx)]
|
|
assert ">/dev/null" in line
|
|
assert 'grep -qE "Name:[[:space:]]*${GFX}' not in source
|
|
|
|
|
|
class TestLlamaCppRuntimeWslOrdering:
|
|
"""The serve-time launcher mirrors binary_env: system HIP before the bundle dir on WSL."""
|
|
|
|
def test_has_wsl_helper(self):
|
|
source = _LLAMA_CPP_PATH.read_text(encoding = "utf-8")
|
|
assert "_wsl_system_rocm_lib_dirs" in source
|
|
|
|
def test_prepends_before_binary_dir(self):
|
|
source = _LLAMA_CPP_PATH.read_text(encoding = "utf-8")
|
|
idx_helper = source.find("lib_dirs.extend(_wsl_system_rocm_lib_dirs())")
|
|
idx_binary = source.find("lib_dirs.append(binary_dir)")
|
|
assert idx_helper != -1 and idx_binary != -1
|
|
assert idx_helper < idx_binary
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|