diff --git a/install.ps1 b/install.ps1 index a2aff0b69a..0ea1d08b0e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -49,6 +49,12 @@ function Install-UnslothStudio { } } + # raw.githubusercontent.com ref for install assets; UNSLOTH_INSTALL_REF overrides 'main' for pre-merge testing. + function Get-UnslothInstallRef { + if ($env:UNSLOTH_INSTALL_REF -and $env:UNSLOTH_INSTALL_REF.Trim()) { return $env:UNSLOTH_INSTALL_REF.Trim() } + return 'main' + } + function Get-TauriTorchIndexFamily { param([string]$TorchIndexUrl) if ($SkipTorch) { return "none" } @@ -93,6 +99,13 @@ function Install-UnslothStudio { if ($TauriMode) { exit $Code } + # -File: `exit` carries the code. Under `irm | iex` (no $PSCommandPath) `exit` + # would kill the user's shell, so set the var then throw: interactive shells + # survive it, `-Command "irm ... | iex"` automation exits 1 (return would look OK). + if ($PSCommandPath) { + exit $Code + } + $global:LASTEXITCODE = $Code throw $Message } @@ -610,7 +623,7 @@ function Install-UnslothStudio { if ($PSScriptRoot -and $PSScriptRoot.Trim()) { $bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico" } - $iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico" + $iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/$(Get-UnslothInstallRef)/studio/frontend/public/unsloth.ico" if (-not (Test-Path -LiteralPath $appDir)) { [System.IO.Directory]::CreateDirectory($appDir) | Out-Null @@ -2189,6 +2202,465 @@ exit 0 (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) $TorchIndexUrl = Get-TorchIndexUrl + # ===== Windows-on-ARM + NVIDIA GPU -> automatic WSL2 fallback (N1X "RTX Spark" / DGX Spark-class) ===== + # win_arm64 has no CUDA PyTorch/Triton wheel, so run the Linux installer inside WSL2 (full + # GPU) plus a Windows `unsloth` shim into it; x86_64 / ARM64-without-NVIDIA unaffected, and + # the probe below keeps the native install if a win_arm64 CUDA wheel ever ships. + # Opt out: UNSLOTH_NO_WSL_FALLBACK=1; pick distro with UNSLOTH_WSL_DISTRO. + try { $_winArm64 = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ieq 'Arm64') } catch { $_winArm64 = $false } + # x64-emulated PS on ARM reports X64/AMD64; Win32_Processor.Architecture (12=ARM64) and + # machine-level PROCESSOR_ARCHITECTURE read the true arch. Only ever turns $_winArm64 ON. + if (-not $_winArm64) { + try { if ((@(Get-CimInstance Win32_Processor -ErrorAction Stop))[0].Architecture -eq 12) { $_winArm64 = $true } } catch {} + } + if (-not $_winArm64) { + try { + $_machArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE -ErrorAction Stop).PROCESSOR_ARCHITECTURE + if ($_machArch -ieq 'ARM64') { $_winArm64 = $true } + } catch {} + } + $_nativeCudaTorchOk = $false + if ($_winArm64 -and $HasNvidiaSmi -and (-not $SkipTorch)) { + # uv resolves for the interpreter's platform tags, so an x64-emulated venv + # python would match the win_amd64 CUDA wheels WoA can't use. Only a real + # win_arm64 interpreter proves a win_arm64 CUDA wheel; else keep the WSL fallback. + $_pyArch = "" + try { $_pyArch = (& $VenvPython -c "import platform; print(platform.machine())" 2>$null | Select-Object -First 1) } catch {} + if ("$_pyArch" -imatch 'ARM64') { + # Probe the real install's exact spec; a bare `torch` could match an + # out-of-range wheel, skipping WSL only to fail the real pinned install. + $prevEapProbe = $ErrorActionPreference; $ErrorActionPreference = "Continue" + # --reinstall: an installed (e.g. CPU-only) torch mustn't satisfy the probe; + # it must prove a native win_arm64 CUDA wheel exists on the index. + $global:LASTEXITCODE = -1 + try { + & uv pip install --python $VenvPython --dry-run --reinstall "torch>=2.4,<2.11.0" --default-index $TorchIndexUrl *> $null + $_nativeCudaTorchOk = ($LASTEXITCODE -eq 0) + } catch { $_nativeCudaTorchOk = $false } finally { $ErrorActionPreference = $prevEapProbe } + if ($_nativeCudaTorchOk) { step "gpu" "native CUDA PyTorch now available for win_arm64 -- keeping native install" "Green" } + } + } + if ($_winArm64 -and $HasNvidiaSmi -and (-not $_nativeCudaTorchOk) -and (-not $SkipTorch) -and ($env:UNSLOTH_NO_WSL_FALLBACK -ne '1')) { + step "wsl" "Windows on ARM + NVIDIA, native CUDA unavailable -- routing GPU setup through WSL2" + substep "no win_arm64 CUDA PyTorch/Triton yet; WSL2 delivers full GPU (DGX Spark / RTX Spark path)." "Yellow" + + # The Tauri desktop app launches its backend from a Windows venv, not WSL, so a + # WSL-only install would start nothing -- send those users to the CLI installer. + if ($TauriMode) { + # A prior native Studio venv was rolled aside (Start-StudioVenvRollback) before + # here; restore it so rejecting this path doesn't orphan the user's working + # install. No-op when nothing was rolled aside. + Restore-StudioVenvRollback + return (Exit-InstallFailure "Windows-on-ARM + NVIDIA GPU needs the WSL2 GPU install, which the desktop app can't launch yet. Install from PowerShell instead: irm https://unsloth.ai/install.ps1 | iex" 1) + } + + # --local installs the Windows checkout editably, but the WSL tunnel installs from + # PyPI / a git ref and never mounts $RepoRoot -- so --local here would silently + # install the published package in WSL and report success. Reject it and point at + # the supported pre-merge mechanism (push the branch + UNSLOTH_INSTALL_REF). + if ($StudioLocalInstall) { + Restore-StudioVenvRollback # see TauriMode note above: don't orphan a rolled-aside venv + return (Exit-InstallFailure "--local can't be honored on Windows-on-ARM + NVIDIA: the GPU install runs inside WSL2 and installs from a published/git ref, not this Windows checkout. For pre-merge testing, push your branch and set UNSLOTH_INSTALL_REF, e.g.: `$env:UNSLOTH_INSTALL_REF=''; irm https://unsloth.ai/install.ps1 | iex" 1) + } + # A custom Studio root only applies to the native Windows layout; the WoA GPU + # install lives in WSL at /root/.unsloth with fixed shim/verification paths. Warn + # rather than pretend to honor it (the uninstaller still cleans the WSL install). + if ($envOverride) { + substep "note: $envOverrideVar='$envOverride' is not used for the Windows-on-ARM WSL install -- Studio installs inside WSL at /root/.unsloth." "Yellow" + } + # --with-llama-cpp-dir names a Windows-side llama.cpp, but this install runs it + # inside WSL2 and would silently ignore the choice. Reject like --local and point + # at the supported WSL-side pins. + if ($WithLlamaCppDir -or $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR) { + Restore-StudioVenvRollback + return (Exit-InstallFailure "--with-llama-cpp-dir / UNSLOTH_LOCAL_LLAMA_CPP_DIR can't be honored on Windows-on-ARM + NVIDIA: llama.cpp runs inside WSL2 and can't use a Windows path. Remove it, or pin the WSL-side build with UNSLOTH_LLAMA_TAG or UNSLOTH_LLAMA_PR instead." 1) + } + + $wslReady = $false + if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + # Reset: a stale 0 would wrongly mark WSL ready if wsl.exe fails to start. + $global:LASTEXITCODE = -1 + try { & wsl.exe --status *> $null; if ($LASTEXITCODE -eq 0) { $wslReady = $true } } catch {} + } + + if (-not $wslReady) { + $isAdmin = $false + try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) } catch {} + step "wsl" "WSL2 isn't enabled yet -- one-time setup (needs admin + reboot)" "Yellow" + if ($isAdmin) { + substep "enabling WSL2..." "Cyan" + try { & wsl.exe --install --no-launch } catch {} + substep "WSL2 enabled. REBOOT, then re-run: irm https://unsloth.ai/install.ps1 | iex" "Green" + } else { + substep "in an ADMINISTRATOR PowerShell run: wsl --install" "Cyan" + substep "reboot, then re-run: irm https://unsloth.ai/install.ps1 | iex" "Cyan" + } + # Deferred until reboot: fail via Exit-InstallFailure so automation can't + # read a deferred setup as success (restores venv, exits 1 / throws). + return (Exit-InstallFailure "WSL setup deferred: enable WSL2 and reboot, then re-run the installer") + } + + $distro = if ($env:UNSLOTH_WSL_DISTRO) { $env:UNSLOTH_WSL_DISTRO } else { "Ubuntu-24.04" } + # For cmd-context uses (.cmd shim, hints): wsl.exe rejects a QUOTED space-free name + # but splits a bare spaced one after -d, so quote ONLY when spaced. + $_distroArg = if ($distro -match '\s') { '"' + $distro + '"' } else { $distro } + # Detect the distro by exit code (encoding-proof; wsl --list emits UTF-16 that PS mis-parses). + $haveDistro = $false + $global:LASTEXITCODE = -1 + try { & wsl.exe -d $distro -- true *> $null; if ($LASTEXITCODE -eq 0) { $haveDistro = $true } } catch {} + if (-not $haveDistro) { + substep "installing WSL distro '$distro' (first time only)..." "Cyan" + # Force version 2 so a WSL1-default host doesn't get a GPU-less distro + # (would fail only at torch.cuda). + $global:LASTEXITCODE = -1 + try { & wsl.exe --set-default-version 2 *> $null } catch {} + try { & wsl.exe --install -d $distro --no-launch } catch {} + } + # Verify WSL2 (set-default-version can fail silently on old builds, leaving a WSL1 + # distro that only fails at torch.cuda). Detect from inside (encoding-proof, unlike + # UTF-16 `wsl -l -v`) and convert in place -- `wsl --set-version` preserves files. + $_wsl2Probe = 'grep -qiE ''microsoft-standard|WSL2'' /proc/version 2>/dev/null || test -e /usr/lib/wsl/lib/libcuda.so' + $_isWsl2 = $false + $global:LASTEXITCODE = -1 + try { & wsl.exe -d $distro -u root -- bash -c $_wsl2Probe *> $null; $_isWsl2 = ($LASTEXITCODE -eq 0) } catch {} + if (-not $_isWsl2) { + substep "distro '$distro' looks like WSL1 (no GPU passthrough) -- converting to WSL2 (one-time; can take a few minutes)..." "Yellow" + $global:LASTEXITCODE = -1 + try { & wsl.exe --set-version $distro 2 } catch {} + $global:LASTEXITCODE = -1 + try { & wsl.exe -d $distro -u root -- bash -c $_wsl2Probe *> $null; $_isWsl2 = ($LASTEXITCODE -eq 0) } catch {} + if (-not $_isWsl2) { + Restore-StudioVenvRollback + return (Exit-InstallFailure "WSL distro '$distro' is WSL1 and automatic conversion failed; NVIDIA GPU passthrough needs WSL2. Convert it, then re-run the installer: wsl --set-version `"$distro`" 2" 1) + } + substep "'$distro' converted to WSL2." "Green" + } + substep "installing Unsloth Studio inside WSL '$distro' with full GPU (this downloads PyTorch)..." "Cyan" + # Non-main ref: fetch + export THAT ref so the WSL venv gets the branch's setup.sh + # + patches (else install.sh pulls PyPI unsloth). main == plain install.sh. + $_instRef = Get-UnslothInstallRef + # The ref is spliced into the inner `bash -lc` twice, so shell metacharacters would + # inject. git refs can't contain those anyway; enforce a strict allow-list and + # reject loudly rather than silently mangle the install. + if ($_instRef -ne 'main' -and ($_instRef -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$')) { + Restore-StudioVenvRollback # see TauriMode note above: don't orphan a rolled-aside venv + return (Exit-InstallFailure "UNSLOTH_INSTALL_REF='$_instRef' is not a valid git ref (allowed: letters, digits, '.', '_', '/', '-'). Set it to a real branch or tag name." 1) + } + # UNSLOTH_WSL_LLAMA_DEFERRED=1: setup.sh skips its foreground CUDA llama.cpp build; + # we build it in the background. apt stderr stays visible so failures are diagnosable. + # Forward UNSLOTH_NO_LLAMA_CUDA (it also skips the dispatch below, so unforwarded + # setup.sh would defer to a background builder that never starts). + $_fwdEnv = '' + if ($env:UNSLOTH_NO_LLAMA_CUDA -eq '1') { $_fwdEnv = 'export UNSLOTH_NO_LLAMA_CUDA=1; ' } + # Forward a user Python pin (Windows env vars don't cross into WSL unless bridged). + # Numeric-only guard (e.g. 3.12) prevents injection. + if ($env:UNSLOTH_PYTHON -and ($env:UNSLOTH_PYTHON -match '^[0-9][0-9.]*$')) { $_fwdEnv += "export UNSLOTH_PYTHON=$($env:UNSLOTH_PYTHON); " } + # Forward a custom PyTorch wheel mirror (doesn't cross into WSL, so a restricted- + # network install would silently fall back to download.pytorch.org). Strict http(s) + # allow-list + single-quote so the value can't break out of the bash -lc string. + if ($env:UNSLOTH_PYTORCH_MIRROR -and ($env:UNSLOTH_PYTORCH_MIRROR -match '^https?://[A-Za-z0-9._~:/?#@%+=&-]+$')) { + $_fwdEnv += "export UNSLOTH_PYTORCH_MIRROR='$($env:UNSLOTH_PYTORCH_MIRROR)'; " + } + # Forward the npm mirror the same way (else the WSL frontend/OXC steps hit + # registry.npmjs.org and fail on mirror-required networks). Same allow-list as above. + if ($env:UNSLOTH_NPM_REGISTRY -and ($env:UNSLOTH_NPM_REGISTRY -match '^https?://[A-Za-z0-9._~:/?#@%+=&-]+$')) { + $_fwdEnv += "export UNSLOTH_NPM_REGISTRY='$($env:UNSLOTH_NPM_REGISTRY)'; " + } + # Forward an explicit UNSLOTH_PYTHON pin (env vars don't cross into WSL, so without + # this install.sh silently built the venv on its default Python). Strict version + # shape so the splice into bash -lc can't break out; default stays install.sh's. + if ($env:UNSLOTH_PYTHON -and ($env:UNSLOTH_PYTHON -match '^\d+\.\d+(\.\d+)?$')) { + $_fwdEnv += "export UNSLOTH_PYTHON='$($env:UNSLOTH_PYTHON)'; " + } + # install.ps1 owns the WoA shortcut; tell install.sh to skip its own WSL .lnk so we + # don't get a duplicate whose %LOCALAPPDATA% icon renders blank. Persist a marker + # too: `unsloth studio update` reruns install.sh through the wsl.exe shim (no env), + # so without it the first update recreates the duplicate .lnk. + # Clear any previous completion stamp: setup.sh rewrites it only after the core venv + # + Studio deps finish and the post-run gate below requires it, so a run that dies + # mid-install can no longer coast on a stale venv passing the torch/CLI probes. + $_fwdEnv += 'export UNSLOTH_SKIP_WSL_WINDOWS_SHORTCUT=1; mkdir -p /root/.unsloth; touch /root/.unsloth/.skip-wsl-windows-shortcut; rm -f /root/.unsloth/.install-ok; ' + # Root login shells reset PATH and can drop /usr/lib/wsl/lib, the only nvidia-smi + # location under WSL2 GPU-PV; without it install.sh picks CPU torch wheels and the + # torch.cuda probe fails. Appended (not prepended) so a PATH nvidia-smi still wins. + $_fwdEnv += 'export PATH="$PATH:/usr/lib/wsl/lib"; ' + # Forward a non-default --package (validated at parse time, so splicing is safe); + # previously it was silently dropped and the user got stock unsloth. + $_shArgs = '' + if ($PackageName -ne 'unsloth') { $_shArgs = ' --package ' + $PackageName } + # Download to a file instead of `curl | sh`: a failed download feeds sh empty + # stdin (exit 0) and a rerun's stale venv then passes the torch probe, faking + # success without the installer running. Exit 86 is the "download failed" sentinel + # checked after the run. /root/.unsloth exists already and is removed on uninstall. + if ($_instRef -eq 'main') { + $wslInstall = $_fwdEnv + 'export DEBIAN_FRONTEND=noninteractive UNSLOTH_WSL_LLAMA_DEFERRED=1; apt-get update -y >/dev/null; apt-get install -y build-essential cmake git curl pciutils libcurl4-openssl-dev >/dev/null; curl -fsSL https://unsloth.ai/install.sh -o /root/.unsloth/unsloth-install.sh || exit 86; sh /root/.unsloth/unsloth-install.sh' + $_shArgs + } else { + $wslInstall = $_fwdEnv + 'export DEBIAN_FRONTEND=noninteractive UNSLOTH_WSL_LLAMA_DEFERRED=1; export UNSLOTH_INSTALL_REF=' + $_instRef + '; apt-get update -y >/dev/null; apt-get install -y build-essential cmake git curl pciutils libcurl4-openssl-dev >/dev/null; curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/' + $_instRef + '/install.sh -o /root/.unsloth/unsloth-install.sh || exit 86; sh /root/.unsloth/unsloth-install.sh' + $_shArgs + } + # install.sh may exit non-zero on the optional llama.cpp prebuilt step (no aarch64 prebuilt) + # even though torch + unsloth + Studio install; lower EAP so it doesn't abort under Stop. + $prevEapWsl = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $global:LASTEXITCODE = -1 + try { + & wsl.exe -d $distro --cd /root -u root -- bash -lc $wslInstall + $wslRc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEapWsl + } + Write-Host "" + # Download sentinel: the installer never ran, so the probes below would only + # re-validate a stale venv from a previous install. + if ($wslRc -eq 86) { + step "wsl" "could not download install.sh inside WSL (network or bad ref) -- the installer never ran." "Yellow" + # Exit-InstallFailure restores the rollback and fails in every invocation mode. + return (Exit-InstallFailure "could not download install.sh inside WSL; the installer never ran") + } + # $wslRc can be non-zero from the llama.cpp step even on success; verify torch.cuda directly. + $torchOk = $false + $prevEapChk = $ErrorActionPreference + $ErrorActionPreference = "Continue" + # Reset so a stale 0 can't mark torch OK if this fails to launch. + $global:LASTEXITCODE = -1 + try { + & wsl.exe -d $distro --cd /root -u root -- /root/.unsloth/studio/unsloth_studio/bin/python -c "import torch,sys; sys.exit(0 if torch.cuda.is_available() else 3)" *> $null + $torchOk = ($LASTEXITCODE -eq 0) + } catch {} finally { $ErrorActionPreference = $prevEapChk } + # torch.cuda alone isn't success: install.sh can exit after PyTorch but before the + # `unsloth` console script, and $wslRc can't tell. Verify the exact binary the shim + # execs exists -- else we'd write a dangling shim and report a broken install as OK. + if ($torchOk) { + $prevEapCli = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $global:LASTEXITCODE = -1 + try { & wsl.exe -d $distro --cd /root -u root -- test -x /root/.unsloth/studio/unsloth_studio/bin/unsloth *> $null } catch {} + $ErrorActionPreference = $prevEapCli + if ($LASTEXITCODE -ne 0) { + substep "WSL install incomplete: 'unsloth' CLI missing (install.sh cut short after PyTorch) -- not creating a dangling shim." "Yellow" + $torchOk = $false + } + } + # Require the completion stamp setup.sh writes after the core venv + Studio deps + # finish (cleared before the run). torch + CLI alone can both come from a stale + # PREVIOUS install while this run died mid-way, which the tolerated nonzero $wslRc + # can't distinguish. Existence only, no mtime: WSL/Windows clocks can skew. + if ($torchOk) { + $prevEapStamp = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $global:LASTEXITCODE = -1 + try { & wsl.exe -d $distro --cd /root -u root -- test -f /root/.unsloth/.install-ok *> $null } catch {} + $ErrorActionPreference = $prevEapStamp + if ($LASTEXITCODE -ne 0) { + substep "WSL install did not complete its core steps this run (no completion stamp; inner exit $wslRc) -- the venv passing the probes is from a previous install." "Yellow" + $torchOk = $false + } + } + # Self-heal web-server deps: a cut-short "studio deps" step leaves torch + unsloth + # but no fastapi/uvicorn/structlog/starlette (`unsloth studio` dies). Reinstall them + # unpinned (no hf-hub/transformers/datasets) so the verified GPU torch stack stays. + if ($torchOk) { + $_studioPy = "/root/.unsloth/studio/unsloth_studio/bin/python" + $_serverOk = $false + $prevEapS = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { + & wsl.exe -d $distro --cd /root -u root -- $_studioPy -c "import structlog, fastapi, uvicorn, starlette" *> $null + $_serverOk = ($LASTEXITCODE -eq 0) + } catch {} finally { $ErrorActionPreference = $prevEapS } + if (-not $_serverOk) { + substep "Studio web-server deps incomplete (install.sh step cut short) -- installing them now..." "Cyan" + # studio.txt minus the hf-hub pin; uv preferred, pip fallback. Bare names + # only: `>=` would become a redirection through PS -> wsl.exe -> bash -lc, + # and latest-of-each satisfies the studio.txt minimums anyway. + $_deps = 'typer fastapi uvicorn matplotlib pandas nest_asyncio pyjwt easydict addict structlog diceware ddgs cryptography httpx fastmcp sqlite-vec pymupdf python-docx' + $_repair = 'PY=/root/.unsloth/studio/unsloth_studio/bin/python; UV="$(command -v uv 2>/dev/null || echo /root/.local/bin/uv)"; if [ -x "$UV" ] || command -v uv >/dev/null 2>&1; then "$UV" pip install --python "$PY" ' + $_deps + '; else "$PY" -m pip install ' + $_deps + '; fi' + $prevEapR = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { & wsl.exe -d $distro --cd /root -u root -- bash -lc $_repair } catch {} finally { $ErrorActionPreference = $prevEapR } + $prevEapS2 = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { + & wsl.exe -d $distro --cd /root -u root -- $_studioPy -c "import structlog, fastapi, uvicorn, starlette" *> $null + $_serverOk = ($LASTEXITCODE -eq 0) + } catch {} finally { $ErrorActionPreference = $prevEapS2 } + if ($_serverOk) { substep "Studio web-server deps installed." "Green" } + else { + # The missing set includes typer, so even the plain unsloth CLI dies; + # reporting success would advertise commands that can't run. Route to + # the failure path, like the CLI-missing case above. + substep "Studio server deps missing and the repair failed -- not reporting success over a broken install." "Yellow" + $torchOk = $false + } + } + # The uv-managed venv ships no `pip`, but unsloth-zoo's check_pip() finds `uv + # pip` only when uv is on PATH. Seed pip so `save_pretrained_gguf` works regardless. + $prevEapP = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { + & wsl.exe -d $distro --cd /root -u root -- $_studioPy -m pip --version *> $null + if ($LASTEXITCODE -ne 0) { + & wsl.exe -d $distro --cd /root -u root -- $_studioPy -m ensurepip --upgrade *> $null + } + } catch {} finally { $ErrorActionPreference = $prevEapP } + } + if ($torchOk) { + step "done" "Unsloth Studio installed in WSL '$distro' -- GPU ready (torch.cuda available)." "Green" + # Native Windows `unsloth` shim forwards every `unsloth ...` into the WSL GPU + # env. WSL2 forwards 127.0.0.1, so http://localhost:8888 opens in Windows. + try { + $shimDir = Join-Path $env:LOCALAPPDATA "Unsloth\bin" + New-Item -ItemType Directory -Force -Path $shimDir *> $null + $shimLines = @( + '@echo off', + # $_distroArg: pre-quoted only when spaced (wsl.exe quoting rule above). + "wsl.exe -d $_distroArg -u root -- /root/.unsloth/studio/unsloth_studio/bin/unsloth %*" + ) + Set-Content -LiteralPath (Join-Path $shimDir "unsloth.cmd") -Value $shimLines -Encoding ASCII + # Record the distro so the uninstaller can clean a custom + # UNSLOTH_WSL_DISTRO install without the env var set. + try { Set-Content -LiteralPath (Join-Path (Split-Path $shimDir -Parent) "wsl-distro.txt") -Value $distro -Encoding ASCII } catch {} + # PREPEND (not append): a previous NATIVE install prepended its + # unsloth.exe to user PATH, and that exe outlives the rolled-aside venv -- + # an appended shim would lose to the dead launcher. Add-ToUserPath de-dupes. + $null = Add-ToUserPath -Directory $shimDir -Position 'Prepend' + $env:Path = $shimDir + ";" + $env:Path.TrimStart(';') + # Drop the dead default-root native shim when the venv binary it launches + # is gone (custom-root shims are left alone; the PATH prepend outranks them). + try { + $staleNativeShim = Join-Path $env:USERPROFILE ".unsloth\studio\bin\unsloth.exe" + $staleNativeTarget = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio\Scripts\unsloth.exe" + if ((Test-Path -LiteralPath $staleNativeShim) -and -not (Test-Path -LiteralPath $staleNativeTarget)) { + Remove-Item -LiteralPath $staleNativeShim -Force -ErrorAction Stop + } + } catch {} + step "shim" "created native 'unsloth' command -> forwards to WSL '$distro'" "Green" + substep "open a NEW terminal, then (no WSL knowledge needed):" "Cyan" + substep " unsloth studio # runs in WSL; opens http://localhost:8888" "Cyan" + substep " unsloth studio run # also forwarded into WSL" "Cyan" + } catch { + substep "(shim creation failed; launch manually): wsl -d $_distroArg -u root -- bash -lic 'unsloth studio -p 8888'" "Yellow" + } + # Desktop + Start Menu shortcuts: launch the WSL Studio and open the browser when ready. + try { + $appDir = Join-Path $env:LOCALAPPDATA "Unsloth" + New-Item -ItemType Directory -Force -Path $appDir *> $null + $launcher = Join-Path $appDir "launch-studio-wsl.ps1" + $L = @( + '$ErrorActionPreference = "SilentlyContinue"', + ('$distro = "' + $distro + '"'), + # Port 8888 may be taken on the Windows side (Jupyter, a second + # Studio): WSL Studio would bind another port while the poll waits on + # 8888 forever. Scan the same 8888..8908 window the native launcher uses + # and pass the winner via -p. WSL2 mirrors the port onto Windows, so a + # Windows-side TcpListener probe is valid. + '$port = 0', + 'foreach ($p in 8888..8908) { $l = $null; try { $l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, $p); $l.Start(); $port = $p } catch {} finally { if ($l) { try { $l.Stop() } catch {} } }; if ($port) { break } }', + 'if (-not $port) { Write-Host "No free port in 8888-8908; close one of the apps using them and relaunch."; Start-Sleep 10; exit 1 }', + 'Start-Job -ArgumentList $port { param($port) for ($i=0; $i -lt 120; $i++) { try { if ((Invoke-WebRequest "http://localhost:$port/api/health" -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200) { Start-Process "http://localhost:$port"; break } } catch {}; Start-Sleep 1 } } | Out-Null', + 'Write-Host "Starting Unsloth Studio in WSL ($distro); browser opens at http://localhost:$port when ready (Ctrl+C to stop)..."', + 'wsl.exe -d $distro --cd /root -u root -- bash -lic "unsloth studio -p $port"' + ) + Set-Content -LiteralPath $launcher -Value $L -Encoding UTF8 + # Icon must live OUTSIDE %LOCALAPPDATA%: on WoA the sandboxed icon broker + # can't read a .ico under AppData\Local, so the shortcut renders BLANK; + # under the user profile it renders fine (verified on N1X). Only the icon moves. + $iconDir = Join-Path $env:USERPROFILE ".unsloth" + New-Item -ItemType Directory -Force -Path $iconDir *> $null + $icon = Join-Path $iconDir "unsloth.ico" + # Prefer the bundled icon, else download. Validate the ICO header (00 00 01 00) + # before attaching, so a partial/HTML-404 download never makes a blank icon. + $bundledIcon = $null + if ($PSScriptRoot -and $PSScriptRoot.Trim()) { $bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico" } + if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) { + try { Copy-Item -LiteralPath $bundledIcon -Destination $icon -Force } catch {} + } elseif (-not (Test-Path -LiteralPath $icon)) { + try { Invoke-WebRequest "https://raw.githubusercontent.com/unslothai/unsloth/$(Get-UnslothInstallRef)/studio/frontend/public/unsloth.ico" -OutFile $icon -UseBasicParsing -TimeoutSec 15 *> $null } catch {} + } + $hasValidIcon = $false + if (Test-Path -LiteralPath $icon) { + try { + $ib = [System.IO.File]::ReadAllBytes($icon) + if ($ib.Length -ge 4 -and $ib[0] -eq 0 -and $ib[1] -eq 0 -and $ib[2] -eq 1 -and $ib[3] -eq 0) { $hasValidIcon = $true } + else { Remove-Item -LiteralPath $icon -Force -ErrorAction SilentlyContinue } + } catch { Remove-Item -LiteralPath $icon -Force -ErrorAction SilentlyContinue } + } + $wsh = New-Object -ComObject WScript.Shell + $lnks = @() + $dd = [Environment]::GetFolderPath("Desktop"); if ($dd -and $dd.Trim()) { $lnks += (Join-Path $dd "Unsloth Studio.lnk") } + if ($env:APPDATA -and $env:APPDATA.Trim()) { $smd = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"; New-Item -ItemType Directory -Force -Path $smd *> $null; $lnks += (Join-Path $smd "Unsloth Studio.lnk") } + foreach ($lnk in $lnks) { + $sc = $wsh.CreateShortcut($lnk) + $sc.TargetPath = (Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe") + $sc.Arguments = "-NoExit -NoProfile -ExecutionPolicy Bypass -File `"$launcher`"" + $sc.WorkingDirectory = $appDir + if ($hasValidIcon) { $sc.IconLocation = "$icon,0" } + $sc.Description = "Unsloth Studio (GPU via WSL)" + $sc.Save() + } + step "shortcuts" "created Desktop + Start Menu shortcuts (launch WSL Studio + open browser)" "Green" + # Nudge Explorer: clear icon cache, per-.lnk SHCNE_UPDATEITEM, global + # SHCNE_ASSOCCHANGED. (The real WoA blank-icon cause was the icon path.) + try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {} + try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {} + try { + if (-not ("UnslothShell.Notify" -as [type])) { + Add-Type -Namespace UnslothShell -Name Notify -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, System.IntPtr item2);' + } + # SHCNE_UPDATEITEM (0x00002000), SHCNF_PATHW (0x0005): global notify alone often misses existing .lnks. + foreach ($lnk in $lnks) { try { [UnslothShell.Notify]::SHChangeNotify(0x00002000, 0x0005, $lnk, [System.IntPtr]::Zero) } catch {} } + # SHCNE_ASSOCCHANGED (0x08000000), SHCNF_IDLIST (0): flush global icon associations. + [UnslothShell.Notify]::SHChangeNotify(0x08000000, 0, $null, [System.IntPtr]::Zero) + } catch {} + } catch { + substep "(could not create shortcuts: $($_.Exception.Message))" "Yellow" + } + # GGUF *inference* needs a CUDA llama-server (no aarch64+CUDA prebuilt), so build + # one into ~/.unsloth/llama.cpp in the BACKGROUND. Opt out: UNSLOTH_NO_LLAMA_CUDA=1. + if ($env:UNSLOTH_NO_LLAMA_CUDA -ne '1') { + $prevEapL = $ErrorActionPreference; $ErrorActionPreference = "Continue" + try { + $_llamaUrl = "https://raw.githubusercontent.com/unslothai/unsloth/$(Get-UnslothInstallRef)/studio/scripts/provision_llama_cuda.sh" + # Step 1: fetch the provision script + write a runner (base64 to dodge + # quoting layers). The runner restores PATH (non-login shells miss the + # /usr/lib/wsl/lib nvidia-smi) and exports the env knobs below. A runner + # FILE lets the detached launcher pass only space-free args, avoiding + # Start-Process mis-splitting `bash -lc `. + $_pathLine = 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/wsl/lib:$PATH"' + "`n" + $_jobsLine = if ($env:UNSLOTH_LLAMA_BUILD_JOBS) { "export UNSLOTH_LLAMA_BUILD_JOBS=$($env:UNSLOTH_LLAMA_BUILD_JOBS)`n" } else { "" } + # Bridge UNSLOTH_LLAMA_TAG / UNSLOTH_LLAMA_PR pins into WSL, else the + # deferred build ignores them. Same allow-lists as the other forwarded + # knobs so a quote can't break out of the single-quoted export. + $_tagLine = if ($env:UNSLOTH_LLAMA_TAG -and ($env:UNSLOTH_LLAMA_TAG -match '^[A-Za-z0-9][A-Za-z0-9._/-]*$')) { "export UNSLOTH_LLAMA_TAG='$($env:UNSLOTH_LLAMA_TAG)'`n" } else { "" } + $_prLine = if ($env:UNSLOTH_LLAMA_PR -and ($env:UNSLOTH_LLAMA_PR -match '^\d+$')) { "export UNSLOTH_LLAMA_PR='$($env:UNSLOTH_LLAMA_PR)'`n" } else { "" } + $_runner = "#!/usr/bin/env bash`n" + $_pathLine + $_jobsLine + $_tagLine + $_prLine + "exec bash /root/.unsloth/provision_llama_cuda.sh > /root/.unsloth/llama_cuda_build.log 2>&1`n" + $_runnerB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($_runner)) + $_fetchCmd = 'mkdir -p /root/.unsloth; if curl -fsSL "' + $_llamaUrl + '" -o /root/.unsloth/provision_llama_cuda.sh && [ -s /root/.unsloth/provision_llama_cuda.sh ]; then chmod +x /root/.unsloth/provision_llama_cuda.sh; echo ' + $_runnerB64 + ' | base64 -d > /root/.unsloth/run_llama_build.sh; chmod +x /root/.unsloth/run_llama_build.sh; echo PROV_FETCHED; else echo PROV_NOSCRIPT; fi' + $_fetchOut = & wsl.exe -d $distro --cd /root -u root -- bash -lc $_fetchCmd 2>$null + if ("$_fetchOut" -match 'PROV_FETCHED') { + # Step 2: a detached Windows-side wsl.exe keeps the WSL VM up for the + # whole build (a WSL-side `nohup &` dies when the session exits). PS 5.1 + # Start-Process joins -ArgumentList WITHOUT quoting, so pass $_distroArg + # (pre-quoted only when spaced); other tokens are space-free. + Start-Process -WindowStyle Hidden -FilePath 'wsl.exe' -ArgumentList @('-d', $_distroArg, '--cd', '/root', '-u', 'root', '--', 'bash', '/root/.unsloth/run_llama_build.sh') | Out-Null + step "llama.cpp" "building CUDA llama.cpp for GGUF inference in the background (a few min); log: ~/.unsloth/llama_cuda_build.log" "Green" + } else { + substep "(GGUF inference needs a CUDA llama.cpp build; build later: wsl -d $_distroArg -u root -- bash ~/.unsloth/provision_llama_cuda.sh)" "Yellow" + } + } catch {} finally { $ErrorActionPreference = $prevEapL } + } + } else { + step "wsl" "WSL Studio install did not finish cleanly (torch.cuda not detected; inner exit $wslRc) -- see log above." "Yellow" + substep "retry, or launch manually: wsl -d $_distroArg -u root -- bash -lic 'unsloth studio -p 8888'" "Cyan" + } + if ($torchOk) { + # Success: the Windows venv is vestigial (all runs in WSL), so drop the + # rolled-aside backup instead of orphaning it. EXCEPT a custom + # UNSLOTH_STUDIO_HOME: we told the user their custom root isn't used by the WSL + # install, so restore its venv rather than delete it (the shim doesn't need it). + if ($envOverride) { Restore-StudioVenvRollback } else { Complete-StudioVenvRollback } + substep "GPU training + GGUF export run inside WSL. (GGUF *inference* additionally needs a CUDA llama.cpp build.)" "Yellow" + $global:LASTEXITCODE = 0 + return + } + # Failed (torch.cuda unavailable): Exit-InstallFailure restores the venv and fails + # in every invocation mode, so automation cannot read this as success. + return (Exit-InstallFailure "WSL Studio install did not finish cleanly (torch.cuda not detected; inner exit $wslRc)") + } + # ── GPU arch → newest compatible Windows ROCm wheel release ── # Wheels bundle their own ROCm runtime; the installed HIP SDK version does # not constrain which release to use. Always picks the newest release that diff --git a/install.sh b/install.sh index fece7b173b..3a1454d742 100755 --- a/install.sh +++ b/install.sh @@ -1042,9 +1042,9 @@ _open_browser() { elif grep -qi microsoft /proc/version 2>/dev/null; then # WSL: xdg-open is unreliable; use Windows browser via PowerShell or cmd if command -v powershell.exe >/dev/null 2>&1; then - powershell.exe -NoProfile -Command "Start-Process '$_url'" >/dev/null 2>&1 & + powershell.exe -NoProfile -Command "Start-Process '$_url'" /dev/null 2>&1 & elif command -v cmd.exe >/dev/null 2>&1; then - cmd.exe /c start "" "$_url" >/dev/null 2>&1 & + cmd.exe /c start "" "$_url" /dev/null 2>&1 & elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$_url" >/dev/null 2>&1 & else @@ -1485,7 +1485,8 @@ STUB_EOF fi _css_created=1 - elif [ "$_css_os" = "wsl" ]; then + elif [ "$_css_os" = "wsl" ] && [ "${UNSLOTH_SKIP_WSL_WINDOWS_SHORTCUT:-0}" != "1" ] \ + && [ ! -f "$HOME/.unsloth/.skip-wsl-windows-shortcut" ]; then # ── WSL: create Windows Desktop and Start Menu shortcuts ── # Detect current WSL distro for targeted shortcut _css_distro="${WSL_DISTRO_NAME:-}" @@ -1533,9 +1534,11 @@ STUB_EOF \$WshShell = New-Object -ComObject WScript.Shell \$targetExe = (Get-Command '$_css_sc_target' -ErrorAction SilentlyContinue).Source if (-not \$targetExe) { exit 1 } -# Best-effort: fetch the Unsloth icon to a stable Windows path (shared with a -# native install if one exists) so the WSL shortcut shows the proper icon. -\$iconDir = Join-Path \$env:LOCALAPPDATA 'Unsloth Studio' +# Best-effort: fetch the Unsloth icon to a stable Windows path so the shortcut +# shows the proper icon. Use %USERPROFILE%\.unsloth, NOT %LOCALAPPDATA%: on +# Windows-on-ARM the sandboxed icon broker can't read a standalone .ico under +# AppData\Local (renders blank); a profile-path icon renders everywhere. +\$iconDir = Join-Path \$env:USERPROFILE '.unsloth' \$iconPath = Join-Path \$iconDir 'unsloth.ico' \$preIconHash = \$null if (Test-Path -LiteralPath \$iconPath) { @@ -1611,7 +1614,7 @@ WSLPS1_EOF # Convert WSL path to Windows path for powershell.exe _css_ps1_win=$(wslpath -w "$_css_ps1_tmp" 2>/dev/null) if [ -n "$_css_ps1_win" ]; then - powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$_css_ps1_win" >/dev/null 2>&1 && _css_created=1 + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$_css_ps1_win" /dev/null 2>&1 && _css_created=1 fi rm -f "$_css_ps1_tmp" fi @@ -1799,6 +1802,11 @@ _has_usable_nvidia_gpu() { _nvsmi="" if command -v nvidia-smi >/dev/null 2>&1; then _nvsmi="nvidia-smi" + elif [ -x "/usr/lib/wsl/lib/nvidia-smi" ]; then + # WSL2 GPU-PV ships nvidia-smi ONLY here, and root login shells drop + # the dir from PATH; without this fallback the WSL install detects no + # NVIDIA GPU and picks CPU torch wheels. + _nvsmi="/usr/lib/wsl/lib/nvidia-smi" elif [ -x "/usr/bin/nvidia-smi" ]; then _nvsmi="/usr/bin/nvidia-smi" fi @@ -2501,6 +2509,9 @@ get_torch_index_url() { _nvidia_detected=1 if command -v nvidia-smi >/dev/null 2>&1; then _smi="nvidia-smi" + elif [ -x "/usr/lib/wsl/lib/nvidia-smi" ]; then + # Same WSL2 GPU-PV location fallback as _has_usable_nvidia_gpu. + _smi="/usr/lib/wsl/lib/nvidia-smi" elif [ -x "/usr/bin/nvidia-smi" ]; then _smi="/usr/bin/nvidia-smi" fi @@ -3945,6 +3956,14 @@ elif [ -n "$TORCH_INDEX_URL" ]; then run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" + elif [ -n "${UNSLOTH_INSTALL_REF:-}" ] && [ "${UNSLOTH_INSTALL_REF}" != "main" ] && [ "$PACKAGE_NAME" = "unsloth" ]; then + # Pre-merge testing: install unsloth from a git ref (set by install.ps1) + # so the branch's setup.sh + patches run. Name unsloth-zoo explicitly -- + # not a base dep, and SKIP_STUDIO_BASE skips base.txt, so it never installs. + substep "installing unsloth from git ref '$UNSLOTH_INSTALL_REF'..." + run_install_cmd "install unsloth (@$UNSLOTH_INSTALL_REF)" uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth --upgrade-package unsloth-zoo \ + "unsloth @ git+https://github.com/unslothai/unsloth@${UNSLOTH_INSTALL_REF}" unsloth-zoo else run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \ ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ @@ -3952,6 +3971,26 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" _UNSLOTH_TORCH_OVERRIDES="" + # aarch64 + NVIDIA (DGX Spark / GB10 / N1X): unsloth's x86_64-oriented cuXXX + # extras break 4-bit QLoRA, but aarch64 manylinux wheels work (verified on + # sm_121 via PTX JIT). Best-effort: no wheel keeps 16-bit LoRA / full finetuning. + # SKIP_TORCH gate stops a --no-torch (GGUF-only) install dragging torch back in. + # nvidia-smi may live only in /usr/lib/wsl/lib (WSL2 GPU-PV), which root login + # shells drop from PATH -- resolve explicitly (same order as setup.sh's + # _resolve_nvsmi) so the WoA/WSL install still gets 4-bit QLoRA support. + _bnb_nvsmi="$(command -v nvidia-smi 2>/dev/null || true)" + [ -z "$_bnb_nvsmi" ] && [ -x /usr/lib/wsl/lib/nvidia-smi ] && _bnb_nvsmi=/usr/lib/wsl/lib/nvidia-smi + [ -z "$_bnb_nvsmi" ] && [ -x /usr/bin/nvidia-smi ] && _bnb_nvsmi=/usr/bin/nvidia-smi + if [ "$SKIP_TORCH" = false ] \ + && { [ "$(uname -m)" = "aarch64" ] || [ "$(uname -m)" = "arm64" ]; } \ + && [ -n "$_bnb_nvsmi" ] \ + && "$_bnb_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' \ + && ! "$_VENV_PY" -c "import bitsandbytes" >/dev/null 2>&1; then + substep "installing bitsandbytes (aarch64 wheels; enables 4-bit QLoRA)..." + if ! uv pip install --python "$_VENV_PY" "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0" >/dev/null 2>&1; then + substep "(no bitsandbytes wheel for this platform; 16-bit LoRA + full finetuning still work)" + fi + fi # AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in # CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1. if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then diff --git a/pyproject.toml b/pyproject.toml index 0f57ecf4df..4254148919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ studio = [ "*.sh", "*.ps1", "*.bat", + "scripts/*.sh", "node_prebuilt_pins.json", "frontend/dist/**/*", "frontend/*.json", diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 9b6e6ebb86..3c2666f51d 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -16,6 +16,25 @@ function Uninstall-UnslothStudio { function _Step { param([string]$Msg) Write-Host $Msg } function _Substep { param([string]$Msg, [string]$Color = "Gray") Write-Host " $Msg" -ForegroundColor $Color } + # True host architecture, mirroring install.ps1's WSL-fallback gate: x64-emulated + # PowerShell on ARM64 reports AMD64, which made the legacy marker-less WSL cleanup + # skip the machines the fallback installed on. Each probe only turns the answer ON; + # Win32_Processor.Architecture 12 = ARM64. + function _IsArm64Host { + $arm = $false + try { $arm = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ieq 'Arm64') } catch { } + if (-not $arm) { + try { if ((@(Get-CimInstance Win32_Processor -ErrorAction Stop))[0].Architecture -eq 12) { $arm = $true } } catch { } + } + if (-not $arm) { + try { + $machArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE -ErrorAction Stop).PROCESSOR_ARCHITECTURE + if ($machArch -ieq 'ARM64') { $arm = $true } + } catch { } + } + return $arm + } + # Remove a file/dir/symlink if present. Idempotent; retries since a just-killed # process can briefly hold a handle (Windows refuses the delete until released). function _RemovePath { @@ -399,13 +418,52 @@ function Uninstall-UnslothStudio { } # ── Remove desktop and Start Menu shortcuts ── + # Canonical name is "Unsloth Studio.lnk". Distro-suffixed names belong to per-distro + # WSL installs, which the section below only cleans for evidenced distros (env var, + # wsl-distro.txt, or legacy ARM64 probe) -- scope this sweep to the same set so a + # surviving WSL install keeps its launcher. Non-wsl.exe launchers are still swept. _Step "Removing desktop and Start Menu shortcuts..." + $_scCands = @() + if ($env:UNSLOTH_WSL_DISTRO) { $_scCands += $env:UNSLOTH_WSL_DISTRO } try { - $desktop = [Environment]::GetFolderPath("Desktop") - if ($desktop) { _RemovePath (Join-Path $desktop "Unsloth Studio.lnk") } + if ($env:LOCALAPPDATA) { + $_scDf = Join-Path (Join-Path $env:LOCALAPPDATA "Unsloth") "wsl-distro.txt" + if (Test-Path -LiteralPath $_scDf) { + $_scRd = (Get-Content -LiteralPath $_scDf -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($_scRd -and $_scRd.Trim()) { $_scCands += $_scRd.Trim() } + } + } } catch { } - if ($env:APPDATA) { - _RemovePath (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk") + if ((-not $_scCands) -and (_IsArm64Host)) { + $_scCands = @('Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian') + } + $_scWs = $null + try { $_scWs = New-Object -ComObject WScript.Shell } catch { } + $shortcutDirs = @() + try { $d = [Environment]::GetFolderPath("Desktop"); if ($d) { $shortcutDirs += $d } } catch { } + if ($env:APPDATA) { $shortcutDirs += (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs") } + foreach ($dir in $shortcutDirs) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + _RemovePath (Join-Path $dir "Unsloth Studio.lnk") + Get-ChildItem -LiteralPath $dir -Filter "Unsloth Studio (*.lnk" -ErrorAction SilentlyContinue | ForEach-Object { + $_scKeep = $false + if ($_scWs) { + try { + $_sc = $_scWs.CreateShortcut($_.FullName) + if ("$($_sc.TargetPath) $($_sc.Arguments)" -match "wsl\.exe") { + $_scD = $null + # install.sh quotes spaced distro names, so match a full quoted + # token first; a naive [^"\s]+ would truncate at the space. + if ($_sc.Arguments -match '-d\s+(?:"([^"]+)"|(\S+))') { + $_scD = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + } + elseif ($_.Name -match '^Unsloth Studio \(WSL - (.+)\)\.lnk$') { $_scD = $Matches[1] } + if ($_scD -and ($_scCands -notcontains $_scD)) { $_scKeep = $true } + } + } catch { } + } + if (-not $_scKeep) { _RemovePath $_.FullName } + } } # Invalidate the Win11 Start Menu tile cache so the removed shortcut's tile # disappears promptly instead of lingering stale (mirrors install.ps1's @@ -480,6 +538,102 @@ function Uninstall-UnslothStudio { Remove-Item -LiteralPath 'HKCU:\Software\Unsloth' -Recurse -Force -ErrorAction SilentlyContinue } catch { } + # ── Windows-on-Arm WSL-fallback artifacts ── + # The ARM64+NVIDIA fallback puts Studio in WSL plus a native shim + launcher under + # %LOCALAPPDATA%\Unsloth with a PATH entry -- none caught above. + _Step "Removing WSL-fallback artifacts (shim, launcher, PATH entry, WSL install)..." + $unslothDir = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth" } else { $null } + # wsl-distro.txt records a custom UNSLOTH_WSL_DISTRO install so it's cleanable without + # the env var set; read it BEFORE the directory is removed below. + $_recordedDistro = $null + if ($unslothDir) { + try { + $_distroFile = Join-Path $unslothDir "wsl-distro.txt" + if (Test-Path -LiteralPath $_distroFile) { + $_recordedDistro = (Get-Content -LiteralPath $_distroFile -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($_recordedDistro) { $_recordedDistro = $_recordedDistro.Trim() } + } + } catch { } + } + if ($unslothDir) { + $shimDir = (Join-Path $unslothDir "bin").TrimEnd('\', '/') + try { + $rk = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + if ($rk) { + try { + $rp = $rk.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($rp) { + $kept = @(); $removed = $false + foreach ($e in ($rp -split ';')) { + if ([string]::IsNullOrWhiteSpace($e)) { continue } + if (([Environment]::ExpandEnvironmentVariables($e).TrimEnd('\', '/')) -ieq $shimDir) { $removed = $true; _Substep "removed PATH entry: $e" "Green"; continue } + $kept += $e + } + if ($removed) { $rk.SetValue('Path', ($kept -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) } + } + } finally { $rk.Close() } + } + } catch { } + _RemovePath $unslothDir + } + # The WoA shortcut icon lives under the user profile (icon broker can't read + # AppData\Local). The sweep above keeps launchers for WSL installs it has no evidence + # for, and those .lnks point at this icon, so only remove it when no Unsloth shortcut + # survives anywhere (mirrors _drop_shared_icon_if_unused on the WSL-side uninstaller). + if ($env:USERPROFILE) { + $_icoInUse = $false + foreach ($_icoDir in $shortcutDirs) { + if ($_icoDir -and (Test-Path -LiteralPath $_icoDir) -and + (Get-ChildItem -LiteralPath $_icoDir -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue)) { + $_icoInUse = $true; break + } + } + if (-not $_icoInUse) { _RemovePath (Join-Path $env:USERPROFILE ".unsloth\unsloth.ico") } + } + # The ~/.unsloth empty-dir sweep above ran BEFORE this icon removal, so the still-present + # unsloth.ico kept it non-empty and it was skipped. Re-attempt now that the icon (the + # last default-mode child) is gone. + if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and + -not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) { + _RemovePath $defaultUnslothHome + } + # Remove the Studio install inside each WSL distro (GPU install + any CUDA llama.cpp build). + if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + try { + # Probe candidates by exit code ('' = default distro) since `wsl --list` emits + # UTF-16 PS mis-parses. Kills run BEFORE rm so a live CUDA build (cmake/nvcc under + # /root/.unsloth/llama.cpp) can't recreate files after the rm. Each matched PID's + # whole process GROUP is signalled (cmake children carry relative argv), guarded + # against this shell's pgid, plus direct children via pkill -P; this shell can't + # self-match (extra backslash + [h]-bracket). Scope STRICTLY to /root; /home/* + # may be another user's. The 8888 kill only targets a listener whose cmdline is + # under /root/.unsloth, so an unrelated service on 8888 isn't killed, and is gated + # on an Unsloth install having existed. /proc greps still work after kills. + $_clean = '_had=0; if [ -d /root/.unsloth ] || [ -L /root/.local/bin/unsloth ]; then _had=1; fi; _mypg=$(ps -o pgid= -p $$ 2>/dev/null | tr -d " "); for _p in $(pgrep -f ''/root/\.unslot[h]/'' 2>/dev/null); do _pg=$(ps -o pgid= -p $_p 2>/dev/null | tr -d " "); case "$_pg" in ""|0|1|"$_mypg") pkill -9 -P $_p 2>/dev/null; kill -9 $_p 2>/dev/null ;; *) kill -9 -- -$_pg 2>/dev/null || kill -9 $_p 2>/dev/null ;; esac; done; if [ $_had -eq 1 ]; then for _p in $(fuser 8888/tcp 2>/dev/null); do grep -qa /root/\.unsloth/ /proc/$_p/cmdline 2>/dev/null && kill -9 $_p 2>/dev/null; done; fi; rm -rf /root/.unsloth /root/llama-cuda /root/provision_llama_cuda.sh /root/llama_cuda_build.log 2>/dev/null; rm -f /root/.local/bin/unsloth 2>/dev/null; true' + # Clean only distros with fallback-install evidence: wsl-distro.txt or an + # explicit UNSLOTH_WSL_DISTRO. The broad candidate probe is only for legacy + # marker-less installs (ARM64 only); on x86 it would delete distros this + # installer never touched (e.g. a ROCm-on-WSL Studio under /root). + $_cands = @() + if ($env:UNSLOTH_WSL_DISTRO) { $_cands += $env:UNSLOTH_WSL_DISTRO } + if ($_recordedDistro) { $_cands += $_recordedDistro } + if ((-not $_cands) -and (_IsArm64Host)) { + $_cands = @('', 'Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian') + } + $_done = @{} + foreach ($d in $_cands) { + if ($d) { & wsl.exe -d $d -- true *> $null } else { & wsl.exe -- true *> $null } + if ($LASTEXITCODE -ne 0) { continue } + $_label = if ($d) { $d } else { "(default)" } + if ($_done[$_label]) { continue } + $_done[$_label] = $true + if ($d) { & wsl.exe -d $d -u root -- bash -lc $_clean *> $null } + else { & wsl.exe -u root -- bash -lc $_clean *> $null } + _Substep "cleaned Unsloth from WSL distro: $_label" "Green" + } + } catch { } + } + Write-Host "" Write-Host "Unsloth Studio uninstalled." Write-Host "Note: Hugging Face model cache at %USERPROFILE%\.cache\huggingface was left in place." @@ -491,6 +645,10 @@ function Uninstall-UnslothStudio { Write-Host "set to also remove that install tree, e.g.:" Write-Host " `$env:UNSLOTH_STUDIO_HOME = 'C:\your\path'; irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex" } + + # The distro probes leave a failing $LASTEXITCODE; reset so success exits 0. Set the + # var rather than `exit 0` so `irm ... | iex` doesn't terminate the caller's shell. + $global:LASTEXITCODE = 0 } Uninstall-UnslothStudio @args diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 957d2b7af2..8b960f2dac 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -212,10 +212,52 @@ _custom_studio_roots | while IFS= read -r _custom_root; do _remove_path "$_custom_root" done _remove_path "$HOME/.unsloth/studio" +# Stop a detached CUDA llama.cpp build BEFORE deleting its tree: _pkill_studio only +# matches Studio roots, and a live cmake/nvcc under ~/.unsloth/llama.cpp would recreate +# build/ files between the rm and the rmdir. TERM first, then KILL after the same grace. +if command -v pkill >/dev/null 2>&1; then + _llama_re=$(_pkill_escape "$HOME/.unsloth/llama.cpp") + # Signal the whole process GROUP of each match: the provisioner cds into the tree + # before `cmake --build build`, so cmake/make children carry relative argv no pattern + # matches, and killing only the wrapper orphans them. PID kill is the fallback when + # pgid is unreadable or shared. Never group-kill our own group: a lingering provisioner + # in a non-interactive session can share our pgid, and kill(-pgid) would TERM this + # script mid-cleanup; fall back to the PID plus its direct children then. + _self_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d '[:space:]') + _kill_llama_build() { + _sig="$1" + for _pat in "run_llama_build\.sh" "provision_llama_cuda\.sh" "$_llama_re"; do + for _pid in $(pgrep -f "$_pat" 2>/dev/null); do + _pgid=$(ps -o pgid= -p "$_pid" 2>/dev/null | tr -d '[:space:]') + case "$_pgid" in + ''|0|1|"$_self_pgid") + pkill "-$_sig" -P "$_pid" 2>/dev/null || true + kill -s "$_sig" "$_pid" 2>/dev/null || true ;; + *) kill -s "$_sig" -- "-$_pgid" 2>/dev/null \ + || kill -s "$_sig" "$_pid" 2>/dev/null || true ;; + esac + done + done + } + _kill_llama_build TERM + sleep 0.5 + _kill_llama_build KILL +fi # Default-mode shared llama.cpp build + cache are siblings of studio (not removed # by deleting it). No-op in env/custom mode (they nest under the custom root) and # when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept. _remove_path "$HOME/.unsloth/llama.cpp" +# WoA/Spark CUDA-build path artifacts (provision script fetched by setup.sh, +# install.ps1's background-build runner + log, and the persisted shortcut-skip +# marker). No-ops when absent. +_remove_path "$HOME/.unsloth/provision_llama_cuda.sh" +_remove_path "$HOME/.unsloth/run_llama_build.sh" +_remove_path "$HOME/.unsloth/llama_cuda_build.log" +_remove_path "$HOME/.unsloth/.skip-wsl-windows-shortcut" +# Core-install completion stamp (written by setup.sh, checked by install.ps1's +# WSL probes). Must go, or a later reinstall could read a stale success. +_remove_path "$HOME/.unsloth/.install-ok" +_remove_path "$HOME/.unsloth/unsloth-install.sh" _remove_path "$HOME/.unsloth/.cache" # Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in # default mode. No-op in env/custom mode (nested under the custom root) and absent. @@ -281,6 +323,7 @@ case "$_os" in # receive trailing tokens as $args. WSL distro names are safe to # embed (no quotes/$/backtick). # shellcheck disable=SC2016 + # $env:APPDATA/$distro are PowerShell-side; $_wsl_distro is shell-injected. powershell.exe -NoProfile -Command '$distro = "'"$_wsl_distro"'"; $dirs = @( [Environment]::GetFolderPath("Desktop"), @@ -305,6 +348,29 @@ case "$_os" in } catch { } } } + # Remove the WoA WSL-fallback native shim/launcher dir + # (%LOCALAPPDATA%\Unsloth) + its PATH entry, so a WSL-side bash uninstall + # is complete. Only when THIS distro owns the fallback (wsl-distro.txt), + # else uninstalling a different distro breaks the still-installed shim. + $ud = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth" } else { $null }; + $owner = $null; + if ($ud) { $of = Join-Path $ud "wsl-distro.txt"; if (Test-Path -LiteralPath $of) { $owner = (Get-Content -LiteralPath $of | Select-Object -First 1).Trim() } } + if ($ud -and ((-not $owner) -or (-not $distro) -or ($owner -ieq $distro))) { + $shim = (Join-Path $ud "bin").TrimEnd("\","/"); + $up = [Environment]::GetEnvironmentVariable("Path","User"); + if ($up) { [Environment]::SetEnvironmentVariable("Path", (($up -split ";" | Where-Object { $_ -and ($_.TrimEnd("\","/") -ine $shim) }) -join ";"), "User") } + # WoA-fallback shortcuts target powershell.exe + launch-studio-wsl.ps1 + # (not wsl.exe), so the sweep above keeps them; remove them here + # before their launcher dir is deleted or they would dangle. + foreach ($d in $dirs) { + if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue } + $l = Join-Path $d "Unsloth Studio.lnk"; + if (Test-Path -LiteralPath $l) { + try { $sc2 = $ws.CreateShortcut($l); if ($sc2.Arguments -match "launch-studio-wsl\.ps1") { Remove-Item -LiteralPath $l -Force -ErrorAction SilentlyContinue } } catch { } + } + } + if (Test-Path -LiteralPath $ud) { Remove-Item -LiteralPath $ud -Recurse -Force -ErrorAction SilentlyContinue } + } # Keep the shared icon while any Unsloth shortcut still uses it (native # install or another WSL distro); drop it only with the last one. $iconInUse = $false; @@ -319,6 +385,15 @@ case "$_os" in $ico = Join-Path $iconDir "unsloth.ico"; if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue } if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue } + } + # install.sh also writes the WSL shortcut icon to the Windows profile + # (%USERPROFILE%\.unsloth\unsloth.ico) since the WoA icon broker cannot + # read AppData\Local; clean it the same way. + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + $pIconDir = Join-Path $env:USERPROFILE ".unsloth"; + $pIco = Join-Path $pIconDir "unsloth.ico"; + if ((-not $iconInUse) -and (Test-Path -LiteralPath $pIco)) { Remove-Item -LiteralPath $pIco -Force -ErrorAction SilentlyContinue } + if ((Test-Path -LiteralPath $pIconDir) -and -not (Get-ChildItem -LiteralPath $pIconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $pIconDir -Recurse -Force -ErrorAction SilentlyContinue } }' >/dev/null 2>&1 || true fi # Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install @@ -342,6 +417,10 @@ case "$_os" in done if [ "$_icon_in_use" = "0" ]; then [ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true + # install.sh also writes the icon to the Windows profile + # (%USERPROFILE%\.unsloth) for the WoA icon broker. + [ -f "$_du/.unsloth/unsloth.ico" ] && rm -f "$_du/.unsloth/unsloth.ico" 2>/dev/null || true + [ -d "$_du/.unsloth" ] && rmdir "$_du/.unsloth" 2>/dev/null || true fi [ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true } diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index baf6329dae..d10818848d 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -818,6 +818,32 @@ def _rocm_classify_unified_memory(props: Any) -> tuple[str, bool]: return gcn_arch, is_unified +def _nvidia_classify_spark_unified_memory(props: Any) -> tuple[str, bool]: + """Classify an NVIDIA device as Spark-class unified-memory or discrete. + + Returns ``(marker, is_unified)``; marker is ``"is_integrated"`` or the matched + name token, else ``""``. Spark-class parts (DGX Spark / GB10, N1X "RTX Spark") + share one memory pool with the OS, so like the ROCm APUs they need a + ``set_per_process_memory_fraction`` cap -- exhausting the pool can stall the box. + + ``is_integrated`` is authoritative on native Linux, but WSL2 paravirtualization + masks it to 0 and renames the device (N1X reports ``JMJWOA-Generic-GPU``, + verified live) -- hence the name-token fallback. Tokens mirror + ``_DGX_SPARK_DEVICE_TOKENS`` in ``unsloth/models/_utils.py`` (duplicated since + this guard runs before any ML import). + """ + if getattr(props, "is_integrated", 0): + return "is_integrated", True + name_upper = (getattr(props, "name", "") or "").upper() + import re + + for token in ("GB10", "GB110", "JMJWOA", "N1X", "DGX SPARK"): + # Whole-token match so "GB10" doesn't match discrete "GB100"/"GB10X". + if re.search(r"(? bool: """True iff a tilelang 0.1.8 wheel will load: Linux x86_64/aarch64, non-HIP torch. @@ -2406,6 +2432,53 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> from utils.hardware import hardware as _hw + # Set PYTORCH_CUDA_ALLOC_CONF before the first CUDA touch: detect_hardware() + # below calls get_device_properties, which latches the allocator config for + # this process (verified: expandable_segments set after init is a no-op). + # CUDA-free nvidia-smi sniff (mirrors _is_dgx_spark_no_cuda_init) honoring + # UNSLOTH_FORCE_DGX_SPARK, same append-don't-override behavior and + # UNSLOTH_NO_EXPANDABLE_SEGMENTS opt-out. + try: + import platform as _plat + import re as _re + + _force_spark = os.environ.get("UNSLOTH_FORCE_DGX_SPARK") + if _force_spark == "1": + _spark_smi = True + elif _force_spark == "0": + _spark_smi = False + else: + _spark_smi = False + if _plat.machine().lower() in ("aarch64", "arm64"): + import shutil as _shutil + + # The WoA shim execs the venv binary directly (no login shell), + # where /usr/lib/wsl/lib can be off PATH -- resolve explicitly. + _smi_bin = "nvidia-smi" + if _shutil.which(_smi_bin) is None and os.path.exists( + "/usr/lib/wsl/lib/nvidia-smi" + ): + _smi_bin = "/usr/lib/wsl/lib/nvidia-smi" + _smi = _sp.run( + [_smi_bin, "--query-gpu=name", "--format=csv,noheader"], + capture_output = True, + text = True, + timeout = 5, + ) + _names_u = (_smi.stdout or "").upper() + _spark_smi = any( + _re.search(r"(? except Exception as _oom_guard_err: logger.debug("Could not set GPU memory fraction: %s", _oom_guard_err) + # ── 1h. NVIDIA Spark-class unified-memory OOM guard ── + # NVIDIA flavor of the ROCm APU guard above: Spark-class parts share one pool + # with the OS, so over-allocation can stall the box instead of raising a + # catchable OutOfMemoryError. Cap at 0.80 like Strix Halo (20% headroom for + # OS/page cache). UNSLOTH_SPARK_MEM_FRACTION overrides; outside (0, 1] disables. + # Discrete NVIDIA GPUs untouched. + else: + try: + # PYTORCH_CUDA_ALLOC_CONF is appended before detect_hardware() near the + # top of run_training_process: by here CUDA has long been initialized + # and the allocator config is latched, so only the runtime-adjustable + # memory fraction is set at this point. + import torch as _torch_mem + if _torch_mem.cuda.is_available(): + _props = _torch_mem.cuda.get_device_properties(0) + # Same UNSLOTH_FORCE_DGX_SPARK override the detectors honor, so a + # forced Spark with an unlisted name still gets the fraction guard + # and FORCE=0 can disable it on a token-matched device. + _force_spark = os.environ.get("UNSLOTH_FORCE_DGX_SPARK") + if _force_spark == "1": + _marker, _is_spark_uma = "forced", True + elif _force_spark == "0": + _marker, _is_spark_uma = "forced-off", False + else: + _marker, _is_spark_uma = _nvidia_classify_spark_unified_memory(_props) + if _is_spark_uma: + _mem_fraction = 0.80 + _frac_env = os.environ.get("UNSLOTH_SPARK_MEM_FRACTION") + if _frac_env: + try: + _mem_fraction = float(_frac_env) + except ValueError: + _mem_fraction = 0.80 + if 0.0 < _mem_fraction <= 1.0: + _torch_mem.cuda.set_per_process_memory_fraction(_mem_fraction) + logger.info( + "Spark unified-memory OOM guard: " + "set_per_process_memory_fraction(%.2f) — %s (matched %s)", + _mem_fraction, + _props.name, + _marker, + ) + else: + logger.info( + "Spark unified-memory OOM guard disabled " + "(UNSLOTH_SPARK_MEM_FRACTION=%s)", + _frac_env, + ) + except Exception as _oom_guard_err: + logger.debug("Could not set GPU memory fraction: %s", _oom_guard_err) + # ── 2. Now import ML libraries (fresh in this clean process) ── try: _send_status(event_queue, "Importing Unsloth...") diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 9e23242b97..2318155d60 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -119,6 +119,20 @@ def _clean_state(monkeypatch, tmp_path): monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False) # Never hit the network in these tests. monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None) + # Default to no live inference backend: on a fully-installed host _run_update's + # `from routes.inference import get_llama_cpp_backend` imports a real Studio + # singleton and blocks on its load lock, hanging/flaking the worker tests. This is + # the fail-open path; load-coordination tests inject their own backend over it. + _routes_pkg = ModuleType("routes") + _routes_pkg.__path__ = [] + _inference_mod = ModuleType("routes.inference") + + def _no_backend_in_tests(): + raise RuntimeError("no inference backend in unit tests") + + _inference_mod.get_llama_cpp_backend = _no_backend_in_tests + monkeypatch.setitem(sys.modules, "routes", _routes_pkg) + monkeypatch.setitem(sys.modules, "routes.inference", _inference_mod) # Keep the whisper piggyback out of the llama-only tests: no host probe, no # whisper phase (test_combined_update.py covers the chained flow). monkeypatch.setattr(upd, "_whisper_chain_status", lambda **kwargs: None) diff --git a/studio/backend/tests/test_spark_oom_guard.py b/studio/backend/tests/test_spark_oom_guard.py new file mode 100644 index 0000000000..a0e13f0bd6 --- /dev/null +++ b/studio/backend/tests/test_spark_oom_guard.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for _nvidia_classify_spark_unified_memory (Spark OOM-guard classifier). + +Two paths: (1) ``is_integrated`` property (authoritative on native Linux), +(2) name-token match -- needed because WSL2 GPU paravirtualization masks +``is_integrated`` to 0 and renames the device (N1X reports ``JMJWOA-Generic-GPU``; +verified live). Mirrors test_rocm_oom_guard.py, which the NVIDIA guard models. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from core.training.worker import _nvidia_classify_spark_unified_memory + + +def _props(**kwargs) -> SimpleNamespace: + """Fake device-properties object with the given attributes.""" + return SimpleNamespace(**kwargs) + + +# ── Path 1: is_integrated property ─────────────────────────────────────────── + + +class TestIsIntegratedProperty: + """``is_integrated`` truthy means unified memory, regardless of name.""" + + def test_integrated_native_spark(self) -> None: + props = _props(is_integrated = 1, name = "NVIDIA GB10") + marker, is_unified = _nvidia_classify_spark_unified_memory(props) + assert marker == "is_integrated" + assert is_unified is True + + def test_integrated_wins_even_with_unknown_name(self) -> None: + props = _props(is_integrated = 1, name = "Some Future Unified Part") + marker, is_unified = _nvidia_classify_spark_unified_memory(props) + assert marker == "is_integrated" + assert is_unified is True + + +# ── Path 2: device-name token fallback (WSL masks is_integrated) ──────────── + + +class TestDeviceNameTokenFallback: + """is_integrated == 0 (or absent) -> classify by Spark name tokens.""" + + @pytest.mark.parametrize( + "name, expected_marker", + [ + ("JMJWOA-Generic-GPU", "JMJWOA"), # N1X under WSL2 (verified live) + ("NVIDIA GB10", "GB10"), # native DGX Spark + ("NVIDIA GB110", "GB110"), # "GB10" is not a substring of "GB110" + ("NVIDIA DGX Spark", "DGX SPARK"), + ("nvidia n1x prototype", "N1X"), # case-insensitive + ], + ) + def test_spark_names_unified(self, name: str, expected_marker: str) -> None: + props = _props(is_integrated = 0, name = name) + marker, is_unified = _nvidia_classify_spark_unified_memory(props) + assert is_unified is True + assert marker == expected_marker + + @pytest.mark.parametrize( + "name", + [ + "NVIDIA GeForce RTX 4090", + "NVIDIA H100 80GB HBM3", + "NVIDIA RTX 6000 Ada Generation", + "Tesla T4", + ], + ) + def test_discrete_names_not_unified(self, name: str) -> None: + props = _props(is_integrated = 0, name = name) + marker, is_unified = _nvidia_classify_spark_unified_memory(props) + assert is_unified is False + assert marker == "" + + def test_missing_attrs_defaults_discrete(self) -> None: + """No is_integrated, no name -> discrete (guard stays off).""" + marker, is_unified = _nvidia_classify_spark_unified_memory(_props()) + assert is_unified is False + assert marker == "" + + def test_none_name_defaults_discrete(self) -> None: + props = _props(is_integrated = 0, name = None) + marker, is_unified = _nvidia_classify_spark_unified_memory(props) + assert is_unified is False + assert marker == "" diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 9b787dbb15..69420cf09d 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -10,6 +10,7 @@ import argparse import atexit import errno import fnmatch +import functools import glob import hashlib import json @@ -2225,6 +2226,49 @@ def _pick_rocm_gfx_target(out: str) -> str | None: return _tokens[0] +@functools.lru_cache(maxsize = 1) +def _running_under_wsl() -> bool: + """WSL kernels self-identify with 'microsoft' in the release string.""" + try: + return "microsoft" in platform.uname().release.lower() + except Exception: + return False + + +def _nvidia_smi_capture( + command: list[str], + *, + attempts: int | None = None, + timeout: int | None = None, +) -> subprocess.CompletedProcess[str]: + """run_capture for nvidia-smi probes, hardened against transient slowness. + + nvidia-smi normally answers in well under a second, but under WSL2 GPU-PV it + can take far longer when the host is under heavy CPU load -- e.g. the + concurrent pip / frontend / cmake work during an `unsloth studio` install. + A single short timeout then raises TimeoutExpired, detect_host treats the + GPU as ABSENT, and the host is misrouted to a CPU prebuilt / slow source + build instead of the CUDA bundle it can actually use. Retry with a generous + per-attempt timeout there. Off WSL that slowness mode does not exist, and a + hung nvidia-smi (broken driver, revoked container GPU) would stall three + successive detect_host probes for ~2 minutes each -- so bare metal keeps a + single short attempt. Only ever reached when nvidia-smi exists on PATH, so + CPU-only hosts never incur this wait. + """ + _on_wsl = _running_under_wsl() + _attempts = max(1, attempts if attempts is not None else (2 if _on_wsl else 1)) + _timeout = timeout if timeout is not None else (60 if _on_wsl else 10) + last_exc: Exception | None = None + for _attempt in range(_attempts): + try: + return run_capture(command, timeout = _timeout) + except subprocess.TimeoutExpired as exc: + last_exc = exc + if _attempt + 1 < _attempts: # don't sleep after the final attempt + time.sleep(2) + raise last_exc if last_exc is not None else RuntimeError("nvidia-smi capture failed") + + # Display-adapter device class: one NNNN subkey per installed display driver # config, each carrying the driver's DriverDesc and PCI MatchingDeviceId. _WINDOWS_DISPLAY_CLASS_KEY = ( @@ -2295,6 +2339,14 @@ def detect_host() -> HostInfo: macos_version = parse_macos_version(platform.mac_ver()[0]) if is_macos else None nvidia_smi = shutil.which("nvidia-smi") + if not nvidia_smi: + # Root WSL sessions drop /usr/lib/wsl/lib (the only nvidia-smi home under + # WSL2 GPU-PV) from PATH, misrouting an ARM NVIDIA WSL host to the CPU + # prebuilt; mirror setup.sh's resolver order. + for _cand in ("/usr/lib/wsl/lib/nvidia-smi", "/usr/bin/nvidia-smi"): + if os.access(_cand, os.X_OK): + nvidia_smi = _cand + break driver_cuda_version = None compute_caps: list[str] = [] visible_cuda_devices = os.environ.get("CUDA_VISIBLE_DEVICES") @@ -2308,7 +2360,7 @@ def detect_host() -> HostInfo: # container leftovers), which would otherwise misclassify an AMD # ROCm host as NVIDIA and short-circuit the ROCm path. try: - listing = run_capture([nvidia_smi, "-L"], timeout = 20) + listing = _nvidia_smi_capture([nvidia_smi, "-L"]) gpu_lines = [line for line in listing.stdout.splitlines() if line.startswith("GPU ")] if gpu_lines: has_physical_nvidia = True @@ -2317,7 +2369,7 @@ def detect_host() -> HostInfo: pass try: - result = run_capture([nvidia_smi], timeout = 20) + result = _nvidia_smi_capture([nvidia_smi]) merged = "\n".join(part for part in (result.stdout, result.stderr) if part) # Newer NVIDIA drivers (e.g. 610.x on Windows) print # "CUDA UMD Version: X.Y" instead of the legacy @@ -2335,13 +2387,12 @@ def detect_host() -> HostInfo: pass try: - caps = run_capture( + caps = _nvidia_smi_capture( [ nvidia_smi, "--query-gpu=index,uuid,compute_cap", "--format=csv,noheader", ], - timeout = 20, ) visible_gpu_rows: list[tuple[str, str, str]] = [] for raw in caps.stdout.splitlines(): diff --git a/studio/scripts/provision_llama_cuda.sh b/studio/scripts/provision_llama_cuda.sh new file mode 100644 index 0000000000..2d51826ac9 --- /dev/null +++ b/studio/scripts/provision_llama_cuda.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Build CUDA llama.cpp for Studio GGUF *inference* into ~/.unsloth/llama.cpp +# (resolver checks /build/bin/llama-server). Idempotent, best-effort, exits +# 0. Exists because no aarch64+CUDA prebuilt covers NVIDIA ARM hosts (DGX Spark / +# GB10, N1X "RTX" laptops). Platform gotchas handled: +# * nvcc rejects gcc-15 -> force gcc-14 / g++-14 host compiler +# * glibc >= 2.41 vs CUDA < 13.3 -> install CUDA 13.3 (rsqrt header clash) +# * sm_121 (Blackwell) -> derive arch from the GPU's compute_cap +# Opt out with UNSLOTH_NO_LLAMA_CUDA=1 (handled by the caller). +set -uo pipefail + +LLAMA_DIR="${UNSLOTH_LLAMA_CPP_PATH:-$HOME/.unsloth/llama.cpp}" +SERVER="$LLAMA_DIR/build/bin/llama-server" +log() { printf ' - %s\n' "$*"; } + +# Serialize against install_llama_prebuilt.py (same lock file as its +# install_lock_path: /..install.lock; its filelock backend is +# flock(2), so this interoperates) and against a second copy of this script: the +# detached background builder can otherwise race an installer rerun or `unsloth +# studio update`, both of which mv/rm -rf inside $LLAMA_DIR. Append-mode open so +# the Python O_EXCL fallback's PID file is never truncated. 2h cap matches a +# worst-case source build; losing the wait means another provisioner is already +# doing this exact job, so exiting 0 is correct. +_LOCK_DIR="$(dirname "$LLAMA_DIR")" +mkdir -p "$_LOCK_DIR" 2>/dev/null +if command -v flock >/dev/null 2>&1; then + if exec 9>>"$_LOCK_DIR/.$(basename "$LLAMA_DIR").install.lock" 2>/dev/null; then + flock -w 7200 9 || { log "another llama.cpp install holds the lock; skipping"; exit 0; } + fi +fi + +# Detect CUDA two ways: monolithic (libggml-cuda in ldd) or split (dlopen-ed +# libggml-cuda.so* beside the binary, missed by ldd). CPU-only builds ship no +# libggml-cuda.so, so its presence is the reliable signal. +is_cuda_server() { + [ -x "$1" ] || return 1 + ldd "$1" 2>/dev/null | grep -qi 'libggml-cuda' && return 0 + for _so in "$(dirname "$1")"/libggml-cuda.so*; do [ -e "$_so" ] && return 0; done + return 1 +} + +# 0. Already provisioned? Skip when the server links libggml-cuda directly (ldd) +# or when a co-located libggml-cuda.so* is paired with the completion stamp this +# script writes after its own final CUDA check. The stamp closes the one gap in +# the structural check: an in-place rebuild interrupted after libggml-cuda.so is +# linked but before llama-server relinks leaves new .so + old CPU server, which +# the bare .so test would wrongly skip. We deliberately do NOT run a functional +# `--list-devices` probe here: this script runs in a stripped-down detached shell whose +# loader path can miss /usr/lib/wsl/lib, so the CUDA backend may fail to enumerate even +# on a perfectly good server -- and a false negative would wipe a validated build and +# trigger a needless, thermally-dangerous source rebuild on the NVIDIA-ARM laptops this +# targets. Trust the .so; never gamble the machine's thermals on an env-fragile probe. +_CUDA_STAMP="$LLAMA_DIR/build/bin/.unsloth-cuda-ok" +if is_cuda_server "$SERVER"; then + if ldd "$SERVER" 2>/dev/null | grep -qi 'libggml-cuda' || [ -e "$_CUDA_STAMP" ]; then + log "CUDA llama-server already present: $SERVER" + exit 0 + fi + log "CUDA .so present but the build never stamped complete (interrupted relink?); rebuilding" +fi + +# 1. Require an NVIDIA GPU (this script is only meaningful with one). +# Resolve nvidia-smi explicitly: root login shells drop /usr/lib/wsl/lib from +# PATH, which is the ONLY location on WSL2 GPU-PV (mirrors setup.sh's resolver). +NVSMI="$(command -v nvidia-smi 2>/dev/null)" +[ -z "$NVSMI" ] && [ -x /usr/lib/wsl/lib/nvidia-smi ] && NVSMI=/usr/lib/wsl/lib/nvidia-smi +[ -z "$NVSMI" ] && [ -x /usr/bin/nvidia-smi ] && NVSMI=/usr/bin/nvidia-smi +if [ -z "$NVSMI" ]; then + log "no nvidia-smi found; skipping CUDA llama.cpp build" + exit 0 +fi + +SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" +HAVE_APT=0; command -v apt-get >/dev/null 2>&1 && HAVE_APT=1 + +# 2. Base toolchain first, then gcc-14 (nvcc rejects gcc-15) in a SEPARATE apt +# transaction: gcc-14 is absent from default Ubuntu 22.04 / Debian 12 sources, so +# a combined transaction would abort and lose the base build tools too. +if [ "$HAVE_APT" -eq 1 ]; then + $SUDO apt-get update -y >/dev/null 2>&1 || true + # libcurl4-openssl-dev: -DLLAMA_CURL=ON needs it, and the WSL deferred path + # skips setup.sh's GGUF dep install that would otherwise provide libcurl. + $SUDO apt-get install -y --no-install-recommends \ + build-essential cmake git curl ca-certificates libcurl4-openssl-dev >/dev/null 2>&1 || true + $SUDO apt-get install -y --no-install-recommends gcc-14 g++-14 >/dev/null 2>&1 || true +fi + +# 3. Locate nvcc; install the CUDA toolkit if missing. Prefer the highest +# /usr/local/cuda- toolkit: a stale unversioned `cuda` symlink or an older +# nvcc earlier on PATH could otherwise win and rebuild with CUDA 12.x, re-hitting +# the glibc>=2.41 / Blackwell clash this script exists to avoid. Fall back to a +# PATH nvcc (e.g. conda) only when no versioned system toolkit is present. +find_nvcc() { + local _v + _v="$(ls -d /usr/local/cuda-*/bin/nvcc 2>/dev/null | sort -V | tail -1)" + if [ -n "$_v" ]; then printf '%s\n' "$_v"; return 0; fi + command -v nvcc 2>/dev/null || ls /usr/local/cuda*/bin/nvcc 2>/dev/null | sort -V | tail -1 +} +# The driver caps which CUDA major can RUN: cu13 binaries need a 580+ driver, +# and minor-version compatibility never crosses majors, so a server built with +# a toolkit newer than the driver loads nothing. Read the driver's supported +# major (all recent drivers print "CUDA Version: X.Y"); unparseable stays empty +# and keeps the previous install-13.3 behavior (Spark-class drivers all parse). +_DRV_CUDA_MAJOR="$("$NVSMI" 2>/dev/null | sed -n 's/.*CUDA Version: *\([0-9][0-9]*\)\..*/\1/p' | head -1)" +case "$_DRV_CUDA_MAJOR" in *[!0-9]*) _DRV_CUDA_MAJOR="" ;; esac +_nvcc_major_of() { "$1" --version 2>/dev/null | sed -n 's/.*release \([0-9][0-9]*\)\..*/\1/p' | head -1; } +_nvcc_minor_of() { "$1" --version 2>/dev/null | sed -n 's/.*release [0-9][0-9]*\.\([0-9][0-9]*\).*/\1/p' | head -1; } + +# glibc >= 2.41 is the host side of the rsqrt header clash that CUDA 13.3 fixes, +# so a 13.0-13.2 toolkit is as unusable here as a pre-13 one. getconf first +# (no ldd on musl-ish images); unparseable stays 0 and keeps the major-only gate. +_glibc_ge_241=0 +_glibc_ver="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}')" +[ -n "$_glibc_ver" ] || _glibc_ver="$(ldd --version 2>/dev/null | head -1 | awk '{print $NF}')" +case "$_glibc_ver" in + [0-9]*.[0-9]*) + _glibc_major="${_glibc_ver%%.*}" + _glibc_minor="${_glibc_ver#*.}"; _glibc_minor="${_glibc_minor%%.*}" + case "$_glibc_major$_glibc_minor" in + *[!0-9]*) ;; + *) if [ "$_glibc_major" -gt 2 ] 2>/dev/null || \ + { [ "$_glibc_major" -eq 2 ] && [ "$_glibc_minor" -ge 41 ]; } 2>/dev/null; then + _glibc_ge_241=1 + fi ;; + esac + ;; +esac + +NVCC="$(find_nvcc)" +# A CUDA < 13 toolkit cannot build for the sm_121 Spark class (and CUDA < 13.3 +# hits the glibc >= 2.41 rsqrt clash from the header) -- keeping it made every +# rerun fail configure/build and exit with the CPU server forever. When apt can +# provide 13.3 AND the driver can run cu13, upgrade past a stale toolkit; +# find_nvcc's sort -V then prefers the new install, and if the install fails +# the old toolkit remains the last resort (previous behavior, still fine on +# non-Spark hosts like GH200 + cu12x, which the driver gate now protects). +_nvcc_stale=0 +if [ -n "$NVCC" ]; then + _nvcc_major="$(_nvcc_major_of "$NVCC")" + _nvcc_minor="$(_nvcc_minor_of "$NVCC")" + if [ -n "$_nvcc_major" ] && [ "$_nvcc_major" -lt 13 ] 2>/dev/null \ + && [ -n "$_DRV_CUDA_MAJOR" ] && [ "$_DRV_CUDA_MAJOR" -ge 13 ]; then + log "existing CUDA $_nvcc_major toolkit ($NVCC) predates this machine class; provisioning CUDA 13.3 alongside it" + _nvcc_stale=1 + elif [ "$_glibc_ge_241" -eq 1 ] && [ -n "$_nvcc_major" ] && [ "$_nvcc_major" -eq 13 ] 2>/dev/null \ + && [ -n "$_nvcc_minor" ] && [ "$_nvcc_minor" -lt 3 ] 2>/dev/null \ + && [ -n "$_DRV_CUDA_MAJOR" ] && [ "$_DRV_CUDA_MAJOR" -ge 13 ]; then + # 13.0-13.2 compiles for sm_121 but hits the rsqrt clash on glibc >= 2.41, + # so the build fails and GGUF inference stays on the CPU server. + log "existing CUDA $_nvcc_major.$_nvcc_minor toolkit ($NVCC) hits the glibc $_glibc_ver rsqrt clash; provisioning CUDA 13.3 alongside it" + _nvcc_stale=1 + fi +fi +if [ -z "$NVCC" ] && [ -n "$_DRV_CUDA_MAJOR" ] && [ "$_DRV_CUDA_MAJOR" -lt 13 ]; then + # No toolkit and the driver cannot run cu13: installing 13.3 would build an + # unloadable server. Bail to the no-toolkit message (CPU fallback stands). + log "driver supports CUDA ${_DRV_CUDA_MAJOR}.x only; not installing CUDA 13.3 (its binaries need a 580+ driver)" +elif { [ -z "$NVCC" ] || [ "$_nvcc_stale" -eq 1 ]; } && [ "$HAVE_APT" -eq 1 ]; then + [ -z "$NVCC" ] && log "CUDA toolkit (nvcc) not found - installing CUDA 13.3 (matches torch cu13x; avoids glibc>=2.41 rsqrt clash)" + # shellcheck disable=SC1091 + . /etc/os-release 2>/dev/null || true + case "$(uname -m)" in + aarch64) NV_ARCH=sbsa ;; + x86_64) NV_ARCH=x86_64 ;; + *) NV_ARCH="" ;; + esac + case "${ID:-}${VERSION_ID:-}" in + ubuntu24.04) NV_DISTRO=ubuntu2404 ;; + ubuntu22.04) NV_DISTRO=ubuntu2204 ;; + debian12) NV_DISTRO=debian12 ;; + *) NV_DISTRO="" ;; + esac + if [ -n "$NV_ARCH" ] && [ -n "$NV_DISTRO" ]; then + KR=/tmp/cuda-keyring.deb + if curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/$NV_DISTRO/$NV_ARCH/cuda-keyring_1.1-1_all.deb" -o "$KR" 2>/dev/null; then + $SUDO dpkg -i "$KR" >/dev/null 2>&1 || true + $SUDO apt-get update -y >/dev/null 2>&1 || true + $SUDO apt-get install -y cuda-toolkit-13-3 >/dev/null 2>&1 \ + || $SUDO apt-get install -y cuda-toolkit >/dev/null 2>&1 || true + fi + fi + NVCC="$(find_nvcc)" +fi + +# Final sanity: never build with a toolkit whose major the driver cannot run +# (find_nvcc prefers the highest install, which may be a manually added 13.x on +# an older-driver host). Prefer the newest toolkit at or below the driver's +# major; with none, fall through to the no-toolkit exit. +if [ -n "$NVCC" ] && [ -n "$_DRV_CUDA_MAJOR" ]; then + _nvcc_major="$(_nvcc_major_of "$NVCC")" + if [ -n "$_nvcc_major" ] && [ "$_nvcc_major" -gt "$_DRV_CUDA_MAJOR" ] 2>/dev/null; then + _alt="$(ls -d /usr/local/cuda-[0-9]*/bin/nvcc 2>/dev/null | sort -V \ + | awk -F'cuda-' -v m="$_DRV_CUDA_MAJOR" '{ split($2, v, /[./]/); if (v[1] + 0 <= m + 0) print }' | tail -1)" + if [ -n "$_alt" ]; then + log "CUDA $_nvcc_major toolkit exceeds the driver's supported major ($_DRV_CUDA_MAJOR); using $_alt instead" + NVCC="$_alt" + else + log "the only CUDA toolkit ($_nvcc_major.x) is newer than the driver supports (CUDA $_DRV_CUDA_MAJOR.x); a build would not load" + NVCC="" + fi + fi +fi + +if [ -z "$NVCC" ]; then + log "could not provision a CUDA toolkit. Training + GGUF export still work;" + log "GGUF *inference* in Studio will be unavailable until a CUDA toolkit exists." + log "Re-run this script after installing one to enable GGUF inference." + exit 0 +fi + +CUDA_HOME="$(dirname "$(dirname "$NVCC")")" +# CUDA + Linux dirs FIRST so the build uses Linux cmake/gcc/git, not Windows tools +# leaked in via WSL interop (/mnt/c). Keep original PATH so nvidia-smi resolves. +export PATH="$CUDA_HOME/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" +export CUDAToolkit_ROOT="$CUDA_HOME" + +# 4. Host compiler: prefer gcc-14 / g++-14 (nvcc rejects 15). +HCC=gcc; command -v gcc-14 >/dev/null 2>&1 && HCC=gcc-14 +HCXX=g++; command -v g++-14 >/dev/null 2>&1 && HCXX=g++-14 +export CC="$HCC" CXX="$HCXX" CUDAHOSTCXX="$HCXX" + +# 5. CUDA arch from the GPU's compute capability (e.g. "12.1" -> 121). Fallback: native. +# Only a purely-numeric capability is a valid CMAKE_CUDA_ARCHITECTURES; some WSL +# GPU-PV / driver combos report "N/A". "native" needs CMake >= 3.24 (Ubuntu +# 22.04 apt ships 3.22), so the fallback omits the flag entirely and lets +# ggml's version-guarded CMake defaults pick the arches instead. +CC_CAP="$("$NVSMI" --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1 | tr -d ' .')" +case "$CC_CAP" in + ''|*[!0-9]*) CUDA_ARCH="" ;; + *) CUDA_ARCH="$CC_CAP" ;; +esac + +# 6. Clone + build into ~/.unsloth/llama.cpp, honoring a UNSLOTH_LLAMA_TAG pin +# (same var setup.sh uses) instead of always tracking ggml-org main. +mkdir -p "$(dirname "$LLAMA_DIR")" +_LLAMA_REF="${UNSLOTH_LLAMA_TAG:-}" +# setup.sh's install policy pins source builds to the newest RELEASE ("latest" +# resolved to a tag; master bypasses the pin). Mirror it: unset or literal +# "latest" resolves via the GitHub API; on API failure the empty ref keeps the +# existing default-branch clone fallback (best effort, as before). +if [ -z "$_LLAMA_REF" ] || [ "$_LLAMA_REF" = "latest" ]; then + _LLAMA_REF="$(curl -fsSL --max-time 15 https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null \ + | grep -om1 '"tag_name": *"[^"]*"' | cut -d'"' -f4)" + [ -n "$_LLAMA_REF" ] && log "pinning llama.cpp to release $_LLAMA_REF" +fi +# Back up any existing (e.g. CPU-only) llama.cpp: restored on any failure exit, +# dropped only once the fresh build yields a server -- never leave NO server. +_LLAMA_BAK="" +_FRESH_CLONE=0 +_restore_prev() { + if [ -n "$_LLAMA_BAK" ] && [ -e "$_LLAMA_BAK" ]; then + rm -rf "$LLAMA_DIR" 2>/dev/null + mv "$_LLAMA_BAK" "$LLAMA_DIR" 2>/dev/null && log "restored previous llama.cpp install" + elif [ "$_FRESH_CLONE" = "1" ] && [ ! -x "$SERVER" ]; then + # We created this clone and produced no server. Leaving a markerless git + # tree under a custom STUDIO_HOME bricks reruns: setup.sh's ownership + # assert refuses the unmarked dir and aborts the whole install. + rm -rf "$LLAMA_DIR" 2>/dev/null + fi +} +if [ ! -d "$LLAMA_DIR/.git" ]; then + if [ -e "$LLAMA_DIR" ]; then + _LLAMA_BAK="${LLAMA_DIR}.prev.$$" + rm -rf "$_LLAMA_BAK" 2>/dev/null + mv "$LLAMA_DIR" "$_LLAMA_BAK" 2>/dev/null || { rm -rf "$LLAMA_DIR" 2>/dev/null; _LLAMA_BAK=""; } + fi + _clone_ok=0 + if [ -n "$_LLAMA_REF" ]; then + git clone --depth 1 --branch "$_LLAMA_REF" https://github.com/ggml-org/llama.cpp "$LLAMA_DIR" >/dev/null 2>&1 && _clone_ok=1 + fi + if [ "$_clone_ok" -ne 1 ]; then + git clone --depth 1 https://github.com/ggml-org/llama.cpp "$LLAMA_DIR" >/dev/null 2>&1 && _clone_ok=1 + fi + [ "$_clone_ok" -eq 1 ] && _FRESH_CLONE=1 + if [ "$_clone_ok" -ne 1 ]; then + log "git clone failed" + _restore_prev + exit 0 + fi +else + # Existing checkout: honor the pin on reruns too (previously all ref + # handling lived in the fresh-clone branch, so an existing tree rebuilt + # whatever commit it had regardless of the pin). Best-effort -- an + # unreachable ref keeps the current commit, matching the clone fallback. + if [ -n "$_LLAMA_REF" ]; then + _cur_head="$(git -C "$LLAMA_DIR" rev-parse HEAD 2>/dev/null)" + if git -C "$LLAMA_DIR" fetch --depth 1 origin "$_LLAMA_REF" >/dev/null 2>&1; then + _ref_head="$(git -C "$LLAMA_DIR" rev-parse FETCH_HEAD 2>/dev/null)" + if [ -n "$_ref_head" ] && [ "$_ref_head" != "$_cur_head" ]; then + git -C "$LLAMA_DIR" checkout -q FETCH_HEAD >/dev/null 2>&1 \ + && log "updated existing llama.cpp checkout to $_LLAMA_REF" \ + || log "could not check out $_LLAMA_REF; keeping the current commit" + fi + else + log "could not fetch $_LLAMA_REF; keeping the current commit" + fi + fi +fi +# Honor a UNSLOTH_LLAMA_PR pin (same var setup.sh supports) on fresh clones and +# existing checkouts alike; best-effort -- a failed fetch keeps what's there. +case "${UNSLOTH_LLAMA_PR:-}" in + ''|*[!0-9]*) ;; + *) + if git -C "$LLAMA_DIR" fetch --depth 1 origin "pull/${UNSLOTH_LLAMA_PR}/head:_unsloth_pr_${UNSLOTH_LLAMA_PR}" >/dev/null 2>&1 \ + && git -C "$LLAMA_DIR" checkout "_unsloth_pr_${UNSLOTH_LLAMA_PR}" >/dev/null 2>&1; then + log "checked out llama.cpp PR #${UNSLOTH_LLAMA_PR} (UNSLOTH_LLAMA_PR)" + else + log "could not fetch llama.cpp PR #${UNSLOTH_LLAMA_PR}; building the default branch" + fi + ;; +esac +cd "$LLAMA_DIR" || { _restore_prev; exit 0; } + +# When rebuilding in-place over an existing git checkout, the whole-dir backup above +# was skipped (_LLAMA_BAK empty) -- but build/ may already hold a working (e.g. CPU) +# llama-server from a prior setup.sh source build. The wipe-on-failure paths below +# would destroy it with nothing to restore, leaving NO server despite the "keeps the +# existing server" promise (a thermal shutdown mid-CUDA-build is a real failure mode +# here). Back up the existing binaries so a failed rebuild can put them back. Only +# bin/ (server + dlopen-ed backends) is needed; cheap since any pre-existing server +# here is the non-CUDA fallback (a CUDA one would have exited at step 0). +_BUILD_BAK="" +if [ -z "$_LLAMA_BAK" ] && [ -x "$SERVER" ]; then + _BUILD_BAK="${LLAMA_DIR}.binbak.$$" + rm -rf "$_BUILD_BAK" 2>/dev/null + cp -a "$LLAMA_DIR/build/bin" "$_BUILD_BAK" 2>/dev/null || _BUILD_BAK="" +fi +_restore_build() { + if [ -n "$_BUILD_BAK" ] && [ -e "$_BUILD_BAK" ] && [ ! -x "$SERVER" ]; then + mkdir -p "$LLAMA_DIR/build" 2>/dev/null + rm -rf "$LLAMA_DIR/build/bin" 2>/dev/null + mv "$_BUILD_BAK" "$LLAMA_DIR/build/bin" 2>/dev/null && log "restored previous llama-server (rebuild failed)" + fi + [ -n "$_BUILD_BAK" ] && rm -rf "$_BUILD_BAK" 2>/dev/null + _BUILD_BAK="" +} + +log "building CUDA llama.cpp (arch=${CUDA_ARCH:-cmake-default}, host=$HCXX) - this takes a few minutes..." +_cmake_configure() { + # Empty CUDA_ARCH (unreadable compute_cap): omit the flag so ggml's own + # CMake defaults apply -- "native" would need CMake >= 3.24. + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DGGML_CUDA=ON -DGGML_CUDA_F16=ON \ + ${CUDA_ARCH:+-DCMAKE_CUDA_ARCHITECTURES="$CUDA_ARCH"} \ + -DCMAKE_CUDA_HOST_COMPILER="$HCXX" \ + -DLLAMA_CURL=ON >/dev/null 2>&1 +} +# A pre-existing build/ may carry a stale CMake cache (relocated dir: bad absolute +# paths + GGML_CUDA=OFF). Reuse first (fast incremental); wipe only on failure. +if ! _cmake_configure; then + log "stale/incompatible CMake cache detected; wiping build dir for a clean CUDA configure" + rm -rf build + _cmake_configure || { log "cmake configure failed"; cd /; _restore_build; _restore_prev; exit 0; } +fi +# Also builds the targets unsloth-zoo's GGUF exporter needs (llama-mtmd-cli, +# llama-gguf-split). Jobs default to ~half the cores (full -j(nproc) CUDA builds +# trip thermal shutdowns on NVIDIA-ARM laptops like the N1X "RTX Spark"), capped +# at ~1.5 GB/nvcc job. Tune: UNSLOTH_LLAMA_BUILD_JOBS=N; re-runs resume. +_ncpu="$(nproc 2>/dev/null || echo 4)" +# Honor a valid positive-int override; ignore junk/0 (cmake treats -j0 as all cores). +if [ -n "${UNSLOTH_LLAMA_BUILD_JOBS:-}" ] && [ "${UNSLOTH_LLAMA_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then + JOBS="$UNSLOTH_LLAMA_BUILD_JOBS" +else + _half=$(( (_ncpu + 1) / 2 )) # ~half the cores for thermal headroom + if [ "$_ncpu" -le 4 ]; then _half="$_ncpu"; fi # tiny boxes: use all cores + _memkb="$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 0)" + _memjobs=$(( _memkb / 1572864 )) # 1.5 GB per nvcc job + if [ "$_memjobs" -lt 1 ]; then _memjobs=1; fi + JOBS="$_half" + if [ "$_memjobs" -lt "$JOBS" ]; then JOBS="$_memjobs"; fi +fi +log "building with -j${JOBS} (cores=${_ncpu})" +# nice/ionice: full speed when idle, yields to foreground Studio/training runs. +_NICE="" +command -v nice >/dev/null 2>&1 && _NICE="nice -n 19" +command -v ionice >/dev/null 2>&1 && _NICE="$_NICE ionice -c 3" +_cmake_build() { + # Only llama-server is REQUIRED: an old UNSLOTH_LLAMA_TAG pin may predate the + # helper targets, whose absence must not fail the whole provision. + $_NICE cmake --build build -j"$JOBS" --target llama-server >/dev/null 2>&1 +} +_cmake_build_extras() { + # Helper targets unsloth-zoo's GGUF exporter also uses -- best-effort each. + for _t in llama-cli llama-quantize llama-mtmd-cli llama-gguf-split; do + $_NICE cmake --build build -j"$JOBS" --target "$_t" >/dev/null 2>&1 || true + done +} +if ! _cmake_build; then + # An interrupted build (thermal/power shutdown, common on this machine class) + # can leave a half-linked libggml-cuda.so that breaks the resume link + # (undefined ggml_cuda_op_* refs); wipe and rebuild clean. + log "build failed (likely interrupted/partial); wiping build dir and rebuilding clean" + rm -rf build + _cmake_configure || { log "cmake configure failed"; cd /; _restore_build; _restore_prev; exit 0; } + _cmake_build || { log "cmake build failed"; cd /; _restore_build; _restore_prev; exit 0; } +fi +_cmake_build_extras +# Drop the backup on a successful build, or restore the prior server if the rebuild +# yielded none (idempotent; only restores when $SERVER is missing). +_restore_build + +if is_cuda_server "$SERVER"; then + : > "$_CUDA_STAMP" 2>/dev/null || true + # unsloth_zoo's check_llama_cpp only searches the repo root, so mirror + # setup.sh's root shim for the GGUF exporter's quantize binary. + if [ -x "$LLAMA_DIR/build/bin/llama-quantize" ] && [ ! -e "$LLAMA_DIR/llama-quantize" ]; then + ln -sf build/bin/llama-quantize "$LLAMA_DIR/llama-quantize" 2>/dev/null || true + fi + log "CUDA llama-server ready: $SERVER" + [ -n "$_LLAMA_BAK" ] && rm -rf "$_LLAMA_BAK" 2>/dev/null +elif [ -x "$SERVER" ]; then + # A server exists but isn't CUDA-confirmed; still better than the old backup. + log "build finished but CUDA llama-server could not be confirmed" + [ -n "$_LLAMA_BAK" ] && rm -rf "$_LLAMA_BAK" 2>/dev/null +else + log "build finished but no llama-server was produced" + cd /; _restore_prev +fi +exit 0 diff --git a/studio/setup.sh b/studio/setup.sh index b4088c15b6..1cc0b82ae9 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -320,16 +320,21 @@ _setup_cvd_hides_nvidia() { # via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches # install_llama_prebuilt.py has_usable_nvidia), so the AMD probes still run # and a mixed host steered to its AMD card keeps the ROCm route. +# nvidia-smi resolver: on WSL2 GPU-PV the binary lives ONLY in /usr/lib/wsl/lib, +# which root login shells drop from PATH, so bare `command -v nvidia-smi` misses +# real GPUs on the flagship WoA path. +_resolve_nvsmi() { + command -v nvidia-smi 2>/dev/null && return 0 + [ -x /usr/lib/wsl/lib/nvidia-smi ] && { echo /usr/lib/wsl/lib/nvidia-smi; return 0; } + [ -x /usr/bin/nvidia-smi ] && { echo /usr/bin/nvidia-smi; return 0; } + return 1 +} + _setup_has_usable_nvidia_gpu() { if _setup_cvd_hides_nvidia; then return 1 fi - _setup_nvsmi="" - if command -v nvidia-smi >/dev/null 2>&1; then - _setup_nvsmi="nvidia-smi" - elif [ -x "/usr/bin/nvidia-smi" ]; then - _setup_nvsmi="/usr/bin/nvidia-smi" - fi + _setup_nvsmi="$(_resolve_nvsmi)" || _setup_nvsmi="" if [ -n "$_setup_nvsmi" ]; then if _setup_run_smi "$_setup_nvsmi" -L 2>/dev/null \ | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then @@ -344,8 +349,8 @@ _setup_has_usable_nvidia_gpu() { } _cuda_driver_max_version() { - command -v nvidia-smi >/dev/null 2>&1 || return 0 - _setup_run_smi nvidia-smi 2>/dev/null \ + _cdm_smi="$(_resolve_nvsmi)" || return 0 + _setup_run_smi "$_cdm_smi" 2>/dev/null \ | sed -nE 's/.*CUDA( UMD)? Version:[[:space:]]*([0-9]+)\.([0-9]+).*/\2.\3/p' \ | head -1 || true } @@ -1233,6 +1238,10 @@ LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp" LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server" _NEED_LLAMA_SOURCE_BUILD=false _LLAMA_CPP_DEGRADED=false +# Deferred != degraded: on WSL2 aarch64+NVIDIA install.ps1 builds the CUDA server in +# the background, so an absent server is success -- must not trip the arm64 CPU-prebuilt +# last-resort or exit 1. +_LLAMA_CPP_DEFERRED=false _LLAMA_CPP_NO_SPACE=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" @@ -1496,6 +1505,54 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && \ _NEED_LLAMA_SOURCE_BUILD=false fi +# ── WSL2 aarch64 + NVIDIA, no nvcc yet: defer to the background CUDA build ── +# install.ps1 builds the CUDA llama-server in the background and signals it via +# UNSLOTH_WSL_LLAMA_DEFERRED=1. ONLY defer when that flag is set: a direct in-WSL +# `unsloth studio update` has no background builder, so deferring there would claim +# "CUDA build running in background" while nothing builds. Without the flag (or with +# nvcc) we fall through to a slow CPU server; opted out the CPU build is the only server. +if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \ + && [ "${UNSLOTH_WSL_LLAMA_DEFERRED:-0}" = "1" ] \ + && [ "$_LLAMA_FORCE_COMPILE" != "1" ] \ + && [ -z "$_LLAMA_PR" ] \ + && [ "${UNSLOTH_NO_LLAMA_CUDA:-0}" != "1" ] \ + && grep -qi microsoft /proc/version 2>/dev/null \ + && { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \ + && _NVSMI_GATE="$(_resolve_nvsmi)" && [ -n "$_NVSMI_GATE" ] \ + && "$_NVSMI_GATE" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' \ + && ! command -v nvcc >/dev/null 2>&1 \ + && ! ls /usr/local/cuda*/bin/nvcc >/dev/null 2>&1; then + step "llama.cpp" "GGUF engine: CUDA build running in background (WSL aarch64 + NVIDIA)" "$C_WARN" + substep "skipping slow CPU build; the background CUDA llama.cpp will provide the server" + substep "(opt out / keep CPU build with UNSLOTH_NO_LLAMA_CUDA=1)" + # DEFERRED, not DEGRADED: DEGRADED triggers the CPU-prebuilt last resort + exit 1. + _NEED_LLAMA_SOURCE_BUILD=false + _LLAMA_CPP_DEFERRED=true +fi + +# ── Native Linux aarch64 + NVIDIA, no nvcc yet: skip the CPU build too ── +# The provision block below installs the CUDA toolkit and does the only build this host +# needs; a CPU source build first would burn minutes (and thermal headroom on Spark-class +# machines) on a binary the CUDA rebuild replaces. Provision failure still cascades to the +# CPU-prebuilt last resort via _LLAMA_CPP_DEGRADED, so no-server states surface. +if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \ + && [ "$_LLAMA_FORCE_COMPILE" != "1" ] \ + && [ -z "$_LLAMA_PR" ] \ + && [ "${UNSLOTH_NO_LLAMA_CUDA:-0}" != "1" ] \ + && [ "${_SKIP_GGUF_BUILD:-}" != true ] \ + && ! grep -qi microsoft /proc/version 2>/dev/null \ + && { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \ + && _NVSMI_GATE="$(_resolve_nvsmi)" && [ -n "$_NVSMI_GATE" ] \ + && "$_NVSMI_GATE" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' \ + && [ "${_setup_nvidia_usable:-}" = true ] \ + && ! command -v nvcc >/dev/null 2>&1 \ + && ! ls /usr/local/cuda*/bin/nvcc >/dev/null 2>&1; then + step "llama.cpp" "GGUF engine: deferring to this run's CUDA provision (aarch64 + NVIDIA, no nvcc)" "$C_WARN" + substep "skipping the slow CPU build; the CUDA llama.cpp build below provides the server" + substep "(opt out / keep the CPU build with UNSLOTH_NO_LLAMA_CUDA=1)" + _NEED_LLAMA_SOURCE_BUILD=false +fi + # ── 8. WSL: pre-install GGUF build dependencies for fallback source builds ── # On WSL, sudo requires a password and can't be entered during GGUF export # (runs in a non-interactive subprocess). Install build deps here instead. @@ -1786,6 +1843,23 @@ else fi if [ "$_CUDA_TOOLKIT_ALLOWED" = true ]; then + # glibc >= 2.41 + CUDA < 13.3: rsqrt/rsqrtf header clash fails every + # .cu -> CPU fallback; only fix is CUDA >= 13.3. Diagnostic only (never + # changes flags), against the final _NVCC_VER (post driver-compat swap). + _GLIBC_VER="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}')" || _GLIBC_VER="" + if [ -n "$_GLIBC_VER" ]; then + _GLIBC_MAJ="${_GLIBC_VER%%.*}"; _GLIBC_MIN="${_GLIBC_VER#*.}"; _GLIBC_MIN="${_GLIBC_MIN%%.*}" + _CU_MAJ="${_NVCC_VER%%.*}"; _CU_MIN="${_NVCC_VER#*.}"; _CU_MIN="${_CU_MIN%%.*}" + if { [ "${_GLIBC_MAJ:-0}" -gt 2 ] 2>/dev/null \ + || { [ "${_GLIBC_MAJ:-0}" -eq 2 ] 2>/dev/null && [ "${_GLIBC_MIN:-0}" -ge 41 ] 2>/dev/null; }; } \ + && { [ "${_CU_MAJ:-0}" -lt 13 ] 2>/dev/null \ + || { [ "${_CU_MAJ:-0}" -eq 13 ] 2>/dev/null && [ "${_CU_MIN:-0}" -lt 3 ] 2>/dev/null; }; }; then + substep "CUDA toolkit ${_NVCC_VER} is incompatible with glibc ${_GLIBC_VER} (rsqrt/rsqrtf header clash)." "$C_ERR" + substep "the GPU build will fail to compile and fall back to CPU -- install CUDA Toolkit >= 13.3:" "$C_WARN" + substep "https://developer.nvidia.com/cuda-downloads (setup.sh auto-selects the newest /usr/local/cuda-*)" "$C_WARN" + fi + fi + # Resolve the arch list before committing to a CUDA build; # an empty list means CPU instead of a PTX-only binary (#5854). _raw_caps="" @@ -1910,6 +1984,27 @@ else substep "$_BUILD_DESC..." NCPU=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + # Thermal cap for the aarch64 + NVIDIA foreground CUDA build. A full -j(nproc) + # nvcc compile can trip a thermal shutdown on the lightly-cooled NVIDIA-ARM boxes + # this targets (DGX Spark / GB10, N1X "RTX Spark") -- same reason + # provision_llama_cuda.sh caps its background build. Mirror it here: ~half the + # cores, also bounded by ~1.5 GB/nvcc job. Other platforms keep full -j(nproc); + # override anywhere with UNSLOTH_LLAMA_BUILD_JOBS=N. + if { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \ + && [ "${GPU_BACKEND:-}" = "cuda" ]; then + if [ -n "${UNSLOTH_LLAMA_BUILD_JOBS:-}" ] && [ "${UNSLOTH_LLAMA_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then + NCPU="$UNSLOTH_LLAMA_BUILD_JOBS" + else + _cap_half=$(( (NCPU + 1) / 2 )) + [ "$NCPU" -le 4 ] && _cap_half="$NCPU" # tiny boxes: use all cores + _cap_memkb="$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 0)" + _cap_memjobs=$(( _cap_memkb / 1572864 )) # ~1.5 GB per nvcc job + [ "$_cap_memjobs" -lt 1 ] && _cap_memjobs=1 + [ "$_cap_memjobs" -lt "$_cap_half" ] && _cap_half="$_cap_memjobs" + NCPU="$_cap_half" + fi + substep "thermal-capped CUDA build: -j${NCPU} (override with UNSLOTH_LLAMA_BUILD_JOBS=N)" + fi CMAKE_GENERATOR_ARGS="" if command -v ninja &>/dev/null; then CMAKE_GENERATOR_ARGS="-G Ninja" @@ -2044,6 +2139,81 @@ else } fi # end _SKIP_GGUF_BUILD check +# ── aarch64 + NVIDIA (DGX Spark / GB10 / N1X "RTX Spark"): provision a CUDA +# llama.cpp when the source build above could not (no CUDA toolkit found) ── +# No aarch64+CUDA prebuilt exists and a fresh Spark ships only driver + nvidia-smi, so the +# build above fell back to CPU; mirror the Windows/WSL fix (provision_llama_cuda.sh) for +# native Linux. Best-effort: on failure the prior CPU/degraded state stands. +# CUDA detection covers both layouts: monolithic (libggml-cuda in ldd) and split +# (dlopen-ed libggml-cuda.so* beside the binary, missed by ldd). CPU-only builds ship none. +_have_cuda_llama_server() { + [ -x "$LLAMA_SERVER_BIN" ] || return 1 + ldd "$LLAMA_SERVER_BIN" 2>/dev/null | grep -qi 'libggml-cuda' && return 0 + # Split-.so builds here come from provision_llama_cuda.sh, which stamps + # .unsloth-cuda-ok only after its final CUDA check. Requiring the stamp keeps an + # interrupted-relink state (new .so + old CPU server) provisioning, not "ready". + _stamp="$(dirname "$LLAMA_SERVER_BIN")/.unsloth-cuda-ok" + for _so in "$(dirname "$LLAMA_SERVER_BIN")"/libggml-cuda.so*; do + [ -e "$_so" ] && [ -e "$_stamp" ] && return 0 + done + return 1 +} +if [ "$_HOST_SYSTEM" = "Linux" ] \ + && { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \ + && { ! grep -qi microsoft /proc/version 2>/dev/null || [ "${UNSLOTH_WSL_LLAMA_DEFERRED:-0}" != "1" ]; } \ + && [ "${UNSLOTH_NO_LLAMA_CUDA:-0}" != "1" ] \ + && [ "${_SKIP_GGUF_BUILD:-}" != true ] \ + && _NVSMI_GATE="$(_resolve_nvsmi)" && [ -n "$_NVSMI_GATE" ] \ + && "$_NVSMI_GATE" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' \ + && [ "${_setup_nvidia_usable:-}" = true ] \ + && [ "$_LOCAL_LLAMA_CPP_LINKED" != true ] \ + && ! _have_cuda_llama_server; then + # Under WSL this runs ONLY for a DIRECT `install.sh` run: install.ps1 sets + # UNSLOTH_WSL_LLAMA_DEFERRED=1 and builds in the background; a direct run has none. + # Resolve provision_llama_cuda.sh: beside setup.sh, then local-dev repo, else fetch + # from GitHub (so `curl | sh` works on an older wheel without it). + _PROV_SH="" + if [ -f "$SCRIPT_DIR/scripts/provision_llama_cuda.sh" ]; then + _PROV_SH="$SCRIPT_DIR/scripts/provision_llama_cuda.sh" + elif [ "${STUDIO_LOCAL_INSTALL:-0}" = "1" ] && [ -f "$REPO_ROOT/studio/scripts/provision_llama_cuda.sh" ]; then + _PROV_SH="$REPO_ROOT/studio/scripts/provision_llama_cuda.sh" + else + _PROV_URL="https://raw.githubusercontent.com/unslothai/unsloth/main/studio/scripts/provision_llama_cuda.sh" + _PROV_TMP="$UNSLOTH_HOME/provision_llama_cuda.sh" + if curl -fsSL "$_PROV_URL" -o "$_PROV_TMP" 2>/dev/null && [ -s "$_PROV_TMP" ]; then + _PROV_SH="$_PROV_TMP" + fi + fi + if [ -n "$_PROV_SH" ]; then + step "llama.cpp" "aarch64 + NVIDIA: provisioning CUDA toolkit + building CUDA llama.cpp for GGUF inference..." "$C_WARN" + substep "(opt out with UNSLOTH_NO_LLAMA_CUDA=1; lower load with UNSLOTH_LLAMA_BUILD_JOBS=N)" + # UNSLOTH_LLAMA_CPP_PATH routes a custom STUDIO_HOME into $LLAMA_CPP_DIR. + UNSLOTH_LLAMA_CPP_PATH="$LLAMA_CPP_DIR" bash "$_PROV_SH" || true + if _have_cuda_llama_server; then + step "llama.cpp" "CUDA llama-server ready (aarch64 + NVIDIA)" + _LLAMA_CPP_DEGRADED=false + # Claim ownership of the fresh $LLAMA_CPP_DIR, else the next custom-STUDIO_HOME + # run's _assert_studio_owned_or_absent aborts. + if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then + : > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true + fi + elif [ -f "$LLAMA_SERVER_BIN" ]; then + substep "CUDA build unavailable; keeping existing (CPU) llama-server" "$C_WARN" + else + substep "CUDA build unavailable and no llama-server present; see $LLAMA_CPP_DIR build output" "$C_WARN" + # No server at all: mark degraded so the arm64 CPU-prebuilt last resort and + # the failure exit fire instead of reporting a working install. + _LLAMA_CPP_DEGRADED=true + fi + else + # Provisioner unreachable (not packaged and the GitHub fetch failed). The native + # deferral above may have skipped the CPU source build expecting this block; without + # a server, surface degraded so the CPU-prebuilt last resort fires, not success. + substep "CUDA provision script unavailable (offline?); cannot build CUDA llama.cpp" "$C_WARN" + [ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true + fi +fi + # ── arm64 Linux GPU: CPU prebuilt as a last resort ── # An arm64 Linux GPU host source-builds for the GPU above. If that produced no # binary, install the fork's arm64 CPU prebuilt (app--linux-arm64-cpu.tar.gz) @@ -2176,7 +2346,9 @@ fi if [ "$_LLAMA_ONLY" = "1" ]; then echo "" printf " ${C_DIM}%s${C_RST}\n" "$RULE" - if [ "$_LLAMA_CPP_DEGRADED" = true ]; then + if [ "$_LLAMA_CPP_DEFERRED" = true ]; then + printf " ${C_TITLE}%s${C_RST}\n" "llama.cpp update finished (GGUF engine: CUDA build running in background)" + elif [ "$_LLAMA_CPP_DEGRADED" = true ]; then printf " ${C_WARN}%s${C_RST}\n" "llama.cpp update finished (limited: llama.cpp unavailable)" else printf " ${C_TITLE}%s${C_RST}\n" "llama.cpp update finished" @@ -2185,7 +2357,9 @@ if [ "$_LLAMA_ONLY" = "1" ]; then elif [ "$IS_COLAB" = true ]; then echo "" printf " ${C_DIM}%s${C_RST}\n" "$RULE" - if [ "$_LLAMA_CPP_DEGRADED" = true ]; then + if [ "$_LLAMA_CPP_DEFERRED" = true ]; then + printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio Setup Complete (GGUF engine: CUDA build running in background)" + elif [ "$_LLAMA_CPP_DEGRADED" = true ]; then printf " ${C_WARN}%s${C_RST}\n" "Unsloth Studio Setup Complete (limited: llama.cpp unavailable)" else printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio Setup Complete" @@ -2195,7 +2369,9 @@ elif [ "$IS_COLAB" = true ]; then substep "start()" else printf " ${C_DIM}%s${C_RST}\n" "$RULE" - if [ "$_LLAMA_CPP_DEGRADED" = true ]; then + if [ "$_LLAMA_CPP_DEFERRED" = true ]; then + printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio Installed (GGUF engine: CUDA build running in background)" + elif [ "$_LLAMA_CPP_DEGRADED" = true ]; then printf " ${C_WARN}%s${C_RST}\n" "Unsloth Studio Installed (limited: llama.cpp unavailable)" else printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio Installed" @@ -2211,6 +2387,14 @@ else fi echo "" +# Core install (venv + torch + Studio deps) is complete here; only the optional llama.cpp +# engine can still be missing. Stamp that BEFORE the tolerated nonzero exit below: +# install.ps1's WSL fallback can't tell that exit from a real mid-install failure, so it +# removes this file before the run and requires it afterwards -- else its torch/CLI probes +# could pass on a stale venv. Removed by scripts/uninstall.sh with the rest of ~/.unsloth. +mkdir -p "$HOME/.unsloth" 2>/dev/null || true +: > "$HOME/.unsloth/.install-ok" 2>/dev/null || true + # When called from install.sh (SKIP_STUDIO_BASE=1), exit non-zero so the # installer can report the GGUF failure after finishing PATH/shortcut setup. # When called directly via 'unsloth studio update', keep the install diff --git a/tests/studio/install/test_gpu_detection_followups.py b/tests/studio/install/test_gpu_detection_followups.py index d2fd7ae8db..83d30a663c 100644 --- a/tests/studio/install/test_gpu_detection_followups.py +++ b/tests/studio/install/test_gpu_detection_followups.py @@ -265,10 +265,13 @@ class TestSetupShHardening: assert wrapped, "compute_cap probe must be wrapped in _setup_run_smi (timeout-bounded)" def test_driver_version_probe_timeout_wrapped(self, setup_src): + # The probe resolves nvidia-smi explicitly (root WSL shells drop /usr/lib/wsl/lib + # from PATH) and must still go through the timeout wrapper with the resolved path. start = setup_src.find("_cuda_driver_max_version()") end = setup_src.find("\n}", start) body = setup_src[start:end] - assert "_setup_run_smi nvidia-smi" in body + assert "_resolve_nvsmi" in body + assert '_setup_run_smi "$_cdm_smi"' in body # TEST: install.sh -- UNSLOTH_TORCH_BACKEND classified on the final path segment @@ -457,7 +460,12 @@ class TestHiddenCvdNotUsable: out = self._run_sh_helper( tmp_path, src, - ["_setup_run_smi", "_setup_cvd_hides_nvidia", "_setup_has_usable_nvidia_gpu"], + [ + "_resolve_nvsmi", + "_setup_run_smi", + "_setup_cvd_hides_nvidia", + "_setup_has_usable_nvidia_gpu", + ], cvd, ) assert out == expected diff --git a/unsloth/kernels/flex_attention.py b/unsloth/kernels/flex_attention.py index 0e386ac4aa..115cdd8f57 100644 --- a/unsloth/kernels/flex_attention.py +++ b/unsloth/kernels/flex_attention.py @@ -25,6 +25,52 @@ torch_compile_options = { "triton.cudagraphs": False, } + +def _flex_is_dgx_spark(): + # CUDA-free copy of _utils._is_dgx_spark_no_cuda_init() (avoids circular import). + # Runs at module import, before ._utils -- touching torch.cuda here would init + # the allocator before patch_dgx_spark_memory_config() sets PYTORCH_CUDA_ALLOC_CONF. + _force = os.environ.get("UNSLOTH_FORCE_DGX_SPARK") + if _force == "1": + return True + if _force == "0": + return False + try: + import platform + + if platform.machine().lower() not in ("aarch64", "arm64"): + return False + import subprocess + import shutil + + # The WoA shim execs the venv binary directly (no login shell), where + # /usr/lib/wsl/lib can be off PATH -- resolve WSL's nvidia-smi explicitly + # (mirrors _is_dgx_spark_no_cuda_init in models/_utils.py). + _smi = "nvidia-smi" + if shutil.which(_smi) is None and os.path.exists("/usr/lib/wsl/lib/nvidia-smi"): + _smi = "/usr/lib/wsl/lib/nvidia-smi" + out = subprocess.run( + [_smi, "--query-gpu=name", "--format=csv,noheader"], + capture_output = True, + text = True, + timeout = 5, + ) + names = (out.stdout or "").upper() + # Whole-token match so "GB10" doesn't match discrete "GB100"/"GB10X". + import re + + return any( + re.search(r"(? (opt-in, default NO cap): caps the + allocator so over-allocation raises OutOfMemoryError early instead of + wedging the box (untracked UMA allocations may never trip a catchable OOM). + """ + if not is_dgx_spark(): + return + os.environ.setdefault("UNSLOTH_DISABLE_DOUBLE_BUFFER", "1") + _frac = os.environ.get("UNSLOTH_SPARK_MEM_FRACTION") + if _frac: + try: + # Out-of-range = no cap (0 OOMs everything; torch rejects > 1). + _frac_val = float(_frac) + if 0.0 < _frac_val <= 1.0: + torch.cuda.set_per_process_memory_fraction(_frac_val) + except Exception: + pass + + +def patch_dgx_spark_dataloader_defaults(): + """Default `dataloader_pin_memory` to False on Spark UMA. + + On one shared pool, pinning only reserves non-pageable RAM and adds a staging + copy (mirrors transformers' use_cpu precedent). Wrapping the base + `TrainingArguments.__post_init__` covers SFT + every TRL trainer in one + idempotent patch. Opt out: UNSLOTH_SPARK_KEEP_PIN_MEMORY=1. No-op off-Spark. + """ + if not is_dgx_spark(): + return + if os.environ.get("UNSLOTH_SPARK_KEEP_PIN_MEMORY") == "1": + return + try: + from transformers import training_args as _ta + Base = _ta.TrainingArguments + except Exception: + return + if getattr(Base.__post_init__, "_unsloth_spark_uma", False): + return + _orig_post_init = Base.__post_init__ + + # *args/**kwargs: tolerate future InitVar params in __post_init__. + def __post_init__(self, *args, **kwargs): + _orig_post_init(self, *args, **kwargs) + if getattr(self, "dataloader_pin_memory", None) is True: + self.dataloader_pin_memory = False + + __post_init__._unsloth_spark_uma = True + Base.__post_init__ = __post_init__ + + +patch_dgx_spark_memory_config() +patch_dgx_spark_caching_allocator_warmup() +patch_dgx_spark_runtime_defaults() +patch_dgx_spark_dataloader_defaults() + # Faster safetensors loads on UMA (integrated) GPUs; lazy gate keeps this import # fork-safe (no CUDA init). No-op off-UMA. Opt out: UNSLOTH_DISABLE_UMA_CLONE_LOAD=1. +# Installed after the Spark patches so patch_dgx_spark_memory_config() still lands +# its PYTORCH_CUDA_ALLOC_CONF before anything can touch the allocator. from ._uma_safetensors import patch_unified_memory_safetensors_load patch_unified_memory_safetensors_load() @@ -2265,6 +2448,9 @@ torch_compile_options = { "trace.enabled": UNSLOTH_COMPILE_DEBUG, "triton.cudagraphs": False, } +# Spark's 48 SMs are under inductor's 68-SM is_big_gpu bar; max_autotune just wastes search time. +if is_dgx_spark(): + torch_compile_options["max_autotune"] = False import accelerate diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 5dcbb47ac3..d5436548ad 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -16,6 +16,7 @@ from ._utils import ( _prepare_model_for_qat, is_bfloat16_supported, is_vLLM_available, + is_dgx_spark, HAS_FLASH_ATTENTION, HAS_FLASH_ATTENTION_SOFTCAPPING, USE_MODELSCOPE, @@ -463,10 +464,10 @@ class FastLanguageModel(FastLlamaModel): ) if DEVICE_TYPE_TORCH == "cuda": for i in range(DEVICE_COUNT): - # [TODO] DGX Spark vLLM breaks - if "NVIDIA GB10" in str(torch.cuda.get_device_name(i)).upper(): + # [TODO] DGX Spark / N1X (Spark-class) vLLM breaks + if is_dgx_spark(): print( - "Unsloth: DGX Spark detected - `fast_inference=True` is currently broken as of January 2026.\n" + "Unsloth: DGX Spark / N1X (Spark-class GPU) detected - `fast_inference=True` is currently broken as of January 2026.\n" "Defaulting to native Unsloth inference." ) fast_inference = False @@ -1158,10 +1159,10 @@ class FastModel(FastBaseModel): ) if DEVICE_TYPE_TORCH == "cuda": for i in range(DEVICE_COUNT): - # [TODO] DGX Spark vLLM breaks - if "NVIDIA GB10" in str(torch.cuda.get_device_name(i)).upper(): + # [TODO] DGX Spark / N1X (Spark-class) vLLM breaks + if is_dgx_spark(): print( - "Unsloth: DGX Spark detected - `fast_inference=True` is currently broken as of January 2026.\n" + "Unsloth: DGX Spark / N1X (Spark-class GPU) detected - `fast_inference=True` is currently broken as of January 2026.\n" "Defaulting to native Unsloth inference." ) fast_inference = False