Skip to content

Windows: fix single-AMD-GPU hosts reading as "gpu none" and looping the installer - #8461

Merged
danielhanchen merged 9 commits into
mainfrom
fix/windows-amd-setup-detection-loop
Aug 11, 2026
Merged

Windows: fix single-AMD-GPU hosts reading as "gpu none" and looping the installer#8461
danielhanchen merged 9 commits into
mainfrom
fix/windows-amd-setup-detection-loop

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 11, 2026

Copy link
Copy Markdown
Member

The bug

On Windows, studio/setup.ps1 builds its AMD adapter list with:

$wmiGpus = if ($healthyGpus.Count -gt 0) { $healthyGpus } else { $amdGpus }

A one-element branch unrolls into a bare WMI object on its way out of the if, and a bare WMI object has no .Count in PowerShell 5.1:

healthy=1 wmiGpus.Count=[] reaches_label=False

So on a host with exactly one AMD adapter the guard right after it never fires, $ROCmGpuLabel stays null, the name to gfx inference is skipped, and setup reports:

gpu            none (chat-only / GGUF)

Two or more AMD adapters keep the array intact and work fine, so this only hits single-GPU machines.

This is engine behaviour, not a quirk of one host. PowerShell has two member-binding paths and only the optimized one carries the PSv3 scalar Count/Length fallback; types with a custom adapter (CimInstance, ManagementObject, COM, PSCustomObject) take the other one, which returned null in the 5.1-era engine. PowerShell/PowerShell#5745 added the fallback there and shipped it in 6.1, which Windows PowerShell 5.1 never received. It also means pwsh on CI answers 1 for the same expression, which matters for the tests below.

Why that bricks the install

install.ps1 resolves the arch with Select-Object -First 1 and is unaffected, so the two scripts disagree inside the same run, one second apart:

[install] gfx arch inferred from GPU name: gfx1151
[install] gpu            AMD ROCm (gfx1151)
[install] installing PyTorch from https://repo.amd.com/rocm/whl/gfx1151/...
[install] unsloth        2026.8.14 installed
[install] setup          running unsloth studio setup...
[setup]   gpu            none (chat-only / GGUF)
[setup]   Stale venv detected (torch rocm != required cpu).
          [ERROR] The existing Unsloth environment needs repair.
[install] restoring previous environment after failed install...

With no GPU detected, $expectedTorchTag is cpu, the installed +rocm wheel reads as stale, setup exits 1, the installer rolls back, preflight still says stale, and the desktop app repairs again. It never converges. Rolling back also leaves the venv without a working CLI, so later passes degrade further into ModuleNotFoundError: No module named 'unsloth_cli'.

Reproduced on a Radeon 8060S (gfx1151, Strix Halo) laptop with Unsloth Desktop 0.1.70-beta, and confirmed fixed there. setx UNSLOTH_ROCM_GFX_ARCH gfx1151 clears it, which is what pointed at the detection path.

