v0.23.2 bfd5eb4

hardware_survey.go RawHistoryFolder

1package env
2
3import (
4	"bufio"
5	"context"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strconv"
10	"strings"
11	"sync"
12
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/logging"
15)
16
17const (
18	// kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB)
19	kibibyte = 1024
20)
21
22// HardwareSurveyor implements port.HardwareSurveyor for Linux systems.
23// It is safe for concurrent use after creation.
24type HardwareSurveyor struct {
25	once   sync.Once
26	cached port.HardwareInfo
27}
28
29// NewHardwareSurveyor creates a new hardware surveyor.
30func NewHardwareSurveyor() *HardwareSurveyor {
31	return &HardwareSurveyor{}
32}
33
34// Survey detects and returns hardware information.
35// Results are cached after the first call. Safe for concurrent use.
36func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
37	s.once.Do(func() {
38		s.cached = s.doSurvey(ctx)
39	})
40	return s.cached
41}
42
43// doSurvey performs the actual hardware detection.
44func (s *HardwareSurveyor) doSurvey(ctx context.Context) port.HardwareInfo {
45	log := logging.FromContext(ctx)
46
47	info := port.HardwareInfo{
48		CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads
49	}
50
51	// Detect physical CPU cores
52	info.CPUCores = s.detectCPUCores(ctx)
53	if info.CPUCores == 0 {
54		info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown
55	}
56
57	// Detect RAM
58	info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx)
59
60	// Detect GPU
61	info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx)
62
63	log.Debug().
64		Int("cpu_cores", info.CPUCores).
65		Int("cpu_threads", info.CPUThreads).
66		Uint64("total_ram_mb", info.TotalRAM/(1024*1024)).
67		Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)).
68		Str("gpu_vendor", string(info.GPUVendor)).
69		Str("gpu_name", info.GPUName).
70		Uint64("vram_mb", info.VRAM/(1024*1024)).
71		Msg("hardware survey completed")
72
73	return info
74}
75
76// detectCPUCores returns the number of physical CPU cores.
77// Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores.
78func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int {
79	log := logging.FromContext(ctx)
80
81	// Method 1: Count unique core_ids across all CPUs
82	coreIDs := make(map[string]struct{})
83	cpuPath := "/sys/devices/system/cpu"
84
85	entries, err := os.ReadDir(cpuPath)
86	if err != nil {
87		log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu")
88		return 0
89	}
90
91	for _, entry := range entries {
92		if !strings.HasPrefix(entry.Name(), "cpu") {
93			continue
94		}
95		// Skip non-numeric cpu entries (like cpufreq, cpuidle)
96		cpuNum := strings.TrimPrefix(entry.Name(), "cpu")
97		if _, err := strconv.Atoi(cpuNum); err != nil {
98			continue
99		}
100
101		coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id")
102		data, err := os.ReadFile(coreIDPath)
103		if err != nil {
104			continue
105		}
106		coreID := strings.TrimSpace(string(data))
107		coreIDs[coreID] = struct{}{}
108	}
109
110	if len(coreIDs) > 0 {
111		return len(coreIDs)
112	}
113
114	// Method 2: Parse /proc/cpuinfo for "cpu cores" field
115	return s.detectCPUCoresFromProcCPUInfo(ctx)
116}
117
118// detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count.
119func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int {
120	log := logging.FromContext(ctx)
121
122	file, err := os.Open("/proc/cpuinfo")
123	if err != nil {
124		log.Debug().Err(err).Msg("cannot read /proc/cpuinfo")
125		return 0
126	}
127	defer func() { _ = file.Close() }()
128
129	scanner := bufio.NewScanner(file)
130	for scanner.Scan() {
131		line := scanner.Text()
132		if strings.HasPrefix(line, "cpu cores") {
133			parts := strings.Split(line, ":")
134			if len(parts) == 2 {
135				cores, err := strconv.Atoi(strings.TrimSpace(parts[1]))
136				if err == nil {
137					return cores
138				}
139			}
140		}
141	}
142	return 0
143}
144
145// detectMemory reads memory info from /proc/meminfo.
146func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) {
147	log := logging.FromContext(ctx)
148
149	file, err := os.Open("/proc/meminfo")
150	if err != nil {
151		log.Debug().Err(err).Msg("cannot read /proc/meminfo")
152		return 0, 0
153	}
154	defer func() { _ = file.Close() }()
155
156	scanner := bufio.NewScanner(file)
157	for scanner.Scan() {
158		line := scanner.Text()
159		if strings.HasPrefix(line, "MemTotal:") {
160			total = s.parseMemInfoLine(line)
161		} else if strings.HasPrefix(line, "MemAvailable:") {
162			available = s.parseMemInfoLine(line)
163		}
164		if total > 0 && available > 0 {
165			break
166		}
167	}
168	return total, available
169}
170
171// parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB".
172func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 {
173	parts := strings.Fields(line)
174	if len(parts) < 2 {
175		return 0
176	}
177	value, err := strconv.ParseUint(parts[1], 10, 64)
178	if err != nil {
179		return 0
180	}
181	// /proc/meminfo reports in kB (actually KiB)
182	return value * kibibyte
183}
184
185// detectGPU detects GPU vendor, name, and VRAM from DRM subsystem.
186// When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM.
187func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) {
188	log := logging.FromContext(ctx)
189
190	const drmBase = "/sys/class/drm"
191	cards := []string{"card0", "card1", "card2", "card3"}
192
193	var bestVendor port.GPUVendor
194	var bestName string
195	var bestVRAM uint64
196	var bestCard string
197
198	for _, card := range cards {
199		cardPath := filepath.Join(drmBase, card)
200		if _, err := os.Stat(cardPath); os.IsNotExist(err) {
201			continue
202		}
203
204		vendor := s.detectGPUVendor(cardPath)
205		if vendor == port.GPUVendorUnknown {
206			continue
207		}
208
209		name := s.detectGPUName(ctx, cardPath)
210		vram := s.detectVRAM(ctx, cardPath)
211
212		log.Debug().
213			Str("card", card).
214			Str("vendor", string(vendor)).
215			Str("name", name).
216			Uint64("vram_mb", vram/(1024*1024)).
217			Msg("found GPU")
218
219		// Prefer GPU with more VRAM (discrete > integrated)
220		if vram > bestVRAM {
221			bestVendor = vendor
222			bestName = name
223			bestVRAM = vram
224			bestCard = card
225		}
226	}
227
228	if bestVendor != port.GPUVendorUnknown {
229		log.Debug().
230			Str("card", bestCard).
231			Str("vendor", string(bestVendor)).
232			Uint64("vram_mb", bestVRAM/(1024*1024)).
233			Msg("selected GPU (highest VRAM)")
234	}
235
236	return bestVendor, bestName, bestVRAM
237}
238
239// detectGPUVendor reads the PCI vendor ID.
240func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor {
241	vendorPath := filepath.Join(cardPath, "device", "vendor")
242	data, err := os.ReadFile(vendorPath)
243	if err != nil {
244		return port.GPUVendorUnknown
245	}
246
247	vendorID := strings.TrimSpace(string(data))
248	switch vendorID {
249	case "0x1002":
250		return port.GPUVendorAMD
251	case "0x8086":
252		return port.GPUVendorIntel
253	case "0x10de":
254		return port.GPUVendorNVIDIA
255	default:
256		return port.GPUVendorUnknown
257	}
258}
259
260// detectGPUName reads the GPU product name from various sources.
261func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string {
262	log := logging.FromContext(ctx)
263
264	// Try reading from device/label first (some drivers provide this)
265	labelPath := filepath.Join(cardPath, "device", "label")
266	if data, err := os.ReadFile(labelPath); err == nil {
267		name := strings.TrimSpace(string(data))
268		if name != "" {
269			return name
270		}
271	}
272
273	// Try reading product name from device subsystem (uevent)
274	ueventPath := filepath.Join(cardPath, "device", "uevent")
275	if file, err := os.Open(ueventPath); err == nil {
276		scanner := bufio.NewScanner(file)
277		for scanner.Scan() {
278			line := scanner.Text()
279			if strings.HasPrefix(line, "PCI_SLOT_NAME=") {
280				// This gives us the PCI address, useful for debugging
281				log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot")
282			}
283		}
284		_ = file.Close()
285	}
286
287	// For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually)
288	// For now, return empty and rely on vendor detection
289	return ""
290}
291
292// detectVRAM reads VRAM size from DRM memory info.
293func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 {
294	log := logging.FromContext(ctx)
295
296	// Method 1: AMD uses mem_info_vram_total in device directory
297	amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total")
298	if data, err := os.ReadFile(amdVRAMPath); err == nil {
299		vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
300		if err == nil && vram > 0 {
301			log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total")
302			return vram
303		}
304	}
305
306	// Method 2: Try reading from resource file (PCI BAR sizes)
307	// The first non-zero memory region is usually VRAM
308	if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 {
309		return vram
310	}
311
312	// Method 3: For Intel integrated, check i915 specific paths
313	// Intel iGPU uses system RAM, so VRAM is usually 0 or shared
314	return 0
315}
316
317// detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes.
318func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 {
319	log := logging.FromContext(ctx)
320
321	resourcePath := filepath.Join(cardPath, "device", "resource")
322	file, err := os.Open(resourcePath)
323	if err != nil {
324		return 0
325	}
326	defer func() { _ = file.Close() }()
327
328	const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions
329	scanner := bufio.NewScanner(file)
330	for scanner.Scan() {
331		line := scanner.Text()
332		parts := strings.Fields(line)
333		if len(parts) >= 2 {
334			start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64)
335			end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64)
336			if err1 == nil && err2 == nil && start > 0 && end > start {
337				size := end - start + 1
338				// VRAM is typically > 256MB, skip smaller regions (I/O, config)
339				if size > minVRAMSize {
340					log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource")
341					return size
342				}
343			}
344		}
345	}
346	return 0
347}
348
349var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close