Fix Windows no-torch setup (#7511)
* Fix Windows no-torch setup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix no-torch env normalization on Windows * Accept on for Windows no-torch mode * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep no-torch mode across studio update on Windows Guarding the direct torch/Triton install made `install.ps1 --no-torch` actually produce a torch-free venv, which then broke the next `unsloth studio update`. That path exports no UNSLOTH_NO_TORCH, so $NoTorchMode was false, the stale-venv check read the missing torch as a broken venv, and setup tried to delete the venv it was running out of: [ERROR] Could not remove stale venv: Access to the path 'python.exe' is denied. That teardown can never succeed there, because setup.ps1 runs via unsloth.exe out of that same venv. The same gap also let the shared dependency pass reinstall torch from PyPI, unpinned, into a GGUF-only environment. install_python_stack.py now records the mode in the install manifest and setup.ps1 reads it back when no env var is exported, then re-exports a canonical value for the dependency pass (setup.ps1 drops the manifest before invoking it, so the child cannot repeat the lookup). The key is additive and MANIFEST_SCHEMA is unchanged, so existing manifests stay valid and a missing key keeps today's behaviour. Also: - read_manifest() caught only OSError, but UnicodeDecodeError is a ValueError. That is now on the installer's import path, so a manifest re-saved as ANSI or truncated mid-write would abort every install. - The env predicate now trims surrounding whitespace, matching the Python side. - The Windows update smoke workflow asserts the update leaves the venv GGUF-only, which is what would have caught this. Known follow-up, pre-existing: an install killed between the manifest drop and the dependency pass leaves no recorded mode, so a later update still walks the stale-venv path. Closing that needs a marker the installer never drops. * Persist no-torch mode in a marker the dependency pass cannot drop The install manifest alone was not enough. Both setup.ps1 and install_python_stack.py remove it before every dependency pass, and it is only rewritten on success, so a no-torch install interrupted in between left nothing recording the mode. The next update then resolved no-torch as false, read the expected missing torch as a stale venv, and tried to delete the environment whose python.exe was running it, which leaves the install unrepairable from the CLI. Add .unsloth-no-torch next to the existing .unsloth-studio-owned marker, written before the pass and cleared when torch is wanted. setup.ps1 writes it as soon as the mode resolves, so the window between the manifest drop and its own torch install is covered too. Read order stays manifest key first, then marker, so migrating out of no-torch is never blocked by a marker an earlier run left behind. Neither present still reads as "install torch", so nothing changes for installs made before either existed. Also adds the AGPL-3.0 header the new test file was missing. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com>
This commit is contained in:
parent
6818318867
commit
d7594ec10f
9 changed files with 540 additions and 9 deletions
|
|
@ -30,6 +30,16 @@ from typing import Dict, List, Optional, Tuple
|
|||
MANIFEST_NAME = "unsloth_install_manifest.json"
|
||||
MANIFEST_SCHEMA = 1
|
||||
|
||||
# Canonical truthy set for UNSLOTH_NO_TORCH, matching install.ps1 / install.sh.
|
||||
NO_TORCH_TRUTHY: Tuple[str, ...] = ("1", "true", "yes", "on")
|
||||
|
||||
# Companion to the no_torch manifest key, next to setup.ps1's .unsloth-studio-owned.
|
||||
# The manifest is deliberately dropped before every dependency pass, so it cannot
|
||||
# answer for a run killed mid-pass; this marker is written before that pass and
|
||||
# outlives it. Without it an interrupted GGUF-only install reads as a stale venv on
|
||||
# the next update, which then tries to delete the venv it is running out of.
|
||||
NO_TORCH_MARKER = ".unsloth-no-torch"
|
||||
|
||||
# Fingerprinted into the manifest, relative to studio/backend/requirements/.
|
||||
# Editing one (a --local install) invalidates it and forces a dependency pass.
|
||||
TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = (
|
||||
|
|
@ -116,6 +126,7 @@ def write_manifest(
|
|||
req_root: Optional[Path] = None,
|
||||
steps_total: int = 0,
|
||||
package_name: str = "unsloth",
|
||||
no_torch: Optional[bool] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Record a completed install. Never raises: no manifest reads as incomplete,
|
||||
which is the safe answer."""
|
||||
|
|
@ -130,6 +141,14 @@ def write_manifest(
|
|||
"steps_total": steps_total,
|
||||
"requirement_files": requirement_digests(req_root),
|
||||
}
|
||||
# Additive, so MANIFEST_SCHEMA does not move and every existing manifest stays
|
||||
# valid. Absent means "unknown", which is NOT False: only a manifest written by
|
||||
# a build that knew about the key can answer, and callers fall back to their own
|
||||
# detection otherwise. Recorded because install.ps1 / install.sh export
|
||||
# UNSLOTH_NO_TORCH for their own run only -- a later `unsloth studio update`
|
||||
# exports nothing and would otherwise reinstall torch into a GGUF-only venv.
|
||||
if no_torch is not None:
|
||||
payload["no_torch"] = bool(no_torch)
|
||||
path = manifest_path(root)
|
||||
try:
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
|
|
@ -143,7 +162,12 @@ def write_manifest(
|
|||
def read_manifest(root: Optional[Path] = None) -> Optional[dict]:
|
||||
try:
|
||||
raw = manifest_path(root).read_text(encoding = "utf-8")
|
||||
except OSError:
|
||||
# UnicodeDecodeError is a ValueError, not an OSError: a manifest re-saved as
|
||||
# ANSI by an editor (the payload embeds the user profile path, so non-ASCII
|
||||
# names show up there) or truncated mid-write must read as "no manifest", not
|
||||
# raise. install_python_stack.py resolves no-torch mode through here at import,
|
||||
# so anything escaping aborts the whole install.
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
|
|
@ -152,6 +176,52 @@ def read_manifest(root: Optional[Path] = None) -> Optional[dict]:
|
|||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def no_torch_marker_path(root: Optional[Path] = None) -> Path:
|
||||
return (root or venv_root()) / NO_TORCH_MARKER
|
||||
|
||||
|
||||
def set_no_torch_marker(no_torch: bool, root: Optional[Path] = None) -> None:
|
||||
"""Record the mode outside the completion manifest. Never raises.
|
||||
|
||||
Written before the dependency pass so an interrupted install still knows what
|
||||
it was building. Removed when torch is wanted, so migrating out of no-torch
|
||||
does not leave a stale marker behind.
|
||||
"""
|
||||
path = no_torch_marker_path(root)
|
||||
try:
|
||||
if no_torch:
|
||||
path.write_text("", encoding = "utf-8")
|
||||
else:
|
||||
path.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def recorded_no_torch(root: Optional[Path] = None) -> Optional[bool]:
|
||||
"""The mode this venv was installed with, or None when unknown.
|
||||
|
||||
None means nothing recorded it: no manifest key and no marker. Callers must
|
||||
fall back to their own detection on None and never to False, so an install
|
||||
made before either existed is not silently switched out of no-torch mode.
|
||||
"""
|
||||
manifest = read_manifest(root)
|
||||
if manifest is not None:
|
||||
value = manifest.get("no_torch")
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
# Tolerate a hand-edited manifest that used a string.
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in NO_TORCH_TRUTHY
|
||||
# No manifest (dropped before the dependency pass, or the install was killed
|
||||
# during it) or one predating the key: the marker is the durable answer.
|
||||
try:
|
||||
if no_torch_marker_path(root).exists():
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_requirement_line(line: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""(distribution name, marker, specifier) for a requirement, or None.
|
||||
|
||||
|
|
|
|||
|
|
@ -2215,13 +2215,28 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|||
def _infer_no_torch() -> bool:
|
||||
"""Determine whether to run in no-torch (GGUF-only) mode.
|
||||
|
||||
Checks UNSLOTH_NO_TORCH first. When unset, falls back to platform
|
||||
detection so Intel Macs use GGUF-only mode even when invoked from
|
||||
``unsloth studio update`` (which does not inject the env var).
|
||||
Precedence: UNSLOTH_NO_TORCH (install.sh / install.ps1 export it, "false"
|
||||
included, so an explicit value always wins) -> the mode recorded in this
|
||||
venv's install manifest -> platform detection, so Intel Macs use GGUF-only
|
||||
mode even when invoked from ``unsloth studio update``.
|
||||
|
||||
The manifest tier is what keeps ``unsloth studio update`` in no-torch mode:
|
||||
it injects no env var, so without it every update reinstalls torch into a
|
||||
GGUF-only venv. Note setup.ps1 resolves the mode itself and re-exports
|
||||
UNSLOTH_NO_TORCH, because it drops the manifest before invoking this script.
|
||||
|
||||
An empty value counts as unset: PowerShell cannot represent a set-but-empty
|
||||
variable (assigning "" deletes it), so the two must mean the same thing here.
|
||||
|
||||
Evaluated at import, which is before install_python_stack() drops the
|
||||
manifest. Do not defer this call into main().
|
||||
"""
|
||||
env = os.environ.get("UNSLOTH_NO_TORCH")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true")
|
||||
if env is not None and env.strip():
|
||||
return env.strip().lower() in install_manifest.NO_TORCH_TRUTHY
|
||||
recorded = install_manifest.recorded_no_torch()
|
||||
if recorded is not None:
|
||||
return recorded
|
||||
return IS_MAC_INTEL
|
||||
|
||||
|
||||
|
|
@ -2871,6 +2886,11 @@ def install_python_stack() -> int:
|
|||
)
|
||||
return 1
|
||||
|
||||
# The manifest just went away, so record the mode in a marker that survives a
|
||||
# pass killed part-way. Otherwise the next update sees neither, reads the
|
||||
# absent torch as a stale venv, and tries to delete the running environment.
|
||||
install_manifest.set_no_torch_marker(NO_TORCH)
|
||||
|
||||
# 1. Try uv for faster installs (before pip upgrade -- uv venvs don't
|
||||
# include pip by default).
|
||||
USE_UV = _bootstrap_uv()
|
||||
|
|
@ -3256,6 +3276,7 @@ def install_python_stack() -> int:
|
|||
req_root = REQ_ROOT,
|
||||
steps_total = _TOTAL,
|
||||
package_name = package_name,
|
||||
no_torch = NO_TORCH,
|
||||
)
|
||||
is None
|
||||
):
|
||||
|
|
|
|||
|
|
@ -2661,6 +2661,8 @@ $VenvDir = Join-Path $StudioHome "unsloth_studio"
|
|||
# the canonical comparison so an override pointing at the legacy default
|
||||
# still behaves like a default install.
|
||||
$StudioOwnedMarker = ".unsloth-studio-owned"
|
||||
# Mirrors install_manifest.NO_TORCH_MARKER; keep the two in step.
|
||||
$NoTorchMarker = ".unsloth-no-torch"
|
||||
$LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
$_studioHomeCanon = $StudioHome
|
||||
if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) {
|
||||
|
|
@ -2704,13 +2706,71 @@ function Mark-StudioOwned {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
# The mode this venv was installed with. install.ps1 exports UNSLOTH_NO_TORCH for
|
||||
# its own run only, so a later `unsloth studio update` (which exports nothing) has
|
||||
# no other way to know. Two sources, because the completion manifest is dropped
|
||||
# before every dependency pass and so cannot answer for a run killed mid-pass:
|
||||
# the manifest key first, then .unsloth-no-torch, which outlives the pass. Neither
|
||||
# present reads as "install torch" -- the pre-existing behavior.
|
||||
function Get-PersistedNoTorch {
|
||||
param([Parameter(Mandatory = $true)][string]$VenvPath)
|
||||
$manifestPath = Join-Path $VenvPath "unsloth_install_manifest.json"
|
||||
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
|
||||
$payload = $null
|
||||
try {
|
||||
$payload = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
} catch {
|
||||
$payload = $null
|
||||
}
|
||||
if ($null -ne $payload -and $null -ne $payload.no_torch) {
|
||||
return ("$($payload.no_torch)" -match '^\s*(?i:true|1|yes|on)\s*$')
|
||||
}
|
||||
}
|
||||
return (Test-Path -LiteralPath (Join-Path $VenvPath $NoTorchMarker) -PathType Leaf)
|
||||
}
|
||||
|
||||
# Written before anything that could be interrupted, and cleared when torch is
|
||||
# wanted so migrating out of no-torch leaves nothing stale behind.
|
||||
function Set-PersistedNoTorch {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$VenvPath,
|
||||
[Parameter(Mandatory = $true)][bool]$NoTorch
|
||||
)
|
||||
if (-not (Test-Path -LiteralPath $VenvPath -PathType Container)) { return }
|
||||
$markerPath = Join-Path $VenvPath $NoTorchMarker
|
||||
try {
|
||||
if ($NoTorch) {
|
||||
[System.IO.File]::WriteAllText($markerPath, "")
|
||||
} elseif (Test-Path -LiteralPath $markerPath -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Stale-venv detection: if the venv exists but its torch flavor no longer
|
||||
# matches the current machine, repair according to invocation context.
|
||||
# - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate
|
||||
# to the installer-level rollback that restores the previous environment.
|
||||
# - direct `unsloth studio update` keeps the pre-existing self-repair behavior.
|
||||
# In no-torch mode, a missing torch package is expected.
|
||||
$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$'
|
||||
$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^\s*(?i:true|1|yes|on)\s*$'
|
||||
# No env var at all means `unsloth studio update` / `studio setup` / setup.bat,
|
||||
# none of which export one. Without the manifest fallback the check below reads a
|
||||
# GGUF-only venv's missing torch as a stale venv and tries to delete the venv this
|
||||
# script is itself running out of, which fails on a locked python.exe.
|
||||
if (-not $NoTorchMode -and [string]::IsNullOrWhiteSpace($env:UNSLOTH_NO_TORCH)) {
|
||||
$NoTorchMode = Get-PersistedNoTorch -VenvPath $VenvDir
|
||||
if ($NoTorchMode) {
|
||||
substep "no-torch install detected -- keeping this environment GGUF-only." "Yellow"
|
||||
}
|
||||
}
|
||||
# Persist before the torch install and the dependency pass below, either of which
|
||||
# can be interrupted; install_python_stack.py refreshes the same marker.
|
||||
Set-PersistedNoTorch -VenvPath $VenvDir -NoTorch $NoTorchMode
|
||||
# install_python_stack.py drops the manifest before its dependency pass, so it
|
||||
# cannot repeat the lookup above; hand it the resolved answer. This also collapses
|
||||
# every accepted spelling to one value both sides parse identically.
|
||||
$env:UNSLOTH_NO_TORCH = if ($NoTorchMode) { "true" } else { "false" }
|
||||
$InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$'
|
||||
if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
||||
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
|
||||
|
|
@ -3214,6 +3274,7 @@ $PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR
|
|||
# goes through $ROCmIndexUrl; on failure the fallback uses the CPU index, not the ROCm pin.
|
||||
$TorchInstallIndexUrl = if ($ROCmIndexUrl) { "$PyTorchWhlBase/cpu" } elseif ($PinnedTorchIndexUrl) { $PinnedTorchIndexUrl } else { "$PyTorchWhlBase/$CuTag" }
|
||||
|
||||
if (-not $NoTorchMode) {
|
||||
$ROCmCpuFallback = $false
|
||||
if ($ROCmIndexUrl) {
|
||||
substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..."
|
||||
|
|
@ -3324,6 +3385,9 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) {
|
|||
substep "Triton for Windows installed (enables torch.compile)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
substep "skipping direct PyTorch and Triton installation (no-torch mode)." "Yellow"
|
||||
}
|
||||
|
||||
# No unsloth.exe rename needed. setup.ps1 runs *via* unsloth.exe, so renaming the
|
||||
# running launcher only ever failed (WinError 32) and printed a scary warning. It's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue