diff --git a/install.ps1 b/install.ps1 index d2312c98ec..af51ae3604 100644 --- a/install.ps1 +++ b/install.ps1 @@ -48,8 +48,8 @@ function Install-UnslothStudio { } } - # Ref for fetching install assets (provision_llama_cuda.sh, the .ico) from - # raw.githubusercontent.com; UNSLOTH_INSTALL_REF overrides 'main' for pre-merge testing. + # raw.githubusercontent.com ref for install assets (provision_llama_cuda.sh, .ico). + # 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' @@ -97,9 +97,8 @@ function Install-UnslothStudio { if ($TauriMode) { exit $Code } - # -File runs exit 0 on a plain return regardless of $LASTEXITCODE, so `exit` - # must carry the code there; under `irm | iex` (no $PSCommandPath) `exit` - # would kill the user's shell, so fall through. + # -File ignores $LASTEXITCODE on plain return, so `exit` must carry the code; + # under `irm | iex` (no $PSCommandPath) `exit` would kill the user's shell. if ($PSCommandPath) { exit $Code } @@ -1774,13 +1773,13 @@ shell.Run cmd, 0, False $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) and - # add a Windows `unsloth` shim that forwards into it. x86_64 / ARM64-without-NVIDIA unaffected; if a - # win_arm64 CUDA torch wheel ever ships, the probe below keeps the native install automatically. - # Opt out: UNSLOTH_NO_WSL_FALLBACK=1; choose the distro with UNSLOTH_WSL_DISTRO. + # win_arm64 has no CUDA PyTorch/Triton wheel, so run the Linux installer inside WSL2 (full GPU) plus + # a Windows `unsloth` shim that forwards into it. x86_64 / ARM64-without-NVIDIA unaffected; the probe + # below keeps the native install if a win_arm64 CUDA torch 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 via .NET and $env:; Win32_Processor.Architecture - # (12=ARM64) and machine-level PROCESSOR_ARCHITECTURE read the true arch. Only ever turns $_winArm64 ON. + # 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 {} } @@ -1793,10 +1792,10 @@ shell.Run cmd, 0, False $_nativeCudaTorchOk = $false if ($_winArm64 -and $HasNvidiaSmi -and (-not $SkipTorch)) { # Probe with the SAME spec as the real install ("torch>=2.4,<2.11.0"): a bare `torch` probe - # can match an out-of-range wheel, skipping WSL only to fail the real pinned install. + # could match an out-of-range wheel, skipping WSL only to fail the real pinned install. $prevEapProbe = $ErrorActionPreference; $ErrorActionPreference = "Continue" - # --reinstall: an already-installed (e.g. CPU-only) torch must not satisfy the probe -- - # it has to prove a native win_arm64 CUDA wheel exists on the index. + # --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" --index-url $TorchIndexUrl *> $null @@ -1809,15 +1808,14 @@ shell.Run cmd, 0, False 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 (resolve_backend_binary), - # not WSL, so a WSL-only install would report complete yet fail to start -- send those - # users to the CLI installer. + # not WSL, so a WSL-only install would start nothing -- send those users to the CLI installer. if ($TauriMode) { 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) } $wslReady = $false if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { - # Reset first: a stale 0 in $LASTEXITCODE would wrongly mark WSL ready if wsl.exe fails to start. + # 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 {} } @@ -1835,8 +1833,8 @@ shell.Run cmd, 0, False substep "reboot, then re-run: irm https://unsloth.ai/install.ps1 | iex" "Cyan" } # Deferred until reboot: restore any rolled-aside previous venv and signal not-complete. - # A plain return exits 0 for -File runs regardless of $LASTEXITCODE, so `exit 1` there; - # under `irm | iex` ($PSCommandPath empty) exit would kill the user's shell, so return. + # `exit 1` for -File (plain return exits 0); under `irm | iex` (no $PSCommandPath) return + # instead, since exit would kill the user's shell. Restore-StudioVenvRollback $global:LASTEXITCODE = 1 if ($PSCommandPath) { exit 1 } @@ -1844,9 +1842,8 @@ shell.Run cmd, 0, False } $distro = if ($env:UNSLOTH_WSL_DISTRO) { $env:UNSLOTH_WSL_DISTRO } else { "Ubuntu-24.04" } - # For cmd-context uses (.cmd shim, copy-paste hints): wsl.exe rejects a QUOTED space-free - # name (WSL_E_DISTRO_NOT_FOUND, verified on 2.x) yet splits a bare spaced one after -d -- - # so quote ONLY when the name contains whitespace. + # For cmd-context uses (.cmd shim, copy-paste hints): wsl.exe rejects a QUOTED space-free name + # (WSL_E_DISTRO_NOT_FOUND on 2.x) yet 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 @@ -1857,9 +1854,8 @@ shell.Run cmd, 0, False try { & wsl.exe --install -d $distro --no-launch } catch {} } else { # A PRE-EXISTING distro may be WSL1 (no GPU passthrough; would only fail at the final - # torch.cuda check). Detect from inside the distro (encoding-proof, unlike UTF-16 - # `wsl -l -v`) and convert in place -- `wsl --set-version` preserves the files. - # Fresh installs default to WSL2, so only the pre-existing case needs this. + # torch.cuda check). Detect from inside (encoding-proof, unlike UTF-16 `wsl -l -v`) and + # convert in place -- `wsl --set-version` preserves files. Fresh installs default to WSL2. $_wsl2Probe = 'grep -qiE ''microsoft-standard|WSL2'' /proc/version 2>/dev/null || test -e /usr/lib/wsl/lib/libcuda.so' $_isWsl2 = $false $global:LASTEXITCODE = -1 @@ -1878,14 +1874,14 @@ shell.Run cmd, 0, False } } 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 unsloth.ai/install.sh. + # 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 unsloth.ai/install.sh. $_instRef = Get-UnslothInstallRef - # UNSLOTH_WSL_LLAMA_DEFERRED=1: setup.sh skips its foreground CUDA llama.cpp build because - # install.ps1 builds it in the background (a DIRECT install.sh run in WSL doesn't set it). - # apt stderr stays visible (only stdout -> /dev/null) so network/repo failures are diagnosable. - # Forward UNSLOTH_NO_LLAMA_CUDA into WSL: the same opt-out skips the dispatch below, so - # unforwarded, setup.sh would defer to a background builder that never starts (no llama-server). + # UNSLOTH_WSL_LLAMA_DEFERRED=1: setup.sh skips its foreground CUDA llama.cpp build since we build + # it in the background (a DIRECT install.sh run in WSL doesn't set it). apt stderr stays visible + # (only stdout -> /dev/null) so network/repo failures are diagnosable. + # Forward UNSLOTH_NO_LLAMA_CUDA into WSL: it also skips the dispatch below, so unforwarded + # setup.sh would defer to a background builder that never starts (no llama-server). $_fwdEnv = '' if ($env:UNSLOTH_NO_LLAMA_CUDA -eq '1') { $_fwdEnv = 'export UNSLOTH_NO_LLAMA_CUDA=1; ' } if ($_instRef -eq 'main') { @@ -1894,7 +1890,7 @@ shell.Run cmd, 0, False $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 | sh' } # install.sh may exit non-zero on the optional llama.cpp prebuilt step (no aarch64 prebuilt) - # though torch + unsloth + Studio still install, so lower EAP so it doesn't abort under Stop. + # though torch + unsloth + Studio still install; lower EAP so it doesn't abort under Stop. $prevEapWsl = $ErrorActionPreference $ErrorActionPreference = "Continue" $global:LASTEXITCODE = -1 @@ -1909,15 +1905,15 @@ shell.Run cmd, 0, False $torchOk = $false $prevEapChk = $ErrorActionPreference $ErrorActionPreference = "Continue" - # Reset first so a stale 0 from a prior command can't mark torch OK if this fails to launch. + # 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 } # Self-heal web-server deps: a cut-short install.sh "studio deps" step leaves torch + unsloth - # but no fastapi/uvicorn/structlog/starlette (`unsloth studio` dies). Reinstall them without - # pinning huggingface-hub/transformers/datasets so the verified GPU torch stack stays intact. + # but no fastapi/uvicorn/structlog/starlette (`unsloth studio` dies). Reinstall them unpinned + # (no huggingface-hub/transformers/datasets) so the verified GPU torch stack stays intact. if ($torchOk) { $_studioPy = "/root/.unsloth/studio/unsloth_studio/bin/python" $_serverOk = $false @@ -1928,8 +1924,8 @@ shell.Run cmd, 0, False } 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 huggingface-hub pin; uv preferred, pip fallback. Bare names - # only: `>=` becomes a redirection through PowerShell -> wsl.exe -> bash -lc, and + # studio.txt minus the huggingface-hub pin; uv preferred, pip fallback. Bare names only: + # `>=` would become a redirection through PowerShell -> 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' $_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' @@ -1943,8 +1939,8 @@ shell.Run cmd, 0, False if ($_serverOk) { substep "Studio web-server deps installed." "Green" } else { substep "(could not auto-install Studio server deps; 'unsloth studio' may fail to start)" "Yellow" } } - # The uv-managed venv ships no `pip`, but unsloth-zoo's exporter calls check_pip() and only - # finds `uv pip` when uv is on PATH. Seed pip so `save_pretrained_gguf` works regardless. + # The uv-managed venv ships no `pip`, but unsloth-zoo's exporter'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 @@ -1956,7 +1952,7 @@ shell.Run cmd, 0, False 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 so the user - # never touches WSL. WSL2 forwards 127.0.0.1, so http://localhost:8888 opens in the Windows browser. + # never touches WSL. 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 @@ -1966,10 +1962,10 @@ shell.Run cmd, 0, False "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 being set again. + # Record the distro so the uninstaller can clean a custom UNSLOTH_WSL_DISTRO install + # without the env var set again. try { Set-Content -LiteralPath (Join-Path (Split-Path $shimDir -Parent) "wsl-distro.txt") -Value $distro -Encoding ASCII } catch {} - # A fresh profile may have no HKCU 'Path' at all; null would make TrimEnd() throw. + # A fresh profile may have no HKCU 'Path'; null would make TrimEnd() throw. $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if (-not $userPath) { $userPath = "" } if (($userPath -split ';') -notcontains $shimDir) { @@ -1997,14 +1993,14 @@ shell.Run cmd, 0, False 'wsl.exe -d $distro --cd /root -u root -- bash -lic "unsloth studio -p 8888"' ) Set-Content -LiteralPath $launcher -Value $L -Encoding UTF8 - # Icon must live OUTSIDE %LOCALAPPDATA%: on WoA the shell's sandboxed icon broker - # can't read a .ico under AppData\Local, so the shortcut renders BLANK -- the same - # file under the user profile renders fine (verified on N1X). Only the icon moves. + # 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; fall back to a GitHub download. Validate the ICO header - # (00 00 01 00) before attaching, so a partial/HTML-404 download never makes a blank icon. + # Prefer the bundled icon, else download from GitHub. 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)) { @@ -2050,21 +2046,21 @@ shell.Run cmd, 0, False } catch { substep "(could not create shortcuts: $($_.Exception.Message))" "Yellow" } - # GGUF *inference* needs a CUDA llama-server (no aarch64+CUDA prebuilt exists), so build one - # into ~/.unsloth/llama.cpp in the BACKGROUND. Best-effort; opt out: UNSLOTH_NO_LLAMA_CUDA=1. + # GGUF *inference* needs a CUDA llama-server (no aarch64+CUDA prebuilt), so build one into + # ~/.unsloth/llama.cpp in the BACKGROUND. Best-effort; 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 /usr/lib/wsl/lib nvidia-smi -> + # The runner restores PATH (non-login shells miss /usr/lib/wsl/lib nvidia-smi, so # provision early-exits) and exports the env knobs below (Windows env vars don't cross # into WSL). 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 too, else the deferred - # build silently ignores them. sh-single-quoted (tags/PRs are simple tokens). + # Bridge UNSLOTH_LLAMA_TAG / UNSLOTH_LLAMA_PR pins into WSL, else the deferred build + # ignores them. sh-single-quoted (tags/PRs are simple tokens). $_tagLine = if ($env:UNSLOTH_LLAMA_TAG) { "export UNSLOTH_LLAMA_TAG='$($env:UNSLOTH_LLAMA_TAG)'`n" } else { "" } $_prLine = if ($env:UNSLOTH_LLAMA_PR) { "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" @@ -2073,9 +2069,9 @@ shell.Run cmd, 0, False $_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: WSL stops the VM when the launching session exits). - # PS 5.1 Start-Process joins -ArgumentList WITHOUT quoting, so pass $_distroArg - # (pre-quoted only when spaced); all other tokens are space-free. + # (a WSL-side `nohup &` dies when the launching session exits). PS 5.1 Start-Process + # joins -ArgumentList WITHOUT quoting, so pass $_distroArg (pre-quoted only when + # spaced); all 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 { @@ -2088,15 +2084,15 @@ shell.Run cmd, 0, False 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 here (everything runs in WSL), so drop the + # Success: the Windows venv is vestigial (everything runs in WSL), so drop the # rolled-aside previous-venv backup instead of orphaning it. 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): restore any rolled-aside previous venv and report - # non-zero (plain return exits 0 for -File; under iex `exit` would kill the caller's shell). + # Failed (torch.cuda unavailable): restore any rolled-aside previous venv and report non-zero + # (plain return exits 0 for -File; under iex `exit` would kill the caller's shell). Restore-StudioVenvRollback $global:LASTEXITCODE = 1 if ($PSCommandPath) { exit 1 } diff --git a/install.sh b/install.sh index 4f062b9f44..13d93531e7 100755 --- a/install.sh +++ b/install.sh @@ -2649,9 +2649,9 @@ elif [ -n "$TORCH_INDEX_URL" ]; then "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 (install.ps1 sets - # UNSLOTH_INSTALL_REF) so the branch's setup.sh + patches run. unsloth-zoo - # is not a base dep and SKIP_STUDIO_BASE skips studio base.txt, so name it - # explicitly or it never gets installed. + # UNSLOTH_INSTALL_REF) so the branch's setup.sh + patches run. Name + # unsloth-zoo explicitly: it's not a base dep and SKIP_STUDIO_BASE skips + # base.txt, so otherwise 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 \ @@ -2660,11 +2660,11 @@ elif [ -n "$TORCH_INDEX_URL" ]; then run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth -- "$PACKAGE_NAME" fi - # aarch64 + NVIDIA (DGX Spark / GB10 / N1X): base unsloth lacks bitsandbytes - # (the cuXXX extras are x86_64-oriented), so 4-bit QLoRA fails out of the box. - # aarch64 manylinux wheels work (verified on sm_121 via PTX JIT); best-effort, - # no wheel just keeps 16-bit LoRA / full finetuning. SKIP_TORCH gate: a - # --no-torch (GGUF-only) install must not let bitsandbytes drag torch back in. + # aarch64 + NVIDIA (DGX Spark / GB10 / N1X): unsloth's cuXXX extras are + # x86_64-oriented, so 4-bit QLoRA fails out of the box. aarch64 manylinux + # wheels work (verified on sm_121 via PTX JIT); best-effort, no wheel just + # keeps 16-bit LoRA / full finetuning. SKIP_TORCH gate: a --no-torch + # (GGUF-only) install must not let bitsandbytes drag torch back in. if [ "$SKIP_TORCH" = false ] \ && { [ "$(uname -m)" = "aarch64" ] || [ "$(uname -m)" = "arm64" ]; } \ && command -v nvidia-smi >/dev/null 2>&1 \ diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index e70b8d413f..287531fd71 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -418,12 +418,12 @@ function Uninstall-UnslothStudio { } catch { } # ── Windows-on-Arm WSL-fallback artifacts ── - # The ARM64+NVIDIA fallback puts Studio inside WSL plus a native shim + launcher under - # %LOCALAPPDATA%\Unsloth (not "Unsloth Studio") with a PATH entry -- all missed by the cleanup above. + # The ARM64+NVIDIA fallback puts Studio in WSL plus a native shim + launcher under + # %LOCALAPPDATA%\Unsloth (not "Unsloth Studio") with a PATH entry -- all missed 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 is cleanable - # without the env var set; read it BEFORE the directory is removed below. + # 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 { @@ -460,19 +460,19 @@ function Uninstall-UnslothStudio { # Remove the Studio install inside each WSL distro (the real GPU install + any CUDA llama.cpp build). if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { try { - # `wsl --list` emits UTF-16 PS mis-parses, so probe candidates by exit code instead - # ('' = default distro). rm runs FIRST (the kills could SIGKILL this shell) and also - # drops the dangling /root/.local/bin/unsloth symlink. Scope STRICTLY to /root (where - # the fallback installs): /home/*/.unsloth may be an unrelated user's. The port-8888 - # kill is gated on an Unsloth install existing (checked BEFORE rm deletes the marker) - # so an unrelated 8888 listener survives; pkill matches argv containing /root/.unsloth/ - # rather than bare names that would kill a user's own llama-server, and the backslash - # + [h]-bracket in '/root/\.unslot[h]/' keep it from matching this command's own argv. + # Probe candidates by exit code ('' = default distro) since `wsl --list` emits UTF-16 PS + # mis-parses. rm runs FIRST (the kills could SIGKILL this shell) and drops the dangling + # /root/.local/bin/unsloth symlink. Scope STRICTLY to /root (where the fallback installs); + # /home/*/.unsloth may be another user's. The 8888 kill is gated on an Unsloth install + # existing (checked BEFORE rm deletes the marker) so an unrelated listener survives; pkill + # matches argv containing /root/.unsloth/ (not bare names that would hit a user's own + # llama-server), and the backslash + [h]-bracket in '/root/\.unslot[h]/' keep it from + # matching this command's own argv. $_clean = '_had=0; if [ -d /root/.unsloth ] || [ -L /root/.local/bin/unsloth ]; then _had=1; 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; if [ $_had -eq 1 ]; then fuser -k 8888/tcp 2>/dev/null; fi; pkill -9 -f ''/root/\.unslot[h]/'' 2>/dev/null; true' - # Clean only distros with evidence of a fallback install: the wsl-distro.txt marker - # or an explicit UNSLOTH_WSL_DISTRO. The broad candidate probe is only for legacy - # marker-less installs, which exist only on ARM64 hosts -- on x86 it would delete - # distros this installer never touched (e.g. a ROCm-on-WSL Studio under /root). + # Clean only distros with evidence of a fallback install: the wsl-distro.txt marker or an + # explicit UNSLOTH_WSL_DISTRO. The broad candidate probe is only for legacy marker-less + # installs, which exist only on ARM64 -- 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 } @@ -505,8 +505,8 @@ function Uninstall-UnslothStudio { 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 it so success exits 0. Set the - # var rather than `exit 0` so `irm ... | iex` doesn't terminate the caller's shell. + # 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 } diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 8abc8bf3b9..20285da856 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -303,9 +303,9 @@ case "$_os" in } catch { } } } - # WoA WSL-fallback (install.ps1) native shim/launcher dir - # (%LOCALAPPDATA%\Unsloth) + its PATH entry. install.ps1 created the - # shim; clean it here too so a WSL-side bash uninstall is complete. + # Remove the WoA WSL-fallback native shim/launcher dir + # (%LOCALAPPDATA%\Unsloth) + its PATH entry that install.ps1 + # created, so a WSL-side bash uninstall is complete. $ud = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth" } else { $null }; if ($ud) { $shim = (Join-Path $ud "bin").TrimEnd("\","/"); diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 3ed83024c8..0d1e51343d 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -738,11 +738,10 @@ def _nvidia_classify_spark_unified_memory(props: Any) -> tuple[str, bool]: Returns ``(marker, is_unified)``; marker is ``"is_integrated"`` or the matched device-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 instead of raising a catchable OutOfMemoryError. + ``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 (the N1X reports ``JMJWOA-Generic-GPU``, + masks it to 0 and renames the device (N1X reports ``JMJWOA-Generic-GPU``, verified on hardware) -- hence the name-token fallback. Tokens mirror ``_DGX_SPARK_DEVICE_TOKENS`` in ``unsloth/models/_utils.py`` (duplicated because this guard runs before any ML import). @@ -2432,16 +2431,16 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> 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 - # memory 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 stays with the OS/page cache). UNSLOTH_SPARK_MEM_FRACTION - # overrides; outside (0, 1] disables. Discrete NVIDIA GPUs untouched. + # 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: - # Set PYTORCH_CUDA_ALLOC_CONF before get_device_properties below inits - # the CUDA allocator -- the later `import unsloth` patch is too late for - # THIS worker process. CUDA-free nvidia-smi sniff (mirrors + # Set PYTORCH_CUDA_ALLOC_CONF before get_device_properties below inits the + # CUDA allocator -- the later `import unsloth` patch is too late for THIS + # worker process. CUDA-free nvidia-smi sniff (mirrors # _is_dgx_spark_no_cuda_init), same append-don't-override and # UNSLOTH_NO_EXPANDABLE_SEGMENTS opt-out as the library patch. try: diff --git a/studio/backend/tests/test_spark_oom_guard.py b/studio/backend/tests/test_spark_oom_guard.py index 74610d4369..4e61dfd3af 100644 --- a/studio/backend/tests/test_spark_oom_guard.py +++ b/studio/backend/tests/test_spark_oom_guard.py @@ -3,13 +3,11 @@ """Unit tests for _nvidia_classify_spark_unified_memory (Spark OOM-guard classifier). -Two paths: (1) the ``is_integrated`` device property (authoritative on native -Linux), (2) device-name token match — needed because WSL2's GPU -paravirtualization masks ``is_integrated`` to 0 and renames the device (the N1X -reports ``JMJWOA-Generic-GPU``; verified on hardware). - -Mirrors test_rocm_oom_guard.py for the ROCm/Strix-Halo classifier the NVIDIA -guard was modeled on. +Two paths: (1) ``is_integrated`` device property (authoritative on native Linux), +(2) device-name token match -- needed because WSL2's GPU paravirtualization masks +``is_integrated`` to 0 and renames the device (N1X reports ``JMJWOA-Generic-GPU``; +verified on hardware). Mirrors test_rocm_oom_guard.py for the ROCm/Strix-Halo +classifier the NVIDIA guard was modeled on. """ from __future__ import annotations diff --git a/studio/scripts/provision_llama_cuda.sh b/studio/scripts/provision_llama_cuda.sh index 8c593e7950..ef4c63bb50 100644 --- a/studio/scripts/provision_llama_cuda.sh +++ b/studio/scripts/provision_llama_cuda.sh @@ -13,9 +13,9 @@ LLAMA_DIR="${UNSLOTH_LLAMA_CPP_PATH:-$HOME/.unsloth/llama.cpp}" SERVER="$LLAMA_DIR/build/bin/llama-server" log() { printf ' - %s\n' "$*"; } -# CUDA shows up two ways: old monolithic (libggml-cuda in ldd) or current split -# build (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. +# CUDA shows up 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 @@ -165,8 +165,8 @@ if ! _cmake_configure; then _cmake_configure || { log "cmake configure failed"; cd /; _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 (N1X "RTX Spark") -- and are +# 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") and are # RAM-capped (~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 reads -j0 as "all cores"). @@ -198,7 +198,7 @@ _cmake_build_extras() { done } if ! _cmake_build; then - # An interrupted build (thermal/power shutdown -- this machine class is prone) + # 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 once. log "build failed (likely interrupted/partial); wiping build dir and rebuilding clean" diff --git a/studio/setup.sh b/studio/setup.sh index 3a55c1607a..c8d9a630d8 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -974,9 +974,9 @@ 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 real CUDA -# server in the background, so a temporarily-absent server is success and must not -# trip the arm64 CPU-prebuilt last-resort or the exit 1. +# Deferred != degraded: on WSL2 aarch64+NVIDIA install.ps1 builds the CUDA server +# in the background, so an absent server is success and must not trip the arm64 +# CPU-prebuilt last-resort or exit 1. _LLAMA_CPP_DEFERRED=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" @@ -1161,10 +1161,10 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && \ fi # ── WSL2 aarch64 + NVIDIA, no nvcc yet: defer to the background CUDA build ── -# install.ps1 builds the real CUDA llama-server in the background after install; -# without nvcc, section 9 could only make a slow CPU server that build discards. -# With nvcc we fall through to section 9; opted out (UNSLOTH_NO_LLAMA_CUDA=1) the -# CPU build is kept as the only server. +# install.ps1 builds the CUDA llama-server in the background; without nvcc, +# section 9 could only make a slow CPU server that build discards. With nvcc we +# fall through to section 9; opted out (UNSLOTH_NO_LLAMA_CUDA=1) the CPU build is +# kept as the only server. if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \ && [ "$_LLAMA_FORCE_COMPILE" != "1" ] \ && [ -z "$_LLAMA_PR" ] \ @@ -1454,10 +1454,10 @@ else if [ "$_CUDA_TOOLKIT_ALLOWED" = true ]; then CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON" - # glibc >= 2.41 + CUDA < 13.3: rsqrt/rsqrtf header clash fails every .cu - # ("exception specification is incompatible") -> CPU fallback; only fix is - # CUDA >= 13.3. Diagnostic only -- never changes flags or aborts. Checked - # against the final _NVCC_VER (after the driver-compat swap above). + # 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 or aborts). Checks the final _NVCC_VER + # (after the driver-compat swap above). _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%%.*}" @@ -1683,12 +1683,11 @@ fi # end _SKIP_GGUF_BUILD check # 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. Gated to Linux aarch64 + NVIDIA with -# no CUDA server yet (opt out: UNSLOTH_NO_LLAMA_CUDA=1); best-effort -- provision -# always exits 0 and on failure the prior CPU/degraded state stands. -# CUDA detection covers both layouts: old monolithic (libggml-cuda in ldd) and -# split build (dlopen-ed libggml-cuda.so* beside the binary, missed by ldd -- -# CPU-only builds ship no libggml-cuda.so, so its presence is the signal). +# (provision_llama_cuda.sh) for native Linux. Best-effort: provision always exits +# 0, and 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 no libggml-cuda.so, so its presence is the signal. _have_cuda_llama_server() { [ -x "$LLAMA_SERVER_BIN" ] || return 1 ldd "$LLAMA_SERVER_BIN" 2>/dev/null | grep -qi 'libggml-cuda' && return 0 @@ -1702,11 +1701,11 @@ if [ "$_HOST_SYSTEM" = "Linux" ] \ && command -v nvidia-smi >/dev/null 2>&1 \ && nvidia-smi -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}' \ && ! _have_cuda_llama_server; then - # Under WSL this runs ONLY for a DIRECT `install.sh` run: install.ps1 exports - # UNSLOTH_WSL_LLAMA_DEFERRED=1 and builds CUDA llama.cpp in the background, but - # a direct run has no background builder, so provision here. - # Resolve provision_llama_cuda.sh: copy beside setup.sh, then local-dev repo, - # else fetch from GitHub (so `curl | sh` works on an older wheel without it). + # Under WSL this runs ONLY for a DIRECT `install.sh` run: install.ps1 sets + # UNSLOTH_WSL_LLAMA_DEFERRED=1 and builds in the background, but a direct run + # has no background builder, so provision here. + # 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" diff --git a/unsloth/kernels/flex_attention.py b/unsloth/kernels/flex_attention.py index 044e8911f2..8cd1e82141 100644 --- a/unsloth/kernels/flex_attention.py +++ b/unsloth/kernels/flex_attention.py @@ -27,10 +27,9 @@ torch_compile_options = { def _flex_is_dgx_spark(): - # Local CUDA-free copy of _utils._is_dgx_spark_no_cuda_init() (avoids a circular - # import). Runs at module import, before ._utils -- touching torch.cuda here would - # init the allocator before patch_dgx_spark_memory_config() can set - # PYTORCH_CUDA_ALLOC_CONF on the very Spark hosts this targets. + # CUDA-free copy of _utils._is_dgx_spark_no_cuda_init() (avoids a circular import). + # Runs at module import, before ._utils -- touching torch.cuda here would init the + # allocator before patch_dgx_spark_memory_config() can set PYTORCH_CUDA_ALLOC_CONF. _force = os.environ.get("UNSLOTH_FORCE_DGX_SPARK") if _force == "1": return True diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index ab7f19133c..7cbe5e1aba 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -993,8 +993,8 @@ from transformers.modeling_utils import logger as transformers_logger # ---- NVIDIA DGX Spark (GB10) / N1X "RTX Spark" unified-memory support ---- -# Device names vary ("NVIDIA GB10" on DGX Spark, "JMJWOA-Generic-GPU" on N1X); -# the aarch64 + CUDA gate keeps every Spark workaround a no-op elsewhere. +# Device names vary ("NVIDIA GB10", "JMJWOA-Generic-GPU" on N1X); the +# aarch64 + CUDA gate keeps every Spark workaround a no-op elsewhere. _DGX_SPARK_DEVICE_TOKENS = ("GB10", "JMJWOA", "N1X", "DGX SPARK", "GB110") @@ -1024,9 +1024,8 @@ def is_dgx_spark(): @functools.lru_cache(maxsize = None) def _is_dgx_spark_no_cuda_init(): - """Spark detection that never initializes CUDA: reads device names via - `nvidia-smi` instead of torch, so allocator-init-time settings - (PYTORCH_CUDA_ALLOC_CONF) can still be set after calling it. Same + """Spark detection that never inits CUDA: reads device names via `nvidia-smi`, + not torch, so PYTORCH_CUDA_ALLOC_CONF can still be set afterwards. Same UNSLOTH_FORCE_DGX_SPARK override; False on any error.""" _force = os.environ.get("UNSLOTH_FORCE_DGX_SPARK") if _force == "1": @@ -1055,10 +1054,10 @@ def _is_dgx_spark_no_cuda_init(): def patch_dgx_spark_caching_allocator_warmup(): """No-op `transformers.modeling_utils.caching_allocator_warmup` on Spark UMA. - `cudaMemGetInfo()` undercounts free memory on Spark unified memory, so HF's - warmup `torch.empty(...)` raises `AcceleratorError: invalid argument` and - aborts bitsandbytes 4/8-bit loads. The warmup is only a speed hint, so skip - it. Gated by `is_dgx_spark()`; idempotent (`_unsloth_spark_noop` marker). + `cudaMemGetInfo()` undercounts free UMA memory, so HF's warmup + `torch.empty(...)` raises `AcceleratorError: invalid argument` and aborts + bitsandbytes 4/8-bit loads. The warmup is only a speed hint, so skip it. + Gated by `is_dgx_spark()`; idempotent (`_unsloth_spark_noop` marker). """ if not is_dgx_spark(): return @@ -1085,7 +1084,7 @@ def patch_dgx_spark_memory_config(): Appends to PYTORCH_CUDA_ALLOC_CONF only when absent; opt out with UNSLOTH_NO_EXPANDABLE_SEGMENTS=1. Must run before the first CUDA allocation, hence the CUDA-free `_is_dgx_spark_no_cuda_init()` gate -- `is_dgx_spark()` - would initialize the allocator before the env var could take effect. + would init the allocator before the env var could take effect. """ if not _is_dgx_spark_no_cuda_init(): return @@ -1104,7 +1103,7 @@ def patch_dgx_spark_runtime_defaults(): - UNSLOTH_DISABLE_DOUBLE_BUFFER=1 (setdefault): zoo's grad-checkpointing double-buffer gates on a mem_get_info check that UNDERCOUNTS on UMA, and - its extra staging buffer is pure waste on a shared pool. + its staging buffer is pure waste on a shared pool. - UNSLOTH_SPARK_MEM_FRACTION=<0..1> (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). @@ -1126,9 +1125,9 @@ def patch_dgx_spark_runtime_defaults(): def patch_dgx_spark_dataloader_defaults(): """Default `dataloader_pin_memory` to False on Spark UMA (accuracy-neutral). - With one shared memory pool, pinning only reserves non-pageable RAM and adds - a staging copy (mirrors transformers' own use_cpu precedent). Wrapping the - base `TrainingArguments.__post_init__` covers SFT + every TRL trainer in one + On one shared pool, pinning only reserves non-pageable RAM and adds a staging + copy (mirrors transformers' own 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():