* Windows: unblock the consumer install on clean and no-winget machines Four independent things stop a clean Windows box today. git was a hard Exit-SetupFailure in setup.ps1, justified as required by pip for git+https:// deps and by npm. Neither holds on the consumer path: the unsloth-zoo git+https URL is only used under STUDIO_LOCAL_INSTALL, node is a pinned nodejs.org prebuilt that never touches system npm, and the frontend lockfile has no VCS dependencies. It stays fatal for --local, where it really is needed. Ensure-VCRedist was winget-only, so on hosts without winget (LTSC, Server, managed corporate images) it silently did nothing while the install reported success, and torch then failed to import on a missing VCRUNTIME140.dll. Adds a direct aka.ms/vs/17/release/vc_redist.<arch>.exe download with /quiet /norestart, accepting exit codes 0 and 3010. The redistributable stays required: it is the runtime the prebuilt llama-server and torch link against, not the MSVC compiler, which is already detection-only. Windows on ARM has no PyTorch at all. Measured with uv against download.pytorch.org/whl/cpu and PyPI for aarch64-pc-windows-msvc / cp313: torch, torchvision and torchaudio all resolve to nothing, wheels exist only for win_amd64 and the manylinux targets. The installer burned three uv retries on an unsatisfiable resolution and reported a bare 'Failed to install PyTorch (exit code 1)'. Now it says what is actually wrong and points at --no-torch, which works because llama.cpp does publish windows-arm64-cpu. install_node_prebuilt.py hit '[WinError 5] Access is denied' on os.replace of the freshly extracted directory during a FRESH install, which is a scanner or indexer holding handles for a moment. Retries only winerror 5, 32 and 145 with capped exponential backoff; any other OSError still raises immediately. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Give the ARM64 dead end a recovery that works for web installs The only remedy printed was .\install.ps1 --no-torch, but the documented path is irm | iex, where no file exists and flags cannot be forwarded. Name the env var the script already honours at line 145. * Windows on ARM: drop torchaudio, do not abort the install The fail-fast was based on a wrong premise. Counted against download.pytorch.org/whl/cpu: torch has 42 win_arm64 wheels and torchvision 60; only torchaudio has none. PyTorch has shipped Arm-native Windows builds since April 2025, so aborting blocked a platform that mostly works. Drop the one unsatisfiable pin instead. Decide from the interpreter uv will resolve for, not the PowerShell host: an x64 CPython under emulation gets working win_amd64 wheels on an ARM64 box, and powershell.exe inherits PROCESSOR_ARCHITECTURE from its parent. * Carry the ARM64 torchaudio omission into studio setup Dropping it from the first PyTorch command was not enough: install.ps1 then runs studio setup with SKIP_STUDIO_BASE=1 and setup.ps1 reinstalls the bare trio from the CPU index, so the ARM64 path still aborted. Apply the same interpreter-based test there. An unreadable platform keeps the full trio. * Build the torch spec list outside the verbose branch The ARM64 guard landed inside `if ($script:UnslothVerbose)`, so on the default path $_torchTrio was never assigned and the splat expanded to nothing: uv ran as `uv pip install --index-url ...` with no package, exit 2, straight to Exit-SetupFailure. That broke the ordinary Windows install. Hoist it above the branch and use substep, which prints on both paths. Realign the two parity guards to the splat form; they asserted the pre-refactor literal command and were the actual cause of the red parity legs. Both halves are still checked: the bounded list is built, and it reaches the install. * Tighten the comments on the Windows install path * Windows install: honour the ARM64 torchaudio skip everywhere and keep git for source builds Hoist the venv-interpreter platform probe above every torch branch in studio/setup.ps1 so the win_arm64 torchaudio omission applies to the ROCm, CPU and CUDA/custom paths. A pinned index whose leaf is not cpu routed an ARM64 host into the CUDA/custom branch, which still asked for torchaudio. Require git again when a llama.cpp source build is opted into up front (UNSLOTH_LLAMA_FORCE_COMPILE, UNSLOTH_LLAMA_PR / PR_FORCE, a non-upstream source). Those paths git clone in phase 4, so setup used to report git as not required, install the build toolchain, then fail at the clone. A local llama.cpp dir overrides them, and the automatic source fallback after a failed prebuilt download stays non-fatal. Also tighten the comments across the changed install paths. * Install the x64 VC++ runtime unconditionally in the direct-download fallback The winget branch always installs Microsoft.VCRedist.2015+.x64, but the direct-download fallback picked the package from PROCESSOR_ARCHITECTURE, which reports the architecture of the running PowerShell process rather than the interpreter that will load the DLLs. Find-CompatiblePython in install.ps1 selects an interpreter on version and non-Conda status alone, with no architecture predicate, so a native ARM64 shell can settle on an emulated x64 Python whose win_amd64 torch and prebuilt llama-server need the x64 runtime, while the fallback had just installed the ARM64-only package. Ensure-VCRedist also runs well before the venv exists, so the interpreter cannot be probed at that point. Microsoft ships the x64 redistributable as an Arm64X superset that carries both ARM64 and x64 binaries, so it is correct on both machines and the manual instruction printed on failure already pointed at it. * Windows on ARM: prefer an x64 Python interpreter An ARM64 host cannot complete the install with a native ARM64 interpreter. pyarrow, pulled in by unsloth -> datasets, has never published a win_arm64 wheel on any version, and neither has hf-transfer, a direct dependency. Both therefore fall back to a source build: pyarrow dies in scikit-build-core CMake configuration and hf-transfer dies in openssl-sys for want of perl, several minutes into a run that looked healthy. torch and torchvision are not the problem, they have win_arm64 wheels and install fine. Windows 11 on ARM runs x64 binaries under emulation and both packages ship win_amd64 wheels, so an x64 interpreter installs cleanly. Find-CompatiblePython accepted an interpreter on version and non-Conda status alone. It now ranks candidates by architecture on ARM64 hosts and returns an x64 one when present, asking each interpreter for its own sysconfig.get_platform() rather than guessing from its path. Host architecture comes from PROCESSOR_ARCHITEW6432 and OSArchitecture as well as PROCESSOR_ARCHITECTURE, which describes only the current process and reads AMD64 in an emulated shell. This is a preference, not a requirement. If only ARM64 is found, x64 is bootstrapped through winget --architecture x64 or the python.org fallback, and if neither works the installer names pyarrow and hf-transfer up front instead of failing later on a CMake or Rust error. The ARM64 torchaudio skip stays live for that path. Non-ARM hosts return on the first match exactly as before, with no extra interpreter probing. * Windows install: three correctness fixes on the ARM64 and git-less paths Ensure-VCRedist never reached its x64 download on an ARM64 machine that already had the arm64 redistributable: Test-VCRedistInstalled accepted System32\vcruntime140_1.dll regardless of architecture, and there that file can be the pure-ARM64 package. An ARM64 PE cannot load into an emulated x64 process, so the x64 Python this branch now prefers would have been left without a usable runtime. The x64 registry entry is the only x64-specific proof, and Microsoft registers Runtimes\{x86|x64|arm64} per architecture, so vc_redist.x64.exe still writes Runtimes\x64 on an ARM64 host and the check cannot loop. The DLL probe stays for x64 hosts. Phase 1 demanded git for any non-blank UNSLOTH_LLAMA_PR_FORCE, but the promotion that actually turns it into a source build requires a positive integer, so PR_FORCE=0 or a non-numeric value aborted a git-less consumer install for a build that never runs. Both sites now use the same predicate. The automatic fallback after a failed prebuilt llama.cpp download reached git clone with no git check anywhere in between, and Invoke-SetupCommand returns 0 for a command-not-found, so a git-less host did not stop there: it continued into an empty directory and reported a cmake configure failure instead. Git is now resolved where the source build is decided, with a last winget attempt, and a missing git degrades exactly like a missing cmake rather than aborting, since the opt-in source triggers already required git in Phase 1. Also tightened the comments across the changed Windows install code, keeping the reasons on the guards that prevent a specific failure. * Rank ARM64 Python candidates by minor version before architecture The x64 preference filtered the whole candidate list on architecture, which outranks the version preference the candidates were collected in. With UNSLOTH_PYTHON=3.12 on a Windows ARM64 box holding an ARM64 3.12 and an x64 3.13, it returned the x64 3.13: the explicit pin was silently broken, and because a x64 interpreter was found the caller never ran Install-X64Python to fetch an x64 3.12. With no pin it was worse still, since an x64 3.11 outranked a newer ARM64 3.13 and defeated the newest-first fallback. Walk $minors in order and take the x64 build of the best minor available, falling back to that minor's ARM64 build so the caller bootstraps x64 for the version actually requested. x64 still wins within a minor, and non-ARM hosts are untouched. * Windows install: see every registered Python, order git before the toolchain Find-CompatiblePython only ever probed `py -3.X`, which runs the launcher's preferred build for that minor. On an ARM64 box that is the native ARM64 interpreter, so a same-minor x64 install that is registered with the launcher but neither preferred nor on PATH never became a candidate. The x64 preference then lost to ARM64, and Install-X64Python re-downloaded an x64 CPython that was already on the machine; when that download is unavailable the install continues on ARM64 and source-builds pyarrow and hf-transfer, which publish no win_arm64 wheels. Enumerate `py -0p` on ARM64 hosts and probe each listed path. The `-3.12-64` suffix cannot be used for this: it has meant "not 32-bit" since 3.11 and does not distinguish arm64 from amd64. studio/setup.ps1 ran Ensure-BuildToolsForLlamaSourceBuild before checking git in Phase 4. That helper calls Exit-SetupFailure when Visual Studio Build Tools cannot be installed, so on a clean no-winget box the git degraded path added by this PR was unreachable and a standalone update aborted instead of finishing in limited mode; where winget does exist it spent a multi-GB Build Tools download on a clone that could never run. Check and install git first, skip the toolchain helper when git is still missing, and report the git branch before the cmake branch so the message names the real cause. _swap_into_place retried the forward rename for about 16 seconds but rolled back with a bare os.replace. A scanner holding the backup for the same WinError 5/32 then left no install_dir at all and stranded the working runtime in .old-*, and its exception replaced the original failure. The rollback now uses the same backoff and logs instead of masking the error it is recovering from. * Installer: use an already installed x64 Python on ARM64 when none can be downloaded Find-CompatiblePython ranks x64 within one minor and returns the native build when that minor is ARM64-only, leaving Install-X64Python to bootstrap x64. On an offline or winget-less box that bootstrap fails, and the retry went through the same resolver, so an x64 build of a lower-priority supported minor already on the machine was never picked up and setup continued on ARM64 Python, where pyarrow and hf-transfer have no wheels. Add an -X64Only mode that returns the best installed x64 interpreter or nothing, and call it as the last resort in Install-X64Python. The version-first preference is unchanged: x64 of the requested minor is still bootstrapped first. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in the Windows ARM64 installer changes * Setup: require Git for a source build behind an unbuilt local llama.cpp dir UNSLOTH_LOCAL_LLAMA_CPP_DIR only overrides the source-build opt-ins once the directory holds a reusable llama-server.exe. Pointing it at the canonical install location with nothing built there falls through to the normal install, so the Phase 1 gate now probes the same layout candidates as the Phase 4 reuse check before dropping the requirement. * Setup: require Git when UNSLOTH_LLAMA_TAG=master forces a source build * Tighten comments in the Windows installer changes * Setup: negotiate TLS 1.2 for the direct VC++ runtime download --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com>
143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Windows on ARM: install.ps1 must not settle for a native ARM64 interpreter.
|
|
|
|
pyarrow (via datasets) and hf-transfer publish no win_arm64 wheels, so an ARM64
|
|
Python source-builds both and dies minutes into the run. The resolver prefers an
|
|
x64 build of the requested minor and bootstraps one otherwise; the case pinned
|
|
here is the recovery path, where nothing can be downloaded but an x64 build of a
|
|
lower-priority supported minor is already installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
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 _extract(pattern: str, source: str) -> str:
|
|
match = re.search(pattern, source, flags = re.DOTALL)
|
|
assert match is not None, f"install.ps1 block not found: {pattern}"
|
|
return match.group(0)
|
|
|
|
|
|
def _resolver_script(installed: list[tuple[str, str]], can_download: bool) -> str:
|
|
"""Both production functions verbatim, over a fake set of interpreters.
|
|
|
|
Extracted rather than reimplemented so the test cannot drift away from the
|
|
text install.ps1 actually runs. `installed` is (minor, arch) in py-launcher
|
|
order, so the first entry for a minor is what a bare `py -3.13` resolves to.
|
|
The fake interpreters are named `*.exe` and invoked through the call operator,
|
|
which resolves a string to a function, so no real binary is needed.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
finder = _extract(r" function Find-CompatiblePython \{.*?\n \}\n", source)
|
|
installer = _extract(r" function Install-X64Python \{.*?\n \}\n", source)
|
|
|
|
names = [f"Py{minor.replace('.', '')}{arch}.exe" for minor, arch in installed]
|
|
table = ", ".join(
|
|
f'@{{ Minor = "{minor}"; Arch = "{arch}"; Name = "{name}" }}'
|
|
for (minor, arch), name in zip(installed, names)
|
|
)
|
|
downloaded = (
|
|
'@{ Version = "3.13"; Path = "Downloaded.exe"; Arch = "x86_64" }'
|
|
if can_download
|
|
else "$null"
|
|
)
|
|
version_stubs = "\n".join(
|
|
f"function {name} {{ param([Parameter(ValueFromRemainingArguments = $true)]$Rest)\n"
|
|
f' if ($Rest -contains "--version") {{ return "Python {minor}.0" }}\n'
|
|
f' return "{name}" }}'
|
|
for (minor, _arch), name in zip(installed, names)
|
|
)
|
|
return f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$PythonVersion = "3.13"
|
|
$script:WingetAvailable = $false
|
|
$script:CondaSkipPattern = 'conda'
|
|
$Interpreters = @({table})
|
|
{version_stubs}
|
|
# `py -0p` lists every registration; `py -3.x` runs the launcher's preferred build
|
|
# for that minor, which on an ARM64 host is normally the native one.
|
|
function FakePy {{
|
|
param([Parameter(ValueFromRemainingArguments = $true)]$Rest)
|
|
if ($Rest -contains "-0p") {{
|
|
return @($Interpreters | ForEach-Object {{ " -V:$($_.Minor) * $($_.Name)" }})
|
|
}}
|
|
$minor = ([string]$Rest[0]).TrimStart('-')
|
|
$hit = @($Interpreters | Where-Object {{ $_.Minor -eq $minor }})
|
|
if ($hit.Count -eq 0) {{ return "" }}
|
|
if ($Rest -contains "--version") {{ return "Python $minor.0" }}
|
|
return $hit[0].Name
|
|
}}
|
|
function substep {{ param($a, $b) }}
|
|
function Get-HostMachineArch {{ return "arm64" }}
|
|
function Get-Command {{
|
|
param([Parameter(Position = 0)][string]$Name,
|
|
[Parameter(ValueFromRemainingArguments = $true)]$Rest)
|
|
if ($Name -eq "py") {{ return @([pscustomobject]@{{ Source = "FakePy" }}) }}
|
|
return @()
|
|
}}
|
|
function Test-Path {{ param([Parameter(ValueFromRemainingArguments = $true)]$Rest) return $true }}
|
|
function Test-IsCondaPython {{ param([string]$Exe) return $false }}
|
|
function Get-PythonPlatformTag {{
|
|
param([string]$Exe)
|
|
foreach ($i in $Interpreters) {{
|
|
if ($i.Name -eq $Exe) {{
|
|
if ($i.Arch -eq "x86_64") {{ return "win-amd64" }} else {{ return "win-arm64" }}
|
|
}}
|
|
}}
|
|
return "win-amd64"
|
|
}}
|
|
function Refresh-SessionPath {{ }}
|
|
function Install-PythonFromPythonOrg {{ param([string]$Arch = "") return {downloaded} }}
|
|
{finder}
|
|
{installer}
|
|
# The caller's ARM64 swap, condensed to what decides the interpreter.
|
|
$found = Find-CompatiblePython
|
|
if ($found -and $found.Arch -ne "x86_64") {{
|
|
$x64 = Install-X64Python
|
|
if ($x64) {{ $found = $x64 }}
|
|
}}
|
|
if ($found) {{ Write-Output "$($found.Version)|$($found.Arch)" }} else {{ Write-Output "none" }}
|
|
"""
|
|
|
|
|
|
def _pwsh(script: str) -> str:
|
|
result = subprocess.run(
|
|
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
check = True,
|
|
capture_output = True,
|
|
text = True,
|
|
env = os.environ.copy(),
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize(
|
|
("installed", "can_download", "expected"),
|
|
[
|
|
# An x64 build of the requested minor wins outright, downloads irrelevant.
|
|
([("3.13", "arm64"), ("3.13", "x86_64")], False, "3.13|x86_64"),
|
|
# Requested minor is ARM64-only: bootstrap x64 rather than take the native one.
|
|
([("3.13", "arm64")], True, "3.13|x86_64"),
|
|
# Offline, but an x64 build of a lower-priority minor is here. Use it: the native
|
|
# 3.13 cannot resolve pyarrow or hf-transfer, and this one can.
|
|
([("3.13", "arm64"), ("3.11", "x86_64")], False, "3.11|x86_64"),
|
|
# ARM64 everywhere: still returned, and the caller warns.
|
|
([("3.13", "arm64"), ("3.11", "arm64")], False, "3.13|arm64"),
|
|
],
|
|
)
|
|
def test_arm64_host_prefers_an_x64_interpreter(installed, can_download, expected):
|
|
assert _pwsh(_resolver_script(installed, can_download)) == expected
|