unsloth/studio/setup.ps1
karan yadav dc26127a42
Studio: propagate required backend version to repair pipeline (#8610) (#8670)
* Studio: propagate required backend version to repair pipeline (#8610)

Fix second-launch infinite repair loop when installed backend version is outdated (#8610).

When the Desktop App launches with an installed managed venv whose version is older than expected_backend_version() (e.g. 2026.8.4 < 2026.8.15), preflight flags the install as ManagedStale with desktop_backend_version_outdated. On auto-repair, unsloth studio update ran setup.sh/setup.ps1 from the old venv, which skipped python dependency installation because INSTALLED_VER == LATEST_VER on PyPI or PyPI timeout, leaving the venv unchanged. The installer fallback (install.sh/install.ps1) also lacked version floor pins on standard fresh paths, locking the user in a permanent repair error loop.

Key changes:
- Pass UNSLOTH_DESKTOP_BACKEND_VERSION from Tauri (update.rs & install.rs) to child process environments.
- Force Python dependency pass in setup.sh & setup.ps1 when UNSLOTH_DESKTOP_BACKEND_VERSION is set and INSTALLED_VER < UNSLOTH_DESKTOP_BACKEND_VERSION.
- Apply UNSLOTH_DESKTOP_BACKEND_VERSION floor constraint when updating core packages in install_python_stack.py.
- Ensure standard fresh install paths in install.sh and install.ps1 use "unsloth>=2026.8.15".
- Bump MIN_DESKTOP_BACKEND_VERSION in preflight/version.rs to "2026.8.15".
- Add shell and Python unit tests for the fast-path escape and desktop backend version constraint.

Fixes #8610

* Narrow desktop repair to version propagation

* Handle version suffixes in repair fallback

---------

Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-08-19 19:12:22 +01:00

6891 lines
368 KiB
PowerShell

#Requires -Version 5.1
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
<#
.SYNOPSIS
Full environment setup for Unsloth Studio on Windows (bundled version).
.DESCRIPTION
Uses an isolated, Unsloth-managed Node.js for the frontend build when the
system Node/npm do not meet requirements (never modifies the system Node).
When running from pip install: skips frontend build (already bundled). When
running from git repo: full setup including frontend build.
Supports NVIDIA GPU (full training + inference) and CPU-only (GGUF chat mode).
.NOTES
Default output is minimal (step/substep), aligned with studio/setup.sh.
FULL / LEGACY LOGGING (defensible audit trail, detailed multi-line output):
unsloth studio setup --verbose
Or: $env:UNSLOTH_VERBOSE='1'; powershell -File .\studio\setup.ps1
Or: .\setup.ps1 --verbose
#>
$ErrorActionPreference = "Stop"
# This script is spawned as powershell.exe -- Windows PowerShell 5.1 (see the PSModulePath note
# below) -- where the Invoke-WebRequest progress bar is redrawn on every read and sets the rate
# instead of the link: the VC++ runtime (24.4 MB, Ensure-VCRedist) took 38.18s with the bar on
# against 0.29s with it off on a windows-latest runner. -UseBasicParsing does not help; only this
# preference does. Script scope, in a separate short-lived process, so nothing outlives it.
$ProgressPreference = 'SilentlyContinue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$PackageDir = Split-Path -Parent $ScriptDir
# `unsloth studio update` spawns powershell.exe, which is Windows PowerShell 5.1,
# and the child inherits the caller's PSModulePath. Launched from a PowerShell 7
# prompt that path leads with PowerShell 7's module directories, which ship their
# own Microsoft.PowerShell.Security. 5.1 finds that copy first and cannot load it:
#
# The 'Get-ExecutionPolicy' command was found in the module
# 'Microsoft.PowerShell.Security', but the module could not be loaded.
#
# Any Security cmdlet reached during the run ends it there with exit 1 and no
# further output -- Get-AuthenticodeSignature, which verifies the VC++ runtime
# download, sits on this path. A try/catch does not help, because the failure is
# module loading in this process rather than an error the caller can catch.
#
# Prepended, not appended: the problem is precedence, not absence. Clearing the
# variable so 5.1 rebuilds its default does not help either, because the
# machine-level value on the windows-latest image also leads with PS7.
#
# PowerShell rewrites PSModulePath only for a direct pwsh -> powershell.exe hop,
# so any intermediate process defeats it (PowerShell/PowerShell#18681 is this
# exact chain through Python). install.ps1 carries the same block for the same
# reason; scripts/uninstall.ps1 needs none, as it loads no Security cmdlet.
#
# Not restored afterwards, deliberately. $env: is the process environment, so
# running this script in an interactive console leaves the reordering in place
# for that session. Narrowing the trigger to detect the broken chain would risk
# skipping the fix on a chain this list does not anticipate, and the cost of
# that is the install failing outright, against a session-lived module
# precedence change here. See the matching note in install.ps1.
if ($PSVersionTable.PSEdition -ne 'Core' -and $env:SystemRoot) {
$_UnslothSystemModules = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\Modules'
if (Test-Path $_UnslothSystemModules) {
$_UnslothKept = @(
$env:PSModulePath -split ';' |
Where-Object { $_ -and ($_ -ne $_UnslothSystemModules) }
)
$env:PSModulePath = (@($_UnslothSystemModules) + $_UnslothKept) -join ';'
}
}
# UTF-8 output invariant. 5.1 encodes redirected output with the OEM code page,
# but the desktop app decodes the pipe as UTF-8 (from_utf8_lossy, install.rs):
# U+1F9A5 prints as '??', U+2500 becomes a bare 0xC4 and arrives as U+FFFD.
# Must precede the first write, since this rebuilds [Console]::Out. ASCII-only,
# because 5.1 parses these BOM-less files as ANSI.
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {
[Console]::OutputEncoding = $_UnslothUtf8NoBom
} catch {
# No console (CREATE_NO_WINDOW). The setter P/Invokes SetConsoleOutputCP and
# drops the cached writer BEFORE throwing, assigning OutputEncoding only
# after, so [Console]::Out would rebuild on the OLD code page. Bind UTF-8
# writers instead: redirected step/substep use Out as their only sink, and
# Tauri decodes stderr the same way to build its failure text.
try {
$_UnslothStdout = New-Object System.IO.StreamWriter -ArgumentList ([Console]::OpenStandardOutput()), $_UnslothUtf8NoBom
$_UnslothStdout.AutoFlush = $true
[Console]::SetOut($_UnslothStdout)
$_UnslothStderr = New-Object System.IO.StreamWriter -ArgumentList ([Console]::OpenStandardError()), $_UnslothUtf8NoBom
$_UnslothStderr.AutoFlush = $true
[Console]::SetError($_UnslothStderr)
} catch { }
}
$OutputEncoding = $_UnslothUtf8NoBom
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
# Resolved once: it picks the output sink in step/substep and must not change
# mid-run. See Write-StudioStdoutMirror.
$script:StudioStdoutRedirected = $false
try { $script:StudioStdoutRedirected = [Console]::IsOutputRedirected } catch { }
# Every other line in this script reached the pipe through Write-Host, which
# 5.1's console host writes with its own writer on the OEM code page, not the
# UTF-8 [Console]::Out rebound above. Under CREATE_NO_WINDOW that writer is
# still what the desktop app reads, so the banner emoji, the U+2500 rule and
# every warning arrived as U+FFFD out of from_utf8_lossy. One sink instead: the
# console handle when redirected, Write-Host when interactive, since it is the
# only one that colorizes. Defined above the first write, for the same ordering
# reason as the encoding block.
function Write-StudioLine {
param([string]$Message = "", [string]$ForegroundColor)
if ($script:StudioStdoutRedirected) {
try { [Console]::Out.WriteLine($Message); [Console]::Out.Flush() } catch {}
return
}
if ($PSBoundParameters.ContainsKey('ForegroundColor')) {
Write-Host $Message -ForegroundColor $ForegroundColor
} else {
Write-Host $Message
}
}
# --------------------------------------------------------------------------
# Maintainer-editable defaults
# Change these in the GitHub-hosted script so users get updated defaults.
# User env vars always override these baked-in values.
# --------------------------------------------------------------------------
# Prefer "latest" over "master" -- "master" bypasses the prebuilt resolver
# (no matching GitHub release), forces a source build, and causes HTTP 422
# errors. Only use "master" temporarily when the latest release is missing
# support for a new model architecture.
#
# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", "cuda", "vulkan",
# "hip", or "rocm". Concrete values select and persist a backend across updates;
# "auto" restores detection. Overrides Studio's Settings > System selection.
$DefaultLlamaPrForce = ""
$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
$DefaultLlamaTag = "latest"
$DefaultLlamaForceCompileRef = "master"
# Corporate-mirror / proxy escape hatch for the frontend npm/bun install (#6491).
# studio/frontend/.npmrc pins registry=https://registry.npmjs.org/ as a supply-chain
# lock, which overrides a corporate user's ~/.npmrc proxy and causes 403s behind a
# firewall. UNSLOTH_NPM_REGISTRY is a deliberate opt-in: when set we splat it as
# `--registry <url>` into every npm/bun install. `--registry` is the highest-precedence
# override for BOTH tools and leaves min-release-age / save-exact in force. Empty array
# (the default) splats to nothing, so normal installs are unchanged.
$NpmRegistryArgs = @()
if ($env:UNSLOTH_NPM_REGISTRY) {
$NpmRegistryArgs = @('--registry', $env:UNSLOTH_NPM_REGISTRY)
}
# Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1.
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1')
foreach ($a in $args) {
if ($a -eq '--verbose' -or $a -eq '-v') {
$script:UnslothVerbose = $true
break
}
}
# Propagate to child processes (e.g. install_python_stack.py) so they
# also respect verbose mode. Process-scoped -- does not persist.
if ($script:UnslothVerbose) {
$env:UNSLOTH_VERBOSE = '1'
}
$script:LlamaCppDegraded = $false
# CUDA toolkit state, published by Resolve-CudaToolkit. Only the Phase 4 source
# build consumes these; the prebuilt path leaves them at these defaults.
$script:CudaToolkitReady = $false
$script:NvccPath = $null
$script:CudaToolkitRoot = $null
$script:CudaArch = $null
function Exit-SetupFailure {
param(
[Parameter(Mandatory = $true)][string]$Message,
[int]$Code = 1
)
if ($Code -eq 0) { $Code = 1 }
if ((@("1", "true") -contains $env:UNSLOTH_TAURI_MODE) -or
(@("1", "true") -contains $env:UNSLOTH_TAURI_UPDATE)) {
$singleLine = ($Message -replace '[\r\n]+', ' ').Trim()
[Console]::Out.WriteLine("[TAURI:ERROR] $singleLine")
[Console]::Out.Flush()
}
exit $Code
}
# Detect if running from pip install (no frontend/ dir in studio)
$FrontendDir = Join-Path $ScriptDir "frontend"
$OxcValidatorDir = Join-Path $ScriptDir "backend\core\data_recipe\oxc-validator"
$IsPipInstall = -not (Test-Path $FrontendDir)
# ─────────────────────────────────────────────
# Helper functions
# ─────────────────────────────────────────────
# Reload ALL environment variables from registry.
# Picks up changes made by installers (winget, msi, etc.) including
# Path, CUDA_PATH, CUDA_PATH_V*, and any other vars they set.
function Refresh-Environment {
foreach ($level in @('Machine', 'User')) {
$vars = [System.Environment]::GetEnvironmentVariables($level)
foreach ($key in $vars.Keys) {
# PSModulePath joins Path as an exception. Reloading it from the
# registry would undo the normalization at the top of this file,
# and several callers of this function run before the uv installer
# (which loads Microsoft.PowerShell.Security), so the module path
# would be broken again exactly where it has to be right.
if ($key -eq 'Path' -or $key -eq 'PSModulePath') { continue }
# Same exception, for the UTF-8 invariant at the top. This runs
# repeatedly, so a registry PYTHONUTF8=0 would otherwise reload over
# ours and every later Python child would go back to mojibake.
if ($key -eq 'PYTHONUTF8' -or $key -eq 'PYTHONIOENCODING') { continue }
Set-Item -Path "Env:$key" -Value $vars[$key] -ErrorAction SilentlyContinue
}
}
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
# Merge: venv Scripts (if active) > Machine > User > current $env:Path. Dedup raw+expanded.
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV 'Scripts' } else { $null }
$sources = @()
if ($venvScripts) { $sources += $venvScripts }
$sources += @($machinePath, $userPath, $env:Path)
$merged = ($sources | Where-Object { $_ }) -join ';'
$seen = @{}
$unique = New-Object System.Collections.Generic.List[string]
foreach ($p in $merged -split ";") {
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
$seen[$rawKey] = $true
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
$unique.Add($p)
}
}
$env:Path = $unique -join ";"
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
# Append (default) keeps existing tools first; Prepend for must-win entries.
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
try {
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$kept = New-Object System.Collections.Generic.List[string]
$matchIndices = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $entries.Count; $i++) {
$stripped = $entries[$i].Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
if ($isMatch) {
$matchIndices.Add($i)
continue
}
$kept.Add($entries[$i])
}
$alreadyPresent = $matchIndices.Count -gt 0
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
return $false
}
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
return $false
}
# One-time backup under HKCU\Software\Unsloth\PathBackup
if ($rawPath) {
try {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
} catch { }
}
if (-not $rawPath) {
Write-StudioLine "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
}
$newPath = if ($rawPath) {
if ($Position -eq 'Prepend') {
(@($Directory) + $kept) -join ';'
} else {
($kept + @($Directory)) -join ';'
}
} else {
$Directory
}
if ($newPath -ceq $rawPath) { # no actual change
return $false
}
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
return $true
} finally {
$regKey.Close()
}
} catch {
Write-StudioLine "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
}
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
function New-UnslothTemporaryFile {
$tempPath = [System.IO.Path]::GetTempFileName()
return Get-Item -LiteralPath $tempPath
}
function Remove-AgentInstructionFiles {
param([string[]]$Roots)
foreach ($root in $Roots) {
if (-not $root) { continue }
$item = Get-Item -LiteralPath $root -Force -ErrorAction SilentlyContinue
if (-not $item -or -not $item.PSIsContainer) { continue }
if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { continue }
$pending = New-Object System.Collections.Stack
$pending.Push($item)
while ($pending.Count -gt 0) {
$current = $pending.Pop()
foreach ($child in @(Get-ChildItem -LiteralPath $current.FullName -Force -ErrorAction SilentlyContinue)) {
if ($child.PSIsContainer) {
if (-not ($child.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
$pending.Push($child)
}
} elseif ($child.Name -in @("AGENTS.md", "CLAUDE.md")) {
Remove-Item -LiteralPath $child.FullName -Force -ErrorAction SilentlyContinue
}
}
}
}
}
# Recognize ERROR_ACCESS_DENIED through PowerShell's wrapper exceptions.
function Test-AccessDeniedError {
param($ErrorRecord)
$ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord }
while ($ex) {
if ($ex -is [System.UnauthorizedAccessException]) { return $true }
# IOException uses HRESULT; Win32Exception uses NativeErrorCode.
if ($ex.HResult -eq -2147024891) { return $true }
if ($ex -is [System.ComponentModel.Win32Exception] -and $ex.NativeErrorCode -eq 5) { return $true }
$ex = $ex.InnerException
}
if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) {
return ($ErrorRecord.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::PermissionDenied)
}
return $false
}
# Keep denied ACLs distinct from absent paths instead of letting Test-Path throw.
function Get-PathState {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
[ValidateSet("Any", "Leaf", "Container")][string]$PathType = "Any"
)
if ([string]::IsNullOrWhiteSpace($Path)) { return "Absent" }
try {
if (Test-Path -LiteralPath $Path -PathType $PathType -ErrorAction Stop) { return "Present" }
return "Absent"
} catch {
if (Test-AccessDeniedError $_) { return "Denied" }
# Malformed path, offline drive, dangling link: nothing usable there.
return "Absent"
}
}
# Non-throwing Test-Path for paths inside install trees we do not control.
# Callers that must react to a denial use Get-PathState instead.
function Test-PathQuiet {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
[ValidateSet("Any", "Leaf", "Container")][string]$PathType = "Any"
)
return ((Get-PathState -Path $Path -PathType $PathType) -eq "Present")
}
# install.ps1 carries a verbatim copy because it cannot dot-source this file;
# test_denied_llama_cpp_preflight.py enforces parity.
# Dir, marker and listing: no single probe separates readable/absent/denied.
function Get-LlamaCppInstallReadState {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
$dirState = Get-PathState -Path $Path -PathType Container
if ($dirState -eq "Denied") { return "Denied" }
if ($dirState -ne "Present") { return "Absent" }
switch (Get-PathState -Path (Join-Path $Path "UNSLOTH_PREBUILT_INFO.json") -PathType Leaf) {
"Denied" { return "Denied" }
}
try { $null = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | Select-Object -First 1) }
catch {
if (Test-AccessDeniedError $_) { return "Denied" }
# Nonfatal here: nothing has been installed yet.
}
return "Readable"
}
# Describe a denied link target without risking another reporting failure.
function Get-PathDenialDetail {
param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return "" }
# Non-filesystem providers do not expose FileSystemInfo attributes.
if ($item -isnot [System.IO.FileSystemInfo]) { return "" }
if (-not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return "" }
$target = $null
try { $target = $item.Target } catch { $target = $null }
# PS 5.1 exposes .Target as a collection; PS 7 as a string.
if ($target) { return " (it is a link to $(@($target) -join ', '))" }
return " (it is a link)"
}
# Print guidance; returns the failure reason as its only pipeline output.
function Write-PathAccessDenied {
param(
[Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Label,
# Never tell users to delete a build they supplied.
[switch]$UserSupplied,
# Unreadable custom homes cannot be identified as managed caches.
[switch]$OwnershipUnverified
)
step "permissions" "$Label at $Path cannot be read: access is denied$(Get-PathDenialDetail -Path $Path)" "Red"
if ($UserSupplied) {
substep "Unsloth will not touch a directory you pointed it at, so this has to be fixed at the source" "Yellow"
substep "Restore access with these two in an elevated PowerShell, or point UNSLOTH_LOCAL_LLAMA_CPP_DIR at a readable build:" "Yellow"
} elseif ($OwnershipUnverified) {
substep "Unsloth cannot confirm this folder is its own install while it is unreadable, so it will not tell you to remove it" "Yellow"
substep "Restore access with these two in an elevated PowerShell, or move the folder aside and re-run setup:" "Yellow"
} else {
substep "This folder lives outside the app, so reinstalling Unsloth Studio, to any drive, reuses it and fails the same way" "Yellow"
substep "Simplest fix: close Unsloth, delete or rename $Path, then re-run setup (it is a managed cache and gets reinstalled)" "Yellow"
substep "If deleting is also denied, run these two in an elevated PowerShell, then re-run setup:" "Yellow"
}
substep "takeown /F `"$Path`" /R /D Y" "Yellow"
substep "icacls `"$Path`" /reset /T" "Yellow"
substep "Antivirus or Controlled folder access can deny this path too; allow or exclude it, then retry" "Yellow"
if ($UserSupplied) {
return "Access denied reading $Label at $Path. Restore access with takeown/icacls, or point UNSLOTH_LOCAL_LLAMA_CPP_DIR at a readable build, then re-run setup."
}
if ($OwnershipUnverified) {
return "Access denied reading $Label at $Path. Unsloth cannot confirm that folder is its own install while it is unreadable: restore access with takeown/icacls, or move it aside, then re-run setup."
}
return "Access denied reading the existing $Label at $Path. Delete or rename that folder (Unsloth reinstalls it) or restore access with takeown/icacls, then re-run setup. Reinstalling the app does not reset it."
}
# Canonicalize a directory for comparison; unresolvable ones are only normalized.
function Get-CanonicalDir {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
$trimmedPath = $Path.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedPath)) { return $trimmedPath }
$resolvedPath = $null
if ((Get-PathState -Path $trimmedPath -PathType Container) -eq "Present") {
try { $resolvedPath = (Resolve-Path -LiteralPath $trimmedPath).Path } catch {}
}
if (-not $resolvedPath) {
try {
$resolvedPath =
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($trimmedPath)
} catch {
$resolvedPath = [System.Environment]::ExpandEnvironmentVariables($trimmedPath)
}
try { $resolvedPath = [System.IO.Path]::GetFullPath($resolvedPath) } catch {}
}
# Resolve-Path keeps a trailing separator, so trim after both branches (never a root).
try {
$root = [System.IO.Path]::GetPathRoot($resolvedPath)
if ($root -and $resolvedPath.Length -gt $root.Length) { return $resolvedPath.TrimEnd('\', '/') }
} catch {}
return $resolvedPath
}
# Compare canonical homes so path spelling does not change ownership policy.
function Test-StudioHomeIsCustom {
return ((Get-CanonicalDir -Path $StudioHome) -ne
(Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth\studio")))
}
# Is this bin\unsloth.cmd one install.ps1 wrote? Only then does it prove the root is
# ours. The name alone is a plausible wrapper for anything built on unsloth, and the
# caller uses the answer to decide whether a user-chosen venv may be deleted. The
# trampoline is the marker; no other file carries it. Mirrored in install.ps1
# (Test-UnslothCmdShimFile) and scripts/uninstall.ps1 (_IsUnslothCmdShim).
function Test-UnslothCmdShimFile {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
try {
if ((Get-Item -LiteralPath $Path -ErrorAction Stop).Length -gt 8192) { return $false }
$text = [System.IO.File]::ReadAllText($Path)
} catch {
# Unreadable proves nothing, and "proves nothing" must not mean "deletable".
return $false
}
return ($text -like "*unsloth-studio-managed-launcher*" -and $text -like "*from unsloth_cli import app*")
}
# Shared default cache, or the custom Studio home's llama.cpp tree.
function Get-ManagedLlamaCppDir {
if (-not (Test-StudioHomeIsCustom)) {
return (Join-Path $env:USERPROFILE ".unsloth\llama.cpp")
}
return (Join-Path (Get-CanonicalDir -Path $StudioHome) "llama.cpp")
}
# Failure reason when the managed tree is denied; never touches its ACLs.
function Invoke-ManagedLlamaCppPreflight {
# Let the existing profile validation handle a missing USERPROFILE later.
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { return $null }
$dir = Get-ManagedLlamaCppDir
if ((Get-LlamaCppInstallReadState -Path $dir) -ne "Denied") { return $null }
Write-StudioLine ""
# A denied custom home cannot be claimed as an Unsloth-managed cache.
$homeIsCustom = Test-StudioHomeIsCustom
# Preserve user-supplied wording when either override names this tree.
$suppliedDir = if ($WithLlamaCppDir) { $WithLlamaCppDir } else { $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR }
$userSupplied = (-not [string]::IsNullOrWhiteSpace($suppliedDir)) -and
((Get-CanonicalDir -Path $suppliedDir) -eq (Get-CanonicalDir -Path $dir))
$reason = Write-PathAccessDenied -Path $dir -Label "llama.cpp install" `
-UserSupplied:$userSupplied -OwnershipUnverified:$homeIsCustom
substep "Stopping here, before phase 1: nothing has been downloaded or installed" "Yellow"
substep "Fix access, then run the same install, setup, or update command again" "Yellow"
Write-StudioLine ""
return "$reason Nothing was installed."
}
# Stop every install path consistently when its destination is unreadable.
function Exit-PathAccessDenied {
param(
[Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Label,
[switch]$UserSupplied,
[switch]$OwnershipUnverified
)
Exit-SetupFailure (Write-PathAccessDenied -Path $Path -Label $Label `
-UserSupplied:$UserSupplied -OwnershipUnverified:$OwnershipUnverified)
}
function Get-InstalledLlamaPrebuiltRelease {
param([string]$InstallDir)
$metadataPath = Join-Path $InstallDir "UNSLOTH_PREBUILT_INFO.json"
if (-not (Test-PathQuiet $metadataPath)) {
return $null
}
try {
$payload = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
} catch {
return $null
}
if (-not $payload.published_repo -or -not $payload.release_tag) {
return $null
}
$message = "installed release: $($payload.published_repo)@$($payload.release_tag)"
if ($payload.tag -and $payload.tag -ne $payload.release_tag) {
$message += " (tag $($payload.tag))"
}
return $message
}
# Find nvcc on PATH, CUDA_PATH, or standard toolkit dirs.
# Returns the path to nvcc.exe, or $null if not found.
function Find-Nvcc {
param([string]$MaxVersion = "")
$toolkitBase = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA'
if ($MaxVersion -and (Test-Path $toolkitBase)) {
$drMajor = [int]$MaxVersion.Split('.')[0]
# Get all installed CUDA dirs, sorted descending (highest first)
$cudaDirs = Get-ChildItem -Directory $toolkitBase | Where-Object {
$_.Name -match '^v(\d+)\.(\d+)'
} | Sort-Object { [version]($_.Name -replace '^v','') } -Descending
foreach ($dir in $cudaDirs) {
if ($dir.Name -match '^v(\d+)\.(\d+)') {
$tkMajor = [int]$Matches[1]
$compatible = ($tkMajor -le $drMajor)
if ($compatible) {
$nvcc = Join-Path $dir.FullName 'bin\nvcc.exe'
if (Test-Path $nvcc) {
return $nvcc
}
}
}
}
# No compatible side-by-side version found
return $null
}
# Fallback: no version constraint — pick latest or whatever is available
# 1. Check nvcc on PATH
$cmd = Get-Command nvcc -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
# 2. Check CUDA_PATH env var
$cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Process')
if (-not $cudaRoot) { $cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Machine') }
if (-not $cudaRoot) { $cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'User') }
if ($cudaRoot -and (Test-Path (Join-Path $cudaRoot 'bin\nvcc.exe'))) {
return (Join-Path $cudaRoot 'bin\nvcc.exe')
}
# 3. Scan standard toolkit directory
if (Test-Path $toolkitBase) {
$latest = Get-ChildItem -Directory $toolkitBase | Where-Object {
$_.Name -match '^v(\d+)\.(\d+)'
} | Sort-Object { [version]($_.Name -replace '^v','') } -Descending | Select-Object -First 1
if ($latest -and (Test-Path (Join-Path $latest.FullName 'bin\nvcc.exe'))) {
return (Join-Path $latest.FullName 'bin\nvcc.exe')
}
}
return $null
}
function Write-CudaDriverToolkitMismatch {
param(
[Parameter(Mandatory = $true)][string]$ToolkitVersion,
[Parameter(Mandatory = $true)][string]$DriverMaxCuda,
[string]$Color = "Yellow"
)
$toolkitMajor = $ToolkitVersion.Split('.')[0]
$driverMajor = $DriverMaxCuda.Split('.')[0]
substep "CUDA Toolkit $ToolkitVersion is a major-version mismatch: toolkit major $toolkitMajor exceeds driver CUDA major $driverMajor ($DriverMaxCuda)." $Color
substep "Update the NVIDIA GPU driver to run CUDA Toolkit $ToolkitVersion, or install a CUDA $driverMajor.x toolkit." $Color
substep "Or let Unsloth use the prebuilt CUDA bundle; it does not need the local toolkit." $Color
}
# Detect CUDA Compute Capability via nvidia-smi.
# Returns e.g. "80" for A100 (8.0), "89" for RTX 4090 (8.9), etc.
# Returns $null if detection fails.
function Get-CudaComputeCapability {
# Use the resolved absolute path ($NvidiaSmiExe) to survive Refresh-Environment
$smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else {
$cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($cmd) { $cmd.Source } else { $null }
}
if (-not $smiExe) { return $null }
try {
# Bounded: a wedged nvidia-smi must not hang setup after the initial
# -L probe succeeded (the helper merges stderr after stdout, so the
# first line is still the compute_cap value).
$raw = Invoke-NvidiaSmiBounded $smiExe @('--query-gpu=compute_cap', '--format=csv,noheader')
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
# nvidia-smi may return multiple GPUs; take the first one
$cap = ($raw -split "`n")[0].Trim()
if ($cap -match '^(\d+)\.(\d+)$') {
$major = $Matches[1]
$minor = $Matches[2]
return "$major$minor"
}
} catch { }
return $null
}
# Check if an nvcc binary supports a given sm_ architecture.
# Uses `nvcc --list-gpu-code` which outputs sm_* tokens (--list-gpu-arch
# outputs compute_* tokens instead). Available since CUDA 11.6.
# Returns $false if the flag isn't supported (old toolkit) — safer to reject
# and fall back to scanning/PTX than to assume support and fail later.
function Test-NvccArchSupport {
param([string]$NvccExe, [string]$Arch)
try {
$listCode = & $NvccExe --list-gpu-code 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { return $false }
return ($listCode -match "sm_$Arch")
} catch {
return $false
}
}
# Given an nvcc binary, return the highest sm_ architecture it supports.
# Returns e.g. "90" for CUDA 12.4. Returns $null if detection fails.
function Get-NvccMaxArch {
param([string]$NvccExe)
try {
$listCode = & $NvccExe --list-gpu-code 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { return $null }
$arches = @()
foreach ($line in $listCode -split "`n") {
if ($line.Trim() -match '^sm_(\d+)') {
$arches += [int]$Matches[1]
}
}
if ($arches.Count -gt 0) {
return ($arches | Sort-Object | Select-Object -Last 1).ToString()
}
} catch { }
return $null
}
# Reserved for the OS, and budgeted per compile job, both in MB. The budget sits
# above the measured per-translation-unit peak on purpose; see the derivation on
# _LLAMA_BUILD_* in setup.sh, which these must match and which the shell test
# pins. MSVC and Windows, where the freeze was reported, need that headroom more.
$LlamaBuildReserveMb = 2048
$LlamaBuildMbPerJob = 2048
# The cmake -j count. A negative $TotalMb means RAM was unreadable and keeps the
# old core-count behaviour. Zero is a reading, not a failure: a box with no
# memory left is the last one that should get its full core count, so it falls
# through and floors at 1, as _llama_jobs_for does for a numeric 0.
# UNSLOTH_LLAMA_BUILD_JOBS wins. Pure, so the tests can drive it.
function Get-LlamaJobsFor {
param([int]$Cores, [long]$TotalMb)
$override = 0
if ($env:UNSLOTH_LLAMA_BUILD_JOBS -and
[int]::TryParse($env:UNSLOTH_LLAMA_BUILD_JOBS.Trim(), [ref]$override) -and $override -ge 1) {
return $override
}
if ($Cores -lt 1) { $Cores = 4 }
if ($TotalMb -lt 0) { return $Cores }
$jobs = [int][Math]::Floor(($TotalMb - $LlamaBuildReserveMb) / $LlamaBuildMbPerJob)
if ($jobs -lt 1) { $jobs = 1 }
if ($jobs -gt $Cores) { $jobs = $Cores }
return $jobs
}
# Usable RAM in MB; -1 when it cannot be read at all. Available, not installed:
# a box with 8 GB already resident cannot host a 14 GB compile just because
# 16 GB is fitted. AvailableMBytes counts the standby list, which the Free
# counters do not, and the raw perf class is not localized the way Get-Counter
# paths are; installed RAM stays the fallback. A reading of 0 is returned as 0
# rather than treated as unreadable, since falling back to installed RAM under
# real pressure would hand the machine its full core count at the worst moment.
function Get-UsableMemoryMb {
try {
$avail = (Get-CimInstance Win32_PerfRawData_PerfOS_Memory -ErrorAction Stop).AvailableMBytes
if ($null -ne $avail) { return [long]$avail }
} catch { }
try {
$bytes = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).TotalPhysicalMemory
if ($bytes -gt 0) { return [long]($bytes / 1MB) }
} catch { }
try {
# TotalVisibleMemorySize is in KB and excludes hardware-reserved RAM.
$kb = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).TotalVisibleMemorySize
if ($kb -gt 0) { return [long]($kb / 1KB) }
} catch { }
return -1
}
function Get-LlamaBuildJobs {
return (Get-LlamaJobsFor -Cores ([Environment]::ProcessorCount) -TotalMb (Get-UsableMemoryMb))
}
# Classify the physical NVIDIA inventory for a cu126 fallback: "cu126" when it covers
# every GPU, "uncovered" for an incompatible mix, empty when no fallback is needed or the
# inventory is unreadable. CUDA_VISIBLE_DEVICES is ignored because the wheel must support
# the host. Mirrors _nvidia_cu126_verdict in install.sh.
function Get-NvidiaCu126Verdict {
# Floor is per-release, not fixed: only 2.11 dropped sm_70 from cu128.
param([string]$SmiExe, [int]$LegacyFloorSm = 75)
if (-not $SmiExe) { return '' }
$raw = Invoke-NvidiaSmiBounded $SmiExe @('--query-gpu=compute_cap', '--format=csv,noheader,nounits') -StdoutOnly
if ($LASTEXITCODE -ne 0 -or -not $raw) { return '' }
$legacy = $false
$outsideCu126 = $false
$seen = $false
foreach ($line in ($raw -split "`n")) {
$value = $line.Trim()
if (-not $value) { continue }
if ($value -notmatch '^(\d+)\.(\d+)$') { return '' }
$sm = ([int]$Matches[1] * 10) + [int]$Matches[2]
if ($sm -lt $LegacyFloorSm) { $legacy = $true }
if ($sm -lt 50 -or $sm -gt 90) { $outsideCu126 = $true }
$seen = $true
}
if (-not $seen -or -not $legacy) { return '' }
if ($outsideCu126) { return 'uncovered' }
return 'cu126'
}
function Get-CudaFamilyCappedForPreTuring {
param([string]$Family, [string]$SmiExe)
if ($Family -notin @('cu128', 'cu130')) { return $Family }
# Windows pins torch<2.11, whose cu128 still ships sm_70, so only cu130
# strands a Volta here. Raise to 75 when that pin reaches 2.11.
$legacyFloorSm = if ($Family -eq 'cu128') { 70 } else { 75 }
$verdict = Get-NvidiaCu126Verdict $SmiExe $legacyFloorSm
if (-not $verdict) { return $Family }
# This runs twice per setup; announce once without polluting pipeline output.
$announce = -not $script:PreTuringCapAnnounced
$script:PreTuringCapAnnounced = $true
if ($verdict -eq 'cu126') {
if ($announce) {
substep "pre-Turing NVIDIA GPUs (sm_<75) are present -- selecting cu126, because PyTorch 2.11's $Family wheels start at sm_75" "Yellow"
}
return 'cu126'
}
if ($announce) {
substep "this host mixes pre-Turing NVIDIA GPUs with GPUs that cu126 cannot serve; no PyTorch 2.11 CUDA family covers both" "Yellow"
substep "keeping $Family, so the pre-Turing GPUs will be unusable; set UNSLOTH_TORCH_INDEX_FAMILY=cu126 to choose the other way" "Yellow"
}
return $Family
}
# Detect driver's max CUDA version from nvidia-smi and return the highest
# compatible PyTorch CUDA index tag (e.g. "cu128").
# PyTorch on Windows ships CPU-only by default from PyPI; CUDA wheels live at
# https://download.pytorch.org/whl/<tag>. The tag must not exceed the driver's
# capability: e.g. driver "CUDA Version: 12.9" → cu128 (not cu130).
function Get-PytorchCudaTag {
$smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else {
$cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($cmd) { $cmd.Source } else { $null }
}
if (-not $smiExe) { return "cu126" }
try {
# Bounded: a wedged nvidia-smi must not hang setup. The helper merges
# stderr into the returned string, matching the old 2>&1 | Out-String
# shape (plain 2>$null leaks ErrorRecord objects in PS 5.1).
$output = Invoke-NvidiaSmiBounded $smiExe
# Newer NVIDIA drivers (e.g. 610.x on Windows) print
# "CUDA UMD Version: X.Y" instead of the legacy "CUDA Version: X.Y".
# Accept both spellings so we don't fall through to the cu126 default.
if ($output -match 'CUDA(?: UMD)? Version:\s+(\d+)\.(\d+)') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
# PyTorch 2.10 offers: cu124, cu126, cu128, cu130
if ($major -ge 13) { $family = "cu130" }
elseif ($major -eq 12 -and $minor -ge 8) { $family = "cu128" }
elseif ($major -eq 12 -and $minor -ge 6) { $family = "cu126" }
elseif ($major -ge 12) { $family = "cu124" }
elseif ($major -ge 11) { $family = "cu118" }
else { return "cpu" }
return (Get-CudaFamilyCappedForPreTuring $family $smiExe)
}
} catch { }
return "cu126"
}
# Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL
# TrimEnd corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared.
function Trim-IndexPathSlashes {
param([string]$Url)
$value = $Url.Trim()
$idx = $value.IndexOfAny([char[]]@('?', '#'))
if ($idx -lt 0) {
return $value.TrimEnd('/')
}
return $value.Substring(0, $idx).TrimEnd('/') + $value.Substring($idx)
}
# Explicit torch-index pin (UNSLOTH_TORCH_INDEX_URL / _FAMILY), shared by the stale-venv check
# and install selection so a pinned index wins over GPU probing (parity with the other
# installers). URL is verbatim; _FAMILY is the leaf joined to the mirror base.
function Get-PinnedTorchIndexUrl {
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) {
return (Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL)
}
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) {
$base = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
return "$base/$($env:UNSLOTH_TORCH_INDEX_FAMILY.Trim().Trim('/'))"
}
return $null
}
# Last path segment of a wheel index URL, query/fragment dropped first so a token-authenticated
# pin (.../cu128?token=x) classifies as cu128 (else it reinstalls every update). Classification
# only. Shared with the py / install.sh leaf extractors.
function Get-TorchIndexLeaf {
param([string]$Url)
if ([string]::IsNullOrWhiteSpace($Url)) { return $null }
$path = ($Url -split '[?#]', 2)[0]
if ([string]::IsNullOrWhiteSpace($path)) { return $null }
return ($path.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
}
# Redact index-URL credentials (userinfo + ?query= + #fragment) from captured installer
# output before printing on failure; uv/pip errors echo the failing --index-url verbatim.
# Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted.
function Redact-InstallOutput {
param([string]$Text)
if (-not $Text) { return $Text }
$Text = $Text -replace '(https?://)[^/@\s`]+@', '$1<redacted>@'
$Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=<redacted>'
# A #token=... fragment is as sensitive as a query; URL-anchored.
return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#<redacted>'
}
# AMD per-arch leaves needing the torch 2.11 floor (the _grouped_mm <2.11 bug). MUST match
# the install-spec path below and the other installers; other leaves ship <2.11 and stay default.
function Test-RocmGfx211Leaf {
param([string]$Leaf)
return @('gfx120x-all', 'gfx1151', 'gfx1150', 'gfx1152') -contains $Leaf
}
# rocmX.Y versions KNOWN to ship torch 2.11: rocm7.2 only today. Do NOT floor an unknown newer
# rocm speculatively. MUST match _ROCM_KNOWN_TORCH211_VERSIONS and the rocm7.2 leaf elsewhere.
function Test-RocmKnown211Version {
param([int]$Major, [int]$Minor)
return ($Major -eq 7 -and $Minor -eq 2)
}
# True only for a real CUDA family leaf: "cu" + digits (cu118, cu128, ...). A bare -like 'cu*'
# would match "custom"/"current" and rebuild the venv every run. Mirrors _is_cuda_family_leaf.
function Test-CudaFamilyLeaf {
param([string]$Leaf)
if ([string]::IsNullOrWhiteSpace($Leaf)) { return $false }
# EXACT cu+digits: cu128-private routes through the unknown-leaf path instead.
return $Leaf -match '^cu[0-9]+$'
}
# True only for a real pip ROCm family leaf: EXACT rocm<digits>[.<digits>] or a gfx leaf. A leaf
# that merely STARTS with rocm (rocm-rel-7.2.1, rocm7.2-private) is a custom pin the verbatim
# path owns, so anchor the match. Mirrors _is_pip_rocm_family_leaf / install.sh.
function Test-PipRocmFamilyLeaf {
param([string]$Leaf)
if ([string]::IsNullOrWhiteSpace($Leaf)) { return $false }
# gfx must be followed by a digit (an architecture leaf); gfx-private is custom.
return ($Leaf -match '^gfx[0-9]') -or ($Leaf -match '^rocm[0-9]+(\.[0-9]+)?$')
}
# Stale-venv ROCm comparison for a pinned gfx*/rocm* index. Returns @{ Expected; Installed } so
# the caller rebuilds when they differ. Mirrors _rocm_pin_family_mismatch (same rocmX.Y / gfx
# cases). An untagged (no +rocm) wheel never satisfies a ROCm pin -> stale.
function Get-RocmPinStaleTags {
param([string]$PinLeaf, [string]$TorchVersion)
$_pinRocm = [regex]::Match($PinLeaf, '^rocm(\d+)\.(\d+)')
$_pinVer = if ($_pinRocm.Success) { "$($_pinRocm.Groups[1].Value).$($_pinRocm.Groups[2].Value)" } else { $null }
# The family classifier accepts a major-only rocm<d> leaf too (rocm7).
$_pinMajorOnly = [regex]::Match($PinLeaf, '^rocm(\d+)$')
# Installed rocm version and whether the wheel is a per-arch (three-part) build.
$_instRocm = [regex]::Match($TorchVersion, '\+rocm(\d+)\.(\d+)')
$_instVer = if ($_instRocm.Success) { "$($_instRocm.Groups[1].Value).$($_instRocm.Groups[2].Value)" } else { $null }
$_instPerArch = [regex]::IsMatch($TorchVersion, '\+rocm\d+\.\d+\.\d+')
# A ROCm build MUST carry a +rocm tag; an untagged wheel can't satisfy any ROCm pin.
$_instHasRocm = [regex]::IsMatch($TorchVersion, '\+rocm')
$_instRel = [regex]::Match($TorchVersion, '^(\d+)\.(\d+)')
$_instIs211 = $false
if ($_instRel.Success) {
$_instIs211 = ([int]$_instRel.Groups[1].Value -gt 2) -or ([int]$_instRel.Groups[1].Value -eq 2 -and [int]$_instRel.Groups[2].Value -ge 11)
}
if ($PinLeaf -like 'gfx*') {
if (Test-RocmGfx211Leaf $PinLeaf) {
# Expect the AMD per-arch (three-part) 2.11 wheel: satisfied only when BOTH
# a 2.11 release AND a three-part rocm tag are installed.
$installed = if ($_instIs211 -and $_instPerArch) { "rocm-perarch(torch>=2.11)" } else { "rocm-generic-or-old" }
return @{ Expected = "rocm-perarch(torch>=2.11)"; Installed = $installed }
}
# Non-2.11 gfx leaf (<2.11 spec): stale on an untagged wheel or a 2.11+ build.
$installed = if (-not $_instHasRocm) { "not-rocm" } elseif ($_instIs211) { "rocm(torch>=2.11)" } else { "rocm(torch<2.11)" }
return @{
Expected = "rocm(torch<2.11)"
Installed = $installed
}
}
# Major-only rocm pin (rocm7): compare majors only -- a +rocm6.4 wheel under a rocm7
# pin is stale, any +rocm7.x wheel satisfies it (no pinned minor to compare, and the
# 2.11-line fallback below would invert both verdicts). Mirrors _rocm_pin_family_mismatch.
if ($_pinMajorOnly.Success) {
$_pinMaj = [int]$_pinMajorOnly.Groups[1].Value
if ($_instVer) {
$_instMaj = [int]$_instRocm.Groups[1].Value
$expected = if ($_instMaj -eq $_pinMaj) { "rocm$_instVer" } else { "rocm$_pinMaj.x" }
return @{ Expected = $expected; Installed = "rocm$_instVer" }
}
# Untagged wheel never satisfies a ROCm pin; a +rocm tag with an unreadable
# version is accepted (matches the lenient unreadable fallback below).
$installed = if ($_instHasRocm) { "rocm" } else { "not-rocm" }
return @{ Expected = "rocm"; Installed = $installed }
}
# rocmX.Y pin.
if ($_pinVer -and $_instVer) {
# Both readable: exact compare. When they match AND the pin is KNOWN-2.11, the
# installed release must also be 2.11 (a +rocm7.2 wheel drifted to 2.12 shares the
# tag but violates the spec), so fold the release into the tag. Mirrors _rocm_pin_family_mismatch.
$_pinKnown211 = Test-RocmKnown211Version -Major ([int]$_pinRocm.Groups[1].Value) -Minor ([int]$_pinRocm.Groups[2].Value)
$_instOn211 = $_instRel.Success -and [int]$_instRel.Groups[1].Value -eq 2 -and [int]$_instRel.Groups[2].Value -eq 11
if ($_pinKnown211 -and -not $_instOn211) {
return @{ Expected = "rocm$_pinVer(torch2.11)"; Installed = "rocm$_instVer(torch-off-2.11)" }
}
return @{ Expected = "rocm$_pinVer"; Installed = "rocm$_instVer" }
}
$_pinNeeds211 = $false
if ($_pinRocm.Success) {
# Only KNOWN-2.11 rocm (rocm7.2) is on the 2.11 line (no speculative floor).
# Matches _ROCM_KNOWN_TORCH211_VERSIONS.
$_pinNeeds211 = Test-RocmKnown211Version -Major ([int]$_pinRocm.Groups[1].Value) -Minor ([int]$_pinRocm.Groups[2].Value)
}
# Fallback (installed rocm version unreadable): compare on the 2.11 line; an untagged
# wheel never satisfies a rocmX.Y pin -> stale.
$installed = if (-not $_instHasRocm) { "not-rocm" } elseif ($_instIs211) { "rocm(torch>=2.11)" } else { "rocm(torch<2.11)" }
return @{
Expected = if ($_pinNeeds211) { "rocm(torch>=2.11)" } else { "rocm(torch<2.11)" }
Installed = $installed
}
}
# Bounded "ask python a question" probe, shared by every torch probe below: a wedged torch import
# or a hanging Intel driver init would block a bare `& python -c ...` forever. ProcessStartInfo,
# not &, so stderr cannot trip $ErrorActionPreference; BOTH streams drain async so a noisy import
# cannot deadlock on a full pipe; WaitForExit bounds the wait and kills the child. Every failure
# reads as .Ok = $false, and .Error carries WHICH failure: stderr used to be drained and thrown
# away, so "the HIP DLLs will not load", "torch is not installed" and "the import never came back"
# all reached the caller as one silent False -- and one such caller deletes the environment over
# that answer. Mirrors install.ps1's copy.
function Invoke-BoundedPythonProbe {
param([string]$PythonExe, [string]$Code, [int]$TimeoutSec = 30)
$result = [pscustomobject]@{ Ok = $false; Output = ""; Error = "" }
if (-not $PythonExe -or -not $Code) { return $result }
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PythonExe
$psi.Arguments = "-c `"$Code`""
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
# Synthesised, not read back: waiting on the reader tasks of a wedged child would
# reintroduce the hang this helper exists to bound.
$result.Error = "python did not answer within $TimeoutSec seconds"
return $result
}
$result.Output = $outTask.GetAwaiter().GetResult()
# Kept, not discarded: the only place a failed probe's OSError / WinError text exists, and
# the caller decides what to do with the venv based on it.
$result.Error = $errTask.GetAwaiter().GetResult()
$result.Ok = ($proc.ExitCode -eq 0)
return $result
} catch {
$result.Error = $_.Exception.Message
return $result
}
}
# True when $PythonExe's torch can actually drive an Intel GPU. Quiet on purpose: the three
# callers read a False differently. Only an XPU build can answer True, so a CPU build never
# vetoes a migration.
function Test-TorchXpuAvailable {
param([string]$PythonExe)
if (-not $PythonExe) { return $false }
# Line-anchored so a stdout banner cannot hide the answer; a timeout reads as "no XPU".
$probe = Invoke-BoundedPythonProbe -PythonExe $PythonExe -Code 'import torch; print(torch.xpu.is_available())'
return ($probe.Ok -and $probe.Output -match '(?m)^\s*True\s*$')
}
# Post-install XPU runtime check. A WMI name match says the part is XPU-capable, not that the
# compute runtime works: on an old Intel driver the wheel installs fine, never initializes, and
# unsloth/device_type.py raises NotImplementedError at import -- a hard crash, not a chat-only
# downgrade. Warn only: the wheel is correct and a driver update fixes it.
function Assert-XpuRuntimeReady {
param([string]$PythonExe)
if (Test-TorchXpuAvailable -PythonExe $PythonExe) { return $true }
substep "[WARN] PyTorch XPU is installed but torch.xpu.is_available() is False." "Yellow"
substep " The Intel GPU driver is most likely too old -- PyTorch XPU on Windows" "Yellow"
substep " needs Intel Graphics Driver 32.0.101.6739 (WHQL) or newer." "Yellow"
substep " Update the driver, then re-run. See:" "Yellow"
substep " https://unsloth.ai/docs/get-started/install/intel" "Yellow"
return $false
}
# Bounded Win32_VideoController scan: the query can block forever on a degraded WMI repository,
# -ErrorAction only suppresses reported errors, and -OperationTimeoutSec is not enforced for the
# local COM session this uses, so out of process with a wall-clock kill is the only bound that
# holds. Ok = $false on an empty answer too, since a Windows host always has an adapter.
# Mirrors install.ps1's copy.
function Invoke-BoundedVideoControllerScan {
param([int]$TimeoutSec = 15)
$result = [pscustomobject]@{ Ok = $false; Names = @() }
$job = $null
try {
$job = Start-Job -ScriptBlock {
Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name
}
if (Wait-Job -Job $job -Timeout $TimeoutSec) {
$names = @(Receive-Job -Job $job -ErrorAction SilentlyContinue)
$result.Names = @($names | Where-Object { $_ })
$result.Ok = ($result.Names.Count -gt 0)
} else {
Stop-Job -Job $job -ErrorAction SilentlyContinue
}
} catch {
} finally {
if ($job) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue }
}
return $result
}
# Registry fallback for the scan above, mirroring install_llama_prebuilt.py's
# windows_intel_gpu_in_registry(): the display-adapter class key, one NNNN subkey per driver
# config. Weaker than WMI (a config can outlive removed hardware), so it is the fallback here
# while that function is registry-first -- there a false positive only picks a different
# llama.cpp bundle, here it would install XPU torch on a host with no Arc. Mirrors install.ps1.
function Get-IntelRegistryAdapterNames {
$names = @()
$classKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
try {
$subs = @(Get-ChildItem -LiteralPath $classKey -ErrorAction SilentlyContinue)
} catch { return @() }
foreach ($sub in $subs) {
# Guarded per subkey, not around the loop: one unreadable entry must not discard the
# adapters found after it. Matches windows_intel_gpu_in_registry()'s per-key skip.
try {
# Numeric subkeys only: "Properties" is ACL-restricted and not an adapter.
if ("$($sub.PSChildName)" -match '^\d+$') {
$props = Get-ItemProperty -LiteralPath $sub.PSPath -ErrorAction SilentlyContinue
if ($props) {
$desc = "$($props.DriverDesc)"
# Callers re-filter on "Intel", so a vendor-ID hit with a localized or
# OEM-branded DriverDesc would be found here and dropped there. Tag it
# instead; still only XPU-capable if the name says Arc / Data Center GPU.
if ("$($props.MatchingDeviceId)" -match '(?i)ven_8086') {
$names += if ($desc -match '(?i)intel') { $desc }
elseif ($desc) { "Intel $desc" }
else { "Intel Graphics" }
} elseif ($desc -match '(?i)intel') {
$names += $desc
}
}
}
} catch { }
}
return $names
}
# The studio venv directory, guessed from the environment alone. The canonical $VenvDir is
# resolved ~1100 lines below, far past the hardware report, so the Intel scan needs its own
# read-only guess. Returns $null when there is nothing to look at; never creates or validates.
function Get-ProbableStudioVenvDir {
$root = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() }
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() }
else { $null }
# Expand a leading ~ like the canonical resolver; without it the path stays cwd-relative.
if ($root -and ($root -eq "~" -or $root -like "~/*" -or $root -like "~\*")) {
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { return $null }
$rest = $root.Substring(1).TrimStart('/', '\')
$root = if ($rest) { Join-Path $env:USERPROFILE $rest } else { $env:USERPROFILE }
}
if (-not $root) {
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { return $null }
$root = Join-Path $env:USERPROFILE ".unsloth\studio"
}
$venv = Join-Path $root "unsloth_studio"
if (Test-Path -LiteralPath $venv -PathType Container) { return $venv }
return $null
}
# Is this venv's torch an XPU wheel? Read off disk, not through the interpreter: version.py
# carries the full local label ("2.9.1+xpu") and costs nothing, so a CPU-only host never pays for
# an `import torch`. The dist-info name is NOT usable -- pip normalises the local label out of it.
function Test-VenvTorchIsXpu {
param([string]$VenvPath)
if (-not $VenvPath) { return $false }
try {
$verPy = Join-Path $VenvPath "Lib\site-packages\torch\version.py"
if (-not (Test-Path -LiteralPath $verPy)) { return $false }
return [bool]((Get-Content -LiteralPath $verPy -TotalCount 40 -ErrorAction Stop) -match "__version__\s*=\s*'[^']*\+xpu")
} catch { return $false }
}
# Is this venv's torch a ROCm wheel? The AMD counterpart of the check above, for the same reason:
# on an AMD box whose HIP runtime faulted the DLL load raises OSError (or hangs) while version.py
# on disk still names a perfectly good wheel, so answering by launching the interpreter that just
# died is how a working environment got deleted. Read off disk, never imported.
#
# The label is always "+rocm", never "+gfx", on every index that publishes wheels:
# repo.amd.com/rocm/whl/<arch>/torch/ (the Windows wheels, arch in the URL only) ships
# torch-2.11.0+rocm7.13.0-cp312-cp312-win_amd64.whl, and download.pytorch.org/whl/rocm6.4 ships
# the two-component 2.8.0+rocm6.4 (Linux only). "gfx" is accepted anyway, cheaply, so a future
# per-arch local label cannot make this read a ROCm venv as unknown and delete it. The dist-info
# name is NOT usable either -- pip normalises the local label out of it.
function Test-VenvTorchIsRocm {
param([string]$VenvPath)
if (-not $VenvPath) { return $false }
try {
$verPy = Join-Path $VenvPath "Lib\site-packages\torch\version.py"
if (-not (Test-Path -LiteralPath $verPy)) { return $false }
return [bool]((Get-Content -LiteralPath $verPy -TotalCount 40 -ErrorAction Stop) -match "__version__\s*=\s*'[^']*\+(rocm|gfx)")
} catch { return $false }
}
# Same free disk read, plus the supported range: unsloth/models/_utils.py raises at import for an
# XPU device on torch < 2.6, so flavour alone would call a 2.5+xpu venv fine; 2.11 is the trio's
# ceiling. Flavour and range ONLY -- whether the runtime reaches the GPU is a driver question.
function Test-VenvTorchIsXpuSupported {
param([string]$VenvPath)
if (-not $VenvPath) { return $false }
try {
$verPy = Join-Path $VenvPath "Lib\site-packages\torch\version.py"
if (-not (Test-Path -LiteralPath $verPy)) { return $false }
$line = @(Get-Content -LiteralPath $verPy -TotalCount 40 -ErrorAction Stop |
Where-Object { $_ -match "^__version__\s*=\s*'[^']*'" })[0]
if (-not $line -or $line -notmatch "^__version__\s*=\s*'([^']*)'") { return $false }
$label = $Matches[1].ToLowerInvariant()
if ($label -notmatch '\+xpu') { return $false }
if ($label -notmatch '^(\d+)\.(\d+)\b') { return $false }
return ([int]$Matches[1] -eq 2 -and [int]$Matches[2] -ge 6 -and [int]$Matches[2] -lt 11)
} catch { return $false }
}
# VS generator -> MSBuild BuildCustomizations dir; toolset tracks the VS major
# (18->v180, 17->v170), defaulting to v170 when unparseable.
function Get-VcBuildCustomizationsDir {
param(
[Parameter(Mandatory)][string]$VsInstallPath,
[string]$Generator
)
$toolset = 'v170'
if ($Generator -and ($Generator -match 'Visual Studio (\d+)\b')) {
$toolset = "v$($Matches[1])0"
}
return (Join-Path $VsInstallPath "MSBuild\Microsoft\VC\$toolset\BuildCustomizations")
}
# Installed cmake version, or $null if absent/unparseable.
function Get-CmakeVersion {
$raw = & cmake --version 2>$null | Select-Object -First 1
if ($raw -and ($raw -match '(\d+)\.(\d+)(?:\.(\d+))?')) {
$patch = if ($Matches[3]) { $Matches[3] } else { '0' }
return [version]"$($Matches[1]).$($Matches[2]).$patch"
}
return $null
}
# VS 18 2026 generator needs cmake >= 4.2 (added there); true for older VS generators.
function Test-CmakeSupportsGenerator {
param(
[Parameter(Mandatory)][string]$CmakeVersion,
[Parameter(Mandatory)][string]$Generator
)
if ($Generator -match 'Visual Studio 18\b') {
$clean = ($CmakeVersion -replace '[^0-9.].*$', '').TrimEnd('.')
try { $v = [version]$clean } catch { return $false }
return ($v -ge [version]'4.2')
}
return $true
}
function Test-CmakeListsGenerator {
# Does `cmake --help` actually list the generator? A VS-bundled cmake can drive
# VS 2026 below the 4.2 floor, so probe rather than trust the version. (#6473)
param([Parameter(Mandatory)][string]$Generator)
$help = & cmake --help 2>$null | Out-String
if (-not $help) { return $false }
$haystack = ($help -replace '\s+', ' ')
$needle = ($Generator -replace '\s+', ' ')
return $haystack.Contains($needle)
}
function Test-CmakeCanDriveGenerator {
# cmake can drive $Generator if it lists it (VS-bundled below 4.2) or meets the floor.
param([Parameter(Mandatory)][string]$Generator)
if (Test-CmakeListsGenerator -Generator $Generator) { return $true }
$verObj = Get-CmakeVersion
$verStr = if ($verObj) { $verObj.ToString() } else { '0.0' }
return (Test-CmakeSupportsGenerator -CmakeVersion $verStr -Generator $Generator)
}
function Add-DefaultCmakeToPath {
# Prepend the default CMake dir so a freshly winget-installed cmake wins over an
# older one already on PATH. $true if found. (#6473)
$cmakeDefaults = @(
"$env:ProgramFiles\CMake\bin",
"${env:ProgramFiles(x86)}\CMake\bin",
"$env:LOCALAPPDATA\CMake\bin"
)
foreach ($d in $cmakeDefaults) {
if (Test-Path (Join-Path $d "cmake.exe")) {
$env:Path = "$d;$env:Path"
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
return $true
}
}
return $false
}
function Get-FallbackVsGenerator {
# Newest pre-2026 VS whose generator the current cmake can drive, for when the
# VS 2026 generator is unusable (old/offline cmake) but an older toolchain exists.
# vswhere first (catches non-default roots like D:\), then Program Files; matches
# Find-VsBuildTools. Returns @{ Generator; InstallPath } or $null. (#6473)
$knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview')
# install path if it holds a usable cl.exe, else $null
$tryCandidate = {
param($gen, $installPath)
if (-not $installPath) { return $null }
$vcDir = Join-Path $installPath "VC\Tools\MSVC"
if (-not (Test-Path $vcDir)) { return $null }
$cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cl) { return @{ Generator = $gen; InstallPath = $installPath } }
return $null
}
# vswhere (non-default roots)
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vsw) {
$json = & $vsw -all -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -format json 2>$null | Out-String
if ($json) {
try { $instances = @($json | ConvertFrom-Json) } catch { $instances = @() }
$ranked = $instances | ForEach-Object {
$label = if ($_.catalog -and $_.catalog.productLineVersion) { [string]$_.catalog.productLineVersion } else { '' }
[pscustomobject]@{ Gen = (Resolve-VsGeneratorFromLabel $label); Path = [string]$_.installationPath }
} | Where-Object { $_.Gen -and ($_.Gen -notmatch 'Visual Studio 18\b') }
# newest first: 2022 > 2019 > 2017
$ranked = $ranked | Sort-Object { switch -regex ($_.Gen) { '17 2022' {0} '16 2019' {1} '15 2017' {2} default {9} } }
foreach ($cand in $ranked) {
if (-not (Test-CmakeListsGenerator -Generator $cand.Gen)) { continue }
$res = & $tryCandidate $cand.Gen $cand.Path
if ($res) { return $res }
}
}
}
# Program Files scan
$roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }
$older = @(
@{ Dir = '2022'; Generator = 'Visual Studio 17 2022' },
@{ Dir = '2019'; Generator = 'Visual Studio 16 2019' },
@{ Dir = '2017'; Generator = 'Visual Studio 15 2017' }
)
foreach ($entry in $older) {
if (-not (Test-CmakeListsGenerator -Generator $entry.Generator)) { continue }
foreach ($r in $roots) {
$vsBase = Join-Path $r "Microsoft Visual Studio\$($entry.Dir)"
if (-not (Test-Path $vsBase)) { continue }
foreach ($ed in $knownEditions) {
$candidate = Join-Path $vsBase $ed
if (-not (Test-Path $candidate)) { continue }
$res = & $tryCandidate $entry.Generator $candidate
if ($res) { return $res }
}
}
}
return $null
}
# VS version label -> cmake generator. vswhere's productLineVersion is the year for
# VS <= 2022 but the internal major "18" for VS 2026, and dir names use either form,
# so accept both. (VS 2026 detection adapted from @LeoBorcherding's #6038.)
function Resolve-VsGeneratorFromLabel {
param([string]$Label)
if (-not $Label) { return $null }
$map = @{
'2026' = 'Visual Studio 18 2026'; '18' = 'Visual Studio 18 2026'
'2022' = 'Visual Studio 17 2022'; '17' = 'Visual Studio 17 2022'
'2019' = 'Visual Studio 16 2019'; '16' = 'Visual Studio 16 2019'
'2017' = 'Visual Studio 15 2017'; '15' = 'Visual Studio 15 2017'
}
return $map[$Label.Trim()]
}
# Find VS Build Tools for cmake -G: vswhere, then a filesystem scan (handles broken
# vswhere registration). Returns @{ Generator; InstallPath; Source } or $null.
function Find-VsBuildTools {
# vswhere first (works when VS is properly registered)
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vsw) {
$info = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property catalog_productLineVersion 2>$null
$path = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null
if ($info -and $path) {
$gen = Resolve-VsGeneratorFromLabel $info
if ($gen) {
return @{ Generator = $gen; InstallPath = $path.Trim(); Source = 'vswhere' }
}
}
}
# filesystem scan (handles broken vswhere registration); VS 2026+ dir is "18"
$roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }
$knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview')
$dirs = @('18', '2026', '2022', '2019', '2017')
foreach ($d in $dirs) {
$gen = Resolve-VsGeneratorFromLabel $d
if (-not $gen) { continue }
foreach ($r in $roots) {
$vsBase = Join-Path $r "Microsoft Visual Studio\$d"
if (-not (Test-Path $vsBase)) { continue }
# VS 2026 (dir "18") may use non-standard edition names, so scan every subdir
if ($d -eq '18' -or $d -eq '2026') {
$editionCandidates = Get-ChildItem -Path $vsBase -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
} else {
$editionCandidates = $knownEditions | ForEach-Object { Join-Path $vsBase $_ }
}
foreach ($candidate in $editionCandidates) {
if (-not (Test-Path $candidate)) { continue }
$vcDir = Join-Path $candidate "VC\Tools\MSVC"
if (Test-Path $vcDir) {
$cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cl) {
$ed = Split-Path $candidate -Leaf
return @{ Generator = $gen; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName }
}
}
}
}
}
return $null
}
# Install CMake + VS Build Tools, deferred here from Phase 1 so the prebuilt path
# never pays for a multi-GB install. Called only when a source build is committed.
# CMake is best-effort (build skips downstream if absent); VS Build Tools are
# required, so exit 1 with guidance if missing. No-ops for VS when already detected.
function Ensure-BuildToolsForLlamaSourceBuild {
# CMake
if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) {
Write-StudioLine "CMake not found -- installing via winget (needed for the llama.cpp source build)..." -ForegroundColor Yellow
if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
try {
Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
} catch { }
}
# winget may install cmake but not put it on PATH yet; try the default dir
if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) {
$cmakeDefaults = @(
"$env:ProgramFiles\CMake\bin",
"${env:ProgramFiles(x86)}\CMake\bin",
"$env:LOCALAPPDATA\CMake\bin"
)
foreach ($d in $cmakeDefaults) {
if (Test-Path (Join-Path $d "cmake.exe")) {
$env:Path = "$d;$env:Path"
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
break
}
}
}
if ($null -ne (Get-Command cmake -ErrorAction SilentlyContinue)) { step "cmake" "installed" }
}
# VS Build Tools
if ($script:VsInstallPath) { return } # already detected by the early probe
$vsResult = Find-VsBuildTools
if (-not $vsResult) {
Write-StudioLine "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow
Write-StudioLine " (Needed only for the llama.cpp source build; may take several minutes)" -ForegroundColor Gray
if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
$prevEAPTemp = $ErrorActionPreference
$ErrorActionPreference = "Continue"
winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait"
$ErrorActionPreference = $prevEAPTemp
# Re-scan after install (don't trust vswhere catalog)
$vsResult = Find-VsBuildTools
}
}
if ($vsResult) {
$script:CmakeGenerator = $vsResult.Generator
$script:VsInstallPath = $vsResult.InstallPath
step "vs" "$($vsResult.Generator) ($($vsResult.Source))"
if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" }
} else {
Write-StudioLine "[ERROR] Visual Studio Build Tools are required for the llama.cpp source build but could not be found or installed." -ForegroundColor Red
Write-StudioLine " Manual install:" -ForegroundColor Red
Write-StudioLine ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow
Write-StudioLine ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow
Exit-SetupFailure "Visual Studio Build Tools are required for the llama.cpp source build"
}
}
# Machine arch: PROCESSOR_ARCHITECTURE describes this PROCESS, so an emulated x64 shell on
# ARM64 reports AMD64; PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
function Get-HostMachineArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { }
foreach ($s in @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
return "other"
}
# Detect the VC++ 2015-2022 Redistributable prebuilt llama-server and PyTorch need (they
# link VCRUNTIME140_1.dll, absent from the Universal CRT). Registry first: Runtimes\x64 is
# the only x64-specific proof; System32\vcruntime140_1.dll is arch-blind and on ARM64 may
# be the ARM64-only package, unloadable under x64 emulation.
function Test-VCRedistInstalled {
foreach ($k in @(
'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
)) {
try {
$r = Get-ItemProperty -Path $k -ErrorAction Stop
if ($r.Installed -eq 1 -and [int]$r.Major -ge 14 -and [int]$r.Minor -ge 20) { return $true }
} catch { }
}
if ((Get-HostMachineArch) -eq "arm64") { return $false }
$sys = $env:SystemRoot
if ($sys -and (Test-Path (Join-Path $sys 'System32\vcruntime140_1.dll'))) { return $true }
return $false
}
# Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op). Unlike CMake
# and Build Tools torch cannot import without it, and winget is absent on LTSC/Server images.
function Ensure-VCRedist {
if (Test-VCRedistInstalled) { step "vcredist" "present"; return }
Write-StudioLine "Microsoft Visual C++ Redistributable (2015-2022) is missing; the prebuilt llama.cpp and PyTorch need it. Installing the runtime..." -ForegroundColor Yellow
if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
try {
Invoke-SetupCommand { winget install --id Microsoft.VCRedist.2015+.x64 --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
} catch { substep "VCRedist install failed: $($_.Exception.Message)" "Yellow" }
}
if (-not (Test-VCRedistInstalled)) {
# Evergreen link; /quiet /norestart so it never blocks or reboots an unattended run.
# Always the x64 package, deliberately: Microsoft ships it as the Arm64X superset of
# both ARM64 and X64 binaries and documents it as the one for ARM64 devices, while
# the arm64 package is ARM64-only (learn.microsoft.com/cpp/windows/latest-supported-vc-redist).
# PROCESSOR_ARCHITECTURE is wrong twice here: it reports the process, and the runtime
# must match the interpreter loading the DLLs, an emulated x64 Python not yet created.
$url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
$dst = Join-Path ([System.IO.Path]::GetTempPath()) "vc_redist.x64.exe"
substep "winget unavailable or failed; downloading the runtime directly..."
# Windows PowerShell 5.1 on an old image can carry a .NET default protocol set that
# predates TLS 1.2, which aka.ms refuses -- exactly the no-winget host this fallback
# exists for. SystemDefault (0) means "let the OS choose" and already covers TLS 1.2+,
# so only an explicit legacy set is upgraded, and it is restored afterwards.
$_prevProtocol = $null
try {
$_cur = [System.Net.ServicePointManager]::SecurityProtocol
if ([int]$_cur -ne 0 -and ([int]$_cur -band [int][System.Net.SecurityProtocolType]::Tls12) -eq 0) {
[System.Net.ServicePointManager]::SecurityProtocol = $_cur -bor [System.Net.SecurityProtocolType]::Tls12
$_prevProtocol = $_cur
}
} catch { $_prevProtocol = $null }
try {
Invoke-WebRequest -Uri $url -OutFile $dst -UseBasicParsing -TimeoutSec 300
# HTTPS secures the transfer, not the payload, and this runs with the setup
# process's privileges. The evergreen URL rules out a SHA-256 pin (the bytes
# change with every VS servicing update), so check the publisher. Status alone
# is not enough: any trusted CA's code-signing cert passes it.
$sig = Get-AuthenticodeSignature -LiteralPath $dst
if ($sig.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
$null -eq $sig.SignerCertificate -or
$sig.SignerCertificate.Subject -notmatch '(^|,\s*)O="?Microsoft Corporation"?(,|$)') {
throw "the downloaded VC++ runtime is not validly signed by Microsoft (signature status: $($sig.Status))"
}
$p = Start-Process -FilePath $dst -ArgumentList '/quiet', '/norestart' -Wait -PassThru
# 3010 = success, reboot required; usable either way.
if ($p.ExitCode -notin @(0, 3010)) {
substep "VC++ runtime installer exited $($p.ExitCode)" "Yellow"
}
Refresh-Environment
} catch {
substep "Direct VC++ runtime download failed: $($_.Exception.Message)" "Yellow"
} finally {
if ($null -ne $_prevProtocol) {
try { [System.Net.ServicePointManager]::SecurityProtocol = $_prevProtocol } catch { }
}
Remove-Item -LiteralPath $dst -Force -ErrorAction SilentlyContinue
}
}
if (Test-VCRedistInstalled) { step "vcredist" "installed" }
else {
substep "Could not install the VC++ Redistributable automatically." "Yellow"
substep "If llama-server or torch reports a missing VCRUNTIME140.dll, install:" "Yellow"
substep "https://aka.ms/vs/17/release/vc_redist.x64.exe" "Yellow"
}
}
# ─────────────────────────────────────────────
# Output style (aligned with studio/setup.sh: step / substep)
# ─────────────────────────────────────────────
$Rule = [string]::new([char]0x2500, 52)
function Enable-StudioVirtualTerminal {
if ($env:NO_COLOR) { return $false }
# A redirected stdout is not a console and GetConsoleMode fails on a non-console handle, so the
# block below could only return $false anyway. Answer without Add-Type, which runs csc.exe and
# drops source in %TEMP%. The CLI and the desktop app both pipe us, so this is the path the
# compile was on.
if ($script:StudioStdoutRedirected) { return $false }
try {
Add-Type -Namespace StudioVT -Name Native -MemberDefinition @'
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);
[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);
'@ -ErrorAction Stop
$h = [StudioVT.Native]::GetStdHandle(-11)
[uint32]$mode = 0
if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false }
$mode = $mode -bor 0x0004
return [StudioVT.Native]::SetConsoleMode($h, $mode)
} catch {
return $false
}
}
$script:StudioVtOk = Enable-StudioVirtualTerminal
function Get-StudioAnsi {
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')]
[string]$Kind
)
$e = [char]27
switch ($Kind) {
'Title' { return "${e}[38;5;150m" }
'Dim' { return "${e}[38;5;245m" }
'Ok' { return "${e}[38;5;108m" }
'Warn' { return "${e}[38;5;136m" }
'Err' { return "${e}[91m" }
'Reset' { return "${e}[0m" }
}
}
function Write-SetupVerboseDetail {
param(
[Parameter(Mandatory = $true)][string]$Message,
[string]$Color = "Gray"
)
if (-not $script:UnslothVerbose) { return }
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
$ansi = switch ($Color) {
'Green' { (Get-StudioAnsi Ok) }
'Gray' { (Get-StudioAnsi Dim) }
'DarkGray' { (Get-StudioAnsi Dim) }
'Yellow' { (Get-StudioAnsi Warn) }
'Cyan' { (Get-StudioAnsi Title) }
'Red' { (Get-StudioAnsi Err) }
default { (Get-StudioAnsi Dim) }
}
Write-StudioLine ($ansi + $Message + (Get-StudioAnsi Reset))
} else {
$fc = switch ($Color) {
'Green' { 'DarkGreen' }
'Gray' { 'DarkGray' }
'Cyan' { 'Green' }
default { $Color }
}
Write-StudioLine $Message -ForegroundColor $fc
}
}
function Invoke-SetupCommand {
param(
[Parameter(Mandatory = $true)][scriptblock]$Command,
[switch]$AlwaysQuiet
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# Reset to avoid stale values from prior native commands.
$global:LASTEXITCODE = 0
if ($script:UnslothVerbose -and -not $AlwaysQuiet) {
# Merge stderr into stdout so progress/warning output stays visible
# without flipping $? on successful native commands (PS 5.1 treats
# stderr records as errors that set $? = $false even on exit code 0).
# Redact per record: uv/pip echo index URLs (credentials and all) in
# their errors, and verbose mode must not bypass the quiet path's
# redaction. ForEach-Object/Out-Host leave $LASTEXITCODE untouched.
& $Command 2>&1 | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
} else {
$output = & $Command 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
}
}
return [int]$LASTEXITCODE
} finally {
$ErrorActionPreference = $prevEap
}
}
function Write-LlamaFailureLog {
param(
[string]$Output,
[int]$MaxLines = 120
)
if (-not $Output) { return }
$lines = @(
($Output -split "`r?`n") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
if ($lines.Count -eq 0) { return }
if ($lines.Count -gt $MaxLines) {
Write-StudioLine " Showing last $MaxLines lines:" -ForegroundColor DarkGray
$lines = $lines | Select-Object -Last $MaxLines
}
foreach ($line in $lines) {
Write-StudioLine " | $line" -ForegroundColor DarkGray
}
}
# Plain (no ANSI) form of a step/substep message on the OS stdout handle.
# Write-Host on 5.1 goes through the Information stream, which does not survive
# every install.ps1 -> python -> powershell.exe chain.
#
# One half of an either/or: when redirected this is the ONLY sink. It used to
# run in ADDITION to Write-Host, assuming that never reached the pipe. It does,
# because the CLI spawns us as `-Command "& 'setup.ps1' *>&1"`, so every step
# printed twice.
function Write-StudioStdoutMirror {
param([Parameter(Mandatory = $true)][string]$Line)
try {
if ($script:StudioStdoutRedirected) {
[Console]::Out.WriteLine($Line)
[Console]::Out.Flush()
}
} catch {}
}
function step {
param(
[Parameter(Mandatory = $true)][string]$Label,
[Parameter(Mandatory = $true)][string]$Value,
[string]$Color = "Green"
)
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
# Exactly one sink: the console handle when redirected, Write-Host (the only
# one that colorizes) when interactive.
if ($script:StudioStdoutRedirected) {
Write-StudioStdoutMirror (" {0}{1}" -f $padded, $Value)
return
}
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
$dim = Get-StudioAnsi Dim
$rst = Get-StudioAnsi Reset
$val = switch ($Color) {
'Green' { Get-StudioAnsi Ok }
'Yellow' { Get-StudioAnsi Warn }
'Red' { Get-StudioAnsi Err }
'DarkGray' { Get-StudioAnsi Dim }
default { Get-StudioAnsi Ok }
}
Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value)
} else {
$fc = switch ($Color) {
'Green' { 'DarkGreen' }
'Yellow' { 'Yellow' }
'Red' { 'Red' }
'DarkGray' { 'DarkGray' }
default { 'DarkGreen' }
}
# One composed record, not `-NoNewline` label + value: two Write-Host
# calls are two Information records, and a redirected consumer turns each
# boundary into a line break. Costs the dimmed label on no-VT consoles.
Write-Host (" {0}{1}" -f $padded, $Value) -ForegroundColor $fc
}
}
function substep {
param(
[Parameter(Mandatory = $true)][string]$Message,
[string]$Color = "DarkGray"
)
# Exactly one sink, as in `step` above.
if ($script:StudioStdoutRedirected) {
Write-StudioStdoutMirror (" {0,-15}{1}" -f "", $Message)
return
}
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
$msgCol = switch ($Color) {
'Yellow' { (Get-StudioAnsi Warn) }
default { (Get-StudioAnsi Dim) }
}
$pad = "".PadRight(15)
Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset))
} else {
$fc = switch ($Color) {
'Yellow' { 'Yellow' }
default { 'DarkGray' }
}
Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
}
}
function Show-NpmRegistryHint {
# Print actionable guidance when a frontend/OXC npm/bun install fails and the
# registry lock is the likely cause (corporate firewall/proxy). No-op once the
# user has opted in via UNSLOTH_NPM_REGISTRY. We never switch registries
# automatically -- we only guide.
if ($env:UNSLOTH_NPM_REGISTRY) { return }
$mirror = $env:NPM_CONFIG_REGISTRY
if (-not $mirror) {
# Read npm config from a dir with no project .npmrc so the frontend's pinned
# registry= does not mask the user's ~/.npmrc / global mirror.
$pushed = $false
try {
Push-Location ([System.IO.Path]::GetTempPath()) -ErrorAction Stop
$pushed = $true
$mirror = (& npm config get registry 2>$null | Out-String).Trim()
} catch { $mirror = "" } finally { if ($pushed) { Pop-Location } }
}
if ($mirror -in @("", "undefined", "null", "https://registry.npmjs.org", "https://registry.npmjs.org/")) {
$mirror = ""
}
Write-StudioLine ""
step "frontend" "registry.npmjs.org looks blocked (corporate firewall/proxy?)" "Yellow"
if ($mirror) {
substep "Unsloth pins the public npm registry; your mirror is being ignored."
substep "Detected a registry in your npm config:"
substep " $mirror"
substep "Re-run pointing Unsloth at it:"
substep " `$env:UNSLOTH_NPM_REGISTRY='$mirror'; .\install.ps1 --local"
} else {
substep "If you use a private mirror/proxy, point Unsloth at it and re-run:"
substep " `$env:UNSLOTH_NPM_REGISTRY='https://your-mirror.example/api/npm/'; .\install.ps1 --local"
}
substep "(min-release-age and save-exact stay enforced.)"
}
# ─────────────────────────────────────────────
# Banner
# ─────────────────────────────────────────────
Write-StudioLine ""
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
Write-StudioLine (" " + (Get-StudioAnsi Title) + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup" + (Get-StudioAnsi Reset))
Write-StudioLine (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
} else {
Write-StudioLine (" " + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup") -ForegroundColor Green
Write-StudioLine " $Rule" -ForegroundColor DarkGray
}
# WebView2 caches keyed by the bundle id can keep serving the previous frontend
# after an update. Cache-only: storage, cookies, settings, models and the studio
# database are untouched. Called only once the UNSLOTH_STUDIO_HOME / STUDIO_HOME
# override is validated, so a mistyped override cannot wipe the cache and then abort.
function Clear-WebViewCaches {
if (-not $env:LOCALAPPDATA) { return }
$wvDefault = Join-Path $env:LOCALAPPDATA "ai.unsloth.studio\EBWebView\Default"
# Drop the version stamp first. The old WebView still holds these files, which is
# why the removals below can fail; the app's own clear is the retry, and it is
# skipped while the stamp matches the running version. Unconditional, since a
# repair or a local rebuild leaves the version unchanged and a redundant clear on
# the next launch is the cheap side of the trade.
Remove-Item -LiteralPath (Join-Path $env:LOCALAPPDATA "ai.unsloth.studio\.webview-cache-cleared") `
-Force -ErrorAction SilentlyContinue
$wvCleared = $false
foreach ($wvSub in @("Cache", "Code Cache", "GPUCache", "Service Worker")) {
$wvPath = Join-Path $wvDefault $wvSub
# Get-Item -Force, not Test-Path: the probe throws on an ACL denial under
# "Stop", and a reparse point must be unlinked, not recursed into.
$wvItem = Get-Item -LiteralPath $wvPath -Force -ErrorAction SilentlyContinue
if (-not $wvItem) { continue }
try {
if ($wvItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $wvItem.Delete() }
else { Remove-Item -LiteralPath $wvPath -Recurse -Force -ErrorAction Stop }
$wvCleared = $true
} catch { }
}
if ($wvCleared) { substep "cleared stale WebView caches (ai.unsloth.studio); settings and data kept" }
}
# Resolve and preflight the install root before phase 1, with the same override
# precedence as the other resolvers. Everything below joins onto USERPROFILE, so a
# blank one must fail here instead of throwing a raw binding error under "Stop".
# Null or empty only: Join-Path accepts whitespace, and those runs used to complete.
if ([string]::IsNullOrEmpty($env:USERPROFILE)) {
Write-StudioLine "ERROR: USERPROFILE is not set." -ForegroundColor Red
Exit-SetupFailure "USERPROFILE is not set"
}
$_studioOverrideVar = $null
$_studioOverride = $null
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
$_studioOverrideVar = "UNSLOTH_STUDIO_HOME"
$_studioOverride = $env:UNSLOTH_STUDIO_HOME.Trim()
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
$_studioOverrideVar = "STUDIO_HOME"
$_studioOverride = $env:STUDIO_HOME.Trim()
}
if ($_studioOverride) {
if ($_studioOverride -eq "~" -or $_studioOverride -like "~/*" -or $_studioOverride -like "~\*") {
$_studioOverride = (Join-Path $env:USERPROFILE $_studioOverride.Substring(1).TrimStart('/','\'))
}
if (Test-Path -LiteralPath $_studioOverride -PathType Container) {
$StudioHome = (Resolve-Path -LiteralPath $_studioOverride).Path
# Mirror setup.sh and install.ps1: fail before install work if it is read-only.
$_setupWriteProbe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid())
try {
[System.IO.File]::WriteAllText($_setupWriteProbe, "")
Remove-Item -LiteralPath $_setupWriteProbe -Force -ErrorAction SilentlyContinue
} catch {
Write-StudioLine "ERROR: $_studioOverrideVar=$StudioHome is not writable." -ForegroundColor Red
Exit-SetupFailure "$_studioOverrideVar=$StudioHome is not writable"
}
} else {
Write-StudioLine "ERROR: $_studioOverrideVar=$_studioOverride does not exist." -ForegroundColor Red
Write-StudioLine " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
Exit-SetupFailure "$_studioOverrideVar=$_studioOverride does not exist"
}
} else {
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
}
$VenvDir = Join-Path $StudioHome "unsloth_studio"
$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"
$StudioHomeIsCustom = Test-StudioHomeIsCustom
$LlamaCppDir = Get-ManagedLlamaCppDir
$UnslothHome = Split-Path -Parent $LlamaCppDir
$WithLlamaCppDir = $null
$llamaPreflightFailure = Invoke-ManagedLlamaCppPreflight
if ($llamaPreflightFailure) {
Exit-SetupFailure $llamaPreflightFailure
}
# Back up User PATH under HKCU\Software\Unsloth before any modifications.
try {
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
if ($envKey) {
try {
$rawPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
} finally {
$envKey.Close()
}
if ($rawPath) {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
}
}
} catch {
Write-StudioLine "[DEBUG] Could not back up User PATH: $($_.Exception.Message)" -ForegroundColor DarkGray
}
# ==========================================================================
# PHASE 1: System-level prerequisites (winget installs, env vars)
# All heavy system tool installs happen here BEFORE touching Python.
# ==========================================================================
# ============================================
# 1a. GPU detection
# ============================================
# ── Helper: run nvidia-smi under a timeout ──
# A wedged NVIDIA driver can make nvidia-smi block during init or after a reset;
# WaitForExit bounds it (mirrors Invoke-AmdSmiNoElevate below) so detection
# cannot hang setup. No RunAsInvoker compat layer: nvidia-smi does not
# auto-elevate. Returns combined stdout+stderr; "" on timeout/failure.
function Invoke-NvidiaSmiBounded {
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Exe,
[Parameter(Position = 1)][string[]]$SmiArgs = @(),
[int]$TimeoutSec = 10,
# Driver warnings on stderr would corrupt machine-readable --query-gpu
# output; the human-readable probes keep the default merge.
[switch]$StdoutOnly
)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = ($SmiArgs -join ' ')
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
$global:LASTEXITCODE = 124
return ""
}
$global:LASTEXITCODE = $proc.ExitCode
if ($StdoutOnly) { return $outTask.Result }
return ($outTask.Result + "`n" + $errTask.Result)
} catch {
$global:LASTEXITCODE = 1
return ""
}
}
# ── Helper: nvidia-smi -L lists at least one real GPU ──
# Exit code 0 alone is not enough: a stale/driverless nvidia-smi can exit 0
# while listing no GPU, which would mark an AMD host NVIDIA and suppress ROCm
# detection. Require a "GPU <n>:" data row.
function Test-NvidiaSmiHasGpu {
param([Parameter(Mandatory = $true)][string]$Exe)
$out = Invoke-NvidiaSmiBounded $Exe @('-L')
return ($LASTEXITCODE -eq 0 -and $out -match '(?m)^GPU\s+\d+:')
}
$HasNvidiaSmi = $false
$NvidiaSmiExe = $null # Absolute path -- survives Refresh-Environment
try {
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($nvSmiCmd -and (Test-NvidiaSmiHasGpu $nvSmiCmd.Source)) {
$HasNvidiaSmi = $true
$NvidiaSmiExe = $nvSmiCmd.Source
}
} catch {}
# Fallback: nvidia-smi may not be on PATH even though a GPU + driver exist.
# Check the default install location and the Windows driver store.
if (-not $HasNvidiaSmi) {
$nvSmiDefaults = @(
"$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
"$env:SystemRoot\System32\nvidia-smi.exe"
)
foreach ($p in $nvSmiDefaults) {
if (Test-Path $p) {
try {
if (Test-NvidiaSmiHasGpu $p) {
$HasNvidiaSmi = $true
$NvidiaSmiExe = $p
Write-StudioLine " Found nvidia-smi at $(Split-Path $p -Parent)" -ForegroundColor Gray
break
}
} catch {}
}
}
}
# ── Helper: run amd-smi without triggering a UAC elevation prompt ──
# amd-smi on Windows auto-elevates to read GPU/APU memory, surfacing a confusing
# DiskPart UAC prompt mid-install (Unsloth backend amd.py hits the same). RunAsInvoker
# forces it (and helpers it spawns) to run un-elevated; on failure the WMI name ->
# gfx fallback still resolves the arch.
function Invoke-AmdSmiNoElevate {
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Exe,
[Parameter(Position = 1)][string[]]$SmiArgs = @(),
[int]$TimeoutSec = 30
)
# RunAsInvoker blocks the auto-elevation/UAC prompt; the timeout bounds a flaky
# amd-smi that can otherwise spin for minutes (30s mirrors the backend amd.py).
$prevCompat = [Environment]::GetEnvironmentVariable('__COMPAT_LAYER', 'Process')
$env:__COMPAT_LAYER = 'RunAsInvoker'
try {
# [Process]::Start, NOT Start-Process -PassThru: the latter leaves .ExitCode
# $null after WaitForExit on PS 5.1, so $LASTEXITCODE (checked by callers)
# reads non-zero and kills detection. Async reads drain the pipes (no
# deadlock); amd-smi args have no spaces so a plain join is safe.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = ($SmiArgs -join ' ')
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
$global:LASTEXITCODE = 124
return ""
}
$global:LASTEXITCODE = $proc.ExitCode
return ($outTask.Result + "`n" + $errTask.Result)
} catch {
$global:LASTEXITCODE = 1
return ""
} finally {
if ($null -eq $prevCompat) {
Remove-Item Env:__COMPAT_LAYER -ErrorAction SilentlyContinue
} else {
$env:__COMPAT_LAYER = $prevCompat
}
}
}
# ── AMD ROCm detection (Windows): probe hipinfo/amd-smi for actual GPU ──
$HasROCm = $false
$HipSdkInstalled = $false # HIP SDK binary found (independent of device accessibility)
$ROCmGpuLabel = $null
$script:ROCmGpuLabels = @() # every AMD adapter name WMI reported (shadowing-aware inference)
$script:ROCmGfxArch = $null
# Beside ROCmGfxArch, NOT inside the `-not $HasNvidiaSmi` block below: the ROCm summary
# reads this unconditionally, so an NVIDIA host would hit an undefined variable under a
# caller's Set-StrictMode. Assigning here also clears a stale value on a re-invocation.
$script:ROCmUnsupportedGfxArch = $null
# APU gfx arches whose board commonly also carries a discrete Radeon. HIP often
# enumerates the APU first, so an index-0 pick reads the iGPU's arch and the dGPU never
# gets its wheels (#7776: gfx1036 Raphael shadowing a gfx1200 RX 9060 XT). In sync with
# _SHADOWING_INTEGRATED_GFX in studio/install_python_stack.py. The Strix arches
# (gfx1150/1151/1152) are deliberately absent: first-class training targets, untouched.
$script:ShadowingIntegratedGfx = @(
"gfx90c", # Renoir / Cezanne
"gfx1013", # Cyan Skillfish
"gfx1033", # Van Gogh
"gfx1035", # Rembrandt
"gfx1036", # Raphael / Mendocino
"gfx1103", # Phoenix / Hawk Point
"gfx1153" # Krackan Point 2
)
# gfx arch -> AMD per-arch wheel index family. Defined here, not beside $ROCmIndexUrl,
# because Resolve-ShadowingGfxPick runs during detection, long before the install block,
# and must know which arches AMD ships Windows wheels for. In sync with
# _GFX_TO_AMD_INDEX_ARCH (test_rocm_arch_table_parity.py).
$archFamilyMap = @{
"gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4
"gfx1151" = "gfx1151"; "gfx1150" = "gfx1150" # RDNA 3.5 (Strix Halo/Point)
"gfx1152" = "gfx1152" # RDNA 3.5 (Krackan Point)
"gfx1103" = "gfx110X-all"; "gfx1102" = "gfx110X-all" # RDNA 3
"gfx1101" = "gfx110X-all"; "gfx1100" = "gfx110X-all"
"gfx1036" = "gfx103X-all"; "gfx1035" = "gfx103X-all" # RDNA 2 (RX 6000)
"gfx1034" = "gfx103X-all"; "gfx1033" = "gfx103X-all"
"gfx1032" = "gfx103X-all"; "gfx1031" = "gfx103X-all"
"gfx1030" = "gfx103X-all"
"gfx90a" = "gfx90a"; "gfx908" = "gfx908" # MI200/MI100
}
# True when any of the three masks is set. Mirrors _visible_devices_pinned(): ANY value
# is a deliberate selection, "" and "-1" included -- those select NO GPU rather than
# meaning "unset".
function Test-VisibleDevicesPinned {
foreach ($visEnv in @($env:HIP_VISIBLE_DEVICES, $env:ROCR_VISIBLE_DEVICES, $env:CUDA_VISIBLE_DEVICES)) {
if ($null -ne $visEnv) { return $true }
}
return $false
}
# The one mask -> index resolver for every pick site below; the sites used to inline
# their own expressions and disagreed (hipinfo rejected " 1 ", amd-smi rejected "1,0"),
# and a mask Resolve-ShadowingGfxPick honours but the index ignores lands on GPU 0, the
# very iGPU this preference exists to skip. Mirrors _pick_visible_index(): first-set-wins
# (the runtime lets an empty HIP mask shadow CUDA_VISIBLE_DEVICES rather than defer to
# it), and an unparseable or out-of-range value falls back to GPU 0.
function Resolve-VisibleGpuIndex {
param([int]$Count)
foreach ($visEnv in @($env:HIP_VISIBLE_DEVICES, $env:ROCR_VISIBLE_DEVICES, $env:CUDA_VISIBLE_DEVICES)) {
if ($null -eq $visEnv) { continue }
$val = $visEnv.Trim()
if ($val -eq "" -or $val -eq "-1") { return 0 }
$first = ($val -split ',')[0].Trim()
# TryParse, not [int]: '2147483648' overflows and .NET's \d also matches full-width
# digits, and either cast throws a TERMINATING error under this script's
# $ErrorActionPreference = "Stop", aborting the install where nothing catches it.
[int]$parsed = 0
if ([int]::TryParse($first, [ref]$parsed)) {
if ($parsed -ge 0 -and $parsed -lt $Count) { return $parsed }
substep "[WARN] HIP/ROCR/CUDA_VISIBLE_DEVICES index $first is out of range ($Count GPU(s) detected); defaulting to GPU 0 for arch selection" "Yellow"
}
return 0
}
return 0
}
# Mirrors _dedup_pick(). setup.ps1 resolves the arch and builds $ROCmIndexUrl from it
# before ever invoking the Python stack installer, so the shadowing-iGPU skip has to
# happen here too or a fresh install still pulls the iGPU's wheel family. Returns $Picked
# unchanged when pinned, when only one distinct arch exists, or when no discrete arch is.
function Resolve-ShadowingGfxPick {
param([AllowNull()][string]$Picked, [AllowNull()][string[]]$AllArches)
if (-not $Picked) { return $Picked }
# Only arches in $archFamilyMap have an AMD Windows wheel index.
function Test-GfxHasWheels { param([AllowNull()][string]$Arch) return [bool]($Arch -and $archFamilyMap.ContainsKey($Arch)) }
# A selected device is honoured verbatim; never repick over the user.
if (Test-VisibleDevicesPinned) { return $Picked }
if ($script:ShadowingIntegratedGfx -notcontains $Picked) { return $Picked }
$distinctArches = @($AllArches | Select-Object -Unique)
if ($distinctArches.Count -lt 2) { return $Picked }
# Deposing a supported APU for a discrete card with no Windows wheels (gfx1036 + an
# older gfx1010) resolves to no index and drops the host to CPU, worse than the
# shadowing itself. So prefer a wheel-backed discrete card, and fall back to an
# unsupported one only when the pick has no wheels either: taking the first
# non-integrated arch instead sent gfx90c,gfx1010,gfx1200 to CPU torch despite the
# supported gfx1200. Mirrors _dedup_pick()'s `_withWheels or (...)`.
$pickedHasWheels = Test-GfxHasWheels $Picked
$others = @($AllArches | Where-Object { $script:ShadowingIntegratedGfx -notcontains $_ })
$withWheels = @($others | Where-Object { Test-GfxHasWheels $_ })
$candidates = if ($withWheels.Count -gt 0) { $withWheels }
elseif (-not $pickedHasWheels) { $others }
else { @() }
$discreteArch = $candidates | Select-Object -First 1
if (-not $discreteArch) { return $Picked }
# Not always device 1: on gfx1036,gfx1010,gfx1200 the pick is device 2, and
# naming 1 would expose the gfx1010 the wheels do not target.
$discreteIdx = [array]::IndexOf(@($AllArches), $discreteArch)
if ($discreteIdx -lt 0) { $discreteIdx = 1 }
substep "multiple AMD GPUs detected ($($distinctArches -join ', ')); installing for the discrete $discreteArch instead of the integrated $Picked" "Cyan"
# HIP still enumerates the iGPU as device 0 at runtime, so the wheels alone do not
# steer training to the dGPU; setx makes the mask persist across sessions.
substep "Run 'setx HIP_VISIBLE_DEVICES $discreteIdx' and reopen your terminal so Unsloth uses $discreteArch at runtime too, not just at install time" "Cyan"
return $discreteArch
}
if (-not $HasNvidiaSmi) {
# hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution).
# AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type.
# Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so amd-smi
# would still auto-elevate. Cf. _path_inside_venv().
function Test-HipinfoIsVenvInternal {
param([AllowNull()][string]$HipinfoPath)
if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false }
# VenvDir/VIRTUAL_ENV can be unset this early (the update flow probes before
# VenvDir is set), so also derive the venv from the setup python + default
# Unsloth home, else the venv hipInfo isn't caught.
$venvRoots = @()
if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV }
$vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue
if ($vd) { $venvRoots += $vd }
if ($env:UNSLOTH_SETUP_PYTHON) {
try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {}
}
if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") }
# A custom Unsloth home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
# venv off the default path; seed it too or its hipInfo escapes the filter.
$studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null }
if ($studioHomeEnv) {
# Expand a leading ~ like the canonical resolver below; else GetFullPath
# keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter.
if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
# A bare "~" leaves an empty child path; Join-Path rejects that on
# PS 5.1, so use USERPROFILE directly and only join a real remainder.
$studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\')
$studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE }
}
$venvRoots += (Join-Path $studioHomeEnv "unsloth_studio")
}
try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false }
foreach ($root in $venvRoots) {
if ([string]::IsNullOrWhiteSpace($root)) { continue }
try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue }
# Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like
# C:\Python311\python.exe yields C:) -- it would match every path on that drive.
if ($r -match '^[a-zA-Z]:$') { continue }
if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or
$hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
# Scan all hipinfo and keep the first non-venv one (the venv copy from the
# bnb fix could shadow a real HIP SDK's). -CommandType Application matches
# only real executables, not a user alias/function named hipinfo.
$hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue |
Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } |
Select-Object -First 1
if (-not $hipinfoExe) {
# Iterate the env roots (mirrors the Python list) and take the first non-venv
# bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH.
$hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null
foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) {
$hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel)
if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue }
$hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe"
if (-not (Test-Path $hipinfoCandidate)) {
if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate }
continue
}
if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK
substep "[WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" "Yellow"
substep " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" "Yellow"
substep " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" "Yellow"
$hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate }
break
}
if ((-not $hipinfoExe) -and $hipMissingLabel) {
substep "[WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" "Yellow"
substep " HIP SDK install may be incomplete -- re-install from:" "Yellow"
substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
}
}
if ($hipinfoExe) {
$HipSdkInstalled = $true # binary found → SDK is installed regardless of device state
try {
$hipOut = & $hipinfoExe.Source 2>&1 | Out-String
if ($hipOut -match "(?i)gcnArchName") {
# hipinfo can crash after printing gcnArchName (#6043).
# Once the arch is printed, keep the ROCm wheel path.
$HasROCm = $true
$_hipAllArches = @([regex]::Matches($hipOut, "(?im)^\s*gcnArchName\s*:\s*(\S+)") | ForEach-Object { ($_.Groups[1].Value -split ':')[0].Trim().ToLower() })
if ($_hipAllArches.Count -gt 0) {
# hipinfo is itself a HIP application, so under a mask it already
# enumerated only the visible devices, renumbered from 0; indexing it
# again applies the mask twice and lands on the wrong card. amd-smi and
# the WMI path below list every GPU, so they still index.
$script:ROCmGfxArch = $_hipAllArches[0]
$script:ROCmGfxArch = Resolve-ShadowingGfxPick $script:ROCmGfxArch $_hipAllArches
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
} else {
$ROCmGpuLabel = "AMD ROCm"
}
if ($LASTEXITCODE -ne 0) {
substep "[INFO] hipinfo exited with code $LASTEXITCODE but reported gcnArchName -- treating as ROCm-capable (see #6043)" "Cyan"
}
} elseif ($LASTEXITCODE -ne 0) {
# hipinfo ran but returned a HIP runtime error without any gcnArchName
# output (e.g. "no ROCm-capable device detected"), or crashed before
# printing device info.
$firstLine = ($hipOut -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1)
substep "[WARN] hipinfo returned a HIP runtime error (exit $LASTEXITCODE)" "Yellow"
substep " $firstLine" "Yellow"
substep " Ensure ROCm drivers are installed: https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
}
} catch {}
}
# amd-smi fallback: HIP runtime present but hipinfo unavailable (no full HIP SDK).
# 'list' confirms GPU visibility, 'static --asic' extracts the gfx arch hipinfo
# would give. Critical for Strix Halo (gfx1151) and other HIP-runtime-only iGPUs.
#
# BUT on hosts without a working HIP runtime amd-smi elevates a child at runtime,
# popping a UAC/DiskPart prompt RunAsInvoker can't suppress (its manifest is
# asInvoker; even 'amd-smi version' hangs). So only probe when a HIP SDK is present
# (hipinfo found -> un-elevated) or the user opts in; else fall through to WMI name
# inference (enough to pick ROCm wheels + the ROCm llama.cpp prebuilt).
# An explicit opt-out (UNSLOTH_ENABLE_AMD_SMI=0/false/no/off) wins over the HIP-SDK
# heuristic: a HIP SDK binary with a broken runtime can still pop the prompt, so
# $HipSdkInstalled must NOT silently re-enable it.
$amdSmiOptOut = $env:UNSLOTH_ENABLE_AMD_SMI -match '^(?i)(0|false|no|off)$'
$amdSmiAllowed = (-not $amdSmiOptOut) -and ($HipSdkInstalled -or ($env:UNSLOTH_ENABLE_AMD_SMI -match '^(?i)(1|true|yes|on)$'))
if (-not $HasROCm -and $amdSmiAllowed) {
$amdSmiExe = Get-Command "amd-smi" -ErrorAction SilentlyContinue
if ($amdSmiExe) {
try {
$smiOut = Invoke-AmdSmiNoElevate $amdSmiExe.Source @('list')
if ($LASTEXITCODE -eq 0 -and $smiOut -match "(?im)^GPU\s*[:\[]\s*\d") {
$HasROCm = $true
# Attempt 1: newer amd-smi versions embed the gfx arch in list output.
# Collect ALL gfx tokens in output order so that on mixed-arch systems
# we can honour HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES and pick the
# arch for the *runtime-visible* GPU rather than always the first one.
# Do NOT deduplicate: a dual same-arch system (e.g. two gfx1151 APUs)
# must produce a 2-element array so HIP_VISIBLE_DEVICES=1 selects the
# second GPU rather than triggering a false out-of-range warning.
# Note: this mapping assumes amd-smi lists GPUs in the same order as
# HIP enumerates them (both follow PCI bus order in practice); it may
# give the wrong arch when GPU indices are non-contiguous (very rare).
$allGfxArches = @([regex]::Matches($smiOut, '(?i)\b(gfx\d+[a-z]?)\b') |
ForEach-Object { $_.Groups[1].Value.ToLower() })
if ($allGfxArches.Count -gt 0) {
# amd-smi lists every GPU regardless of the masks, so resolve the
# index here, via the shared helper so a comma list or a padded
# value cannot select a different GPU than elsewhere.
$script:ROCmGfxArch = $allGfxArches[(Resolve-VisibleGpuIndex $allGfxArches.Count)]
$script:ROCmGfxArch = Resolve-ShadowingGfxPick $script:ROCmGfxArch $allGfxArches
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
} else {
# Attempt 2: 'static --asic' exposes ASIC details on ROCm 6+,
# including the GFX target needed for wheel index selection.
$smiAsicOut = ""
try { $smiAsicOut = Invoke-AmdSmiNoElevate $amdSmiExe.Source @('static','--asic') } catch {}
# Every token, not just the first: this branch is reached precisely
# when 'list' saw multiple GPUs but printed no arches, so a leading
# iGPU here reintroduces #7776.
$asicGfxArches = @([regex]::Matches($smiAsicOut, '(?i)\b(gfx\d+[a-z]?)\b') |
ForEach-Object { $_.Groups[1].Value.ToLower() })
if ($asicGfxArches.Count -gt 0) {
$script:ROCmGfxArch = $asicGfxArches[(Resolve-VisibleGpuIndex $asicGfxArches.Count)]
$script:ROCmGfxArch = Resolve-ShadowingGfxPick $script:ROCmGfxArch $asicGfxArches
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
} elseif ($smiAsicOut -match "(?im)Market.?Name\s*[:\|]\s*([^\r\n]+)") {
$ROCmGpuLabel = "AMD ROCm ($($Matches[1].Trim()))"
} else {
$ROCmGpuLabel = "AMD ROCm"
}
}
}
} catch {}
}
}
# WMI fallback: AMD GPU in device list but no HIP SDK → guide the user.
# WMI gives a marketing name (e.g. "AMD Radeon 890M") but never a gfx arch.
# $HasROCm is intentionally NOT set here — we cannot confirm ROCm runtime
# support without hipinfo or amd-smi. The name is saved to $ROCmGpuLabel
# so the name-based inference below can still attempt an arch lookup.
if (-not $HasROCm) {
try {
# Keep every AMD adapter, not just the first: WMI orders controllers as the
# driver stack enumerated them, so a shadowing iGPU can lead here exactly as
# under HIP (#7776), and the inference below runs the same preference over the
# whole list. ConfigManagerErrorCode 0 is "working properly": a disabled or
# driver-errored Radeon must not depose a working iGPU.
$amdGpus = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "AMD|Radeon" })
$healthyGpus = @($amdGpus | Where-Object {
($null -eq $_.ConfigManagerErrorCode) -or ($_.ConfigManagerErrorCode -eq 0) })
# If that leaves none, the filter alone made the host look GPU-less and the
# inference forwards nothing: code 45 ("not connected") is routine on a muxless
# laptop with a parked dGPU, and with no healthy peer there is nothing to
# depose. Mirrors the same fallback in install_python_stack.py's WMI path.
# @() wraps the WHOLE if, not each branch: a one-element array unrolls on its way
# out of an if-expression, and a scalar's .Count is $null under Windows PowerShell
# 5.1, so the single Radeon in the machine read as no AMD GPU at all -- no
# $script:ROCmGpuLabels, no inferred gfx arch, "gpu none" in the report, and an
# installed +rocm venv judged stale against a required "cpu" (#8335). Same idiom as
# the Intel scan below and in install.ps1.
$wmiGpus = @(if ($healthyGpus.Count -gt 0) { $healthyGpus } else { $amdGpus })
if ($wmiGpus.Count -gt 0) {
$script:ROCmGpuLabels = @($wmiGpus | ForEach-Object { $_.Name })
$ROCmGpuLabel = $script:ROCmGpuLabels[0]
}
} catch {}
}
# Peer names for the REPORT ONLY. amd-smi can confirm a runtime with no gfx token and
# only the first card's name, and the scan above is skipped there, so the verdict below
# would speak for a host it has seen one adapter of. Kept out of $script:ROCmGpuLabels
# deliberately: that feeds the inference, and widening it turned a CPU install into ROCm.
$script:ROCmPeerLabels = @()
if ($HasROCm -and -not $script:ROCmGfxArch) {
try {
$peerGpus = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match "AMD|Radeon" })
$healthyPeers = @($peerGpus | Where-Object {
($null -eq $_.ConfigManagerErrorCode) -or ($_.ConfigManagerErrorCode -eq 0) })
$usePeers = @(if ($healthyPeers.Count -gt 0) { $healthyPeers } else { $peerGpus })
$script:ROCmPeerLabels = @($usePeers | ForEach-Object { $_.Name })
} catch {}
}
# GPU name -> gfx arch for AMD generations Unsloth's ROCm wheels do NOT cover: RDNA 1
# and Polaris 10/20/30 (unslothai#8529). Kept apart from $nameArchTable on purpose: it
# only WORDS a message, never selects a wheel index or a prebuilt. AMD's TheRock ships
# RDNA 1 wheels, but not on the repo.amd.com indexes routed here, and never gfx803.
# The (?!0) guards stop "RX 570" swallowing an "RX 5700". Names from LLVM's AMDGPU
# tables plus libdrm amdgpu.ids/pci.ids for the Navi 10/14 professional parts LLVM
# omits; nothing is guessed, so Polaris 11/12 (RX 460/550/560, a different die) is
# left out.
$unsupportedNameArchTable = @(
@{ P = "Radeon Pro V520|Radeon Pro 5600M"; A = "gfx1011" } # RDNA 1
@{ P = "RX 5700|RX 5600|Radeon Pro 5600 XT|Radeon Pro 5700|Radeon Pro W5700"; A = "gfx1010" } # RDNA 1 (Navi 10)
@{ P = "RX 5500|RX 5300|Radeon Pro W5500|Radeon Pro W5300"; A = "gfx1012" } # RDNA 1 (Navi 14)
@{ P = "RX 4[78]0(?!0)|RX 5[789]0(?!0)|Radeon Pro WX 7100|Radeon Pro WX 5100"; A = "gfx803" } # Polaris 10/20/30
)
$script:ROCmUnsupportedGfxArch = $null
# ── Arch resolution: env-var override → name inference ──────────────────
# Runs after all probes, even when none confirmed a ROCm runtime ($HasROCm false):
# the Adrenalin driver alone runs the per-gfx ROCm llama.cpp prebuilt (bundles its
# own runtime), and all it needs is the gfx arch, inferable from the WMI GPU name.
# Resolving it here lets setup.ps1 forward --rocm-gfx so a GPU llama.cpp is pulled
# instead of CPU. (PyTorch ROCm wheels still require a HIP SDK -- gated on $HasROCm
# below -- so this only affects llama.cpp / inference.)
if (-not $script:ROCmGfxArch) {
# 1. Manual override: set UNSLOTH_ROCM_GFX_ARCH=gfx1151 before running.
if ($env:UNSLOTH_ROCM_GFX_ARCH) {
$script:ROCmGfxArch = $env:UNSLOTH_ROCM_GFX_ARCH.Trim().ToLower()
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
substep "gfx arch from UNSLOTH_ROCM_GFX_ARCH env override: $script:ROCmGfxArch" "Cyan"
}
# 2. Best-effort name → arch lookup (amd-smi / WMI). Most-specific first,
# first match wins. Covers only arches the ROCm prebuilts support
# (gfx120X/110X/1151/1150/103X); unknown names fall back cleanly to CPU.
elseif ($ROCmGpuLabel) {
$nameArchTable = @(
@{ P = "9070|9080|R9700"; A = "gfx1201" } # RDNA 4 (Navi 48: Radeon RX 9070 XT / 9070 GRE / 9070 / 9080, Radeon AI PRO R9700)
@{ P = "9060"; A = "gfx1200" } # RDNA 4 (Navi 44: Radeon RX 9060 XT / 9060)
@{ P = "8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
@{ P = "890M|880M|Strix Point|HX 37[05]|AI 9 HX|AI 9 36[05]"; A = "gfx1150" } # RDNA 3.5 (Strix Point: Radeon 890M/880M, Ryzen AI 9 HX 370/375)
@{ P = "860M|840M|Krackan|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1152" } # RDNA 3.5 (Krackan Point: Radeon 860M/840M, Ryzen AI 7 350 / AI 5 340)
@{ P = "RX 7900|PRO W7900|PRO W7800"; A = "gfx1100" } # RDNA 3 desktop / workstation (Navi 31)
@{ P = "RX 7800|RX 7700(?!S)|PRO W7700|PRO V710"; A = "gfx1101" } # RDNA 3 (Navi 32)
@{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500"; A = "gfx1102" } # RDNA 3 (Navi 33)
@{ P = "780M|760M|740M|Phoenix|Hawk Point|Z1 Extreme|Z2 Extreme"; A = "gfx1103" } # RDNA 3 iGPU (Phoenix / Hawk Point)
@{ P = "RX 6900|RX 6800|RX 6750|RX 6700|PRO W6800|PRO W6900"; A = "gfx1030" } # RDNA 2 (Navi 21) -- gfx103X family
@{ P = "RX 6650|RX 6600|PRO W6600|PRO W6650"; A = "gfx1032" } # RDNA 2 (Navi 23) -- gfx103X family
@{ P = "RX 6500|RX 6400|RX 6300|PRO W6400|PRO W6500"; A = "gfx1034" } # RDNA 2 (Navi 24) -- gfx103X family
)
function Get-GfxArchFromGpuName {
param([AllowNull()][string]$Name, [object[]]$Table)
if (-not $Name) { return $null }
foreach ($row in $Table) { if ($Name -match $row.P) { return $row.A } }
return $null
}
# Only the WMI path carries more than one name; other callers left a single
# synthesized label, so default to it.
# @() wraps the WHOLE if, not each branch: a one-element branch unrolls on its way
# out, leaving a bare String on a single-adapter host, and $gpuNames[$nameIdx] then
# indexes the NAME and yields "A". Nothing maps, and the $nameArches[0] rescue below
# is skipped under a visible-device mask, so a pinned single-GPU host inferred no
# arch at all -- the same "gpu none" the WMI scan above used to report.
$gpuNames = @(if ($script:ROCmGpuLabels) { @($script:ROCmGpuLabels) } else { @($ROCmGpuLabel) })
# Index over the ADAPTER list, not the inferred arches: an unrecognised name
# drops out below, and indexing the shortened list would name the wrong card.
$nameIdx = Resolve-VisibleGpuIndex $gpuNames.Count
$nameArches = @()
foreach ($gpuName in $gpuNames) {
$inferred = Get-GfxArchFromGpuName -Name $gpuName -Table $nameArchTable
if ($inferred) { $nameArches += $inferred }
}
$pickedName = Get-GfxArchFromGpuName -Name $gpuNames[$nameIdx] -Table $nameArchTable
# Borrow another adapter's arch only when unpinned: an unmappable leading
# adapter is exactly the #7776 iGPU and the named discrete card should decide,
# but under a mask substituting installs wheels for a GPU they masked away.
if (-not $pickedName -and -not (Test-VisibleDevicesPinned) -and $nameArches.Count -gt 0) {
$pickedName = $nameArches[0]
}
if ($pickedName) {
# Repick only when every adapter mapped: an unknown name may BE the
# discrete card, so skipping the iGPU could pick the wrong one.
$script:ROCmGfxArch = if ($nameArches.Count -eq $gpuNames.Count) {
Resolve-ShadowingGfxPick -Picked $pickedName -AllArches $nameArches
} else { $pickedName }
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
substep "gfx arch inferred from GPU name: $script:ROCmGfxArch" "Cyan"
substep "Tip: set UNSLOTH_ROCM_GFX_ARCH=$script:ROCmGfxArch to skip inference next time" "Cyan"
} else {
# Nothing mapped: the card may be a generation ROCm never covered rather
# than one we failed to recognise (unslothai#8529). $script:ROCmGfxArch
# stays null either way, so only the wording of the report below moves.
# Stay quiet when a PEER is covered: "no override can help" is false beside a
# card that has wheels. Read-only; never reaches $gpuNames or the inference.
# HIP/ROCR only, unlike Test-VisibleDevicesPinned: CUDA_VISIBLE_DEVICES says
# nothing about which Radeon was chosen, and counting it would fire the verdict
# beside a covered card.
$unsupMasked = @($env:HIP_VISIBLE_DEVICES, $env:ROCR_VISIBLE_DEVICES) |
Where-Object { $null -ne $_ }
$unsupPeerCovered = $false
if ($unsupMasked) {
# Under a mask a peer cannot answer for the named card, as the
# arch-borrowing rule above. But $gpuNames is one synthesized label on the
# amd-smi path, and $nameIdx cannot index a card it never saw, so a mask
# onto a supported peer would be blamed on adapter 0. Unseen peer, no
# verdict: Win32_VideoController does not promise HIP's order to guess with.
$unsupPeerCovered = ($script:ROCmPeerLabels.Count -gt $gpuNames.Count)
} else {
foreach ($peerName in $script:ROCmPeerLabels) {
if (Get-GfxArchFromGpuName -Name $peerName -Table $nameArchTable) {
$unsupPeerCovered = $true
break
}
}
}
if (-not $unsupPeerCovered) {
$script:ROCmUnsupportedGfxArch = Get-GfxArchFromGpuName -Name $gpuNames[$nameIdx] -Table $unsupportedNameArchTable
}
}
}
}
# 3. Last resort: the arch install.ps1 resolved a second ago. Last because everything above
# is mask- and shadowing-aware and the installer's scan is not, so this fills a gap rather
# than deposing a better answer. Without it, a scan that answers there but not here expects
# cpu torch against the ROCm wheels just placed, calls the venv stale, and loops forever.
# Private, never UNSLOTH_ROCM_GFX_ARCH: nested installers read that as an operator
# override, and this value is inferred, not chosen by anyone.
# Skipped under a visible-device mask, matching the inference above, which leaves
# $pickedName unset rather than borrowing a peer's arch when pinned. The installer's scan
# ignores the masks, so its answer is the FIRST adapter, not the selected one, and taking
# it would install for a GPU the mask hides from the runtime entirely. A host that wants
# an arch named under a mask sets UNSLOTH_ROCM_GFX_ARCH, which still wins above.
if (-not $script:ROCmGfxArch -and $env:_UNSLOTH_ROCM_GFX_ARCH_HANDOFF -and
-not (Test-VisibleDevicesPinned)) {
$script:ROCmGfxArch = $env:_UNSLOTH_ROCM_GFX_ARCH_HANDOFF.Trim().ToLower()
$ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)"
substep "gfx arch forwarded by the installer: $script:ROCmGfxArch" "Cyan"
}
# Capture ROCm version early for display and wheel selection.
# Run whenever the HIP SDK binary is present, not just when the device is accessible --
# hipconfig --version works even when hipinfo reports no ROCm device (driver issue).
if ($HasROCm -or $HipSdkInstalled) {
$script:ROCmVersion = $null
$hipConfigExe = Get-Command hipconfig -ErrorAction SilentlyContinue
if (-not $hipConfigExe) {
$hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null }
if ($hipRoot) {
$hipConfigCandidate = Join-Path $hipRoot "bin\hipconfig.exe"
if (Test-Path $hipConfigCandidate) {
$hipConfigEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" }
substep "[WARN] hipconfig not on PATH -- located via ${hipConfigEnvLabel}: $hipConfigCandidate" "Yellow"
$hipConfigExe = [PSCustomObject]@{ Source = $hipConfigCandidate }
}
}
}
if ($hipConfigExe) {
try {
$hipVerOut = & $hipConfigExe.Source --version 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$hipVerLine = ($hipVerOut -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1).Trim()
if ($hipVerLine -match '(\d+\.\d+)') {
$script:ROCmVersion = $Matches[1]
$script:ROCmVersionFull = $hipVerLine
}
}
} catch {}
}
if (-not $script:ROCmVersion -and $amdSmiAllowed) {
$amdSmiVer = Get-Command "amd-smi" -ErrorAction SilentlyContinue
if ($amdSmiVer) {
try {
$smiVerOut = Invoke-AmdSmiNoElevate $amdSmiVer.Source @('version')
if ($LASTEXITCODE -eq 0 -and $smiVerOut -match 'ROCm version:\s*(\d+\.\d+)') { $script:ROCmVersion = $Matches[1] }
} catch {}
}
}
}
}
# gfx arches AMD publishes Windows ROCm wheels for (repo.amd.com/rocm/whl/<family>). Hoisted
# above the Intel scan, which needs it: an unlisted arch gets CPU torch, so it must not outrank a
# usable Arc card. test_rocm_arch_table_parity.py keeps it in sync with $archFamilyMap below.
$_rocmWheelArches = @(
"gfx1201", "gfx1200", # RDNA 4
"gfx1151", "gfx1150", "gfx1152", # RDNA 3.5 (Strix Halo/Point, Krackan Point)
"gfx1103", "gfx1102", "gfx1101", "gfx1100", # RDNA 3
"gfx1036", "gfx1035", "gfx1034", "gfx1033", "gfx1032", "gfx1031", "gfx1030", # RDNA 2 (RX 6000)
"gfx90a", "gfx908" # MI200 / MI100
)
# "AMD gets GPU wheels here", NOT "an AMD GPU is present": $HasROCm / $ROCmGfxArch are true on
# unmapped arches (Vega, RDNA1) too, and those install CPU torch.
$AmdHasGpuWheels = [bool]($script:ROCmGfxArch -and ($_rocmWheelArches -contains $script:ROCmGfxArch))
# Mirrors the Intel scan in install.ps1 so setup does not report "none (chat-only)" right after
# install.ps1 reported a usable Arc GPU. Self-contained, because `studio update` runs setup.ps1
# standalone. Same $AmdHasGpuWheels gate (an Arc card wins over an AMD host heading for CPU torch
# anyway) and the same Arc / Data Center match.
$script:IsIntelXpu = $false
# Set when the stale check below keeps an installed +xpu venv instead of wiping it. Separate from
# $script:IsIntelXpu on purpose: it steers the INSTALL, not the hardware report, which must stay
# honest about the NVIDIA GPU that is also in the machine.
$script:PreservedXpuVenv = $false
# Flavour tag ("rocm" / "cu128" / "xpu") of a GPU wheel the stale check below kept. Declared here,
# like the flag above it, because the index selection reads it on every run while a fresh install
# never reaches the assignment -- so under a caller's Set-StrictMode the read would be fatal.
$script:PreservedInstallerTorchTag = $null
$IntelGpuLabel = $null
if (-not $HasNvidiaSmi -and -not $AmdHasGpuWheels) {
try {
# Bounded, registry as the fallback when WMI does not answer: an unbounded query hangs
# `studio update`, a swallowed one silently reports no GPU.
$_gpuScan = Invoke-BoundedVideoControllerScan
# @() wraps the WHOLE if, not each branch: a one-element array unrolls on its way out,
# making $_gpuNames a String on a single-adapter host and turning the += below into
# string concatenation.
$_gpuNames = @(if ($_gpuScan.Ok) { $_gpuScan.Names } else { Get-IntelRegistryAdapterNames })
# One definition for both the reconciliation gate and the classification below.
$_xpuNameRe = "(?i)Intel.*(Arc|Data Center GPU)"
# On non-English Windows the WMI name carries no ASCII "Intel" for the classification
# below. The registry helper resolves the PCI vendor id, so use it to RE-LABEL an adapter
# WMI already reported, never to add one (an unmatched entry is a driver record outliving
# its card). Gated on the absence of an XPU match, not of any Intel name: a hybrid laptop
# reports "Intel UHD" next to a localized Arc.
if ($_gpuScan.Ok -and -not ($_gpuNames | Where-Object { $_ -match $_xpuNameRe })) {
foreach ($_reg in @(Get-IntelRegistryAdapterNames)) {
foreach ($_wmiName in $_gpuNames) {
if ($_wmiName -and $_reg.Contains($_wmiName)) { $_gpuNames += $_reg; break }
}
}
}
$xpuGpu = $_gpuNames | Where-Object { $_ -match $_xpuNameRe } | Select-Object -First 1
if ($xpuGpu) { $script:IsIntelXpu = $true; $IntelGpuLabel = $xpuGpu }
} catch {}
# Neither WMI nor the registry recognised an adapter, so ask the environment: an installed
# +xpu wheel whose runtime initialises proves the host better than a marketing name. The same
# check runs ~1300 lines below, but AFTER the report just below, so without this an Arc user
# on a wedged WMI is told no training GPU exists and then watches setup keep the XPU
# environment. Own try, so a throwing scan or a junk UNSLOTH_STUDIO_HOME cannot abort setup.
if (-not $script:IsIntelXpu) {
try {
$_probeVenv = Get-ProbableStudioVenvDir
if (Test-VenvTorchIsXpu $_probeVenv) {
$_probePy = Join-Path $_probeVenv "Scripts\python.exe"
if (Test-TorchXpuAvailable -PythonExe $_probePy) {
$script:IsIntelXpu = $true
$IntelGpuLabel = "Intel XPU runtime (reported by PyTorch)"
}
}
} catch {}
}
}
if ($HasNvidiaSmi) {
step "gpu" "NVIDIA GPU detected"
} elseif ($script:IsIntelXpu) {
# Ranks above every AMD branch: only true when AMD gets no GPU wheel ($AmdHasGpuWheels gates
# the scan above), so those branches would all end on CPU torch.
Write-StudioLine ""
step "gpu" "Intel GPU detected" "Green"
substep "$IntelGpuLabel"
substep "PyTorch XPU (SYCL) wheels provide training and GPU inference on this GPU." "Cyan"
Write-StudioLine ""
} elseif ($HasROCm -and -not $script:ROCmUnsupportedGfxArch) {
# Guarded like the HIP SDK arm below: amd-smi can report a GPU with no gfx token
# and only a market name, which sets $HasROCm without an arch.
step "gpu" $ROCmGpuLabel
$hipSdkPath = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { "on system PATH" }
substep "HIP SDK: $hipSdkPath"
if ($script:ROCmVersionFull) { substep "hipconfig: $script:ROCmVersionFull" }
} elseif ($HipSdkInstalled -and $ROCmGpuLabel -and -not $script:ROCmUnsupportedGfxArch) {
# HIP SDK installed but ROCm can't see the device (driver issue, not SDK issue).
# Excludes cards already known to be out of scope: the #8529 reporters installed the
# HIP SDK BECAUSE this arm said to, so unguarded it hides the arm below from them.
$sdkVer = if ($script:ROCmVersionFull) { " (HIP $script:ROCmVersionFull)" } else { "" }
Write-StudioLine ""
step "gpu" "AMD GPU detected -- not ROCm-accessible$sdkVer" "Yellow"
substep "Detected: $ROCmGpuLabel" "Yellow"
substep "[WARN] HIP SDK is installed but hipinfo reports no ROCm-capable device." "Yellow"
substep " This is a driver issue, not an SDK issue." "Yellow"
substep " Ensure the ROCm compute driver is installed alongside the display driver:" "Yellow"
substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
} elseif ($script:ROCmGfxArch) {
# Known arch: PyTorch comes from AMD's bundled-runtime ROCm wheels (repo.amd.com),
# which ship their own runtime -- HIP SDK optional (only adds the system toolchain).
Write-StudioLine ""
step "gpu" "AMD ROCm ($script:ROCmGfxArch)" "Cyan"
substep "Detected: $ROCmGpuLabel" "Cyan"
substep "GPU PyTorch uses AMD's bundled-runtime ROCm wheels -- HIP SDK not required (optional)." "Cyan"
Write-StudioLine ""
} elseif ($script:ROCmUnsupportedGfxArch) {
# Detected, identified, out of scope for ROCm PyTorch. Ranks above the "arch
# unknown" arm below: the arch is known here, and that arm's advice cannot succeed
# on this GPU (unslothai#8529).
Write-StudioLine ""
step "gpu" "AMD GPU detected ($script:ROCmUnsupportedGfxArch) -- no ROCm PyTorch wheels Unsloth installs" "Yellow"
substep "Detected: $ROCmGpuLabel" "Yellow"
# Not "training runs on CPU": with no CUDA/XPU visible, unsloth raises
# NotImplementedError at import (unsloth/device_type.py). The Vulkan setter is
# single-quoted so PowerShell prints $env:... rather than expanding it; a pasted
# VAR=value resolves as a command name here and sets nothing.
# Both claims are conditional: an explicit index pin is honoured for any arch, and
# this same script THROWS on the Vulkan variable on Windows ARM64, where no bundle
# is published, so the usual advice would abort the next update instead of helping.
$unsupPinned = (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) -or `
(-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY))
if ($unsupPinned) {
substep "Unsloth ships no ROCm PyTorch wheels for $script:ROCmUnsupportedGfxArch, but the torch" "Yellow"
substep "index you pinned is used as given, so torch is whatever it publishes." "Yellow"
} else {
substep "Unsloth installs no ROCm PyTorch wheels for $script:ROCmUnsupportedGfxArch, so torch stays" "Yellow"
substep "CPU-only: Unsloth training and GPU inference are unavailable. Installing the" "Yellow"
substep "HIP SDK or setting UNSLOTH_ROCM_GFX_ARCH will not change that for it." "Yellow"
}
if ((Get-HostMachineArch) -eq "arm64") {
substep "GGUF chat would need Vulkan on this GPU, and no Windows ARM64 Vulkan bundle is published: build llama.cpp from source, or run this on x64." "Yellow"
} else {
substep "GGUF chat can still use this GPU through Vulkan: set" "Yellow"
substep '$env:UNSLOTH_LLAMA_CPP_BACKEND = "vulkan" and re-run the installer. It selects' "Yellow"
substep "the llama.cpp bundle at install time, so setting it afterwards has no effect" "Yellow"
substep "until you install or update again." "Yellow"
}
Write-StudioLine ""
} elseif ($ROCmGpuLabel) {
Write-StudioLine ""
step "gpu" "AMD GPU detected -- arch unknown" "Yellow"
substep "Detected: $ROCmGpuLabel" "Yellow"
substep "Could not determine the GPU arch (gfx...). Install the HIP SDK or set" "Yellow"
substep "UNSLOTH_ROCM_GFX_ARCH to enable GPU ROCm PyTorch:" "Yellow"
substep "https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
Write-StudioLine ""
} else {
Write-StudioLine ""
step "gpu" "none (chat-only / GGUF)" "Yellow"
substep "Training and GPU inference require an NVIDIA, AMD ROCm, or Intel Arc GPU." "Yellow"
Write-StudioLine ""
}
# ============================================
# 1a.5. Windows Long Paths (required for deep node_modules / Python paths)
# ============================================
$LongPathsEnabled = $false
try {
$regVal = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction SilentlyContinue
if ($regVal -and $regVal.LongPathsEnabled -eq 1) {
$LongPathsEnabled = $true
}
} catch {}
if ($LongPathsEnabled) {
step "long paths" "enabled"
} else {
Write-StudioLine "Windows Long Paths not enabled (required for Triton compilation and deep dependency paths)." -ForegroundColor Yellow
Write-StudioLine " Requesting admin access to fix..." -ForegroundColor Yellow
try {
# Spawn an elevated process to set the registry key (triggers UAC prompt)
$proc = Start-Process -FilePath "reg.exe" `
-ArgumentList 'add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f' `
-Verb RunAs -Wait -PassThru -ErrorAction Stop
if ($proc.ExitCode -eq 0) {
$LongPathsEnabled = $true
step "long paths" "enabled (via UAC)"
} else {
step "long paths" "failed to enable (exit code: $($proc.ExitCode))" "Yellow"
}
} catch {
step "long paths" "could not enable (UAC declined/unavailable)" "Yellow"
Write-StudioLine " Run this manually in an Admin terminal:" -ForegroundColor Yellow
Write-StudioLine ' reg add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f' -ForegroundColor Cyan
}
}
# ============================================
# 1b. Git (only required for --local / source installs)
# ============================================
# Was fatal as "required by pip and npm", but the consumer path uses neither: the
# unsloth-zoo git+https URL is STUDIO_LOCAL_INSTALL only, node is a pinned prebuilt, and the
# frontend lockfile has no VCS deps. Being fatal blocked clean no-winget Windows boxes.
$HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
if (-not $HasGit) {
# Fatal only where git is used: --local and the opt-in llama.cpp source build. A local
# llama.cpp dir overrides those opt-ins, but only once it holds a reusable binary:
# pointing at the canonical install location with nothing built there falls through to
# the normal install, so an explicit source build still needs git. The automatic
# fallback after a failed prebuilt download is not knowable here; Phase 4 handles it.
$gitNeeded = ($env:STUDIO_LOCAL_INSTALL -eq '1')
$_localLlamaDir = if ($env:UNSLOTH_LOCAL_LLAMA_CPP_DIR) { $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR.Trim() } else { "" }
$_localLlamaBuilt = $false
if ($_localLlamaDir) {
# Same layout candidates as the reuse check in Phase 4.
foreach ($_c in @("llama-server.exe", "build\bin\llama-server.exe", "build\bin\Release\llama-server.exe")) {
# Denied here terminated the run under "Stop" long before Phase 4's
# guarded probes, so this scan needs the same three-state handling.
$_cState = Get-PathState -Path (Join-Path $_localLlamaDir $_c)
if ($_cState -eq "Denied") {
Exit-PathAccessDenied -Path $_localLlamaDir -Label "the UNSLOTH_LOCAL_LLAMA_CPP_DIR build" -UserSupplied
}
if ($_cState -eq "Present") { $_localLlamaBuilt = $true; break }
}
}
if (-not $_localLlamaBuilt) {
$_prForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
$_llamaSrc = $DefaultLlamaSource -replace '\.git$', ''
# Same tag resolution as Phase 4. "master" is a branch, never a release, so the
# prebuilt lookup always misses and Phase 4 rebuilds it from source.
$_llamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag }
if ($_llamaTag -eq "master") { $gitNeeded = $true }
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq '1') { $gitNeeded = $true }
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_LLAMA_PR)) { $gitNeeded = $true }
# Same positive-integer predicate as the PR_FORCE promotion below: 0 or non-numeric
# never forces a source build, so it must not demand git.
if ($_prForce -match '^\d+$' -and [int]$_prForce -gt 0) { $gitNeeded = $true }
if ($_llamaSrc -ne "https://github.com/ggml-org/llama.cpp") { $gitNeeded = $true }
}
Write-StudioLine "Git not found -- attempting install via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
try {
Invoke-SetupCommand { winget install Git.Git --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
$HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
} catch { }
}
if (-not $HasGit) {
if ($gitNeeded) {
Write-StudioLine "[ERROR] Git is required for --local and llama.cpp source-build installs but could not be installed." -ForegroundColor Red
Write-StudioLine " --local clones unsloth-zoo, and a source build clones llama.cpp." -ForegroundColor Red
Write-StudioLine " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red
Exit-SetupFailure "Git is required for --local / source-build installs but could not be installed"
}
step "git" "not found (not required)" "Yellow"
substep "Unsloth installs prebuilt binaries and wheels, so git is not needed."
substep "Install it only for --local/source installs: https://git-scm.com/download/win"
} else {
step "git" "$(git --version)"
}
} else {
step "git" "$(git --version)"
}
# ============================================
# 1b.5. Visual C++ Redistributable (runtime for the prebuilt llama.cpp + PyTorch)
# ============================================
# Runtime dep, not a build tool: the prebuilt llama-server and PyTorch load it.
Ensure-VCRedist
# ============================================
# 1c. CMake (only needed for a llama.cpp SOURCE build -- detection only)
# ============================================
# Detection only: the prebuilt path needs no compiler, so do not install or exit
# here. Ensure-BuildToolsForLlamaSourceBuild installs CMake if a source build runs.
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
if ($HasCmake) {
step "cmake" "$(cmake --version | Select-Object -First 1)"
} else {
step "cmake" "not detected (only needed if a llama.cpp source build is required)" "Yellow"
}
# ============================================
# 1d. Visual Studio Build Tools (only needed for a llama.cpp SOURCE build -- detection only)
# ============================================
# Detection only: detect VS for a possible source build, but never install or exit
# here. Install is deferred to Ensure-BuildToolsForLlamaSourceBuild.
$CmakeGenerator = $null
$VsInstallPath = $null
$vsResult = Find-VsBuildTools
if ($vsResult) {
$CmakeGenerator = $vsResult.Generator
$VsInstallPath = $vsResult.InstallPath
step "vs" "$CmakeGenerator ($($vsResult.Source)) (only used if a source build is needed)"
if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" }
} else {
step "vs" "not detected (only needed if a llama.cpp source build is required)" "Yellow"
}
# ============================================
# 1e. CUDA Toolkit (nvcc for llama.cpp build + env vars)
# ============================================
# Defined here but invoked lazily right before a Phase 4 source build; the
# prebuilt llama.cpp path needs no local toolkit. With -RequireOrExit a source
# build is committed, so hard-fail if no driver-compatible toolkit can be found
# or installed. Without it, detection is best-effort and only sets the flag.
function Resolve-CudaToolkit {
param([switch]$RequireOrExit)
# Toolkit major must be <= the driver's max CUDA major (nvidia-smi "CUDA Version: X.Y");
# a newer-major toolkit fails at runtime ("ggml_cuda_init: failed to initialize CUDA").
$DriverMaxCuda = $null
try {
# Bounded: source-build toolkit resolution must not hang on a wedged smi.
# test_resolve_cuda_toolkit.ps1 extracts this function alone into a child
# pwsh (no Invoke-NvidiaSmiBounded in scope) and stubs nvidia-smi with a
# .ps1 script, so fall back to direct invocation when the bounded runner
# is unavailable; production setup.ps1 always has it defined.
$smiOut = if (Get-Command Invoke-NvidiaSmiBounded -ErrorAction SilentlyContinue) {
Invoke-NvidiaSmiBounded $NvidiaSmiExe
} else {
& $NvidiaSmiExe 2>&1 | Out-String
}
# Newer drivers report "CUDA UMD Version: X.Y" instead of "CUDA Version: X.Y"; accept both.
if ($smiOut -match "CUDA(?: UMD)? Version:\s+([\d]+)\.([\d]+)") {
$DriverMaxCuda = "$($Matches[1]).$($Matches[2])"
substep "driver supports up to CUDA $DriverMaxCuda"
}
} catch {}
# Detect compute capability early so we can validate toolkit support
$CudaArch = Get-CudaComputeCapability
if ($CudaArch) {
substep "GPU Compute Capability = $($CudaArch.Insert($CudaArch.Length-1, '.')) (sm_$CudaArch)"
}
# -- Find a toolkit that's compatible with the driver AND the GPU --
# Strategy: prefer the toolkit at CUDA_PATH (user's existing setup) if it's
# compatible with the driver AND supports the GPU architecture. Only fall back
# to scanning side-by-side installs if CUDA_PATH is missing, points to an
# incompatible version, or can't compile for the GPU. This avoids
# header/binary mismatches when multiple toolkits are installed.
$IncompatibleToolkit = $null
$NvccPath = $null
if ($DriverMaxCuda) {
$drMajorCuda = [int]$DriverMaxCuda.Split('.')[0]
# --- Step 1: Check existing CUDA_PATH first ---
$existingCudaPath = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Machine')
if (-not $existingCudaPath) {
$existingCudaPath = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'User')
}
if ($existingCudaPath -and (Test-Path (Join-Path $existingCudaPath 'bin\nvcc.exe'))) {
$candidateNvcc = Join-Path $existingCudaPath 'bin\nvcc.exe'
$verOut = & $candidateNvcc --version 2>&1 | Out-String
if ($verOut -match 'release\s+(\d+)\.(\d+)') {
$tkMaj = [int]$Matches[1]; $tkMin = [int]$Matches[2]
$isCompat = ($tkMaj -le $drMajorCuda)
if ($isCompat) {
# Also verify the toolkit supports our GPU architecture
$archOk = $true
if ($CudaArch) {
$archOk = Test-NvccArchSupport -NvccExe $candidateNvcc -Arch $CudaArch
if (-not $archOk) {
substep "CUDA_PATH toolkit (CUDA $tkMaj.$tkMin) does not support GPU arch sm_$CudaArch" "Yellow"
substep "Looking for a newer toolkit..." "Yellow"
}
}
if ($archOk) {
$NvccPath = $candidateNvcc
substep "using existing CUDA Toolkit at CUDA_PATH (nvcc: $NvccPath)"
}
} else {
substep "CUDA_PATH ($existingCudaPath) has CUDA $tkMaj.$tkMin with major $tkMaj, which exceeds driver CUDA major $drMajorCuda ($DriverMaxCuda)" "Yellow"
}
}
}
# --- Step 2: Fall back to scanning side-by-side installs ---
if (-not $NvccPath) {
$NvccPath = Find-Nvcc -MaxVersion $DriverMaxCuda
if ($NvccPath) {
substep "found compatible CUDA Toolkit (nvcc: $NvccPath)"
if ($existingCudaPath) {
$selectedRoot = Split-Path (Split-Path $NvccPath -Parent) -Parent
if ($existingCudaPath.TrimEnd('\') -ne $selectedRoot.TrimEnd('\')) {
substep "overriding CUDA_PATH from $existingCudaPath to $selectedRoot" "Yellow"
}
}
} else {
# No side-by-side match: a major-compatible toolkit may still be on
# PATH/CUDA_PATH/a custom dir; use it, else record it as too-new.
$AnyNvcc = Find-Nvcc
if ($AnyNvcc) {
$NvccOut = & $AnyNvcc --version 2>&1 | Out-String
if ($NvccOut -match "release\s+(\d+)\.(\d+)") {
$tkMaj = [int]$Matches[1]; $tkMin = [int]$Matches[2]
if ($tkMaj -le $drMajorCuda) {
$NvccPath = $AnyNvcc
substep "found compatible CUDA Toolkit (nvcc: $NvccPath)"
} else {
$IncompatibleToolkit = "$tkMaj.$tkMin"
}
}
}
}
}
} else {
$NvccPath = Find-Nvcc
}
# A newer-major toolkit blocked by the driver: explain the mismatch.
if (-not $NvccPath -and $IncompatibleToolkit) {
Write-CudaDriverToolkitMismatch -ToolkitVersion $IncompatibleToolkit -DriverMaxCuda $DriverMaxCuda
if (-not $RequireOrExit) {
$script:CudaToolkitReady = $false
return
}
# Reached only by a source build (forced, or after a prebuilt-install failure);
# with no compatible toolkit it must fail (setup.sh degrades to CPU instead).
Write-StudioLine "" -ForegroundColor Red
Write-StudioLine "========================================================================" -ForegroundColor Red
Write-StudioLine "[ERROR] CUDA source build cannot use the installed toolkit with this driver." -ForegroundColor Red
Write-StudioLine "========================================================================" -ForegroundColor Red
Exit-SetupFailure "The installed CUDA toolkit is incompatible with the current driver"
}
# -- No toolkit at all: install via winget (only when a source build needs it) --
if (-not $NvccPath -and $RequireOrExit) {
Write-StudioLine "CUDA toolkit (nvcc) not found -- installing via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
if ($DriverMaxCuda) {
# Query winget for available CUDA Toolkit versions
$drMajor = [int]$DriverMaxCuda.Split('.')[0]
$AvailableVersions = @()
try {
$rawOutput = winget show Nvidia.CUDA --versions --source winget --accept-source-agreements 2>&1 | Out-String
# Parse version lines (e.g. "12.6", "12.5", "11.8")
foreach ($line in $rawOutput -split "`n") {
$line = $line.Trim()
if ($line -match '^\d+\.\d+') {
$AvailableVersions += $line
}
}
} catch {}
# Filter to compatible major versions and pick the highest
$BestVersion = $null
foreach ($ver in $AvailableVersions) {
$parts = $ver.Split('.')
$vMajor = [int]$parts[0]
if ($vMajor -le $drMajor) {
$BestVersion = $ver
break # list is descending, first match is highest compatible
}
}
if ($BestVersion) {
substep "Installing CUDA Toolkit $BestVersion via winget..."
$prevEAPCuda = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Invoke-SetupCommand { winget install --id=Nvidia.CUDA --version=$BestVersion -e --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
$ErrorActionPreference = $prevEAPCuda
Refresh-Environment
$NvccPath = Find-Nvcc -MaxVersion $DriverMaxCuda
if ($NvccPath) {
substep "CUDA Toolkit $BestVersion installed (nvcc: $NvccPath)"
}
} else {
substep "no compatible CUDA Toolkit version found in winget (need CUDA major <= $drMajor)" "Yellow"
}
} else {
substep "Installing CUDA Toolkit (latest) via winget..."
winget install --id=Nvidia.CUDA -e --source winget --accept-package-agreements --accept-source-agreements
Refresh-Environment
$NvccPath = Find-Nvcc
if ($NvccPath) {
substep "CUDA Toolkit installed (nvcc: $NvccPath)"
}
}
}
}
if (-not $NvccPath) {
if (-not $RequireOrExit) {
substep "no driver-compatible CUDA Toolkit found -- skipping; prebuilt llama.cpp needs no local toolkit" "Yellow"
$script:CudaToolkitReady = $false
return
}
Write-StudioLine "[ERROR] CUDA Toolkit (nvcc) is required but could not be found or installed." -ForegroundColor Red
if ($DriverMaxCuda) {
Write-StudioLine " Install a CUDA Toolkit with major version $($DriverMaxCuda.Split('.')[0]) from https://developer.nvidia.com/cuda-toolkit-archive" -ForegroundColor Yellow
} else {
Write-StudioLine " Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads" -ForegroundColor Yellow
}
Exit-SetupFailure "A compatible CUDA Toolkit could not be found or installed"
}
# -- Set CUDA env vars so cmake AND MSBuild can find the toolkit --
$CudaToolkitRoot = Split-Path (Split-Path $NvccPath -Parent) -Parent
# CUDA_PATH: used by cmake's find_package(CUDAToolkit)
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'Process')
# CudaToolkitDir: the MSBuild property that CUDA .targets checks directly
# Trailing backslash required -- the .targets file appends subpaths to it
[Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process')
# Always persist CUDA_PATH to User registry so the compatible toolkit is used
# in future sessions (overwrites any existing value pointing to a newer, incompatible version)
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'User')
substep "Persisted CUDA_PATH=$CudaToolkitRoot to user environment"
# Clear all versioned CUDA_PATH_V* env vars in this process to prevent
# cmake/MSBuild from discovering a conflicting CUDA installation.
$cudaPathVars = @([Environment]::GetEnvironmentVariables('Process').Keys | Where-Object { $_ -match '^CUDA_PATH_V' })
foreach ($v in $cudaPathVars) {
[Environment]::SetEnvironmentVariable($v, $null, 'Process')
}
# Set only the versioned var matching the selected toolkit (e.g. CUDA_PATH_V13_0)
$tkDirName = Split-Path $CudaToolkitRoot -Leaf
if ($tkDirName -match '^v(\d+)\.(\d+)') {
$cudaPathVerVar = "CUDA_PATH_V$($Matches[1])_$($Matches[2])"
[Environment]::SetEnvironmentVariable($cudaPathVerVar, $CudaToolkitRoot, 'Process')
substep "Set $cudaPathVerVar (cleared other CUDA_PATH_V* vars)"
}
# Ensure nvcc's bin dir is on PATH for this process
$nvccBinDir = Split-Path $NvccPath -Parent
if ($env:PATH -notlike "*$nvccBinDir*") {
[Environment]::SetEnvironmentVariable('PATH', "$nvccBinDir;$env:PATH", 'Process')
}
# Persist nvcc bin dir (Prepend so the driver-compatible toolkit wins).
if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') {
substep "Persisted CUDA bin dir to user PATH"
}
# -- Ensure CUDA ↔ Visual Studio integration files exist --
# When CUDA is installed before VS Build Tools (or VS is reinstalled after CUDA),
# the MSBuild .targets/.props files that let VS compile .cu files are missing.
# cmake fails with "No CUDA toolset found". Fix: copy from CUDA extras dir.
if ($VsInstallPath -and $CudaToolkitRoot) {
$vsCustomizations = Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator
$cudaExtras = Join-Path $CudaToolkitRoot "extras\visual_studio_integration\MSBuildExtensions"
if ((Test-Path $cudaExtras) -and (Test-Path $vsCustomizations)) {
$hasTargets = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue
if (-not $hasTargets) {
substep "CUDA VS integration missing -- copying .targets files..." "Yellow"
try {
Copy-Item "$cudaExtras\*" $vsCustomizations -Force -ErrorAction Stop
substep "CUDA VS integration files installed"
} catch {
# Direct copy failed (needs admin). Try elevated copy via Start-Process.
try {
$copyCmd = "Copy-Item '$cudaExtras\*' '$vsCustomizations' -Force"
Start-Process powershell -ArgumentList "-NoProfile -Command $copyCmd" -Verb RunAs -Wait -ErrorAction Stop
$hasTargetsRetry = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue
if ($hasTargetsRetry) {
substep "CUDA VS integration files installed (elevated)"
} else {
throw "Copy did not produce .targets files"
}
} catch {
substep "could not copy CUDA VS integration files" "Yellow"
substep "The llama.cpp build may fail with 'No CUDA toolset found'." "Yellow"
substep "Manual fix: copy contents of" "Yellow"
substep "$cudaExtras"
substep "into:" "Yellow"
substep "$vsCustomizations"
}
}
}
}
}
step "cuda" $NvccPath
substep "CUDA_PATH = $CudaToolkitRoot"
substep "CudaToolkitDir = $CudaToolkitRoot\"
# $CudaArch was detected earlier (before toolkit selection) so it could
# influence which toolkit we picked. Just log the final state here.
if (-not $CudaArch) {
substep "could not detect compute capability -- cmake will use defaults" "Yellow"
}
# Publish the resolved toolkit to script scope for the Phase 4 build.
$script:NvccPath = $NvccPath
$script:CudaToolkitRoot = $CudaToolkitRoot
$script:CudaArch = $CudaArch
$script:CudaToolkitReady = $true
}
if ($HasROCm -and -not $script:ROCmUnsupportedGfxArch) {
$rocmVerLabel = if ($script:ROCmVersionFull) { "ROCm $script:ROCmVersionFull" } elseif ($script:ROCmVersion) { "ROCm $script:ROCmVersion" } else { "ROCm (version unknown)" }
step "rocm" $rocmVerLabel
} elseif ($script:ROCmGfxArch) {
# GPU training/inference works via AMD's bundled-runtime ROCm PyTorch wheels;
# the HIP SDK is optional (only the system ROCm toolchain).
step "rocm" "GPU via bundled ROCm wheels ($script:ROCmGfxArch) -- HIP SDK optional" "Cyan"
} elseif ($script:ROCmUnsupportedGfxArch) {
# Naming the HIP SDK here would read as "install it and this resolves"; on a
# generation with no ROCm PyTorch wheels it never does (unslothai#8529).
step "rocm" "AMD GPU detected ($script:ROCmUnsupportedGfxArch) -- no ROCm PyTorch wheels Unsloth installs" "Yellow"
} elseif ($ROCmGpuLabel) {
step "rocm" "AMD GPU detected -- arch unknown; HIP SDK not found" "Yellow"
}
# ============================================
# 1f. Node.js / npm (skip if pip-installed or Tauri -- only needed for frontend build)
# ============================================
# Frontend and OXC share this Node floor. The helper returns:
# system | bundled | skip.
function Get-NodeDecision {
param(
[string]$NodeVersion, # `node -v` output, e.g. v22.17.1 (or empty)
[string]$NpmVersion, # `npm -v` output, e.g. 10.9.2 (or empty)
[string]$SkipInstall # "1" => never auto-install
)
$node = ($NodeVersion -replace '^v', '').Trim()
$npm = "$NpmVersion".Trim()
if ($node -match '^\d+\.\d+' -and $npm -match '^\d+') {
$nodeMajor = [int]($node.Split('.')[0])
$nodeMinor = [int]($node.Split('.')[1])
$npmMajor = [int]($npm.Split('.')[0])
$nodeOk = ($nodeMajor -eq 20 -and $nodeMinor -ge 19) -or
($nodeMajor -eq 22 -and $nodeMinor -ge 12) -or
($nodeMajor -ge 23)
if ($nodeOk -and $npmMajor -ge 11) { return "system" }
}
if ($SkipInstall -eq "1") { return "skip" }
return "bundled"
}
function Test-PackagedFrontend {
param([string]$LocalInstall, [string]$IndexPath, [string]$ProjectFilePath)
# install.ps1 and `unsloth studio update` explicitly pass 0 for PyPI
# installs. Wheel extraction mtimes do not preserve build ordering, so use
# the release-built dist whenever its entry point is present.
#
# $ProjectFilePath is the pyproject.toml beside studio/. The mode records
# where the Python package came from, not which tree this script runs out
# of, and an editable overlay separates the two: it leaves the mode at 0
# while $ScriptDir is a checkout, whose dist is a stale build artifact
# rather than a release one. A wheel ships no top-level files, so that file
# existing means source tree -- keep the mtime rebuild there.
if ($LocalInstall -ne "0") { return $false }
if ($ProjectFilePath -and (Test-Path -LiteralPath $ProjectFilePath -PathType Leaf)) { return $false }
return (Test-Path -LiteralPath $IndexPath -PathType Leaf)
}
$SkipFrontend = ($env:SKIP_STUDIO_FRONTEND -eq "1")
$NodeOverride = $null
$NodeParent = $null
$NodeDir = $null
$SysNodeVersion = ""
$SysNpmVersion = ""
$NodeSource = $null
if (-not $IsPipInstall) {
# Put Node beside the Unsloth root. OXC can still need npm when the
# frontend build is skipped.
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $NodeOverride = $env:UNSLOTH_STUDIO_HOME.Trim() }
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $NodeOverride = $env:STUDIO_HOME.Trim() }
if ($NodeOverride) {
if ($NodeOverride -eq "~") {
$NodeOverride = $env:USERPROFILE
} elseif ($NodeOverride -like "~/*" -or $NodeOverride -like "~\*") {
$NodeOverride = (Join-Path $env:USERPROFILE $NodeOverride.Substring(1).TrimStart('/', '\'))
}
if (-not (Test-Path -LiteralPath $NodeOverride -PathType Container)) {
Write-StudioLine "ERROR: UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist." -ForegroundColor Red
Write-StudioLine " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
Exit-SetupFailure "UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist"
}
$NodeParent = (Resolve-Path -LiteralPath $NodeOverride).Path
# An override pointing at the legacy default maps to the legacy sibling
# ~/.unsloth/node (what the runtime resolver and setup.sh use), not <root>/node.
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
}
if ($NodeParent -eq $_legacyStudio) {
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
$NodeOverride = $null
}
} else {
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
}
$NodeDir = Join-Path $NodeParent "node"
# Probe system node/npm without letting a missing/broken command abort setup.
# Under $ErrorActionPreference = "Stop" a bare `node -v` for an absent node
# throws a terminating error `2>$null` cannot swallow, and a present-but-broken
# shim throws too. Guard with Get-Command (node/npm independently) + try/catch;
# empty version => Get-NodeDecision returns "bundled".
$SysNodeVersion = try { if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>$null) } else { "" } } catch { "" }
$SysNpmVersion = try { if (Get-Command npm -ErrorAction SilentlyContinue) { (npm -v 2>$null) } else { "" } } catch { "" }
$NodeSource = Get-NodeDecision -NodeVersion "$SysNodeVersion" -NpmVersion "$SysNpmVersion" -SkipInstall "$($env:UNSLOTH_SKIP_NODE_INSTALL)"
}
if ($IsPipInstall) {
step "frontend" "bundled (pip install)"
} elseif ($SkipFrontend) {
step "frontend" "bundled (Tauri)"
} else {
# Stale npm used to trigger system Node changes. Keep this process-local
# and provision only when the build or OXC needs Node.
if ($NodeSource -eq "system") {
substep "Node $SysNodeVersion and npm $SysNpmVersion already meet requirements (system)."
} elseif ($NodeSource -eq "bundled") {
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable; will use an isolated Node (system left untouched)."
} else {
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable and UNSLOTH_SKIP_NODE_INSTALL set; frontend build will be skipped." "Yellow"
}
}
# Conda CPython ships modified DLL search paths that break torch's c10.dll
# loading on Windows; a venv made from conda Python inherits its base_prefix,
# so check the executable path AND sys.base_prefix.
$CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
function Test-IsConda {
param([string]$Exe)
if ($Exe -match $CondaSkipPattern) { return $true }
try {
$basePrefix = (& $Exe -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
if ($basePrefix -match $CondaSkipPattern) { return $true }
} catch { }
return $false
}
# 1g. Python (>= 3.11 and < 3.14). Prefer the interpreter install.ps1 already
# resolved and built the venv with (UNSLOTH_SETUP_PYTHON), or the existing
# venv python, before re-probing a system where a 3.14 or a WindowsApps stub
# ahead on PATH would trip the gate. setup.ps1 only updates packages in that
# venv, so the handoff is safe to reuse once validated.
function Resolve-ReusedSetupPython {
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_SETUP_PYTHON) -and
(Test-Path -LiteralPath $env:UNSLOTH_SETUP_PYTHON)) {
return $env:UNSLOTH_SETUP_PYTHON
}
# Standalone `unsloth studio setup/update` (install.ps1 did not run): derive
# the venv python from the studio root, mirroring the resolver below.
$root = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() }
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() }
else { Join-Path $env:USERPROFILE ".unsloth\studio" }
if ($root -eq "~") {
# Join-Path with an empty child throws on Windows PowerShell 5.1.
$root = $env:USERPROFILE
} elseif ($root -like "~/*" -or $root -like "~\*") {
$root = Join-Path $env:USERPROFILE $root.Substring(1).TrimStart('/', '\')
}
$venvPy = Join-Path $root "unsloth_studio\Scripts\python.exe"
if (Test-Path -LiteralPath $venvPy) { return $venvPy }
return $null
}
$ReusedSetupPython = Resolve-ReusedSetupPython
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
$PythonOk = $false
$DetectedPyVer = $null
function Get-CompatiblePythonVersion {
param([string]$PythonExe)
try {
$out = & $PythonExe --version 2>&1 | Out-String
if ($out -match 'Python (3\.(11|12|13)(\.\d+)?)') {
return $Matches[1]
}
} catch { }
return $null
}
function Add-PythonDirToProcessPath {
param([string]$PythonExe)
try {
if ($PythonExe -and (Test-Path -LiteralPath $PythonExe)) {
$resolvedDir = Split-Path -Parent $PythonExe
$alreadyOnPath = ($env:PATH -split ';' | Where-Object { $_.TrimEnd('\') -ieq $resolvedDir.TrimEnd('\') }).Count -gt 0
if (-not $alreadyOnPath) {
$env:PATH = "$resolvedDir;$env:PATH"
}
$script:HasPython = $true
}
} catch { }
}
# Reuse the install.ps1 / venv interpreter before any system probe.
$ValidatedSetupPython = $null
if ($ReusedSetupPython) {
$_reusedVer = Get-CompatiblePythonVersion $ReusedSetupPython
if ($_reusedVer -and -not (Test-IsConda $ReusedSetupPython)) {
$DetectedPyVer = $_reusedVer
Add-PythonDirToProcessPath $ReusedSetupPython
$PythonOk = $true
$ValidatedSetupPython = $ReusedSetupPython
}
}
# Fall back to every py.exe on PATH (all-users and per-user launchers can both
# register). -All is required: Windows PowerShell 5.1 returns only the first
# launcher without it, and the PowerShell 7 multi-match array breaks the call
# operator if used directly.
$PyLaunchers = if ($PythonOk) { @() } else { @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue) }
foreach ($PyLauncher in $PyLaunchers) {
if ($PyLauncher.Source -match $CondaSkipPattern) { continue }
foreach ($minor in @("3.13", "3.12", "3.11")) {
try {
$out = & $PyLauncher.Source "-$minor" --version 2>&1 | Out-String
if ($out -match 'Python (3\.\d+\.\d+)') {
$DetectedPyVer = $Matches[1]
# Make `python` resolvable for the rest of setup. Without this,
# py-launcher-only installs (no python.exe on PATH) pass the gate
# and then crash on the first bare `python` call below.
try {
$resolvedExe = (& $PyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Select-Object -First 1)
if ($resolvedExe -and (Test-Path $resolvedExe)) {
Add-PythonDirToProcessPath $resolvedExe
}
} catch { }
$PythonOk = $true
break
}
} catch { }
}
if ($PythonOk) { break }
}
if (-not $PythonOk -and $HasPython) {
$PyVer = python --version 2>&1
if ($PyVer -match "(\d+)\.(\d+)") {
$PyMajor = [int]$Matches[1]; $PyMinor = [int]$Matches[2]
if ($PyMajor -eq 3 -and $PyMinor -ge 11 -and $PyMinor -lt 14) {
$DetectedPyVer = "$PyMajor.$PyMinor"
$PythonOk = $true
}
}
}
if ($PythonOk) {
substep "Python $DetectedPyVer"
} elseif (-not $HasPython) {
# No `python` on PATH (and py.exe either absent or only had unsupported
# minors). Try winget as before -- gating on $HasPython alone, not also
# on $PyLauncher, so a launcher-only install with just 3.14 still gets
# an automatic 3.12 install instead of a hard error.
Write-StudioLine "Python 3.11-3.13 not found -- installing Python 3.12 via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
winget install -e --id Python.Python.3.12 --source winget --accept-package-agreements --accept-source-agreements
Refresh-Environment
}
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
if (-not $HasPython) {
Write-StudioLine "[ERROR] Python could not be installed automatically." -ForegroundColor Red
Write-StudioLine " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
Exit-SetupFailure "Python could not be installed automatically"
}
step "python" "$(python --version 2>&1)"
$PythonOk = $true
} else {
# python.exe is on PATH but its version is unsupported, and py.exe (if
# present) had no supported minor either.
Write-StudioLine "[ERROR] No supported Python (3.11-3.13) found on this system." -ForegroundColor Red
Write-StudioLine " py.exe could not locate -3.11/-3.12/-3.13 and `python` on PATH is unsupported." -ForegroundColor Yellow
Write-StudioLine " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
Exit-SetupFailure "No supported Python 3.11-3.13 was found"
}
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).
$ScriptsDir = python -c "import os, sysconfig; p = sysconfig.get_path('scripts', 'nt_user'); print(p if os.path.exists(p) else '')"
if ($LASTEXITCODE -eq 0 -and $ScriptsDir -and (Test-Path $ScriptsDir)) {
# Append (not Prepend) -- this dir has other pip scripts; shim handles unsloth.
if (Add-ToUserPath -Directory $ScriptsDir) {
# Also add to current process so it's available immediately
$ProcessPathEntries = $env:PATH.Split(';')
if (-not ($ProcessPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
$env:PATH = "$ScriptsDir;$env:PATH"
}
substep "Persisted Python Scripts dir to user PATH: $ScriptsDir"
}
}
Write-StudioLine ""
step "system" "prerequisites ready"
Write-StudioLine ""
# ==========================================================================
# PHASE 2: Frontend build (skip if pip-installed -- already bundled)
# ==========================================================================
$DistDir = Join-Path $FrontendDir "dist"
$PackagedFrontend = Test-PackagedFrontend `
-LocalInstall "$($env:STUDIO_LOCAL_INSTALL)" `
-IndexPath (Join-Path $DistDir "index.html") `
-ProjectFilePath (Join-Path $PackageDir "pyproject.toml")
# Wheel extraction mtimes are not a source-freshness signal. Standard PyPI
# installs use the release-built dist; local/source installs retain mtime checks.
# Tauri is checked first so the reported reason matches setup.sh on a desktop
# update, where both this and the packaged branch would otherwise apply.
$NeedFrontendBuild = $true
if ($SkipFrontend) {
$NeedFrontendBuild = $false
step "frontend" "bundled (Tauri)"
} elseif ($IsPipInstall -or $PackagedFrontend) {
$NeedFrontendBuild = $false
step "frontend" "bundled (pip install)"
} elseif (Test-Path $DistDir) {
$DistTime = (Get-Item $DistDir).LastWriteTime
$NewerFile = $null
# Check src/ and public/ recursively (probe paths directly, not via -Include)
foreach ($subDir in @("src", "public")) {
$subPath = Join-Path $FrontendDir $subDir
if (Test-Path $subPath) {
$NewerFile = Get-ChildItem -Path $subPath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt $DistTime } | Select-Object -First 1
if ($NewerFile) { break }
}
}
# Also check all top-level files (package.json, vite.config.ts, index.html, etc.)
if (-not $NewerFile) {
$NewerFile = Get-ChildItem -Path $FrontendDir -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne "bun.lock" -and $_.LastWriteTime -gt $DistTime } |
Select-Object -First 1
}
if (-not $NewerFile) {
$NeedFrontendBuild = $false
step "frontend" "up to date"
} else {
substep "Frontend source changed since last build -- rebuilding..." "Yellow"
}
}
# Provision Node when the frontend build OR the OXC runtime install needs it (the
# OXC `npm install` runs whenever its dir exists, regardless of dist staleness);
# never eagerly. System Node is used read-only; the isolated one is ours.
$NeedNodeForSetup = (-not $IsPipInstall) -and ($NeedFrontendBuild -or (Test-Path $OxcValidatorDir))
if ($NeedNodeForSetup) {
if ($NodeSource -eq "skip") {
if ($NeedFrontendBuild) {
step "frontend" "skipped (no suitable Node; system left untouched)" "Yellow"
}
$NeedFrontendBuild = $false
substep "found Node='$SysNodeVersion' npm='$SysNpmVersion'; Unsloth needs Node >=20.19/22.12/23 and npm >= 11" "Yellow"
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node" "Yellow"
} elseif ($NodeSource -eq "bundled") {
New-Item -ItemType Directory -Force -Path $NodeParent -ErrorAction SilentlyContinue | Out-Null
# Minimal ownership guard for a custom-home dir (the full Unsloth-owned
# helpers are defined later); never os.replace over a user-owned dir.
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
$nodeOwnedMarker = Join-Path $NodeDir ".unsloth-studio-owned"
$nodeMeta = Join-Path $NodeDir "UNSLOTH_NODE_PREBUILT_INFO.json"
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
Write-StudioLine "[ERROR] $NodeDir already exists and is not an Unsloth-owned Node install." -ForegroundColor Red
Write-StudioLine " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
Exit-SetupFailure "$NodeDir is not an Unsloth-owned Node install"
}
}
substep "installing isolated Node (system Node/npm left untouched)..."
# The main Python resolver runs later; bare `python` may be a Store stub or
# absent this early, so prefer the validated handed-off/venv Python.
$NodeInstallPython = if ($ValidatedSetupPython) { $ValidatedSetupPython } else { "python" }
$nodeOut = & $NodeInstallPython "$PSScriptRoot\install_node_prebuilt.py" --install-dir $NodeDir 2>&1 | Out-String
$nodeExit = $LASTEXITCODE
if ($nodeExit -eq 3) {
Write-StudioLine $nodeOut -ForegroundColor DarkGray
step "node" "install blocked by another active Unsloth install" "Red"
Exit-SetupFailure "Node install is blocked by another active Unsloth install" 3
} elseif ($nodeExit -ne 0) {
Write-StudioLine $nodeOut -ForegroundColor DarkGray
Write-StudioLine "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red
Write-StudioLine " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
Exit-SetupFailure "Could not install an isolated Node runtime"
}
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
}
# Windows Node zip ships node.exe + npm.cmd at the root; prepend it (this
# process only) so node/npm/bun resolve here for the build.
$env:PATH = "$NodeDir;" + $env:PATH
# Keep npm and module resolution inside the isolated Node.
$env:NPM_CONFIG_PREFIX = $NodeDir
$env:npm_config_prefix = $NodeDir
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
step "node" "$(node -v) | npm $(npm -v) (isolated)"
# bun (optional, faster installs); npm -g stays in the isolated prefix.
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
substep "installing bun (faster frontend package installs)..."
$prevEAP_bun = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun @NpmRegistryArgs } | Out-Null
$ErrorActionPreference = $prevEAP_bun
Refresh-Environment
# Refresh-Environment rebuilds PATH (Machine;User;current), demoting the
# isolated-Node prepend; re-prepend so it wins for the build and OXC step.
$env:PATH = "$NodeDir;" + $env:PATH
$env:NPM_CONFIG_PREFIX = $NodeDir
$env:npm_config_prefix = $NodeDir
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
if (Get-Command bun -ErrorAction SilentlyContinue) {
substep "bun installed ($(bun --version))"
} else {
substep "bun install skipped (npm will be used instead)"
}
}
} else {
# system Node already satisfies requirements; use it as-is. We do NOT
# install global packages (bun) here -- the build falls back to npm.
step "node" "$SysNodeVersion | npm $SysNpmVersion (system)"
}
}
if ($NeedFrontendBuild -and -not $IsPipInstall) {
Write-StudioLine ""
substep "building frontend..."
# ── Tailwind v4 .gitignore workaround ──
# Tailwind v4's oxide scanner respects .gitignore in parent directories.
# Python venvs create a .gitignore with "*" (ignore everything), which
# prevents Tailwind from scanning .tsx source files for class names.
# Temporarily hide any such .gitignore during the build, then restore it.
$HiddenGitignores = @()
$WalkDir = (Get-Item $FrontendDir).Parent.FullName
while ($WalkDir -and $WalkDir -ne [System.IO.Path]::GetPathRoot($WalkDir)) {
$gi = Join-Path $WalkDir ".gitignore"
if (Test-Path $gi) {
$content = Get-Content $gi -Raw -ErrorAction SilentlyContinue
if ($content -and ($content.Trim() -match '^\*$')) {
$hidden = "$gi._twbuild"
Rename-Item -Path $gi -NewName (Split-Path $hidden -Leaf) -Force
$HiddenGitignores += $gi
substep "Temporarily hiding $gi (venv .gitignore blocks Tailwind scanner)"
}
}
$WalkDir = Split-Path $WalkDir -Parent
}
# Use bun if available (faster install), fall back to npm.
# Bun is used only as package manager; Node runs the actual build (Vite 8).
$prevEAP_npm = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Push-Location $FrontendDir
$UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue)
# bun's package cache can become corrupt -- packages get stored with only
# metadata but no actual content (bin/, lib/). When this happens bun install
# exits 0 but leaves binaries missing. We validate after install and clear
# the cache + retry once before falling back to npm.
if ($UseBun) {
Write-StudioLine " Using bun for package install (faster)" -ForegroundColor DarkGray
$bunExit = Invoke-SetupCommand { bun install @NpmRegistryArgs }
# On Windows, .bin/ entries vary by package manager:
# npm → tsc, tsc.cmd, tsc.ps1
# bun → tsc.exe, tsc.bunx
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
if ($bunExit -eq 0 -and $hasTsc -and $hasVite) {
# bun install succeeded and critical binaries are present
} elseif ($bunExit -eq 0) {
Write-StudioLine " bun install exited 0 but critical binaries are missing, clearing cache and retrying..." -ForegroundColor Yellow
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
Invoke-SetupCommand { bun pm cache rm } | Out-Null
$bunExit = Invoke-SetupCommand { bun install @NpmRegistryArgs }
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
if ($bunExit -ne 0 -or -not $hasTsc -or -not $hasVite) {
Write-StudioLine " bun retry failed, falling back to npm" -ForegroundColor Yellow
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
$UseBun = $false
}
} else {
substep "bun install failed (exit $bunExit), falling back to npm" "Yellow"
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
$UseBun = $false
}
}
if (-not $UseBun) {
$npmExit = Invoke-SetupCommand { npm install @NpmRegistryArgs }
if ($npmExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_npm
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
Write-StudioLine "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red
Write-StudioLine " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow
Show-NpmRegistryHint
Exit-SetupFailure "Frontend dependency installation failed (exit code $npmExit)"
}
}
# Always use npm to run the build (Node runtime — avoids bun Windows runtime issues)
$buildExit = Invoke-SetupCommand { npm run build }
if ($buildExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_npm
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
Write-StudioLine "[ERROR] npm run build failed (exit code $buildExit)" -ForegroundColor Red
Exit-SetupFailure "Frontend build failed (exit code $buildExit)"
}
Pop-Location
$ErrorActionPreference = $prevEAP_npm
# ── Restore hidden .gitignore files ──
foreach ($gi in $HiddenGitignores) {
Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue
}
# ── Validate CSS output ──
$CssFiles = Get-ChildItem (Join-Path $DistDir "assets") -Filter "*.css" -ErrorAction SilentlyContinue
$MaxCssSize = ($CssFiles | Measure-Object -Property Length -Maximum).Maximum
if ($MaxCssSize -lt 100000) {
step "frontend" "built (warning: CSS may be truncated)" "Yellow"
} else {
step "frontend" "built"
}
}
if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command npm -ErrorAction SilentlyContinue)) {
substep "installing OXC validator runtime..."
$prevEAP_oxc = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Push-Location $OxcValidatorDir
$oxcInstallExit = Invoke-SetupCommand { npm install @NpmRegistryArgs }
if ($oxcInstallExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
Write-StudioLine "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red
Show-NpmRegistryHint
Exit-SetupFailure "OXC validator dependency installation failed (exit code $oxcInstallExit)"
}
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
step "oxc runtime" "installed"
} elseif ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip") {
# No npm on PATH (e.g. a pip install with no system Node and no isolated Node
# provisioned). Skip rather than abort; the runtime resolver degrades. Mirrors setup.sh.
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "Yellow"
}
Remove-AgentInstructionFiles -Roots @(
(Join-Path $FrontendDir "node_modules"),
(Join-Path $OxcValidatorDir "node_modules")
)
# ==========================================================================
# PHASE 3: Python environment + dependencies
# ==========================================================================
Write-StudioLine ""
substep "setting up Python environment..."
# Find Python -- skip Anaconda/Miniconda distributions ($CondaSkipPattern and
# Test-IsConda are defined above the 1g gate). Standalone CPython (python.org,
# winget, uv) does not have conda's torch c10.dll loading issue.
$PythonCmd = $null
# 0. Reuse the interpreter install.ps1 already resolved and built the venv with
# (UNSLOTH_SETUP_PYTHON, or the existing venv python) before probing the
# system -- it is already validated as supported and non-conda.
if ($ReusedSetupPython) {
try {
$out = & $ReusedSetupPython --version 2>&1 | Out-String
if ($out -match 'Python 3\.(\d+)') {
$pyMinor = [int]$Matches[1]
if ($pyMinor -ge 11 -and $pyMinor -le 13 -and -not (Test-IsConda $ReusedSetupPython)) {
$PythonCmd = $ReusedSetupPython
}
}
} catch { }
}
# 1. Try the Python Launcher (py.exe) first -- most reliable on Windows.
# Enumerate every launcher with -All (Windows PowerShell 5.1 returns only
# the first match without it) and search each for a supported, non-conda
# interpreter.
$PyLaunchersResolve = if ($PythonCmd) { @() } else { @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue) }
foreach ($pyLauncher in $PyLaunchersResolve) {
if ($pyLauncher.Source -match $CondaSkipPattern) { continue }
foreach ($minor in @("3.13", "3.12", "3.11")) {
try {
$out = & $pyLauncher.Source "-$minor" --version 2>&1 | Out-String
if ($out -match 'Python 3\.(\d+)') {
$pyMinor = [int]$Matches[1]
if ($pyMinor -ge 11 -and $pyMinor -le 13) {
# Resolve the actual executable path so venv creation
# does not re-resolve back to a conda interpreter.
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsConda $resolvedExe)) {
$PythonCmd = $resolvedExe
break
}
}
}
} catch { }
}
if ($PythonCmd) { break }
}
# 2. Fall back to scanning python3.x / python3 / python on PATH.
# Use Get-Command -All to look past conda entries.
if (-not $PythonCmd) {
foreach ($candidate in @("python3.13", "python3.12", "python3.11", "python3", "python")) {
foreach ($cmdInfo in @(Get-Command $candidate -All -ErrorAction SilentlyContinue)) {
try {
if (-not $cmdInfo.Source) { continue }
if ($cmdInfo.Source -like "*\WindowsApps\*") { continue }
if (Test-IsConda $cmdInfo.Source) {
substep "skipping $($cmdInfo.Source) (conda Python breaks torch DLL loading)" "Yellow"
continue
}
$ver = & $cmdInfo.Source --version 2>&1
if ($ver -match 'Python 3\.(\d+)') {
$minor = [int]$Matches[1]
if ($minor -ge 11 -and $minor -le 13) {
$PythonCmd = $cmdInfo.Source
break
}
}
} catch { }
}
if ($PythonCmd) { break }
}
}
if (-not $PythonCmd) {
Write-StudioLine "[ERROR] No standalone Python 3.11-3.13 found (conda Python is not supported)." -ForegroundColor Red
Write-StudioLine " Install Python from https://python.org/downloads/ or via:" -ForegroundColor Yellow
Write-StudioLine " winget install -e --id Python.Python.3.12" -ForegroundColor Yellow
Exit-SetupFailure "No standalone Python 3.11-3.13 was found"
}
substep "Python found: $PythonCmd"
# $StudioHome / $VenvDir are resolved and preflighted before phase 1, so only the
# cache clear stays here. Venv-gated: a writable-but-empty override still fails the
# venv check below, and clearing first would cost the cache for a run that then does
# nothing. Still before any install work.
if (Test-Path -LiteralPath (Join-Path $VenvDir "Scripts\python.exe") -PathType Leaf) {
Clear-WebViewCaches
}
# why: in env-override mode $StudioHome is user-chosen; require the
# ownership marker before Remove-Item so unrelated dirs survive. Gated on
# the canonical comparison so an override pointing at the legacy default
# still behaves like a default install.
# Directory-local evidence that Unsloth created $Path, used to adopt a custom-home
# llama.cpp or whisper.cpp predating the .unsloth-studio-owned marker (see
# setup.sh). Only Unsloth prebuilt markers count; source builds are
# indistinguishable from a user clone on Windows and stay under the strict guard.
# "Yes" / "No" / "Denied". A denied marker is not evidence of absence: reading it
# as "No" makes the guard below call an Unsloth tree an unrelated directory and
# tell the user to move it aside, when the real problem is permissions.
function Get-StudioAdoptableState {
param([Parameter(Mandatory = $true)][string]$Path)
$denied = $false
foreach ($marker in @("UNSLOTH_PREBUILT_INFO.json", "UNSLOTH_WHISPER_PREBUILT_INFO.json")) {
switch (Get-PathState -Path (Join-Path $Path $marker) -PathType Leaf) {
"Present" { return "Yes" }
"Denied" { $denied = $true }
}
}
if ($denied) { return "Denied" }
# Windows reports a MISSING child of an unreadable directory as absent, so the
# probes above cannot tell "no markers here" from "cannot look"; listing the
# directory itself can. Without this a denied tree reads as unowned.
try { $null = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | Select-Object -First 1) }
catch {
if (Test-AccessDeniedError $_) { return "Denied" }
# Anything else was not adoptable before either; this must not throw.
}
return "No"
}
# Boolean view for callers that only gate a cosmetic cleanup on adoption.
function Test-StudioOwnedAdoptable {
param([Parameter(Mandatory = $true)][string]$Path)
return ((Get-StudioAdoptableState -Path $Path) -eq "Yes")
}
function Assert-StudioOwnedOrAbsent {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Label,
# whisper.cpp is non-fatal by contract, so it needs the denial handed back
# rather than exited on. Only this mode returns a value.
[switch]$NonFatal
)
# Denied is not Absent: a root we cannot read cannot be proven ours, and
# returning here would let the caller replace it. Both stops stay gated on
# $StudioHomeIsCustom, as before; a default-home denial is reported by the
# phase that owns the path.
$pathState = Get-PathState -Path $Path -PathType Container
if ($pathState -ne "Present") {
if ($StudioHomeIsCustom -and $pathState -eq "Denied") {
if ($NonFatal) { return "Denied" }
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
}
return
}
$markerState = Get-PathState -Path (Join-Path $Path $StudioOwnedMarker) -PathType Leaf
if ($StudioHomeIsCustom -and $markerState -eq "Denied") {
if ($NonFatal) { return "Denied" }
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
}
if ($StudioHomeIsCustom -and $markerState -ne "Present") {
$adoptState = Get-StudioAdoptableState -Path $Path
if ($adoptState -eq "Denied") {
if ($NonFatal) { return "Denied" }
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
}
if ($adoptState -eq "Yes") {
Mark-StudioOwned $Path
return
}
Write-StudioLine "[ERROR] $Path already exists and is not marked as an Unsloth-owned $Label." -ForegroundColor Red
Write-StudioLine " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
Exit-SetupFailure "$Label path is not an Unsloth-owned install: $Path"
}
}
function Mark-StudioOwned {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
try {
[System.IO.File]::WriteAllText((Join-Path $Path $StudioOwnedMarker), "")
} 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 '^\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)$'
# The torch family install.ps1 settled on, in the vocabulary the probe below produces (cu<digits>
# / rocm / xpu / cpu). $null means it did not say: an installer predating the variable (setup.ps1
# ships in the pip package, install.ps1 is fetched from unsloth.ai, so the two can be different
# ages), --no-torch, or a custom index whose leaf names no flavor. Absent is unknown rather than a
# mismatch, which keeps an older cached installer on its pre-variable behaviour. IsNullOrWhiteSpace
# rather than a presence test: 7.5+ keeps an empty assignment as a present blank value, 5.1 and
# 7.0-7.4 remove the variable.
$InstallerTorchTag = if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_INSTALLER_TORCH_TAG)) { $null }
else { $env:UNSLOTH_INSTALLER_TORCH_TAG.Trim().ToLowerInvariant() }
# Only the stale-venv block below assigns this, but the XPU install reads it to decide whether to
# force-reinstall and a fresh install never enters that block. Declaring it keeps a caller's
# Set-StrictMode from making the read fatal.
$installedTorchTag = $null
# Hoisted for the same reason: four install arms read it to decide --force-reinstall and the
# installer-managed repair below raises it, yet only the block a fresh install never enters
# assigns it.
$script:PinChangedForceReinstall = $false
if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) {
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
$installedTorchTag = $null
# Declared before the branch that assigns it: the failure message below reads its .Error, and
# a venv with no python.exe never runs the probe at all.
$_verProbe = $null
$shouldRebuild = $false
# Set when a stale venv under a pin is repaired in place (force-reinstall) not wiped.
$script:PinChangedForceReinstall = $false
if (Test-Path -LiteralPath $VenvPyExe) {
# Bounded like every other torch probe: reading stdout to EOF before waiting would hang
# setup forever on a wedged `import torch`, and an undrained stderr deadlocks on a noisy
# import. An unreadable flavor -> rebuild.
$_verProbe = Invoke-BoundedPythonProbe -PythonExe $VenvPyExe -Code 'import torch; print(torch.__version__)'
$torchVer = $_verProbe.Output.Trim()
if ($_verProbe.Ok -and $torchVer) {
if ($torchVer -match '\+(cu\d+)') {
$installedTorchTag = $Matches[1]
} elseif ($torchVer -match '\+rocm') {
# Any +rocm / gfx wheel -> generic "rocm" flavor (the exact version is
# repaired later by install_python_stack.py; here we only need the flavor).
$installedTorchTag = "rocm"
} elseif ($torchVer -match '\+xpu') {
# Without this arm a +xpu wheel reads as "cpu" and an Arc venv looks correct
# while being CPU-only. Matches ConvertTo-TorchFlavorTag.
$installedTorchTag = "xpu"
} elseif ($torchVer -match '\+cpu') {
$installedTorchTag = "cpu"
} else {
# Untagged wheel (plain "2.x.y" from PyPI) -> cpu.
$installedTorchTag = "cpu"
}
} elseif (Test-VenvTorchIsXpu -VenvPath $VenvDir) {
# Bounding this probe made a timeout mean "rebuild", and the host most likely to
# time out inside `import torch` is an Arc box with a stalled driver -- where
# version.py still names a good +xpu wheel, and the stale path would DELETE $VenvDir.
# Trust the disk and warn about the driver; other families keep rebuilding.
$installedTorchTag = "xpu"
substep "PyTorch did not respond in time but this venv holds an XPU build -- keeping it." "Yellow"
substep "If training fails, update the Intel GPU compute driver." "Yellow"
} elseif (Test-VenvTorchIsRocm -VenvPath $VenvDir) {
# Same rescue on the AMD side, and the one that has cost users whole installs: a
# faulted Adrenalin or HIP runtime makes `import torch` raise at the DLL load or never
# return, so a fine ROCm environment read as "torch could not be imported" and was
# thrown away, which does not fix a driver. version.py on disk still names the wheel,
# so trust it and point at the driver instead (#8335, #7275).
$installedTorchTag = "rocm"
substep "PyTorch did not respond but this venv holds a ROCm build -- keeping it." "Yellow"
substep "If training fails, reboot and update the AMD Adrenalin / HIP SDK driver." "Yellow"
} else {
$shouldRebuild = $true
}
} else {
# Missing python.exe means the venv is incomplete -- rebuild it.
$shouldRebuild = $true
}
if (-not $shouldRebuild) {
# The scan above only matches a WMI marketing name, so a throwing Get-CimInstance -- or
# an Intel part outside the Arc|Data Center regex -- leaves $script:IsIntelXpu false, and
# the chain below would expect "cpu", see "xpu" and WIPE a working XPU venv.
# torch.xpu.is_available() on a +xpu wheel proves the host, so re-evaluate before the
# stale decision. This repeats the scan's own gate, so NVIDIA / AMD keep winning.
if ($installedTorchTag -eq "xpu" -and -not $script:IsIntelXpu -and
-not $HasNvidiaSmi -and -not $AmdHasGpuWheels -and
(Test-TorchXpuAvailable -PythonExe $VenvPyExe)) {
$script:IsIntelXpu = $true
substep "Intel XPU runtime confirmed by PyTorch -- keeping this XPU environment." "Cyan"
}
$_pinnedIdx = Get-PinnedTorchIndexUrl
$_expectedKnown = $true
if ($_pinnedIdx) {
$_pinLeaf = Get-TorchIndexLeaf $_pinnedIdx
# Digit-gated like the install selection: a custom rocm-* leaf (rocm-current /
# rocm-rel-7.2.1) is NOT a ROCm family and must not be stale-compared.
if (Test-PipRocmFamilyLeaf $_pinLeaf) {
# Don't collapse a pinned ROCm/gfx leaf to a generic "rocm" (would mask a family
# change, rocm6.4 -> gfx1151). Get-RocmPinStaleTags uses the SAME 2.11 allowlist
# as the install path, so a gfx110X-all/gfx90a/gfx908 pin on a <2.11 wheel is NOT stale.
$_rocmTags = Get-RocmPinStaleTags -PinLeaf $_pinLeaf -TorchVersion $torchVer
$expectedTorchTag = $_rocmTags.Expected
$installedTorchTag = $_rocmTags.Installed
} elseif ((Test-CudaFamilyLeaf $_pinLeaf) -or $_pinLeaf -eq 'cpu' -or $_pinLeaf -eq 'xpu') {
# cu*/cpu/xpu leaves stay specific so a cu126-vs-cu128 (or cpu-vs-xpu) mismatch
# is caught; /custom and /current fall through to the unknown-index branch below.
$expectedTorchTag = $_pinLeaf
} else {
# Custom index whose leaf is not a torch flavor (a /simple mirror): the
# flavor can't be inferred, so never treat the venv as stale over it.
$_expectedKnown = $false
$expectedTorchTag = $installedTorchTag
}
} elseif ($HasNvidiaSmi) {
$expectedTorchTag = Get-PytorchCudaTag
} elseif ($script:IsIntelXpu) {
# Arc / Data Center host: the install below selects the xpu index. BEFORE the AMD
# arm -- both can be true on an unmapped-arch AMD box, where xpu is what gets
# installed. A CPU wheel is not wiped (the xpu install force-reinstalls over it in
# place), so expect "cpu" there; a cu*/rocm wheel still rebuilds.
$expectedTorchTag = if ($installedTorchTag -eq "cpu") { "cpu" } else { "xpu" }
} elseif ($HasROCm -or $script:ROCmGfxArch) {
# AMD/ROCm host with no explicit pin: an existing +rocm wheel is correct (gfx arch
# counts even when $HasROCm is false). But only the arches the install path maps to a
# repo.amd.com index get ROCm torch; an unmapped arch installs CPU, so expect "cpu"
# for those or a correct CPU venv rebuilds every update.
# $_rocmWheelArches is defined above the Intel scan (that gate needs it too).
if ($AmdHasGpuWheels) {
# A correct +rocm wheel is not stale. A CPU wheel on a supported AMD arch is
# NOT wiped either (the AMD Windows ROCm override below upgrades it in place);
# expect "cpu" for that case. A wrong CUDA wheel still rebuilds.
if ($installedTorchTag -eq "cpu") {
$expectedTorchTag = "cpu"
} else {
$expectedTorchTag = "rocm"
}
} else {
$expectedTorchTag = "cpu"
}
} else {
$expectedTorchTag = "cpu"
}
if ($_expectedKnown -and $installedTorchTag -and $installedTorchTag -ne $expectedTorchTag) {
$shouldRebuild = $true
}
}
# A stale venv under a pin whose torch still imports is repaired IN PLACE (the dependency
# pass force-reinstalls from the pin). The rebuild path wipes the venv and would strand a
# direct `studio update`; only a broken venv or an unpinned drift wipes.
if ($shouldRebuild -and $_pinnedIdx -and $installedTorchTag) {
substep "Torch-index pin changed ($installedTorchTag) -- reinstalling torch from the pin in place." "Cyan"
$script:PinChangedForceReinstall = $true
$shouldRebuild = $false
}
# Same for an unpinned cu* -> cu* move: the cap can change the expected family on a
# healthy venv, and only install.ps1 creates venvs, so wiping here strands a direct
# `studio update`. CPU/ROCm/XPU drift still rebuilds.
if ($shouldRebuild -and -not $_pinnedIdx -and $installedTorchTag -and
(Test-CudaFamilyLeaf $installedTorchTag) -and (Test-CudaFamilyLeaf $expectedTorchTag)) {
substep "CUDA family $installedTorchTag does not cover this host -- reinstalling $expectedTorchTag in place." "Cyan"
$script:PinChangedForceReinstall = $true
$shouldRebuild = $false
}
# A +xpu venv is never wiped by a DIRECT update: on a hybrid NVIDIA+Arc box the promotion
# above is gated on -not $HasNvidiaSmi, so a later pinless update expects a cu* tag, calls the
# working Arc venv stale and deletes it -- then exits, because only install.ps1 creates venvs.
# Under install.ps1 the in-place repair below covers every flavour, so this escape stays out.
if ($shouldRebuild -and -not $InstallerManagedSetup -and $installedTorchTag -eq "xpu") {
substep "Keeping the installed Intel XPU environment (this host expects $expectedTorchTag)." "Yellow"
substep "Re-run install.ps1 to replace it -- that path rebuilds with a rollback copy." "Yellow"
$shouldRebuild = $false
# Keeping the venv is only half the job: the index selection below prefers NVIDIA, and
# the CUDA arm does NOT --reinstall-package torch, so uv would keep the +xpu wheel as
# satisfied while installing triton-windows over torch's XPU triton, with $XpuIndexUrl
# null and nothing to swap it back. So the whole pass stays on the xpu index.
$script:PreservedXpuVenv = $true
}
# A GPU wheel install.ps1 SELECTED is never force-reinstalled onto a different family on the
# strength of a rescan. Only a wheel matching the family install.ps1 reported in
# $env:UNSLOTH_INSTALLER_TORCH_TAG counts as its answer, because "there is a GPU wheel in the
# venv" does not by itself mean this run put it there: the migrated-venv arm (install.ps1's
# `if ($_Migrated)`) installs unsloth alone and never touches torch, and install.ps1's flavor
# repair no-ops whenever its expected tag is 'cpu' or unrecognised. So an ordinary upgrade off
# the legacy ~/.unsloth/studio/.venv layout can hand setup a +cu118 wheel from a previous
# install on different hardware; preserving THAT would leave the environment permanently wrong
# and, on a mapped AMD host, the kept cu* tag also blocks the ROCm reroute below (it needs
# $CuTag -eq "cpu"). Those repair in place, as they did before this guard existed.
#
# $InstallerTorchTag $null means the installer did not say (an older cached install.ps1, or
# --no-torch), and unknown falls back to preserving, the behaviour that shipped before the
# variable existed. Otherwise the disagreement is between setup's second probe and the first,
# not with the hardware -- a Get-CimInstance that threw, an nvidia-smi that did not answer, the
# single-Radeon unroll of #8335 -- and setup has no better claim than the installer that just
# ran: the in-place repair below would --force-reinstall the other family over a working GPU
# environment and exit 0, at which point install.ps1 discards the rollback copy and the damage
# is permanent. A loud failure traded for a silently wrong install is worse than the loop this
# block exists to end.
#
# Covers cpu rescans (+rocm -> cpu) and family-to-family ones (+rocm -> cu128, +cu128 -> rocm,
# +xpu -> cu128) alike. Nothing legitimate is suppressed: a venv whose torch does not import at
# all leaves $installedTorchTag $null and still repairs, a CPU wheel that has to become a GPU
# one still repairs, cu* -> cu* already repaired in place above, and an explicit index pin
# escaped before that. A genuine GPU swap is install.ps1's job, done before calling here.
#
# $expectedTorchTag is read in the MESSAGE, never in the condition: it is assigned only inside
# the `if (-not $shouldRebuild)` block above, so on a venv whose torch would not import it was
# never created and reading it under a caller's Set-StrictMode is fatal. The body is reached
# only once $installedTorchTag has answered, which is only true on the path that assigned it.
if ($shouldRebuild -and $InstallerManagedSetup -and
$installedTorchTag -and $installedTorchTag -ne "cpu" -and
((-not $InstallerTorchTag) -or $installedTorchTag -eq $InstallerTorchTag)) {
substep "This host rescanned as $expectedTorchTag but the installer just placed a $installedTorchTag build here -- keeping it." "Yellow"
substep "Set UNSLOTH_TORCH_INDEX_URL to move this environment onto another PyTorch build on purpose." "DarkGray"
$shouldRebuild = $false
# Keeping the wheel is only half the job: the index selection below re-runs the same
# rescan, and two of its arms force-reinstall torch regardless of
# $script:PinChangedForceReinstall (the AMD arm always, the XPU arm whenever the installed
# tag is not xpu), undoing this guard from a thousand lines further down.
$script:PreservedInstallerTorchTag = $installedTorchTag
# Same reason as the direct-update escape above, and it needs its own flag: stay on the
# xpu index, or triton-windows lands over torch's XPU triton with nothing to swap it back.
if ($installedTorchTag -eq "xpu") { $script:PreservedXpuVenv = $true }
}
$reason = $null
if ($shouldRebuild) {
$reason = if ($installedTorchTag) { "torch $installedTorchTag != required $expectedTorchTag" } else { "torch could not be imported" }
# "torch could not be imported" covers a dead GPU driver, a half-written wheel and no torch
# at all. Print what python actually said (the last stderr line is the exception), so a
# WinError 126 reads as a driver problem instead of a broken install.
if ($_verProbe -and -not $_verProbe.Ok -and $_verProbe.Error) {
# No @(...)[0] around this: the guard above passes on a whitespace-only stderr,
# Where-Object then drops every line, and [0] into the empty array that leaves is fatal
# under a caller's Set-StrictMode. -Last 1 already yields one string or nothing.
$_probeErrLine = $_verProbe.Error -split "`r?`n" |
Where-Object { $_.Trim() } | Select-Object -Last 1
if ($_probeErrLine) { substep "PyTorch reported: $($_probeErrLine.Trim())" "DarkGray" }
}
}
# The abort that used to live here was an unrecoverable fixed point, reported from four
# separate triggers (#5942, #7275, #8335, plus a driver crash). It told the user to re-run
# install.ps1 for a safe rollback replace -- but install.ps1 IS the caller under
# $InstallerManagedSetup, it had already done exactly that earlier in the same run, and its
# failure path moves the previous environment straight back, so every attempt ended on the
# byte-identical state it started from and the install could never converge.
#
# Repair in place instead of wiping: the dependency pass force-reinstalls the torch trio from
# the resolved index over this venv, the route an index-pin change and a cu* family change
# already take above. Deleting is not an option here anyway -- install.ps1 invokes setup
# through the venv's own python.exe, which is therefore locked by the process running this.
if ($shouldRebuild -and $InstallerManagedSetup) {
substep "Environment does not match this host ($reason) -- reinstalling PyTorch in place." "Yellow"
substep "install.ps1 keeps a rollback copy of the previous environment until this run succeeds." "DarkGray"
$script:PinChangedForceReinstall = $true
$shouldRebuild = $false
}
if ($shouldRebuild) {
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
# why: mirror install.ps1 env-mode guard so an update against a custom
# UNSLOTH_STUDIO_HOME never wipes an unrelated unsloth_studio venv;
# -PathType Leaf rejects a directory masquerading as the sentinel.
# The .cmd counts too, and for the same reason the uninstaller accepts it: a
# policy's quarantine can take the unsigned .exe and leave a root that is still
# ours. Content-checked, never by name -- this guard gates a recursive delete.
if (
$StudioHomeIsCustom -and
-not (Test-Path -LiteralPath (Join-Path $VenvDir $StudioOwnedMarker) -PathType Leaf) -and
-not (Test-Path -LiteralPath (Join-Path $StudioHome "share\studio.conf") -PathType Leaf) -and
-not (Test-Path -LiteralPath (Join-Path $StudioHome "bin\unsloth.exe") -PathType Leaf) -and
-not (Test-UnslothCmdShimFile (Join-Path $StudioHome "bin\unsloth.cmd"))
) {
Write-StudioLine "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
Write-StudioLine " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
Exit-SetupFailure "$VenvDir is not an Unsloth Studio environment"
}
try {
Remove-Item -LiteralPath $VenvDir -Recurse -Force -ErrorAction Stop
} catch {
Write-StudioLine " [ERROR] Could not remove stale venv: $($_.Exception.Message)" -ForegroundColor Red
Write-StudioLine " Close any running Unsloth/Python processes and re-run setup." -ForegroundColor Red
Exit-SetupFailure "Could not remove the stale environment at $VenvDir"
}
}
}
if (-not (Test-Path -LiteralPath $VenvDir)) {
Write-StudioLine "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red
Write-StudioLine " Run install.ps1 first to create the environment:" -ForegroundColor Yellow
Write-StudioLine " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
Exit-SetupFailure "Virtual environment not found at $VenvDir"
} else {
substep "reusing existing virtual environment at $VenvDir"
$_venvPyExe = Join-Path $VenvDir "Scripts\python.exe"
$_venvActivate = Join-Path $VenvDir "Scripts\Activate.ps1"
if (Test-Path -LiteralPath $_venvPyExe) {
# The interpreter is not the only file the rest of this script needs. Everything below
# reaches the venv through the dot-sourced Activate.ps1 and a bare `python` (Fast-Install
# resolves its target with (Get-Command python).Source), and install.ps1 deliberately
# leaves the venv's Scripts directory off PATH. So a venv that kept python.exe but lost
# Activate.ps1 fails the dot-source non-terminatingly at the "Continue" the pip section
# runs at, installs the whole stack into whatever interpreter is on PATH, and exits 0.
# Newly reachable now that an installer-managed stale verdict repairs instead of aborting.
if (-not (Test-Path -LiteralPath $_venvActivate)) {
Write-StudioLine "[ERROR] $VenvDir has no activation script at Scripts\Activate.ps1." -ForegroundColor Red
Write-StudioLine " The environment is incomplete rather than out of date. Re-run the installer" -ForegroundColor Yellow
Write-StudioLine " to rebuild it: irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
Exit-SetupFailure "No activation script at $_venvActivate"
}
try {
$_venvPyVer = (& $_venvPyExe --version 2>&1 | Out-String).Trim()
if ($_venvPyVer) { substep $_venvPyVer }
} catch {}
} else {
# Stop here, because nothing downstream would: the activation below is a dot-source, and a
# MISSING script is a NON-terminating error at the "Continue" the pip section runs at, so
# setup would print one red line and carry on, resolving every `python` and `uv pip` that
# follows against whatever interpreter is on PATH, installing the whole stack outside the
# environment it was asked to build, and exiting 0 over a venv with not one package in it.
#
# An interpreter-less venv is incomplete, not out of date, so none of the decisions above
# apply: the in-place torch repair has no interpreter to repair through, and the rebuild
# path deletes the directory only to hit "Virtual environment not found" a few lines up.
# Unlike the abort this replaced upstream, re-creating the environment genuinely changes
# this state -- install.ps1 still holds the rollback copy of the previous one.
Write-StudioLine "[ERROR] $VenvDir has no interpreter at Scripts\python.exe." -ForegroundColor Red
Write-StudioLine " The environment is incomplete rather than out of date. Re-run the installer" -ForegroundColor Yellow
Write-StudioLine " to rebuild it: irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
Exit-SetupFailure "No interpreter at $_venvPyExe"
}
}
# pip and python write to stderr even on success (progress bars, warnings).
# With $ErrorActionPreference = "Stop" (set at top of script), PS 5.1
# converts stderr lines into terminating ErrorRecords, breaking output.
# Lower to "Continue" for the pip/python section.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# Existence is not activation. The two refusals above check that a file is THERE; neither can tell
# whether dot-sourcing it took effect, and the ways it silently does not are ordinary damage on a
# half-written venv. Activate.ps1 prepends the venv to PATH in its LAST statement, 28 lines after
# it sets $env:VIRTUAL_ENV, so a copy truncated by an interrupted or out-of-disk `python -m venv`
# parses, runs to its last complete statement and returns without printing anything at all, and an
# unparseable one is a ParserError, non-terminating at the "Continue" set just above. Either way
# the dot-source "succeeds" with the ambient interpreter still first on PATH -- the system or
# Microsoft Store python, since install.ps1 keeps the venv's Scripts directory off PATH on purpose.
# Fast-Install hands exactly that to `uv pip install --python`, the whole stack lands outside the
# venv, every Exit-SetupFailure below keys off that wrong interpreter's exit code, and setup exits
# 0 -- at which point install.ps1 commits over the rollback copy and the previous working
# environment is gone for good.
#
# So assert the post-condition instead of a third pre-condition: the `python` now in effect has to
# live under $VenvDir. $env:VIRTUAL_ENV would not do, precisely because Activate.ps1 sets it
# before the line that matters.
function Assert-VenvActivated {
param([Parameter(Mandatory = $true)][string]$VenvDir)
# Both sides normalised through Get-Item, which is what Activate.ps1 itself uses to build the
# PATH entry ($VenvExecDir = Get-Item -Path $VenvExecPath): agreeing on the normaliser keeps a
# short 8.3 path, a substituted drive, a junction or a differently cased drive letter from
# reading as "outside". A guard that false-positives here would break every install, so every
# branch that cannot PROVE the interpreter is wrong returns.
$venvRoot = $null
try { $venvRoot = (Get-Item -LiteralPath $VenvDir -Force -ErrorAction Stop).FullName.TrimEnd('\', '/') } catch { $venvRoot = $null }
if (-not $venvRoot) { return }
$_pyCmd = Get-Command python -ErrorAction SilentlyContinue | Select-Object -First 1
# A function or alias named python carries no Source to judge, and judging it wrong would
# refuse an install that works. Only an application resolved off PATH is decidable, and that
# is also the only shape Fast-Install's -- python hand-off can be aimed at.
if ($_pyCmd -and $_pyCmd.CommandType -ne 'Application') { return }
$_pyPath = $null
if ($_pyCmd -and $_pyCmd.Source) {
try { $_pyPath = (Get-Item -LiteralPath $_pyCmd.Source -Force -ErrorAction Stop).FullName } catch { $_pyPath = $_pyCmd.Source }
}
if ($_pyPath) {
foreach ($_sep in @('\', '/')) {
if ($_pyPath.StartsWith($venvRoot + $_sep, [System.StringComparison]::OrdinalIgnoreCase)) { return }
}
}
$_where = if ($_pyPath) { $_pyPath } else { "nothing on PATH" }
Write-StudioLine "[ERROR] Activating $VenvDir did not take effect: python resolves to $_where." -ForegroundColor Red
Write-StudioLine " The activation script is present but did not put the environment on PATH," -ForegroundColor Yellow
Write-StudioLine " so the environment is incomplete rather than out of date. Re-run the installer" -ForegroundColor Yellow
Write-StudioLine " to rebuild it: irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
Exit-SetupFailure "Activating $VenvDir did not put its interpreter on PATH (python resolves to $_where)"
}
# Mirrors install.ps1's Install-UvFromRelease: same archive, destination priority and user-PATH
# prepend as astral's installer, but it fetches a data file with a pinned SHA-256 instead of
# running remote script text in-process, which is what AMSI scores hardest. Bumping the version
# means bumping all 3 hashes:
# curl -sL https://github.com/astral-sh/uv/releases/download/<ver>/uv-<arch>-pc-windows-msvc.zip.sha256
$UvPinnedVersion = "0.12.1"
$UvPinnedAssets = @{
"x86_64" = @{ Asset = "uv-x86_64-pc-windows-msvc.zip"; Sha256 = "8FCB0CB46E1229065E344758980924E569BEF5882EF45F46FADA8FB24E06B74A" }
"arm64" = @{ Asset = "uv-aarch64-pc-windows-msvc.zip"; Sha256 = "9BC7C18E616230FA2DC6FB24BC3AFDE18A95C2B5C9433DE747E9502C66041568" }
"x86" = @{ Asset = "uv-i686-pc-windows-msvc.zip"; Sha256 = "9B51C33D307A8AB9E9DFD88D4AE1491761F63DE0BFFA3CEC96BEC536491C9B97" }
}
# Not Get-HostMachineArch: it answers arm64/other for the VC++ and prebuilt probes, and "other"
# cannot pick between the x86_64 and i686 archives. install.ps1's resolution order.
function Get-UvHostArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
$signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
foreach ($s in $signals) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
foreach ($s in $signals) {
if ([string]::IsNullOrWhiteSpace($s)) { continue }
switch ($s.ToLowerInvariant()) {
"amd64" { return "x86_64" }
"x64" { return "x86_64" }
"x86" { return "x86" }
}
}
return "unknown"
}
# Writes to the pipeline, not the console: under Invoke-SetupCommand a quiet run swallows this
# exactly as it swallowed astral's output, and a verbose run shows it. The console lines around
# the call site are unchanged.
function Get-SetupUvExecutableVerdict {
# Mirrors Get-UvExecutableVerdict in install.ps1: "ok", "failed" or "unknown". Only the
# binary answering non-zero is "failed"; a launch that throws or a wait that times out got
# no verdict, and the digest already proved the bytes are astral's pinned release.
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return "failed" }
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
try {
$proc = Start-Process -FilePath $Path -ArgumentList "--version" -NoNewWindow -PassThru `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile -ErrorAction Stop
if (-not $proc.WaitForExit(20000)) {
try { $proc.Kill() } catch {}
Write-Output "uv did not answer --version within 20s; installing it unprobed."
return "unknown"
}
# The timed overload can return before the exit code is cached, which is how
# arm64 and the Windows containers reported an EMPTY code and had a working uv
# read as broken. The parameterless wait settles it and returns at once, since
# the process has already exited. No code at all is still no verdict.
try { $proc.WaitForExit() } catch {}
$code = $null
try { $code = $proc.ExitCode } catch {}
if ($null -eq $code -or "$code" -eq "") {
Write-Output "uv --version gave no exit code; installing it unprobed."
return "unknown"
}
if ($code -eq 0) { return "ok" }
$detail = ""
try {
$detail = Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue
} catch {}
if ($detail) { $detail = " " + (($detail.Trim()) -replace '\s+', ' ') }
Write-Output "uv --version exited $code.$detail"
return "failed"
} catch {
Write-Output "could not probe uv: $($_.Exception.Message); installing it unprobed."
return "unknown"
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
}
function Install-UvFromPinnedRelease {
$arch = Get-UvHostArch
if (-not $UvPinnedAssets.ContainsKey($arch)) {
Write-Output "No uv build is published for this architecture ($arch)."
return $false
}
$asset = $UvPinnedAssets[$arch].Asset
$wanted = $UvPinnedAssets[$arch].Sha256
# astral's destination priority, so an existing uv is replaced in place and the Get-Command
# probe after Refresh-Environment still finds it.
$destDir = $null
foreach ($candidate in @($env:UV_INSTALL_DIR, $env:UV_UNMANAGED_INSTALL, $env:XDG_BIN_HOME)) {
if ($candidate) { $destDir = $candidate; break }
}
if (-not $destDir -and $env:XDG_DATA_HOME) { $destDir = Join-Path $env:XDG_DATA_HOME "../bin" }
if (-not $destDir) {
$userHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
if (-not $userHome) {
Write-Output "Could not determine a home directory to install uv into."
return $false
}
$destDir = Join-Path $userHome ".local\bin"
}
# astral's sources in astral's order, each exclusive when set. UV_DOWNLOAD_URL (and its older
# alias INSTALLER_DOWNLOAD_URL) outrank the mirror variables there, and a host that sets one
# usually cannot reach the public endpoints at all, so trying those first would stall. The pin
# still applies: a source serving a different build fails the digest and the caller falls back.
$uvBase = if ($env:UV_DOWNLOAD_URL) {
@("$($env:UV_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:INSTALLER_DOWNLOAD_URL) {
@("$($env:INSTALLER_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:UV_INSTALLER_GHE_BASE_URL) {
@("$($env:UV_INSTALLER_GHE_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
} elseif ($env:UV_INSTALLER_GITHUB_BASE_URL) {
@("$($env:UV_INSTALLER_GITHUB_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
} else {
@("https://releases.astral.sh/github/uv/releases/download/$UvPinnedVersion",
"https://github.com/astral-sh/uv/releases/download/$UvPinnedVersion")
}
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("unsloth-uv-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$zip = Join-Path $work $asset
try {
[System.IO.Directory]::CreateDirectory($work) | Out-Null
# Digest per mirror, as install.ps1 does: a proxy answering 200 with its own body is a
# successful download by every measure Invoke-WebRequest has.
$downloaded = $false
foreach ($base in $uvBase) {
Write-Output "downloading uv $UvPinnedVersion ($arch) from $base..."
try {
Invoke-WebRequest -UseBasicParsing -OutFile $zip -Uri "$base/$asset"
} catch {
Write-Output "uv download failed: $($_.Exception.Message)"
continue
}
$actual = ""
try { $actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash } catch {}
if ($actual -eq $wanted) {
$downloaded = $true
break
}
Write-Output "uv download failed checksum verification -- discarding it."
Write-Output "expected $wanted, got $actual"
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
}
if (-not $downloaded) { return $false }
# The Windows archives are flat: uv.exe, uvx.exe, uvw.exe at the root.
Expand-Archive -LiteralPath $zip -DestinationPath $work -Force
[System.IO.Directory]::CreateDirectory($destDir) | Out-Null
$stagedUv = Join-Path $work "uv.exe"
if (-not (Test-Path -LiteralPath $stagedUv)) {
Write-Output "uv.exe was not present in $asset."
return $false
}
# Run it where it landed, before the destination is touched: a host can have a working
# older uv while a policy refuses this one, and copying first leaves it with neither.
if ((Get-SetupUvExecutableVerdict -Path $stagedUv) -eq "failed") {
Write-Output "the downloaded uv $UvPinnedVersion could not run on this machine."
return $false
}
# uvw.exe is the windowless launcher and has no console to answer a probe on, so the
# staged uv.exe above stands for the set: it came from the same verified archive.
# Copy-Item under Stop so a locked or ACL-denied destination fails the install rather
# than leaving half a set behind quietly.
$haveUv = $true
foreach ($exe in @("uv.exe", "uvx.exe", "uvw.exe")) {
$src = Join-Path $work $exe
if (-not (Test-Path -LiteralPath $src)) { continue }
$dst = Join-Path $destDir $exe
try {
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
} catch {
$haveUv = $false
break
}
if ($exe -eq "uv.exe") {
# Invoke-SetupCommand sets ErrorActionPreference to Continue, so compare
# against the archive we verified: a stale uv.exe must not pass for ours.
$copied = $false
try {
$copied = (Test-Path -LiteralPath $dst) -and
(Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash -eq
(Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
} catch { $copied = $false }
if (-not $copied) { $haveUv = $false; break }
}
}
if (-not $haveUv) {
Write-Output "uv.exe was not present in $asset."
return $false
}
} finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}
# astral's PATH treatment and opt-outs: an unmanaged install forces no-modify-path there, so
# it must here too. The user-PATH prepend is what survives the Refresh-Environment below.
if (-not $env:UV_NO_MODIFY_PATH -and -not $env:UV_UNMANAGED_INSTALL) {
Add-ToUserPath -Directory $destDir -Position Prepend | Out-Null
}
$env:PATH = "$destDir;$env:PATH"
# Recorded on the script scope as well as returned: the caller runs this through
# Invoke-SetupCommand, which hands back [int]$LASTEXITCODE rather than the pipeline value,
# so the return alone cannot tell the fallback whether to run.
$script:UvPinnedInstalled = $true
return $true
}
$ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1"
. $ActivateScript
Assert-VenvActivated -VenvDir $VenvDir
# Try to use uv (much faster than pip), fall back to pip if unavailable
$UseUv = $false
if (Get-Command uv -ErrorAction SilentlyContinue) {
$UseUv = $true
} else {
substep "installing uv package manager..."
try {
$script:UvPinnedInstalled = $false
Invoke-SetupCommand { Install-UvFromPinnedRelease } | Out-Null
# The merge base ran astral's installer here, so a failed pinned install needs somewhere
# to go: with no fallback the setup drops to pip for torch, bitsandbytes and Triton, which
# is a different resolver rather than a different download. winget, not the remote script,
# which is the shape this branch removes and is what install.ps1 already tries first.
if (-not $script:UvPinnedInstalled -and (Get-Command winget -ErrorAction SilentlyContinue)) {
Invoke-SetupCommand {
winget install --id astral-sh.uv --source winget --accept-source-agreements `
--accept-package-agreements --silent
} | Out-Null
}
Refresh-Environment
# Re-activate venv since Refresh-Environment rebuilds PATH from
# registry and drops the venv's Scripts directory
. $ActivateScript
if (Get-Command uv -ErrorAction SilentlyContinue) { $UseUv = $true }
} catch { }
}
# Refresh-Environment rebuilt PATH from the registry, and the re-activation meant to put the venv
# back sits inside a catch that swallows everything -- including a dot-source that died after
# Activate.ps1's own `deactivate -nondestructive` had restored the pre-venv PATH. Re-check outside
# the catch: this is the last statement before Fast-Install starts resolving `python`.
Assert-VenvActivated -VenvDir $VenvDir
# Helper: install a package, preferring uv with pip fallback
function Fast-Install {
param([Parameter(ValueFromRemainingArguments=$true)]$Args_)
# An explicit --index-url must win: inherited uv index vars otherwise pull CPU torch over
# the CUDA/ROCm build (#6898), so drop them for pinned installs (scrub covers the whole
# function since the pip fallback honours PIP_* too). UV_TORCH_BACKEND / UV_FIND_LINKS also
# reroute; UV_NO_CONFIG=1 (+ dropping UV_CONFIG_FILE) stops a uv.toml index outranking the
# pin (uv 0.10); PIP_NO_INDEX / PIP_INDEX_URL would defeat the pinned --index-url in pip.
$saved = @{}
$pinned = @($Args_) -contains '--index-url'
if ($pinned) {
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL',
'UV_TORCH_BACKEND', 'UV_FIND_LINKS', 'PIP_EXTRA_INDEX_URL', 'PIP_FIND_LINKS',
'PIP_NO_INDEX', 'PIP_INDEX_URL',
'UV_CONFIG_FILE', 'UV_NO_CONFIG', 'PIP_CONFIG_FILE') {
$saved[$n] = [Environment]::GetEnvironmentVariable($n)
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
}
$env:UV_NO_CONFIG = '1'
# A `pip config` global.extra-index-url still adds indexes to the pip FALLBACK;
# PIP_CONFIG_FILE = 'nul' (Windows devnull) loads NO config (uv ignores pip config).
$env:PIP_CONFIG_FILE = 'nul'
}
try {
if ($UseUv) {
$VenvPy = (Get-Command python).Source
$result = & uv pip install --python $VenvPy @Args_ 2>&1
if ($LASTEXITCODE -eq 0) { return }
}
& python -m pip install @Args_ 2>&1
}
finally {
if ($pinned) {
Remove-Item "Env:UV_NO_CONFIG" -ErrorAction SilentlyContinue
Remove-Item "Env:PIP_CONFIG_FILE" -ErrorAction SilentlyContinue
}
foreach ($n in $saved.Keys) { if ($null -ne $saved[$n]) { Set-Item "Env:$n" $saved[$n] } }
}
}
# uv first, pip as the fallback -- the shape install.sh uses to prune a dependency-pulled wheel.
# No index scrub: an uninstall reads no index.
function Fast-Uninstall {
param([Parameter(ValueFromRemainingArguments=$true)]$Args_)
if ($UseUv) {
$VenvPy = (Get-Command python).Source
& uv pip uninstall --python $VenvPy @Args_ 2>&1
if ($LASTEXITCODE -eq 0) { return }
}
& python -m pip uninstall -y @Args_ 2>&1
}
# Fetch a wheel without installing it, so a destructive step can be staged behind a download.
# pip only: uv has no `pip download` (astral-sh/uv#3163). Scrubbed like Fast-Install, minus the
# UV_* pip cannot read: an inherited PIP_INDEX_URL or user pip.conf would outrank --index-url.
function Fast-Download {
param([Parameter(ValueFromRemainingArguments=$true)]$Args_)
$saved = @{}
foreach ($n in 'PIP_EXTRA_INDEX_URL', 'PIP_FIND_LINKS', 'PIP_NO_INDEX', 'PIP_INDEX_URL', 'PIP_CONFIG_FILE') {
$saved[$n] = [Environment]::GetEnvironmentVariable($n)
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
}
$env:PIP_CONFIG_FILE = 'nul'
try {
& python -m pip download @Args_ 2>&1
}
finally {
Remove-Item "Env:PIP_CONFIG_FILE" -ErrorAction SilentlyContinue
foreach ($n in $saved.Keys) { if ($null -ne $saved[$n]) { Set-Item "Env:$n" $saved[$n] } }
}
}
# ── Check if Python deps need updating ──
# Compare installed package version against PyPI latest.
# Skip all Python dependency work if versions match (fast update path).
$_PkgName = if ($env:STUDIO_PACKAGE_NAME) { $env:STUDIO_PACKAGE_NAME } else { "unsloth" }
$SkipPythonDeps = $false
if ($env:SKIP_STUDIO_BASE -ne "1" -and $env:STUDIO_LOCAL_INSTALL -ne "1") {
# Only check when NOT called from install.ps1 (which just installed the package)
$InstalledVer = try { (& python -c "from importlib.metadata import version; print(version('$_PkgName'))" 2>$null | Out-String).Trim() } catch { "" }
$LatestVer = ""
try {
$pypiJson = Invoke-RestMethod -Uri "https://pypi.org/pypi/$_PkgName/json" -TimeoutSec 5 -ErrorAction Stop
$LatestVer = "$($pypiJson.info.version)".Trim()
} catch { }
if ($InstalledVer -and $LatestVer -and ($InstalledVer -eq $LatestVer)) {
step "python" "$_PkgName $InstalledVer is up to date"
$SkipPythonDeps = $true
# A pre-#6483-fix install can be stuck on anyio>=4.14 even though
# $_PkgName itself is current; the fast path above would otherwise
# never reach install_python_stack's anyio repair (#6797).
$_anyioBad = $false
try {
& python -c "
import re, sys
from importlib.metadata import version, PackageNotFoundError
try:
parts = version('anyio').split('.')
major = int(parts[0])
minor = int(re.sub(r'[^0-9].*', '', parts[1])) if len(parts) > 1 else 0
except (PackageNotFoundError, ValueError, IndexError):
sys.exit(1)
sys.exit(0 if (major, minor) >= (4, 14) else 1)
" 2>$null
if ($LASTEXITCODE -eq 0) { $_anyioBad = $true }
} catch {}
if ($_anyioBad) {
substep "anyio >=4.14 found (#6483) -- forcing dependency pass to repair..." "Cyan"
$SkipPythonDeps = $false
}
# An interrupted install leaves $_PkgName current while studio.txt
# never finished, so the compare above says "up to date" and update --
# plus the desktop Repair button -- no-ops on a venv that cannot boot.
$_studioInstallIncomplete = $false
try {
& python -c "
import sys
sys.path.insert(0, sys.argv[1])
try:
import install_manifest
except Exception:
sys.exit(0) # older tree without the manifest helper: leave the fast path alone
sys.exit(0 if install_manifest.verify_install()['ok'] else 1)
" "$PSScriptRoot" 2>$null
if ($LASTEXITCODE -ne 0) { $_studioInstallIncomplete = $true }
} catch {}
if ($_studioInstallIncomplete) {
substep "studio install incomplete -- forcing dependency pass to repair..." "Cyan"
$SkipPythonDeps = $false
}
# If the desktop app specifies a minimum required backend version and the installed
# package is older than that requirement, force the dependency pass to upgrade it.
if ($env:UNSLOTH_DESKTOP_BACKEND_VERSION) {
$_desktopVerBad = $false
try {
& python -c "
import re, sys
try:
from packaging.version import parse as parse_v
except ImportError:
def parse_v(v):
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)', (v or '').strip())
return (int(match.group(1)), int(match.group(2)), int(match.group(3))) if match else None
installed = parse_v(sys.argv[1])
required = parse_v(sys.argv[2])
sys.exit(0 if installed is not None and required is not None and installed >= required else 1)
" "$InstalledVer" "$env:UNSLOTH_DESKTOP_BACKEND_VERSION" 2>$null
if ($LASTEXITCODE -ne 0) { $_desktopVerBad = $true }
} catch {}
if ($_desktopVerBad) {
substep "$_PkgName $InstalledVer < $env:UNSLOTH_DESKTOP_BACKEND_VERSION (required by desktop app) -- forcing dependency pass to update..." "Cyan"
$SkipPythonDeps = $false
}
}
# ...but not if an AMD GPU is present and installed PyTorch is CPU-only
# (host predates ROCm-wheel support, or GPU added later): the fast "up to
# date" path would leave the user on CPU torch with Train/Export disabled.
# Force the dependency pass so the ROCm wheels get installed.
if ($script:ROCmGfxArch) {
# Bounded, like every other torch probe here and for the reason the disk-based ROCm
# rescue above exists: on a faulted HIP runtime `import torch` never comes back, and
# the rescue now KEEPS that venv rather than deleting it, so this is the first
# `import torch` such a host reaches and an unbounded call would hang setup forever.
# A probe that does not answer keeps $_torchIsCpu true, the same safe direction as
# before: one dependency pass, never a silent skip.
$_rocmTorchProbe = Invoke-BoundedPythonProbe -PythonExe "python" `
-Code "import torch, sys; sys.exit(0 if torch.cuda.is_available() else 1)"
$_torchIsCpu = -not $_rocmTorchProbe.Ok
if ($_torchIsCpu) {
substep "AMD GPU ($script:ROCmGfxArch) detected but installed PyTorch is CPU-only -- reinstalling ROCm PyTorch" "Cyan"
$SkipPythonDeps = $false
}
}
# ...and the same for an Intel Arc / Data Center GPU, or an up-to-date package on a CPU
# wheel stays on CPU torch forever. $SkipPythonDeps is re-tested so an escape taken above
# does not read twice. Both escapes below exist to reach the XPU install and its two
# remediations, all three gated on $XpuIndexUrl (set only when the resolved leaf is xpu),
# so $_xpuIsReachable holds them back where a pin or no-torch mode sends this host
# elsewhere and clearing the fast path would install nothing and re-fire forever.
$_pinLeafNow = Get-TorchIndexLeaf (Get-PinnedTorchIndexUrl)
$_xpuIsReachable = (-not $NoTorchMode) -and ((-not $_pinLeafNow) -or ($_pinLeafNow -eq "xpu"))
if ($script:IsIntelXpu -and $SkipPythonDeps -and $_xpuIsReachable) {
# The WHEEL, not the runtime: torch.xpu.is_available() is also false for a supported
# +xpu wheel on a wedged driver, and the dependency pass cannot repair a driver, so
# keying on it would force a full resolution every update for nothing.
if (-not (Test-VenvTorchIsXpuSupported -VenvPath $VenvDir)) {
substep "Intel GPU detected but installed PyTorch is not a supported XPU build -- reinstalling XPU PyTorch" "Cyan"
$SkipPythonDeps = $false
}
}
# Keyed off the installed wheel as well as the scan: an explicit xpu pin on a host the
# scan skips (a mixed NVIDIA + Intel box) still ends up on XPU with $script:IsIntelXpu
# false. The bitsandbytes floor and the Triton replacement live in the dependency pass
# below, so a venv that reached +xpu without them would fast-path past them forever.
if ($SkipPythonDeps -and $_xpuIsReachable -and ($script:IsIntelXpu -or $installedTorchTag -eq "xpu")) {
$_xpuDepsCode = "import importlib.metadata as m; " +
"print('BNB=' + next((d.version for d in m.distributions() " +
"if (d.metadata['Name'] or '').lower() == 'bitsandbytes'), '')); " +
"print('TRITONWIN=' + next((d.version for d in m.distributions() " +
"if (d.metadata['Name'] or '').lower().replace('_','-') == 'triton-windows'), ''))"
$_xpuDeps = Invoke-BoundedPythonProbe -PythonExe "python" -Code $_xpuDepsCode
if (-not $_xpuDeps.Ok) {
# A probe that did not answer says nothing about the venv, and reading that as
# "dependencies are current" would fast-path past both remediations forever.
# Same direction as an unparseable version below: one extra pass.
substep "Intel XPU dependencies could not be read -- running the dependency pass" "Cyan"
$SkipPythonDeps = $false
} else {
$_bnbVer = if ($_xpuDeps.Output -match '(?m)^BNB=(\S+)\s*$') { $Matches[1] } else { "" }
# An unreadable version is treated as stale, the safe direction: one extra pass,
# never a venv left without 4-bit kernels. Trailing suffixes (0.51.0.dev0) are
# dropped, not cast.
$_bnbNum = ($_bnbVer -replace '[^0-9.].*$', '').TrimEnd('.')
$_bnbStale = $true
if ($_bnbNum -match '^\d+\.\d+') {
try { $_bnbStale = [version]$_bnbNum -lt [version]"0.50.0" } catch {}
}
$_tritonWinPresent = $_xpuDeps.Output -match '(?m)^TRITONWIN=\S+\s*$'
if ($_bnbStale -or $_tritonWinPresent) {
substep "Intel XPU dependencies are stale -- running the dependency pass" "Cyan"
$SkipPythonDeps = $false
}
}
}
} elseif ($InstalledVer -and $LatestVer) {
substep "$_PkgName $InstalledVer -> $LatestVer available, updating..."
} elseif (-not $LatestVer) {
substep "could not reach PyPI, updating to be safe..."
}
}
# if (-not $IsPipInstall) {
# # Running from repo: copy requirements and do editable install
# $RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).Path
# $ReqsSrc = Join-Path $RepoRoot "backend\requirements"
# $ReqsDst = Join-Path $PackageDir "requirements"
# if (-not (Test-Path $ReqsDst)) { New-Item -ItemType Directory -Path $ReqsDst | Out-Null }
# Copy-Item (Join-Path $ReqsSrc "*.txt") $ReqsDst -Force
# Write-Host " Installing CLI entry point..." -ForegroundColor Cyan
# pip install -e $RepoRoot 2>&1 | Out-Null
# } else {
# # Running from pip install: the package is in system Python but not in
# # the fresh .venv. Install it so run_install() can find its modules
# # and bundled requirements files.
# Write-Host " Installing package into venv..." -ForegroundColor Cyan
# pip install unsloth 2>&1 | Out-Null
# }
# A torch-index pin change repairs in place: force the dependency pass so the torch install
# below force-reinstalls from the new pin (else the fast path keeps the old wheel).
if ($script:PinChangedForceReinstall) { $SkipPythonDeps = $false }
if (-not $SkipPythonDeps) {
# install_python_stack.py drops the manifest before its own dependency pass, but
# pip, torch and triton are replaced first here. Drop it now so a run killed in
# those leaves the venv marked half-built, not behind a marker that verifies.
$_ManifestDropped = $true
try {
& python -c "
import sys
sys.path.insert(0, sys.argv[1])
try:
import install_manifest
except Exception:
sys.exit(0) # older tree without the manifest helper
sys.exit(0 if install_manifest.remove_manifest() else 1)
" "$PSScriptRoot" 2>$null
if ($LASTEXITCODE -ne 0) { $_ManifestDropped = $false }
} catch { $_ManifestDropped = $false }
if (-not $_ManifestDropped) {
Write-StudioLine "[ERROR] Could not remove the stale unsloth_install_manifest.json." -ForegroundColor Red
Write-StudioLine " Refusing to install behind a marker that still reports this venv as complete." -ForegroundColor Red
Exit-SetupFailure "Could not remove the stale unsloth_install_manifest.json"
}
if ($script:UnslothVerbose) {
Fast-Install --upgrade pip
} else {
Fast-Install --upgrade pip | Out-Null
}
# Pre-install PyTorch with CUDA support.
# On Windows, the default PyPI torch wheel is CPU-only.
# We need PyTorch's CUDA index to get GPU-enabled wheels.
# PyTorch bundles its own CUDA runtime, so this works regardless
# of whether the CUDA Toolkit is installed yet.
# The CUDA tag is chosen based on the driver's max supported CUDA version.
# Triton/inductor filenames are long and can hit Windows MAX_PATH (260). With long
# paths on, cache under Unsloth home; else use a short drive-root dir for headroom.
if ($LongPathsEnabled) {
$TorchCacheDir = Join-Path $StudioHome "TORCHINDUCTOR_CACHE_DIR"
} else {
$TorchCacheDir = "C:\tc"
}
if (-not (Test-Path -LiteralPath $TorchCacheDir)) { [System.IO.Directory]::CreateDirectory($TorchCacheDir) | Out-Null }
$env:TORCHINDUCTOR_CACHE_DIR = $TorchCacheDir
[Environment]::SetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', $TorchCacheDir, 'User')
substep "TORCHINDUCTOR_CACHE_DIR set to $TorchCacheDir (avoids MAX_PATH issues)"
# Explicit pin (URL or family) wins over GPU probing and suppresses the AMD reroute below;
# matches install.sh / install.ps1 / install_python_stack.py.
$PinnedTorchIndexUrl = Get-PinnedTorchIndexUrl
$TorchIndexPinned = [bool]$PinnedTorchIndexUrl
if ($PinnedTorchIndexUrl) {
$CuTag = Get-TorchIndexLeaf $PinnedTorchIndexUrl
} elseif ($script:PreservedXpuVenv) {
# The stale check kept an installed +xpu venv rather than wiping it, which a hybrid
# NVIDIA + Arc host reaches. Ahead of the NVIDIA arm deliberately: converting halfway leaves
# +xpu torch under triton-windows and no XPU index to swap it back. install.ps1 converts.
$CuTag = "xpu"
} elseif ($script:PreservedInstallerTorchTag) {
# The stale check kept the GPU wheel install.ps1 placed here minutes ago. That decision has to
# reach the install arms below or it is undone there: the AMD arm force-reinstalls
# unconditionally and the XPU arm whenever the installed tag is not xpu, so neither is held off
# by $script:PinChangedForceReinstall staying false. Selecting the family already installed
# keeps both out of the way -- a cu* tag skips the AMD reroute below (it needs $CuTag -eq
# "cpu") and the XPU arm (it needs "xpu") and lands on the CUDA arm, which forces nothing; a
# kept +rocm venv reads as "cpu" here and lands on the CPU arm, whose bare torch range a +rocm
# build already satisfies. The +xpu case is the branch above. Behind the pin check, like every
# other arm: an explicit pin repairs in place before the guard runs anyway.
$CuTag = if (Test-CudaFamilyLeaf $script:PreservedInstallerTorchTag) { $script:PreservedInstallerTorchTag } else { "cpu" }
} elseif ($HasNvidiaSmi) {
$CuTag = Get-PytorchCudaTag
} elseif ($script:IsIntelXpu) {
# XPU (SYCL) wheels ship under the /xpu leaf, so $TorchInstallIndexUrl below resolves to
# <mirror>/xpu. A pin above still wins; the AMD reroute below needs $CuTag -eq "cpu".
$CuTag = "xpu"
} else {
$CuTag = "cpu"
}
# ── GPU arch → newest compatible Windows ROCm wheel release ──
# Wheels bundle their own ROCm runtime; the installed HIP SDK version does
# not constrain which release to use. Always picks the newest release that
# supports the GPU architecture.
# ── AMD Windows ROCm torch override ──────────────────────────────────────────
# Uses AMD's arch-specific pip index (repo.amd.com/rocm/whl/{arch}/).
# Wheels bundle their own ROCm runtime; HIP SDK version is irrelevant.
$ROCmGfxArch = $script:ROCmGfxArch
$ROCmIndexUrl = $null
# Install AMD ROCm PyTorch wheels when ROCm is confirmed OR a gfx arch is known
# (name-inferred on Adrenalin-only hosts). The per-arch wheels bundle the runtime
# (rocm-sdk-libraries-<gfx>), so torch.cuda.is_available() is True without a HIP
# SDK -- which flips Unsloth out of chat-only (CHAT_ONLY) and enables Train/Export.
# Gating on $HasROCm alone left Strix Halo / Radeon 8060S on CPU torch; a failed
# ROCm install still falls back to CPU below, so this is safe.
if (-not $TorchIndexPinned -and ($HasROCm -or $ROCmGfxArch) -and $CuTag -eq "cpu") {
$amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" }
# gfx120X and Strix have a null _grouped_mm kernel on torch <2.11.0.
# Mirrors the $torchFloorMap in install.ps1 so both installers enforce
# the same floor and ceiling when pulling from AMD's per-arch index.
$torchFloorMap = @{
"gfx1201" = "torch>=2.11.0,<2.12.0"; "gfx1200" = "torch>=2.11.0,<2.12.0"
"gfx1151" = "torch>=2.11.0,<2.12.0"; "gfx1150" = "torch>=2.11.0,<2.12.0"
"gfx1152" = "torch>=2.11.0,<2.12.0"
}
# Companion ranges for torchvision/torchaudio -- must stay in sync with the
# torch ceiling so pip can always find a consistent trio on AMD's per-arch
# index. AMD publishes each package independently and may add a newer
# torchvision (e.g. 0.27 for torch 2.12) before removing 0.26, which would
# cause pip to resolve an ABI-incompatible set if these are left bare.
# Matches _ROCM_TORCH_PKG_SPECS["rocm7.2"] in install_python_stack.py.
# Bump all three ceilings together when torch 2.12.x is validated.
$torchvisionFloorMap = @{
"gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0"
"gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0"
"gfx1152" = "torchvision>=0.26.0,<0.27.0"
}
$torchaudioFloorMap = @{
"gfx1201" = "torchaudio>=2.11.0,<2.12.0"; "gfx1200" = "torchaudio>=2.11.0,<2.12.0"
"gfx1151" = "torchaudio>=2.11.0,<2.12.0"; "gfx1150" = "torchaudio>=2.11.0,<2.12.0"
"gfx1152" = "torchaudio>=2.11.0,<2.12.0"
}
$archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null }
$ROCmTorchSpec = if ($ROCmGfxArch -and $torchFloorMap.ContainsKey($ROCmGfxArch)) { $torchFloorMap[$ROCmGfxArch] } else { "torch" }
$ROCmVisionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$ROCmAudioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
if ($archFamily) {
$ROCmIndexUrl = "$amdIndexBase/$archFamily/"
} elseif ($ROCmGfxArch) {
# GPU arch detected but not in the supported wheel map — warn explicitly
# so the user knows why they are getting CPU PyTorch instead of ROCm.
substep "[WARN] AMD GPU ($ROCmGfxArch) not in supported arch list -- falling back to CPU-only PyTorch" "Yellow"
substep " Supported: gfx1200/1201 (RDNA 4), gfx1150/1151/1152 (RDNA 3.5), gfx1100-1103 (RDNA 3), gfx1030-1036 (RDNA 2), gfx90a, gfx908" "Yellow"
} else {
# HIP SDK present ($HasROCm=true via amd-smi) but gcnArchName was not
# readable — warn rather than silently falling back to CPU PyTorch.
substep "[WARN] AMD GPU detected (HIP SDK present) but GPU arch could not be read -- falling back to CPU-only PyTorch" "Yellow"
substep " Arch detection requires hipinfo to report gcnArchName. Re-install the HIP SDK if this is unexpected." "Yellow"
}
}
# A pinned gfx*/rocm index skips the auto-reroute above; route it through the ROCm install path
# with the same floor/companions the unpinned AMD path uses (mirrors install.ps1), else the CUDA
# branch installs bare torch and resolves a known-bad wheel for gfx115x/gfx120x/rocm>=7.2.
if ($TorchIndexPinned -and -not $ROCmIndexUrl -and $PinnedTorchIndexUrl) {
$_pinLeaf = Get-TorchIndexLeaf $PinnedTorchIndexUrl
$_pinRocm211 = $false
# Anchor the match ($) so a suffixed custom leaf (rocm7.2-private) falls through to the
# verbatim install instead of being floored by its rocm7.2 prefix.
if ($_pinLeaf -match '^rocm(\d+)\.(\d+)$') {
# Only KNOWN-2.11 rocm (rocm7.2) gets the floor (no speculative floor). Matches
# Test-RocmKnown211Version / _ROCM_KNOWN_TORCH211_VERSIONS.
$_pinRocm211 = Test-RocmKnown211Version -Major ([int]$Matches[1]) -Minor ([int]$Matches[2])
}
# Only the 2.11 gfx arches need the floor; others publish <2.11 and stay bare. Reuse
# Test-RocmGfx211Leaf so this allowlist and the stale-venv check never diverge.
$_pinGfx211 = Test-RocmGfx211Leaf $_pinLeaf
if ($_pinGfx211 -or $_pinRocm211) {
$ROCmIndexUrl = $PinnedTorchIndexUrl
$ROCmTorchSpec = "torch>=2.11.0,<2.12.0"
$ROCmVisionSpec = "torchvision>=0.26.0,<0.27.0"
$ROCmAudioSpec = "torchaudio>=2.11.0,<2.12.0"
substep "pinned ROCm index ($_pinLeaf) -- enforcing $ROCmTorchSpec" "Cyan"
} elseif (Test-PipRocmFamilyLeaf $_pinLeaf) {
# Other gfx / older rocm (<=7.1) ship torch <2.11; route via the ROCm path with
# bare specs. Only EXACT rocm<digits> and gfx* are --index-url families; a suffixed
# leaf stays on the verbatim path. Mirrors install.ps1 / _is_pip_rocm_family_leaf.
$ROCmIndexUrl = $PinnedTorchIndexUrl
$ROCmTorchSpec = "torch"
$ROCmVisionSpec = "torchvision"
$ROCmAudioSpec = "torchaudio"
}
}
$PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
# A full URL pin is used verbatim; a family pin already set $CuTag. A pinned ROCm install
# 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" }
# Declared outside the guard: the bitsandbytes and Triton passes read it from outside too, and
# no-torch mode never reaches the assignment below.
$XpuIndexUrl = $null
if (-not $NoTorchMode) {
# Windows on ARM has win_arm64 torch and torchvision wheels but no torchaudio on any index,
# so every branch below drops it. Ask the interpreter uv resolves for, not
# PROCESSOR_ARCHITECTURE, which describes the host process. Inside the no-torch guard
# because all three uses are, and no-torch installs nothing to skip.
$_setupPlatform = ""
try {
$_setupPlatform = (& python -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
} catch { $_setupPlatform = "" }
$WinArm64NoAudio = ($_setupPlatform -eq "win-arm64")
if ($WinArm64NoAudio) { substep "windows on arm: skipping torchaudio (no win_arm64 wheel upstream)" }
$ROCmCpuFallback = $false
if ($ROCmIndexUrl) {
substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..."
if ($ROCmTorchSpec -ne "torch") {
substep " enforcing $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec (known _grouped_mm bug in older wheels)" "Cyan"
}
# Built above the verbose branch: a splat assigned inside it is unset on the other.
$_rocmTrio = @($ROCmTorchSpec, $ROCmVisionSpec, $ROCmAudioSpec)
if ($WinArm64NoAudio) { $_rocmTrio = @($ROCmTorchSpec, $ROCmVisionSpec) }
if ($script:UnslothVerbose) {
Fast-Install @_rocmTrio --force-reinstall --index-url $ROCmIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install @_rocmTrio --force-reinstall --index-url $ROCmIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
Write-StudioLine "[WARN] AMD ROCm PyTorch install failed -- falling back to CPU" -ForegroundColor Yellow
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Yellow
$ROCmIndexUrl = $null
$ROCmCpuFallback = $true
} else {
# Tell install_python_stack.py to skip probe + suppress manual-install warning.
$env:UNSLOTH_ROCM_TORCH_INSTALLED = "1"
substep "GPU ROCm PyTorch installed ($ROCmGfxArch) -- training and GPU inference will use the GPU" "Cyan"
}
}
# ── Intel XPU (SYCL) ─────────────────────────────────────────────────────────
# Its own index leaf, so it must not fall into the CUDA branch below ("CUDA support (xpu)").
# Reached on an Arc / Data Center host and on an explicit xpu pin.
$XpuCpuFallback = $false
if (-not $ROCmIndexUrl -and $CuTag -eq "xpu") { $XpuIndexUrl = $TorchInstallIndexUrl }
if ($XpuIndexUrl) {
substep "installing PyTorch (Intel XPU)..."
# Bounded like install.ps1's XPU install: the xpu index serves torch past what Unsloth
# supports. The floor is 2.6, not the usual 2.4 -- unsloth/models/_utils.py raises at import
# for an XPU device below that, and an older wheel would be kept as satisfying the range.
$_xpuTrio = @("torch>=2.6,<2.11.0", "torchvision>=0.21,<0.26.0", "torchaudio>=2.6,<2.11.0")
if ($WinArm64NoAudio) { $_xpuTrio = @("torch>=2.6,<2.11.0", "torchvision>=0.21,<0.26.0") }
# Gated like $cpuForce below, NOT unconditional: install.ps1 already installed the XPU trio
# before calling setup, so forcing every pass re-downloads GB there and on every update.
# Force only on a flavor change -- a CPU (or cu*/rocm) wheel satisfies the range, so uv would
# keep it and never migrate. An unreadable flavor forces too, the safe direction.
$xpuForce = @()
if ($installedTorchTag -ne "xpu") { $xpuForce = @("--force-reinstall") }
# A changed pin repairs in place, so the existing +xpu wheel must be replaced too.
if ($script:PinChangedForceReinstall) { $xpuForce = @("--force-reinstall") }
if ($script:UnslothVerbose) {
Fast-Install @_xpuTrio @xpuForce --index-url $XpuIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install @_xpuTrio @xpuForce --index-url $XpuIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
# Transient XPU-index failure: fall back to a CPU base rather than leaving no torch
# (same shape as ROCm above).
Write-StudioLine "[WARN] Intel XPU PyTorch install failed -- falling back to CPU" -ForegroundColor Yellow
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Yellow
$XpuIndexUrl = $null
$XpuCpuFallback = $true
$TorchInstallIndexUrl = "$PyTorchWhlBase/cpu"
} else {
substep "GPU XPU PyTorch installed -- training and GPU inference will use the GPU" "Cyan"
# The wheel being in place does not mean the runtime initializes -- see the helper.
Assert-XpuRuntimeReady -PythonExe "python" | Out-Null
}
}
if (-not $ROCmIndexUrl -and -not $XpuIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback -or $XpuCpuFallback)) {
substep "installing PyTorch (CPU-only)..."
# After an AMD ROCm fallback, force-reinstall so a partial ROCm torch (which satisfies the
# CPU torch>= range) is replaced by the CPU build; skip on a genuine CPU host to stay fast.
# $ROCmCpuFallback matters when a PINNED ROCm index failed ($CuTag is still the rocm leaf).
# Build the array directly: an if-expression collapses @("x") to a scalar @splat would
# enumerate char-by-char.
$cpuForce = @()
if ($ROCmCpuFallback) { $cpuForce = @("--force-reinstall") }
# Same after an Intel XPU fallback: a partial +xpu torch satisfies the CPU torch>= range.
if ($XpuCpuFallback) { $cpuForce = @("--force-reinstall") }
# --force-reinstall on a pin change: a stale +cu / +rocm wheel still satisfies the CPU
# torch>= range, so uv would keep it and only swap companions.
if ($script:PinChangedForceReinstall) { $cpuForce = @("--force-reinstall") }
# A PINNED cpu index installs the bounded trio (parity with _CPU_TORCH_PKG_SPEC): the /cpu
# index serves newer torch, and _ensure_cpu_torch keeps any CPU build, so a bare trio could
# land an unsupported version. Unpinned CPU hosts keep the bare trio (pre-pin behavior).
$cpuTorchSpec = "torch"; $cpuVisionSpec = "torchvision"; $cpuAudioSpec = "torchaudio"
if ($TorchIndexPinned) {
$cpuTorchSpec = "torch>=2.4,<2.12.0"
$cpuVisionSpec = "torchvision>=0.19,<0.27.0"
$cpuAudioSpec = "torchaudio>=2.4,<2.12.0"
}
# Bound an XPU fallback too: this is not the plain CPU box the bare trio was preserved for.
if ($XpuCpuFallback) {
$cpuTorchSpec = "torch>=2.4,<2.12.0"
$cpuVisionSpec = "torchvision>=0.19,<0.27.0"
$cpuAudioSpec = "torchaudio>=2.4,<2.12.0"
}
$_torchTrio = @($cpuTorchSpec, $cpuVisionSpec, $cpuAudioSpec)
if ($WinArm64NoAudio) { $_torchTrio = @($cpuTorchSpec, $cpuVisionSpec) }
if ($script:UnslothVerbose) {
Fast-Install @_torchTrio @cpuForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install @_torchTrio @cpuForce --index-url $TorchInstallIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
Write-StudioLine "[FAILED] PyTorch install failed (exit code $torchInstallExit)" -ForegroundColor Red
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
Exit-SetupFailure "PyTorch installation failed (exit code $torchInstallExit)"
}
} elseif (-not $ROCmIndexUrl -and -not $XpuIndexUrl) {
substep "installing PyTorch with CUDA support ($CuTag)..."
substep "(This download is ~2.8 GB -- may take a few minutes)"
# --force-reinstall on a pin change: an installed cuXXX wheel satisfies the bare torch
# requirement (PEP 440 ignores the +cuXXX tag), so without it a changed CUDA pin (cu126
# -> cu128) never applies.
$cudaForce = @()
if ($script:PinChangedForceReinstall) { $cudaForce = @("--force-reinstall") }
# An unknown-leaf custom pin (/simple, /current) routes here with $CuTag as that leaf. Bound
# the trio like the fresh custom-pin paths so a mirror can't pull an ABI-newer companion
# against the capped torch. Known cu* leaves keep bare specs.
$cudaTorchSpec = "torch"
$cudaVisionSpec = "torchvision"
$cudaAudioSpec = "torchaudio"
if ($TorchIndexPinned -and -not (Test-CudaFamilyLeaf $CuTag)) {
$cudaTorchSpec = "torch>=2.4,<2.11.0"
$cudaVisionSpec = "torchvision>=0.19,<0.26.0"
$cudaAudioSpec = "torchaudio>=2.4,<2.11.0"
}
# A custom pin whose leaf is not cpu (a corporate /simple mirror) lands an ARM64 host
# here, so this branch drops torchaudio too.
$_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec, $cudaAudioSpec)
if ($WinArm64NoAudio) { $_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec) }
if ($script:UnslothVerbose) {
Fast-Install @_cudaTrio @cudaForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install @_cudaTrio @cudaForce --index-url $TorchInstallIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
Write-StudioLine "[FAILED] PyTorch CUDA install failed (exit code $torchInstallExit)" -ForegroundColor Red
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
Exit-SetupFailure "PyTorch CUDA installation failed (exit code $torchInstallExit)"
}
# Install Triton for Windows (enables torch.compile -- without it training can hang)
substep "installing Triton for Windows..."
if ($script:UnslothVerbose) {
Fast-Install "triton-windows<3.7"
$tritonInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install "triton-windows<3.7" | Out-String
$tritonInstallExit = $LASTEXITCODE
}
if ($tritonInstallExit -ne 0) {
substep "Triton install failed -- torch.compile may not work" "Yellow"
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Yellow
} else {
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. install.ps1 no longer starts the generated console
# script at all (it runs the CLI through the venv's python.exe, #8490), so the rename
# would now target a file nothing is holding -- but it was never needed either:
# install.ps1 sets SKIP_STUDIO_BASE=1 (base never reinstalled) and 'studio update'
# goes through uv (--upgrade-package), whose pip fallback no-ops on the
# already-satisfied bare unsloth/unsloth-zoo. Either way unsloth.exe stays.
# Ordered heavy dependency installation -- shared cross-platform script
substep "running ordered dependency installation..."
python "$PSScriptRoot\install_python_stack.py"
$stackExit = $LASTEXITCODE
# ── Intel XPU: bitsandbytes must carry XPU kernels ──
# unsloth/bnb_availability.py binds cgemv_4bit_inference_fp16/bf16 for device_type "xpu" and only
# bitsandbytes' XPU library exports those, so a wheel without it turns 4-bit QLoRA off. 0.48.2 is
# the first win_amd64 build carrying that library, but the floor is 0.50.0, the AMD paths' floor:
# <=0.49.2 NaNs at 4-bit decode on AMD, and an Arc card can sit next to a Radeon. unsloth's own
# floor (>=0.45.5) lets a MIGRATED venv keep its old wheel while the stack above upgrades unsloth
# alone, so run after it for the last word. --no-deps (torch and numpy are in), and never the
# curated unsloth[intel-gpu-torch*] extra: it pins torch to one +xpu wheel URL, unpinning the
# trio above, and carries a preview bitsandbytes wheel uv refuses. $XpuIndexUrl is the "ended up
# on XPU" gate. Must stay ABOVE the $ErrorActionPreference restore below: Fast-Install needs
# EAP=Continue or PS 5.1 turns pip's stderr into a terminating error. Best-effort.
if ($stackExit -eq 0 -and $XpuIndexUrl) {
substep "installing bitsandbytes with Intel XPU kernels..."
if ($script:UnslothVerbose) {
Fast-Install --no-deps "bitsandbytes>=0.50.0" | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$bnbXpuExit = $LASTEXITCODE
$bnbOutput = ""
} else {
$bnbOutput = Fast-Install --no-deps "bitsandbytes>=0.50.0" | Out-String
$bnbXpuExit = $LASTEXITCODE
}
if ($bnbXpuExit -ne 0) {
substep "[WARN] could not install an XPU-capable bitsandbytes (exit $bnbXpuExit); 4-bit QLoRA may be unavailable." "Yellow"
Write-StudioLine (Redact-InstallOutput $bnbOutput) -ForegroundColor Yellow
}
}
# ── Intel XPU: triton-windows must not shadow torch's XPU triton ──
# triton-windows and torch's XPU triton BOTH own the top-level `triton` package (80 to 160 shared
# paths, __init__.py and _C/libtriton.pyd among them), so a cu*-to-xpu pin repair leaves the CUDA
# build shadowing the XPU one. AFTER the stack, since unsloth declares triton-windows a win32
# core dependency install reinstalls anything removed earlier. Uninstall always paired with a
# reinstall: removing one drops the shared paths the other overwrote. The spec is read from the
# installed torch (renamed pytorch-triton-xpu -> triton-xpu in torch 2.10).
if ($stackExit -eq 0 -and $XpuIndexUrl) {
# One -c line, so no double quotes (Invoke-BoundedPythonProbe wraps $Code in them).
$_tritonCode = "import importlib.metadata as m; " +
"print('TRITONWIN=' + next((d.version for d in m.distributions() " +
"if (d.metadata['Name'] or '').lower().replace('_','-') == 'triton-windows'), '')); " +
"print('TRITONXPU=' + next((r.split(';')[0].strip() " +
"for r in (m.requires('torch') or []) if 'triton' in r.lower()), ''))"
$_tritonProbe = Invoke-BoundedPythonProbe -PythonExe "python" -Code $_tritonCode
# Line-anchored like the other probes so a stdout banner ahead of the answer hides nothing.
$_tritonWinVer = if ($_tritonProbe.Ok -and $_tritonProbe.Output -match '(?m)^TRITONWIN=(\S+)\s*$') { $Matches[1] } else { "" }
$_tritonXpuSpec = if ($_tritonProbe.Ok -and $_tritonProbe.Output -match '(?m)^TRITONXPU=(\S+)\s*$') { $Matches[1] } else { "" }
# The spec must itself be an XPU triton (pytorch-triton-xpu / triton-xpu); anything else means
# torch is not the +xpu wheel this branch assumes.
if ($_tritonWinVer -and $_tritonXpuSpec -match '(?i)xpu') {
substep "replacing triton-windows $_tritonWinVer with $_tritonXpuSpec (Intel XPU)..." "Cyan"
# install_manifest.manifest_path() is venv_root()/MANIFEST_NAME and venv_root() is
# sys.prefix, which is $VenvDir here -- the same join Get-PersistedNoTorch does. Assembled
# rather than asked for: a subprocess to learn a constant can hang or fail in a way
# indistinguishable from "there is no manifest", which reopens the window this closes.
# test_intel_registry_fallback.ps1 asserts this literal still matches MANIFEST_NAME.
$_manifestPath = Join-Path $VenvDir "unsloth_install_manifest.json"
# Fetch, THEN uninstall, THEN install the file. The uninstall cannot go last -- it drops
# the paths in triton-windows' OWN record, which are the shared ones -- so pre-fetching
# keeps a dead mirror from stranding the venv between the two steps. uv has no
# `pip download`, hence Fast-Download.
$_tritonTmp = Join-Path ([System.IO.Path]::GetTempPath()) "unsloth_triton_xpu_$([guid]::NewGuid().ToString('N').Substring(0,8))"
try {
New-Item -ItemType Directory -Force -Path $_tritonTmp -ErrorAction SilentlyContinue | Out-Null
if ($script:UnslothVerbose) {
Fast-Download --no-deps --only-binary=:all: -d $_tritonTmp $_tritonXpuSpec --index-url $XpuIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$tritonDlExit = $LASTEXITCODE
$tritonDlOutput = ""
} else {
$tritonDlOutput = Fast-Download --no-deps --only-binary=:all: -d $_tritonTmp $_tritonXpuSpec --index-url $XpuIndexUrl | Out-String
$tritonDlExit = $LASTEXITCODE
}
# The exit code alone is not enough: no wheel on disk means nothing to install from.
$_tritonWheel = @(Get-ChildItem -LiteralPath $_tritonTmp -Filter "*.whl" -ErrorAction SilentlyContinue) | Select-Object -First 1 -ExpandProperty FullName
if ($tritonDlExit -ne 0 -or -not $_tritonWheel) {
substep "[WARN] could not fetch $_tritonXpuSpec (exit $tritonDlExit); triton-windows $_tritonWinVer left in place -- it still shadows torch XPU triton, so torch.compile will not use the XPU." "Yellow"
Write-StudioLine (Redact-InstallOutput $tritonDlOutput) -ForegroundColor Yellow
} else {
# From here to the reinstall below is a window where a kill leaves a venv with
# no triton that the next update still reads as complete and fast-paths past.
# MOVE the manifest into the wheel's temp dir for the swap, never read and
# rewrite it: PS 5.1's Set-Content defaults to ANSI and its -Encoding utf8 emits
# a BOM read_manifest's json.load rejects, so either corrupts a manifest with a
# non-ASCII path. The finally below deletes the dir, so an unrestored manifest
# stays gone -- which is the truth while the venv has no triton.
$_manifestHeld = $null
$_manifestBlocked = $false
$_uninstallExit = 0
if ($_manifestPath -and (Test-Path -LiteralPath $_manifestPath -PathType Leaf)) {
$_held = Join-Path $_tritonTmp "held_manifest.json"
try {
Move-Item -LiteralPath $_manifestPath -Destination $_held -Force -ErrorAction Stop
# Move-Item across volumes is a copy plus a delete and reports success
# even when only the delete fails, leaving the original in place. The
# manifest must be GONE for the swap, so confirm rather than trust.
if (Test-Path -LiteralPath $_manifestPath -PathType Leaf) {
Remove-Item -LiteralPath $_held -Force -ErrorAction SilentlyContinue
$_manifestBlocked = $true
} else {
$_manifestHeld = $_held
}
} catch { $_manifestBlocked = $true }
}
if ($_manifestBlocked) {
# A manifest that will not move (locked, read-only) would stay valid right
# through the destructive window, so do not open one. Leaving triton-windows
# costs torch.compile on the XPU, which the next run can still fix.
substep "[WARN] could not set the install manifest aside; triton-windows $_tritonWinVer left in place -- it still shadows torch XPU triton, so torch.compile will not use the XPU." "Yellow"
} else {
Fast-Uninstall "triton-windows" | Out-Null
$_uninstallExit = $LASTEXITCODE
}
# A triton-windows that would not uninstall (Studio running and holding
# _C/libtriton.pyd open) still shadows the XPU triton, so installing over it
# achieves nothing and would restore the manifest onto an unchanged venv.
if (-not $_manifestBlocked -and $_uninstallExit -ne 0) {
substep "[WARN] could not remove triton-windows $_tritonWinVer (exit $_uninstallExit); it still shadows torch XPU triton, so torch.compile will not use the XPU." "Yellow"
if ($_manifestHeld) {
try { Move-Item -LiteralPath $_manifestHeld -Destination $_manifestPath -Force -ErrorAction Stop } catch {}
}
} elseif (-not $_manifestBlocked) {
if ($script:UnslothVerbose) {
Fast-Install --force-reinstall --no-deps $_tritonWheel | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$tritonXpuExit = $LASTEXITCODE
$tritonOutput = ""
} else {
$tritonOutput = Fast-Install --force-reinstall --no-deps $_tritonWheel | Out-String
$tritonXpuExit = $LASTEXITCODE
}
$_tritonPresent = ($tritonXpuExit -eq 0)
if ($tritonXpuExit -ne 0) {
# Off the network by now, so this is disk/permissions. triton-windows is
# already gone and took the shared paths with it, so put SOME working
# triton back rather than leave the venv unable to import one.
Fast-Install --force-reinstall --no-deps "triton-windows<3.7" | Out-Null
$tritonBackExit = $LASTEXITCODE
$_tritonPresent = ($tritonBackExit -eq 0)
Write-StudioLine (Redact-InstallOutput $tritonOutput) -ForegroundColor Yellow
if ($tritonBackExit -eq 0) {
substep "[WARN] could not install $_tritonXpuSpec (exit $tritonXpuExit); restored triton-windows, so triton still imports -- but torch.compile will not use the XPU." "Yellow"
} else {
# Redacted: a mirror pin can carry a token, and this is the only place
# setup.ps1 shows an index URL. A tokenless URL survives verbatim.
$_tritonRepairUrl = Redact-InstallOutput $XpuIndexUrl
Write-StudioLine "[ERROR] triton-windows was removed and neither triton would reinstall -- torch.compile is broken." -ForegroundColor Red
Write-StudioLine " Repair with: python -m pip install --force-reinstall --no-deps $_tritonXpuSpec --index-url $_tritonRepairUrl" -ForegroundColor Red
# Printing alone left $stackExit at 0, so setup reported success and
# install.ps1 COMMITTED this venv over its rollback copy. It has no
# importable triton at all; the caller must not accept it.
$stackExit = $tritonBackExit
}
}
# Moved back, never rewritten, and only once a triton is importable again. If
# neither would reinstall the venv really is incomplete, and losing the
# manifest with the temp dir is what makes the next update repair it.
if ($_manifestHeld -and $_tritonPresent) {
try { Move-Item -LiteralPath $_manifestHeld -Destination $_manifestPath -Force -ErrorAction Stop } catch {}
# The finally below deletes the held copy either way, so an unreported
# failure loses the manifest silently and the next run does a full
# dependency pass with nothing on screen explaining why.
if (-not (Test-Path -LiteralPath $_manifestPath -PathType Leaf)) {
substep "[WARN] could not restore the install manifest; the next update will re-run the dependency pass." "Yellow"
}
}
}
}
} finally {
# The wheel is ~300 MB, so it never outlives the install.
Remove-Item -Recurse -Force -LiteralPath $_tritonTmp -ErrorAction SilentlyContinue
}
}
}
# Restore ErrorActionPreference after pip/python work
$ErrorActionPreference = $prevEAP
if ($stackExit -ne 0) {
Write-StudioLine "[FAILED] Python dependency installation failed (exit code $stackExit)" -ForegroundColor Red
Write-StudioLine " Re-run the installer or check the error above for details." -ForegroundColor Red
Exit-SetupFailure "Python dependency installation failed (exit code $stackExit)"
}
} else {
step "python" "dependencies up to date"
# Restore ErrorActionPreference (was lowered for pip/python section)
$ErrorActionPreference = $prevEAP
}
# ── Pre-install transformers 5.x into .venv_t5_530/, .venv_t5_550/, and .venv_t5_510/ ──
# Runs outside the deps fast-path gate so that upgrades from the legacy
# single .venv_t5 are always migrated to the tiered layout.
# T5 sidecar venvs live under the resolved $StudioHome so custom installs are self-contained.
$VenvT5_530Dir = Join-Path $StudioHome ".venv_t5_530"
$VenvT5_550Dir = Join-Path $StudioHome ".venv_t5_550"
$VenvT5_510Dir = Join-Path $StudioHome ".venv_t5_510"
$VenvT5Legacy = Join-Path $StudioHome ".venv_t5"
function Test-TargetPackageVersion {
param(
[Parameter(Mandatory = $true)][string]$TargetDir,
[Parameter(Mandatory = $true)][string]$PackageName,
[Parameter(Mandatory = $true)][string]$ExpectedVersion
)
if (-not (Test-Path -LiteralPath $TargetDir -PathType Container)) { return $false }
$packageNorm = $PackageName.Replace("-", "_")
foreach ($pattern in @("$packageNorm-*.dist-info", "$PackageName-*.dist-info")) {
foreach ($distInfo in @(Get-ChildItem -LiteralPath $TargetDir -Directory -Filter $pattern -ErrorAction SilentlyContinue)) {
$metadata = Join-Path $distInfo.FullName "METADATA"
if (-not (Test-Path -LiteralPath $metadata -PathType Leaf)) { continue }
foreach ($line in (Get-Content -LiteralPath $metadata -ErrorAction SilentlyContinue)) {
if ($line -eq "Version: $ExpectedVersion") { return $true }
}
}
}
return $false
}
$_NeedT5Install = $false
if (Test-Path -LiteralPath $VenvT5Legacy) {
Assert-StudioOwnedOrAbsent -Path $VenvT5Legacy -Label "legacy transformers sidecar venv"
Remove-Item -LiteralPath $VenvT5Legacy -Recurse -Force
$_NeedT5Install = $true
}
if (-not (Test-Path -LiteralPath $VenvT5_530Dir)) { $_NeedT5Install = $true }
if (-not (Test-Path -LiteralPath $VenvT5_550Dir)) { $_NeedT5Install = $true }
if (-not (Test-Path -LiteralPath $VenvT5_510Dir)) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_530Dir -PackageName "transformers" -ExpectedVersion "5.3.0")) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_550Dir -PackageName "transformers" -ExpectedVersion "5.5.0")) { $_NeedT5Install = $true }
if (-not (Test-TargetPackageVersion -TargetDir $VenvT5_510Dir -PackageName "transformers" -ExpectedVersion "5.10.2")) { $_NeedT5Install = $true }
# Also reinstall when python deps were updated
if (-not $SkipPythonDeps) { $_NeedT5Install = $true }
if ($_NeedT5Install) {
Write-StudioLine ""
$prevEAP_t5 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# --- .venv_t5_530 (transformers 5.3.0) ---
substep "pre-installing transformers 5.3.0 for newer model support..."
Assert-StudioOwnedOrAbsent -Path $VenvT5_530Dir -Label "transformers 5.3 sidecar venv"
if (Test-Path -LiteralPath $VenvT5_530Dir) { Remove-Item -LiteralPath $VenvT5_530Dir -Recurse -Force }
[System.IO.Directory]::CreateDirectory($VenvT5_530Dir) | Out-Null
Mark-StudioOwned -Path $VenvT5_530Dir
foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_530Dir --no-deps $pkg
$t5PkgExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_530Dir --no-deps $pkg | Out-String
$t5PkgExit = $LASTEXITCODE
}
if ($t5PkgExit -ne 0) {
Write-StudioLine "[FAIL] Could not install $pkg into .venv_t5_530/" -ForegroundColor Red
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
Exit-SetupFailure "Could not install $pkg into .venv_t5_530"
}
}
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_530Dir --no-deps tiktoken
$tiktokenInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_530Dir --no-deps tiktoken | Out-String
$tiktokenInstallExit = $LASTEXITCODE
}
if ($tiktokenInstallExit -ne 0) {
substep "Could not install tiktoken into .venv_t5_530/ -- Qwen tokenizers may fail" "Yellow"
}
step "transformers" "5.3.0 pre-installed"
# --- .venv_t5_550 (transformers 5.5.0) ---
substep "pre-installing transformers 5.5.0 for Gemma 4 support..."
Assert-StudioOwnedOrAbsent -Path $VenvT5_550Dir -Label "transformers 5.5 sidecar venv"
if (Test-Path -LiteralPath $VenvT5_550Dir) { Remove-Item -LiteralPath $VenvT5_550Dir -Recurse -Force }
[System.IO.Directory]::CreateDirectory($VenvT5_550Dir) | Out-Null
Mark-StudioOwned -Path $VenvT5_550Dir
foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_550Dir --no-deps $pkg
$t5PkgExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_550Dir --no-deps $pkg | Out-String
$t5PkgExit = $LASTEXITCODE
}
if ($t5PkgExit -ne 0) {
Write-StudioLine "[FAIL] Could not install $pkg into .venv_t5_550/" -ForegroundColor Red
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
Exit-SetupFailure "Could not install $pkg into .venv_t5_550"
}
}
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_550Dir --no-deps tiktoken
$tiktokenInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_550Dir --no-deps tiktoken | Out-String
$tiktokenInstallExit = $LASTEXITCODE
}
if ($tiktokenInstallExit -ne 0) {
substep "Could not install tiktoken into .venv_t5_550/ -- Qwen tokenizers may fail" "Yellow"
}
step "transformers" "5.5.0 pre-installed"
# --- .venv_t5_510 (transformers 5.10.2) ---
substep "pre-installing transformers 5.10.2 for Gemma 4 Unified support..."
Assert-StudioOwnedOrAbsent -Path $VenvT5_510Dir -Label "transformers 5.10 sidecar venv"
if (Test-Path -LiteralPath $VenvT5_510Dir) { Remove-Item -LiteralPath $VenvT5_510Dir -Recurse -Force }
[System.IO.Directory]::CreateDirectory($VenvT5_510Dir) | Out-Null
Mark-StudioOwned -Path $VenvT5_510Dir
foreach ($pkg in @("transformers==5.10.2", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_510Dir --no-deps $pkg
$t5PkgExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_510Dir --no-deps $pkg | Out-String
$t5PkgExit = $LASTEXITCODE
}
if ($t5PkgExit -ne 0) {
Write-StudioLine "[FAIL] Could not install $pkg into .venv_t5_510/" -ForegroundColor Red
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
Exit-SetupFailure "Could not install $pkg into .venv_t5_510"
}
}
if ($script:UnslothVerbose) {
Fast-Install --target $VenvT5_510Dir --no-deps tiktoken
$tiktokenInstallExit = $LASTEXITCODE
$output = ""
} else {
$output = Fast-Install --target $VenvT5_510Dir --no-deps tiktoken | Out-String
$tiktokenInstallExit = $LASTEXITCODE
}
if ($tiktokenInstallExit -ne 0) {
substep "Could not install tiktoken into .venv_t5_510/ -- Qwen tokenizers may fail" "Yellow"
}
$ErrorActionPreference = $prevEAP_t5
step "transformers" "5.10.2 pre-installed"
} # end $_NeedT5Install
# ==========================================================================
# PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build
# ==========================================================================
# Reuse the managed path resolved and preflighted before phase 1.
if (-not (Test-Path -LiteralPath $UnslothHome)) { [System.IO.Directory]::CreateDirectory($UnslothHome) | Out-Null }
$NeedLlamaSourceBuild = $false
$SkipPrebuiltInstall = $false
$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag }
# Every host installs the fork's app-* prebuilts now: GPU Windows (CUDA / ROCm)
# already did, and the fork now also ships the CPU bundles for Windows x64 and
# arm64 (windows-cpu / windows-arm64). ggml-org artifacts are no longer used by
# default. Mirrors setup.sh's routing.
$HelperReleaseRepo = "unslothai/llama.cpp"
$LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" }
$LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
$LlamaSource = $DefaultLlamaSource
if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) }
$ResolvedSourceUrl = $LlamaSource
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
$ResolvedLlamaTag = $RequestedLlamaTag
$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()
$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()
$explicitLlamaSourceBackend = $null
if (-not $IsMacOS) {
if ($sourceLlamaBackend -in @("cpu", "cuda", "vulkan", "hip", "rocm")) {
$explicitLlamaSourceBackend = if ($sourceLlamaBackend -eq "hip") { "rocm" } else { $sourceLlamaBackend }
} elseif ($sourceLlamaBackend -ne "auto" -and $sourceLegacyForceVulkan -in @("1", "true", "yes", "on")) {
$explicitLlamaSourceBackend = "vulkan"
}
}
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
$NeedLlamaSourceBuild = $true
$SkipPrebuiltInstall = $true
}
function Invoke-LlamaHelper {
param(
[string[]]$Arguments,
[string]$StderrPath = $null
)
$previousErrorActionPreference = $ErrorActionPreference
$previousNativeErrorPreference = $null
$restoreNativeErrorPreference = $false
$ErrorActionPreference = "Continue"
if ($PSVersionTable.PSVersion.Major -ge 7) {
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
$restoreNativeErrorPreference = $true
}
try {
# Capture all output (stdout + stderr) so that PowerShell does not
# convert stderr lines into visible ErrorRecord objects. Separate
# stdout from stderr afterwards.
$allOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @Arguments 2>&1
$exitCode = $LASTEXITCODE
$stdoutLines = @()
$stderrLines = @()
foreach ($line in $allOutput) {
if ($line -is [System.Management.Automation.ErrorRecord]) {
$stderrLines += $line.ToString()
} else {
$stdoutLines += $line
}
}
if ($StderrPath -and $stderrLines.Count -gt 0) {
$stderrLines | Out-File -FilePath $StderrPath -Encoding utf8
}
return [pscustomobject]@{
Output = $stdoutLines
ExitCode = $exitCode
}
} finally {
$ErrorActionPreference = $previousErrorActionPreference
if ($restoreNativeErrorPreference) {
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
}
}
}
if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") {
step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow"
$NeedLlamaSourceBuild = $true
$SkipPrebuiltInstall = $true
}
if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int]$LlamaPrForce -gt 0) {
$LlamaPr = $LlamaPrForce
step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow"
}
if ($LlamaPr) {
if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) {
Write-StudioLine "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red
Exit-SetupFailure "UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number"
}
step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow"
$ResolvedLlamaTag = "pr-$LlamaPr"
$ResolvedSourceUrl = $LlamaSource
$ResolvedSourceRef = "pr-$LlamaPr"
$ResolvedSourceRefKind = "pull"
$NeedLlamaSourceBuild = $true
$SkipPrebuiltInstall = $true
}
$LocalLlamaCppLinked = $false
$LocalLlamaCppSrc = $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR
if ($LocalLlamaCppSrc) {
# Unreadable is not missing: reporting "does not exist" would send the user
# looking for the wrong problem.
$localSrcState = Get-PathState -Path $LocalLlamaCppSrc -PathType Container
if ($localSrcState -eq "Denied") {
Exit-PathAccessDenied -Path $LocalLlamaCppSrc -Label "the UNSLOTH_LOCAL_LLAMA_CPP_DIR directory" -UserSupplied
}
if ($localSrcState -ne "Present") {
step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $LocalLlamaCppSrc" "Red"
Exit-SetupFailure "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $LocalLlamaCppSrc"
}
$ResolvedLocal = (Resolve-Path -LiteralPath $LocalLlamaCppSrc).Path
# Reusing a local dir disables both the prebuilt download and the source
# build, so a runnable llama-server.exe must already be present. Accept any
# layout LlamaCppBackend._layout_candidates() resolves (root-level, build\bin,
# or build\bin\Release) so the flag never rejects a tree Unsloth could run.
$LocalLlamaServerFound = $false
$LocalIsCanonical = ($ResolvedLocal -eq $LlamaCppDir)
foreach ($_cand in @(
(Join-Path $ResolvedLocal "llama-server.exe"),
(Join-Path $ResolvedLocal "build\bin\llama-server.exe"),
(Join-Path $ResolvedLocal "build\bin\Release\llama-server.exe"))) {
# Denied must not read as "nothing built here": the canonical branch
# below would then hand the tree to the prebuilt installer, which
# replaces the very build this flag asked to reuse.
$candState = Get-PathState -Path $_cand
if ($candState -eq "Denied") {
# -UserSupplied even when this is the canonical location: the
# override says the tree is the user's build, so never advise
# deleting it, managed path or not.
Exit-PathAccessDenied -Path $ResolvedLocal -Label "the UNSLOTH_LOCAL_LLAMA_CPP_DIR build" -UserSupplied
}
if ($candState -eq "Present") { $LocalLlamaServerFound = $true; break }
}
if ($LocalIsCanonical) {
# Points at the canonical install location itself: never delete-then-link
# onto itself. Reuse an existing build here (skip prebuilt + source) so the
# staged prebuilt installer can't replace a build the user asked to reuse;
# if nothing is built yet, fall through to the normal install.
if ($LocalLlamaServerFound) {
substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR is the canonical install location and already holds a build; reusing it" "Yellow"
$LocalLlamaCppLinked = $true
$NeedLlamaSourceBuild = $false
} else {
substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location with nothing built there yet; running the normal install" "Yellow"
}
} else {
# Fail clearly rather than junction an unbuilt or wrong-platform checkout
# and leave Unsloth with no usable binary.
if (-not $LocalLlamaServerFound) {
step "llama.cpp" "no llama-server.exe under $ResolvedLocal (looked for .\llama-server.exe, .\build\bin and .\build\bin\Release) -- build llama.cpp there first, or drop --with-llama-cpp-dir" "Red"
Exit-SetupFailure "No llama-server.exe was found under $ResolvedLocal"
}
# If the target is already a junction/symlink (e.g. a previous
# --with-llama-cpp-dir run), delete only the link via DirectoryInfo.Delete().
# Remove-Item -Recurse -Force on a reparse point can traverse the link and
# wipe the user's real llama.cpp directory on PowerShell 5.1. Dropping the
# stale link here also keeps the custom-home ownership check below idempotent.
# Use Get-Item -Force (not Test-Path): a *broken* junction whose target was
# moved/deleted makes Test-Path return false, which would leave the dangling
# link in place and make mklink below fail; Get-Item still resolves it so we
# can remove it and relink to a new valid directory.
$existing = Get-Item -LiteralPath $LlamaCppDir -Force -ErrorAction SilentlyContinue
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
# A link reads Present, so the probe below cannot cover a denied
# unlink; report it here rather than terminate on the raw throw.
try { $existing.Delete() }
catch {
if (Test-AccessDeniedError $_) { Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" }
throw
}
}
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
}
# The destination is about to be deleted and replaced, so a denial here
# must stop rather than throw raw: under a default home nothing above
# has probed it three-state.
$destState = Get-PathState -Path $LlamaCppDir
if ($destState -eq "Denied") {
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install"
}
if ($destState -eq "Present") {
Remove-Item -Recurse -Force -LiteralPath $LlamaCppDir -ErrorAction SilentlyContinue
# A locked/in-use tree can silently survive removal (SilentlyContinue
# masks it). Don't then junction/copy over a half-present dir; mirror the
# prebuilt path's active-process handling and stop with a clear message.
# Denied counts as surviving: unreadable is not gone.
if ((Get-PathState -Path $LlamaCppDir) -ne "Absent") {
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
substep "Close Unsloth or other llama.cpp users and retry" "Yellow"
Exit-SetupFailure "llama.cpp install is blocked by an active llama.cpp process" 3
}
}
cmd /c "mklink /J `"$LlamaCppDir`" `"$ResolvedLocal`"" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
substep "Could not create directory junction; copying instead..." "Yellow"
Copy-Item -Recurse -LiteralPath $ResolvedLocal -Destination $LlamaCppDir
Remove-AgentInstructionFiles -Roots @($LlamaCppDir)
}
Write-StudioLine ""
step "llama.cpp" "linked local directory: $ResolvedLocal"
$LocalLlamaCppLinked = $true
$NeedLlamaSourceBuild = $false
}
}
if ($LocalLlamaCppLinked) {
# local directory linked above; skip prebuilt install
} elseif ($explicitLlamaSourceBackend -and $NeedLlamaSourceBuild) {
Write-StudioLine ""
step "llama.cpp" "$explicitLlamaSourceBackend was explicitly requested, but this installation requires a source build" "Red"
substep "Explicit backend selection requires a matching prebuilt bundle; allow prebuilts or unset UNSLOTH_LLAMA_CPP_BACKEND" "Yellow"
Exit-SetupFailure "$explicitLlamaSourceBackend was explicitly requested, but this installation requires a source build. Explicit backend selection requires a matching prebuilt bundle."
} elseif ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
Write-StudioLine ""
substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow"
$NeedLlamaSourceBuild = $true
} elseif ($SkipPrebuiltInstall) {
Write-StudioLine ""
substep "Skipping prebuilt install -- falling back to source build" "Yellow"
} else {
Write-StudioLine ""
# Keep this late guard as defense in depth before the prebuilt installer.
$llamaDirState = Get-LlamaCppInstallReadState -Path $LlamaCppDir
if ($llamaDirState -eq "Denied") {
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
}
if ($llamaDirState -eq "Readable") {
substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement"
# If the existing install is the wrong kind (e.g. windows-cpu on a ROCm
# machine that should have windows-rocm), remove it so the installer is
# forced to download the correct variant rather than skipping on tag match.
$existingMetaPath = Join-Path $LlamaCppDir "UNSLOTH_PREBUILT_INFO.json"
# Readable state leaves only marker presence to decide here.
if (Test-PathQuiet -Path $existingMetaPath -PathType Leaf) {
try {
$existingMeta = Get-Content -LiteralPath $existingMetaPath -Raw | ConvertFrom-Json
$existingKind = $existingMeta.install_kind
# A ROCm host may legitimately carry the fork's windows-rocm bundle
# or the upstream windows-hip fallback, so accept either and never
# treat a valid ROCm install as mismatched. A name-inferred gfx
# arch (Adrenalin-only, no confirmed runtime) still counts as
# ROCm-capable -- the ROCm prebuilt bundles its own runtime,
# mirroring the --rocm-gfx forward below. The CPU branch covers both
# the x64 windows-cpu and arm64 windows-arm64 bundles (Windows arm64
# has no GPU prebuilt). NOTE: this block is currently inert --
# write_prebuilt_metadata does not persist an install_kind key, so
# $existingKind is always null; keep $expectedKinds in sync with the
# kinds install_llama_prebuilt.py installs before relying on it.
$expectedKinds = if ($HasROCm -or $script:ROCmGfxArch) { @("windows-rocm", "windows-hip") } elseif ($HasNvidiaSmi) { @("windows-cuda") } else { @("windows-cpu", "windows-arm64") }
if ($existingKind -and ($existingKind -notin $expectedKinds)) {
substep "Removing mismatched llama.cpp install (found '$existingKind', need one of: $($expectedKinds -join ', '))..."
Remove-Item -Recurse -Force -LiteralPath $LlamaCppDir -ErrorAction SilentlyContinue
}
} catch {
# unreadable metadata -- let the installer handle it
}
}
}
substep "installing prebuilt llama.cpp bundle (preferred path)..."
# why: install_llama_prebuilt.py uses os.replace(), which would displace
# an unrelated $env:UNSLOTH_STUDIO_HOME\llama.cpp before the source-build
# ownership check below ever runs.
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
}
$prebuiltArgs = @(
"$PSScriptRoot\install_llama_prebuilt.py",
"--install-dir", $LlamaCppDir,
"--llama-tag", $RequestedLlamaTag,
"--published-repo", $HelperReleaseRepo
)
if ($HasROCm) {
$prebuiltArgs += "--has-rocm"
}
# Forward the resolved gfx arch so the per-gfx ROCm prebuilt is picked even
# when the installer's probe can't confirm the runtime (amd-smi-only /
# Adrenalin-only, name-inferred arch). --rocm-gfx is authoritative and
# implies ROCm in install_llama_prebuilt.py, so the GPU prebuilt is selected
# even with $HasROCm false. Gating on $HasROCm gave Strix Halo / 8060S CPU.
if ($script:ROCmGfxArch) {
$prebuiltArgs += @("--rocm-gfx", $script:ROCmGfxArch)
}
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) {
$prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG)
}
# Reporting only: the installer reads UNSLOTH_LLAMA_CPP_BACKEND itself, and
# it is also the only side that can see a choice recorded in the install
# marker, so forwarding a second copy from here could only ever disagree
# with it. The override does not change the torch backend.
$llamaBackend = $sourceLlamaBackend
$windowsArm64 = (
$env:OS -eq "Windows_NT" -and
(
"$($env:PROCESSOR_ARCHITECTURE)".ToUpperInvariant() -eq "ARM64" -or
"$($env:PROCESSOR_ARCHITEW6432)".ToUpperInvariant() -eq "ARM64"
)
)
if ($llamaBackend -eq "vulkan" -or $explicitLlamaSourceBackend -eq "vulkan") {
if ($IsMacOS) {
Write-StudioLine "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow
} elseif ($windowsArm64) {
throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_CPP_BACKEND / UNSLOTH_FORCE_VULKAN or compile llama.cpp from source."
} else {
Write-StudioLine " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan
}
} elseif ($llamaBackend -and $llamaBackend -notin @("auto", "cpu", "cuda", "hip", "rocm")) {
Write-StudioLine "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$llamaBackend' (expected 'auto', 'cpu', 'cuda', 'vulkan', 'hip', or 'rocm')" -ForegroundColor Yellow
}
$prevEAPPrebuilt = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$previousNativeErrorPreference = $null
$restoreNativeErrorPreference = $false
if ($PSVersionTable.PSVersion.Major -ge 7) {
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
$restoreNativeErrorPreference = $true
}
try {
if ($script:UnslothVerbose) {
# Show live output in verbose mode while still capturing for error log
$prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log"
& python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host
$prebuiltExit = $LASTEXITCODE
$prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" }
Remove-Item $prebuiltLog -ErrorAction SilentlyContinue
} else {
$prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String
$prebuiltExit = $LASTEXITCODE
}
} finally {
if ($restoreNativeErrorPreference) {
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
}
}
$ErrorActionPreference = $prevEAPPrebuilt
if ($prebuiltExit -eq 0) {
if ($prebuiltOutput -match "already matches") {
step "llama.cpp" "prebuilt up to date and validated"
} else {
step "llama.cpp" "prebuilt installed and validated"
}
if ($StudioHomeIsCustom -and (Test-Path -LiteralPath $LlamaCppDir -PathType Container)) {
Mark-StudioOwned -Path $LlamaCppDir
}
$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir
if ($installedRelease) {
substep $installedRelease
}
} elseif ($prebuiltExit -eq 3) {
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
Write-LlamaFailureLog -Output $prebuiltOutput
if (Test-Path -LiteralPath $LlamaCppDir) {
substep "Existing install was restored" "Yellow"
}
substep "Close Unsloth or other llama.cpp users and retry" "Yellow"
Exit-SetupFailure "llama.cpp install is blocked by an active llama.cpp process" 3
} elseif ($prebuiltExit -eq 4) {
step "llama.cpp" "not enough disk space to install llama.cpp" "Yellow"
Write-LlamaFailureLog -Output $prebuiltOutput
substep "Free up disk or move UNSLOTH_STUDIO_HOME/TEMP to a larger volume, then re-run" "Yellow"
$PreservedLlamaServerFound = $false
foreach ($_cand in @(
(Join-Path $LlamaCppDir "llama-server.exe"),
(Join-Path $LlamaCppDir "build\bin\llama-server.exe"),
(Join-Path $LlamaCppDir "build\bin\Release\llama-server.exe"))) {
if (Test-PathQuiet $_cand) { $PreservedLlamaServerFound = $true; break }
}
if (-not $PreservedLlamaServerFound) { $script:LlamaCppDegraded = $true }
# A preserved server may not satisfy an explicit backend request, and
# it leaves LlamaCppDegraded false. Never report success on an
# unverified backend after the requested replacement ran out of space.
if ($explicitLlamaSourceBackend) {
step "llama.cpp" "$explicitLlamaSourceBackend was explicitly requested, so the installer will not keep an unverified existing backend" "Red"
Exit-SetupFailure "$explicitLlamaSourceBackend was explicitly requested, so the installer will not keep an unverified existing llama.cpp backend."
}
} elseif ($prebuiltExit -eq 5) {
step "llama.cpp" "selected backend could not be installed" "Red"
Write-LlamaFailureLog -Output $prebuiltOutput
if (Test-Path -LiteralPath $LlamaCppDir) {
substep "Existing install was restored" "Yellow"
}
substep "Check the error above, choose another backend, or retry" "Yellow"
Exit-SetupFailure "The selected llama.cpp backend could not be installed, so the installer will not substitute a different source backend."
} elseif ($prebuiltExit -eq 2) {
step "llama.cpp" "prebuilt install failed" "Yellow"
Write-LlamaFailureLog -Output $prebuiltOutput
if (Test-Path -LiteralPath $LlamaCppDir) {
substep "Prebuilt update failed; existing install was restored or cleaned before source build fallback" "Yellow"
}
# Exit 2 means no concrete backend was in play: a request the installer
# could not honour -- named here or recorded in the install marker,
# which this script cannot see -- exits 5 above instead.
substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow"
$NeedLlamaSourceBuild = $true
} else {
step "llama.cpp" "prebuilt helper failed unexpectedly" "Red"
Write-LlamaFailureLog -Output $prebuiltOutput
if (Test-Path -LiteralPath $LlamaCppDir) {
substep "Existing install was restored or left unchanged" "Yellow"
}
substep "Source build was not started because it cannot repair an unexpected helper or permissions error" "Yellow"
Exit-SetupFailure "llama.cpp prebuilt helper failed unexpectedly (exit code $prebuiltExit). Check the error above and retry setup."
}
}
# ==========================================================================
# PHASE 3.4: Install the whisper.cpp prebuilt (dictation runtime)
# ==========================================================================
# Mirrors the llama.cpp prebuilt install above; current whisper releases are
# slim bundles that reuse the llama install's ggml runtime, so this runs after
# llama. Failure is never fatal: local dictation falls back to Transformers STT.
$WhisperCppDir = Join-Path $UnslothHome "whisper.cpp"
$WhisperInstaller = Join-Path $PSScriptRoot "install_whisper_prebuilt.py"
# Same opt-outs as setup.sh: a user-configured binary/dir or an explicit skip
# disables the managed install entirely.
if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
substep "whisper.cpp: using a user-configured binary/dir; skipping managed install"
} elseif ($env:UNSLOTH_SKIP_WHISPER_INSTALL -eq "1") {
substep "whisper.cpp: install skipped (UNSLOTH_SKIP_WHISPER_INSTALL=1)"
} elseif ($StudioHomeIsCustom -and (Test-Path -LiteralPath $WhisperInstaller) -and
(Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install" -NonFatal) -eq "Denied") {
# Never fatal, per the phase header: the guard below would exit the whole run
# on an unreadable tree, taking llama.cpp down with it. Only the denial is
# caught here; an unowned tree still stops.
step "whisper.cpp" "install directory cannot be read: access is denied; curated whisper.cpp dictation is unavailable; restore access to $WhisperCppDir or move it aside, then re-run setup; browser and Transformers dictation remain available" "Yellow"
} elseif (Test-Path -LiteralPath $WhisperInstaller) {
# The installer's atomic activation replaces the whole directory, so the
# custom-home ownership guard must run first (mirrors the llama block).
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install"
}
$whisperArgs = @($WhisperInstaller, "--install-dir", $WhisperCppDir)
if ($env:UNSLOTH_WHISPER_RELEASE_TAG) {
$whisperArgs += @("--published-release-tag", $env:UNSLOTH_WHISPER_RELEASE_TAG)
}
if ($script:ROCmGfxArch) {
$whisperArgs += @("--rocm-gfx", $script:ROCmGfxArch)
} elseif ($HasROCm) {
$whisperArgs += "--has-rocm"
}
$prevEAPWhisper = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$previousNativeErrorPreferenceW = $null
$restoreNativeErrorPreferenceW = $false
if ($PSVersionTable.PSVersion.Major -ge 7) {
$previousNativeErrorPreferenceW = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
$restoreNativeErrorPreferenceW = $true
}
try {
$whisperOutput = & python @whisperArgs 2>&1 | Out-String
$whisperExit = $LASTEXITCODE
} finally {
if ($restoreNativeErrorPreferenceW) {
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreferenceW
}
}
$ErrorActionPreference = $prevEAPWhisper
if ($whisperExit -eq 0) {
if ($whisperOutput -match "already matches") {
step "whisper.cpp" "prebuilt up to date"
} else {
step "whisper.cpp" "prebuilt installed"
}
if ($StudioHomeIsCustom -and (Test-PathQuiet $WhisperCppDir "Container")) {
Mark-StudioOwned -Path $WhisperCppDir
}
} elseif ($whisperExit -eq 3) {
step "whisper.cpp" "install busy; keeping existing runtime" "Yellow"
} elseif ($whisperExit -eq 2) {
$requiredWhisperLlamaTag = "unknown"
if ($whisperOutput -match "slim bundle requires llama\.cpp ([^;\s]+)") {
$requiredWhisperLlamaTag = $Matches[1]
}
$installedWhisperLlamaTag = "unknown"
$llamaMarker = Join-Path $LlamaCppDir "UNSLOTH_PREBUILT_INFO.json"
if (Test-PathQuiet $llamaMarker "Leaf") {
try {
$markerPayload = Get-Content -LiteralPath $llamaMarker -Raw | ConvertFrom-Json
if ($markerPayload.release_tag) { $installedWhisperLlamaTag = $markerPayload.release_tag }
} catch {}
}
step "whisper.cpp" "no compatible prebuilt (installed llama.cpp $installedWhisperLlamaTag; whisper requires $requiredWhisperLlamaTag); curated whisper.cpp dictation is unavailable; publish paired releases in llama.cpp then whisper.cpp order; browser and Transformers dictation remain available" "Yellow"
} else {
step "whisper.cpp" "prebuilt install failed; curated whisper.cpp dictation is unavailable; retry setup or inspect verbose output; browser and Transformers dictation remain available" "Yellow"
}
}
# ==========================================================================
# PHASE 3.5: Install OpenSSL dev (for HTTPS support in llama-server)
# ==========================================================================
# llama-server needs OpenSSL to download models from HuggingFace via -hf.
# ShiningLight.OpenSSL.Dev includes headers + libs that cmake can find.
$OpenSslAvailable = $false
if ($NeedLlamaSourceBuild) {
# Check if OpenSSL dev is already installed (look for include dir)
$OpenSslRoots = @(
'C:\Program Files\OpenSSL-Win64',
'C:\Program Files\OpenSSL',
'C:\OpenSSL-Win64'
)
$OpenSslRoot = $null
foreach ($root in $OpenSslRoots) {
if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) {
$OpenSslRoot = $root
break
}
}
if ($OpenSslRoot) {
$OpenSslAvailable = $true
substep "OpenSSL dev found at $OpenSslRoot"
} else {
Write-StudioLine ""
substep "installing OpenSSL dev (for HTTPS in llama-server)..."
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
winget install -e --id ShiningLight.OpenSSL.Dev --source winget --accept-package-agreements --accept-source-agreements
# Re-check after install
foreach ($root in $OpenSslRoots) {
if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) {
$OpenSslRoot = $root
$OpenSslAvailable = $true
substep "OpenSSL dev installed at $OpenSslRoot"
break
}
}
}
if (-not $OpenSslAvailable) {
substep "OpenSSL dev not available -- llama-server will be built without HTTPS" "Yellow"
}
}
} else {
substep "OpenSSL dev install skipped -- prebuilt llama.cpp already validated" "Yellow"
}
# ==========================================================================
# PHASE 4: Build llama.cpp with CUDA for GGUF inference + export
# ==========================================================================
# Builds at ~/.unsloth/llama.cpp — a single shared location under the user's
# home directory. This is used by both the inference server and the GGUF
# export pipeline (unsloth-zoo).
# We build:
# - llama-server: for GGUF model inference (with HTTPS if OpenSSL available)
# - llama-quantize: for GGUF export quantization
# Prerequisites git, cmake, VS Build Tools were installed in Phase 1; the CUDA
# Toolkit is resolved lazily just below via Resolve-CudaToolkit (source build only).
$OriginalLlamaCppDir = $LlamaCppDir
$BuildDir = Join-Path $LlamaCppDir "build"
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
$HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
$HasGitForBuild = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
# Check if existing llama-server matches current GPU mode. A CUDA-built binary
# on a now-CPU-only machine (or vice versa) needs to be rebuilt.
$NeedRebuild = $false
# A forced compile, a pinned PR or a custom source skips the prebuilt path and its
# phase 3.4 denial guard, so this can be the first probe to read inside the tree.
# A linked local dir is skipped: it reads through the junction into the user's own
# checkout, and nothing here is consumed on that path anyway.
$llamaBinState = if ($LocalLlamaCppLinked) { "Absent" } else { Get-PathState -Path $LlamaServerBin -PathType Leaf }
if ($llamaBinState -eq "Denied") {
# Nothing proved this tree is ours here, so do not advise deleting it.
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
}
if ($llamaBinState -eq "Present") {
$CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt"
if (Test-PathQuiet $CmakeCacheFile "Leaf") {
# A listed file can still deny the read, which Test-PathQuiet cannot see.
try {
$cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
} catch {
if (-not (Test-AccessDeniedError $_)) { throw }
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
}
if ($HasNvidiaSmi -and -not $cachedCuda) {
Write-StudioLine " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow
$NeedRebuild = $true
} elseif (-not $HasNvidiaSmi -and $cachedCuda) {
Write-StudioLine " Existing llama-server was built with CUDA but no GPU detected -- rebuilding" -ForegroundColor Yellow
$NeedRebuild = $true
}
}
}
# Install build tools now (last resort) rather than eagerly in Phase 1, so the
# prebuilt path stays fast. Same condition as the if/elseif chain below: a source
# build runs only when needed and no usable binary is already present. A linked
# local dir sets $NeedLlamaSourceBuild = $false, so this no-ops for that path.
$WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and `
-not ((Test-PathQuiet $LlamaServerBin "Leaf") -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master")
if ($WillBuildLlamaFromSource) {
if (-not $HasGitForBuild) {
# Phase 1 keeps git optional, so only the automatic fallback after a failed prebuilt
# download arrives here without it. Last chance to install: Invoke-SetupCommand
# returns 0 for command-not-found, so a git-less clone misreports as a cmake failure.
if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
try {
Invoke-SetupCommand { winget install Git.Git --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
} catch { }
}
$HasGitForBuild = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
}
# Git first, then the toolchain: Ensure-BuildToolsForLlamaSourceBuild exits setup when
# Build Tools cannot be installed, so running it first made the degraded path below
# unreachable on a no-winget box, and elsewhere spent a multi-GB download on a clone
# that cannot happen.
if ($HasGitForBuild) {
Ensure-BuildToolsForLlamaSourceBuild
# refresh so the chain below sees a newly installed cmake
$HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
}
}
if ($LocalLlamaCppLinked) {
# Local dir linked above -- honor the flag's contract: skip BOTH the prebuilt
# download and the source build. Falling through here would run CMake inside
# the user's checkout (via the junction) when it lacks build\bin\Release\llama-server.exe.
Write-StudioLine ""
step "llama.cpp" "linked (skipping build)"
} elseif (-not $NeedLlamaSourceBuild) {
Write-StudioLine ""
step "llama.cpp" "prebuilt (validated)"
} elseif ((Test-PathQuiet $LlamaServerBin "Leaf") -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
# Skip rebuild only for pinned tags (e.g. b8635). When the requested
# tag is "master" (a moving target), always rebuild so the binary picks
# up new model architecture support (e.g. Gemma 4).
Write-StudioLine ""
step "llama.cpp" "already built"
} elseif (-not $HasGitForBuild) {
# Before cmake: the toolchain install is skipped without git, so cmake may be missing
# purely as a consequence. Degrade rather than abort; the opt-in source triggers already
# required git in Phase 1, so only the automatic fallback lands here.
Write-StudioLine ""
step "llama.cpp" "build skipped (git not available)" "Yellow"
substep "The prebuilt download failed and a source build clones llama.cpp." "Yellow"
substep "GGUF inference and export will not be available." "Yellow"
substep "Install Git from https://git-scm.com/download/win and re-run setup." "Yellow"
$script:LlamaCppDegraded = $true
} elseif (-not $HasCmakeForBuild) {
Write-StudioLine ""
if (-not $HasNvidiaSmi) {
# CPU-only machines depend entirely on llama-server for GGUF chat -- cmake is required
substep "CMake is required to build llama-server for GGUF chat mode." "Yellow"
substep "Continuing setup without llama.cpp build." "Yellow"
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
}
step "llama.cpp" "build skipped (cmake not available)" "Yellow"
substep "GGUF inference and export will not be available." "Yellow"
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
$script:LlamaCppDegraded = $true
} else {
# Finalize the VS generator (gate/fallback below) BEFORE Resolve-CudaToolkit,
# which copies the CUDA .targets into the current generator's dir; a later swap
# would strand them. The CMake 4.2 gate for VS 2026 is checked only here, in the
# source-build path, so a VS 2026 + cmake < 4.2 host can still use the prebuilt. (#6473)
if ($CmakeGenerator -match 'Visual Studio 18\b') {
if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) {
$cmakeVerObj = Get-CmakeVersion
$cmakeVerStr = if ($cmakeVerObj) { $cmakeVerObj.ToString() } else { '0.0' }
substep "CMake $cmakeVerStr cannot drive the Visual Studio 2026 generator (need 4.2+ or a VS-bundled cmake) -- updating via winget..." "Yellow"
if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
# upgrade first (fast if Kitware.CMake is already a winget app), then
# prepend the default dir so the new cmake wins over an older one on PATH
try {
Invoke-SetupCommand { winget upgrade Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
} catch { substep "CMake winget upgrade failed: $($_.Exception.Message)" "Yellow" }
Add-DefaultCmakeToPath | Out-Null
# upgrade no-ops if the cmake came from Scoop/Chocolatey/VS, not the
# Kitware winget package; install it so a 4.2+ cmake is available
if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) {
try {
Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
Refresh-Environment
} catch { substep "CMake winget install failed: $($_.Exception.Message)" "Yellow" }
Add-DefaultCmakeToPath | Out-Null
}
}
if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) {
# cmake still cannot drive VS 2026; before failing, fall back to an
# older installed VS whose generator it can drive (e.g. VS 2022 + old
# cmake on an offline box keeps building)
$fallback = Get-FallbackVsGenerator
if ($fallback) {
substep "CMake cannot drive $CmakeGenerator; falling back to $($fallback.Generator)" "Yellow"
$CmakeGenerator = $fallback.Generator
$VsInstallPath = $fallback.InstallPath
} else {
Write-StudioLine "[ERROR] CMake 4.2+ is required to build llama.cpp with the Visual Studio 2026 generator, and no older Visual Studio toolchain was found to fall back to." -ForegroundColor Red
Write-StudioLine " Upgrade CMake from https://cmake.org/download/ and re-run, or use a prebuilt llama.cpp bundle." -ForegroundColor Red
Exit-SetupFailure "CMake cannot drive the Visual Studio 2026 generator"
}
}
}
substep "CMake can drive the $CmakeGenerator generator"
}
# CUDA resolved here (fail fast if none), after the final VS generator so its
# .targets land in the toolset cmake actually uses.
if ($HasNvidiaSmi) { Resolve-CudaToolkit -RequireOrExit }
Write-StudioLine ""
if ($HasNvidiaSmi) {
substep "building llama.cpp with CUDA support..."
} elseif ($HasROCm -or $script:ROCmGfxArch) {
# AMD GPU present but in the CPU-only source-build fallback: a HIP source
# build needs the full HIP SDK + ROCm clang toolchain. AMD GPU acceleration
# comes from the per-gfx ROCm prebuilt (bundles the runtime, no SDK) -- reaching
# here means it couldn't be installed. Warn loudly, don't ship a slow CPU build.
$_amdArch = if ($script:ROCmGfxArch) { $script:ROCmGfxArch } else { "ROCm" }
substep "[WARN] AMD GPU ($_amdArch) detected, but the GPU-accelerated ROCm" "Yellow"
substep " llama.cpp prebuilt could not be installed -- falling back to a CPU build." "Yellow"
substep " The prebuilt is the AMD GPU path (no HIP SDK required). To restore GPU" "Yellow"
substep " acceleration: re-run the installer (check your network / proxy), or set" "Yellow"
substep " UNSLOTH_LLAMA_RELEASE_TAG to a tag with a gfx prebuilt for your GPU." "Yellow"
substep "building llama.cpp (CPU-only fallback)..." "Yellow"
} else {
substep "building llama.cpp (CPU-only, no NVIDIA GPU detected)..."
}
substep "This typically takes 5-10 minutes on first build."
Write-StudioLine ""
# Start total build timer
$totalSw = [System.Diagnostics.Stopwatch]::StartNew()
# Native commands (git, cmake) write to stderr even on success.
# With $ErrorActionPreference = "Stop" (set at top of script), PS 5.1
# converts stderr lines into terminating ErrorRecords, breaking output.
# Lower to "Continue" for the build section.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$BuildOk = $true
$FailedStep = ""
# Re-sanitize CUDA_PATH_V* vars — Refresh-Environment (called during
# Node/Python installs above) may have repopulated conflicting versioned
# vars from the Machine registry.
if ($HasNvidiaSmi -and $CudaToolkitRoot) {
$cudaPathVars2 = @([Environment]::GetEnvironmentVariables('Process').Keys | Where-Object { $_ -match '^CUDA_PATH_V' })
foreach ($v2 in $cudaPathVars2) {
[Environment]::SetEnvironmentVariable($v2, $null, 'Process')
}
$tkDirName2 = Split-Path $CudaToolkitRoot -Leaf
if ($tkDirName2 -match '^v(\d+)\.(\d+)') {
[Environment]::SetEnvironmentVariable("CUDA_PATH_V$($Matches[1])_$($Matches[2])", $CudaToolkitRoot, 'Process')
}
# Also re-assert CUDA_PATH and CudaToolkitDir in case they were overwritten
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'Process')
[Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process')
}
if (-not $LlamaPr) {
$ResolvedSourceUrl = $LlamaSource
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
if ($RequestedLlamaTag -eq "latest") {
$ResolvedSourceRef = if ($env:UNSLOTH_LLAMA_FORCE_COMPILE_REF) {
$env:UNSLOTH_LLAMA_FORCE_COMPILE_REF
} else {
$DefaultLlamaForceCompileRef
}
$ResolvedSourceRefKind = "branch"
} else {
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
}
} elseif ($RequestedLlamaTag -eq "latest") {
$resolveTagArgs = @("--resolve-llama-tag", "latest", "--published-repo", "ggml-org/llama.cpp", "--output-format", "json")
$resolveTagResult = Invoke-LlamaHelper -Arguments $resolveTagArgs
$resolveTagOutput = $resolveTagResult.Output
$resolveTagExit = $resolveTagResult.ExitCode
if ($resolveTagExit -eq 0 -and $resolveTagOutput) {
try {
$ResolvedSourceRef = (($resolveTagOutput | Out-String) | ConvertFrom-Json).llama_tag
} catch {
$ResolvedSourceRef = ""
}
} else {
$ResolvedSourceRef = ""
}
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) {
$ResolvedSourceRef = "latest"
}
$ResolvedSourceRefKind = "tag"
} else {
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
}
if ([string]::IsNullOrWhiteSpace($ResolvedSourceUrl)) { $ResolvedSourceUrl = $LlamaSource }
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $RequestedLlamaTag }
}
# -- Step A: Clone or pull llama.cpp --
$UseConcreteRef = ($ResolvedSourceRef -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedSourceRef))
# Denied must not read as "no checkout here": the fresh-clone branch ends in
# a swap that recursively removes this tree and moves the temp one over it,
# under "Continue" and unchecked, so an unreadable child would leave a
# half-deleted install behind. Stop while that is still avoidable.
$llamaGitState = Get-PathState -Path (Join-Path $LlamaCppDir ".git")
if ($llamaGitState -eq "Denied") {
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install"
}
if ($llamaGitState -eq "Present") {
# why: in-place git mutation (remote set-url, checkout -B, clean -fdx)
# rewrites $LlamaCppDir; mirror the prebuilt and temp-dir-swap guards
# so an unrelated workspace .git tree is never silently overwritten.
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
}
Write-StudioLine " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray
# Always sync the remote URL so switching between default/fork sources works
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$ResolvedSourceUrl.git" } | Out-Null
if ($LlamaPr) {
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" }
if ($gitFetchExit -ne 0) {
$BuildOk = $false
$FailedStep = "git fetch PR #$LlamaPr"
} else {
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B "pr-$LlamaPr" FETCH_HEAD }
if ($gitCheckoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout PR #$LlamaPr"
} else {
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
}
}
} elseif ($ResolvedSourceRefKind -eq "pull") {
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
if ($gitFetchExit -ne 0) {
substep "git fetch failed -- using existing source" "Yellow"
} else {
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
if ($gitCheckoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout"
} else {
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
}
}
} elseif ($ResolvedSourceRefKind -eq "commit") {
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
if ($gitFetchExit -ne 0) {
substep "git fetch failed -- using existing source" "Yellow"
} else {
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
if ($gitCheckoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout"
} else {
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
}
}
} elseif ($UseConcreteRef) {
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
if ($gitFetchExit -ne 0) {
substep "git fetch failed -- using existing source" "Yellow"
} else {
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
if ($gitCheckoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout"
} else {
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
}
}
} else {
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin }
if ($gitFetchExit -ne 0) {
substep "git fetch failed -- using existing source" "Yellow"
} else {
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
if ($gitCheckoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout"
} else {
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
}
}
}
# why: in-place git-sync (the temp-dir clone path calls Mark-StudioOwned
# at swap-time) must mark the existing tree so a subsequent prebuilt
# update path's Assert-StudioOwnedOrAbsent does not exit on the same root.
if ($BuildOk -and $StudioHomeIsCustom) {
Mark-StudioOwned -Path $LlamaCppDir
}
} else {
Write-StudioLine " Cloning llama.cpp @ $ResolvedSourceRef..." -ForegroundColor Gray
$buildTmp = "$LlamaCppDir.build.$PID"
$null = [System.IO.Directory]::CreateDirectory((Split-Path -LiteralPath $LlamaCppDir))
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
if ($LlamaPr) {
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp }
if ($cloneExit -ne 0) {
$BuildOk = $false
$FailedStep = "git clone"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
if ($BuildOk) {
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin "pull/$LlamaPr/head:pr-$LlamaPr" }
if ($fetchExit -ne 0) {
$BuildOk = $false
$FailedStep = "git fetch PR #$LlamaPr"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
if ($BuildOk) {
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout "pr-$LlamaPr" }
if ($checkoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout PR #$LlamaPr"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
} elseif ($ResolvedSourceRefKind -eq "pull") {
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
if ($cloneExit -ne 0) {
$BuildOk = $false
$FailedStep = "git clone"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
if ($BuildOk) {
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
if ($fetchExit -ne 0) {
$BuildOk = $false
$FailedStep = "git fetch source PR ref"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
if ($BuildOk) {
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
if ($checkoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout source PR ref"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
} elseif ($ResolvedSourceRefKind -eq "commit") {
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
if ($cloneExit -ne 0) {
$BuildOk = $false
$FailedStep = "git clone"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
if ($BuildOk) {
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
if ($fetchExit -ne 0) {
$BuildOk = $false
$FailedStep = "git fetch source commit"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
if ($BuildOk) {
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
if ($checkoutExit -ne 0) {
$BuildOk = $false
$FailedStep = "git checkout source commit"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
} else {
$cloneArgs = @("clone", "--depth", "1")
if ($UseConcreteRef) {
$cloneArgs += @("--branch", $ResolvedSourceRef)
}
$cloneArgs += @("$ResolvedSourceUrl.git", $buildTmp)
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs }
if ($cloneExit -ne 0) {
$BuildOk = $false
$FailedStep = "git clone"
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
}
}
# Use temp dir for build; swap into $LlamaCppDir only after build succeeds
if ($BuildOk) {
$LlamaCppDir = $buildTmp
$BuildDir = Join-Path $LlamaCppDir "build"
}
}
# -- Step B: cmake configure --
if ($BuildOk) {
Write-StudioLine ""
Write-StudioLine "--- cmake configure ---" -ForegroundColor Cyan
$CmakeArgs = @(
'-S', $LlamaCppDir,
'-B', $BuildDir,
'-G', $CmakeGenerator,
'-Wno-dev'
)
# Tell cmake exactly where VS is (bypasses registry lookup)
if ($VsInstallPath) {
$CmakeArgs += "-DCMAKE_GENERATOR_INSTANCE=$VsInstallPath"
}
# Common flags
$CmakeArgs += '-DBUILD_SHARED_LIBS=OFF'
$CmakeArgs += '-DLLAMA_BUILD_TESTS=OFF'
$CmakeArgs += '-DLLAMA_BUILD_EXAMPLES=OFF'
$CmakeArgs += '-DLLAMA_BUILD_SERVER=ON'
$CmakeArgs += '-DGGML_NATIVE=ON'
# HTTPS support via OpenSSL
if ($OpenSslAvailable -and $OpenSslRoot) {
$CmakeArgs += "-DOPENSSL_ROOT_DIR=$OpenSslRoot"
$CmakeArgs += '-DLLAMA_OPENSSL=ON'
} else {
$CmakeArgs += '-DLLAMA_CURL=OFF'
}
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
# CUDA flags -- only if GPU available, otherwise explicitly disable
if ($HasNvidiaSmi -and $NvccPath) {
# UNSLOTH_LLAMA_CUDA_ARCHS (e.g. "120" or "89;86") forces the build
# arch and wins over detection, matching setup.sh.
$CudaArchOverride = if ($env:UNSLOTH_LLAMA_CUDA_ARCHS) { ($env:UNSLOTH_LLAMA_CUDA_ARCHS -replace '\s', '') } else { '' }
if ((-not $CudaArch) -and (-not $CudaArchOverride)) {
# No detectable compute capability (#5854): -DGGML_CUDA=ON with no
# arch builds a PTX-only binary, so build CPU instead. Mirrors the
# Linux fix; set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build.
substep "could not detect a CUDA compute capability; building CPU llama.cpp instead of a PTX-only binary (set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force a CUDA build)." "Yellow"
$CmakeArgs += '-DGGML_CUDA=OFF'
} else {
$CmakeArgs += '-DGGML_CUDA=ON'
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
# (covers the configure probe + build), after Refresh-Environment, idempotent.
$nvccAllowFlag = '-allow-unsupported-compiler'
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
}
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
if ($CudaArchOverride) {
# Forced arch wins verbatim (no nvcc validation), matching setup.sh.
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArchOverride"
} elseif ($CudaArch) {
# Validate nvcc actually supports this architecture
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
} else {
# GPU arch too new for this toolkit -- fall back to highest supported.
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
if ($maxArch) {
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
}
# else: omit flag entirely, let cmake pick defaults
}
}
}
} else {
$CmakeArgs += '-DGGML_CUDA=OFF'
}
$cmakeOutput = cmake @CmakeArgs 2>&1 | Out-String
$cmakeConfigureExit = $LASTEXITCODE
if ($cmakeConfigureExit -ne 0) {
$BuildOk = $false
$FailedStep = "cmake configure"
Write-LlamaFailureLog -Output $cmakeOutput
if ($cmakeOutput -match 'No CUDA toolset found|CUDA_TOOLKIT_ROOT_DIR|nvcc') {
Write-StudioLine ""
Write-StudioLine " Hint: CUDA VS integration may be missing. Try running as admin:" -ForegroundColor Yellow
Write-StudioLine " Copy contents of:" -ForegroundColor Yellow
Write-StudioLine " <CUDA_PATH>\extras\visual_studio_integration\MSBuildExtensions" -ForegroundColor Yellow
Write-StudioLine " into:" -ForegroundColor Yellow
$hintCustomizations = if ($VsInstallPath) { Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator } else { "<VS_PATH>\MSBuild\Microsoft\VC\v170\BuildCustomizations" }
Write-StudioLine " $hintCustomizations" -ForegroundColor Yellow
}
}
}
# -- Step C: Build llama-server --
$NumCpu = Get-LlamaBuildJobs
if ($BuildOk) {
Write-StudioLine ""
Write-StudioLine "--- cmake build (llama-server) ---" -ForegroundColor Cyan
Write-StudioLine " Parallel jobs: $NumCpu of $([Environment]::ProcessorCount) cores (RAM-capped; UNSLOTH_LLAMA_BUILD_JOBS overrides)" -ForegroundColor Gray
Write-StudioLine ""
$output = cmake --build $BuildDir --config Release --target llama-server -j $NumCpu 2>&1 | Out-String
$cmakeBuildServerExit = $LASTEXITCODE
if ($cmakeBuildServerExit -ne 0) {
$BuildOk = $false
$FailedStep = "cmake build (llama-server)"
Write-LlamaFailureLog -Output $output
}
}
# -- Step D: Build llama-quantize (optional, best-effort) --
if ($BuildOk) {
Write-StudioLine ""
Write-StudioLine "--- cmake build (llama-quantize) ---" -ForegroundColor Cyan
$output = cmake --build $BuildDir --config Release --target llama-quantize -j $NumCpu 2>&1 | Out-String
$cmakeBuildQuantizeExit = $LASTEXITCODE
if ($cmakeBuildQuantizeExit -ne 0) {
substep "llama-quantize build failed (GGUF export may be unavailable)" "Yellow"
Write-LlamaFailureLog -Output $output
}
}
# -- Step E: Build the DiffusionGemma visual server (optional, best-effort) --
# An example target present on llama.cpp PR #24423; lets Unsloth serve
# DiffusionGemma GGUFs without DG_VISUAL_BIN. No-op when not configured.
if ($BuildOk) {
$null = cmake --build $BuildDir --config Release --target llama-diffusion-gemma-visual-server -j $NumCpu 2>&1 | Out-String
}
# Swap temp build dir into final location (only if we built in a temp dir)
if ($BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
Assert-StudioOwnedOrAbsent -Path $OriginalLlamaCppDir -Label "llama.cpp install"
if ((Get-PathState -Path $OriginalLlamaCppDir) -ne "Absent") {
Remove-Item -LiteralPath $OriginalLlamaCppDir -Recurse -Force -ErrorAction SilentlyContinue
# Any unreadable or locked child survives the removal, and Move-Item
# then nests the build *inside* the leftovers instead of replacing
# them. Both are non-terminating here, so check before destroying
# more: the temp build is still whole at this point.
$swapState = Get-PathState -Path $OriginalLlamaCppDir
if ($swapState -eq "Denied") {
Exit-PathAccessDenied -Path $OriginalLlamaCppDir -Label "llama.cpp install"
}
if ($swapState -ne "Absent") {
step "llama.cpp" "could not replace the existing install at $OriginalLlamaCppDir" "Red"
substep "Part of it survived removal; the new build is intact at $LlamaCppDir" "Yellow"
substep "Close Unsloth and other llama.cpp users, or move that folder aside, then re-run setup" "Yellow"
Exit-SetupFailure "llama.cpp install at $OriginalLlamaCppDir could not be replaced; the new build is at $LlamaCppDir" 3
}
}
Move-Item -LiteralPath $LlamaCppDir -Destination $OriginalLlamaCppDir
# A failed move is non-terminating too; without this setup would report
# a build it never installed.
if ((Get-PathState -Path $LlamaCppDir) -ne "Absent") {
step "llama.cpp" "could not move the new build into $OriginalLlamaCppDir" "Red"
Exit-SetupFailure "llama.cpp build at $LlamaCppDir could not be moved into $OriginalLlamaCppDir" 3
}
$LlamaCppDir = $OriginalLlamaCppDir
$BuildDir = Join-Path $LlamaCppDir "build"
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
Mark-StudioOwned -Path $LlamaCppDir
} elseif (-not $BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
# Build failed -- clean up temp dir, preserve existing install
if (Test-Path -LiteralPath $LlamaCppDir) { Remove-Item -LiteralPath $LlamaCppDir -Recurse -Force }
$LlamaCppDir = $OriginalLlamaCppDir
$BuildDir = Join-Path $LlamaCppDir "build"
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
}
# Restore ErrorActionPreference
$ErrorActionPreference = $prevEAP
# Stop timer
$totalSw.Stop()
$totalMin = [math]::Floor($totalSw.Elapsed.TotalMinutes)
$totalSec = [math]::Round($totalSw.Elapsed.TotalSeconds % 60, 1)
# -- Summary --
if ($BuildOk -and (Test-PathQuiet $LlamaServerBin "Leaf")) {
step "llama.cpp" "built"
$QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe"
if (Test-PathQuiet $QuantizeBin "Leaf") {
step "llama-quantize" "built"
}
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
} else {
$altBin = Join-Path $BuildDir "bin\llama-server.exe"
if ($BuildOk -and (Test-PathQuiet $altBin "Leaf")) {
step "llama.cpp" "built"
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
} else {
step "llama.cpp" "build failed at: $FailedStep (${totalMin}m ${totalSec}s); continuing" "Yellow"
substep "To retry: delete $LlamaCppDir and re-run setup." "Yellow"
$script:LlamaCppDegraded = $true
}
}
}
$llamaCppItem = Get-Item -LiteralPath $LlamaCppDir -Force -ErrorAction SilentlyContinue
$llamaCppIsLink = $llamaCppItem -and ($llamaCppItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
if (-not $llamaCppIsLink -and (
-not $StudioHomeIsCustom -or
(Test-PathQuiet (Join-Path $LlamaCppDir $StudioOwnedMarker) "Leaf") -or
(Test-StudioOwnedAdoptable $LlamaCppDir)
)) {
Remove-AgentInstructionFiles -Roots @($LlamaCppDir)
}
# ─────────────────────────────────────────────
# Footer
# ─────────────────────────────────────────────
$DoneLabel = if ($env:SKIP_STUDIO_BASE -eq "1") { "Unsloth Studio Setup Complete" } else { "Unsloth Studio Updated" }
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
Write-StudioLine (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
if ($script:LlamaCppDegraded) {
Write-StudioLine (" " + (Get-StudioAnsi Warn) + "$DoneLabel (limited: llama.cpp unavailable)" + (Get-StudioAnsi Reset))
} else {
Write-StudioLine (" " + (Get-StudioAnsi Title) + $DoneLabel + (Get-StudioAnsi Reset))
}
Write-StudioLine (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
} else {
Write-StudioLine " $Rule" -ForegroundColor DarkGray
if ($script:LlamaCppDegraded) {
Write-StudioLine " $DoneLabel (limited: llama.cpp unavailable)" -ForegroundColor Yellow
} else {
Write-StudioLine " $DoneLabel" -ForegroundColor Green
}
Write-StudioLine " $Rule" -ForegroundColor DarkGray
}
step "launch" "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
Write-StudioLine ""
# Match studio/setup.sh: exit non-zero for degraded llama.cpp when called
# from install.ps1 (SKIP_STUDIO_BASE=1) so the installer can detect the
# failure. Direct 'unsloth studio update' does not set SKIP_STUDIO_BASE,
# so it keeps degraded installs successful.
if ($script:LlamaCppDegraded -and $env:SKIP_STUDIO_BASE -eq "1") {
# Tauri mode reports instead of aborting, exactly as setup.sh does. install.ps1
# turns any non-zero status from here into Exit-InstallFailure, and install.rs
# turns that into "Installation failed", so on Windows too a single transient
# prebuilt download failure would throw away a first-launch install whose own
# footer just said complete. [TAURI:PROGRESS] (not [TAURI:STEP], which would
# push the frontend step counter past the seven INSTALL_STEPS entries) reaches
# the user as install-progress-detail text.
if (@("1", "true") -contains $env:UNSLOTH_TAURI_MODE) {
[Console]::Out.WriteLine("[TAURI:PROGRESS] llama.cpp unavailable; GGUF inference is disabled until 'unsloth studio update' succeeds")
[Console]::Out.Flush()
} else {
Exit-SetupFailure "llama.cpp setup did not produce a usable server"
}
}