unsloth/tests/python/test_windows_xformers_wheel_match.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

199 lines
8.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""xFormers must match the CUDA build of the resident torch on Windows.
xformers/_C.pyd is linked against ONE exact (torch, CUDA) pair. Loaded next to any
other pair, ``torch.ops.load_library`` raises and xformers/_cpp_lib.py swallows it
into a warning -- memory-efficient attention, SwiGLU and the sparse ops all vanish
while the import still "succeeds". PyPI publishes only the CUDA-12.8 flavour, so a
cu130 install that resolves xformers from PyPI loses every kernel silently.
These tests pin the two halves of the fix that live in pyproject.toml:
* the CUDA-matched Windows route (the cuXXX-torchYYY extras) really does resolve
to a win_amd64 wheel from the MATCHING CUDA index, and
* the CUDA-agnostic ``windows`` extra can no longer float onto an arbitrary
xFormers release.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = REPO_ROOT / "pyproject.toml"
WHEEL_INDEX_BASE = "https://download.pytorch.org/whl"
# (CUDA family, torch release) -> xFormers version, i.e. the wheel that index
# actually publishes for that torch. Every row was HEAD-verified as live on
# download.pytorch.org, and the cu128/cu130 0.0.34 wheels were downloaded and
# their xformers/cpp_lib.json read back:
# cu128 -> {"cuda": 1208, "torch": "2.10.0+cu128"}
# cu130 -> {"cuda": 1300, "torch": "2.10.0+cu130"}
# Keep this in step with _XFORMERS_WHEEL_VERSIONS in
# studio/backend/utils/wheel_utils.py and $script:XformersWheelVersions in
# install.ps1 -- test_xformers_matrix_agrees_with_wheel_utils below enforces it.
XFORMERS_WHEEL_MATRIX: dict[tuple[str, str], str] = {
("cu126", "290"): "0.0.33.post1",
("cu128", "290"): "0.0.33.post1",
("cu130", "290"): "0.0.33.post1",
("cu126", "291"): "0.0.33.post2",
("cu128", "291"): "0.0.33.post2",
("cu130", "291"): "0.0.33.post2",
("cu126", "2100"): "0.0.34",
("cu128", "2100"): "0.0.34",
("cu130", "2100"): "0.0.34",
}
# torch 2.11 publishes no xFormers wheel on any index yet, and the Windows torch
# pin in install.ps1 is torch<2.11.0 for exactly that kind of reason. Assert the
# absence so a future 2.11 row has to be added deliberately (with a live wheel)
# rather than inherited from 2.10 -- xFormers' extension ABI does not survive a
# torch minor bump, so reusing the 2.10 wheel there would reintroduce this bug.
TORCH_RELEASES_WITHOUT_XFORMERS_WHEELS = ("2110",)
def _tomllib():
if sys.version_info >= (3, 11):
import tomllib
return tomllib
return pytest.importorskip("tomli")
def _extras() -> dict[str, list[str]]:
tomllib = _tomllib()
return tomllib.loads(PYPROJECT.read_text(encoding = "utf-8"))["project"]["optional-dependencies"]
def _windows_xformers_urls(deps: list[str]) -> list[str]:
"""Every xformers direct-URL dep in ``deps`` whose marker holds on Windows x64."""
markers = pytest.importorskip("packaging.markers")
env = {
"sys_platform": "win32",
"platform_machine": "AMD64",
"platform_system": "Windows",
"os_name": "nt",
"python_version": "3.13",
"python_full_version": "3.13.2",
"implementation_name": "cpython",
"platform_python_implementation": "CPython",
}
urls: list[str] = []
for dep in deps:
spec, _, marker_text = dep.partition(";")
if "xformers @ " not in spec:
continue
if marker_text.strip() and not markers.Marker(marker_text.strip()).evaluate(env):
continue
urls.append(spec.split("@", 1)[1].strip())
return urls
@pytest.mark.parametrize(("family", "torch_tag"), sorted(XFORMERS_WHEEL_MATRIX))
def test_windows_resolves_a_cuda_matched_wheel(family: str, torch_tag: str):
"""`unsloth[cu128-torch2100]` / `unsloth[cu130-torch2100]` and friends must land on
a win_amd64 wheel served by their OWN CUDA index -- never PyPI, never a neighbour's."""
version = XFORMERS_WHEEL_MATRIX[(family, torch_tag)]
deps = _extras()[f"{family}onlytorch{torch_tag}"]
urls = _windows_xformers_urls(deps)
assert len(urls) == 1, (
f"{family}onlytorch{torch_tag} must resolve exactly one xformers wheel on "
f"Windows, got {urls}"
)
assert urls[0] == (f"{WHEEL_INDEX_BASE}/{family}/xformers-{version}-cp39-abi3-win_amd64.whl")
@pytest.mark.parametrize(("family", "torch_tag"), sorted(XFORMERS_WHEEL_MATRIX))
def test_aggregate_extra_pulls_in_the_matched_wheel(family: str, torch_tag: str):
"""The user-facing `cuXXX-torchYYY` extra must reference the `only` extra that
carries the win_amd64 row, else the Windows route documented in pyproject.toml
silently degrades to whatever `unsloth[huggingface]` drags in."""
aggregate = _extras()[f"{family}-torch{torch_tag}"]
assert f"unsloth[{family}onlytorch{torch_tag}]" in aggregate
@pytest.mark.parametrize("torch_tag", TORCH_RELEASES_WITHOUT_XFORMERS_WHEELS)
def test_no_extras_invented_for_torch_without_xformers_wheels(torch_tag: str):
extras = _extras()
# CUDA extras only -- the intel-gpu-torch2110 / intelgputorch2110 XPU extras carry
# no xformers row and are not affected.
pattern = re.compile(rf"^cu\d+(?:only)?-?torch{torch_tag}$")
offenders = [n for n in extras if pattern.match(n)]
assert offenders == [], (
f"no xFormers wheel is published for torch {torch_tag}; extras {offenders} "
"would resolve a wheel built for a different torch minor"
)
def test_windows_extra_xformers_spec_is_a_version_range():
"""The windows extra is the CUDA-agnostic fallback, so it must stay a plain range.
It is deliberately uncapped. 0.0.35 declares torch>=2.10 rather than an exact pin
because xFormers moved to the PyTorch stable API/ABI in 0.0.34, and upstream states
that such builds "will be compatible with any later version". A cap would also strand
anyone on torch 2.10.1, since 0.0.34 pins torch==2.10.0 exactly. The CUDA family is
the axis that has to match, and this extra cannot see it -- install.ps1 does that.
"""
deps = _extras()["windows"]
specs = [d for d in deps if d.split(";")[0].strip().startswith("xformers")]
assert len(specs) == 1, f"expected one xformers spec in the windows extra, got {specs}"
spec = specs[0].split(";")[0].strip()
assert "xformers @ " not in spec, (
"the windows extra is the CUDA-agnostic fallback and must stay a version range; "
"a direct URL here hard-pins torch for every Windows user"
)
def test_windows_extra_documents_the_cuda_matched_route():
"""The comment block is load bearing: it is the only place a Windows user is told
that `unsloth[windows]` cannot pick a CUDA-matched wheel and `unsloth[cu130-torch2100]`
can. Losing it is how this regressed the first time."""
text = PYPROJECT.read_text(encoding = "utf-8")
header = text.split("\nwindows = [", 1)[0]
assert "unsloth[cu130-torch2100]" in header
assert "unsloth[cu128-torch2100]" in header
def test_xformers_matrix_agrees_with_wheel_utils():
"""One matrix, three consumers (pyproject, wheel_utils, install.ps1). Drift here is
exactly the bug: a runtime resolver that disagrees with the packaged pin."""
source = (REPO_ROOT / "studio" / "backend" / "utils" / "wheel_utils.py").read_text(
encoding = "utf-8"
)
body = re.search(
r"_XFORMERS_WHEEL_VERSIONS[^=]*=\s*\{(.*?)^\}", source, re.DOTALL | re.MULTILINE
)
assert body, "could not find _XFORMERS_WHEEL_VERSIONS in wheel_utils.py"
for (family, torch_tag), version in XFORMERS_WHEEL_MATRIX.items():
release = f"{torch_tag[0]}.{torch_tag[1:-1]}.{torch_tag[-1]}" # "2100" -> "2.10.0"
row = re.search(rf'^\s*"{re.escape(release)}":\s*\{{(.*?)\}}', body.group(1), re.MULTILINE)
assert row, f"wheel_utils has no row for torch {release}"
assert f'"{family}": "{version}"' in row.group(1), (
f"wheel_utils torch {release} row must map {family} -> {version}, got "
f"{row.group(1)!r}"
)
def test_install_ps1_matrix_agrees_with_pyproject():
source = (REPO_ROOT / "install.ps1").read_text(encoding = "utf-8")
body = re.search(
r"\$script:XformersWheelVersions\s*=\s*@\{(.*?)^\s*\}", source, re.DOTALL | re.MULTILINE
)
assert body, "could not find $script:XformersWheelVersions in install.ps1"
for (family, torch_tag), version in XFORMERS_WHEEL_MATRIX.items():
release = f"{torch_tag[0]}.{torch_tag[1:-1]}.{torch_tag[-1]}"
row = re.search(
rf'^\s*"{re.escape(release)}"\s*=\s*@\{{(.*?)\}}', body.group(1), re.MULTILINE
)
assert row, f"install.ps1 has no row for torch {release}"
assert f'"{family}" = "{version}"' in row.group(1), (
f"install.ps1 torch {release} row must map {family} -> {version}, got "
f"{row.group(1)!r}"
)