Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 132 additions & 1 deletion src/main/lib/gpu.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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')
})
})
156 changes: 123 additions & 33 deletions src/main/lib/gpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -56,35 +127,44 @@ async function detectGPU(): Promise<GpuInfo | null> {
}

async function detectWindowsGPU(): Promise<GpuId | null> {
const wmiResult = await queryWmiVendorIds()
const wmiResult = await queryWmiDevices()
if (wmiResult) return wmiResult
if (await hasNvidiaSmi()) return "nvidia"
return null
}

function queryWmiVendorIds(): Promise<GpuId | null> {
/** WMI record shape returned by our PowerShell query. */
interface WmiVideoController {
PNPDeviceID?: string
Name?: string
AdapterRAM?: number
}

function queryWmiDevices(): Promise<GpuId | null> {
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)
}
Expand All @@ -102,46 +182,56 @@ function hasNvidiaSmi(): Promise<boolean> {
}

async function detectLinuxGPU(): Promise<GpuId | null> {
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<GpuId | null> {
/**
* 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<GpuId | null> {
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
}
Expand Down
Loading