Skip to content

Commit 1551f59

Browse files
committed
fix: Intel Arc not showing on multi-card hosts (Proxmox etc.)
card0 on a passthrough VM is usually a VirtIO adapter, not the Arc GPU. intel_gpu_top without -d always grabbed the first card, so the real GPU was never queried. Also the stats function was a stub that never actually called intel_gpu_top. Now it targets each Intel card by PCI vendor ID and parses the JSON output for real VRAM figures. Added a sysfs fallback so the card shows up even when intel_gpu_top isn't installed (ARM, minimal images). Fixes #999
1 parent 1f8d72f commit 1551f59

1 file changed

Lines changed: 178 additions & 10 deletions

File tree

backend/internal/api/ws_handler.go

Lines changed: 178 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,7 +1636,21 @@ func (h *WebSocketHandler) detectGPUs(ctx context.Context) error {
16361636
slog.InfoContext(ctx, "Using configured GPU type", "type", "intel")
16371637
return nil
16381638
}
1639-
return fmt.Errorf("intel_gpu_top not found but GPU_TYPE set to intel")
1639+
// intel_gpu_top not in PATH — fall back to sysfs vendor detection so that
1640+
// the GPU still shows up in the dashboard (e.g. on ARM builds or when the
1641+
// package isn't installed).
1642+
if hasIntelGPUInternal() {
1643+
h.gpuDetectionCache.Lock()
1644+
h.gpuDetectionCache.detected = true
1645+
h.gpuDetectionCache.gpuType = "intel"
1646+
h.gpuDetectionCache.toolPath = ""
1647+
h.gpuDetectionCache.timestamp = time.Now()
1648+
h.gpuDetectionCache.Unlock()
1649+
h.detectionDone = true
1650+
slog.InfoContext(ctx, "Using configured GPU type via sysfs (intel_gpu_top not found)", "type", "intel")
1651+
return nil
1652+
}
1653+
return fmt.Errorf("intel_gpu_top not found and no Intel GPU detected in sysfs, but GPU_TYPE set to intel")
16401654

16411655
default:
16421656
slog.WarnContext(ctx, "Invalid GPU_TYPE specified, falling back to auto-detection", "gpu_type", h.gpuType)
@@ -1679,6 +1693,20 @@ func (h *WebSocketHandler) detectGPUs(ctx context.Context) error {
16791693
return nil
16801694
}
16811695

1696+
// Last resort: detect Intel GPU via sysfs vendor ID so it shows up even when
1697+
// intel_gpu_top is absent (e.g. ARM builds, minimal containers).
1698+
if hasIntelGPUInternal() {
1699+
h.gpuDetectionCache.Lock()
1700+
h.gpuDetectionCache.detected = true
1701+
h.gpuDetectionCache.gpuType = "intel"
1702+
h.gpuDetectionCache.toolPath = ""
1703+
h.gpuDetectionCache.timestamp = time.Now()
1704+
h.gpuDetectionCache.Unlock()
1705+
h.detectionDone = true
1706+
slog.InfoContext(ctx, "Intel GPU detected", "method", "sysfs")
1707+
return nil
1708+
}
1709+
16821710
h.detectionDone = true
16831711
return fmt.Errorf("no supported GPU found")
16841712
}
@@ -1844,17 +1872,157 @@ func hasAMDGPUInternal() bool {
18441872
return false
18451873
}
18461874