The fix

  1. studio/setup.ps1: wrap the whole if in @(), the idiom the Intel scan a few lines below already documents ("@() wraps the WHOLE if, not each branch").

  2. studio/setup.ps1: the gpu name list one block down had the same expression, wrapping each branch but not the if:

    $gpuNames = if ($script:ROCmGpuLabels) { @($script:ROCmGpuLabels) } else { @($ROCmGpuLabel) }

    A single adapter name unrolls to a bare String, so $gpuNames[$nameIdx] indexes the name and yields A, and nothing maps. The $nameArches[0] rescue below covers that unless a visible-device mask is set, so a single-GPU host with HIP_VISIBLE_DEVICES set still inferred no arch and looped the same way, and a standalone unsloth studio update was never covered by the installer handoff at all. Every = if ( site across the .ps1 files was audited; this was the only other one.

  3. install.ps1: hand the arch this run resolved to setup as _UNSLOTH_ROCM_GFX_ARCH_HANDOFF, so a future divergence between the two scans cannot become an unrecoverable rollback loop.

    Deliberately not UNSLOTH_ROCM_GFX_ARCH. That is the documented operator override, and install_llama_prebuilt.py reads it back as _manual to decide whether a forwarded --rocm-gfx outranks its own probe, so publishing an auto-detected value there disarms that safeguard. install.ps1's scan is also the weaker of the two: first AMD adapter, no visible-device mask, no shadowing-iGPU repick, all of which setup.ps1 applies. On a Radeon 780M + RX 9070 XT host setup resolves gfx1201, and forwarding the installer's gfx1103 would have handed llama.cpp the iGPU bundle.

    So it uses the _UNSLOTH_ prefix the neighbouring handoffs use, is saved and restored in the same finally block (install.ps1 is documented as irm ... | iex, so it runs in the caller's own shell), is cleared rather than left alone when nothing was resolved, and setup.ps1 consumes it only after its own probes and name inference come up empty.

Tests

tests/test_windows_amd_gpu_scan_fallback.py extracts the shipped blocks from setup.ps1 and install.ps1 and runs them against stubbed adapter lists, so it exercises the source rather than a copy.

The first version of this file asserted on .Count, which pwsh answers as 1 for a scalar for the reason above, so it passed against the unfixed source and guarded nothing. Every runtime case now asserts the shape of the value, which is identical on both engines, and re-runs each case against stubs carrying an explicit Count = $null to reproduce 5.1's observable consequence rather than only its cause.

area cases
adapter scan one adapter (x pwsh/5.1 x lax/strict), two adapters, parked-only, healthy over parked, Intel-only, no adapters
name inference the name reaches inference whole rather than as A; 11 marketing names to arch; discrete card preferred over a shadowing iGPU; a pinned mask honoured over that preference
masks single-GPU host still resolves gfx1151 across 3 mask variables x 8 values (0, 1, empty, -1, unparseable, out of range, padded, list)
handoff fills the gap when nothing else resolves, never deposes setup's own inference, a user override still wins, normalization, empty ignored
handoff lifecycle the caller's environment survives the setup call across resolved/inherited/neither x setup succeeding/throwing, and only this run's arch reaches the child
source both @(if ( forms, the private handoff is set before setup and restored after, UNSLOTH_ROCM_GFX_ARCH is never exported, and the handoff is consumed after inference

71 pass on the branch. Run against the merge base the same file reports 38 failures, including the single-adapter, pinned-mask and name-indexing cases, and test_these_assertions_fail_against_the_source_before_the_fix pins that so a later edit cannot quietly re-lose it.

Both scripts parse clean through [System.Management.Automation.Language.Parser]::ParseFile. tests/studio/install + tests/python report an identical 5 failed / 2486 passed / 91 skipped / 32 errors on this branch and on the merge base, so the pre-existing failures are unchanged. test_rocm_support.py (486 passed), the arch-table parity and cross-platform parity suites, and the neighbouring tests/studio/*.ps1 suites all pass. An old setup.ps1 ignores the new handoff rather than breaking on it, and install.sh / setup.sh / macOS are untouched.

…he installer

setup.ps1's WMI fallback built its adapter list with

    $wmiGpus = if ($healthyGpus.Count -gt 0) { $healthyGpus } else { $amdGpus }

An unwrapped one-element branch unrolls to a bare WMI object on its way out of the
if, and a bare WMI object has no .Count in PS 5.1 (a string or hashtable does), so
`if ($wmiGpus.Count -gt 0)` never fired on a host with exactly one AMD adapter and
$ROCmGpuLabel stayed null. Setup reported "gpu none (chat-only / GGUF)", skipped the
name to gfx inference, and the stale-venv check then expected cpu torch against the
ROCm wheels install.ps1 had just placed:

    Stale venv detected (torch rocm != required cpu).
    [ERROR] The existing Unsloth environment needs repair.

The installer rolled back and the desktop app retried the same failure indefinitely.
Reproduced on a Radeon 8060S (gfx1151) Strix Halo laptop, where install.ps1 resolved
gfx1151 and setup.ps1 one second later saw no GPU at all.

Wrap the whole if in @(), the idiom the Intel scan below already documents.

install.ps1 also now exports the arch it resolved before invoking setup.ps1, so the
two never re-derive it independently. Setting UNSLOTH_ROCM_GFX_ARCH by hand was the
workaround for this bug, and doing it in the installer keeps any future divergence
between the two scans from turning into an unrecoverable rollback loop.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen and others added 2 commits August 11, 2026 17:18
…g bug in the name list

Follow-up to the single-AMD-GPU scan fix in this branch, from reviewing what it
could do to hosts other than the one it was reported on.

install.ps1 forwarded its resolved arch as UNSLOTH_ROCM_GFX_ARCH. That name is
the documented operator override, and install_llama_prebuilt.py reads it back as
_manual to decide whether a forwarded --rocm-gfx outranks its own probe, so
publishing an auto-detected value there disarmed that safeguard. install.ps1's
scan is also the weaker of the two: it takes the first AMD adapter with no
visible-device mask and no shadowing-iGPU repick, both of which setup.ps1
applies. On a 780M + RX 9070 XT host setup resolves gfx1201 today; the forward
made it take the installer's gfx1103 verbatim and hand llama.cpp the iGPU
bundle. It was also never restored, so it outlived the install in the caller's
shell on the documented irm | iex path.

It now travels as _UNSLOTH_ROCM_GFX_ARCH_HANDOFF, matching the _UNSLOTH_ prefix
the neighbouring handoffs use, saved and restored in the same finally block, and
consumed by setup.ps1 only after its own probes and inference come up empty.

setup.ps1's gpu name list had the same unwrapped if as the adapter scan: it
wraps each branch but not the if, so a single adapter name unrolls to a bare
String and $gpuNames[$nameIdx] indexes the name and yields "A". The
$nameArches[0] rescue covers that unless a visible-device mask is set, so a
pinned single-GPU host still inferred no arch and looped the same way. Audited
every '= if (' site across the .ps1 files; this was the only other one.

The tests asserted on .Count, which pwsh answers as 1 for a scalar because
PowerShell/PowerShell#5745 added that fallback in 6.1 and Windows PowerShell 5.1
never got it, so they passed against the unfixed source and guarded nothing.
They now assert the shape of the value, re-run each case against stubs carrying
Count = $null to reproduce 5.1's behaviour, cover the mask and multi-adapter
paths and the handoff lifecycle, and pin their own failure against the merge
base.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

The check resolved its pre-fix source through git merge-base against main. That
holds only until this merges: after it, the merge base is a commit that already
carries the fix, so the fixed source goes in as the before case and the three
assertions fail on every host with pwsh. Reaching for an older revision at all
also breaks in a shallow CI clone.

It now undoes just the two @() wraps in the shipped source in memory, which is
immutable, needs no git, and isolates the one thing under test since everything
else about the two sources is identical by construction. Verified both ways:
fed the fixed source in as before, the old assertion fails exactly as predicted;
with the wraps undone it reports scalar and no label as it should.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 73594a9f82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

chatgpt-codex-connector[bot]

This comment was marked as resolved.

The inference above deliberately leaves $pickedName unset when a mask is set
and the selected adapter's name is not in the table, rather than borrowing a
peer's arch. The handoff then took it anyway: install.ps1 scans without the
masks and forwards the FIRST recognized adapter, so a host masking an unknown
discrete card while a 780M is listed first resolved gfx1103 and installed
wheels and prebuilts for the iGPU the mask hides.

ROCR_VISIBLE_DEVICES filters below HIP, so masked devices never reach the
runtime's enumeration at all, which makes targeting one strictly wrong rather
than merely suboptimal. Setup now skips the handoff whenever any of the three
masks is set, matching what its own inference and Resolve-ShadowingGfxPick
already do. UNSLOTH_ROCM_GFX_ARCH stays the escape hatch and still wins.

Confirmed both ways: with the guard removed the masked host resolves gfx1103,
with it in place it resolves nothing, and the unmasked gap-filling case the
handoff exists for is unchanged.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

install.ps1 took the first AMD adapter WMI listed with no health check, while
setup.ps1 filters on ConfigManagerErrorCode. A disabled Radeon listed ahead of a
healthy unsupported one therefore resolved that dead card's arch. Because a
mapped arch installs ROCm wheels right there, the machine got wheels for a GPU
it cannot use while the live card went unserved, and setup, which discards that
adapter, disagreed and took the forwarded arch as its last resort.

Fixed at the source rather than by teaching setup to distrust the handoff:
rejecting it there would leave setup expecting cpu torch against the ROCm wheels
install.ps1 had already placed, which is the stale-venv rollback loop this
branch exists to end. Filtering here means the two scans start from the same
healthy set, so a forwarded arch can only ever name an adapter setup also kept.

Keeps setup's fallback for the case where the filter empties the list, since
code 45 is routine on a muxless laptop with a parked dGPU.

Confirmed both ways: before, the disabled card resolves gfx1201; after, the host
resolves nothing and lands on CPU exactly as setup does.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 11, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 11, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 11, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 11, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 11, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 11, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: f46e7330c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

pre-commit-ci Bot and others added 2 commits August 11, 2026 18:56
#8398 landed the same $wmiGpus @() wrap from the same report, so the only
conflict is the comment above an identical line. Took main's, which is the
merged wording and cites #8335.

What is left here on top of main: the second unwrapped if a block down
($gpuNames, still unwrapped on main), the ConfigManagerErrorCode filter in
install.ps1's WMI fallback (still Select-Object -First 1 on main), the private
arch handoff, and the tests.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 11, 2026
@danielhanchen
danielhanchen merged commit d7fed06 into main Aug 11, 2026
6 of 75 checks passed
@danielhanchen
danielhanchen deleted the fix/windows-amd-setup-detection-loop branch August 11, 2026 19:17
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 865023c6d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant