@@ -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.
18481929func (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