unsloth/tests/python/test_windows_xformers_installer.py
Daniel Han e219b469a2
Install a CUDA-matched xFormers on Windows instead of whatever PyPI serves (#8156)
* Bound the Windows xFormers pin and document the CUDA-matched route

The `windows` extra resolved `xformers>=0.0.22.post7` from PyPI with no upper
bound and no index. PyPI publishes exactly one win_amd64 flavour of xFormers,
built against CUDA 12.8 (0.0.34's cpp_lib.json reads `torch 2.10.0+cu128`), so
next to the cu130 torch install.ps1 pulls from download.pytorch.org the
xformers/_C.pyd fails torch.ops.load_library and _cpp_lib.py turns that into a
warning: memory-efficient attention, SwiGLU and the sparse ops all disappear
while the import still reports success.

Unbounded, the same spec now resolves to 0.0.35, which is published as a
py39-none wheel with no compiled extension at all (2.6 MB vs 103 MB) and whose
torch>=2.10 floor lets pip drag a pinned torch forward.

Bound the fallback and document the route that IS CUDA-matched on Windows: the
cuXXX-torchYYY extras, every one of which has carried win_amd64 rows since
cu124-torch240. Verified live on download.pytorch.org (HTTP 200) for
cu126/cu128/cu130 x xformers 0.0.33.post1 / 0.0.33.post2 / 0.0.34, and read
cpp_lib.json back out of the cu128 and cu130 0.0.34 wheels to confirm they
really are built for 2.10.0+cu128 and 2.10.0+cu130 respectively.

* install.ps1: install the xFormers wheel built for the torch it installed

install.ps1 never mentioned xFormers. It installed torch from a cu126 / cu128 /
cu130 index and then plain `unsloth`, leaving xFormers to whatever pip resolved
later -- and PyPI publishes exactly one win_amd64 flavour, built against CUDA
12.8. On a cu130 host that is the NVIDIA QA report verbatim: "xFormers was built
for PyTorch 2.10.0+cu128 with CUDA 1208 (you have 2.10.0+cu130)", after which
xformers/_cpp_lib.py logs a warning and drops memory-efficient attention, SwiGLU
and the sparse ops while the import still succeeds.

Select the wheel from the CUDA family the resident torch actually carries and
install it from the same index the torch install used, so
UNSLOTH_TORCH_INDEX_URL / UNSLOTH_TORCH_INDEX_FAMILY / UNSLOTH_PYTORCH_MIRROR
keep working unchanged. Additive and best-effort: no wheel for this (torch,
CUDA) pair means install nothing rather than a mismatch, a failed install warns
and the run continues on torch SDPA, and UNSLOTH_SKIP_XFORMERS=1 opts out.

Three details that are easy to get wrong:
  - The step runs after the torch flavor repair, which can itself reinstall
    torch from a different index.
  - xFormers publishes one wheel per exact torch PATCH (2.9.0 -> 0.0.33.post1,
    2.9.1 -> 0.0.33.post2, 2.10.0 -> 0.0.34), so the table is keyed on the full
    release, not the minor.
  - cu126, cu128 and cu130 all publish the SAME xformers version string, so a
    wrong-CUDA wheel is invisible to a version check. The repair reads the
    resident xformers/cpp_lib.json (the same metadata xFormers quotes in its own
    error) and force-replaces the package when it disagrees.

Every row in the table was HEAD-verified live on download.pytorch.org and its
cpp_lib.json read back. install.ps1 parses clean under
[System.Management.Automation.Language.Parser]::ParseFile, and the new selector
tests execute the extracted helpers under pwsh.

* wheel_utils: resolve CUDA-matched xFormers wheels, Windows included

linux_wheel_platform_tag() returned None for Windows, so nothing in the backend
could resolve a Windows wheel URL at all. Rename it to wheel_platform_tag() and
emit win_amd64, since download.pytorch.org does publish CUDA-matched win_amd64
xFormers wheels.

Both call sites were inside this module, but probe_torch_wheel_env() is a real
behaviour risk: its three production callers all resolve flash-attn /
causal-conv1d / mamba-ssm assets, and those upstreams publish no win_amd64
wheels, so returning an env on Windows would only build 404s. Windows is
therefore opt-in there via include_windows, leaving every existing caller
byte-identical.

Add the resolver: torch release + torch.version.cuda -> the exact
download.pytorch.org wheel. The probe payload grows torch_version and
cuda_version because neither existing key can pick the right wheel -- xFormers
publishes one wheel per torch PATCH (2.9.0 -> 0.0.33.post1, 2.9.1 ->
0.0.33.post2) and per CUDA MINOR (cu126 and cu128 are different builds carrying
the same version string). Unlisted pairs resolve to None so callers install
nothing; serving a neighbouring CUDA family is the bug, not the fallback.

* diffusion: stop installing an unpinned xFormers on demand

The on-demand backend installer ran `pip install --only-binary :all: --no-deps
xformers`, with no version and no index. Three things then line up badly:

  - PyPI publishes exactly one win_amd64 xFormers flavour, built against CUDA
    12.8, so a cu130 host installs a mismatched extension every time.
  - --no-deps deliberately stops pip from ever reading the wheel's
    `Requires-Dist: torch==X`, so nothing checks the pairing.
  - the failure is SILENT. torch.ops.load_library raises, xformers/_cpp_lib.py
    catches it and logs a warning, and the import still succeeds -- so find_spec
    reports the backend as present while memory-efficient attention, SwiGLU and
    the sparse ops are all gone. The old comment ("An ABI mismatch just fails to
    import") described the flash-attn case, not this one.

Unbounded also means 0.0.35 today, which ships no compiled extension at all.

Resolve the exact download.pytorch.org wheel for the running torch build and
install that URL, or refuse. Refusing is the point: no matched wheel means no
install, the caller stays on torch SDPA, and the reason comes back from
_ensure_attention_backend_installed (previously -> None) so it is reportable
rather than only logged. Like the kernels/hub gate this is a policy refusal, so
it records nothing in _INSTALL_ATTEMPTED.

This changes Linux too, and deliberately: the same PyPI cu128 build lands beside
a cu130 Linux torch. Torch versions with no published wheel (2.11, 2.12) now
refuse instead of installing 0.0.35, which diffusers would accept at set time
and then fail inside the denoise loop.

Verified end to end against the real torch in this checkout (2.9.1+cu128 ->
cu128/xformers-0.0.33.post2-cp39-abi3-manylinux_2_28_x86_64.whl, live HEAD).

* wheel_utils: do not build xFormers URLs that 404 on pre-abi3 rows

Self-review caught this: enumerating every URL the matrix can produce and
HEAD-checking all 44 found 4 dead ones. xFormers only switched to a single
cp39-abi3 wheel at 0.0.31; the 0.0.30 row (torch 2.7.0) publishes one wheel per
interpreter and stops at cp312, so a 3.13 host built a URL that does not exist.

Gate the pre-abi3 branch on the interpreter range those wheels actually cover.
diffusion_attention already url_exists-gated before installing, so this was not
user visible, but xformers_wheel_url is a public helper and should not hand a
caller a dead link.

Add the enumerate-and-HEAD check as a test so no future row can be added
without a live wheel behind it: a 404 fails (that row is wrong), a network
outage skips, matching tests/version_compat/_fetch.py.

* Fix issues found in review: two false claims and three real defects

Two things I had written down were wrong. I inferred both from wheel sizes
instead of opening the wheels; a reviewer opened them.

  - xFormers 0.0.35 DOES ship a compiled extension. cu130's py39-none wheel
    carries a 19.4 MB xformers/_C.pyd and a cpp_lib.json reading
    {"cuda":1300,"torch":"2.10.0+cu130"}; cu128's carries an 8.4 MB one. The
    103 MB -> 2.6 MB drop is the bundled flash_attn_3/_C.pyd going away, not the
    extension. The real hazard is narrower and still worth the pin: 0.0.35's
    extension is built for torch 2.10.0 while its metadata asks only for
    torch>=2.10, so pip may pair it with a torch it cannot load. 0.0.34 declares
    torch==2.10.0, an exact pair.
  - PyPI's WINDOWS torch has no CUDA at all. torch 2.10.0's win_amd64 wheel
    reports __version__ '2.10.0+cpu' with cuda = None; only the Linux wheel is
    +cu128. So `unsloth[windows]` cannot produce a working CUDA xFormers on any
    PyPI-only install, which strengthens rather than weakens the case for the
    cuXXX-torchYYY route.

Real defects:

  - install.ps1 keyed the index leaf off $TorchIndexUrl, and the comment claimed
    the flavor repair had already reconciled it with the resident torch. It has
    not: the repair is skipped when the expected tag is 'cpu' or unrecognised, so
    a migrated venv can hold a +cu128 torch while the leaf says /cpu -- and
    whl/cpu serves only xformers 0.0.22.post4, with --default-index leaving no
    fallback. Derive the leaf from the resident torch, reusing $TorchIndexUrl
    (and any custom mirror) only when it already points at that family.
  - Get-InstalledXformersBuild compared only the build tag, so a resident 0.0.35
    matching the torch made the step print "xFormers 0.0.34 already matches" and
    skip. Compare version AND build tag.
  - The torch 2.7.0 / xFormers 0.0.30 row predates the abi3 switch: one wheel per
    interpreter, stopping at cp312, while this installer defaults to Python 3.13.
    wheel_utils had a guard for it, install.ps1 did not. Drop the row from both
    rather than carry two per-interpreter gates for a torch that resolves to
    nothing on the default install.

Also from review: resolution no longer HEAD-checks the URL (it can run under
_generate_lock, since the video loader has no out-of-lock pre-install hop), the
probe timeout drops 120s -> 30s to match the other probe_torch_wheel_env
callers, only DETERMINISTIC refusals are memoised so one probe timeout cannot
disable xFormers for a whole session, the memo takes a lock, the wheel filename
tag is a verified range instead of an open-ended floor, and the backend test
suite pins UNSLOTH_DIFFUSION_ATTENTION_INSTALL=0 so no future test can shell out
to a real pip.

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

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

* Correct four wheel-matrix claims and route the xFormers install through $script:UvExe

Deep validation of this branch turned up one merge hazard and four comments
that do not survive checking against the live indexes.

The install call was the only bare `uv` left in install.ps1 once #8161 lands.
That branch rewrites every other invocation to `& $script:UvExe` so a profile
alias named uv cannot capture them, and the two branches merge cleanly with
zero conflict markers -- so git would silently reintroduce the one hole #8161
exists to close. Resolve through the variable when something has set it, and
fall back to the bare name otherwise, so the call is correct on this branch
alone and correct after the merge. Get-Variable rather than a bare read keeps
the lookup safe under a profile's Set-StrictMode.

The comment claiming no cu118 / cu121 / cu124 win_amd64 xFormers wheel exists
is wrong; cu124/xformers-0.0.28.post1-cp312-cp312-win_amd64.whl is live. The
real reason those families are absent is that they all stop before the
cp39-abi3 switch at 0.0.31, so their wheels are one file per interpreter and
the single filename template cannot name them.

"Every cuXXX-torchYYY extra has carried win_amd64 rows since cu124-torch240"
is likewise wrong: the cu118 and cu121 rows carry none and cu130onlytorch280
is empty. And the suggested `pip install "unsloth[cu130-torch2100]"` is not
enough on its own -- the extra pins xFormers by URL but pins no torch, and
PyPI's Windows torch is 2.10.0+cpu with cuda None, so without --index-url it
lands a cu130 extension beside a CPU torch: the same mismatch, inverted.

Linux aarch64 gets a platform tag, so it never reaches the "this platform has
no xFormers wheel" refusal that names it. It lands on the other one, which
reported only torch and CUDA and so read as "upstream never built this pair"
when the truth is "not for this arch". Name the platform there and stop
claiming aarch64 in the branch it cannot reach.

Finally, record why a mismatched xFormers that is ALREADY installed is not
repaired here: find_spec sees it and returns before the matched-wheel block.
Repairing under _generate_lock would mean a 100 MB download blocking unload
and cancel, on a package the user may have pinned deliberately. install.ps1
does that repair outside any request. What this path prevents is Studio
creating the mismatch, which is how it was made.

* Record what the wheel matrix over-approximates, and correct the 0.0.35 history

Three more comments that do not survive checking against the published
artifacts. All comment-only; no behaviour changes.

"PyPI publishes only the CUDA-12.8 flavour" is true of 0.0.34 and 0.0.35 but
reads as an invariant, and it is not one: the single PyPI win_amd64 wheel has
been cu124 at 0.0.29.post2, cu126 at 0.0.30, cu128 at 0.0.32, cu130 at 0.0.33
and cu128 again from 0.0.33.post1. That churn is a better argument for
resolving a URL than the fixed-flavour version was. Worth recording too that
cpp_lib.json's `cuda` is the NVCC toolkit version and not torch's family --
the cu126 0.0.34 wheel also reports 1208, so only the `torch` field separates
the flavours, which is the field this resolver already keys on.

0.0.35 did not move its extension behind torch.ops.load_library; 0.0.34 loads
through exactly the same call, and the two _cpp_lib.py files are identical.
What changed is setup.py dropping py_limited_api=True in favour of a custom
bdist_wheel that force-tags py39-none, on the stated grounds that the
extension never bound the CPython ABI at all -- which holds up: its _C.so
defines no PyInit and references no Py* symbol.

Finally, name the two places the table is deliberately stricter or narrower
than the ABI requires, so neither gets "fixed" by interpolation. Keying on the
CUDA minor is stricter than needed -- the cu126 and cu128 extensions have
identical undefined-symbol sets and both link libcudart.so.12, and only a
major bump changes that -- but the minor names a real index directory, so an
exact hit is what guarantees the URL exists. And the table stops at torch
2.10.0 because upstream does: 2.11 through 2.13 are released, 0.0.35 declares
torch>=2.10 yet every published 0.0.35 wheel was compiled against 2.10.0, and
only the unreleased 0.0.35.dev1130 targets 2.11.

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

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

* Stop capping xFormers below 0.0.35, and map torch 2.11+ onto it

The cap was wrong. I bounded the windows extra at <0.0.35 on the reasoning
that 0.0.35 declares torch>=2.10 while its extension is built for 2.10.0, so
pip could pair it with torch 2.11 and recreate the mismatch this branch
exists to fix. Upstream says that pairing is supported. v0.0.34's notes
record the migration to the PyTorch stable API/ABI, "which means that binary
builds targeting PyTorch 2.10+ will be compatible with any later version",
and 0.0.35's loosened pin plus its py39-none tag are both consequences of
that, not oversights.

The cap also cost more than it bought. 0.0.34 pins torch==2.10.0 exactly, so
anyone on 2.10.1 could install no xFormers at all.

The axis that has to match is the CUDA family, which the extra cannot see and
install.ps1 already handles. Verified from the wheels rather than inferred:
every PyPI wheel, 0.0.34 and 0.0.35 alike, reports cuda 1208 / torch
2.10.0+cu128, while the cu130 wheels report cuda 1300. Only the cu130 build
carries Blackwell SASS -- cu126 and cu128 stop at 9.0a, so on sm_100 or
sm_120 they have no native kernels and fall back to PTX JIT from 8.0+PTX.

Both selector tables gain 2.11.0, 2.12.0 and 2.13.0 rows mapping to 0.0.35,
keyed per release so an unknown torch still resolves to nothing rather than
borrowing a neighbour's wheel. All six 0.0.35 URLs the tables can emit return
200.

Two test docstrings asserted 0.0.35 was extension-less and shipped no
cpp_lib.json. Both are false: the PyPI wheel carries an 8.2 MB _C.pyd and the
cu130 wheel a 19 MB one, and every release from 0.0.31 on ships cpp_lib.json.
The 103 MB to 2.6 MB drop was the bundled flash_attn_3 kernels going away.

* Resolve xFormers for the torch that is actually installed, not just the listed ones

- an exact-key matrix refuses every patch release published after it ships,
  so 2.10.1 / 2.11.1 / 2.12.1 got no xFormers at all; above the stable-ABI
  floor the answer is known without a row, in both selectors.
- the direct wheel URL hard-coded download.pytorch.org, the one path in the
  installer stack that ignored UNSLOTH_PYTORCH_MIRROR.
- install.ps1 threw away an explicitly pinned index whose leaf did not happen
  to name the CUDA family, which is exactly what a documented full-URL
  override looks like.
- an unpinned install now goes through the direct wheel URL: --default-index
  does not make an index exclusive, and cu126 / cu128 / cu130 share a version
  string, so UV_INDEX could still supply the wrong-CUDA artifact.
- the already-installed check compared the wheel's recorded build target
  against the resident torch, so a correct 0.0.35 was force-reinstalled on
  every run.
- the live wheel sweep treated any HTTP status as a dead row, failing the
  suite on a 429 from the CDN.
- and the two torch 2.11 refusal cases were stale as of the new matrix.

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

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

* Build the direct wheel URL for a family pin, and keep mirror credentials out of the log

UNSLOTH_TORCH_INDEX_FAMILY sets $TorchIndexPinned as well, so a plain cu130 pin took
the --default-index path. uv's --index / UV_INDEX are used "in addition to" the default
index, and cu126 / cu128 / cu130 publish the same xFormers version string, so a
machine-level UV_INDEX could satisfy the pin with a wheel from the wrong CUDA family --
the silent extension failure this step exists to prevent. A family pin names a leaf, so
it can have a direct URL; only a full UNSLOTH_TORCH_INDEX_URL override, which may be an
authenticated mirror nothing here can rebuild, still goes through the index.

And UNSLOTH_PYTORCH_MIRROR is allowed to carry userinfo or a token. The Studio
on-demand path bakes it into the wheel URL and then logged that URL verbatim as the
package name, so the first install (or failed install) wrote the secret into the backend
log. pip still gets the real URL; the log gets a redacted one, and pip's stderr is put
through the same filter because pip echoes back what it was handed. Same rule as the
installer's Remove-IndexUrlCredentials: no userinfo, no query, no fragment.

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

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

* Address the wheel URL under a full-URL override, and keep a query-token mirror usable

Two ways the wrong wheel could still be installed, or none at all.

A full UNSLOTH_TORCH_INDEX_URL override went to --default-index, which uv does not treat
as exclusive: --index / UV_INDEX are used "in addition to" it, and cu126 / cu128 / cu130
all publish the same xFormers version, so a machine-level index could satisfy the pin
from the wrong family. When the override names a CUDA leaf -- the documented shape -- the
wheel is addressed under it directly instead. A mirror root whose leaf is not a family
still has to be resolved, and for that one call UV_INDEX and UV_EXTRA_INDEX_URL are
cleared, in a finally, so the chosen index really is the only one.

And a mirror may authenticate by query string. Appending "/cu130/..." after the query put
the wheel path inside the token value, leaving the request path at /whl -- so the
tokenized private mirror UNSLOTH_PYTORCH_MIRROR exists for was the one shape that could
not resolve a wheel at all. Both sides join before the ?/# now: join_wheel_url in Python,
Join-UrlPath in the installer, with the PowerShell one exercised through pwsh.

---------

Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 00:20:55 -07:00

352 lines
16 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""install.ps1 must install the xFormers wheel built for the torch it installed.
Before this, install.ps1 never mentioned xFormers at all: it installed torch from a
cu126 / cu128 / cu130 index and then plain `unsloth`, whose `windows` extra pulled
xFormers from PyPI -- which publishes only the CUDA-12.8 flavour. On a cu130 host that
produced the NVIDIA QA report "xFormers was built for PyTorch 2.10.0+cu128 with CUDA
1208 (you have 2.10.0+cu130)", with memory-efficient attention silently disabled.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_PS1 = REPO_ROOT / "install.ps1"
def _source() -> str:
return INSTALL_PS1.read_text(encoding = "utf-8")
def _extract(pattern: str, source: str) -> str:
match = re.search(pattern, source, flags = re.DOTALL | re.MULTILINE)
assert match is not None, f"installer block not found: {pattern}"
return match.group(0)
def _selector_harness() -> str:
"""`$script:XformersWheelVersions` + the two pure helpers, ready to dot-source."""
source = _source()
return "\n".join(
(
_extract(r"^ \$script:XformersWheelVersions = @\{.*?^ \}", source),
_extract(r"^ \$script:XformersStableAbiFloor = [^\n]*", source),
_extract(r"^ \$script:XformersStableAbiVersion = [^\n]*", source),
_extract(r"^ \$script:XformersStableAbiFamilies = [^\n]*", source),
_extract(r"^ function ConvertTo-TorchFlavorTag \{.*?^ \}", source),
_extract(r"^ function ConvertTo-TorchReleaseVersion \{.*?^ \}", source),
_extract(r"^ function Get-XformersWheelVersion \{.*?^ \}", source),
_extract(r"^ function Get-XformersExpectedTorchBuild \{.*?^ \}", source),
_extract(r"^ function Get-XformersFilenamePythonTag \{.*?^ \}", source),
)
)
def _run_pwsh(script: str) -> str:
result = subprocess.run(
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
check = True,
capture_output = True,
text = True,
)
return result.stdout.strip()
# (torch.__version__, expected xFormers version or "" for "no wheel, install nothing").
# The live wheels behind each row were HEAD-verified on download.pytorch.org and their
# xformers/cpp_lib.json read back -- cu130/xformers-0.0.34 reports {"torch":
# "2.10.0+cu130"}, cu128/xformers-0.0.34 reports {"torch": "2.10.0+cu128"}.
SELECTION_CASES = [
("2.10.0+cu130", "0.0.34"),
("2.10.0+cu128", "0.0.34"),
("2.10.0+cu126", "0.0.34"),
("2.9.1+cu130", "0.0.33.post2"),
("2.9.1+cu128", "0.0.33.post2"),
("2.9.0+cu130", "0.0.33.post1"),
("2.9.0+cu126", "0.0.33.post1"),
("2.8.0+cu129", "0.0.32.post2"),
("2.7.1+cu128", "0.0.31.post1"),
# 0.0.30 predates the abi3 switch and has no cp313 wheel; the row is deliberately gone.
("2.7.0+cu128", ""),
# No cu130 build of xFormers exists for torch 2.8 or earlier, and no cu118 /
# cu124 win_amd64 build exists at all -- refuse rather than serve a neighbour.
("2.8.0+cu130", ""),
("2.9.0+cu118", ""),
("2.10.0+cu124", ""),
# torch 2.11+ resolves to 0.0.35. It is built against 2.10.0 and loads there by
# design: xFormers moved to the PyTorch stable API/ABI in 0.0.34, whose notes state
# such builds are "compatible with any later version".
("2.11.0+cu130", "0.0.35"),
("2.13.0+cu128", "0.0.35"),
# And so does every release above the floor that the table cannot list, because they
# are published after this script ships. Refusing them left supported builds with no
# xFormers at all.
("2.10.1+cu130", "0.0.35"),
("2.11.1+cu128", "0.0.35"),
("2.12.4+cu126", "0.0.35"),
("2.14.0+cu130", "0.0.35"),
# Bounded in both directions: below the floor there is no stable ABI to lean on, and a
# CUDA family that publishes nothing gains no wheel from the era.
("2.9.2+cu130", ""),
("2.12.0+cu124", ""),
# Non-CUDA and nightly builds must miss the table outright.
("2.10.0+cpu", ""),
("2.10.0+rocm6.4", ""),
("2.10.0.dev20260101+cu130", ""),
]
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
@pytest.mark.parametrize(("torch_version", "expected"), SELECTION_CASES)
def test_selector_picks_the_matching_wheel(torch_version: str, expected: str):
out = _run_pwsh(
f"{_selector_harness()}\n"
f"$v = '{torch_version}'\n"
"$tag = ConvertTo-TorchFlavorTag $v\n"
"$sel = Get-XformersWheelVersion -TorchVersion $v -CudaTag $tag\n"
'Write-Output "[$sel]"\n'
)
assert out == f"[{expected}]", f"{torch_version} selected {out}, expected [{expected}]"
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
def test_selector_refuses_a_missing_or_blank_input():
out = _run_pwsh(
f"{_selector_harness()}\n"
"Write-Output \"[$(Get-XformersWheelVersion -TorchVersion '' -CudaTag 'cu130')]\"\n"
"Write-Output \"[$(Get-XformersWheelVersion -TorchVersion '2.10.0+cu130' -CudaTag '')]\"\n"
)
assert out.splitlines() == ["[]", "[]"]
def test_installer_installs_xformers_from_the_torch_index():
source = _source()
block = _extract(r" # ── Pin xFormers to the wheel built for the torch.*?^ \}\n", source)
assert "--no-deps" in block, (
"the xFormers wheel declares torch==<exact release>; without --no-deps uv can pull "
"a PyPI (CUDA 12.8) torch over the CUDA build just installed"
)
assert "--reinstall-package xformers" in block, (
"cu126/cu128/cu130 publish the SAME xformers version string, so a wrong-CUDA wheel "
"is invisible to a version check and must be force-replaced"
)
assert "--default-index $_xfIndexUrl" in block
assert "xformers==$_xfVersion" in block
assert "UNSLOTH_SKIP_XFORMERS" in block
# A full-URL index pin is authoritative and is reused WHOLE. Its leaf is not required
# to name the CUDA family: a documented full-URL override can be an authenticated
# mirror, and rebuilding a download.pytorch.org URL over it strands an air-gapped host.
assert (
"if ($TorchIndexUrl -and -not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL))"
in block
)
# The leaf is read to BUILD a URL under the override, never to throw the override away:
# a full-URL override can be an authenticated mirror whose leaf is not a family, and
# rebuilding a download.pytorch.org URL over it strands an air-gapped host.
assert "$_xfIndexUrl = $TorchIndexUrl" in block
# Unpinned, install the direct wheel URL: --default-index does not make an index
# exclusive, and cu126/cu128/cu130 share a version string, so a machine with UV_INDEX
# set can satisfy the pin from the wrong CUDA family.
assert '$_xfWheelUrl = Join-UrlPath $_xfBase "$_xfCudaTag/$_xfWheelName"' in block
assert "--reinstall-package xformers $_xfWheelUrl" in block
# The already-installed check compares against the wheel's OWN build target, not the
# resident torch: the stable-ABI wheel records the floor release it was compiled
# against, so the old comparison force-reinstalled a correct 0.0.35 on every run.
assert "Get-XformersExpectedTorchBuild -Version $_xfVersion" in block
def test_a_family_pin_still_gets_the_direct_wheel_url():
"""UNSLOTH_TORCH_INDEX_FAMILY sets $TorchIndexPinned too, so keying the index branch off
that flag sent a plain `cu130` pin down --default-index -- and uv's --index / UV_INDEX
are used "in addition to" the default one, so a machine-level UV_INDEX carrying the same
xFormers version could satisfy the pin from the wrong CUDA family, which is the failure
this step exists to prevent. A family pin names a leaf, so a direct URL can be built for
it; only a full-URL override (possibly an authenticated mirror we cannot rebuild) has to
go through the index."""
block = _extract(
r" # ── Pin xFormers to the wheel built for the torch.*?^ \}\n", _source()
)
condition = re.search(r"if \(\$TorchIndexUrl[^\n]*\) \{", block)
assert condition is not None, "the index branch must be gated on the full-URL override"
assert "UNSLOTH_TORCH_INDEX_URL" in condition.group(0)
assert "$TorchIndexPinned" not in condition.group(0)
# ...and the family-pin side builds a URL rather than resolving a version.
assert '$_xfWheelUrl = Join-UrlPath $_xfBase "$_xfCudaTag/$_xfWheelName"' in block
def test_a_full_url_override_naming_a_cuda_leaf_gets_a_direct_url():
"""`--default-index` is not exclusive -- uv reads UV_INDEX "in addition to" it -- and every
CUDA family publishes the same xFormers version, so an index resolve can always be
satisfied from the wrong one. A documented `.../cu130` override names the leaf, so the
wheel is addressed under it directly, which nothing can substitute for."""
block = _extract(
r" # ── Pin xFormers to the wheel built for the torch.*?^ \}\n", _source()
)
assert "$_xfOverrideLeaf = Get-TorchIndexLeafName $TorchIndexUrl" in block
assert "'^cu\\d+$'" in block
assert "$_xfWheelUrl = Join-UrlPath $TorchIndexUrl $_xfWheelName" in block
def test_the_index_fallback_hides_the_machine_level_indexes():
"""The one path that still resolves rather than fetching a URL: a mirror root whose leaf
is not a CUDA family. UV_INDEX / UV_EXTRA_INDEX_URL are cleared around that call so the
chosen index really is the only one, and restored afterwards."""
block = _extract(
r" # ── Pin xFormers to the wheel built for the torch.*?^ \}\n", _source()
)
assert "$env:UV_INDEX = $null" in block
assert "$env:UV_EXTRA_INDEX_URL = $null" in block
assert "$env:UV_INDEX = $_xfSavedIndex" in block
assert "$env:UV_EXTRA_INDEX_URL = $_xfSavedExtra" in block
assert "} finally {" in block, "the restore must survive a failing install"
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
def test_the_url_join_keeps_a_tokenized_mirrors_query():
"""Executed, not read: a private mirror may authenticate by query string, and appending
after the query put the wheel path inside the token value -- request path still /whl,
token unusable. Same rule as the Python side's join_wheel_url."""
joiner = _extract(r"^ function Join-UrlPath \{.*?^ \}", _source())
out = _run_pwsh(
f"{joiner}\n"
"Write-Output (Join-UrlPath 'https://m/whl?token=abc' 'cu130/x.whl')\n"
"Write-Output (Join-UrlPath 'https://m/whl/' 'cu130/x.whl')\n"
"Write-Output (Join-UrlPath 'https://m/whl#f' 'cu130/x.whl')\n"
)
assert out.splitlines() == [
"https://m/whl/cu130/x.whl?token=abc",
"https://m/whl/cu130/x.whl",
"https://m/whl/cu130/x.whl#f",
]
def test_installer_never_installs_an_unpinned_xformers():
"""A bare `xformers` resolves to whatever the index serves newest, which is not
necessarily built for the CUDA family the resident torch came from. The installer
picks the exact version for (torch, cuda) instead, so every spec must be pinned."""
source = _source()
for match in re.finditer(r'"xformers[^"]*"', source):
spec = match.group(0)
# A wheel FILENAME is not a spec: it names one exact file and cannot resolve to
# anything else, which is the whole point of preferring it.
if spec.endswith('.whl"'):
continue
assert spec == '"xformers==$_xfVersion"', f"unpinned xFormers spec in install.ps1: {spec}"
def test_xformers_step_runs_after_the_torch_flavor_repair():
"""The repair can reinstall torch from a different index; selecting the wheel before it
would pin against a torch build that is about to be replaced."""
source = _source()
repair = source.index("Enforce the installed torch flavor matches the detected GPU build")
xformers = source.index("Pin xFormers to the wheel built for the torch")
overlay = source.index("CI only: overlay a source checkout")
assert repair < xformers < overlay
def test_xformers_step_is_skipped_for_no_torch_installs():
block = _extract(
r" # ── Pin xFormers to the wheel built for the torch.*?^ \}\n", _source()
)
assert "if (-not $SkipTorch" in block
# cu<digits> only: cpu / rocm / xpu torch has no xFormers wheel on any index.
assert "'^cu\\d+$'" in block
def test_installed_build_probe_reads_cpp_lib_json():
"""The version string cannot distinguish cu128 from cu130, so the repair check has to read
the build metadata xFormers itself reports in its error message."""
block = _extract(r" function Get-InstalledXformersBuild \{.*?^ \}", _source())
assert "cpp_lib.json" in block
assert "find_spec" in block
# Reading the file rather than importing xformers keeps a mismatched .pyd from writing
# its own warning into the probe output. Every release from 0.0.31 on ships
# cpp_lib.json, 0.0.35 included, so absence means an unbuilt or source install.
assert "import xformers" not in block
# Invoke-BoundedPythonProbe interpolates the code into a double-quoted -c argument.
assert '"' not in block.split("$code = ", 1)[1].split("\n", 1)[0].strip("'")
# Torch releases the exact tables cannot list, and the answer both selectors must give.
# The two implementations resolve the same machine (install.ps1 during install, wheel_utils
# on demand from Studio), so a fallback that lives in only one of them is a machine whose
# answer changes depending on which one asked.
STABLE_ABI_PARITY_CASES = [
("2.10.1+cu130", "13.0", "0.0.35"),
("2.11.1+cu128", "12.8", "0.0.35"),
("2.12.4+cu126", "12.6", "0.0.35"),
("2.14.0+cu130", "13.0", "0.0.35"),
("2.9.2+cu130", "13.0", ""),
("2.12.0+cu124", "12.4", ""),
]
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
@pytest.mark.parametrize(("torch_version", "cuda_version", "expected"), STABLE_ABI_PARITY_CASES)
def test_both_selectors_agree_outside_the_exact_tables(torch_version, cuda_version, expected):
import sys
sys.path.insert(0, str(REPO_ROOT / "studio" / "backend"))
try:
from utils import wheel_utils
finally:
sys.path.pop(0)
family = wheel_utils.xformers_cuda_family(cuda_version)
python_side = wheel_utils.xformers_wheel_version(torch_version, family) or ""
ps_side = _run_pwsh(
f"{_selector_harness()}\n"
f"$v = '{torch_version}'\n"
"$tag = ConvertTo-TorchFlavorTag $v\n"
'Write-Output "[$(Get-XformersWheelVersion -TorchVersion $v -CudaTag $tag)]"\n'
)
assert python_side == expected
assert ps_side == f"[{expected}]"
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
def test_the_already_installed_check_uses_the_wheels_own_build_target():
"""A resident 0.0.35 records the torch it was COMPILED against (the stable-ABI floor),
not the torch it is running under. Comparing against the resident torch made every
correct install look mismatched, so each run force-reinstalled a good wheel -- and
warned about a failed xFormers install whenever the index was unreachable."""
out = _run_pwsh(
f"{_selector_harness()}\n"
"Write-Output \"[$(Get-XformersExpectedTorchBuild -Version '0.0.35' "
"-TorchVersion '2.12.1+cu130' -CudaTag 'cu130')]\"\n"
"Write-Output \"[$(Get-XformersExpectedTorchBuild -Version '0.0.34' "
"-TorchVersion '2.10.0+cu128' -CudaTag 'cu128')]\"\n"
)
# The stable-ABI wheel reports the floor release; an exact-era wheel reports the torch
# it was pinned to, which is the resident one.
assert out.splitlines() == ["[2.10.0+cu130]", "[2.10.0+cu128]"]
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
def test_the_direct_wheel_filename_tag_matches_the_python_resolver():
import sys
sys.path.insert(0, str(REPO_ROOT / "studio" / "backend"))
try:
from utils import wheel_utils
finally:
sys.path.pop(0)
versions = ["0.0.31.post1", "0.0.32.post2", "0.0.33.post2", "0.0.34", "0.0.35"]
ps_out = _run_pwsh(
f"{_selector_harness()}\n"
+ "\n".join(f"Write-Output \"[$(Get-XformersFilenamePythonTag '{v}')]\"" for v in versions)
)
assert ps_out.splitlines() == [
f"[{wheel_utils.xformers_filename_python_tag(v)}]" for v in versions
]