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: | |||
| 20 | 20 | WebViewPool: {} | |
| 21 | 21 | MemoryPressureApplier: {} | |
| 22 | 22 | WebKitContextProvider: {} | |
| 23 | + | HardwareSurveyor: {} | |
| 23 | 24 | github.com/bnema/dumber/internal/domain/repository: | |
| 24 | 25 | config: | |
| 25 | 26 | 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) | |||
| 638 | 638 | 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 | 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 | 720 | // The first argument is typically a *testing.T value. | |
| 643 | 721 | func NewMockSessionLogger(t interface { | |
| @@ -638,6 +638,84 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||||
| 638 | return _c | 638 | 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( | |||
| 32 | 32 | themeManager *theme.Manager, | |
| 33 | 33 | logger zerolog.Logger, | |
| 34 | 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 | 46 | // Resolve performance profile to get actual settings | |
| 36 | - | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance) | |
| 47 | + | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | |
| 37 | 48 | logger.Info(). | |
| 38 | 49 | Str("profile", string(cfg.Performance.Profile)). | |
| 39 | 50 | 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 settings | 46 | // 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | - | import "runtime" | |
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | const ( | |
| 6 | 10 | // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads | |
| 7 | 11 | // supported by WebKitGTK (0-8 range). | |
| 8 | 12 | 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 | 50 | // ResolvedPerformanceSettings contains the computed performance settings | |
| @@ -36,13 +75,15 @@ type ResolvedPerformanceSettings struct { | |||
| 36 | 75 | ||
| 37 | 76 | // ResolvePerformanceProfile computes the actual performance settings based on | |
| 38 | 77 | // 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 { | |
| 41 | 82 | switch cfg.Profile { | |
| 42 | 83 | case ProfileLite: | |
| 43 | - | return resolveLiteProfile() | |
| 84 | + | return resolveLiteProfile(hw) | |
| 44 | 85 | case ProfileMax: | |
| 45 | - | return resolveMaxProfile() | |
| 86 | + | return resolveMaxProfile(hw) | |
| 46 | 87 | case ProfileCustom: | |
| 47 | 88 | return resolveCustomProfile(cfg) | |
| 48 | 89 | default: // ProfileDefault or empty | |
| @@ -75,9 +116,16 @@ func resolveDefaultProfile() ResolvedPerformanceSettings { | |||
| 75 | 116 | } | |
| 76 | 117 | ||
| 77 | 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 | 127 | return ResolvedPerformanceSettings{ | |
| 80 | - | SkiaCPUPaintingThreads: 2, | |
| 128 | + | SkiaCPUPaintingThreads: cpuThreads, | |
| 81 | 129 | SkiaGPUPaintingThreads: -1, // unset | |
| 82 | 130 | SkiaEnableCPURendering: false, | |
| 83 | 131 | ||
| @@ -98,34 +146,121 @@ func resolveLiteProfile() ResolvedPerformanceSettings { | |||
| 98 | 146 | } | |
| 99 | 147 | ||
| 100 | 148 | // 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) | |
| 110 | 162 | ||
| 111 | 163 | return ResolvedPerformanceSettings{ | |
| 112 | 164 | SkiaCPUPaintingThreads: cpuThreads, | |
| 113 | - | SkiaGPUPaintingThreads: 2, | |
| 165 | + | SkiaGPUPaintingThreads: gpuThreads, | |
| 114 | 166 | SkiaEnableCPURendering: false, | |
| 115 | 167 | ||
| 116 | - | WebProcessMemoryLimitMB: 2048, | |
| 168 | + | WebProcessMemoryLimitMB: webMemMB, | |
| 117 | 169 | WebProcessMemoryPollIntervalSec: 0, // use WebKit default | |
| 118 | 170 | WebProcessMemoryConservativeThreshold: 0.5, | |
| 119 | 171 | WebProcessMemoryStrictThreshold: 0.7, | |
| 120 | 172 | WebProcessMemoryKillThreshold: -1, // never kill | |
| 121 | 173 | ||
| 122 | - | NetworkProcessMemoryLimitMB: 512, | |
| 174 | + | NetworkProcessMemoryLimitMB: netMemMB, | |
| 123 | 175 | NetworkProcessMemoryPollIntervalSec: 0, | |
| 124 | 176 | NetworkProcessMemoryConservativeThreshold: 0.5, | |
| 125 | 177 | NetworkProcessMemoryStrictThreshold: 0.7, | |
| 126 | 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 | ||
| @@ -1,11 +1,50 @@ | |||||
| 1 | package config | 1 | 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 threads | 10 | // 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 = 8 | 12 | 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 settings | 50 | // ResolvedPerformanceSettings contains the computed performance settings | ||
| @@ -36,13 +75,15 @@ type ResolvedPerformanceSettings struct { | |||||
| 36 | 75 | ||||
| 37 | // ResolvePerformanceProfile computes the actual performance settings based on | 76 | // 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 empty | 89 | 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, // unset | 129 | 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() / 2 | 151 | + | // CPU threads: use physical cores (not threads), capped at WebKitGTK limit |
| 104 | - | if cpuThreads < 4 { | 152 | + | cpuThreads := computeMaxCPUThreads(hw) |
| 105 | - | cpuThreads = 4 | 153 | + | |
| 106 | - | } | 154 | + | // GPU threads: scale based on VRAM |
| 107 | - | if cpuThreads > maxSkiaCPUThreads { | 155 | + | gpuThreads := computeMaxGPUThreads(hw) |
| 108 | - | cpuThreads = maxSkiaCPUThreads | 156 | + | |
| 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 default | 169 | 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 kill | 172 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "runtime" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestResolvePerformanceProfile_Default(t *testing.T) { | |
| @@ -10,7 +12,7 @@ func TestResolvePerformanceProfile_Default(t *testing.T) { | |||
| 10 | 12 | Profile: ProfileDefault, | |
| 11 | 13 | } | |
| 12 | 14 | ||
| 13 | - | result := ResolvePerformanceProfile(cfg) | |
| 15 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 14 | 16 | ||
| 15 | 17 | // Default should have no tuning (unset values) | |
| 16 | 18 | if result.SkiaCPUPaintingThreads != 0 { | |
| @@ -32,7 +34,7 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) { | |||
| 32 | 34 | Profile: "", // Empty should behave like default | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | - | result := ResolvePerformanceProfile(cfg) | |
| 37 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 36 | 38 | ||
| 37 | 39 | if result.SkiaCPUPaintingThreads != 0 { | |
| 38 | 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 | 48 | cfg := &PerformanceConfig{ | |
| 47 | 49 | Profile: ProfileLite, | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | - | result := ResolvePerformanceProfile(cfg) | |
| 52 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 51 | 53 | ||
| 52 | - | // Lite should have reduced resource usage | |
| 54 | + | // Lite without hardware info should use default lite values | |
| 53 | 55 | if result.SkiaCPUPaintingThreads != 2 { | |
| 54 | 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 | 90 | cfg := &PerformanceConfig{ | |
| 72 | 91 | Profile: ProfileMax, | |
| 73 | 92 | } | |
| 74 | 93 | ||
| 75 | - | result := ResolvePerformanceProfile(cfg) | |
| 94 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 76 | 95 | ||
| 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] | |
| 79 | 98 | expectedCPUThreads := runtime.NumCPU() / 2 | |
| 80 | 99 | if expectedCPUThreads < 4 { | |
| 81 | 100 | expectedCPUThreads = 4 | |
| @@ -86,9 +105,11 @@ func TestResolvePerformanceProfile_Max(t *testing.T) { | |||
| 86 | 105 | if result.SkiaCPUPaintingThreads != expectedCPUThreads { | |
| 87 | 106 | t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads) | |
| 88 | 107 | } | |
| 108 | + | // GPU threads: conservative default when no VRAM info | |
| 89 | 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 | 113 | if result.WebProcessMemoryLimitMB != 2048 { | |
| 93 | 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 | 223 | func TestResolvePerformanceProfile_Custom(t *testing.T) { | |
| 104 | 224 | cfg := &PerformanceConfig{ | |
| 105 | 225 | Profile: ProfileCustom, | |
| @@ -110,10 +230,17 @@ func TestResolvePerformanceProfile_Custom(t *testing.T) { | |||
| 110 | 230 | WebProcessMemoryConservativeThreshold: 0.4, | |
| 111 | 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 values | |
| 243 | + | // Custom should use user-specified values, ignoring hardware | |
| 117 | 244 | if result.SkiaCPUPaintingThreads != 6 { | |
| 118 | 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 | + | } | |
| @@ -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 default | 34 | 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 usage | 54 | + | // 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 allocation | 96 | + | // 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() / 2 | 98 | expectedCPUThreads := runtime.NumCPU() / 2 | ||
| 80 | if expectedCPUThreads < 4 { | 99 | if expectedCPUThreads < 4 { | ||
| 81 | expectedCPUThreads = 4 | 100 | 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 values | 243 | + | // 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) | |||