From 1c3b3b36bd2dec0013ddc9a99d755f146501c4c0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Mon, 18 May 2026 11:53:14 +0400 Subject: [PATCH] Windows: pick PyTorch wheel from installed CUDA Toolkit, not driver max MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nvidia-smi reports the driver's max-supported CUDA, which can outrun the userland — picking cu130 off a "CUDA 13.0" driver with a 12.9 toolkit ships a torch that silently falls back to CPU. Resolve the wheel from the actual toolkit version instead, winget-installing the highest <= driver max if no toolkit is present. --- install.ps1 | 143 ++++++++++++++++++++++++++++++++++++++++++----- studio/setup.ps1 | 71 ++++++++++++++--------- 2 files changed, 174 insertions(+), 40 deletions(-) diff --git a/install.ps1 b/install.ps1 index ef87c5ed08..63880bf199 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1213,24 +1213,124 @@ shell.Run cmd, 0, False substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow" } - # ── Choose the correct PyTorch index URL based on driver CUDA version ── + # ── Detect installed CUDA Toolkit version ── + # Returns "12.9" / "13.0" / $null. Checks CUDA_PATH and the standard + # toolkit install root. The toolkit version is the actual userland CUDA + # available to PyTorch; nvidia-smi only reports the driver's *max + # supported* CUDA, which can be a release ahead of the userland. + function Get-CudaToolkitVersion { + $candidates = @() + foreach ($scope in @('Process','Machine','User')) { + $cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', $scope) + if ($cudaRoot) { $candidates += (Join-Path $cudaRoot 'bin\nvcc.exe') } + } + $toolkitBase = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA' + if (Test-Path -LiteralPath $toolkitBase) { + $highest = Get-ChildItem -Directory -LiteralPath $toolkitBase -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^v(\d+)\.(\d+)' } | + Sort-Object { [version]($_.Name -replace '^v','') } -Descending | + Select-Object -First 1 + if ($highest) { $candidates += (Join-Path $highest.FullName 'bin\nvcc.exe') } + } + $onPath = Get-Command nvcc -ErrorAction SilentlyContinue + if ($onPath) { $candidates += $onPath.Source } + foreach ($nvcc in $candidates) { + if ($nvcc -and (Test-Path -LiteralPath $nvcc)) { + try { + $verOut = & $nvcc --version 2>&1 | Out-String + if ($verOut -match 'release\s+(\d+\.\d+)') { return $Matches[1] } + } catch {} + } + } + return $null + } + + # ── Read driver's max CUDA from nvidia-smi (bound on toolkit install) ── + $DriverMaxCuda = $null + if ($NvidiaSmiExe) { + try { + $smiOut = & $NvidiaSmiExe 2>&1 | Out-String + if ($smiOut -match 'CUDA Version:\s+(\d+\.\d+)') { $DriverMaxCuda = $Matches[1] } + } catch {} + } + + # ── Install CUDA Toolkit if missing, so Get-TorchIndexUrl can pick the ── + # PyTorch wheel from the *userland* CUDA version instead of nvidia-smi's + # driver-max ceiling. Mirrors phase 1e of setup.ps1; setup.ps1 still + # runs the full CUDA <-> VS Build Tools integration later. Bounded by + # the driver max so we never install a toolkit the driver can't load. + function Install-CudaToolkitIfMissing { + param([Parameter(Mandatory = $true)][string]$DriverMaxCuda) + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + substep "winget unavailable; skipping CUDA Toolkit install (studio setup will retry later)." "Yellow" + return + } + $drMajor = [int]$DriverMaxCuda.Split('.')[0] + $drMinor = [int]$DriverMaxCuda.Split('.')[1] + $available = @() + try { + $rawOutput = winget show Nvidia.CUDA --versions --accept-source-agreements 2>&1 | Out-String + foreach ($line in $rawOutput -split "`n") { + $line = $line.Trim() + if ($line -match '^\d+\.\d+') { $available += $line } + } + } catch {} + $best = $null + foreach ($ver in $available) { + $parts = $ver.Split('.') + $vMajor = [int]$parts[0]; $vMinor = [int]$parts[1] + if ($vMajor -lt $drMajor -or ($vMajor -eq $drMajor -and $vMinor -le $drMinor)) { + # winget show --versions returns the list in descending order, + # so the first compatible entry is the highest <= driver max. + $best = $ver + break + } + } + if (-not $best) { + substep "no CUDA Toolkit version <= driver $DriverMaxCuda available in winget (studio setup will retry later)." "Yellow" + return + } + substep "no CUDA Toolkit found; installing CUDA $best via winget (~3 GB, several minutes)..." + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + Invoke-InstallCommand { winget install --id=Nvidia.CUDA --version=$best -e --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + } catch {} + $ErrorActionPreference = $prevEAP + Refresh-SessionPath + } + + if ($HasNvidiaSmi -and -not $SkipTorch -and $DriverMaxCuda -and -not (Get-CudaToolkitVersion)) { + Write-TauriLog "STEP" "Installing CUDA Toolkit (winget)" + Install-CudaToolkitIfMissing -DriverMaxCuda $DriverMaxCuda + } + + # ── Choose the correct PyTorch index URL based on installed CUDA ── # Mirrors Get-PytorchCudaTag in setup.ps1. + # + # Prefer the installed CUDA Toolkit version: it is the hard constraint + # on which PyTorch wheels can actually load. nvidia-smi's "CUDA Version" + # is only the driver's *max supported* CUDA (a ceiling) — picking cu130 + # off a "CUDA 13.0" driver that actually has toolkit 12.9 ships a torch + # that silently falls back to CPU. The toolkit install above eliminates + # the no-toolkit case on first-time installs; the nvidia-smi fallback + # here only fires when winget is unavailable or has no compatible + # toolkit, and setup.ps1 retries the install in phase 1e. function Get-TorchIndexUrl { $baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" } if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" } - try { - $output = & $NvidiaSmiExe 2>&1 | Out-String - if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') { - $major = [int]$Matches[1]; $minor = [int]$Matches[2] - if ($major -ge 13) { return "$baseUrl/cu130" } - if ($major -eq 12 -and $minor -ge 8) { return "$baseUrl/cu128" } - if ($major -eq 12 -and $minor -ge 6) { return "$baseUrl/cu126" } - if ($major -ge 12) { return "$baseUrl/cu124" } - if ($major -ge 11) { return "$baseUrl/cu118" } - return "$baseUrl/cpu" - } - } catch {} - substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow" + $cudaVer = Get-CudaToolkitVersion + if (-not $cudaVer) { $cudaVer = $DriverMaxCuda } + if ($cudaVer -match '^(\d+)\.(\d+)$') { + $major = [int]$Matches[1]; $minor = [int]$Matches[2] + if ($major -ge 13) { return "$baseUrl/cu130" } + if ($major -eq 12 -and $minor -ge 8) { return "$baseUrl/cu128" } + if ($major -eq 12 -and $minor -ge 6) { return "$baseUrl/cu126" } + if ($major -ge 12) { return "$baseUrl/cu124" } + if ($major -ge 11) { return "$baseUrl/cu118" } + return "$baseUrl/cpu" + } + substep "could not determine CUDA version, defaulting to cu126" "Yellow" return "$baseUrl/cu126" } $TorchIndexUrl = Get-TorchIndexUrl @@ -1248,6 +1348,21 @@ shell.Run cmd, 0, False Write-Host "" } + # ── Warn when CUDA wheel was picked from the driver report, not a toolkit ── + # Reached only when winget couldn't install a compatible CUDA Toolkit + # (no winget, or no <= driver-max version in winget's manifest). The + # PyTorch wheel may not load until a matching CUDA Toolkit is present; + # studio setup will retry the install, but if that also fails the user + # has to install it manually. + if ($HasNvidiaSmi -and -not $SkipTorch -and $TorchIndexFamily -like "cu*" -and -not (Get-CudaToolkitVersion)) { + Write-Host "" + substep "No CUDA Toolkit is installed; PyTorch wheel ($TorchIndexFamily) was picked from" "Yellow" + substep "your driver's max-CUDA report. Studio setup will retry the toolkit install;" "Yellow" + substep "if that also fails, install a CUDA Toolkit matching $TorchIndexFamily manually:" "Yellow" + substep " https://developer.nvidia.com/cuda-toolkit-archive" "Yellow" + Write-Host "" + } + # ── Install PyTorch first, then unsloth separately ── # # Why two steps? diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 40788a0ecb..afbb78a903 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -335,36 +335,55 @@ function Get-NvccMaxArch { return $null } -# Detect driver's max CUDA version from nvidia-smi and return the highest -# compatible PyTorch CUDA index tag (e.g. "cu128"). +# Pick the PyTorch CUDA index tag (e.g. "cu128") for the installed stack. # PyTorch on Windows ships CPU-only by default from PyPI; CUDA wheels live at -# https://download.pytorch.org/whl/. The tag must not exceed the driver's -# capability: e.g. driver "CUDA Version: 12.9" → cu128 (not cu130). +# https://download.pytorch.org/whl/. +# +# Prefer the installed CUDA Toolkit version (already resolved into +# $script:NvccPath by phase 1e and proven <= driver max). nvidia-smi's +# "CUDA Version" line is only the driver's *max-supported* CUDA, which can +# be a release ahead of the userland — picking cu130 off a "CUDA 13.0" +# driver paired with a 12.9 toolkit ships a torch that silently falls back +# to CPU. Fall back to the driver report only when no toolkit is resolved +# (e.g. the function is called before phase 1e, or no GPU is present). function Get-PytorchCudaTag { - $smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else { - $cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue - if ($cmd) { $cmd.Source } else { $null } + $major = $null + $minor = $null + if ($script:NvccPath -and (Test-Path -LiteralPath $script:NvccPath)) { + try { + $verOut = & $script:NvccPath --version 2>&1 | Out-String + if ($verOut -match 'release\s+(\d+)\.(\d+)') { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + } + } catch { } } - if (-not $smiExe) { return "cu126" } - - try { - # 2>&1 | Out-String merges stderr into stdout then converts to a single - # string. Plain 2>$null doesn't fully suppress stderr in PS 5.1 -- - # ErrorRecord objects leak into $output and break the -match. - $output = & $smiExe 2>&1 | Out-String - if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') { - $major = [int]$Matches[1] - $minor = [int]$Matches[2] - # PyTorch 2.10 offers: cu124, cu126, cu128, cu130 - if ($major -ge 13) { return "cu130" } - if ($major -eq 12 -and $minor -ge 8) { return "cu128" } - if ($major -eq 12 -and $minor -ge 6) { return "cu126" } - if ($major -ge 12) { return "cu124" } - if ($major -ge 11) { return "cu118" } - return "cpu" + if ($null -eq $major) { + $smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else { + $cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue + if ($cmd) { $cmd.Source } else { $null } } - } catch { } - + if (-not $smiExe) { return "cu126" } + try { + # 2>&1 | Out-String merges stderr into stdout then converts to a single + # string. Plain 2>$null doesn't fully suppress stderr in PS 5.1 -- + # ErrorRecord objects leak into $output and break the -match. + $output = & $smiExe 2>&1 | Out-String + if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + } + } catch { } + } + if ($null -ne $major) { + # PyTorch 2.10 offers: cu118, cu124, cu126, cu128, cu130 + if ($major -ge 13) { return "cu130" } + if ($major -eq 12 -and $minor -ge 8) { return "cu128" } + if ($major -eq 12 -and $minor -ge 6) { return "cu126" } + if ($major -ge 12) { return "cu124" } + if ($major -ge 11) { return "cu118" } + return "cpu" + } return "cu126" }