Repository navigation
Expand file tree
/
Copy pathdetect.ps1
More file actions
608 lines (568 loc) · 35.6 KB
/
Copy pathdetect.ps1
File metadata and controls
608 lines (568 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# setup/detect.ps1 — hardware/OS detection for the offload-harness installer.
# Prints human-readable findings, then a FINAL machine-readable JSON line.
# Exit 0 = a viable backend exists; exit 1 = hard blocker (explained on stderr).
#
# H1 (arch-class matrix): in addition to the base verdict this now emits
# gpu_arch — blackwell|ampere|ada|volta|rdna3|gcn|other|none (name-regex match)
# gpu_count — number of discrete NVIDIA+AMD GPUs (Intel iGPUs excluded)
# profile — one of the arch-class ids the installer (H2) keys a template off
# ram_tier — high|mid|low|min (drives the 26B RAM gate)
# The full 12-config matrix lives in
# docs/superpowers/plans/2026-07-11-agent-capabilities-and-hardware-matrix.md
#
# Run modes:
# detect.ps1 -> normal detection, JSON on the last line, exit 0/1
# detect.ps1 -SelfTest -> run the classifier self-checks, print PASS/FAIL,
# exit 0 all-pass / 1 any-fail. No hardware detection.
[CmdletBinding()]
param([switch]$SelfTest)
# ---------------------------------------------------------------------------
# Classification helpers (pure functions — no hardware access, so they are
# unit-testable via -SelfTest and by setup/detect.tests.ps1).
# ---------------------------------------------------------------------------
# Map a raw GPU name to an arch class. Case-insensitive regex rules; first hit wins.
# Rules (documented per the matrix's config list):
# blackwell : RTX 50-series (5060 / 5060 Ti / 5090 / any "RTX 50xx" / "RTX 50").
# ada : RTX 40-series (4060 / 4090 / any "RTX 40xx").
# ampere : RTX 30-series (3050 / 3070 / 3080 / 3090 / any "RTX 30xx").
# volta : Tesla V100 / bare "V100".
# rdna3 : AMD RDNA3 — Radeon 780M/760M iGPU and 7xxx discrete (RX 7900, etc.).
# gcn : older AMD — Vega (e.g. "Vega 7" iGPU) / GCN-era parts.
# other : a GPU we recognise as NVIDIA/AMD but not a class above.
# none : empty / no GPU name.
function Get-GpuArch {
param([string]$Name)
if ([string]::IsNullOrWhiteSpace($Name)) { return 'none' }
# NVIDIA RTX generations — match the 4-digit model so "RTX 3070" -> ampere.
if ($Name -imatch 'RTX\s*50\d{2}' -or $Name -imatch 'RTX\s*50\b') { return 'blackwell' }
# RTX PRO Blackwell workstation cards ("NVIDIA RTX PRO 5000 Blackwell"): "RTX PRO"
# breaks the RTX\s*50xx match above, so match the explicit Blackwell suffix, plus
# the "RTX PRO NNNN" branding defensively (that branding IS the Blackwell pro
# generation — pre-Blackwell pro cards were "RTX A6000" / "RTX 6000 Ada Generation",
# which do not match). CAVEAT: a future non-Blackwell "RTX PRO" generation needs a
# new rule ABOVE this one.
if ($Name -imatch '\bBlackwell\b') { return 'blackwell' }
if ($Name -imatch 'RTX\s+PRO\s+\d{4}') { return 'blackwell' }
if ($Name -imatch 'RTX\s*40\d{2}' -or $Name -imatch 'RTX\s*40\b') { return 'ada' }
if ($Name -imatch 'RTX\s*30\d{2}' -or $Name -imatch 'RTX\s*30\b') { return 'ampere' }
# Tesla / data-center Volta.
if ($Name -imatch '\bV100\b') { return 'volta' }
# AMD RDNA3: 780M/760M iGPU (Phoenix) and 7000-series discrete.
if ($Name -imatch '\b7\d{2}M\b' -or $Name -imatch 'RX\s*7\d{3}' -or $Name -imatch 'RDNA\s*3') { return 'rdna3' }
# AMD GCN / Vega (older iGPU + discrete). "Vega 7" is a Ryzen APU iGPU.
if ($Name -imatch '\bVega\b' -or $Name -imatch '\bGCN\b') { return 'gcn' }
# Recognised vendor but unclassified generation.
if ($Name -imatch 'NVIDIA|GeForce|Quadro|Tesla|AMD|Radeon') { return 'other' }
return 'other'
}
# Decide whether an NVIDIA card fell through to the VRAM-banded ampere default
# without being recognised by any arch regex. When arch=other AND vendor=nvidia,
# Get-Profile assigns an ampere-16/-8/-6 profile purely by VRAM band with no
# other signal, so the operator should be warned the card was unidentified.
# Returns the warning string, or $null when no warning is warranted. Pure fn.
function Get-UnrecognizedNvidiaWarning {
param(
[string]$Vendor, # nvidia|amd|none
[string]$GpuArch, # blackwell|ampere|ada|volta|rdna3|gcn|other|none
[string]$GpuName,
[string]$ProfileId
)
if ($Vendor -eq 'nvidia' -and $GpuArch -eq 'other') {
return "unrecognized NVIDIA GPU '$GpuName' (arch=other) - using profile '$ProfileId' by VRAM band; verify the serving template fits before relying on it."
}
return $null
}
# Parse the driver's max CUDA out of the nvidia-smi banner. Two header formats
# exist in the wild: classic drivers print "CUDA Version: 13.3"; newer drivers
# (e.g. 610.62) print "KMD Version: 610.62 CUDA UMD Version: 13.3" — the old
# regex missed the UMD form and silently reported null on real Blackwell boxes
# (found live on the workstation, 2026-07-15). Pure function — self-tested.
function Get-CudaFromSmiHeader {
param([string]$SmiText)
if ([string]::IsNullOrWhiteSpace($SmiText)) { return $null }
$m = [regex]::Match($SmiText, 'CUDA(?:\s+UMD)?\s+Version:\s*([0-9]+\.[0-9]+)')
if ($m.Success) { return $m.Groups[1].Value }
return $null
}
# J1: is this AMD adapter name an iGPU (APU graphics)? The Radeon iGPUs are the 'NNNM'
# series (780M/760M/680M...) plus the named-graphics APUs. Used to (a) prefer a discrete
# adapter when a hybrid box exposes both, and (b) refuse to band an iGPU into the
# amd-rdna3-dgpu profile just because its BIOS carve-out is large (a 16GB UMA carve-out
# reads as >=12GB 'dedicated' but is NOT discrete VRAM). Pure fn - self-tested.
function Test-AmdIgpuName {
param([string]$Name)
if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
return [bool]($Name -imatch '\b\d{3}M\b' -or $Name -imatch '\bVega\s*\d+\b' -or $Name -imatch 'Graphics\s*$')
}
# J1: classify an AMD Adrenalin (Radeon Software) version string against the known
# deep-context Vulkan crash class (llama.cpp #17432 - present in 25.11.1 and older).
# Returns a warning string for affected/unreadable versions, $null when the driver is
# newer than the known-bad class. Pure function - self-tested. The caller reads the
# actual version from the registry (Get-AdrenalinVersion below).
function Get-AdrenalinWarning {
param([string]$Version)
$knownBad = [version]'25.11.1'
if ([string]::IsNullOrWhiteSpace($Version)) {
return "Adrenalin version UNREADABLE - keep the AMD driver CURRENT: 25.11.1 and older have a known deep-context Vulkan crash (llama.cpp #17432); selftest.ps1 includes a canary for it."
}
$v = $null
# Adrenalin versions are 'YY.M.P' (e.g. 25.11.1, 26.3.2). Take at most THREE components:
# a 4-part registry value like '25.11.1.0' must still compare equal-class to 25.11.1
# ([version]'25.11.1.0' would sort GREATER and dodge the known-bad match).
$m = [regex]::Match($Version.Trim(), '^(\d+(?:\.\d+){1,2})')
if ($m.Success) { try { $v = [version]$m.Groups[1].Value } catch { $v = $null } }
if ($null -eq $v) {
return "Adrenalin version '$Version' did not parse - keep the AMD driver CURRENT: 25.11.1 and older have a known deep-context Vulkan crash (llama.cpp #17432)."
}
if ($v -le $knownBad) {
return "Adrenalin $Version has the KNOWN deep-context Vulkan crash class (llama.cpp #17432, affects <= 25.11.1) - STOP and ask the human to update the driver before relying on this box. Never update a GPU driver yourself."
}
return $null
}
# Map RAM size to a tier. >=120 high (128GB config), >=56 mid (64GB configs
# pass the RAM gate for the cpu backend's 26B), >=28 low (32GB), else min (<32GB warns already).
function Get-RamTier {
param([int]$RamGb)
if ($RamGb -ge 120) { return 'high' }
if ($RamGb -ge 56) { return 'mid' }
if ($RamGb -ge 28) { return 'low' }
return 'min'
}
# Accelerators (ADR 0024): additive devices beside the GPU. Mirror of
# internal/hwdetect.AcceleratorsFromHailortcli — change the Go rule FIRST, then
# this. A missing hailortcli or a failed probe is "no accelerator", never an error.
function Get-Accelerators {
$cli = Get-Command hailortcli -ErrorAction SilentlyContinue
if (-not $cli) { $cli = Get-Item 'C:\Program Files\HailoRT\bin\hailortcli.exe' -ErrorAction SilentlyContinue }
if (-not $cli) { return @() }
$exe = if ($cli.PSObject.Properties['Source']) { $cli.Source } else { $cli.FullName }
try { $scan = (& $exe scan 2>$null) -join "`n" } catch { return @() }
if ($scan -notmatch 'Device:') { return @() }
try { $ident = (& $exe fw-control identify 2>$null) -join "`n" } catch { return @() }
if ($ident -match '(?m)^\s*Device Architecture:\s*HAILO8L\s*$') { return @('hailo-8l') }
return @()
}
# Choose the arch-class profile id from (vendor, arch, vram, gpu_count, ram).
# Returns @{ profile=<id>; big_ram=<bool> }. big_ram is only meaningful for the
# dual-gpu profile (config #4 = the 128GB Optane variant) — detect cannot see the
# Optane drive, so we approximate config-4 by RAM>=120 on a dual-GPU rig.
function Get-Profile {
param(
[string]$Vendor, # nvidia|amd|none
[string]$Arch, # blackwell|ampere|ada|volta|rdna3|gcn|other|none
[double]$VramGb,
[int]$GpuCount,
[int]$RamGb,
[string[]]$ArchsAll = @() # EVERY adapter's arch; a homogeneity decision needs all, not the biggest
)
$bigRam = $false
# A homogeneous PAIR of 16GB-class Blackwell cards is its own hardware class,
# decided BEFORE the generic multi-GPU rule: one sm_120 CUDA build serves both
# cards, and the topology is per-card pinning on an asymmetric-bandwidth pair —
# neither of which the heterogeneous dual-gpu row describes. STRICT on all three
# axes: one arch captured PER counted GPU and every one blackwell (an unknown
# second card falls through to dual-gpu), AND the primary card in the 16GB band —
# the "16" is the template's load-bearing assumption (it pins the 26B and a ~10GB
# vision seat per card), so a 2x 8GB or 2x 32GB pair must not claim it. VramGb is
# the LARGEST card's; a mixed 16+8 pair passes the band check — known limit until
# per-GPU VRAM capture lands. (@() everywhere — the 1-element-unwrap trap.)
if ($GpuCount -eq 2 -and $Vendor -eq 'nvidia' -and @($ArchsAll).Count -eq 2 -and
-not (@($ArchsAll) | Where-Object { $_ -ne 'blackwell' }) -and
$VramGb -ge 12 -and $VramGb -lt 24) {
if ($RamGb -ge 120) { $bigRam = $true }
return @{ profile = 'blackwell-2x16'; big_ram = $bigRam }
}
# THREE homogeneous Blackwell 16GB cards -> blackwell-3x16 (the reference
# workstation's own shape since 2026-08-31). Placed BEFORE the generic
# multi-GPU rule for the same reason blackwell-2x16 is: three sm_120 cards
# served by one build, with a per-card placement law, is not the heterogeneous
# dual-gpu row. Until this rule existed the fleet's MOST-measured machine
# classified as `dual-gpu` -- a tier that has never run on any hardware and
# that the installer refuses to install (CUDA 13 cannot compile sm_70). Same
# strictness as the 2-card rule: one arch captured PER counted GPU, every one
# blackwell, and the largest card in the 16GB band.
if ($GpuCount -eq 3 -and $Vendor -eq 'nvidia' -and @($ArchsAll).Count -eq 3 -and
-not (@($ArchsAll) | Where-Object { $_ -ne 'blackwell' }) -and
$VramGb -ge 12 -and $VramGb -lt 24) {
if ($RamGb -ge 120) { $bigRam = $true }
return @{ profile = 'blackwell-3x16'; big_ram = $bigRam }
}
# THREE homogeneous Blackwell 16GB cards -> blackwell-3x16 (the reference
# workstation's own shape since 2026-08-31). Placed BEFORE the generic
# multi-GPU rule for the same reason blackwell-2x16 is: three sm_120 cards
# served by one build, with a per-card placement law, is not the heterogeneous
# dual-gpu row. Until this rule existed the fleet's MOST-measured machine
# classified as `dual-gpu` -- a tier that has never run on any hardware and
# that the installer refuses to install (CUDA 13 cannot compile sm_70). Same
# strictness as the 2-card rule: one arch captured PER counted GPU, every one
# blackwell, and the largest card in the 16GB band.
if ($GpuCount -eq 3 -and $Vendor -eq 'nvidia' -and @($ArchsAll).Count -eq 3 -and
-not (@($ArchsAll) | Where-Object { $_ -ne 'blackwell' }) -and
$VramGb -ge 12 -and $VramGb -lt 24) {
if ($RamGb -ge 120) { $bigRam = $true }
return @{ profile = 'blackwell-3x16'; big_ram = $bigRam }
}
# Multi-GPU with at least one NVIDIA -> the 5060 Ti + V100 dual-resident rig
# (configs 3-4). Two models resident, no swap. Checked first: a heterogeneous
# pair outranks any single-card band.
if ($GpuCount -ge 2 -and $Vendor -eq 'nvidia') {
if ($RamGb -ge 120) { $bigRam = $true } # config #4: +128GB (+Optane, unseen)
return @{ profile = 'dual-gpu'; big_ram = $bigRam }
}
if ($Vendor -eq 'nvidia') {
switch ($Arch) {
'blackwell' {
# Bands above 16GB (configs 13-15, spec 2026-07-16-blackwell-profile-tiers-design.md):
# 72 covers the RTX PRO 5000 72GB AND the PRO 6000 96GB until H3 measures more.
if ($VramGb -ge 64) { return @{ profile = 'blackwell-72'; big_ram = $false } }
if ($VramGb -ge 40) { return @{ profile = 'blackwell-48'; big_ram = $false } }
if ($VramGb -ge 24) { return @{ profile = 'blackwell-32'; big_ram = $false } }
if ($VramGb -ge 12) { return @{ profile = 'blackwell-16'; big_ram = $false } }
return @{ profile = 'blackwell-8'; big_ram = $false }
}
'volta' {
return @{ profile = 'volta-16'; big_ram = $false }
}
default {
# ampere / ada / other NVIDIA share the ampere-* bands (Ada ~= Ampere here).
if ($VramGb -ge 12) { return @{ profile = 'ampere-16'; big_ram = $false } } # defensive: 3090-class
if ($VramGb -ge 7) { return @{ profile = 'ampere-8'; big_ram = $false } } # 8GB band (3070/5060 fallback)
return @{ profile = 'ampere-6'; big_ram = $false } # 6GB band (3050)
}
}
}
if ($Vendor -eq 'amd') {
if ($Arch -eq 'rdna3') {
# J1 AMD VRAM banding: an iGPU (780M-class) reports a SMALL dedicated carve-out
# (typically 0.5-4GB - real capacity is UMA/GTT shared memory), while a discrete
# RDNA3 card (RX 7900-class) reports its real >=12GB dedicated VRAM. Before this
# band a 24GB RX 7900 XTX silently got the iGPU floor profile (audit finding).
if ($VramGb -ge 12) { return @{ profile = 'amd-rdna3-dgpu'; big_ram = $false } }
return @{ profile = 'amd-rdna3'; big_ram = $false }
}
return @{ profile = 'amd-gcn'; big_ram = $false } # gcn / vega / other AMD -> weakest Vulkan path
}
return @{ profile = 'cpu'; big_ram = $false } # no usable GPU
}
# ---------------------------------------------------------------------------
# Self-test mode: feed synthetic tuples to the classifier and assert profiles.
# ---------------------------------------------------------------------------
if ($SelfTest) {
$fail = 0
function Assert-Arch {
param([string]$Name, [string]$Expected)
$got = Get-GpuArch -Name $Name
if ($got -eq $Expected) { Write-Host "PASS arch '$Name' -> $got" }
else { Write-Host "FAIL arch '$Name' -> $got (expected $Expected)"; $script:fail++ }
}
function Assert-Profile {
param([string]$Label, [string]$Vendor, [string]$Arch, [double]$Vram, [int]$Count, [int]$Ram, [string]$Expected, [bool]$ExpectBigRam = $false, [string[]]$ArchsAll = @())
$r = Get-Profile -Vendor $Vendor -Arch $Arch -VramGb $Vram -GpuCount $Count -RamGb $Ram -ArchsAll $ArchsAll
$ok = ($r.profile -eq $Expected) -and ($r.big_ram -eq $ExpectBigRam)
if ($ok) { Write-Host "PASS prof $Label -> $($r.profile) (big_ram=$($r.big_ram))" }
else { Write-Host "FAIL prof $Label -> $($r.profile) big_ram=$($r.big_ram) (expected $Expected big_ram=$ExpectBigRam)"; $script:fail++ }
}
function Assert-RamTier {
param([int]$Ram, [string]$Expected)
$got = Get-RamTier -RamGb $Ram
if ($got -eq $Expected) { Write-Host "PASS ram ${Ram}GB -> $got" }
else { Write-Host "FAIL ram ${Ram}GB -> $got (expected $Expected)"; $script:fail++ }
}
function Assert-UnrecognizedWarning {
param([string]$Label, [string]$Vendor, [string]$Arch, [string]$Name, [string]$ProfileId, [bool]$ExpectWarn)
$w = Get-UnrecognizedNvidiaWarning -Vendor $Vendor -GpuArch $Arch -GpuName $Name -ProfileId $ProfileId
$got = [bool]$w
if ($got -eq $ExpectWarn) { Write-Host "PASS warn $Label -> warn=$got" }
else { Write-Host "FAIL warn $Label -> warn=$got (expected warn=$ExpectWarn)"; $script:fail++ }
}
function Assert-CudaHeader {
param([string]$Label, [string]$SmiText, [string]$Expected)
$got = Get-CudaFromSmiHeader -SmiText $SmiText
if ("$got" -eq "$Expected") { Write-Host "PASS cuda $Label -> $(if ($got) { $got } else { '(null)' })" }
else { Write-Host "FAIL cuda $Label -> $got (expected $Expected)"; $script:fail++ }
}
Write-Host '== nvidia-smi CUDA header parsing (both driver formats) =='
Assert-CudaHeader 'classic header' '| NVIDIA-SMI 570.86.10 Driver Version: 570.86.10 CUDA Version: 12.8 |' '12.8'
Assert-CudaHeader 'UMD header (610.62)' '| NVIDIA-SMI 610.62 KMD Version: 610.62 CUDA UMD Version: 13.3 |' '13.3'
Assert-CudaHeader 'no CUDA in text' 'NVIDIA-SMI has failed' ''
Assert-CudaHeader 'empty text' '' ''
Write-Host '== arch name matching =='
Assert-Arch 'NVIDIA GeForce RTX 5060 Ti' 'blackwell'
Assert-Arch 'NVIDIA GeForce RTX 5090' 'blackwell'
Assert-Arch 'NVIDIA GeForce RTX 5060' 'blackwell'
Assert-Arch 'NVIDIA GeForce RTX 4090' 'ada'
Assert-Arch 'NVIDIA GeForce RTX 3070 Laptop GPU' 'ampere'
Assert-Arch 'NVIDIA GeForce RTX 3050' 'ampere'
Assert-Arch 'Tesla V100-PCIE-16GB' 'volta'
# RTX PRO Blackwell workstation cards: "RTX PRO" breaks the RTX\s*50xx match,
# so these need their own rules (72-96GB workstation cards classify here).
Assert-Arch 'NVIDIA RTX PRO 4500 Blackwell' 'blackwell'
Assert-Arch 'NVIDIA RTX PRO 5000 Blackwell' 'blackwell'
Assert-Arch 'NVIDIA RTX PRO 6000 Blackwell Workstation Edition' 'blackwell'
# Pre-Blackwell pro cards must NOT trip the defensive RTX-PRO rule.
Assert-Arch 'NVIDIA RTX 6000 Ada Generation' 'other'
Assert-Arch 'AMD Radeon 780M Graphics' 'rdna3'
Assert-Arch 'AMD Radeon RX 7900 XTX' 'rdna3'
Assert-Arch 'AMD Radeon Vega 7 Graphics' 'gcn'
Assert-Arch '' 'none'
Assert-Arch 'Intel(R) UHD Graphics' 'other'
Write-Host '== profile selection (matrix configs) =='
Assert-Profile '5060 Ti 16GB (cfg1)' 'nvidia' 'blackwell' 16 1 64 'blackwell-16'
Assert-Profile 'V100 16GB (cfg2)' 'nvidia' 'volta' 16 1 64 'volta-16'
Assert-Profile 'dual 5060Ti+V100 (cfg3)' 'nvidia' 'blackwell' 16 2 64 'dual-gpu' $false @('blackwell','volta')
Assert-Profile 'dual +128GB (cfg4)' 'nvidia' 'blackwell' 16 2 128 'dual-gpu' $true @('blackwell','volta')
# Homogeneous dual-sm_120 pair = its OWN tier; strict rules mirrored from Go
# (hwdetect.TestHomogeneousBlackwellPairGetsItsOwnTier — keep the two tables in sync).
Assert-Profile '5060Ti+5070Ti 128GB (blackwell-2x16 ref)' 'nvidia' 'blackwell' 16 2 127 'blackwell-2x16' $true @('blackwell','blackwell')
Assert-Profile '2x blackwell, 64GB' 'nvidia' 'blackwell' 16 2 64 'blackwell-2x16' $false @('blackwell','blackwell')
Assert-Profile 'pair, archs not captured -> dual-gpu' 'nvidia' 'blackwell' 16 2 128 'dual-gpu' $true
Assert-Profile 'pair, ONE arch captured -> dual-gpu' 'nvidia' 'blackwell' 16 2 128 'dual-gpu' $true @('blackwell')
Assert-Profile '3x blackwell -> blackwell-3x16' 'nvidia' 'blackwell' 16 3 128 'blackwell-3x16' $true @('blackwell','blackwell','blackwell')
Assert-Profile '3x blackwell, low RAM' 'nvidia' 'blackwell' 16 3 64 'blackwell-3x16' $false @('blackwell','blackwell','blackwell')
Assert-Profile '3 GPUs mixed arch -> dual-gpu' 'nvidia' 'blackwell' 16 3 128 'dual-gpu' $true @('blackwell','blackwell','ampere')
Assert-Profile '4x blackwell -> dual-gpu' 'nvidia' 'blackwell' 16 4 128 'dual-gpu' $true @('blackwell','blackwell','blackwell','blackwell')
# The 16GB band is ENFORCED (the template pins the 26B + a ~10GB vision seat per card):
Assert-Profile '2x blackwell 8GB -> dual-gpu (not the 16GB tier)' 'nvidia' 'blackwell' 8 2 128 'dual-gpu' $true @('blackwell','blackwell')
Assert-Profile '2x blackwell 32GB -> dual-gpu (not the 16GB tier)' 'nvidia' 'blackwell' 32 2 128 'dual-gpu' $true @('blackwell','blackwell')
Assert-Profile 'band floor 12GB -> blackwell-2x16' 'nvidia' 'blackwell' 12 2 128 'blackwell-2x16' $true @('blackwell','blackwell')
Assert-Profile '3070 8GB (cfg5)' 'nvidia' 'ampere' 8 1 16 'ampere-8'
Assert-Profile '3070+64GB (cfg6)' 'nvidia' 'ampere' 8 1 64 'ampere-8'
Assert-Profile '780M+64GB (cfg7)' 'amd' 'rdna3' 0.5 1 64 'amd-rdna3'
# J1 AMD VRAM banding: iGPU carve-out (small dedicated) stays on the UMA floor;
# a discrete RDNA3 card (>=12GB dedicated) gets the dgpu band, not the iGPU floor.
Assert-Profile '780M 4GB carve-out' 'amd' 'rdna3' 4 1 64 'amd-rdna3'
Assert-Profile 'RX 7900 XTX 24GB' 'amd' 'rdna3' 24 1 64 'amd-rdna3-dgpu'
Assert-Profile 'RX 7700 XT 12GB' 'amd' 'rdna3' 12 1 32 'amd-rdna3-dgpu'
Assert-Profile '5060 8GB (cfg8)' 'nvidia' 'blackwell' 8 1 32 'blackwell-8'
Assert-Profile '3050 6GB (cfg10)' 'nvidia' 'ampere' 6 1 16 'ampere-6'
Assert-Profile '3090 24GB (defensive)' 'nvidia' 'ampere' 24 1 64 'ampere-16'
# Blackwell bands above 16GB (configs 13-15; spec 2026-07-16-blackwell-profile-tiers-design.md)
Assert-Profile '5090 32GB (cfg13)' 'nvidia' 'blackwell' 32 1 64 'blackwell-32'
Assert-Profile 'PRO 4500 32GB (cfg13)' 'nvidia' 'blackwell' 32 1 128 'blackwell-32'
Assert-Profile 'PRO 5000 48GB (cfg14)' 'nvidia' 'blackwell' 48 1 64 'blackwell-48'
Assert-Profile 'PRO 5000 72GB (cfg15)' 'nvidia' 'blackwell' 72 1 128 'blackwell-72'
Assert-Profile 'PRO 6000 96GB (cfg15)' 'nvidia' 'blackwell' 96 1 128 'blackwell-72'
Assert-Profile 'Vega7+32GB (cfg12)' 'amd' 'gcn' 0.5 1 32 'amd-gcn'
Assert-Profile 'no GPU (cpu)' 'none' 'none' 0 0 16 'cpu'
Write-Host '== ram tiers =='
Assert-RamTier 128 'high'
Assert-RamTier 64 'mid'
Assert-RamTier 56 'mid'
Assert-RamTier 32 'low'
Assert-RamTier 16 'min'
Write-Host '== Adrenalin version classification (J1) =='
function Assert-Adrenalin {
param([string]$Label, [string]$Version, [bool]$ExpectWarn)
$w = Get-AdrenalinWarning -Version $Version
$got = [bool]$w
if ($got -eq $ExpectWarn) { Write-Host "PASS adren $Label -> warn=$got" }
else { Write-Host "FAIL adren $Label -> warn=$got (expected warn=$ExpectWarn)"; $script:fail++ }
}
Assert-Adrenalin 'known-bad 25.11.1 warns' '25.11.1' $true
Assert-Adrenalin 'older 25.5.1 warns' '25.5.1' $true
Assert-Adrenalin 'newer 26.3.2 clean' '26.3.2' $false
Assert-Adrenalin 'newer 25.12.1 clean' '25.12.1' $false
Assert-Adrenalin '4-part 25.11.1.0 warns' '25.11.1.0' $true
Assert-Adrenalin 'unreadable (empty) warns' '' $true
Assert-Adrenalin 'garbage string warns' 'WHQL' $true
Write-Host '== AMD iGPU name classification (J1) =='
function Assert-Igpu {
param([string]$Name, [bool]$Expected)
$got = Test-AmdIgpuName -Name $Name
if ($got -eq $Expected) { Write-Host "PASS igpu '$Name' -> $got" }
else { Write-Host "FAIL igpu '$Name' -> $got (expected $Expected)"; $script:fail++ }
}
Assert-Igpu 'AMD Radeon(TM) 780M Graphics' $true
Assert-Igpu 'AMD Radeon 760M' $true
Assert-Igpu 'AMD Radeon(TM) Graphics' $true
Assert-Igpu 'AMD Radeon Vega 7 Graphics' $true
Assert-Igpu 'AMD Radeon RX 7900 XTX' $false
Assert-Igpu 'AMD Radeon RX 7700 XT' $false
Write-Host '== unrecognized-NVIDIA fallback warning =='
# A GPU that CIM reports as NVIDIA but whose name matches no arch regex ->
# Get-GpuArch returns 'other', Get-Profile bands it into ampere-* by VRAM, and
# the operator MUST be warned the card was unidentified.
Assert-UnrecognizedWarning 'unknown NVIDIA (arch=other) warns' 'nvidia' 'other' 'NVIDIA GeForce RTX 6070' 'ampere-16' $true
# A recognized NVIDIA card (arch=ampere) must NOT get this warning.
Assert-UnrecognizedWarning 'recognized ampere does NOT warn' 'nvidia' 'ampere' 'NVIDIA GeForce RTX 3070 Laptop GPU' 'ampere-8' $false
# AMD arch=other must NOT trip the NVIDIA-only warning (AMD warns via its own path).
Assert-UnrecognizedWarning 'AMD arch=other does NOT warn' 'amd' 'other' 'AMD Radeon Pro Wxxxx' 'amd-gcn' $false
if ($fail -eq 0) { Write-Host 'ALL PASS'; exit 0 }
Write-Host "FAILURES: $fail"; exit 1
}
# ---------------------------------------------------------------------------
# Normal detection.
# ---------------------------------------------------------------------------
$ErrorActionPreference = 'Stop'
$warnings = @()
$os = if ($env:OS -eq 'Windows_NT') { 'windows' } else { 'other' }
if ($os -ne 'windows') { Write-Error 'This detector targets Windows. Use the Linux/WSL skill path instead.'; exit 1 }
# CUDA version discovery (NVIDIA only), so the installer can be FLEXIBLE about the build:
# - driver_cuda = the driver's max CUDA (nvidia-smi header "CUDA Version: X.Y")
# - driver_version= the NVIDIA driver build
# - toolkit_cuda = the installed CUDA Toolkit (nvcc), or null if no toolkit is present
# Why it matters on Blackwell (sm_120): a CUDA-13 build SERVES (falls back from the MMQ
# kernel to cuBLAS, ~5.6x slower prefill on Q4 — functional, not peak); a CUDA-12.8 build
# hits MMQ for full speed; the old CUDA-12.4 prebuilt has no sm_120 and won't run. The
# installer keys the build choice off these, not a fixed assumption. All null when the
# tools are absent (e.g. no driver yet) — that is a valid, honest state.
function Get-CudaInfo {
$info = [ordered]@{ driver_cuda = $null; driver_version = $null; toolkit_cuda = $null }
try {
$smi = (& nvidia-smi 2>$null) -join "`n"
if ($smi) {
$info.driver_cuda = Get-CudaFromSmiHeader -SmiText $smi
$dv = (& nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>$null | Select-Object -First 1)
if ($dv) { $info.driver_version = ([string]$dv).Trim() }
}
} catch { }
try {
$nv = (& nvcc --version 2>$null) -join "`n"
if ($nv) {
$m = [regex]::Match($nv, 'release\s*([0-9]+\.[0-9]+)')
if ($m.Success) { $info.toolkit_cuda = $m.Groups[1].Value }
}
} catch { }
return $info
}
# J1: read the AMD Adrenalin (Radeon Software) marketing version from the display-class
# registry key matching the GPU (the same key family the VRAM read below uses). WMI's
# DriverVersion is the internal build (e.g. 32.0.13031.x), NOT the Adrenalin version the
# crash-class advisories are keyed on - AMD writes that as RadeonSoftwareVersion. Returns
# the version string or $null (unreadable is a valid, honest state the classifier handles).
function Get-AdrenalinVersion {
param([string]$GpuName)
try {
$keys = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\0*' -ErrorAction SilentlyContinue |
Where-Object { $_.PSObject.Properties['RadeonSoftwareVersion'] -and $_.RadeonSoftwareVersion }
if (-not $keys) { return $null }
$match = $keys | Where-Object { $GpuName -and $_.DriverDesc -eq $GpuName } | Select-Object -First 1
if (-not $match) { $match = $keys | Select-Object -First 1 }
return [string]$match.RadeonSoftwareVersion
} catch { return $null }
}
$gpus = Get-CimInstance Win32_VideoController | Where-Object { $_.Name }
$gpuNames = ($gpus | ForEach-Object Name) -join '; '
$nvidia = $gpus | Where-Object { $_.Name -match 'NVIDIA' } | Select-Object -First 1
# J1: on a hybrid AMD box (iGPU + discrete) prefer the DISCRETE adapter - WMI enumeration
# order is arbitrary, and picking the iGPU here would under-serve the real card.
$amdAll = @($gpus | Where-Object { $_.Name -match 'AMD|Radeon' })
$amd = $amdAll | Where-Object { -not (Test-AmdIgpuName -Name $_.Name) } | Select-Object -First 1
if (-not $amd) { $amd = $amdAll | Select-Object -First 1 }
# Detection contract: NVIDIA present = CIM name match OR nvidia-smi resolves.
$nvidiaSmi = Get-Command nvidia-smi -ErrorAction SilentlyContinue
# gpu_count = discrete NVIDIA + AMD/Radeon adapters (Intel iGPUs are NOT usable
# offload targets and are excluded from the count). @() forces array semantics
# so .Count is reliable even for a single match.
$gpuCount = @($gpus | Where-Object { $_.Name -match 'NVIDIA|AMD|Radeon' }).Count
$vendor = 'none'; $backend = 'cpu'; $gpuName = ''
if ($nvidia) { $vendor = 'nvidia'; $backend = 'cuda'; $gpuName = $nvidia.Name }
elseif ($nvidiaSmi) {
# nvidia-smi present but CIM naming missed the adapter — still NVIDIA/cuda per contract.
$vendor = 'nvidia'; $backend = 'cuda'
try { $gpuName = (& nvidia-smi --query-gpu=name --format=csv,noheader 2>$null | Select-Object -First 1).Trim() }
catch { $gpuName = '' }
if (-not $gpuName) { $gpuName = '' }
# nvidia-smi found a card CIM missed -> ensure the count reflects at least one.
if ($gpuCount -lt 1) { $gpuCount = 1 }
}
elseif ($amd) { $vendor = 'amd'; $backend = 'vulkan'; $gpuName = $amd.Name }
# Dedicated VRAM (iGPUs report small carve-out; that is EXPECTED — Vulkan uses shared memory)
# Primary source: registry qwMemorySize (64-bit, matched by DriverDesc = GPU name);
# fallback: Win32_VideoController.AdapterRAM (uint32 — saturates at ~4GB on big cards).
$vramGB = 0
if ($vendor -ne 'none') {
$qw = $null
try {
$qw = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\0*' -ErrorAction SilentlyContinue |
Where-Object { $_.DriverDesc -eq $gpuName -and $_.'HardwareInformation.qwMemorySize' } |
Select-Object -First 1).'HardwareInformation.qwMemorySize'
} catch { $qw = $null }
if ($qw) { $vramGB = [math]::Round($qw / 1GB, 1) }
else {
$adapterRAM = ($gpus | Where-Object { $_.Name -eq $gpuName }).AdapterRAM
if ($adapterRAM) { $vramGB = [math]::Round($adapterRAM / 1GB, 1) }
}
}
$ramGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)
# Free disk is measured on the INSTALL TARGET drive: models/binaries go under
# $env:OFFLOAD_HOME (default $HOME\offload-stack), not necessarily where this script lives.
$installRoot = if ($env:OFFLOAD_HOME) { $env:OFFLOAD_HOME } else { $HOME }
$targetDrive = (Split-Path -Qualifier $installRoot).TrimEnd(':')
$diskGB = [math]::Round((Get-PSDrive -Name $targetDrive).Free / 1GB)
# Arch class + profile (pure functions above).
$gpuArch = Get-GpuArch -Name $gpuName
$ramTier = Get-RamTier -RamGb $ramGB
# Every adapter's arch, for the homogeneous-pair rule. nvidia-smi is authoritative
# when present (one row per card, marketing names the arch table matches); the CIM
# fallback names whatever it saw. @() everywhere — the 1-element-unwrap trap.
$gpuArchsAll = @()
if ($nvidiaSmi) {
try {
$gpuArchsAll = @((& nvidia-smi --query-gpu=name --format=csv,noheader 2>$null) |
ForEach-Object { $n = "$_".Trim(); if ($n) { Get-GpuArch -Name $n } } | Where-Object { $_ })
} catch { $gpuArchsAll = @() }
}
if (@($gpuArchsAll).Count -eq 0) {
$gpuArchsAll = @($gpus | Where-Object { $_.Name -match 'NVIDIA|AMD|Radeon' } |
ForEach-Object { Get-GpuArch -Name $_.Name })
}
# NB: use $profileId, not $profile — $profile is a PowerShell automatic variable.
$sel = Get-Profile -Vendor $vendor -Arch $gpuArch -VramGb $vramGB -GpuCount $gpuCount -RamGb $ramGB -ArchsAll $gpuArchsAll
$profileId = $sel.profile
$bigRam = [bool]$sel.big_ram
# Accelerators beside the GPU (ADR 0024). OFFLOAD_ACCELERATORS overrides the probe
# so installs can be tested/forced without a physical device on the bench.
$accelerators = @(Get-Accelerators)
if ($env:OFFLOAD_ACCELERATORS) { $accelerators = @($env:OFFLOAD_ACCELERATORS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
# J1 guard: a large BIOS UMA carve-out can make an iGPU report >=12GB 'dedicated', which
# would band it into the DISCRETE profile. The name says iGPU -> the UMA floor profile wins.
if ($profileId -eq 'amd-rdna3-dgpu' -and (Test-AmdIgpuName -Name $gpuName)) {
$warnings += "iGPU '$gpuName' reports ${vramGB}GB dedicated (a BIOS UMA carve-out, not discrete VRAM) - keeping the amd-rdna3 UMA profile, not amd-rdna3-dgpu."
$profileId = 'amd-rdna3'
}
# CUDA versions (NVIDIA only) + a Blackwell build hint so the installer/agent picks the
# right llama.cpp build flexibly rather than assuming one CUDA version.
$cuda = [ordered]@{ driver_cuda = $null; driver_version = $null; toolkit_cuda = $null }
if ($vendor -eq 'nvidia') { $cuda = Get-CudaInfo }
if ($gpuArch -eq 'blackwell') {
$eff = if ($cuda.toolkit_cuda) { $cuda.toolkit_cuda } elseif ($cuda.driver_cuda) { $cuda.driver_cuda } else { $null }
if ($eff -match '^12\.4') {
$warnings += "Blackwell (sm_120) with CUDA ${eff}: the 12.4 prebuilt has NO sm_120 and will not run - install a CUDA-13 (serves) or CUDA-12.8 (peak) build. See SETUP-AGENT.md Blackwell note."
} elseif ($eff -match '^13\.') {
$warnings += "Blackwell (sm_120) with CUDA ${eff}: SERVES on a CUDA-13 build (cuBLAS fallback, ~5.6x slower prefill on Q4). For peak throughput build/install against CUDA 12.8 (MMQ). Functional now; not peak."
} elseif ($eff -match '^12\.(8|9)' -or $eff -match '^12\.1[0-9]') {
$warnings += "Blackwell (sm_120) with CUDA ${eff}: peak path - build with -DCMAKE_CUDA_ARCHITECTURES=120 (MMQ)."
} else {
$warnings += "Blackwell (sm_120): CUDA version undetected ($eff). Detect the installed CUDA before choosing the build (12.8=peak, 13.x=serves-slower, 12.4=won't run)."
}
}
# An NVIDIA card that matched no arch regex (arch=other) fell through to the
# VRAM-banded ampere default. Make that silence audible (WARNING only — the
# fallback profile stands and the exit code is unchanged).
$unrecognizedNvidiaWarning = Get-UnrecognizedNvidiaWarning -Vendor $vendor -GpuArch $gpuArch -GpuName $gpuName -ProfileId $profileId
if ($unrecognizedNvidiaWarning) { $warnings += $unrecognizedNvidiaWarning }
if ($ramGB -lt 32) { $warnings += "RAM ${ramGB}GB < 32GB: install only the E4B workhorse tier (set OFFLOAD_WITH_FAMILY=0)" }
if ($diskGB -lt 25) { Write-Error "Only ${diskGB}GB free on ${targetDrive}: (install target drive); need >=25GB for models + binaries."; exit 1 }
$amdAdrenalin = $null
if ($vendor -eq 'amd') {
$warnings += 'AMD path uses the llama.cpp VULKAN backend (native Windows). ROCm/HIP is NOT supported on RDNA3 iGPUs (gfx1103) and WSL2 cannot accelerate AMD iGPUs - do not attempt either.'
# J1: the driver-age warning is now CHECKED, not generic - read the Adrenalin version
# and classify it against the known deep-context Vulkan crash class (<= 25.11.1).
$amdAdrenalin = Get-AdrenalinVersion -GpuName $gpuName
$adrenWarn = Get-AdrenalinWarning -Version $amdAdrenalin
if ($adrenWarn) { $warnings += $adrenWarn }
else { Write-Host "Adrenalin: $amdAdrenalin (newer than the known crash class <= 25.11.1 - OK; selftest's deep-context canary still runs)" }
}
Write-Host "OS: windows | GPUs: $gpuNames"
Write-Host "Vendor: $vendor | Arch: $gpuArch | GPUs(discrete): $gpuCount | Backend: $backend | Dedicated VRAM: ${vramGB}GB | RAM: ${ramGB}GB (tier=$ramTier) | Free disk: ${diskGB}GB on ${targetDrive}: (install target)"
Write-Host "Profile: $profileId$(if ($bigRam) { ' (big_ram)' } else { '' })"
Write-Host "Accelerators: $(if ($accelerators.Count) { $accelerators -join ', ' } else { 'none' })"
$warnings | ForEach-Object { Write-Host "WARN: $_" }
@{ os=$os; gpu_vendor=$vendor; gpu_name=$gpuName; gpu_arch=$gpuArch; gpu_count=$gpuCount;
gpu_archs=@($gpuArchsAll);
vram_dedicated_gb=$vramGB; ram_gb=$ramGB; ram_tier=$ramTier; profile=$profileId; big_ram=$bigRam;
accelerators=@($accelerators);
cuda_driver=$cuda.driver_cuda; cuda_driver_version=$cuda.driver_version; cuda_toolkit=$cuda.toolkit_cuda;
amd_adrenalin=$amdAdrenalin;
disk_free_gb=$diskGB; backend=$backend; warnings=$warnings } | ConvertTo-Json -Compress