1847-
// getIntelStats collects Intel GPU statistics using intel_gpu_top
1875+
// intelGPUTopOutput is the subset of intel_gpu_top JSON we care about.
1876+
// The memory block is only present on discrete GPUs (Intel Arc and later).
1877+
type intelGPUTopOutput struct {
1878+
Memory *struct {
1879+
Unit string `json:"unit"`
1880+
Local *struct {
1881+
Total float64 `json:"total"`
1882+
Free float64 `json:"free"`
1883+
} `json:"local"`
1884+
} `json:"memory"`
1885+
}
1886+
1887+
// findIntelDRICards returns the /dev/dri/cardN paths for cards whose PCI vendor
1888+
// is Intel (0x8086), as reported by the DRM sysfs. This handles Proxmox and
1889+
// similar setups where card0 is a VirtIO display adapter and the real GPU is
1890+
// card1 (or higher).
1891+
func findIntelDRICards() []string {
1892+
entries, err := os.ReadDir(amdGPUSysfsPath)
1893+
if err != nil {
1894+
return nil
1895+
}
1896+
var cards []string
1897+
for _, entry := range entries {
1898+
name := entry.Name()
1899+
if !strings.HasPrefix(name, "card") || strings.Contains(name, "-") {
1900+
continue
1901+
}
1902+
vendorPath := fmt.Sprintf("%s/%s/device/vendor", amdGPUSysfsPath, name)
1903+
data, err := os.ReadFile(vendorPath)
1904+
if err != nil {
1905+
continue
1906+
}
1907+
if strings.TrimSpace(string(data)) == "0x8086" {
1908+
cards = append(cards, fmt.Sprintf("/dev/dri/%s", name))
1909+
}
1910+
}
1911+
return cards
1912+
}
1913+
1914+
// hasIntelGPUInternal reports whether at least one Intel GPU is present via DRM sysfs.
1915+
func hasIntelGPUInternal() bool {
1916+
return len(findIntelDRICards()) > 0
1917+
}
1918+
1919+
// getIntelStats collects Intel GPU statistics.
1920+
//
1921+
// For each Intel DRI card it runs `intel_gpu_top -d drm:<path>` to get per-GPU
1922+
// stats. This correctly handles multi-card hosts (e.g. Proxmox with a VirtIO
1923+
// display adapter on card0 and an Arc GPU on card1) by targeting only cards
1924+
// with vendor ID 0x8086 rather than relying on the tool's default device.
1925+
//
1926+
// When intel_gpu_top is not available (ARM builds, minimal containers) the
1927+
// function falls back to sysfs-only enumeration so the GPU still appears in
1928+
// the dashboard, albeit without memory statistics.
18481929
func (h *WebSocketHandler) getIntelStats(ctx context.Context) ([]systemtypes.GPUStats, error) {
1849-
stats := []systemtypes.GPUStats{
1850-
{
1851-
Name: "Intel GPU",
1852-
Index: 0,
1853-
MemoryUsed: 0,
1854-
MemoryTotal: 0,
1855-
},
1930+
h.gpuDetectionCache.RLock()
1931+
toolPath := h.gpuDetectionCache.toolPath
1932+
h.gpuDetectionCache.RUnlock()
1933+
1934+
intelCards := findIntelDRICards()
1935+
if len(intelCards) == 0 {
1936+
return nil, fmt.Errorf("no Intel GPU found in sysfs")
1937+
}
1938+
1939+
var stats []systemtypes.GPUStats
1940+
for i, cardPath := range intelCards {
1941+
gpuName := h.intelGPUName(cardPath)
1942+
entry := systemtypes.GPUStats{
1943+
Name: gpuName,
1944+
Index: i,
1945+
}
1946+
1947+
if toolPath != "" {
1948+
if mem, err := h.intelGPUTopMemory(ctx, toolPath, cardPath); err == nil {
1949+
entry.MemoryUsed = mem.used
1950+
entry.MemoryTotal = mem.total
1951+
} else {
1952+
slog.DebugContext(ctx, "intel_gpu_top memory query failed", "card", cardPath, "error", err)
1953+
}
1954+
}
1955+
1956+
stats = append(stats, entry)
18561957
}
18571958

1858-
slog.DebugContext(ctx, "Intel GPU detected but detailed stats not yet implemented")
1959+
slog.DebugContext(ctx, "Collected Intel GPU stats", "gpu_count", len(stats))
18591960
return stats, nil
18601961
}
1962+
1963+
type intelMemStats struct{ used, total float64 }
1964+
1965+
// intelGPUTopMemory runs intel_gpu_top for a single card and returns memory stats.
1966+
func (h *WebSocketHandler) intelGPUTopMemory(ctx context.Context, toolPath, cardPath string) (intelMemStats, error) {
1967+
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
1968+
defer cancel()
1969+
1970+
// -d selects the specific DRI device, -J outputs JSON, -s sets the sample
1971+
// interval in milliseconds, -c 1 exits after one sample.
1972+
cmd := exec.CommandContext(ctx, toolPath,
1973+
"-d", fmt.Sprintf("drm:%s", cardPath),
1974+
"-J", "-s", "100", "-c", "1")
1975+
out, err := cmd.Output()
1976+
if err != nil {
1977+
return intelMemStats{}, fmt.Errorf("intel_gpu_top: %w", err)
1978+
}
1979+
1980+
var result intelGPUTopOutput
1981+
// intel_gpu_top may wrap the single record in an array or emit it bare.
1982+
data := bytes.TrimSpace(out)
1983+
if len(data) > 0 && data[0] == '[' {
1984+
var arr []intelGPUTopOutput
1985+
if err := json.Unmarshal(data, &arr); err != nil || len(arr) == 0 {
1986+
return intelMemStats{}, fmt.Errorf("parse intel_gpu_top array: %w", err)
1987+
}
1988+
result = arr[0]
1989+
} else {
1990+
if err := json.Unmarshal(data, &result); err != nil {
1991+
return intelMemStats{}, fmt.Errorf("parse intel_gpu_top object: %w", err)
1992+
}
1993+
}
1994+
1995+
if result.Memory == nil || result.Memory.Local == nil {
1996+
// Integrated GPU or older tool version — no discrete memory info.
1997+
return intelMemStats{}, fmt.Errorf("no local memory info in intel_gpu_top output")
1998+
}
1999+
2000+
unit := strings.ToLower(strings.TrimSpace(result.Memory.Unit))
2001+
var scale float64
2002+
switch unit {
2003+
case "mib":
2004+
scale = 1024 * 1024
2005+
case "gib":
2006+
scale = 1024 * 1024 * 1024
2007+
default:
2008+
scale = 1 // assume bytes
2009+
}
2010+
2011+
total := result.Memory.Local.Total * scale
2012+
used := (result.Memory.Local.Total - result.Memory.Local.Free) * scale
2013+
return intelMemStats{used: used, total: total}, nil
2014+
}
2015+
2016+
// intelGPUName returns a human-readable label for an Intel DRI card.
2017+
// It prefers the DRM card's "label" sysfs attribute, then falls back to the
2018+
// card device name (e.g. "Intel GPU (card1)").
2019+
func (h *WebSocketHandler) intelGPUName(cardPath string) string {
2020+
cardName := strings.TrimPrefix(cardPath, "/dev/dri/")
2021+
labelPath := fmt.Sprintf("%s/%s/device/label", amdGPUSysfsPath, cardName)
2022+
if data, err := os.ReadFile(labelPath); err == nil {
2023+
if label := strings.TrimSpace(string(data)); label != "" {
2024+
return label
2025+
}
2026+
}
2027+
return fmt.Sprintf("Intel GPU (%s)", cardName)
2028+
}

0 commit comments

Comments
 (0)