detached 80b95cf

feat(perf): scale profiles using hardware survey

commit
80b95cffde066966cdbdb76acf58789ea28ac698
author
brice · · 2025-12-28 20:12 UTC
committer
brice · · 2025-12-29 17:28 UTC
parent
043cd76
signature
unsigned
7 files · +859 -33
M .mockery.yaml +1 -0
@@ -20,6 +20,7 @@ packages:
2020 WebViewPool: {}
2121 MemoryPressureApplier: {}
2222 WebKitContextProvider: {}
23+ HardwareSurveyor: {}
2324 github.com/bnema/dumber/internal/domain/repository:
2425 config:
2526 dir: '{{.InterfaceDir}}/mocks'
@@ -20,6 +20,7 @@ packages:
20 WebViewPool: {}20 WebViewPool: {}
21 MemoryPressureApplier: {}21 MemoryPressureApplier: {}
22 WebKitContextProvider: {}22 WebKitContextProvider: {}
23+ HardwareSurveyor: {}
23 github.com/bnema/dumber/internal/domain/repository:24 github.com/bnema/dumber/internal/domain/repository:
24 config:25 config:
25 dir: '{{.InterfaceDir}}/mocks'26 dir: '{{.InterfaceDir}}/mocks'
A internal/application/port/hardware.go +59 -0
@@ -0,0 +1,59 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// HardwareInfo contains detected system hardware specifications.
7+// Used for performance profile auto-tuning.
8+type HardwareInfo struct {
9+ // CPU information
10+ CPUCores int // Number of physical CPU cores
11+ CPUThreads int // Number of logical CPU threads (with hyperthreading)
12+
13+ // Memory information (in bytes)
14+ TotalRAM uint64 // Total system RAM
15+ AvailableRAM uint64 // Currently available RAM
16+
17+ // GPU information
18+ GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown
19+ GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT")
20+ VRAM uint64 // Video memory in bytes (0 if unknown)
21+}
22+
23+// CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback.
24+func (h HardwareInfo) CPUCoresOrDefault(fallback int) int {
25+ if h.CPUCores > 0 {
26+ return h.CPUCores
27+ }
28+ return fallback
29+}
30+
31+// CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback.
32+func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int {
33+ if h.CPUThreads > 0 {
34+ return h.CPUThreads
35+ }
36+ return fallback
37+}
38+
39+// TotalRAMMB returns total RAM in megabytes.
40+func (h HardwareInfo) TotalRAMMB() int {
41+ return int(h.TotalRAM / (1024 * 1024))
42+}
43+
44+// VRAMMB returns VRAM in megabytes.
45+func (h HardwareInfo) VRAMMB() int {
46+ return int(h.VRAM / (1024 * 1024))
47+}
48+
49+// HasDedicatedGPU returns true if a discrete GPU with VRAM was detected.
50+func (h HardwareInfo) HasDedicatedGPU() bool {
51+ return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown
52+}
53+
54+// HardwareSurveyor detects system hardware specifications.
55+type HardwareSurveyor interface {
56+ // Survey detects and returns hardware information.
57+ // This should be called once at startup and cached.
58+ Survey(ctx context.Context) HardwareInfo
59+}
@@ -0,0 +1,59 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// HardwareInfo contains detected system hardware specifications.
7+// Used for performance profile auto-tuning.
8+type HardwareInfo struct {
9+ // CPU information
10+ CPUCores int // Number of physical CPU cores
11+ CPUThreads int // Number of logical CPU threads (with hyperthreading)
12+
13+ // Memory information (in bytes)
14+ TotalRAM uint64 // Total system RAM
15+ AvailableRAM uint64 // Currently available RAM
16+
17+ // GPU information
18+ GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown
19+ GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT")
20+ VRAM uint64 // Video memory in bytes (0 if unknown)
21+}
22+
23+// CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback.
24+func (h HardwareInfo) CPUCoresOrDefault(fallback int) int {
25+ if h.CPUCores > 0 {
26+ return h.CPUCores
27+ }
28+ return fallback
29+}
30+
31+// CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback.
32+func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int {
33+ if h.CPUThreads > 0 {
34+ return h.CPUThreads
35+ }
36+ return fallback
37+}
38+
39+// TotalRAMMB returns total RAM in megabytes.
40+func (h HardwareInfo) TotalRAMMB() int {
41+ return int(h.TotalRAM / (1024 * 1024))
42+}
43+
44+// VRAMMB returns VRAM in megabytes.
45+func (h HardwareInfo) VRAMMB() int {
46+ return int(h.VRAM / (1024 * 1024))
47+}
48+
49+// HasDedicatedGPU returns true if a discrete GPU with VRAM was detected.
50+func (h HardwareInfo) HasDedicatedGPU() bool {
51+ return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown
52+}
53+
54+// HardwareSurveyor detects system hardware specifications.
55+type HardwareSurveyor interface {
56+ // Survey detects and returns hardware information.
57+ // This should be called once at startup and cached.
58+ Survey(ctx context.Context) HardwareInfo
59+}
M internal/application/port/mocks/mocks.go +78 -0
@@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
638638 return _c
639639 }
640640
641+// NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
642+// The first argument is typically a *testing.T value.
643+func NewMockHardwareSurveyor(t interface {
644+ mock.TestingT
645+ Cleanup(func())
646+}) *MockHardwareSurveyor {
647+ mock := &MockHardwareSurveyor{}
648+ mock.Mock.Test(t)
649+
650+ t.Cleanup(func() { mock.AssertExpectations(t) })
651+
652+ return mock
653+}
654+
655+// MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type
656+type MockHardwareSurveyor struct {
657+ mock.Mock
658+}
659+
660+type MockHardwareSurveyor_Expecter struct {
661+ mock *mock.Mock
662+}
663+
664+func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter {
665+ return &MockHardwareSurveyor_Expecter{mock: &_m.Mock}
666+}
667+
668+// Survey provides a mock function for the type MockHardwareSurveyor
669+func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
670+ ret := _mock.Called(ctx)
671+
672+ if len(ret) == 0 {
673+ panic("no return value specified for Survey")
674+ }
675+
676+ var r0 port.HardwareInfo
677+ if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok {
678+ r0 = returnFunc(ctx)
679+ } else {
680+ r0 = ret.Get(0).(port.HardwareInfo)
681+ }
682+ return r0
683+}
684+
685+// MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey'
686+type MockHardwareSurveyor_Survey_Call struct {
687+ *mock.Call
688+}
689+
690+// Survey is a helper method to define mock.On call
691+// - ctx context.Context
692+func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call {
693+ return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)}
694+}
695+
696+func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call {
697+ _c.Call.Run(func(args mock.Arguments) {
698+ var arg0 context.Context
699+ if args[0] != nil {
700+ arg0 = args[0].(context.Context)
701+ }
702+ run(
703+ arg0,
704+ )
705+ })
706+ return _c
707+}
708+
709+func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
710+ _c.Call.Return(hardwareInfo)
711+ return _c
712+}
713+
714+func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
715+ _c.Call.Return(run)
716+ return _c
717+}
718+
641719 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
642720 // The first argument is typically a *testing.T value.
643721 func NewMockSessionLogger(t interface {
@@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
638 return _c638 return _c
639 }639 }
640 640
641+// NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
642+// The first argument is typically a *testing.T value.
643+func NewMockHardwareSurveyor(t interface {
644+ mock.TestingT
645+ Cleanup(func())
646+}) *MockHardwareSurveyor {
647+ mock := &MockHardwareSurveyor{}
648+ mock.Mock.Test(t)
649+
650+ t.Cleanup(func() { mock.AssertExpectations(t) })
651+
652+ return mock
653+}
654+
655+// MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type
656+type MockHardwareSurveyor struct {
657+ mock.Mock
658+}
659+
660+type MockHardwareSurveyor_Expecter struct {
661+ mock *mock.Mock
662+}
663+
664+func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter {
665+ return &MockHardwareSurveyor_Expecter{mock: &_m.Mock}
666+}
667+
668+// Survey provides a mock function for the type MockHardwareSurveyor
669+func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
670+ ret := _mock.Called(ctx)
671+
672+ if len(ret) == 0 {
673+ panic("no return value specified for Survey")
674+ }
675+
676+ var r0 port.HardwareInfo
677+ if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok {
678+ r0 = returnFunc(ctx)
679+ } else {
680+ r0 = ret.Get(0).(port.HardwareInfo)
681+ }
682+ return r0
683+}
684+
685+// MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey'
686+type MockHardwareSurveyor_Survey_Call struct {
687+ *mock.Call
688+}
689+
690+// Survey is a helper method to define mock.On call
691+// - ctx context.Context
692+func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call {
693+ return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)}
694+}
695+
696+func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call {
697+ _c.Call.Run(func(args mock.Arguments) {
698+ var arg0 context.Context
699+ if args[0] != nil {
700+ arg0 = args[0].(context.Context)
701+ }
702+ run(
703+ arg0,
704+ )
705+ })
706+ return _c
707+}
708+
709+func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
710+ _c.Call.Return(hardwareInfo)
711+ return _c
712+}
713+
714+func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
715+ _c.Call.Return(run)
716+ return _c
717+}
718+
641 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.719 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
642 // The first argument is typically a *testing.T value.720 // The first argument is typically a *testing.T value.
643 func NewMockSessionLogger(t interface {721 func NewMockSessionLogger(t interface {
M internal/bootstrap/webkit_stack.go +12 -1
@@ -32,8 +32,19 @@ func BuildWebKitStack(
3232 themeManager *theme.Manager,
3333 logger zerolog.Logger,
3434 ) WebKitStack {
35+ // Detect hardware for performance profile scaling
36+ hwSurveyor := env.NewHardwareSurveyor()
37+ hwInfo := hwSurveyor.Survey(ctx)
38+ logger.Info().
39+ Int("cpu_cores", hwInfo.CPUCores).
40+ Int("cpu_threads", hwInfo.CPUThreads).
41+ Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)).
42+ Str("gpu_vendor", string(hwInfo.GPUVendor)).
43+ Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
44+ Msg("hardware survey completed")
45+
3546 // Resolve performance profile to get actual settings
36- perfSettings := config.ResolvePerformanceProfile(&cfg.Performance)
47+ perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
3748 logger.Info().
3849 Str("profile", string(cfg.Performance.Profile)).
3950 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
@@ -32,8 +32,19 @@ func BuildWebKitStack(
32 themeManager *theme.Manager,32 themeManager *theme.Manager,
33 logger zerolog.Logger,33 logger zerolog.Logger,
34 ) WebKitStack {34 ) WebKitStack {
35+ // Detect hardware for performance profile scaling
36+ hwSurveyor := env.NewHardwareSurveyor()
37+ hwInfo := hwSurveyor.Survey(ctx)
38+ logger.Info().
39+ Int("cpu_cores", hwInfo.CPUCores).
40+ Int("cpu_threads", hwInfo.CPUThreads).
41+ Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)).
42+ Str("gpu_vendor", string(hwInfo.GPUVendor)).
43+ Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
44+ Msg("hardware survey completed")
45+
35 // Resolve performance profile to get actual settings46 // Resolve performance profile to get actual settings
36- perfSettings := config.ResolvePerformanceProfile(&cfg.Performance)47+ perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
37 logger.Info().48 logger.Info().
38 Str("profile", string(cfg.Performance.Profile)).49 Str("profile", string(cfg.Performance.Profile)).
39 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).50 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
M internal/infrastructure/config/profile.go +155 -20
@@ -1,11 +1,50 @@
11 package config
22
3-import "runtime"
3+import (
4+ "runtime"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
48
59 const (
610 // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads
711 // supported by WebKitGTK (0-8 range).
812 maxSkiaCPUThreads = 8
13+
14+ // Memory tier thresholds (in bytes)
15+ ramTierLow = 8 * 1024 * 1024 * 1024 // 8 GB
16+ ramTierMedium = 16 * 1024 * 1024 * 1024 // 16 GB
17+ ramTierHigh = 32 * 1024 * 1024 * 1024 // 32 GB
18+
19+ // VRAM tier thresholds (in bytes)
20+ vramTierLow = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated)
21+ vramTierMid = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete)
22+ vramTierHigh = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete)
23+
24+ // GPU thread scaling values
25+ gpuThreadsHigh = 8 // For 16GB+ VRAM
26+ gpuThreadsMid = 6 // For 8-16GB VRAM
27+ gpuThreadsLow = 4 // For 4-8GB VRAM
28+ gpuThreadsMin = 2 // For <4GB VRAM or unknown
29+
30+ // Memory limit values (in MB)
31+ webMemHigh = 4096 // For 32GB+ RAM
32+ webMemMedium = 3072 // For 16-32GB RAM
33+ webMemLow = 2048 // For 8-16GB RAM
34+ webMemMin = 1024 // For <8GB RAM
35+ netMemHigh = 1024 // For 32GB+ RAM
36+ netMemMedium = 768 // For 16-32GB RAM
37+ netMemLow = 512 // For 8-16GB RAM
38+ netMemMin = 256 // For <8GB RAM
39+ webMemFallback = 2048 // When RAM unknown
40+ netMemFallback = 512 // When RAM unknown
41+
42+ // WebView pool prewarm counts
43+ poolPrewarmHigh = 12 // For 32GB+ RAM
44+ poolPrewarmMedium = 8 // For 16-32GB RAM
45+ poolPrewarmLow = 6 // For 8-16GB RAM
46+ poolPrewarmMin = 4 // For <8GB RAM
47+ poolPrewarmFallback = 8 // When RAM unknown
948 )
1049
1150 // ResolvedPerformanceSettings contains the computed performance settings
@@ -36,13 +75,15 @@ type ResolvedPerformanceSettings struct {
3675
3776 // ResolvePerformanceProfile computes the actual performance settings based on
3877 // the selected profile. For ProfileCustom, it returns the user's configured values.
39-// For other profiles, it computes appropriate values.
40-func ResolvePerformanceProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {
78+// For other profiles, it computes appropriate values based on detected hardware.
79+//
80+// The hw parameter is optional - if nil, fallback values are used.
81+func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings {
4182 switch cfg.Profile {
4283 case ProfileLite:
43- return resolveLiteProfile()
84+ return resolveLiteProfile(hw)
4485 case ProfileMax:
45- return resolveMaxProfile()
86+ return resolveMaxProfile(hw)
4687 case ProfileCustom:
4788 return resolveCustomProfile(cfg)
4889 default: // ProfileDefault or empty
@@ -75,9 +116,16 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
75116 }
76117
77118 // resolveLiteProfile returns settings optimized for low-RAM systems.
78-func resolveLiteProfile() ResolvedPerformanceSettings {
119+// Uses hardware info to scale CPU threads if available.
120+func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
121+ // Use 2 CPU threads, or scale down on very low-core systems
122+ cpuThreads := 2
123+ if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 {
124+ cpuThreads = 1
125+ }
126+
79127 return ResolvedPerformanceSettings{
80- SkiaCPUPaintingThreads: 2,
128+ SkiaCPUPaintingThreads: cpuThreads,
81129 SkiaGPUPaintingThreads: -1, // unset
82130 SkiaEnableCPURendering: false,
83131
@@ -98,34 +146,121 @@ func resolveLiteProfile() ResolvedPerformanceSettings {
98146 }
99147
100148 // resolveMaxProfile returns settings optimized for maximum responsiveness.
101-func resolveMaxProfile() ResolvedPerformanceSettings {
102- // Use half of available CPUs, minimum 4, maximum 8 (WebKitGTK Skia limit)
103- cpuThreads := runtime.NumCPU() / 2
104- if cpuThreads < 4 {
105- cpuThreads = 4
106- }
107- if cpuThreads > maxSkiaCPUThreads {
108- cpuThreads = maxSkiaCPUThreads
109- }
149+// Scales values based on detected hardware capabilities.
150+func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
151+ // CPU threads: use physical cores (not threads), capped at WebKitGTK limit
152+ cpuThreads := computeMaxCPUThreads(hw)
153+
154+ // GPU threads: scale based on VRAM
155+ gpuThreads := computeMaxGPUThreads(hw)
156+
157+ // Memory limits: scale based on system RAM
158+ webMemMB, netMemMB := computeMaxMemoryLimits(hw)
159+
160+ // WebView pool: scale based on RAM
161+ poolPrewarm := computeMaxPoolPrewarm(hw)
110162
111163 return ResolvedPerformanceSettings{
112164 SkiaCPUPaintingThreads: cpuThreads,
113- SkiaGPUPaintingThreads: 2,
165+ SkiaGPUPaintingThreads: gpuThreads,
114166 SkiaEnableCPURendering: false,
115167
116- WebProcessMemoryLimitMB: 2048,
168+ WebProcessMemoryLimitMB: webMemMB,
117169 WebProcessMemoryPollIntervalSec: 0, // use WebKit default
118170 WebProcessMemoryConservativeThreshold: 0.5,
119171 WebProcessMemoryStrictThreshold: 0.7,
120172 WebProcessMemoryKillThreshold: -1, // never kill
121173
122- NetworkProcessMemoryLimitMB: 512,
174+ NetworkProcessMemoryLimitMB: netMemMB,
123175 NetworkProcessMemoryPollIntervalSec: 0,
124176 NetworkProcessMemoryConservativeThreshold: 0.5,
125177 NetworkProcessMemoryStrictThreshold: 0.7,
126178 NetworkProcessMemoryKillThreshold: -1,
127179
128- WebViewPoolPrewarmCount: 8,
180+ WebViewPoolPrewarmCount: poolPrewarm,
181+ }
182+}
183+
184+// computeMaxCPUThreads calculates Skia CPU threads for max profile.
185+// Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit.
186+func computeMaxCPUThreads(hw *port.HardwareInfo) int {
187+ var cpuThreads int
188+
189+ if hw != nil && hw.CPUCores > 0 {
190+ // Use physical cores directly - they're better for parallel painting
191+ cpuThreads = hw.CPUCores
192+ } else {
193+ // Fallback: assume hyperthreading, use half of logical CPUs
194+ cpuThreads = runtime.NumCPU() / 2
195+ }
196+
197+ // Minimum 4 for meaningful parallelism
198+ if cpuThreads < 4 {
199+ cpuThreads = 4
200+ }
201+ // Cap at WebKitGTK Skia limit
202+ if cpuThreads > maxSkiaCPUThreads {
203+ cpuThreads = maxSkiaCPUThreads
204+ }
205+
206+ return cpuThreads
207+}
208+
209+// computeMaxGPUThreads calculates Skia GPU threads based on VRAM.
210+func computeMaxGPUThreads(hw *port.HardwareInfo) int {
211+ if hw == nil || hw.VRAM == 0 {
212+ // No VRAM info - use conservative default
213+ return gpuThreadsMin
214+ }
215+
216+ // Scale GPU threads based on VRAM tiers
217+ switch {
218+ case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+)
219+ return gpuThreadsHigh
220+ case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070)
221+ return gpuThreadsMid
222+ case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete)
223+ return gpuThreadsLow
224+ default: // <4GB VRAM (integrated / old discrete)
225+ return gpuThreadsMin
226+ }
227+}
228+
229+// computeMaxMemoryLimits returns web process and network process memory limits.
230+func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) {
231+ if hw == nil || hw.TotalRAM == 0 {
232+ // Fallback: assume 16GB system
233+ return webMemFallback, netMemFallback
234+ }
235+
236+ // Scale memory limits based on system RAM
237+ switch {
238+ case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM
239+ return webMemHigh, netMemHigh
240+ case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM
241+ return webMemMedium, netMemMedium
242+ case hw.TotalRAM >= ramTierLow: // 8-16GB RAM
243+ return webMemLow, netMemLow
244+ default: // <8GB RAM
245+ return webMemMin, netMemMin
246+ }
247+}
248+
249+// computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM.
250+func computeMaxPoolPrewarm(hw *port.HardwareInfo) int {
251+ if hw == nil || hw.TotalRAM == 0 {
252+ return poolPrewarmFallback
253+ }
254+
255+ switch {
256+ case hw.TotalRAM >= ramTierHigh: // 32GB+
257+ return poolPrewarmHigh
258+ case hw.TotalRAM >= ramTierMedium: // 16-32GB
259+ return poolPrewarmMedium
260+ case hw.TotalRAM >= ramTierLow: // 8-16GB
261+ return poolPrewarmLow
262+ default: // <8GB
263+ return poolPrewarmMin
129264 }
130265 }
131266
@@ -1,11 +1,50 @@
1 package config1 package config
2 2
3-import "runtime"3+import (
4+ "runtime"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
4 8
5 const (9 const (
6 // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads10 // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads
7 // supported by WebKitGTK (0-8 range).11 // supported by WebKitGTK (0-8 range).
8 maxSkiaCPUThreads = 812 maxSkiaCPUThreads = 8
13+
14+ // Memory tier thresholds (in bytes)
15+ ramTierLow = 8 * 1024 * 1024 * 1024 // 8 GB
16+ ramTierMedium = 16 * 1024 * 1024 * 1024 // 16 GB
17+ ramTierHigh = 32 * 1024 * 1024 * 1024 // 32 GB
18+
19+ // VRAM tier thresholds (in bytes)
20+ vramTierLow = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated)
21+ vramTierMid = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete)
22+ vramTierHigh = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete)
23+
24+ // GPU thread scaling values
25+ gpuThreadsHigh = 8 // For 16GB+ VRAM
26+ gpuThreadsMid = 6 // For 8-16GB VRAM
27+ gpuThreadsLow = 4 // For 4-8GB VRAM
28+ gpuThreadsMin = 2 // For <4GB VRAM or unknown
29+
30+ // Memory limit values (in MB)
31+ webMemHigh = 4096 // For 32GB+ RAM
32+ webMemMedium = 3072 // For 16-32GB RAM
33+ webMemLow = 2048 // For 8-16GB RAM
34+ webMemMin = 1024 // For <8GB RAM
35+ netMemHigh = 1024 // For 32GB+ RAM
36+ netMemMedium = 768 // For 16-32GB RAM
37+ netMemLow = 512 // For 8-16GB RAM
38+ netMemMin = 256 // For <8GB RAM
39+ webMemFallback = 2048 // When RAM unknown
40+ netMemFallback = 512 // When RAM unknown
41+
42+ // WebView pool prewarm counts
43+ poolPrewarmHigh = 12 // For 32GB+ RAM
44+ poolPrewarmMedium = 8 // For 16-32GB RAM
45+ poolPrewarmLow = 6 // For 8-16GB RAM
46+ poolPrewarmMin = 4 // For <8GB RAM
47+ poolPrewarmFallback = 8 // When RAM unknown
9 )48 )
10 49
11 // ResolvedPerformanceSettings contains the computed performance settings50 // ResolvedPerformanceSettings contains the computed performance settings
@@ -36,13 +75,15 @@ type ResolvedPerformanceSettings struct {
36 75
37 // ResolvePerformanceProfile computes the actual performance settings based on76 // ResolvePerformanceProfile computes the actual performance settings based on
38 // the selected profile. For ProfileCustom, it returns the user's configured values.77 // the selected profile. For ProfileCustom, it returns the user's configured values.
39-// For other profiles, it computes appropriate values.78+// For other profiles, it computes appropriate values based on detected hardware.
40-func ResolvePerformanceProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {79+//
80+// The hw parameter is optional - if nil, fallback values are used.
81+func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings {
41 switch cfg.Profile {82 switch cfg.Profile {
42 case ProfileLite:83 case ProfileLite:
43- return resolveLiteProfile()84+ return resolveLiteProfile(hw)
44 case ProfileMax:85 case ProfileMax:
45- return resolveMaxProfile()86+ return resolveMaxProfile(hw)
46 case ProfileCustom:87 case ProfileCustom:
47 return resolveCustomProfile(cfg)88 return resolveCustomProfile(cfg)
48 default: // ProfileDefault or empty89 default: // ProfileDefault or empty
@@ -75,9 +116,16 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
75 }116 }
76 117
77 // resolveLiteProfile returns settings optimized for low-RAM systems.118 // resolveLiteProfile returns settings optimized for low-RAM systems.
78-func resolveLiteProfile() ResolvedPerformanceSettings {119+// Uses hardware info to scale CPU threads if available.
120+func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
121+ // Use 2 CPU threads, or scale down on very low-core systems
122+ cpuThreads := 2
123+ if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 {
124+ cpuThreads = 1
125+ }
126+
79 return ResolvedPerformanceSettings{127 return ResolvedPerformanceSettings{
80- SkiaCPUPaintingThreads: 2,128+ SkiaCPUPaintingThreads: cpuThreads,
81 SkiaGPUPaintingThreads: -1, // unset129 SkiaGPUPaintingThreads: -1, // unset
82 SkiaEnableCPURendering: false,130 SkiaEnableCPURendering: false,
83 131
@@ -98,34 +146,121 @@ func resolveLiteProfile() ResolvedPerformanceSettings {
98 }146 }
99 147
100 // resolveMaxProfile returns settings optimized for maximum responsiveness.148 // resolveMaxProfile returns settings optimized for maximum responsiveness.
101-func resolveMaxProfile() ResolvedPerformanceSettings {149+// Scales values based on detected hardware capabilities.
102- // Use half of available CPUs, minimum 4, maximum 8 (WebKitGTK Skia limit)150+func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
103- cpuThreads := runtime.NumCPU() / 2151+ // CPU threads: use physical cores (not threads), capped at WebKitGTK limit
104- if cpuThreads < 4 {152+ cpuThreads := computeMaxCPUThreads(hw)
105- cpuThreads = 4153+
106- }154+ // GPU threads: scale based on VRAM
107- if cpuThreads > maxSkiaCPUThreads {155+ gpuThreads := computeMaxGPUThreads(hw)
108- cpuThreads = maxSkiaCPUThreads156+
109- }157+ // Memory limits: scale based on system RAM
158+ webMemMB, netMemMB := computeMaxMemoryLimits(hw)
159+
160+ // WebView pool: scale based on RAM
161+ poolPrewarm := computeMaxPoolPrewarm(hw)
110 162
111 return ResolvedPerformanceSettings{163 return ResolvedPerformanceSettings{
112 SkiaCPUPaintingThreads: cpuThreads,164 SkiaCPUPaintingThreads: cpuThreads,
113- SkiaGPUPaintingThreads: 2,165+ SkiaGPUPaintingThreads: gpuThreads,
114 SkiaEnableCPURendering: false,166 SkiaEnableCPURendering: false,
115 167
116- WebProcessMemoryLimitMB: 2048,168+ WebProcessMemoryLimitMB: webMemMB,
117 WebProcessMemoryPollIntervalSec: 0, // use WebKit default169 WebProcessMemoryPollIntervalSec: 0, // use WebKit default
118 WebProcessMemoryConservativeThreshold: 0.5,170 WebProcessMemoryConservativeThreshold: 0.5,
119 WebProcessMemoryStrictThreshold: 0.7,171 WebProcessMemoryStrictThreshold: 0.7,
120 WebProcessMemoryKillThreshold: -1, // never kill172 WebProcessMemoryKillThreshold: -1, // never kill
121 173
122- NetworkProcessMemoryLimitMB: 512,174+ NetworkProcessMemoryLimitMB: netMemMB,
123 NetworkProcessMemoryPollIntervalSec: 0,175 NetworkProcessMemoryPollIntervalSec: 0,
124 NetworkProcessMemoryConservativeThreshold: 0.5,176 NetworkProcessMemoryConservativeThreshold: 0.5,
125 NetworkProcessMemoryStrictThreshold: 0.7,177 NetworkProcessMemoryStrictThreshold: 0.7,
126 NetworkProcessMemoryKillThreshold: -1,178 NetworkProcessMemoryKillThreshold: -1,
127 179
128- WebViewPoolPrewarmCount: 8,180+ WebViewPoolPrewarmCount: poolPrewarm,
181+ }
182+}
183+
184+// computeMaxCPUThreads calculates Skia CPU threads for max profile.
185+// Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit.
186+func computeMaxCPUThreads(hw *port.HardwareInfo) int {
187+ var cpuThreads int
188+
189+ if hw != nil && hw.CPUCores > 0 {
190+ // Use physical cores directly - they're better for parallel painting
191+ cpuThreads = hw.CPUCores
192+ } else {
193+ // Fallback: assume hyperthreading, use half of logical CPUs
194+ cpuThreads = runtime.NumCPU() / 2
195+ }
196+
197+ // Minimum 4 for meaningful parallelism
198+ if cpuThreads < 4 {
199+ cpuThreads = 4
200+ }
201+ // Cap at WebKitGTK Skia limit
202+ if cpuThreads > maxSkiaCPUThreads {
203+ cpuThreads = maxSkiaCPUThreads
204+ }
205+
206+ return cpuThreads
207+}
208+
209+// computeMaxGPUThreads calculates Skia GPU threads based on VRAM.
210+func computeMaxGPUThreads(hw *port.HardwareInfo) int {
211+ if hw == nil || hw.VRAM == 0 {
212+ // No VRAM info - use conservative default
213+ return gpuThreadsMin
214+ }
215+
216+ // Scale GPU threads based on VRAM tiers
217+ switch {
218+ case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+)
219+ return gpuThreadsHigh
220+ case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070)
221+ return gpuThreadsMid
222+ case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete)
223+ return gpuThreadsLow
224+ default: // <4GB VRAM (integrated / old discrete)
225+ return gpuThreadsMin
226+ }
227+}
228+
229+// computeMaxMemoryLimits returns web process and network process memory limits.
230+func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) {
231+ if hw == nil || hw.TotalRAM == 0 {
232+ // Fallback: assume 16GB system
233+ return webMemFallback, netMemFallback
234+ }
235+
236+ // Scale memory limits based on system RAM
237+ switch {
238+ case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM
239+ return webMemHigh, netMemHigh
240+ case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM
241+ return webMemMedium, netMemMedium
242+ case hw.TotalRAM >= ramTierLow: // 8-16GB RAM
243+ return webMemLow, netMemLow
244+ default: // <8GB RAM
245+ return webMemMin, netMemMin
246+ }
247+}
248+
249+// computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM.
250+func computeMaxPoolPrewarm(hw *port.HardwareInfo) int {
251+ if hw == nil || hw.TotalRAM == 0 {
252+ return poolPrewarmFallback
253+ }
254+
255+ switch {
256+ case hw.TotalRAM >= ramTierHigh: // 32GB+
257+ return poolPrewarmHigh
258+ case hw.TotalRAM >= ramTierMedium: // 16-32GB
259+ return poolPrewarmMedium
260+ case hw.TotalRAM >= ramTierLow: // 8-16GB
261+ return poolPrewarmLow
262+ default: // <8GB
263+ return poolPrewarmMin
129 }264 }
130 }265 }
131 266
M internal/infrastructure/config/profile_test.go +211 -12
@@ -3,6 +3,8 @@ package config
33 import (
44 "runtime"
55 "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
68 )
79
810 func TestResolvePerformanceProfile_Default(t *testing.T) {
@@ -10,7 +12,7 @@ func TestResolvePerformanceProfile_Default(t *testing.T) {
1012 Profile: ProfileDefault,
1113 }
1214
13- result := ResolvePerformanceProfile(cfg)
15+ result := ResolvePerformanceProfile(cfg, nil)
1416
1517 // Default should have no tuning (unset values)
1618 if result.SkiaCPUPaintingThreads != 0 {
@@ -32,7 +34,7 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
3234 Profile: "", // Empty should behave like default
3335 }
3436
35- result := ResolvePerformanceProfile(cfg)
37+ result := ResolvePerformanceProfile(cfg, nil)
3638
3739 if result.SkiaCPUPaintingThreads != 0 {
3840 t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
@@ -42,14 +44,14 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
4244 }
4345 }
4446
45-func TestResolvePerformanceProfile_Lite(t *testing.T) {
47+func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
4648 cfg := &PerformanceConfig{
4749 Profile: ProfileLite,
4850 }
4951
50- result := ResolvePerformanceProfile(cfg)
52+ result := ResolvePerformanceProfile(cfg, nil)
5153
52- // Lite should have reduced resource usage
54+ // Lite without hardware info should use default lite values
5355 if result.SkiaCPUPaintingThreads != 2 {
5456 t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads)
5557 }
@@ -67,15 +69,32 @@ func TestResolvePerformanceProfile_Lite(t *testing.T) {
6769 }
6870 }
6971
70-func TestResolvePerformanceProfile_Max(t *testing.T) {
72+func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) {
73+ cfg := &PerformanceConfig{
74+ Profile: ProfileLite,
75+ }
76+ hw := &port.HardwareInfo{
77+ CPUCores: 2, // Very low core count
78+ CPUThreads: 4,
79+ }
80+
81+ result := ResolvePerformanceProfile(cfg, hw)
82+
83+ // Lite on low-core system should scale down
84+ if result.SkiaCPUPaintingThreads != 1 {
85+ t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads)
86+ }
87+}
88+
89+func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) {
7190 cfg := &PerformanceConfig{
7291 Profile: ProfileMax,
7392 }
7493
75- result := ResolvePerformanceProfile(cfg)
94+ result := ResolvePerformanceProfile(cfg, nil)
7695
77- // Max should have high resource allocation
78- // CPU threads: NumCPU/2, clamped to [4, 8] range (WebKitGTK Skia limit)
96+ // Max without hardware info should use fallback values
97+ // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8]
7998 expectedCPUThreads := runtime.NumCPU() / 2
8099 if expectedCPUThreads < 4 {
81100 expectedCPUThreads = 4
@@ -86,9 +105,11 @@ func TestResolvePerformanceProfile_Max(t *testing.T) {
86105 if result.SkiaCPUPaintingThreads != expectedCPUThreads {
87106 t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads)
88107 }
108+ // GPU threads: conservative default when no VRAM info
89109 if result.SkiaGPUPaintingThreads != 2 {
90- t.Errorf("expected SkiaGPUPaintingThreads=2, got %d", result.SkiaGPUPaintingThreads)
110+ t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads)
91111 }
112+ // Memory: fallback assumes 16GB system
92113 if result.WebProcessMemoryLimitMB != 2048 {
93114 t.Errorf("expected WebProcessMemoryLimitMB=2048, got %d", result.WebProcessMemoryLimitMB)
94115 }
@@ -100,6 +121,105 @@ func TestResolvePerformanceProfile_Max(t *testing.T) {
100121 }
101122 }
102123
124+func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) {
125+ cfg := &PerformanceConfig{
126+ Profile: ProfileMax,
127+ }
128+ hw := &port.HardwareInfo{
129+ CPUCores: 16,
130+ CPUThreads: 32,
131+ TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB
132+ VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU)
133+ GPUVendor: port.GPUVendorAMD,
134+ }
135+
136+ result := ResolvePerformanceProfile(cfg, hw)
137+
138+ // CPU threads: use physical cores capped at 8 (WebKitGTK limit)
139+ if result.SkiaCPUPaintingThreads != 8 {
140+ t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads)
141+ }
142+ // GPU threads: high-end tier (16GB+ VRAM)
143+ if result.SkiaGPUPaintingThreads != 8 {
144+ t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads)
145+ }
146+ // Memory: high-end tier (32GB+ RAM)
147+ if result.WebProcessMemoryLimitMB != 4096 {
148+ t.Errorf("expected WebProcessMemoryLimitMB=4096 for 64GB RAM, got %d", result.WebProcessMemoryLimitMB)
149+ }
150+ if result.NetworkProcessMemoryLimitMB != 1024 {
151+ t.Errorf("expected NetworkProcessMemoryLimitMB=1024 for 64GB RAM, got %d", result.NetworkProcessMemoryLimitMB)
152+ }
153+ // Pool prewarm: high-end tier
154+ if result.WebViewPoolPrewarmCount != 12 {
155+ t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount)
156+ }
157+}
158+
159+func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) {
160+ cfg := &PerformanceConfig{
161+ Profile: ProfileMax,
162+ }
163+ hw := &port.HardwareInfo{
164+ CPUCores: 8,
165+ CPUThreads: 16,
166+ TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB
167+ VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU)
168+ GPUVendor: port.GPUVendorNVIDIA,
169+ }
170+
171+ result := ResolvePerformanceProfile(cfg, hw)
172+
173+ // CPU threads: use physical cores (8)
174+ if result.SkiaCPUPaintingThreads != 8 {
175+ t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads)
176+ }
177+ // GPU threads: mid-range tier (8-16GB VRAM)
178+ if result.SkiaGPUPaintingThreads != 6 {
179+ t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads)
180+ }
181+ // Memory: mid-range tier (16-32GB RAM)
182+ if result.WebProcessMemoryLimitMB != 3072 {
183+ t.Errorf("expected WebProcessMemoryLimitMB=3072 for 16GB RAM, got %d", result.WebProcessMemoryLimitMB)
184+ }
185+ // Pool prewarm: mid-range tier
186+ if result.WebViewPoolPrewarmCount != 8 {
187+ t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount)
188+ }
189+}
190+
191+func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) {
192+ cfg := &PerformanceConfig{
193+ Profile: ProfileMax,
194+ }
195+ hw := &port.HardwareInfo{
196+ CPUCores: 4,
197+ CPUThreads: 4,
198+ TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB
199+ VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete)
200+ GPUVendor: port.GPUVendorIntel,
201+ }
202+
203+ result := ResolvePerformanceProfile(cfg, hw)
204+
205+ // CPU threads: use physical cores (4)
206+ if result.SkiaCPUPaintingThreads != 4 {
207+ t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads)
208+ }
209+ // GPU threads: low tier (<4GB VRAM)
210+ if result.SkiaGPUPaintingThreads != 2 {
211+ t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads)
212+ }
213+ // Memory: low tier (<8GB RAM)
214+ if result.WebProcessMemoryLimitMB != 1024 {
215+ t.Errorf("expected WebProcessMemoryLimitMB=1024 for 4GB RAM, got %d", result.WebProcessMemoryLimitMB)
216+ }
217+ // Pool prewarm: low tier
218+ if result.WebViewPoolPrewarmCount != 4 {
219+ t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount)
220+ }
221+}
222+
103223 func TestResolvePerformanceProfile_Custom(t *testing.T) {
104224 cfg := &PerformanceConfig{
105225 Profile: ProfileCustom,
@@ -110,10 +230,17 @@ func TestResolvePerformanceProfile_Custom(t *testing.T) {
110230 WebProcessMemoryConservativeThreshold: 0.4,
111231 WebViewPoolPrewarmCount: 5,
112232 }
233+ // Hardware info should be ignored for custom profile
234+ hw := &port.HardwareInfo{
235+ CPUCores: 16,
236+ CPUThreads: 32,
237+ TotalRAM: 64 * 1024 * 1024 * 1024,
238+ VRAM: 16 * 1024 * 1024 * 1024,
239+ }
113240
114- result := ResolvePerformanceProfile(cfg)
241+ result := ResolvePerformanceProfile(cfg, hw)
115242
116- // Custom should use user-specified values
243+ // Custom should use user-specified values, ignoring hardware
117244 if result.SkiaCPUPaintingThreads != 6 {
118245 t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads)
119246 }
@@ -213,3 +340,75 @@ func TestIsValidPerformanceProfile(t *testing.T) {
213340 })
214341 }
215342 }
343+
344+func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) {
345+ tests := []struct {
346+ name string
347+ vramGB int
348+ expected int
349+ }{
350+ {"no VRAM info", 0, 2},
351+ {"2GB VRAM (integrated)", 2, 2},
352+ {"4GB VRAM (entry discrete)", 4, 4},
353+ {"6GB VRAM (low-mid discrete)", 6, 4},
354+ {"8GB VRAM (mid discrete)", 8, 6},
355+ {"12GB VRAM (mid-high discrete)", 12, 6},
356+ {"16GB VRAM (high-end)", 16, 8},
357+ {"24GB VRAM (workstation)", 24, 8},
358+ }
359+
360+ for _, tt := range tests {
361+ t.Run(tt.name, func(t *testing.T) {
362+ hw := &port.HardwareInfo{
363+ VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024,
364+ }
365+ if tt.vramGB == 0 {
366+ hw = nil
367+ }
368+ result := computeMaxGPUThreads(hw)
369+ if result != tt.expected {
370+ t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result)
371+ }
372+ })
373+ }
374+}
375+
376+func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) {
377+ tests := []struct {
378+ name string
379+ ramGB int
380+ expectedWeb int
381+ expectedNet int
382+ expectedPool int
383+ }{
384+ {"no RAM info", 0, 2048, 512, 8},
385+ {"4GB RAM", 4, 1024, 256, 4},
386+ {"8GB RAM", 8, 2048, 512, 6},
387+ {"16GB RAM", 16, 3072, 768, 8},
388+ {"32GB RAM", 32, 4096, 1024, 12},
389+ {"64GB RAM", 64, 4096, 1024, 12},
390+ }
391+
392+ for _, tt := range tests {
393+ t.Run(tt.name, func(t *testing.T) {
394+ hw := &port.HardwareInfo{
395+ TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024,
396+ }
397+ if tt.ramGB == 0 {
398+ hw = nil
399+ }
400+ webMB, netMB := computeMaxMemoryLimits(hw)
401+ poolPrewarm := computeMaxPoolPrewarm(hw)
402+
403+ if webMB != tt.expectedWeb {
404+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB)
405+ }
406+ if netMB != tt.expectedNet {
407+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB)
408+ }
409+ if poolPrewarm != tt.expectedPool {
410+ t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm)
411+ }
412+ })
413+ }
414+}
@@ -3,6 +3,8 @@ package config
3 import (3 import (
4 "runtime"4 "runtime"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
6 )8 )
7 9
8 func TestResolvePerformanceProfile_Default(t *testing.T) {10 func TestResolvePerformanceProfile_Default(t *testing.T) {
@@ -10,7 +12,7 @@ func TestResolvePerformanceProfile_Default(t *testing.T) {
10 Profile: ProfileDefault,12 Profile: ProfileDefault,
11 }13 }
12 14
13- result := ResolvePerformanceProfile(cfg)15+ result := ResolvePerformanceProfile(cfg, nil)
14 16
15 // Default should have no tuning (unset values)17 // Default should have no tuning (unset values)
16 if result.SkiaCPUPaintingThreads != 0 {18 if result.SkiaCPUPaintingThreads != 0 {
@@ -32,7 +34,7 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
32 Profile: "", // Empty should behave like default34 Profile: "", // Empty should behave like default
33 }35 }
34 36
35- result := ResolvePerformanceProfile(cfg)37+ result := ResolvePerformanceProfile(cfg, nil)
36 38
37 if result.SkiaCPUPaintingThreads != 0 {39 if result.SkiaCPUPaintingThreads != 0 {
38 t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)40 t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
@@ -42,14 +44,14 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
42 }44 }
43 }45 }
44 46
45-func TestResolvePerformanceProfile_Lite(t *testing.T) {47+func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
46 cfg := &PerformanceConfig{48 cfg := &PerformanceConfig{
47 Profile: ProfileLite,49 Profile: ProfileLite,
48 }50 }
49 51
50- result := ResolvePerformanceProfile(cfg)52+ result := ResolvePerformanceProfile(cfg, nil)
51 53
52- // Lite should have reduced resource usage54+ // Lite without hardware info should use default lite values
53 if result.SkiaCPUPaintingThreads != 2 {55 if result.SkiaCPUPaintingThreads != 2 {
54 t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads)56 t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads)
55 }57 }
@@ -67,15 +69,32 @@ func TestResolvePerformanceProfile_Lite(t *testing.T) {
67 }69 }
68 }70 }
69 71
70-func TestResolvePerformanceProfile_Max(t *testing.T) {72+func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) {
73+ cfg := &PerformanceConfig{
74+ Profile: ProfileLite,
75+ }
76+ hw := &port.HardwareInfo{
77+ CPUCores: 2, // Very low core count
78+ CPUThreads: 4,
79+ }
80+
81+ result := ResolvePerformanceProfile(cfg, hw)
82+
83+ // Lite on low-core system should scale down
84+ if result.SkiaCPUPaintingThreads != 1 {
85+ t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads)
86+ }
87+}
88+
89+func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) {
71 cfg := &PerformanceConfig{90 cfg := &PerformanceConfig{
72 Profile: ProfileMax,91 Profile: ProfileMax,
73 }92 }
74 93
75- result := ResolvePerformanceProfile(cfg)94+ result := ResolvePerformanceProfile(cfg, nil)
76 95
77- // Max should have high resource allocation96+ // Max without hardware info should use fallback values
78- // CPU threads: NumCPU/2, clamped to [4, 8] range (WebKitGTK Skia limit)97+ // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8]
79 expectedCPUThreads := runtime.NumCPU() / 298 expectedCPUThreads := runtime.NumCPU() / 2
80 if expectedCPUThreads < 4 {99 if expectedCPUThreads < 4 {
81 expectedCPUThreads = 4100 expectedCPUThreads = 4
@@ -86,9 +105,11 @@ func TestResolvePerformanceProfile_Max(t *testing.T) {
86 if result.SkiaCPUPaintingThreads != expectedCPUThreads {105 if result.SkiaCPUPaintingThreads != expectedCPUThreads {
87 t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads)106 t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads)
88 }107 }
108+ // GPU threads: conservative default when no VRAM info
89 if result.SkiaGPUPaintingThreads != 2 {109 if result.SkiaGPUPaintingThreads != 2 {
90- t.Errorf("expected SkiaGPUPaintingThreads=2, got %d", result.SkiaGPUPaintingThreads)110+ t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads)
91 }111 }
112+ // Memory: fallback assumes 16GB system
92 if result.WebProcessMemoryLimitMB != 2048 {113 if result.WebProcessMemoryLimitMB != 2048 {
93 t.Errorf("expected WebProcessMemoryLimitMB=2048, got %d", result.WebProcessMemoryLimitMB)114 t.Errorf("expected WebProcessMemoryLimitMB=2048, got %d", result.WebProcessMemoryLimitMB)
94 }115 }
@@ -100,6 +121,105 @@ func TestResolvePerformanceProfile_Max(t *testing.T) {
100 }121 }
101 }122 }
102 123
124+func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) {
125+ cfg := &PerformanceConfig{
126+ Profile: ProfileMax,
127+ }
128+ hw := &port.HardwareInfo{
129+ CPUCores: 16,
130+ CPUThreads: 32,
131+ TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB
132+ VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU)
133+ GPUVendor: port.GPUVendorAMD,
134+ }
135+
136+ result := ResolvePerformanceProfile(cfg, hw)
137+
138+ // CPU threads: use physical cores capped at 8 (WebKitGTK limit)
139+ if result.SkiaCPUPaintingThreads != 8 {
140+ t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads)
141+ }
142+ // GPU threads: high-end tier (16GB+ VRAM)
143+ if result.SkiaGPUPaintingThreads != 8 {
144+ t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads)
145+ }
146+ // Memory: high-end tier (32GB+ RAM)
147+ if result.WebProcessMemoryLimitMB != 4096 {
148+ t.Errorf("expected WebProcessMemoryLimitMB=4096 for 64GB RAM, got %d", result.WebProcessMemoryLimitMB)
149+ }
150+ if result.NetworkProcessMemoryLimitMB != 1024 {
151+ t.Errorf("expected NetworkProcessMemoryLimitMB=1024 for 64GB RAM, got %d", result.NetworkProcessMemoryLimitMB)
152+ }
153+ // Pool prewarm: high-end tier
154+ if result.WebViewPoolPrewarmCount != 12 {
155+ t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount)
156+ }
157+}
158+
159+func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) {
160+ cfg := &PerformanceConfig{
161+ Profile: ProfileMax,
162+ }
163+ hw := &port.HardwareInfo{
164+ CPUCores: 8,
165+ CPUThreads: 16,
166+ TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB
167+ VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU)
168+ GPUVendor: port.GPUVendorNVIDIA,
169+ }
170+
171+ result := ResolvePerformanceProfile(cfg, hw)
172+
173+ // CPU threads: use physical cores (8)
174+ if result.SkiaCPUPaintingThreads != 8 {
175+ t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads)
176+ }
177+ // GPU threads: mid-range tier (8-16GB VRAM)
178+ if result.SkiaGPUPaintingThreads != 6 {
179+ t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads)
180+ }
181+ // Memory: mid-range tier (16-32GB RAM)
182+ if result.WebProcessMemoryLimitMB != 3072 {
183+ t.Errorf("expected WebProcessMemoryLimitMB=3072 for 16GB RAM, got %d", result.WebProcessMemoryLimitMB)
184+ }
185+ // Pool prewarm: mid-range tier
186+ if result.WebViewPoolPrewarmCount != 8 {
187+ t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount)
188+ }
189+}
190+
191+func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) {
192+ cfg := &PerformanceConfig{
193+ Profile: ProfileMax,
194+ }
195+ hw := &port.HardwareInfo{
196+ CPUCores: 4,
197+ CPUThreads: 4,
198+ TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB
199+ VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete)
200+ GPUVendor: port.GPUVendorIntel,
201+ }
202+
203+ result := ResolvePerformanceProfile(cfg, hw)
204+
205+ // CPU threads: use physical cores (4)
206+ if result.SkiaCPUPaintingThreads != 4 {
207+ t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads)
208+ }
209+ // GPU threads: low tier (<4GB VRAM)
210+ if result.SkiaGPUPaintingThreads != 2 {
211+ t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads)
212+ }
213+ // Memory: low tier (<8GB RAM)
214+ if result.WebProcessMemoryLimitMB != 1024 {
215+ t.Errorf("expected WebProcessMemoryLimitMB=1024 for 4GB RAM, got %d", result.WebProcessMemoryLimitMB)
216+ }
217+ // Pool prewarm: low tier
218+ if result.WebViewPoolPrewarmCount != 4 {
219+ t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount)
220+ }
221+}
222+
103 func TestResolvePerformanceProfile_Custom(t *testing.T) {223 func TestResolvePerformanceProfile_Custom(t *testing.T) {
104 cfg := &PerformanceConfig{224 cfg := &PerformanceConfig{
105 Profile: ProfileCustom,225 Profile: ProfileCustom,
@@ -110,10 +230,17 @@ func TestResolvePerformanceProfile_Custom(t *testing.T) {
110 WebProcessMemoryConservativeThreshold: 0.4,230 WebProcessMemoryConservativeThreshold: 0.4,
111 WebViewPoolPrewarmCount: 5,231 WebViewPoolPrewarmCount: 5,
112 }232 }
233+ // Hardware info should be ignored for custom profile
234+ hw := &port.HardwareInfo{
235+ CPUCores: 16,
236+ CPUThreads: 32,
237+ TotalRAM: 64 * 1024 * 1024 * 1024,
238+ VRAM: 16 * 1024 * 1024 * 1024,
239+ }
113 240
114- result := ResolvePerformanceProfile(cfg)241+ result := ResolvePerformanceProfile(cfg, hw)
115 242
116- // Custom should use user-specified values243+ // Custom should use user-specified values, ignoring hardware
117 if result.SkiaCPUPaintingThreads != 6 {244 if result.SkiaCPUPaintingThreads != 6 {
118 t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads)245 t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads)
119 }246 }
@@ -213,3 +340,75 @@ func TestIsValidPerformanceProfile(t *testing.T) {
213 })340 })
214 }341 }
215 }342 }
343+
344+func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) {
345+ tests := []struct {
346+ name string
347+ vramGB int
348+ expected int
349+ }{
350+ {"no VRAM info", 0, 2},
351+ {"2GB VRAM (integrated)", 2, 2},
352+ {"4GB VRAM (entry discrete)", 4, 4},
353+ {"6GB VRAM (low-mid discrete)", 6, 4},
354+ {"8GB VRAM (mid discrete)", 8, 6},
355+ {"12GB VRAM (mid-high discrete)", 12, 6},
356+ {"16GB VRAM (high-end)", 16, 8},
357+ {"24GB VRAM (workstation)", 24, 8},
358+ }
359+
360+ for _, tt := range tests {
361+ t.Run(tt.name, func(t *testing.T) {
362+ hw := &port.HardwareInfo{
363+ VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024,
364+ }
365+ if tt.vramGB == 0 {
366+ hw = nil
367+ }
368+ result := computeMaxGPUThreads(hw)
369+ if result != tt.expected {
370+ t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result)
371+ }
372+ })
373+ }
374+}
375+
376+func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) {
377+ tests := []struct {
378+ name string
379+ ramGB int
380+ expectedWeb int
381+ expectedNet int
382+ expectedPool int
383+ }{
384+ {"no RAM info", 0, 2048, 512, 8},
385+ {"4GB RAM", 4, 1024, 256, 4},
386+ {"8GB RAM", 8, 2048, 512, 6},
387+ {"16GB RAM", 16, 3072, 768, 8},
388+ {"32GB RAM", 32, 4096, 1024, 12},
389+ {"64GB RAM", 64, 4096, 1024, 12},
390+ }
391+
392+ for _, tt := range tests {
393+ t.Run(tt.name, func(t *testing.T) {
394+ hw := &port.HardwareInfo{
395+ TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024,
396+ }
397+ if tt.ramGB == 0 {
398+ hw = nil
399+ }
400+ webMB, netMB := computeMaxMemoryLimits(hw)
401+ poolPrewarm := computeMaxPoolPrewarm(hw)
402+
403+ if webMB != tt.expectedWeb {
404+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB)
405+ }
406+ if netMB != tt.expectedNet {
407+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB)
408+ }
409+ if poolPrewarm != tt.expectedPool {
410+ t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm)
411+ }
412+ })
413+ }
414+}
A internal/infrastructure/env/hardware_survey.go +343 -0
@@ -0,0 +1,343 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+const (
17+ // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB)
18+ kibibyte = 1024
19+)
20+
21+// HardwareSurveyor implements port.HardwareSurveyor for Linux systems.
22+type HardwareSurveyor struct {
23+ cached *port.HardwareInfo
24+}
25+
26+// NewHardwareSurveyor creates a new hardware surveyor.
27+func NewHardwareSurveyor() *HardwareSurveyor {
28+ return &HardwareSurveyor{}
29+}
30+
31+// Survey detects and returns hardware information.
32+// Results are cached after the first call.
33+func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
34+ if s.cached != nil {
35+ return *s.cached
36+ }
37+
38+ log := logging.FromContext(ctx)
39+
40+ info := port.HardwareInfo{
41+ CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads
42+ }
43+
44+ // Detect physical CPU cores
45+ info.CPUCores = s.detectCPUCores(ctx)
46+ if info.CPUCores == 0 {
47+ info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown
48+ }
49+
50+ // Detect RAM
51+ info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx)
52+
53+ // Detect GPU
54+ info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx)
55+
56+ log.Debug().
57+ Int("cpu_cores", info.CPUCores).
58+ Int("cpu_threads", info.CPUThreads).
59+ Uint64("total_ram_mb", info.TotalRAM/(1024*1024)).
60+ Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)).
61+ Str("gpu_vendor", string(info.GPUVendor)).
62+ Str("gpu_name", info.GPUName).
63+ Uint64("vram_mb", info.VRAM/(1024*1024)).
64+ Msg("hardware survey completed")
65+
66+ s.cached = &info
67+ return info
68+}
69+
70+// detectCPUCores returns the number of physical CPU cores.
71+// Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores.
72+func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int {
73+ log := logging.FromContext(ctx)
74+
75+ // Method 1: Count unique core_ids across all CPUs
76+ coreIDs := make(map[string]struct{})
77+ cpuPath := "/sys/devices/system/cpu"
78+
79+ entries, err := os.ReadDir(cpuPath)
80+ if err != nil {
81+ log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu")
82+ return 0
83+ }
84+
85+ for _, entry := range entries {
86+ if !strings.HasPrefix(entry.Name(), "cpu") {
87+ continue
88+ }
89+ // Skip non-numeric cpu entries (like cpufreq, cpuidle)
90+ cpuNum := strings.TrimPrefix(entry.Name(), "cpu")
91+ if _, err := strconv.Atoi(cpuNum); err != nil {
92+ continue
93+ }
94+
95+ coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id")
96+ data, err := os.ReadFile(coreIDPath)
97+ if err != nil {
98+ continue
99+ }
100+ coreID := strings.TrimSpace(string(data))
101+ coreIDs[coreID] = struct{}{}
102+ }
103+
104+ if len(coreIDs) > 0 {
105+ return len(coreIDs)
106+ }
107+
108+ // Method 2: Parse /proc/cpuinfo for "cpu cores" field
109+ return s.detectCPUCoresFromProcCPUInfo(ctx)
110+}
111+
112+// detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count.
113+func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int {
114+ log := logging.FromContext(ctx)
115+
116+ file, err := os.Open("/proc/cpuinfo")
117+ if err != nil {
118+ log.Debug().Err(err).Msg("cannot read /proc/cpuinfo")
119+ return 0
120+ }
121+ defer func() { _ = file.Close() }()
122+
123+ scanner := bufio.NewScanner(file)
124+ for scanner.Scan() {
125+ line := scanner.Text()
126+ if strings.HasPrefix(line, "cpu cores") {
127+ parts := strings.Split(line, ":")
128+ if len(parts) == 2 {
129+ cores, err := strconv.Atoi(strings.TrimSpace(parts[1]))
130+ if err == nil {
131+ return cores
132+ }
133+ }
134+ }
135+ }
136+ return 0
137+}
138+
139+// detectMemory reads memory info from /proc/meminfo.
140+func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) {
141+ log := logging.FromContext(ctx)
142+
143+ file, err := os.Open("/proc/meminfo")
144+ if err != nil {
145+ log.Debug().Err(err).Msg("cannot read /proc/meminfo")
146+ return 0, 0
147+ }
148+ defer func() { _ = file.Close() }()
149+
150+ scanner := bufio.NewScanner(file)
151+ for scanner.Scan() {
152+ line := scanner.Text()
153+ if strings.HasPrefix(line, "MemTotal:") {
154+ total = s.parseMemInfoLine(line)
155+ } else if strings.HasPrefix(line, "MemAvailable:") {
156+ available = s.parseMemInfoLine(line)
157+ }
158+ if total > 0 && available > 0 {
159+ break
160+ }
161+ }
162+ return total, available
163+}
164+
165+// parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB".
166+func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 {
167+ parts := strings.Fields(line)
168+ if len(parts) < 2 {
169+ return 0
170+ }
171+ value, err := strconv.ParseUint(parts[1], 10, 64)
172+ if err != nil {
173+ return 0
174+ }
175+ // /proc/meminfo reports in kB (actually KiB)
176+ return value * kibibyte
177+}
178+
179+// detectGPU detects GPU vendor, name, and VRAM from DRM subsystem.
180+// When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM.
181+func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) {
182+ log := logging.FromContext(ctx)
183+
184+ const drmBase = "/sys/class/drm"
185+ cards := []string{"card0", "card1", "card2", "card3"}
186+
187+ var bestVendor port.GPUVendor
188+ var bestName string
189+ var bestVRAM uint64
190+ var bestCard string
191+
192+ for _, card := range cards {
193+ cardPath := filepath.Join(drmBase, card)
194+ if _, err := os.Stat(cardPath); os.IsNotExist(err) {
195+ continue
196+ }
197+
198+ vendor := s.detectGPUVendor(cardPath)
199+ if vendor == port.GPUVendorUnknown {
200+ continue
201+ }
202+
203+ name := s.detectGPUName(ctx, cardPath)
204+ vram := s.detectVRAM(ctx, cardPath)
205+
206+ log.Debug().
207+ Str("card", card).
208+ Str("vendor", string(vendor)).
209+ Str("name", name).
210+ Uint64("vram_mb", vram/(1024*1024)).
211+ Msg("found GPU")
212+
213+ // Prefer GPU with more VRAM (discrete > integrated)
214+ if vram > bestVRAM {
215+ bestVendor = vendor
216+ bestName = name
217+ bestVRAM = vram
218+ bestCard = card
219+ }
220+ }
221+
222+ if bestVendor != port.GPUVendorUnknown {
223+ log.Debug().
224+ Str("card", bestCard).
225+ Str("vendor", string(bestVendor)).
226+ Uint64("vram_mb", bestVRAM/(1024*1024)).
227+ Msg("selected GPU (highest VRAM)")
228+ }
229+
230+ return bestVendor, bestName, bestVRAM
231+}
232+
233+// detectGPUVendor reads the PCI vendor ID.
234+func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor {
235+ vendorPath := filepath.Join(cardPath, "device", "vendor")
236+ data, err := os.ReadFile(vendorPath)
237+ if err != nil {
238+ return port.GPUVendorUnknown
239+ }
240+
241+ vendorID := strings.TrimSpace(string(data))
242+ switch vendorID {
243+ case "0x1002":
244+ return port.GPUVendorAMD
245+ case "0x8086":
246+ return port.GPUVendorIntel
247+ case "0x10de":
248+ return port.GPUVendorNVIDIA
249+ default:
250+ return port.GPUVendorUnknown
251+ }
252+}
253+
254+// detectGPUName reads the GPU product name from various sources.
255+func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string {
256+ log := logging.FromContext(ctx)
257+
258+ // Try reading from device/label first (some drivers provide this)
259+ labelPath := filepath.Join(cardPath, "device", "label")
260+ if data, err := os.ReadFile(labelPath); err == nil {
261+ name := strings.TrimSpace(string(data))
262+ if name != "" {
263+ return name
264+ }
265+ }
266+
267+ // Try reading product name from device subsystem (uevent)
268+ ueventPath := filepath.Join(cardPath, "device", "uevent")
269+ if file, err := os.Open(ueventPath); err == nil {
270+ scanner := bufio.NewScanner(file)
271+ for scanner.Scan() {
272+ line := scanner.Text()
273+ if strings.HasPrefix(line, "PCI_SLOT_NAME=") {
274+ // This gives us the PCI address, useful for debugging
275+ log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot")
276+ }
277+ }
278+ _ = file.Close()
279+ }
280+
281+ // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually)
282+ // For now, return empty and rely on vendor detection
283+ return ""
284+}
285+
286+// detectVRAM reads VRAM size from DRM memory info.
287+func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 {
288+ log := logging.FromContext(ctx)
289+
290+ // Method 1: AMD uses mem_info_vram_total in device directory
291+ amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total")
292+ if data, err := os.ReadFile(amdVRAMPath); err == nil {
293+ vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
294+ if err == nil && vram > 0 {
295+ log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total")
296+ return vram
297+ }
298+ }
299+
300+ // Method 2: Try reading from resource file (PCI BAR sizes)
301+ // The first non-zero memory region is usually VRAM
302+ if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 {
303+ return vram
304+ }
305+
306+ // Method 3: For Intel integrated, check i915 specific paths
307+ // Intel iGPU uses system RAM, so VRAM is usually 0 or shared
308+ return 0
309+}
310+
311+// detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes.
312+func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 {
313+ log := logging.FromContext(ctx)
314+
315+ resourcePath := filepath.Join(cardPath, "device", "resource")
316+ file, err := os.Open(resourcePath)
317+ if err != nil {
318+ return 0
319+ }
320+ defer func() { _ = file.Close() }()
321+
322+ const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions
323+ scanner := bufio.NewScanner(file)
324+ for scanner.Scan() {
325+ line := scanner.Text()
326+ parts := strings.Fields(line)
327+ if len(parts) >= 2 {
328+ start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64)
329+ end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64)
330+ if err1 == nil && err2 == nil && start > 0 && end > start {
331+ size := end - start + 1
332+ // VRAM is typically > 256MB, skip smaller regions (I/O, config)
333+ if size > minVRAMSize {
334+ log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource")
335+ return size
336+ }
337+ }
338+ }
339+ }
340+ return 0
341+}
342+
343+var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil)
@@ -0,0 +1,343 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+const (
17+ // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB)
18+ kibibyte = 1024
19+)
20+
21+// HardwareSurveyor implements port.HardwareSurveyor for Linux systems.
22+type HardwareSurveyor struct {
23+ cached *port.HardwareInfo
24+}
25+
26+// NewHardwareSurveyor creates a new hardware surveyor.
27+func NewHardwareSurveyor() *HardwareSurveyor {
28+ return &HardwareSurveyor{}
29+}
30+
31+// Survey detects and returns hardware information.
32+// Results are cached after the first call.
33+func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
34+ if s.cached != nil {
35+ return *s.cached
36+ }
37+
38+ log := logging.FromContext(ctx)
39+
40+ info := port.HardwareInfo{
41+ CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads
42+ }
43+
44+ // Detect physical CPU cores
45+ info.CPUCores = s.detectCPUCores(ctx)
46+ if info.CPUCores == 0 {
47+ info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown
48+ }
49+
50+ // Detect RAM
51+ info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx)
52+
53+ // Detect GPU
54+ info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx)
55+
56+ log.Debug().
57+ Int("cpu_cores", info.CPUCores).
58+ Int("cpu_threads", info.CPUThreads).
59+ Uint64("total_ram_mb", info.TotalRAM/(1024*1024)).
60+ Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)).
61+ Str("gpu_vendor", string(info.GPUVendor)).
62+ Str("gpu_name", info.GPUName).
63+ Uint64("vram_mb", info.VRAM/(1024*1024)).
64+ Msg("hardware survey completed")
65+
66+ s.cached = &info
67+ return info
68+}
69+
70+// detectCPUCores returns the number of physical CPU cores.
71+// Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores.
72+func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int {
73+ log := logging.FromContext(ctx)
74+
75+ // Method 1: Count unique core_ids across all CPUs
76+ coreIDs := make(map[string]struct{})
77+ cpuPath := "/sys/devices/system/cpu"
78+
79+ entries, err := os.ReadDir(cpuPath)
80+ if err != nil {
81+ log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu")
82+ return 0
83+ }
84+
85+ for _, entry := range entries {
86+ if !strings.HasPrefix(entry.Name(), "cpu") {
87+ continue
88+ }
89+ // Skip non-numeric cpu entries (like cpufreq, cpuidle)
90+ cpuNum := strings.TrimPrefix(entry.Name(), "cpu")
91+ if _, err := strconv.Atoi(cpuNum); err != nil {
92+ continue
93+ }
94+
95+ coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id")
96+ data, err := os.ReadFile(coreIDPath)
97+ if err != nil {
98+ continue
99+ }
100+ coreID := strings.TrimSpace(string(data))
101+ coreIDs[coreID] = struct{}{}
102+ }
103+
104+ if len(coreIDs) > 0 {
105+ return len(coreIDs)
106+ }
107+
108+ // Method 2: Parse /proc/cpuinfo for "cpu cores" field
109+ return s.detectCPUCoresFromProcCPUInfo(ctx)
110+}
111+
112+// detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count.
113+func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int {
114+ log := logging.FromContext(ctx)
115+
116+ file, err := os.Open("/proc/cpuinfo")
117+ if err != nil {
118+ log.Debug().Err(err).Msg("cannot read /proc/cpuinfo")
119+ return 0
120+ }
121+ defer func() { _ = file.Close() }()
122+
123+ scanner := bufio.NewScanner(file)
124+ for scanner.Scan() {
125+ line := scanner.Text()
126+ if strings.HasPrefix(line, "cpu cores") {
127+ parts := strings.Split(line, ":")
128+ if len(parts) == 2 {
129+ cores, err := strconv.Atoi(strings.TrimSpace(parts[1]))
130+ if err == nil {
131+ return cores
132+ }
133+ }
134+ }
135+ }
136+ return 0
137+}
138+
139+// detectMemory reads memory info from /proc/meminfo.
140+func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) {
141+ log := logging.FromContext(ctx)
142+
143+ file, err := os.Open("/proc/meminfo")
144+ if err != nil {
145+ log.Debug().Err(err).Msg("cannot read /proc/meminfo")
146+ return 0, 0
147+ }
148+ defer func() { _ = file.Close() }()
149+
150+ scanner := bufio.NewScanner(file)
151+ for scanner.Scan() {
152+ line := scanner.Text()
153+ if strings.HasPrefix(line, "MemTotal:") {
154+ total = s.parseMemInfoLine(line)
155+ } else if strings.HasPrefix(line, "MemAvailable:") {
156+ available = s.parseMemInfoLine(line)
157+ }
158+ if total > 0 && available > 0 {
159+ break
160+ }
161+ }
162+ return total, available
163+}
164+
165+// parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB".
166+func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 {
167+ parts := strings.Fields(line)
168+ if len(parts) < 2 {
169+ return 0
170+ }
171+ value, err := strconv.ParseUint(parts[1], 10, 64)
172+ if err != nil {
173+ return 0
174+ }
175+ // /proc/meminfo reports in kB (actually KiB)
176+ return value * kibibyte
177+}
178+
179+// detectGPU detects GPU vendor, name, and VRAM from DRM subsystem.
180+// When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM.
181+func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) {
182+ log := logging.FromContext(ctx)
183+
184+ const drmBase = "/sys/class/drm"
185+ cards := []string{"card0", "card1", "card2", "card3"}
186+
187+ var bestVendor port.GPUVendor
188+ var bestName string
189+ var bestVRAM uint64
190+ var bestCard string
191+
192+ for _, card := range cards {
193+ cardPath := filepath.Join(drmBase, card)
194+ if _, err := os.Stat(cardPath); os.IsNotExist(err) {
195+ continue
196+ }
197+
198+ vendor := s.detectGPUVendor(cardPath)
199+ if vendor == port.GPUVendorUnknown {
200+ continue
201+ }
202+
203+ name := s.detectGPUName(ctx, cardPath)
204+ vram := s.detectVRAM(ctx, cardPath)
205+
206+ log.Debug().
207+ Str("card", card).
208+ Str("vendor", string(vendor)).
209+ Str("name", name).
210+ Uint64("vram_mb", vram/(1024*1024)).
211+ Msg("found GPU")
212+
213+ // Prefer GPU with more VRAM (discrete > integrated)
214+ if vram > bestVRAM {
215+ bestVendor = vendor
216+ bestName = name
217+ bestVRAM = vram
218+ bestCard = card
219+ }
220+ }
221+
222+ if bestVendor != port.GPUVendorUnknown {
223+ log.Debug().
224+ Str("card", bestCard).
225+ Str("vendor", string(bestVendor)).
226+ Uint64("vram_mb", bestVRAM/(1024*1024)).
227+ Msg("selected GPU (highest VRAM)")
228+ }
229+
230+ return bestVendor, bestName, bestVRAM
231+}
232+
233+// detectGPUVendor reads the PCI vendor ID.
234+func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor {
235+ vendorPath := filepath.Join(cardPath, "device", "vendor")
236+ data, err := os.ReadFile(vendorPath)
237+ if err != nil {
238+ return port.GPUVendorUnknown
239+ }
240+
241+ vendorID := strings.TrimSpace(string(data))
242+ switch vendorID {
243+ case "0x1002":
244+ return port.GPUVendorAMD
245+ case "0x8086":
246+ return port.GPUVendorIntel
247+ case "0x10de":
248+ return port.GPUVendorNVIDIA
249+ default:
250+ return port.GPUVendorUnknown
251+ }
252+}
253+
254+// detectGPUName reads the GPU product name from various sources.
255+func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string {
256+ log := logging.FromContext(ctx)
257+
258+ // Try reading from device/label first (some drivers provide this)
259+ labelPath := filepath.Join(cardPath, "device", "label")
260+ if data, err := os.ReadFile(labelPath); err == nil {
261+ name := strings.TrimSpace(string(data))
262+ if name != "" {
263+ return name
264+ }
265+ }
266+
267+ // Try reading product name from device subsystem (uevent)
268+ ueventPath := filepath.Join(cardPath, "device", "uevent")
269+ if file, err := os.Open(ueventPath); err == nil {
270+ scanner := bufio.NewScanner(file)
271+ for scanner.Scan() {
272+ line := scanner.Text()
273+ if strings.HasPrefix(line, "PCI_SLOT_NAME=") {
274+ // This gives us the PCI address, useful for debugging
275+ log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot")
276+ }
277+ }
278+ _ = file.Close()
279+ }
280+
281+ // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually)
282+ // For now, return empty and rely on vendor detection
283+ return ""
284+}
285+
286+// detectVRAM reads VRAM size from DRM memory info.
287+func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 {
288+ log := logging.FromContext(ctx)
289+
290+ // Method 1: AMD uses mem_info_vram_total in device directory
291+ amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total")
292+ if data, err := os.ReadFile(amdVRAMPath); err == nil {
293+ vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
294+ if err == nil && vram > 0 {
295+ log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total")
296+ return vram
297+ }
298+ }
299+
300+ // Method 2: Try reading from resource file (PCI BAR sizes)
301+ // The first non-zero memory region is usually VRAM
302+ if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 {
303+ return vram
304+ }
305+
306+ // Method 3: For Intel integrated, check i915 specific paths
307+ // Intel iGPU uses system RAM, so VRAM is usually 0 or shared
308+ return 0
309+}
310+
311+// detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes.
312+func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 {
313+ log := logging.FromContext(ctx)
314+
315+ resourcePath := filepath.Join(cardPath, "device", "resource")
316+ file, err := os.Open(resourcePath)
317+ if err != nil {
318+ return 0
319+ }
320+ defer func() { _ = file.Close() }()
321+
322+ const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions
323+ scanner := bufio.NewScanner(file)
324+ for scanner.Scan() {
325+ line := scanner.Text()
326+ parts := strings.Fields(line)
327+ if len(parts) >= 2 {
328+ start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64)
329+ end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64)
330+ if err1 == nil && err2 == nil && start > 0 && end > start {
331+ size := end - start + 1
332+ // VRAM is typically > 256MB, skip smaller regions (I/O, config)
333+ if size > minVRAMSize {
334+ log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource")
335+ return size
336+ }
337+ }
338+ }
339+ }
340+ return 0
341+}
342+
343+var _ 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