From f7d3509b9e17c1e3e31b0960f71af0d92e8b7428 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Thu, 26 Mar 2026 23:39:59 -0700 Subject: [PATCH] fix: smarter GPU detection to prefer Intel XPU over AMD iGPU When a machine has both an AMD CPU with integrated Radeon graphics (vendor 1002) and an Intel Arc discrete GPU (vendor 8086), the old pickGPU() priority (NVIDIA > AMD > Intel) would incorrectly select AMD even though the AMD device is just an iGPU incapable of ROCm. Changes: - pickGPU() now accepts rich GpuDevice[] with name/VRAM/discrete info - New isAmdIgpu() heuristic classifies AMD GPUs as integrated vs discrete using device name patterns, VRAM size, and sysfs boot_vga attribute - Priority is now: NVIDIA > discrete AMD > Intel (XPU) > AMD iGPU > CPU - Windows WMI query expanded to fetch Name + AdapterRAM per controller - Linux lspci parser extracts device descriptions for name-based heuristics - Linux sysfs reader uses boot_vga attribute to flag integrated GPUs - Comprehensive unit tests for isAmdIgpu and pickGPU covering the bug scenario Fixes #342 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019d2dfc-3f03-760c-b05d-f88aae3ea22e --- src/main/lib/gpu.test.ts | 133 ++++++++++++++++++++++++++++++++- src/main/lib/gpu.ts | 156 ++++++++++++++++++++++++++++++--------- 2 files changed, 255 insertions(+), 34 deletions(-) diff --git a/src/main/lib/gpu.test.ts b/src/main/lib/gpu.test.ts index a9e1fe22..6a00b425 100644 --- a/src/main/lib/gpu.test.ts +++ b/src/main/lib/gpu.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parseNvidiaDriverVersion } from './gpu' +import { parseNvidiaDriverVersion, isAmdIgpu, pickGPU } from './gpu' +import type { GpuDevice } from './gpu' describe('parseNvidiaDriverVersion', () => { it('parses driver version from nvidia-smi table output', () => { @@ -25,3 +26,133 @@ describe('parseNvidiaDriverVersion', () => { expect(parseNvidiaDriverVersion('Driver Version: 535.183.01')).toBe('535.183.01') }) }) + +describe('isAmdIgpu', () => { + it('identifies AMD Radeon(TM) Graphics as iGPU', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon(TM) Graphics' })).toBe(true) + }) + + it('identifies AMD Radeon Vega 8 Graphics as iGPU', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon Vega 8 Graphics' })).toBe(true) + }) + + it('identifies AMD Radeon Graphics as iGPU', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon Graphics' })).toBe(true) + }) + + it('identifies AMD Radeon RX 7900 XTX as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon RX 7900 XTX' })).toBe(false) + }) + + it('identifies Radeon RX 580 as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'Radeon RX 580' })).toBe(false) + }) + + it('identifies AMD Radeon RX 6800 XT as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon RX 6800 XT' })).toBe(false) + }) + + it('identifies AMD Radeon Pro W6800 as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon Pro W6800' })).toBe(false) + }) + + it('identifies AMD Radeon VII as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon VII' })).toBe(false) + }) + + it('identifies AMD Instinct MI250X as discrete', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Instinct MI250X' })).toBe(false) + }) + + it('trusts explicit discrete=true flag', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon(TM) Graphics', discrete: true })).toBe(false) + }) + + it('trusts explicit discrete=false flag', () => { + expect(isAmdIgpu({ vendor: '1002', name: 'AMD Radeon RX 7900 XTX', discrete: false })).toBe(true) + }) + + it('flags low VRAM (<512MB) as iGPU', () => { + expect(isAmdIgpu({ vendor: '1002', adapterRam: 0 })).toBe(true) + expect(isAmdIgpu({ vendor: '1002', adapterRam: 256 * 1024 * 1024 })).toBe(true) + }) + + it('does not flag high VRAM as iGPU by VRAM alone', () => { + expect(isAmdIgpu({ vendor: '1002', adapterRam: 8 * 1024 * 1024 * 1024 })).toBe(false) + }) + + it('does not flag exactly 512MB as iGPU (boundary)', () => { + expect(isAmdIgpu({ vendor: '1002', adapterRam: 512 * 1024 * 1024 })).toBe(false) + }) + + it('returns false for device with no name and no VRAM info', () => { + expect(isAmdIgpu({ vendor: '1002' })).toBe(false) + }) +}) + +describe('pickGPU', () => { + const nvidia: GpuDevice = { vendor: '10DE', name: 'NVIDIA GeForce RTX 4090' } + const amdDiscrete: GpuDevice = { vendor: '1002', name: 'AMD Radeon RX 7900 XTX' } + const amdIgpu: GpuDevice = { vendor: '1002', name: 'AMD Radeon(TM) Graphics' } + const intel: GpuDevice = { vendor: '8086', name: 'Intel(R) Arc(TM) A770' } + + it('returns null for empty list', () => { + expect(pickGPU([])).toBeNull() + }) + + it('picks NVIDIA when only NVIDIA present', () => { + expect(pickGPU([nvidia])).toBe('nvidia') + }) + + it('picks AMD when only discrete AMD present', () => { + expect(pickGPU([amdDiscrete])).toBe('amd') + }) + + it('picks AMD when only AMD iGPU present (fallback)', () => { + expect(pickGPU([amdIgpu])).toBe('amd') + }) + + it('picks Intel when only Intel present', () => { + expect(pickGPU([intel])).toBe('intel') + }) + + it('picks NVIDIA over everything', () => { + expect(pickGPU([nvidia, amdDiscrete, intel])).toBe('nvidia') + expect(pickGPU([nvidia, amdIgpu, intel])).toBe('nvidia') + }) + + it('picks discrete AMD over Intel', () => { + expect(pickGPU([amdDiscrete, intel])).toBe('amd') + }) + + // THE KEY BUG FIX: AMD iGPU + Intel Arc should pick Intel + it('picks Intel over AMD iGPU (issue #342)', () => { + expect(pickGPU([amdIgpu, intel])).toBe('intel') + }) + + it('picks Intel over AMD iGPU regardless of order', () => { + expect(pickGPU([intel, amdIgpu])).toBe('intel') + }) + + it('picks Intel over AMD iGPU detected by low VRAM', () => { + const amdLowVram: GpuDevice = { vendor: '1002', adapterRam: 0 } + expect(pickGPU([amdLowVram, intel])).toBe('intel') + }) + + it('picks Intel over AMD iGPU detected by explicit discrete=false', () => { + const amdExplicitIgpu: GpuDevice = { vendor: '1002', discrete: false } + expect(pickGPU([amdExplicitIgpu, intel])).toBe('intel') + }) + + it('picks discrete AMD when both discrete AMD and Intel present', () => { + expect(pickGPU([amdDiscrete, intel])).toBe('amd') + }) + + it('handles mixed: NVIDIA + AMD iGPU + Intel', () => { + expect(pickGPU([nvidia, amdIgpu, intel])).toBe('nvidia') + }) + + it('handles AMD iGPU + discrete AMD + Intel', () => { + expect(pickGPU([amdIgpu, amdDiscrete, intel])).toBe('amd') + }) +}) diff --git a/src/main/lib/gpu.ts b/src/main/lib/gpu.ts index f226dd91..0366b9de 100644 --- a/src/main/lib/gpu.ts +++ b/src/main/lib/gpu.ts @@ -20,10 +20,81 @@ const NVIDIA_VENDOR_ID = "10DE" const AMD_VENDOR_ID = "1002" const INTEL_VENDOR_ID = "8086" -function pickGPU(hasNvidia: boolean, hasAmd: boolean, hasIntel: boolean): GpuId | null { +/** Info about a single detected GPU device. */ +export interface GpuDevice { + vendor: string // Uppercase PCI vendor ID (e.g. "10DE") + name?: string // Human-readable name (e.g. "Intel(R) Arc(TM) A770") + adapterRam?: number // Dedicated VRAM in bytes (Windows WMI only) + discrete?: boolean // Explicitly known discrete/integrated status +} + +/** + * Heuristic: returns true if an AMD GPU device looks like an integrated GPU + * (iGPU built into an AMD CPU) rather than a discrete Radeon card. + * + * Integrated AMD GPUs typically have generic names like: + * "AMD Radeon(TM) Graphics", "AMD Radeon Vega 8 Graphics" + * Discrete AMD GPUs have model numbers like: + * "AMD Radeon RX 7900 XTX", "Radeon RX 580" + */ +export function isAmdIgpu(device: GpuDevice): boolean { + // If we explicitly know it's discrete, trust that + if (device.discrete === true) return false + if (device.discrete === false) return true + + // Low or zero dedicated VRAM is a strong iGPU signal (Windows WMI) + if (device.adapterRam !== undefined && device.adapterRam < 512 * 1024 * 1024) { + return true + } + + if (!device.name) return false + const n = device.name.toLowerCase() + + // Discrete AMD GPUs contain model identifiers like "rx", "pro w", "wx", "vii", "fury" + if (/\brx\b/.test(n)) return false + if (/\bpro\s+w/i.test(n)) return false + if (/\bwx\b/.test(n)) return false + if (/\bradeon\s+vii\b/.test(n)) return false + if (/\bfury\b/.test(n)) return false + if (/\binstinct\b/.test(n)) return false + + // Generic names without a model number are iGPUs + // e.g. "AMD Radeon(TM) Graphics", "AMD Radeon Vega 8 Graphics" + if (/radeon.*graphics/i.test(n) && !/\brx\b/.test(n)) return true + + return false +} + +/** + * Pick the best GPU from a list of detected devices. + * + * Priority: NVIDIA > discrete AMD > Intel (XPU) > integrated AMD > CPU. + * When both AMD (iGPU) and Intel (discrete Arc) are present, Intel wins. + */ +export function pickGPU(devices: GpuDevice[]): GpuId | null { + let hasNvidia = false + let hasDiscreteAmd = false + let hasAmdIgpu = false + let hasIntel = false + + for (const d of devices) { + if (d.vendor === NVIDIA_VENDOR_ID) { + hasNvidia = true + } else if (d.vendor === AMD_VENDOR_ID) { + if (isAmdIgpu(d)) { + hasAmdIgpu = true + } else { + hasDiscreteAmd = true + } + } else if (d.vendor === INTEL_VENDOR_ID) { + hasIntel = true + } + } + if (hasNvidia) return "nvidia" - if (hasAmd) return "amd" + if (hasDiscreteAmd) return "amd" if (hasIntel) return "intel" + if (hasAmdIgpu) return "amd" return null } @@ -56,35 +127,44 @@ async function detectGPU(): Promise { } async function detectWindowsGPU(): Promise { - const wmiResult = await queryWmiVendorIds() + const wmiResult = await queryWmiDevices() if (wmiResult) return wmiResult if (await hasNvidiaSmi()) return "nvidia" return null } -function queryWmiVendorIds(): Promise { +/** WMI record shape returned by our PowerShell query. */ +interface WmiVideoController { + PNPDeviceID?: string + Name?: string + AdapterRAM?: number +} + +function queryWmiDevices(): Promise { return new Promise((resolve) => { execFile( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", - '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty PNPDeviceID | ConvertTo-Json -Compress'], + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_VideoController | Select-Object PNPDeviceID, Name, AdapterRAM | ConvertTo-Json -Compress'], { timeout: 10000, windowsHide: true }, (err: Error | null, stdout: string) => { if (err) return resolve(null) try { - const ids: unknown = JSON.parse(stdout) - const list: unknown[] = Array.isArray(ids) ? ids : [ids] - let hasNvidia = false, hasAmd = false, hasIntel = false - for (const id of list) { - if (typeof id !== "string") continue - const match = id.match(/ven_([0-9a-f]{4})/i) - if (!match || !match[1]) continue - const vendor = match[1].toUpperCase() - if (vendor === NVIDIA_VENDOR_ID) hasNvidia = true - else if (vendor === AMD_VENDOR_ID) hasAmd = true - else if (vendor === INTEL_VENDOR_ID) hasIntel = true + const raw: unknown = JSON.parse(stdout) + const list: WmiVideoController[] = Array.isArray(raw) ? raw : [raw as WmiVideoController] + const devices: GpuDevice[] = [] + for (const entry of list) { + const pnp = entry.PNPDeviceID + if (typeof pnp !== "string") continue + const match = pnp.match(/ven_([0-9a-f]{4})/i) + if (!match?.[1]) continue + devices.push({ + vendor: match[1].toUpperCase(), + name: typeof entry.Name === "string" ? entry.Name : undefined, + adapterRam: typeof entry.AdapterRAM === "number" ? entry.AdapterRAM : undefined, + }) } - resolve(pickGPU(hasNvidia, hasAmd, hasIntel)) + resolve(pickGPU(devices)) } catch { resolve(null) } @@ -102,46 +182,56 @@ function hasNvidiaSmi(): Promise { } async function detectLinuxGPU(): Promise { - const lspciResult = await queryLspciVendors() + const lspciResult = await queryLspciDevices() if (lspciResult) return lspciResult - const sysfsResult = querySysfsVendors() + const sysfsResult = querySysfsDevices() if (sysfsResult) return sysfsResult if (await hasNvidiaSmi()) return "nvidia" return null } -function queryLspciVendors(): Promise { +/** + * Parse lspci -nn output into GpuDevice entries. + * Each VGA/3D/Display line contains the vendor:device ID and a description + * that serves as the device name for iGPU heuristics. + */ +function queryLspciDevices(): Promise { return new Promise((resolve) => { execFile("lspci", ["-nn"], { timeout: 5000 }, (err: Error | null, stdout: string) => { if (err) return resolve(null) - let hasNvidia = false, hasAmd = false, hasIntel = false + const devices: GpuDevice[] = [] for (const line of stdout.split("\n")) { if (!/vga|3d|display/i.test(line)) continue const match = line.match(/\[([0-9a-f]{4}):[0-9a-f]{4}\]/i) - if (!match || !match[1]) continue - const vendor = match[1].toUpperCase() - if (vendor === NVIDIA_VENDOR_ID) hasNvidia = true - else if (vendor === AMD_VENDOR_ID) hasAmd = true - else if (vendor === INTEL_VENDOR_ID) hasIntel = true + if (!match?.[1]) continue + // Extract the device description (everything after the class label) + const descMatch = line.match(/(?:VGA|3D|Display)\s+(?:compatible\s+)?controller:\s*(.+?)(?:\s*\[[0-9a-f]{4}:[0-9a-f]{4}\])/i) + devices.push({ + vendor: match[1].toUpperCase(), + name: descMatch?.[1]?.trim(), + }) } - resolve(pickGPU(hasNvidia, hasAmd, hasIntel)) + resolve(pickGPU(devices)) }) }) } -function querySysfsVendors(): GpuId | null { +/** + * Read GPU info from sysfs (/sys/class/drm/cardN/device/). + * Sysfs only exposes vendor IDs — device names are not available here, + * so iGPU classification relies on the lspci fallback (which runs first). + */ +function querySysfsDevices(): GpuId | null { try { const cards = fs.readdirSync("/sys/class/drm").filter((d) => /^card\d+$/.test(d)) - let hasNvidia = false, hasAmd = false, hasIntel = false + const devices: GpuDevice[] = [] for (const card of cards) { try { const vendor = fs.readFileSync(`/sys/class/drm/${card}/device/vendor`, "utf-8").trim().replace(/^0x/i, "").toUpperCase() - if (vendor === NVIDIA_VENDOR_ID) hasNvidia = true - else if (vendor === AMD_VENDOR_ID) hasAmd = true - else if (vendor === INTEL_VENDOR_ID) hasIntel = true + devices.push({ vendor }) } catch {} } - return pickGPU(hasNvidia, hasAmd, hasIntel) + return pickGPU(devices) } catch {} return null }