#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" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $PackageDir = Split-Path -Parent $ScriptDir # Maintainer-editable defaults (user env vars override). "latest" not "master": # "master" bypasses the prebuilt resolver and forces a source build. # UNSLOTH_LLAMA_CPP_BACKEND=cpu forces the CPU-only prebuilt (Intel iGPU Vulkan crash #7213). $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" $DefaultLlamaTag = "latest" $DefaultLlamaForceCompileRef = "master" # UNSLOTH_NPM_REGISTRY: opt-in --registry splat for corporate proxies behind the # frontend .npmrc registry lock (min-release-age/save-exact stay in force). Empty = no-op. $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 verbose to child processes (process-scoped). if ($script:UnslothVerbose) { $env:UNSLOTH_VERBOSE = '1' } $script:LlamaCppDegraded = $false # CUDA toolkit state, published by Resolve-CudaToolkit (Phase 4 source build only). $script:CudaToolkitReady = $false $script:NvccPath = $null $script:CudaToolkitRoot = $null $script:CudaArch = $null # 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 installer changes: Path, CUDA_PATH, etc). function Refresh-Environment { foreach ($level in @('Machine', 'User')) { $vars = [System.Environment]::GetEnvironmentVariables($level) foreach ($key in $vars.Keys) { if ($key -eq 'Path') { 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 ";" } # Add a directory to the persistent User PATH. Direct registry access preserves # REG_EXPAND_SZ. 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-Host "[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+ 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-Host "[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 } } } } } function Get-InstalledLlamaPrebuiltRelease { param([string]$InstallDir) $metadataPath = Join-Path $InstallDir "UNSLOTH_PREBUILT_INFO.json" if (-not (Test-Path $metadataPath)) { return $null } try { $payload = Get-Content $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; $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 (e.g. "89" for RTX 4090); $null on failure. function Get-CudaComputeCapability { # $NvidiaSmiExe is an absolute path that survives 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 so a wedged nvidia-smi can't hang setup. $raw = Invoke-NvidiaSmiBounded $smiExe @('--query-gpu=compute_cap', '--format=csv,noheader') if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null } # Multiple GPUs: take the first. $cap = ($raw -split "`n")[0].Trim() if ($cap -match '^(\d+)\.(\d+)$') { $major = $Matches[1] $minor = $Matches[2] return "$major$minor" } } catch { } return $null } # Does an nvcc binary support a given sm_ arch? Uses `nvcc --list-gpu-code` (CUDA 11.6+); # $false on old toolkits without the flag (safer to reject and fall back to scanning/PTX). 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 } } # Highest sm_ arch an nvcc binary supports (e.g. "90" for CUDA 12.4); $null on failure. 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 } # Highest PyTorch CUDA index tag (e.g. "cu128") the driver's max CUDA supports; # the tag must not exceed the driver (CUDA 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 so a wedged nvidia-smi can't hang setup. $output = Invoke-NvidiaSmiBounded $smiExe # Newer drivers print "CUDA UMD Version: X.Y"; accept both spellings. 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) { return "cu130" } if ($major -eq 12 -and $minor -ge 8) { return "cu128" } if ($major -eq 12 -and $minor -ge 6) { return "cu126" } if ($major -ge 12) { return "cu124" } if ($major -ge 11) { return "cu118" } return "cpu" } } catch { } return "cu126" } # Trim trailing slashes from the URL path only, preserving ?query / #fragment. 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 verbatim, or _FAMILY joined to the # mirror base), shared so a pin wins over GPU probing. Parity with the other installers. 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) so .../cu128?token=x # classifies as cu128. Classification only; shared with the py / install.sh 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 echo the failing --index-url verbatim. function Redact-InstallOutput { param([string]$Text) if (-not $Text) { return $Text } $Text = $Text -replace '(https?://)[^/@\s`]+@', '$1@' $Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=' # #token=... fragment is as sensitive as a query. return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#' } # AMD per-arch leaves needing the torch 2.11 floor (_grouped_mm <2.11 bug). Must match # the install-spec path below and the other installers. 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; no speculative floor). # Must match _ROCM_KNOWN_TORCH211_VERSIONS. function Test-RocmKnown211Version { param([int]$Major, [int]$Minor) return ($Major -eq 7 -and $Minor -eq 2) } # True only for a real CUDA family leaf: EXACT cu+digits (cu118, cu128). A bare 'cu*' glob # would match "custom"/"current" and rebuild every run. Mirrors _is_cuda_family_leaf. function Test-CudaFamilyLeaf { param([string]$Leaf) if ([string]::IsNullOrWhiteSpace($Leaf)) { return $false } return $Leaf -match '^cu[0-9]+$' } # True only for a real pip ROCm family leaf: EXACT rocm[.] or gfx. # A merely-STARTS-with-rocm leaf (rocm-rel-7.2.1) 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 } 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. 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 } $_pinMajorOnly = [regex]::Match($PinLeaf, '^rocm(\d+)$') # major-only rocm leaf (rocm7) $_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+') # per-arch three-part build # 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) { # AMD per-arch 2.11 wheel: satisfied only with BOTH a 2.11 release AND a three-part rocm tag. $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. Any +rocm7.x wheel satisfies it. 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. $installed = if ($_instHasRocm) { "rocm" } else { "not-rocm" } return @{ Expected = "rocm"; Installed = $installed } } # rocmX.Y pin. if ($_pinVer -and $_instVer) { # Both readable: exact compare. A KNOWN-2.11 pin also requires a 2.11 release (a # +rocm7.2 wheel drifted to 2.12 shares the tag but violates the spec), so fold it in. $_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. 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. $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 } } # VS generator -> MSBuild BuildCustomizations dir; toolset tracks the VS major (18->v180), default v170. 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` 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 fresh winget cmake wins over an older one on PATH. (#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, when the VS 2026 # generator is unusable. vswhere first (non-default roots like D:\), then Program Files. # 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. productLineVersion is the year for VS <= 2022 # but internal major "18" for VS 2026, and dir names use either form, so accept both. 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 from Phase 1 so the prebuilt path skips the # multi-GB install. Called only for a source build. CMake best-effort; VS required (exit 1). function Ensure-BuildToolsForLlamaSourceBuild { # CMake if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) { Write-Host "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-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow Write-Host " (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-Host "[ERROR] Visual Studio Build Tools are required for the llama.cpp source build but could not be found or installed." -ForegroundColor Red Write-Host " Manual install:" -ForegroundColor Red Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow exit 1 } } # Detect the VC++ 2015-2022 Redistributable the prebuilt llama-server and PyTorch need # (VCRUNTIME140_1.dll). Signal is System32\vcruntime140_1.dll (VS 2019+), registry fallback. function Test-VCRedistInstalled { $sys = $env:SystemRoot if ($sys -and (Test-Path (Join-Path $sys 'System32\vcruntime140_1.dll'))) { return $true } 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 { } } return $false } # Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op). function Ensure-VCRedist { if (Test-VCRedistInstalled) { step "vcredist" "present"; return } Write-Host "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 (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 } 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-Host ($ansi + $Message + (Get-StudioAnsi Reset)) } else { $fc = switch ($Color) { 'Green' { 'DarkGreen' } 'Gray' { 'DarkGray' } 'Cyan' { 'Green' } default { $Color } } Write-Host $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 (PS 5.1 stderr records flip $? even on exit 0). Redact per # record: verbose mode must not bypass the quiet path's index-URL redaction. & $Command 2>&1 | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host } else { $output = & $Command 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { Write-Host (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-Host " Showing last $MaxLines lines:" -ForegroundColor DarkGray $lines = $lines | Select-Object -Last $MaxLines } foreach ($line in $lines) { Write-Host " | $line" -ForegroundColor DarkGray } } # Mirror plain step/substep lines to [Console]::Out when stdout is redirected: Write-Host doesn't # propagate across the process chain. Gated on IsOutputRedirected (no double-print interactively). function Write-StudioStdoutMirror { param([Parameter(Mandatory = $true)][string]$Line) try { if ([Console]::IsOutputRedirected) { [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) } 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 { Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray $fc = switch ($Color) { 'Green' { 'DarkGreen' } 'Yellow' { 'Yellow' } 'Red' { 'Red' } 'DarkGray' { 'DarkGray' } default { 'DarkGreen' } } Write-Host $Value -ForegroundColor $fc } Write-StudioStdoutMirror (" {0}{1}" -f $padded, $Value) } function substep { param( [Parameter(Mandatory = $true)][string]$Message, [string]$Color = "DarkGray" ) 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 } Write-StudioStdoutMirror (" {0,-15}{1}" -f "", $Message) } function Show-NpmRegistryHint { # Guidance when a frontend/OXC npm/bun install fails behind a corporate firewall/proxy. # Only guides; never switches registries automatically. No-op if UNSLOTH_NPM_REGISTRY set. 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-Host "" 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-Host "" if ($script:StudioVtOk -and -not $env:NO_COLOR) { Write-Host (" " + (Get-StudioAnsi Title) + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup" + (Get-StudioAnsi Reset)) Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) } else { Write-Host (" " + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup") -ForegroundColor Green Write-Host " $Rule" -ForegroundColor DarkGray } # 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-Host "[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 # ============================================ # Run nvidia-smi under a timeout: a wedged driver can block during init/reset, so WaitForExit # bounds it. 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 ) 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 return ($outTask.Result + "`n" + $errTask.Result) } catch { $global:LASTEXITCODE = 1 return "" } } # nvidia-smi -L lists at least one real GPU: a stale/driverless smi can exit 0 while # listing no GPU (would mark an AMD host NVIDIA), so require a "GPU :" 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 though a GPU + driver exist -- check default dirs. 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-Host " Found nvidia-smi at $(Split-Path $p -Parent)" -ForegroundColor Gray break } } catch {} } } } # Run amd-smi without triggering a UAC elevation prompt: amd-smi auto-elevates to read # GPU/APU memory (a confusing DiskPart UAC prompt mid-install). RunAsInvoker forces # 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. $prevCompat = [Environment]::GetEnvironmentVariable('__COMPAT_LAYER', 'Process') $env:__COMPAT_LAYER = 'RunAsInvoker' try { # [Process]::Start, NOT Start-Process -PassThru (which leaves .ExitCode $null on # PS 5.1, killing detection). Async reads drain the pipes; amd-smi args have no spaces. $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:ROCmGfxArch = $null if (-not $HasNvidiaSmi) { # hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback. Ignore the venv hipInfo.exe # (AMD wheel, not a HIP SDK, 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, 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 moves the venv off the default path; seed it too. $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; else GetFullPath keeps the literal ~. 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 on PS 5.1. $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 -- 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, keep the first non-venv one. -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, 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); keep the ROCm path once printed. $HasROCm = $true $_hipAllArches = @([regex]::Matches($hipOut, "(?im)^\s*gcnArchName\s*:\s*(\S+)") | ForEach-Object { ($_.Groups[1].Value -split ':')[0].Trim().ToLower() }) $_hipVisIdx = if ($env:HIP_VISIBLE_DEVICES -match '^\d') { [int]($env:HIP_VISIBLE_DEVICES -split ',')[0] } elseif ($env:ROCR_VISIBLE_DEVICES -match '^\d') { [int]($env:ROCR_VISIBLE_DEVICES -split ',')[0] } else { 0 } if ($_hipAllArches.Count -gt 0) { $script:ROCmGfxArch = if ($_hipVisIdx -lt $_hipAllArches.Count) { $_hipAllArches[$_hipVisIdx] } else { $_hipAllArches[0] } $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 returned a HIP runtime error with no gcnArchName (no ROCm device), or crashed early. $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 when hipinfo is unavailable ('list' + 'static --asic' give the gfx arch). # Probe only with a HIP SDK present or explicit opt-in (amd-smi can pop a UAC prompt # RunAsInvoker can't suppress); an explicit UNSLOTH_ENABLE_AMD_SMI opt-out wins. $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 embeds the gfx arch in list output. Collect ALL gfx # tokens in output order (no dedup) to honour HIP/ROCR_VISIBLE_DEVICES and pick # the runtime-visible GPU's arch. Assumes amd-smi lists GPUs in HIP enumeration order. $allGfxArches = @([regex]::Matches($smiOut, '(?i)\b(gfx\d+[a-z]?)\b') | ForEach-Object { $_.Groups[1].Value.ToLower() }) if ($allGfxArches.Count -gt 0) { # A single integer index selects that GPU; otherwise index 0. $visGpu = if ($env:HIP_VISIBLE_DEVICES) { $env:HIP_VISIBLE_DEVICES } elseif ($env:ROCR_VISIBLE_DEVICES) { $env:ROCR_VISIBLE_DEVICES } else { $null } $gpuIdx = 0 if ($visGpu -match '^\s*(\d+)\s*$') { $gpuIdx = [int]$Matches[1] } if ($gpuIdx -ge $allGfxArches.Count) { substep "[WARN] HIP/ROCR_VISIBLE_DEVICES index $gpuIdx is out of range ($($allGfxArches.Count) GPU(s) detected); defaulting to GPU 0 for arch selection" "Yellow" $gpuIdx = 0 } $script:ROCmGfxArch = $allGfxArches[$gpuIdx] $ROCmGpuLabel = "AMD ROCm ($script:ROCmGfxArch)" } else { # Attempt 2: 'static --asic' exposes the GFX target on ROCm 6+. $smiAsicOut = "" try { $smiAsicOut = Invoke-AmdSmiNoElevate $amdSmiExe.Source @('static','--asic') } catch {} if ($smiAsicOut -match "(?i)\b(gfx\d+[a-z]?)\b") { $script:ROCmGfxArch = $Matches[1].ToLower() $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: marketing name only (never a gfx arch); $HasROCm stays false (runtime # unconfirmed) but $ROCmGpuLabel feeds the name-based arch inference below. if (-not $HasROCm) { try { $wmiGpu = Get-WmiObject Win32_VideoController -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "AMD|Radeon" } | Select-Object -First 1 if ($wmiGpu) { $ROCmGpuLabel = $wmiGpu.Name } } catch {} } # ── Arch resolution: env-var override -> name inference ── # Runs even with no confirmed ROCm runtime: the per-gfx llama.cpp prebuilt only needs the # gfx arch (forwarded via --rocm-gfx); PyTorch ROCm wheels still gate on $HasROCm below. 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 (most-specific first, first match wins). Covers # only arches the ROCm prebuilts support; unknown names fall back cleanly to CPU. elseif ($ROCmGpuLabel) { $nameArchTable = @( @{ P = "9070|9080"; A = "gfx1201" } # RDNA 4 (Navi 48: Radeon RX 9070 XT / 9070 GRE / 9070 / 9080) @{ 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 ) foreach ($row in $nameArchTable) { if ($ROCmGpuLabel -match $row.P) { $script:ROCmGfxArch = $row.A $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" break } } } } # Capture ROCm version (display + wheel selection). Runs whenever the HIP SDK binary is # present: hipconfig --version works even when hipinfo reports no ROCm device. 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 {} } } } } if ($HasNvidiaSmi) { step "gpu" "NVIDIA GPU detected" } elseif ($HasROCm) { 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) { # HIP SDK is installed but ROCm can't see the device (driver issue, not SDK issue) $sdkVer = if ($script:ROCmVersionFull) { " (HIP $script:ROCmVersionFull)" } else { "" } Write-Host "" 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 (HIP SDK optional). Write-Host "" 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-Host "" } elseif ($ROCmGpuLabel) { Write-Host "" 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-Host "" } else { Write-Host "" step "gpu" "none (chat-only / GGUF)" "Yellow" substep "Training and GPU inference require an NVIDIA or AMD ROCm GPU." "Yellow" Write-Host "" } # ============================================ # 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-Host "Windows Long Paths not enabled (required for Triton compilation and deep dependency paths)." -ForegroundColor Yellow Write-Host " Requesting admin access to fix..." -ForegroundColor Yellow try { # Elevated process sets 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-Host " Run this manually in an Admin terminal:" -ForegroundColor Yellow Write-Host ' reg add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f' -ForegroundColor Cyan } } # ============================================ # 1b. Git (required by pip for git+https:// deps and by npm) # ============================================ $HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue) if (-not $HasGit) { Write-Host "Git not found -- installing 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) { Write-Host "[ERROR] Git is required but could not be installed automatically." -ForegroundColor Red Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red exit 1 } 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. Ensure-BuildToolsForLlamaSourceBuild installs CMake for a source build. $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; 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) # ============================================ # Invoked lazily before a Phase 4 source build (the prebuilt 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; otherwise best-effort, only sets the flag. function Resolve-CudaToolkit { param([switch]$RequireOrExit) # Toolkit major must be <= the driver's max CUDA major; a newer-major toolkit fails at runtime. $DriverMaxCuda = $null try { # Bounded so a wedged smi can't hang. test_resolve_cuda_toolkit.ps1 extracts this function # alone (no Invoke-NvidiaSmiBounded in scope), so fall back to direct invocation. $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"; 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 compatible with the driver AND the GPU. Prefer CUDA_PATH when it's # driver-compatible and supports the GPU arch; else scan side-by-side installs (avoids # header/binary mismatches with multiple toolkits 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; use it, else record it 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 } # Source build only: with no compatible toolkit it must fail. Write-Host "" -ForegroundColor Red Write-Host "========================================================================" -ForegroundColor Red Write-Host "[ERROR] CUDA source build cannot use the installed toolkit with this driver." -ForegroundColor Red Write-Host "========================================================================" -ForegroundColor Red exit 1 } # -- No toolkit at all: install via winget (only when a source build needs it) -- if (-not $NvccPath -and $RequireOrExit) { Write-Host "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-Host "[ERROR] CUDA Toolkit (nvcc) is required but could not be found or installed." -ForegroundColor Red if ($DriverMaxCuda) { Write-Host " Install a CUDA Toolkit with major version $($DriverMaxCuda.Split('.')[0]) from https://developer.nvidia.com/cuda-toolkit-archive" -ForegroundColor Yellow } else { Write-Host " Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads" -ForegroundColor Yellow } exit 1 } # -- 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: MSBuild property the CUDA .targets checks; trailing backslash required (.targets appends subpaths). [Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process') # Persist CUDA_PATH to User registry so the compatible toolkit is used in future sessions. [Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'User') substep "Persisted CUDA_PATH=$CudaToolkitRoot to user environment" # Clear versioned CUDA_PATH_V* vars in this process so cmake/MSBuild can't find a conflicting install. $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 -- # CUDA installed before VS Build Tools leaves the MSBuild .targets/.props missing and cmake # fails with "No CUDA toolset found"; copy them from the 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. 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 (it influenced toolkit selection); just log final state. 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) { $rocmVerLabel = if ($script:ROCmVersionFull) { "ROCm $script:ROCmVersionFull" } elseif ($script:ROCmVersion) { "ROCm $script:ROCmVersion" } else { "ROCm (version unknown)" } step "rocm" $rocmVerLabel } elseif ($script:ROCmGfxArch) { # GPU works via AMD's bundled-runtime ROCm wheels; HIP SDK optional. step "rocm" "GPU via bundled ROCm wheels ($script:ROCmGfxArch) -- HIP SDK optional" "Cyan" } 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. 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" } $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 need npm even 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-Host "ERROR: UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist." -ForegroundColor Red Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red exit 1 } $NodeParent = (Resolve-Path -LiteralPath $NodeOverride).Path # An override at the legacy default maps to the legacy sibling ~/.unsloth/node, not /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 aborting setup: under -ErrorAction Stop a bare `node -v` # for an absent/broken node throws past 2>$null, so guard with Get-Command + 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 { # Provision Node process-local, only when the build or OXC needs it. 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 breaks torch's c10.dll loading on Windows; a venv 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 built the venv with # (UNSLOTH_SETUP_PYTHON) or the existing venv python, before re-probing a system where a 3.14 # or WindowsApps stub ahead on PATH would trip the gate. 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 setup/update (install.ps1 did not run): derive the venv python from the studio root. $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 is required: PS 5.1 returns only the first launcher without it. $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: a py-launcher-only install would otherwise crash on the first bare `python`. 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 (py.exe absent or only unsupported minors). Try winget; gating on # $HasPython alone lets a launcher-only 3.14 install still get an automatic 3.12. Write-Host "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-Host "[ERROR] Python could not be installed automatically." -ForegroundColor Red Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow exit 1 } step "python" "$(python --version 2>&1)" $PythonOk = $true } else { # python.exe on PATH is unsupported and py.exe had no supported minor either. Write-Host "[ERROR] No supported Python (3.11-3.13) found on this system." -ForegroundColor Red Write-Host " py.exe could not locate -3.11/-3.12/-3.13 and `python` on PATH is unsupported." -ForegroundColor Yellow Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow exit 1 } # 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) { # Add to current process for immediate availability. $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-Host "" step "system" "prerequisites ready" Write-Host "" # ========================================================================== # PHASE 2: Frontend build (skip if pip-installed -- already bundled) # ========================================================================== $DistDir = Join-Path $FrontendDir "dist" # Skip build if dist/ exists and no tracked input (src/, public/, package.json, configs) is newer. $NeedFrontendBuild = $true if ($IsPipInstall) { $NeedFrontendBuild = $false step "frontend" "bundled (pip install)" } elseif ($SkipFrontend) { $NeedFrontendBuild = $false step "frontend" "bundled (Tauri)" } 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 only when the frontend build OR the OXC runtime install needs it. 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; 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-Host "[ERROR] $NodeDir already exists and is not an Unsloth-owned Node install." -ForegroundColor Red Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow exit 1 } } substep "installing isolated Node (system Node/npm left untouched)..." # Prefer the validated handed-off/venv Python: bare `python` may be a Store stub this early. $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-Host $nodeOut -ForegroundColor DarkGray step "node" "install blocked by another active Unsloth install" "Red" exit 3 } elseif ($nodeExit -ne 0) { Write-Host $nodeOut -ForegroundColor DarkGray Write-Host "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red Write-Host " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow exit 1 } 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). $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 demotes the isolated-Node prepend; re-prepend so it wins. $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 satisfies requirements; use as-is (no global bun -- the build falls back to npm). step "node" "$SysNodeVersion | npm $SysNpmVersion (system)" } } if ($NeedFrontendBuild -and -not $IsPipInstall) { Write-Host "" substep "building frontend..." # Tailwind v4 .gitignore workaround: its oxide scanner respects parent .gitignore, and a # Python venv's "*" .gitignore hides .tsx sources. Temporarily hide any such file, then restore. $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), else npm. Bun is only the package manager; Node runs the build. $prevEAP_npm = $ErrorActionPreference $ErrorActionPreference = "Continue" Push-Location $FrontendDir $UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue) # bun's package cache can corrupt (metadata but no bin/lib), so bun install exits 0 with # binaries missing. Validate after install, clear cache + retry once, then fall back to npm. if ($UseBun) { Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray $bunExit = Invoke-SetupCommand { bun install @NpmRegistryArgs } # .bin/ entries vary by manager (npm: tsc/.cmd/.ps1; bun: .exe/.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, binaries present } elseif ($bunExit -eq 0) { Write-Host " 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-Host " 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-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow Show-NpmRegistryHint exit 1 } } # Always run the build via npm (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-Host "[ERROR] npm run build failed (exit code $buildExit)" -ForegroundColor Red exit 1 } 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-Host "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red Show-NpmRegistryHint exit 1 } Pop-Location $ErrorActionPreference = $prevEAP_oxc step "oxc runtime" "installed" } elseif ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip") { # No npm on PATH: 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-Host "" substep "setting up Python environment..." # Find Python -- skip Anaconda/Miniconda (conda's torch c10.dll loading issue). Standalone # CPython (python.org, winget, uv) is fine. $PythonCmd = $null # 0. Reuse the interpreter install.ps1 built the venv with (already validated, 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. -All enumerates every launcher (PS 5.1 returns # only the first without it); 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 so venv creation doesn't re-resolve to conda. $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 (-All looks 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-Host "[ERROR] No standalone Python 3.11-3.13 found (conda Python is not supported)." -ForegroundColor Red Write-Host " Install Python from https://python.org/downloads/ or via:" -ForegroundColor Yellow Write-Host " winget install -e --id Python.Python.3.12" -ForegroundColor Yellow exit 1 } substep "Python found: $PythonCmd" # The venv must already exist (install.ps1 made it); this script only updates packages. # UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the root; UNSLOTH_STUDIO_HOME wins. # Whitespace-only values are treated as unset (Python .strip() semantics). $_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 # Fail fast when the custom root is read-only, not later while creating sidecar venvs. $_setupWriteProbe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid()) try { [System.IO.File]::WriteAllText($_setupWriteProbe, "") Remove-Item -LiteralPath $_setupWriteProbe -Force -ErrorAction SilentlyContinue } catch { Write-Host "ERROR: $_studioOverrideVar=$StudioHome is not writable." -ForegroundColor Red exit 1 } } else { Write-Host "ERROR: $_studioOverrideVar=$_studioOverride does not exist." -ForegroundColor Red Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red exit 1 } } else { $StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" } $VenvDir = Join-Path $StudioHome "unsloth_studio" # In env-override mode $StudioHome is user-chosen; require the ownership marker before any # Remove-Item so unrelated dirs survive. Gated on the canonical comparison so an override at # the legacy default still behaves like a default install. $StudioOwnedMarker = ".unsloth-studio-owned" $LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" $_studioHomeCanon = $StudioHome if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) { $_studioHomeCanon = (Resolve-Path -LiteralPath $_studioHomeCanon).Path } if (Test-Path -LiteralPath $LegacyStudioHome -PathType Container) { $LegacyStudioHome = (Resolve-Path -LiteralPath $LegacyStudioHome).Path } $StudioHomeIsCustom = ($_studioHomeCanon -ne $LegacyStudioHome) # 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. function Test-StudioOwnedAdoptable { param([Parameter(Mandatory = $true)][string]$Path) if (Test-Path -LiteralPath (Join-Path $Path "UNSLOTH_PREBUILT_INFO.json") -PathType Leaf) { return $true } if (Test-Path -LiteralPath (Join-Path $Path "UNSLOTH_WHISPER_PREBUILT_INFO.json") -PathType Leaf) { return $true } return $false } function Assert-StudioOwnedOrAbsent { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Label ) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return } if ($StudioHomeIsCustom -and -not (Test-Path -LiteralPath (Join-Path $Path $StudioOwnedMarker) -PathType Leaf)) { if (Test-StudioOwnedAdoptable $Path) { Mark-StudioOwned $Path return } Write-Host "[ERROR] $Path already exists and is not marked as an Unsloth-owned $Label." -ForegroundColor Red Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow exit 1 } } 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 {} } # Stale-venv detection: if the venv's torch flavor no longer matches the machine, repair by # context. install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 to delegate to installer-level # rollback; direct `studio update` self-repairs. No-torch mode expects a missing torch. $NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$' $InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$' if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) { $VenvPyExe = Join-Path $VenvDir "Scripts\python.exe" $installedTorchTag = $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) { try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $VenvPyExe $psi.Arguments = '-c "import torch; print(torch.__version__)"' $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $proc = [System.Diagnostics.Process]::Start($psi) $torchVer = $proc.StandardOutput.ReadToEnd().Trim() $finished = $proc.WaitForExit(30000) if ($finished -and $proc.ExitCode -eq 0 -and $torchVer) { if ($torchVer -match '\+(cu\d+)') { $installedTorchTag = $Matches[1] } elseif ($torchVer -match '\+rocm') { # Any +rocm / gfx wheel -> generic "rocm" flavor (exact version repaired later). $installedTorchTag = "rocm" } elseif ($torchVer -match '\+cpu') { $installedTorchTag = "cpu" } else { # Untagged wheel (plain "2.x.y" from PyPI) -> cpu. $installedTorchTag = "cpu" } } else { if (-not $finished) { try { $proc.Kill() } catch {} } $shouldRebuild = $true } } catch { $shouldRebuild = $true } } else { # Missing python.exe means the venv is incomplete -- rebuild it. $shouldRebuild = $true } if (-not $shouldRebuild) { $_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) { # Keep a pinned ROCm/gfx leaf specific (a generic "rocm" would mask rocm6.4 -> gfx1151). # Get-RocmPinStaleTags uses the SAME 2.11 allowlist as the install path. $_rocmTags = Get-RocmPinStaleTags -PinLeaf $_pinLeaf -TorchVersion $torchVer $expectedTorchTag = $_rocmTags.Expected $installedTorchTag = $_rocmTags.Installed } elseif ((Test-CudaFamilyLeaf $_pinLeaf) -or $_pinLeaf -eq 'cpu') { # cu*/cpu leaves stay specific so a cu126-vs-cu128 mismatch rebuilds; /custom, /current fall through. $expectedTorchTag = $_pinLeaf } else { # Custom index whose leaf isn't a torch flavor (/simple mirror): flavor unknown, never stale. $_expectedKnown = $false $expectedTorchTag = $installedTorchTag } } elseif ($HasNvidiaSmi) { $expectedTorchTag = Get-PytorchCudaTag } elseif ($HasROCm -or $script:ROCmGfxArch) { # AMD/ROCm host, no pin: an existing +rocm wheel is correct. Only arches the install # path maps to a repo.amd.com index get ROCm torch; an unmapped arch expects "cpu". $_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 ) if ($script:ROCmGfxArch -and ($_rocmWheelArches -contains $script:ROCmGfxArch)) { # A correct +rocm wheel isn't stale. A CPU wheel on a supported AMD arch is upgraded # in place (expect "cpu"), not wiped. 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 (force-reinstall); # 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 } if ($shouldRebuild) { $reason = if ($installedTorchTag) { "torch $installedTorchTag != required $expectedTorchTag" } else { "torch could not be imported" } if ($InstallerManagedSetup) { substep "Stale venv detected ($reason)." "Yellow" Write-Host " [ERROR] The existing Unsloth environment needs repair." -ForegroundColor Red Write-Host " Re-run install.ps1 so it can replace the environment safely with rollback." -ForegroundColor Yellow exit 1 } substep "Stale venv detected ($reason) -- rebuilding..." "Yellow" # Mirror install.ps1 env-mode guard so an update against a custom UNSLOTH_STUDIO_HOME # never wipes an unrelated venv; -PathType Leaf rejects a directory sentinel. 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) ) { Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow exit 1 } try { Remove-Item -LiteralPath $VenvDir -Recurse -Force -ErrorAction Stop } catch { Write-Host " [ERROR] Could not remove stale venv: $($_.Exception.Message)" -ForegroundColor Red Write-Host " Close any running Unsloth/Python processes and re-run setup." -ForegroundColor Red exit 1 } } } if (-not (Test-Path -LiteralPath $VenvDir)) { Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow exit 1 } else { substep "reusing existing virtual environment at $VenvDir" $_venvPyExe = Join-Path $VenvDir "Scripts\python.exe" if (Test-Path -LiteralPath $_venvPyExe) { try { $_venvPyVer = (& $_venvPyExe --version 2>&1 | Out-String).Trim() if ($_venvPyVer) { substep $_venvPyVer } } catch {} } } # pip/python write to stderr even on success, which -ErrorAction Stop turns into terminating # errors on PS 5.1; lower to "Continue" for the pip/python section. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" $ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1" . $ActivateScript # 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 { Invoke-SetupCommand { Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1") } | Out-Null Refresh-Environment # Re-activate: Refresh-Environment rebuilds PATH and drops the venv's Scripts dir. . $ActivateScript if (Get-Command uv -ErrorAction SilentlyContinue) { $UseUv = $true } } catch { } } # 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/pip index vars otherwise pull CPU torch over # the CUDA/ROCm build (#6898), so drop them for pinned installs (pip fallback honours PIP_* too). # UV_NO_CONFIG=1 (+ dropping UV_CONFIG_FILE) stops a uv.toml index outranking the pin (uv 0.10). $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' # PIP_CONFIG_FILE = 'nul' (Windows devnull) stops a `pip config` extra-index-url adding indexes to the pip fallback. $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] } } } } # Check if Python deps need updating: compare installed version to PyPI latest, skip if they match. $_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 install stuck on anyio>=4.14 with $_PkgName current would skip the # install_python_stack anyio repair (#6797); force the pass. $_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 } # AMD GPU present but installed PyTorch is CPU-only: the fast path would leave the user # on CPU torch with Train/Export disabled, so force the pass to install ROCm wheels. if ($script:ROCmGfxArch) { $_torchIsCpu = $true try { & python -c "import torch, sys; sys.exit(0 if torch.cuda.is_available() else 1)" 2>$null if ($LASTEXITCODE -eq 0) { $_torchIsCpu = $false } } catch {} if ($_torchIsCpu) { substep "AMD GPU ($script:ROCmGfxArch) detected but installed PyTorch is CPU-only -- reinstalling ROCm PyTorch" "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..." } } # A torch-index pin change repairs in place: force the pass so torch force-reinstalls from the new pin. if ($script:PinChangedForceReinstall) { $SkipPythonDeps = $false } if (-not $SkipPythonDeps) { if ($script:UnslothVerbose) { Fast-Install --upgrade pip } else { Fast-Install --upgrade pip | Out-Null } # Pre-install PyTorch with CUDA support: the default Windows PyPI wheel is CPU-only, so use # PyTorch's CUDA index (tag chosen from the driver's max CUDA; wheels bundle their own runtime). # Triton/inductor filenames can hit Windows MAX_PATH (260). With long paths on, cache under # Unsloth home; else 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. $PinnedTorchIndexUrl = Get-PinnedTorchIndexUrl $TorchIndexPinned = [bool]$PinnedTorchIndexUrl if ($PinnedTorchIndexUrl) { $CuTag = Get-TorchIndexLeaf $PinnedTorchIndexUrl } elseif ($HasNvidiaSmi) { $CuTag = Get-PytorchCudaTag } else { $CuTag = "cpu" } # ── AMD Windows ROCm torch override ── # AMD's arch-specific pip index (repo.amd.com/rocm/whl/{arch}/); wheels bundle their own runtime. $ROCmGfxArch = $script:ROCmGfxArch $ROCmIndexUrl = $null # Install AMD ROCm wheels when ROCm is confirmed OR a gfx arch is known (name-inferred on # Adrenalin-only hosts): the per-arch wheels bundle the runtime so torch.cuda.is_available() is # True without a HIP SDK, flipping Unsloth out of chat-only. A failed ROCm install falls back to CPU. 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" } $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 } # gfx120X/Strix have a null _grouped_mm kernel on torch <2.11.0. Mirrors $torchFloorMap in install.ps1. $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" } # torchvision/torchaudio companions kept in sync with the torch ceiling so pip finds a # consistent trio (AMD publishes each independently). Matches _ROCM_TORCH_PKG_SPECS["rocm7.2"]; # 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 why torch is CPU-only. 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 but gcnArchName unreadable -- warn rather than silently CPU torch. 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 path with the # same floor/companions the unpinned AMD path uses (mirrors install.ps1), else the CUDA branch # 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 ($) so a suffixed custom leaf (rocm7.2-private) falls through to the verbatim install. if ($_pinLeaf -match '^rocm(\d+)\.(\d+)$') { # Only KNOWN-2.11 rocm (rocm7.2) gets the floor. Matches Test-RocmKnown211Version. $_pinRocm211 = Test-RocmKnown211Version -Major ([int]$Matches[1]) -Minor ([int]$Matches[2]) } # Only the 2.11 gfx arches need the floor. Reuse Test-RocmGfx211Leaf so this and the stale 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. # 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" } # Full URL pin verbatim; family pin already set $CuTag. On ROCm-install failure the fallback uses the CPU index. $TorchInstallIndexUrl = if ($ROCmIndexUrl) { "$PyTorchWhlBase/cpu" } elseif ($PinnedTorchIndexUrl) { $PinnedTorchIndexUrl } else { "$PyTorchWhlBase/$CuTag" } $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" } # Release preservation (twin of install.sh's _previous_torch_pin): install.ps1 exports the previous # venv's exact torch RELEASE via UNSLOTH_KEPT_TORCH. Substitute the kept trio unless it conflicts with # a >=2.11 floor already enforced in $ROCmTorchSpec (a kept minor < 11 keeps the floor). On a failed # install, restore the computed specs and retry once. Absent the env var (direct 'studio update'), # nothing changes and the loop runs exactly once. $_rocmKeptActive = $false $_rocmOrigTorch = $ROCmTorchSpec; $_rocmOrigVision = $ROCmVisionSpec; $_rocmOrigAudio = $ROCmAudioSpec if ($env:UNSLOTH_KEPT_TORCH -match '^\d+\.\d+(\.\d+)?$') { $_keptMinor = [int](($env:UNSLOTH_KEPT_TORCH -split '\.')[1]) if (-not ($ROCmTorchSpec -match 'torch>=2\.11' -and $_keptMinor -lt 11)) { $ROCmTorchSpec = "torch==$($env:UNSLOTH_KEPT_TORCH)" $ROCmVisionSpec = "torchvision==0.$($_keptMinor + 15).*" $ROCmAudioSpec = "torchaudio==2.$($_keptMinor).*" $_rocmKeptActive = $true } } while ($true) { if ($script:UnslothVerbose) { Fast-Install $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec --force-reinstall --index-url $ROCmIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host $torchInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec --force-reinstall --index-url $ROCmIndexUrl | Out-String $torchInstallExit = $LASTEXITCODE } if ($torchInstallExit -eq 0 -or -not $_rocmKeptActive) { break } substep "[WARN] torch==$($env:UNSLOTH_KEPT_TORCH) not installable on this ROCm index -- using the supported release" "Yellow" $ROCmTorchSpec = $_rocmOrigTorch; $ROCmVisionSpec = $_rocmOrigVision; $ROCmAudioSpec = $_rocmOrigAudio Remove-Item Env:UNSLOTH_KEPT_TORCH -ErrorAction SilentlyContinue $_rocmKeptActive = $false } if ($torchInstallExit -ne 0) { Write-Host "[WARN] AMD ROCm PyTorch install failed -- falling back to CPU" -ForegroundColor Yellow Write-Host (Redact-InstallOutput $output) -ForegroundColor Yellow $ROCmIndexUrl = $null $ROCmCpuFallback = $true } else { # Tell install_python_stack.py to skip the probe and suppress the manual-install warning. $env:UNSLOTH_ROCM_TORCH_INSTALLED = "1" substep "GPU ROCm PyTorch installed ($ROCmGfxArch) -- training and GPU inference will use the GPU" "Cyan" } } if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) { substep "installing PyTorch (CPU-only)..." # After an AMD ROCm fallback, force-reinstall so a partial ROCm torch (satisfies the CPU # torch>= range) is replaced by the CPU build; also on a pin change (a stale +cu/+rocm wheel # still satisfies the range). Build the array directly (an if-expression @splat enumerates chars). $cpuForce = @() if ($ROCmCpuFallback) { $cpuForce = @("--force-reinstall") } 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. Unpinned keeps the bare trio. $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" } # Release preservation: keep install.ps1's exported torch RELEASE (UNSLOTH_KEPT_TORCH) -- a kept # release from the /cpu index resolves the correct cpu-flavor build. On a failed install, restore the # computed specs and retry once. Absent the env var, nothing changes and the loop runs exactly once. $_cpuKeptActive = $false $_cpuOrigTorch = $cpuTorchSpec; $_cpuOrigVision = $cpuVisionSpec; $_cpuOrigAudio = $cpuAudioSpec if ($env:UNSLOTH_KEPT_TORCH -match '^\d+\.\d+(\.\d+)?$') { $_keptMinor = [int](($env:UNSLOTH_KEPT_TORCH -split '\.')[1]) $cpuTorchSpec = "torch==$($env:UNSLOTH_KEPT_TORCH)" $cpuVisionSpec = "torchvision==0.$($_keptMinor + 15).*" $cpuAudioSpec = "torchaudio==2.$($_keptMinor).*" $_cpuKeptActive = $true } while ($true) { if ($script:UnslothVerbose) { Fast-Install $cpuTorchSpec $cpuVisionSpec $cpuAudioSpec @cpuForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host $torchInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install $cpuTorchSpec $cpuVisionSpec $cpuAudioSpec @cpuForce --index-url $TorchInstallIndexUrl | Out-String $torchInstallExit = $LASTEXITCODE } if ($torchInstallExit -eq 0 -or -not $_cpuKeptActive) { break } substep "[WARN] torch==$($env:UNSLOTH_KEPT_TORCH) not installable from the CPU index -- using the supported release" "Yellow" $cpuTorchSpec = $_cpuOrigTorch; $cpuVisionSpec = $_cpuOrigVision; $cpuAudioSpec = $_cpuOrigAudio Remove-Item Env:UNSLOTH_KEPT_TORCH -ErrorAction SilentlyContinue $_cpuKeptActive = $false } if ($torchInstallExit -ne 0) { Write-Host "[FAILED] PyTorch install failed (exit code $torchInstallExit)" -ForegroundColor Red Write-Host (Redact-InstallOutput $output) -ForegroundColor Red exit 1 } } elseif (-not $ROCmIndexUrl) { 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 bare torch (PEP 440 # ignores +cuXXX), so a changed CUDA pin (cu126 -> cu128) never applies without it. $cudaForce = @() if ($script:PinChangedForceReinstall) { $cudaForce = @("--force-reinstall") } # An unknown-leaf custom pin (/simple, /current) routes here: bound the trio 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.12.0" $cudaVisionSpec = "torchvision>=0.19,<0.27.0" $cudaAudioSpec = "torchaudio>=2.4,<2.12.0" } # Release preservation: keep install.ps1's exported torch RELEASE (UNSLOTH_KEPT_TORCH) so a re-run # reinstalls the same CUDA build (+cuXXX follows this index). On a failed install, restore the computed # specs and retry once. Absent the env var (direct 'studio update'), nothing changes; loop runs once. $_cudaKeptActive = $false $_cudaOrigTorch = $cudaTorchSpec; $_cudaOrigVision = $cudaVisionSpec; $_cudaOrigAudio = $cudaAudioSpec if ($env:UNSLOTH_KEPT_TORCH -match '^\d+\.\d+(\.\d+)?$') { $_keptMinor = [int](($env:UNSLOTH_KEPT_TORCH -split '\.')[1]) $cudaTorchSpec = "torch==$($env:UNSLOTH_KEPT_TORCH)" $cudaVisionSpec = "torchvision==0.$($_keptMinor + 15).*" $cudaAudioSpec = "torchaudio==2.$($_keptMinor).*" $_cudaKeptActive = $true } while ($true) { if ($script:UnslothVerbose) { Fast-Install $cudaTorchSpec $cudaVisionSpec $cudaAudioSpec @cudaForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host $torchInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install $cudaTorchSpec $cudaVisionSpec $cudaAudioSpec @cudaForce --index-url $TorchInstallIndexUrl | Out-String $torchInstallExit = $LASTEXITCODE } if ($torchInstallExit -eq 0 -or -not $_cudaKeptActive) { break } substep "[WARN] torch==$($env:UNSLOTH_KEPT_TORCH) not installable from this CUDA index -- using the supported release" "Yellow" $cudaTorchSpec = $_cudaOrigTorch; $cudaVisionSpec = $_cudaOrigVision; $cudaAudioSpec = $_cudaOrigAudio Remove-Item Env:UNSLOTH_KEPT_TORCH -ErrorAction SilentlyContinue $_cudaKeptActive = $false } if ($torchInstallExit -ne 0) { Write-Host "[FAILED] PyTorch CUDA install failed (exit code $torchInstallExit)" -ForegroundColor Red Write-Host (Redact-InstallOutput $output) -ForegroundColor Red exit 1 } # 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-Host (Redact-InstallOutput $output) -ForegroundColor Yellow } else { substep "Triton for Windows installed (enables torch.compile)" } } # No unsloth.exe rename: setup.ps1 runs via unsloth.exe, so renaming the running launcher only # ever failed (WinError 32), and it's unnecessary (install.ps1 sets SKIP_STUDIO_BASE=1). # Ordered heavy dependency installation -- shared cross-platform script substep "running ordered dependency installation..." python "$PSScriptRoot\install_python_stack.py" $stackExit = $LASTEXITCODE # Restore ErrorActionPreference after pip/python work $ErrorActionPreference = $prevEAP if ($stackExit -ne 0) { Write-Host "[FAILED] Python dependency installation failed (exit code $stackExit)" -ForegroundColor Red Write-Host " Re-run the installer or check the error above for details." -ForegroundColor Red exit 1 } } else { step "python" "dependencies up to date" # Restore ErrorActionPreference (was lowered for pip/python section) $ErrorActionPreference = $prevEAP } # Pre-install transformers 5.x into tiered sidecar venvs under $StudioHome. Runs outside the # deps fast-path gate so upgrades from the legacy single .venv_t5 always migrate. $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-Host "" $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-Host "[FAIL] Could not install $pkg into .venv_t5_530/" -ForegroundColor Red Write-Host (Redact-InstallOutput $output) -ForegroundColor Red $ErrorActionPreference = $prevEAP_t5 exit 1 } } if ($script:UnslothVerbose) { Fast-Install --target $VenvT5_530Dir tiktoken $tiktokenInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install --target $VenvT5_530Dir 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-Host "[FAIL] Could not install $pkg into .venv_t5_550/" -ForegroundColor Red Write-Host (Redact-InstallOutput $output) -ForegroundColor Red $ErrorActionPreference = $prevEAP_t5 exit 1 } } if ($script:UnslothVerbose) { Fast-Install --target $VenvT5_550Dir tiktoken $tiktokenInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install --target $VenvT5_550Dir 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-Host "[FAIL] Could not install $pkg into .venv_t5_510/" -ForegroundColor Red Write-Host (Redact-InstallOutput $output) -ForegroundColor Red $ErrorActionPreference = $prevEAP_t5 exit 1 } } if ($script:UnslothVerbose) { Fast-Install --target $VenvT5_510Dir tiktoken $tiktokenInstallExit = $LASTEXITCODE $output = "" } else { $output = Fast-Install --target $VenvT5_510Dir 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 # ========================================================================== # Nest llama.cpp under $StudioHome only for real env-overrides (reuses $StudioHomeIsCustom so # the nest matches ownership-guard semantics). if ($StudioHomeIsCustom) { $UnslothHome = $StudioHome } else { $UnslothHome = Join-Path $env:USERPROFILE ".unsloth" } if (-not (Test-Path -LiteralPath $UnslothHome)) { [System.IO.Directory]::CreateDirectory($UnslothHome) | Out-Null } $LlamaCppDir = Join-Path $UnslothHome "llama.cpp" $NeedLlamaSourceBuild = $false $SkipPrebuiltInstall = $false $RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag } # Every host installs the fork's app-* prebuilts (GPU CUDA/ROCm + CPU 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 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 (2>&1) so PS doesn't turn stderr into ErrorRecords; split 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-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red exit 1 } 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) { if (-not (Test-Path -LiteralPath $LocalLlamaCppSrc -PathType Container)) { step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $LocalLlamaCppSrc" "Red" exit 1 } $ResolvedLocal = (Resolve-Path -LiteralPath $LocalLlamaCppSrc).Path # Reusing a local dir disables prebuilt + source build, so a runnable llama-server.exe must # exist. Accept any layout LlamaCppBackend._layout_candidates() resolves. $LocalLlamaServerFound = $false 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"))) { if (Test-Path -LiteralPath $_cand) { $LocalLlamaServerFound = $true; break } } if ($ResolvedLocal -eq $LlamaCppDir) { # Points at the canonical install location itself: never delete-then-link onto itself. # Reuse an existing build (skip prebuilt + source); if nothing is built, fall through to 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/wrong-platform checkout 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 1 } # If the target is already a junction/symlink, delete only the link via DirectoryInfo.Delete() # -- Remove-Item -Recurse -Force can traverse a reparse point and wipe the real dir on PS 5.1. # Use Get-Item -Force (not Test-Path): a broken junction reads false under Test-Path, leaving # a dangling link that makes mklink fail; Get-Item still resolves it so we can relink. $existing = Get-Item -LiteralPath $LlamaCppDir -Force -ErrorAction SilentlyContinue if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $existing.Delete() } if ($StudioHomeIsCustom) { Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" } if (Test-Path -LiteralPath $LlamaCppDir) { Remove-Item -Recurse -Force -LiteralPath $LlamaCppDir -ErrorAction SilentlyContinue # A locked tree can survive removal (SilentlyContinue masks it); don't junction/copy # over a half-present dir -- stop with a clear message like the prebuilt path. if (Test-Path -LiteralPath $LlamaCppDir) { step "llama.cpp" "install blocked by active llama.cpp process" "Yellow" substep "Close Unsloth or other llama.cpp users and retry" "Yellow" exit 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-Host "" step "llama.cpp" "linked local directory: $ResolvedLocal" $LocalLlamaCppLinked = $true $NeedLlamaSourceBuild = $false } } if ($LocalLlamaCppLinked) { # local directory linked above; skip prebuilt install } elseif ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { Write-Host "" substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow" $NeedLlamaSourceBuild = $true } elseif ($SkipPrebuiltInstall) { Write-Host "" substep "Skipping prebuilt install -- falling back to source build" "Yellow" } else { Write-Host "" if (Test-Path -LiteralPath $LlamaCppDir) { substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement" # If the existing install is the wrong kind, remove it so the installer downloads the # correct variant rather than skipping on tag match. $existingMetaPath = Join-Path $LlamaCppDir "UNSLOTH_PREBUILT_INFO.json" if (Test-Path $existingMetaPath) { try { $existingMeta = Get-Content $existingMetaPath -Raw | ConvertFrom-Json $existingKind = $existingMeta.install_kind # A ROCm host may carry windows-rocm or the windows-hip fallback (accept either); # a name-inferred gfx arch counts as ROCm-capable. The CPU branch covers windows-cpu # and windows-arm64. NOTE: currently inert -- write_prebuilt_metadata persists no # install_kind, so $existingKind is always null; keep $expectedKinds in sync first. $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)..." # install_llama_prebuilt.py uses os.replace(), which would displace an unrelated # custom-home llama.cpp before the source-build ownership check 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 # probe can't confirm the runtime. --rocm-gfx is authoritative and implies ROCm, so the # GPU prebuilt is selected with $HasROCm false (gating on $HasROCm gave Strix Halo CPU). if ($script:ROCmGfxArch) { $prebuiltArgs += @("--rocm-gfx", $script:ROCmGfxArch) } if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } # UNSLOTH_LLAMA_CPP_BACKEND=cpu forces the CPU-only prebuilt via --force-cpu (Intel iGPU Vulkan crash #7213). $llamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() if ($llamaBackend -eq "cpu") { $prebuiltArgs += "--force-cpu" } elseif ($llamaBackend -and $llamaBackend -ne "auto") { Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto' or 'cpu')" -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) { # Live output while still capturing for the 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 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-Path -LiteralPath $_cand) { $PreservedLlamaServerFound = $true; break } } if (-not $PreservedLlamaServerFound) { $script:LlamaCppDegraded = $true } } else { step "llama.cpp" "prebuilt install failed (continuing)" "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" } substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow" $NeedLlamaSourceBuild = $true } } # ========================================================================== # 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 (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-Path -LiteralPath $WhisperCppDir -PathType 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-Path -LiteralPath $llamaMarker -PathType 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 HF models via -hf; ShiningLight.OpenSSL.Dev ships headers + libs cmake finds. $OpenSslAvailable = $false if ($NeedLlamaSourceBuild) { # Already installed? (look for the 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-Host "" 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 (shared by the inference server + GGUF export). Targets: # llama-server (GGUF inference, HTTPS if OpenSSL) and llama-quantize (GGUF export). git/cmake/VS # came from Phase 1; the CUDA Toolkit is resolved lazily below (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) # Does the existing llama-server match the current GPU mode? A CUDA binary on a now-CPU-only machine (or vice versa) rebuilds. $NeedRebuild = $false if (Test-Path -LiteralPath $LlamaServerBin) { $CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt" if (Test-Path -LiteralPath $CmakeCacheFile) { $cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet if ($HasNvidiaSmi -and -not $cachedCuda) { Write-Host " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow $NeedRebuild = $true } elseif (-not $HasNvidiaSmi -and $cachedCuda) { Write-Host " Existing llama-server was built with CUDA but no GPU detected -- rebuilding" -ForegroundColor Yellow $NeedRebuild = $true } } } # Install build tools now (last resort) not eagerly in Phase 1, so the prebuilt path stays fast. # Same condition as the chain below: source build runs only when needed and no usable binary exists. $WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and ` -not ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") if ($WillBuildLlamaFromSource) { 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: skip BOTH prebuilt download and source build (falling through would # run CMake inside the user's checkout via the junction). Write-Host "" step "llama.cpp" "linked (skipping build)" } elseif (-not $NeedLlamaSourceBuild) { Write-Host "" step "llama.cpp" "prebuilt (validated)" } elseif ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") { # Skip rebuild only for pinned tags; "master" is a moving target so always rebuild. Write-Host "" step "llama.cpp" "already built" } elseif (-not $HasCmakeForBuild) { Write-Host "" if (-not $HasNvidiaSmi) { # CPU-only machines depend entirely on llama-server for GGUF chat -- cmake 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 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 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, then prepend the default dir so the new cmake wins over an older one. 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 for a Scoop/Chocolatey/VS cmake; install to get a 4.2+ one. 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 can't drive VS 2026; before failing, fall back to an older installed VS. $fallback = Get-FallbackVsGenerator if ($fallback) { substep "CMake cannot drive $CmakeGenerator; falling back to $($fallback.Generator)" "Yellow" $CmakeGenerator = $fallback.Generator $VsInstallPath = $fallback.InstallPath } else { Write-Host "[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-Host " Upgrade CMake from https://cmake.org/download/ and re-run, or use a prebuilt llama.cpp bundle." -ForegroundColor Red exit 1 } } } substep "CMake can drive the $CmakeGenerator generator" } # CUDA resolved here (fail fast), after the final VS generator so its .targets land in the toolset cmake uses. if ($HasNvidiaSmi) { Resolve-CudaToolkit -RequireOrExit } Write-Host "" if ($HasNvidiaSmi) { substep "building llama.cpp with CUDA support..." } elseif ($HasROCm -or $script:ROCmGfxArch) { # AMD GPU in the CPU-only source-build fallback: a HIP source build needs the full HIP SDK, # and GPU acceleration comes from the per-gfx ROCm prebuilt (reaching here means it failed). $_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-Host "" # Start total build timer $totalSw = [System.Diagnostics.Stopwatch]::StartNew() # git/cmake write to stderr even on success, which -ErrorAction Stop turns terminating on PS 5.1; lower for the build. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" $BuildOk = $true $FailedStep = "" # Re-sanitize CUDA_PATH_V* vars: Refresh-Environment (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)) if (Test-Path -LiteralPath (Join-Path $LlamaCppDir ".git")) { # In-place git mutation (set-url, checkout -B, clean -fdx) rewrites $LlamaCppDir; guard so # an unrelated workspace .git tree is never silently overwritten. if ($StudioHomeIsCustom) { Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" } Write-Host " 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 } } } # In-place git-sync must mark the existing tree so a later prebuilt update's # Assert-StudioOwnedOrAbsent doesn't exit on the same root. if ($BuildOk -and $StudioHomeIsCustom) { Mark-StudioOwned -Path $LlamaCppDir } } else { Write-Host " 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-Host "" Write-Host "--- cmake configure ---" -ForegroundColor Cyan $CmakeArgs = @( '-S', $LlamaCppDir, '-B', $BuildDir, '-G', $CmakeGenerator, '-Wno-dev' ) # Tell cmake 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, 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 (set UNSLOTH_LLAMA_CUDA_ARCHS=120 to force CUDA). 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 otherwise aborts # "unsupported Microsoft Visual Studio version"). Via env, 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 JITs 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-Host "" Write-Host " Hint: CUDA VS integration may be missing. Try running as admin:" -ForegroundColor Yellow Write-Host " Copy contents of:" -ForegroundColor Yellow Write-Host " \extras\visual_studio_integration\MSBuildExtensions" -ForegroundColor Yellow Write-Host " into:" -ForegroundColor Yellow $hintCustomizations = if ($VsInstallPath) { Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator } else { "\MSBuild\Microsoft\VC\v170\BuildCustomizations" } Write-Host " $hintCustomizations" -ForegroundColor Yellow } } } # -- Step C: Build llama-server -- $NumCpu = [Environment]::ProcessorCount if ($NumCpu -lt 1) { $NumCpu = 4 } if ($BuildOk) { Write-Host "" Write-Host "--- cmake build (llama-server) ---" -ForegroundColor Cyan Write-Host " Parallel jobs: $NumCpu" -ForegroundColor Gray Write-Host "" $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-Host "" Write-Host "--- 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) -- # Example target from llama.cpp PR #24423; serves DiffusionGemma GGUFs without DG_VISUAL_BIN. 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 (Test-Path -LiteralPath $OriginalLlamaCppDir) { Remove-Item -LiteralPath $OriginalLlamaCppDir -Recurse -Force } Move-Item -LiteralPath $LlamaCppDir -Destination $OriginalLlamaCppDir $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-Path -LiteralPath $LlamaServerBin)) { step "llama.cpp" "built" $QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe" if (Test-Path -LiteralPath $QuantizeBin) { 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-Path -LiteralPath $altBin)) { 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-Path -LiteralPath (Join-Path $LlamaCppDir $StudioOwnedMarker) -PathType 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-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) if ($script:LlamaCppDegraded) { Write-Host (" " + (Get-StudioAnsi Warn) + "$DoneLabel (limited: llama.cpp unavailable)" + (Get-StudioAnsi Reset)) } else { Write-Host (" " + (Get-StudioAnsi Title) + $DoneLabel + (Get-StudioAnsi Reset)) } Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) } else { Write-Host " $Rule" -ForegroundColor DarkGray if ($script:LlamaCppDegraded) { Write-Host " $DoneLabel (limited: llama.cpp unavailable)" -ForegroundColor Yellow } else { Write-Host " $DoneLabel" -ForegroundColor Green } Write-Host " $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-Host "" # Match setup.sh: exit non-zero for degraded llama.cpp when called from install.ps1 # (SKIP_STUDIO_BASE=1) so the installer detects it; direct 'studio update' stays successful. if ($script:LlamaCppDegraded -and $env:SKIP_STUDIO_BASE -eq "1") { exit 1 }