From fa129e23878d7acbc357553d5dda853502ea6583 Mon Sep 17 00:00:00 2001 From: Sivaprasad Tummala Date: Thu, 30 Jul 2026 13:46:22 +0000 Subject: [PATCH 1/3] refactor(cpu): consolidate CPU power meter implementations Consolidate all CPU-specific power measurement implementations (hwmon, RAPL, fake, mock) into cpu package. This refactor decouples CPU power metering logic from the generic device layer, improving code organization, modularity and maintainability while preserving existing functionality. Signed-off-by: Sivaprasad Tummala --- .gitignore | 1 + cmd/kepler/main.go | 4 +- internal/device/cpu/cpu_power_meter.go | 22 + .../create_meter.go} | 43 +- .../device/{ => cpu}/fake_cpu_power_meter.go | 44 +- .../{ => cpu}/fake_cpu_power_meter_test.go | 17 +- internal/device/{ => cpu}/hwmon_chip_rules.go | 2 +- .../device/{ => cpu}/hwmon_power_meter.go | 79 ++-- .../{ => cpu}/hwmon_power_meter_test.go | 388 +++++++++--------- .../device/{ => cpu}/mock_cpu_power_meter.go | 40 +- .../{ => cpu}/rapl_sysfs_power_meter.go | 47 +-- .../{ => cpu}/rapl_sysfs_power_meter_test.go | 119 +++--- .../{ => cpu}/rapl_zone_filtering_test.go | 11 +- internal/device/cpu_power_meter_test.go | 157 ------- internal/device/energy_zone.go | 19 +- internal/device/energy_zone_test.go | 8 +- internal/device/power_meter.go | 27 ++ .../power_collector_concurrency_test.go | 15 +- .../collector/power_collector_test.go | 15 +- internal/exporter/stdout/stdout_test.go | 5 +- internal/monitor/mock_utils.go | 25 +- internal/monitor/monitor.go | 6 +- internal/monitor/monitor_concurrency_test.go | 5 +- internal/monitor/monitor_test.go | 21 +- internal/monitor/node_test.go | 32 +- 25 files changed, 521 insertions(+), 631 deletions(-) create mode 100644 internal/device/cpu/cpu_power_meter.go rename internal/device/{cpu_power_meter.go => cpu/create_meter.go} (67%) rename internal/device/{ => cpu}/fake_cpu_power_meter.go (78%) rename internal/device/{ => cpu}/fake_cpu_power_meter_test.go (92%) rename internal/device/{ => cpu}/hwmon_chip_rules.go (99%) rename internal/device/{ => cpu}/hwmon_power_meter.go (94%) rename internal/device/{ => cpu}/hwmon_power_meter_test.go (88%) rename internal/device/{ => cpu}/mock_cpu_power_meter.go (70%) rename internal/device/{ => cpu}/rapl_sysfs_power_meter.go (82%) rename internal/device/{ => cpu}/rapl_sysfs_power_meter_test.go (83%) rename internal/device/{ => cpu}/rapl_zone_filtering_test.go (94%) delete mode 100644 internal/device/cpu_power_meter_test.go diff --git a/.gitignore b/.gitignore index 457b57b320..76f0e7e5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ go.work.sum tmp coverage.* +*.patch diff --git a/cmd/kepler/main.go b/cmd/kepler/main.go index 095ebfe457..100635b5e0 100644 --- a/cmd/kepler/main.go +++ b/cmd/kepler/main.go @@ -13,7 +13,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/sustainable-computing-io/kepler/config" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/device/gpu" _ "github.com/sustainable-computing-io/kepler/internal/device/gpu/nvidia" // Register NVIDIA backend "github.com/sustainable-computing-io/kepler/internal/exporter/prometheus" @@ -130,7 +130,7 @@ Configuration func createServices(logger *slog.Logger, cfg *config.Config) ([]service.Service, error) { logger.Debug("Creating all services") - cpuPowerMeter, err := device.CreateCPUMeter(logger, cfg) + cpuPowerMeter, err := cpu.CreateCPUMeter(logger, cfg) if err != nil { return nil, fmt.Errorf("failed to create CPU power meter: %w", err) } diff --git a/internal/device/cpu/cpu_power_meter.go b/internal/device/cpu/cpu_power_meter.go new file mode 100644 index 0000000000..0f53f55c95 --- /dev/null +++ b/internal/device/cpu/cpu_power_meter.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package cpu + +import ( + "github.com/sustainable-computing-io/kepler/internal/device" +) + +// CPUPowerMeter is the interface for CPU power measurement. +// It embeds PowerMeter and adds CPU-specific methods. +type CPUPowerMeter interface { + device.PowerMeter + + // Zones() returns a slice of the energy measurement zones + Zones() ([]device.EnergyZone, error) + + // PrimaryEnergyZone() returns the zone with the highest energy coverage/priority + // This zone represents the most comprehensive energy measurement available + // E.g. Psys > Package > Core > DRAM > Uncore + PrimaryEnergyZone() (device.EnergyZone, error) +} diff --git a/internal/device/cpu_power_meter.go b/internal/device/cpu/create_meter.go similarity index 67% rename from internal/device/cpu_power_meter.go rename to internal/device/cpu/create_meter.go index 1726584620..b07078680c 100644 --- a/internal/device/cpu_power_meter.go +++ b/internal/device/cpu/create_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -11,47 +11,6 @@ import ( "github.com/sustainable-computing-io/kepler/config" ) -// EnergyZone represents a measurable energy or power zone/domain exposed by a power meter. -// An EnergyZone typically represents a logical zone of the hardware unit, e.g. cpu core, cpu package -// dram, uncore etc. -// Reference: https://firefox-source-docs.mozilla.org/performance/power_profiling_overview.html -type EnergyZone interface { - // Name() returns the zone name - Name() string - - // Index() returns the index of the zone - Index() int - - // Path() returns the path from which the energy usage value ie being read - Path() string - - // Energy() returns energy consumed by the zone. - Energy() (Energy, error) - - // MaxEnergy returns the maximum value of energy usage that can be read. - // When energy usage reaches this value, the energy value returned by Energy() - // will wrap around and start again from zero. - MaxEnergy() Energy - - // Power() returns the current power consumption by the zone. - // This method is used for zones that provide instantaneous power readings. - Power() (Power, error) -} - -// CPUPowerMeter is the interface for CPU power measurement. -// It embeds PowerMeter and adds CPU-specific methods. -type CPUPowerMeter interface { - PowerMeter - - // Zones() returns a slice of the energy measurement zones - Zones() ([]EnergyZone, error) - - // PrimaryEnergyZone() returns the zone with the highest energy coverage/priority - // This zone represents the most comprehensive energy measurement available - // E.g. Psys > Package > Core > DRAM > Uncore - PrimaryEnergyZone() (EnergyZone, error) -} - // CreateCPUMeter walks cfg.Cpu.PreferredMeters in preference order, builds // each backend, runs Init(), and returns the first meter that reports zones. // diff --git a/internal/device/fake_cpu_power_meter.go b/internal/device/cpu/fake_cpu_power_meter.go similarity index 78% rename from internal/device/fake_cpu_power_meter.go rename to internal/device/cpu/fake_cpu_power_meter.go index a28013b2a5..5bd3707386 100644 --- a/internal/device/fake_cpu_power_meter.go +++ b/internal/device/cpu/fake_cpu_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "fmt" @@ -10,10 +10,12 @@ import ( "path/filepath" "strings" "sync" + + "github.com/sustainable-computing-io/kepler/internal/device" ) // NOTE: This fake meter is not intended to be used in production and is for testing only -var defaultFakeZones = []Zone{ZonePackage, ZoneCore, ZoneDRAM} +var defaultFakeZones = []device.Zone{device.ZonePackage, device.ZoneCore, device.ZoneDRAM} const defaultRaplPath = "/sys/class/powercap/intel-rapl" @@ -22,16 +24,16 @@ type fakeEnergyZone struct { name string index int path string - energy Energy - maxEnergy Energy + energy device.Energy + maxEnergy device.Energy mu sync.Mutex // For generating fake values - increment Energy + increment device.Energy randomFactor float64 } -var _ EnergyZone = (*fakeEnergyZone)(nil) +var _ device.EnergyZone = (*fakeEnergyZone)(nil) // Name returns the zone name func (z *fakeEnergyZone) Name() string { @@ -49,23 +51,23 @@ func (z *fakeEnergyZone) Path() string { } // Energy returns energy consumed by the zone. -func (z *fakeEnergyZone) Energy() (Energy, error) { +func (z *fakeEnergyZone) Energy() (device.Energy, error) { z.mu.Lock() defer z.mu.Unlock() - randomComponent := Energy(rand.Float64() * float64(z.increment) * z.randomFactor) + randomComponent := device.Energy(rand.Float64() * float64(z.increment) * z.randomFactor) z.energy = (z.energy + z.increment + randomComponent) % z.maxEnergy return z.energy, nil } // MaxEnergy returns the maximum value of energy usage that can be read. -func (z *fakeEnergyZone) MaxEnergy() Energy { +func (z *fakeEnergyZone) MaxEnergy() device.Energy { return z.maxEnergy } // Power for fake zones implemented to satisfy interface. -func (z *fakeEnergyZone) Power() (Power, error) { +func (z *fakeEnergyZone) Power() (device.Power, error) { // Fake zones don't provide power, only energy return 0, fmt.Errorf("fake zones do not provide power readings") } @@ -73,7 +75,7 @@ func (z *fakeEnergyZone) Power() (Power, error) { // fakeRaplMeter implements the CPUPowerMeter interface type fakeRaplMeter struct { logger *slog.Logger - zones []EnergyZone + zones []device.EnergyZone devicePath string } @@ -95,7 +97,7 @@ func WithFakePath(path string) FakeOptFn { } // WithFakeMaxEnergy sets the maximum energy value before wrap-around -func WithFakeMaxEnergy(e Energy) FakeOptFn { +func WithFakeMaxEnergy(e device.Energy) FakeOptFn { return func(m *fakeRaplMeter) { for _, z := range m.zones { if fz, ok := z.(*fakeEnergyZone); ok { @@ -124,14 +126,14 @@ func NewFakeCPUMeter(zones []string, opts ...FakeOptFn) (CPUPowerMeter, error) { zones = defaultFakeZones } - zoneIncrementFactor := map[Zone]int{ - ZonePackage: 12, - ZoneCore: 8, - ZoneDRAM: 5, - ZoneUncore: 2, + zoneIncrementFactor := map[device.Zone]int{ + device.ZonePackage: 12, + device.ZoneCore: 8, + device.ZoneDRAM: 5, + device.ZoneUncore: 2, } - meter.zones = make([]EnergyZone, 0, len(zones)) + meter.zones = make([]device.EnergyZone, 0, len(zones)) for i, zoneName := range zones { meter.zones = append(meter.zones, &fakeEnergyZone{ @@ -139,7 +141,7 @@ func NewFakeCPUMeter(zones []string, opts ...FakeOptFn) (CPUPowerMeter, error) { index: i, path: filepath.Join(defaultRaplPath, fmt.Sprintf("energy_%s", zoneName)), maxEnergy: 1000000, - increment: Energy(100 + zoneIncrementFactor[zoneName]), + increment: device.Energy(100 + zoneIncrementFactor[zoneName]), randomFactor: 0.5, }) } @@ -161,12 +163,12 @@ func (m *fakeRaplMeter) Init() error { return nil } -func (m *fakeRaplMeter) Zones() ([]EnergyZone, error) { +func (m *fakeRaplMeter) Zones() ([]device.EnergyZone, error) { return m.zones, nil } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (m *fakeRaplMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (m *fakeRaplMeter) PrimaryEnergyZone() (device.EnergyZone, error) { zones, err := m.Zones() if err != nil { return nil, err diff --git a/internal/device/fake_cpu_power_meter_test.go b/internal/device/cpu/fake_cpu_power_meter_test.go similarity index 92% rename from internal/device/fake_cpu_power_meter_test.go rename to internal/device/cpu/fake_cpu_power_meter_test.go index b85de19a73..de646e5672 100644 --- a/internal/device/fake_cpu_power_meter_test.go +++ b/internal/device/cpu/fake_cpu_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/sustainable-computing-io/kepler/internal/device" ) func TestNewFakeCPUMeter(t *testing.T) { @@ -52,7 +53,7 @@ func TestFakeEnergyZone_Basics(t *testing.T) { assert.Equal(t, "test-zone", zone.Name()) assert.Equal(t, 42, zone.Index()) assert.Equal(t, "/fake/path/energy_test-zone", zone.Path()) - assert.Equal(t, Energy(500000), zone.MaxEnergy()) + assert.Equal(t, device.Energy(500000), zone.MaxEnergy()) } func TestFakeEnergyZone_Energy(t *testing.T) { @@ -67,18 +68,18 @@ func TestFakeEnergyZone_Energy(t *testing.T) { // First read should return the increment e1, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(100), e1) + assert.Equal(t, device.Energy(100), e1) // Second read should return double the increment e2, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(200), e2) + assert.Equal(t, device.Energy(200), e2) // Test wrap-around at maxEnergy zone.energy = 950 e3, err := zone.Energy() assert.NoError(t, err) - assert.Equal(t, Energy(50), e3) // Wrapped around: 950 + 100 = 1050, but 1050 % 1000 = 50 + assert.Equal(t, device.Energy(50), e3) // Wrapped around: 950 + 100 = 1050, but 1050 % 1000 = 50 } func TestWithFakeZones(t *testing.T) { @@ -125,7 +126,7 @@ func TestWithFakePath(t *testing.T) { } func TestWithFakeMaxEnergy(t *testing.T) { - customMax := Energy(999999) + customMax := device.Energy(999999) meter, err := NewFakeCPUMeter(nil, WithFakeMaxEnergy(customMax)) assert.NoError(t, err) @@ -151,7 +152,7 @@ func TestWithFakeLogger(t *testing.T) { func TestMultipleOptions(t *testing.T) { customPath := "/custom/rapl/path" - customMax := Energy(888888) + customMax := device.Energy(888888) customZones := []string{"custom1", "custom2"} logger := slog.Default().With("test", "logger") @@ -190,7 +191,7 @@ func TestEnergyRandomness(t *testing.T) { } // Read energy multiple times - var readings []Energy + var readings []device.Energy for range 10 { e, err := zone.Energy() assert.NoError(t, err) diff --git a/internal/device/hwmon_chip_rules.go b/internal/device/cpu/hwmon_chip_rules.go similarity index 99% rename from internal/device/hwmon_chip_rules.go rename to internal/device/cpu/hwmon_chip_rules.go index 72a67e67c1..55f00cc04b 100644 --- a/internal/device/hwmon_chip_rules.go +++ b/internal/device/cpu/hwmon_chip_rules.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import "strings" diff --git a/internal/device/hwmon_power_meter.go b/internal/device/cpu/hwmon_power_meter.go similarity index 94% rename from internal/device/hwmon_power_meter.go rename to internal/device/cpu/hwmon_power_meter.go index 696f6dd33e..a0007a753f 100644 --- a/internal/device/hwmon_power_meter.go +++ b/internal/device/cpu/hwmon_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -14,16 +14,17 @@ import ( "strconv" "strings" + "github.com/sustainable-computing-io/kepler/internal/device" "golang.org/x/sys/unix" ) // hwmonPowerMeter implements CPUPowerMeter using hwmon sysfs type hwmonPowerMeter struct { reader hwmonReader - cachedZones []EnergyZone + cachedZones []device.EnergyZone logger *slog.Logger zoneFilter []string - topZone EnergyZone + topZone device.EnergyZone } // HwmonOptionFn is a function that configures hwmonPowerMeter options @@ -31,7 +32,7 @@ type HwmonOptionFn func(*hwmonPowerMeter) // hwmonReader is an interface for reading hwmon data, used for mocking in tests type hwmonReader interface { - Zones() ([]EnergyZone, error) + Zones() ([]device.EnergyZone, error) } // WithHwmonReader sets the hwmonReader to be used by hwmonPowerMeter @@ -138,7 +139,7 @@ func (h *hwmonPowerMeter) needsZoneFiltering() bool { return len(h.zoneFilter) != 0 } -func (h *hwmonPowerMeter) Zones() ([]EnergyZone, error) { +func (h *hwmonPowerMeter) Zones() ([]device.EnergyZone, error) { // Return cached zones if already initialized if len(h.cachedZones) != 0 { return h.cachedZones, nil @@ -162,7 +163,7 @@ func (h *hwmonPowerMeter) Zones() ([]EnergyZone, error) { } // filterZones applies zone filters -func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { +func (h *hwmonPowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { if !h.needsZoneFiltering() { return zones } @@ -173,7 +174,7 @@ func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { } var included, excluded []string - filtered := make([]EnergyZone, 0, len(zones)) + filtered := make([]device.EnergyZone, 0, len(zones)) for _, zone := range zones { // Check zone filter @@ -192,9 +193,9 @@ func (h *hwmonPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { // groupZonesByName groups zones by their base name and creates AggregatedZone // instances when multiple zones share the same name -func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { +func (h *hwmonPowerMeter) groupZonesByName(zones []device.EnergyZone) []device.EnergyZone { // Group zones by base name - zoneGroups := make(map[string][]EnergyZone) + zoneGroups := make(map[string][]device.EnergyZone) for _, zone := range zones { name := zone.Name() @@ -202,7 +203,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { } // Create aggregated zones for duplicates, keep single zones as-is - var result []EnergyZone + var result []device.EnergyZone for name, zones := range zoneGroups { if len(zones) == 1 { result = append(result, zones[0]) @@ -213,7 +214,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { // LIMITATION: aggregation occurs when the devices are different with coincidentally // the same labels. This should not happen. Ideally, Kepler identifies whether the zones with same // name occur due to multi-socket CPU or independent devices. - aggregated, _ := NewAggregatedZone(zones) + aggregated, _ := device.NewAggregatedZone(zones) result = append(result, aggregated) h.logger.Debug("Created aggregated zone", "name", name, @@ -229,7 +230,7 @@ func (h *hwmonPowerMeter) groupZonesByName(zones []EnergyZone) []EnergyZone { } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (h *hwmonPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (h *hwmonPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { // Return cached zone if already initialized if h.topZone != nil { return h.topZone, nil @@ -244,7 +245,7 @@ func (h *hwmonPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { return nil, fmt.Errorf("no energy zones available") } - zoneMap := map[string]EnergyZone{} + zoneMap := map[string]device.EnergyZone{} for _, zone := range zones { zoneMap[strings.ToLower(zone.Name())] = zone } @@ -284,7 +285,7 @@ var ( hwmonInvalidMetricChars = regexp.MustCompile("[^a-z0-9:_]") ) -func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { +func (r *sysfsHwmonReader) Zones() ([]device.EnergyZone, error) { hwmonDirs, err := os.ReadDir(r.basePath) if err != nil { if os.IsNotExist(err) { @@ -293,7 +294,7 @@ func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { return nil, fmt.Errorf("failed to read hwmon directory: %w", err) } - var zones []EnergyZone + var zones []device.EnergyZone for _, entry := range hwmonDirs { // check for valid hwmon devices if !entry.IsDir() && !isSymlink(filepath.Join(r.basePath, entry.Name())) { @@ -319,7 +320,7 @@ func (r *sysfsHwmonReader) Zones() ([]EnergyZone, error) { return zones, nil } -func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]EnergyZone, error) { +func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]device.EnergyZone, error) { // Get chip name chipName, err := r.getChipName(hwmonPath) if err != nil { @@ -335,7 +336,7 @@ func (r *sysfsHwmonReader) discoverZones(hwmonPath string) ([]EnergyZone, error) return nil, fmt.Errorf("failed to retrieve sensor files: %w", err) } - var zones []EnergyZone + var zones []device.EnergyZone // Tier 1: Energy sensors (preferred — cumulative µJ, like RAPL) energySensors := r.findSensorsByType(files, "energy") @@ -419,7 +420,7 @@ func (r *sysfsHwmonReader) createPowerZone( hwmonPath, chipName, humanName string, sensorNum int, sensorFiles map[string]string, -) (EnergyZone, error) { +) (device.EnergyZone, error) { // Determine the zone name from label or generate one var zoneName string if labelFile, hasLabel := sensorFiles["label"]; hasLabel { @@ -460,7 +461,7 @@ func (r *sysfsHwmonReader) createEnergyZone( hwmonPath, chipName, humanName string, sensorNum int, sensorFiles map[string]string, -) (EnergyZone, error) { +) (device.EnergyZone, error) { // Determine the zone name from label or generate one var zoneName string if labelFile, hasLabel := sensorFiles["label"]; hasLabel { @@ -510,7 +511,7 @@ var ErrVoltageCurrentNoLabels = fmt.Errorf("voltage and current sensors found bu func (r *sysfsHwmonReader) discoverVoltageCurrentZones( hwmonPath, chipName, humanName string, files []os.DirEntry, -) ([]EnergyZone, error) { +) ([]device.EnergyZone, error) { // Find voltage sensors (in*) and current sensors (curr*) voltageSensors := r.findSensorsByType(files, "in") currentSensors := r.findSensorsByType(files, "curr") @@ -609,7 +610,7 @@ func (r *sysfsHwmonReader) discoverVoltageCurrentZones( currentByIndex[sensorNum] = info } - var zones []EnergyZone + var zones []device.EnergyZone // PRIORITY 1: Label-based matching // This is the most robust method - labels explicitly identify matching sensors @@ -642,8 +643,8 @@ func (r *sysfsHwmonReader) matchByLabel( chipName, humanName string, voltageLabelMap map[string]voltageSensorInfo, currentLabelMap map[string]currentSensorInfo, -) []EnergyZone { - var zones []EnergyZone +) []device.EnergyZone { + var zones []device.EnergyZone for label, voltageInfo := range voltageLabelMap { currentInfo, found := currentLabelMap[label] @@ -668,14 +669,14 @@ func (r *sysfsHwmonReader) matchByChipRule( chipName, humanName string, voltageByIndex map[int]voltageSensorInfo, currentByIndex map[int]currentSensorInfo, -) []EnergyZone { +) []device.EnergyZone { // Get the pairing rule for this chip (config rules take precedence over hardcoded) rule := getChipPairingRule(humanName, r.configChipRules) if rule == nil { return nil } - var zones []EnergyZone + var zones []device.EnergyZone if rule.useSameIndex { // Same-index pairing with skip rules @@ -733,8 +734,8 @@ func (r *sysfsHwmonReader) matchBySameIndex( chipName, humanName string, voltageByIndex map[int]voltageSensorInfo, currentByIndex map[int]currentSensorInfo, -) []EnergyZone { - var zones []EnergyZone +) []device.EnergyZone { + var zones []device.EnergyZone for idx, voltageInfo := range voltageByIndex { currentInfo, found := currentByIndex[idx] @@ -980,18 +981,18 @@ func (z *hwmonPowerZone) Path() string { return z.path } -func (z *hwmonPowerZone) Energy() (Energy, error) { +func (z *hwmonPowerZone) Energy() (device.Energy, error) { // hwmon provides power, not energy // Return 0 for interface compatibility return 0, fmt.Errorf("hwmon zones do not provide energy readings") } -func (z *hwmonPowerZone) MaxEnergy() Energy { +func (z *hwmonPowerZone) MaxEnergy() device.Energy { // No maximum for power sensors return 0 } -func (z *hwmonPowerZone) Power() (Power, error) { +func (z *hwmonPowerZone) Power() (device.Power, error) { // Read current power value using direct syscall to avoid EAGAIN polling issues data, err := sysReadFile(z.path) if err != nil { @@ -1005,7 +1006,7 @@ func (z *hwmonPowerZone) Power() (Power, error) { } // Power type represents microwatts - return Power(powerMicrowatts), nil + return device.Power(powerMicrowatts), nil } func (z *hwmonVoltageCurrentZone) Name() string { @@ -1021,12 +1022,12 @@ func (z *hwmonVoltageCurrentZone) Path() string { return z.voltagePath } -func (z *hwmonVoltageCurrentZone) Energy() (Energy, error) { +func (z *hwmonVoltageCurrentZone) Energy() (device.Energy, error) { // Calculated power zones do not provide energy readings return 0, fmt.Errorf("hwmon calculated power zones do not provide energy readings") } -func (z *hwmonVoltageCurrentZone) MaxEnergy() Energy { +func (z *hwmonVoltageCurrentZone) MaxEnergy() device.Energy { // No maximum for calculated power zones return 0 } @@ -1054,7 +1055,7 @@ func (z *hwmonEnergyZone) Path() string { return z.path } -func (z *hwmonEnergyZone) Energy() (Energy, error) { +func (z *hwmonEnergyZone) Energy() (device.Energy, error) { data, err := sysReadFile(z.path) if err != nil { return 0, fmt.Errorf("failed to read energy from %s: %w", z.path, err) @@ -1066,21 +1067,21 @@ func (z *hwmonEnergyZone) Energy() (Energy, error) { return 0, fmt.Errorf("failed to parse energy value from %s: %w", z.path, err) } - return Energy(microjoules), nil + return device.Energy(microjoules), nil } -func (z *hwmonEnergyZone) MaxEnergy() Energy { +func (z *hwmonEnergyZone) MaxEnergy() device.Energy { // hwmon energy sensors have no max_energy_range equivalent. // The monitor detects energy zones via energyReading > 0 as fallback. return 0 } -func (z *hwmonEnergyZone) Power() (Power, error) { +func (z *hwmonEnergyZone) Power() (device.Power, error) { // Energy zones provide cumulative energy, not instantaneous power return 0, fmt.Errorf("hwmon energy zones do not provide instantaneous power readings") } -func (z *hwmonVoltageCurrentZone) Power() (Power, error) { +func (z *hwmonVoltageCurrentZone) Power() (device.Power, error) { // Read voltage in millivolts voltageData, err := sysReadFile(z.voltagePath) if err != nil { @@ -1109,5 +1110,5 @@ func (z *hwmonVoltageCurrentZone) Power() (Power, error) { // Example: 12000 mV × 5000 mA = 60,000,000 µW = 60 W powerMicrowatts := voltageMV * currentMA - return Power(powerMicrowatts), nil + return device.Power(powerMicrowatts), nil } diff --git a/internal/device/hwmon_power_meter_test.go b/internal/device/cpu/hwmon_power_meter_test.go similarity index 88% rename from internal/device/hwmon_power_meter_test.go rename to internal/device/cpu/hwmon_power_meter_test.go index 7a6df8b843..d187793f65 100644 --- a/internal/device/hwmon_power_meter_test.go +++ b/internal/device/cpu/hwmon_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" @@ -12,11 +12,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) const ( - validHwmonPath = "testdata/sys" - badHwmonPath = "testdata/bad_sysfs" + validHwmonPath = "../testdata/sys" + badHwmonPath = "../testdata/bad_sysfs" ) // TestHwmonPowerMeterInterface ensures that hwmonPowerMeter properly implements the CPUPowerMeter interface @@ -46,11 +47,11 @@ func TestWithHwmonReader(t *testing.T) { // Create a mock reader mockReader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Create meter with custom reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Verify the custom reader was set @@ -68,7 +69,7 @@ func TestWithHwmonLogger(t *testing.T) { })) // Create meter with custom logger - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonLogger(customLogger)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonLogger(customLogger)) require.NoError(t, err) // Verify logger was set (check that it's not nil and has "service" attribute) @@ -85,7 +86,7 @@ func TestWithHwmonZoneFilter(t *testing.T) { // Create meter with zone filter zoneFilter := []string{"package", "core"} - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonZoneFilter(zoneFilter)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonZoneFilter(zoneFilter)) require.NoError(t, err) // Verify zone filter was set @@ -101,7 +102,7 @@ func TestNewHwmonPowerMeter_WithMultipleOptions(t *testing.T) { // Create custom components mockReader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } customLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelDebug, @@ -109,7 +110,7 @@ func TestNewHwmonPowerMeter_WithMultipleOptions(t *testing.T) { zoneFilter := []string{"package"} // Create meter with all options - meter, err := NewHwmonPowerMeter("testdata/sys", + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader), WithHwmonLogger(customLogger), WithHwmonZoneFilter(zoneFilter)) @@ -211,18 +212,18 @@ func TestHwmonPowerMeter_Zones(t *testing.T) { t.Logf(" Index: %d", zone.Index()) t.Logf(" Path: %s", zone.Path()) - // Test reading capability - zones provide either Power() or Energy() + // Test reading capability - zones provide either device.Power() or device.Energy() power, powerErr := zone.Power() energy, energyErr := zone.Energy() if _, ok := zone.(*hwmonEnergyZone); ok { - // Energy zones: Energy() succeeds, Power() fails - assert.NoError(t, energyErr, "Energy() should succeed for energy zone %s", zone.Name()) - assert.Error(t, powerErr, "Power() should return error for energy zone %s", zone.Name()) + // Energy zones: device.Energy() succeeds, device.Power() fails + assert.NoError(t, energyErr, "device.Energy() should succeed for energy zone %s", zone.Name()) + assert.Error(t, powerErr, "device.Power() should return error for energy zone %s", zone.Name()) t.Logf(" Energy: %d µJ", energy) } else { - // Power zones: Power() succeeds, Energy() fails - assert.NoError(t, powerErr, "Power() should not return error for zone %s", zone.Name()) + // Power zones: device.Power() succeeds, device.Energy() fails + assert.NoError(t, powerErr, "device.Power() should not return error for zone %s", zone.Name()) t.Logf(" Power: %.2f W", power.Watts()) } } @@ -250,9 +251,9 @@ func TestHwmonPowerMeter_ZoneDetails(t *testing.T) { t.Logf("\n=== Detailed Zone Information ===") for _, zone := range zones { // Check if this is an aggregated zone or regular zone - if aggZone, ok := zone.(*AggregatedZone); ok { + if aggZone, ok := zone.(*device.AggregatedZone); ok { t.Logf("\nAggregated Zone: %s", zone.Name()) - t.Logf(" Contains: %d individual zones", len(aggZone.zones)) + t.Logf(" Contains: %d individual zones", aggZone.Count()) t.Logf(" Index: %d (aggregated)", zone.Index()) power, err := zone.Power() @@ -332,7 +333,7 @@ func TestHwmonPowerMeter_PowerReadings(t *testing.T) { } for _, zone := range zones { - // Skip energy-only zones (they don't provide Power()) + // Skip energy-only zones (they don't provide device.Power()) if _, ok := zone.(*hwmonEnergyZone); ok { energy, err := zone.Energy() require.NoError(t, err, "Failed to read energy for zone %s", zone.Name()) @@ -493,7 +494,7 @@ func TestHwmonPowerZone_Interface(t *testing.T) { zone := &hwmonPowerZone{ name: "test_zone", index: 1, - path: "testdata/sys/class/hwmon/hwmon0/power1_input", + path: "../testdata/sys/class/hwmon/hwmon0/power1_input", chipName: "test_chip", humanName: "test_human", } @@ -501,18 +502,18 @@ func TestHwmonPowerZone_Interface(t *testing.T) { t.Logf("\n=== Testing hwmonPowerZone Interface ===") assert.Equal(t, "test_zone", zone.Name()) assert.Equal(t, 1, zone.Index()) - assert.Equal(t, "testdata/sys/class/hwmon/hwmon0/power1_input", zone.Path()) + assert.Equal(t, "../testdata/sys/class/hwmon/hwmon0/power1_input", zone.Path()) - // Test Energy() returns 0 + // Test device.Energy() returns 0 energy, err := zone.Energy() assert.EqualError(t, err, "hwmon zones do not provide energy readings") - assert.Equal(t, Energy(0), energy, "Energy() should return 0") + assert.Equal(t, device.Energy(0), energy, "device.Energy() should return 0") // Test MaxEnergy() returns 0 maxEnergy := zone.MaxEnergy() - assert.Equal(t, Energy(0), maxEnergy, "MaxEnergy() should return 0") + assert.Equal(t, device.Energy(0), maxEnergy, "MaxEnergy() should return 0") - // Test Power() reads actual value + // Test device.Power() reads actual value power, err := zone.Power() assert.NoError(t, err) assert.InDelta(t, 45.0, power.Watts(), 0.01, "Power should be 45W from test file") @@ -641,13 +642,13 @@ func TestHwmonPowerMeter_RealSystem(t *testing.T) { // TestGetChipName_DevicePathStrategy tests chip name derivation from device symlink func TestGetChipName_DevicePathStrategy(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Device Path Strategy for Chip Name ===") // Test with device symlink present - hwmonPath := "testdata/sys/class/hwmon/hwmon_device_path" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_device_path" chipName, err := reader.getChipName(hwmonPath) require.NoError(t, err, "getChipName should not fail with device symlink") @@ -663,13 +664,13 @@ func TestGetChipName_DevicePathStrategy(t *testing.T) { // TestGetChipName_DirectoryFallback tests chip name derivation from directory name func TestGetChipName_DirectoryFallback(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Directory Name Fallback for Chip Name ===") // Test with no name file and no device symlink - hwmonPath := "testdata/sys/class/hwmon/hwmon_dir_fallback" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_dir_fallback" chipName, err := reader.getChipName(hwmonPath) require.NoError(t, err, "getChipName should fall back to directory name") @@ -684,7 +685,7 @@ func TestGetChipName_DirectoryFallback(t *testing.T) { // TestGetChipName_AllStrategies tests all chip name derivation strategies func TestGetChipName_AllStrategies(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } testCases := []struct { @@ -696,21 +697,21 @@ func TestGetChipName_AllStrategies(t *testing.T) { }{ { name: "device_path_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon_device_path", + hwmonPath: "../testdata/sys/class/hwmon/hwmon_device_path", expectError: false, expectedContains: "card0", description: "Should derive name from device symlink path", }, { name: "name_file_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon0", + hwmonPath: "../testdata/sys/class/hwmon/hwmon0", expectError: false, expectedContains: "k10temp", description: "Should read name from 'name' file", }, { name: "directory_fallback_strategy", - hwmonPath: "testdata/sys/class/hwmon/hwmon_dir_fallback", + hwmonPath: "../testdata/sys/class/hwmon/hwmon_dir_fallback", expectError: false, expectedContains: "hwmon_dir_fallback", description: "Should fall back to directory name", @@ -738,12 +739,12 @@ func TestGetChipName_AllStrategies(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_DevicePath tests zone discovery with device path func TestSysfsHwmonReader_DiscoverZones_DevicePath(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Device Path ===") - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_device_path") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_device_path") require.NoError(t, err, "Should discover zones in hwmon_device_path") assert.NotEmpty(t, zones, "Should find at least one zone") @@ -772,12 +773,12 @@ func TestSysfsHwmonReader_DiscoverZones_DevicePath(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_DirectoryFallback tests zone discovery with dir fallback func TestSysfsHwmonReader_DiscoverZones_DirectoryFallback(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Directory Fallback ===") - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_dir_fallback") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_dir_fallback") require.NoError(t, err, "Should discover zones in hwmon_dir_fallback") assert.NotEmpty(t, zones, "Should find at least one zone") @@ -833,7 +834,7 @@ func TestGetChipName_DeviceNameOnly(t *testing.T) { // TestGetChipName_ErrorCases tests error conditions func TestGetChipName_ErrorCases(t *testing.T) { - reader := &sysfsHwmonReader{basePath: "testdata/sys/class/hwmon"} + reader := &sysfsHwmonReader{basePath: "../testdata/sys/class/hwmon"} t.Run("nonexistent_path", func(t *testing.T) { _, err := reader.getChipName("nonexistent/path/that/does/not/exist") @@ -933,7 +934,7 @@ func TestIsSymlink(t *testing.T) { // Test with existing test data t.Run("test_with_existing_symlink_in_testdata", func(t *testing.T) { // We created a device symlink in hwmon_device_path earlier - deviceSymlink := "testdata/sys/class/hwmon/hwmon_device_path/device" + deviceSymlink := "../testdata/sys/class/hwmon/hwmon_device_path/device" result := isSymlink(deviceSymlink) assert.True(t, result, "isSymlink should return true for device symlink") @@ -946,7 +947,7 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { t.Logf("\n=== Testing Aggregated Zones ===") // Create a meter that will discover zones with duplicate names - meter, err := NewHwmonPowerMeter("testdata/sys") + meter, err := NewHwmonPowerMeter("../testdata/sys") require.NoError(t, err) // Discover zones - this should trigger aggregation @@ -956,7 +957,7 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { t.Logf("Found %d zones after aggregation", len(zones)) // Look for the aggregated "package" zone - var aggregatedZone EnergyZone + var aggregatedZone device.EnergyZone for _, zone := range zones { if zone.Name() == "package" { aggregatedZone = zone @@ -966,20 +967,20 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { require.NotNil(t, aggregatedZone, "Should find 'package' zone") - // Check if it's an AggregatedZone by checking the index - // AggregatedZone has index = -1 + // Check if it's an device.AggregatedZone by checking the index + // device.AggregatedZone has index = -1 if aggregatedZone.Index() == -1 { - t.Logf("✓ Zone 'package' is an AggregatedZone (index=-1)") + t.Logf("✓ Zone 'package' is an device.AggregatedZone (index=-1)") - // Verify it's actually an AggregatedZone type - aggZone, ok := aggregatedZone.(*AggregatedZone) - require.True(t, ok, "Zone should be *AggregatedZone type") + // Verify it's actually an device.AggregatedZone type + aggZone, ok := aggregatedZone.(*device.AggregatedZone) + require.True(t, ok, "Zone should be *device.AggregatedZone type") - t.Logf(" Aggregated zone contains %d individual zones", len(aggZone.zones)) + t.Logf(" Aggregated zone contains %d individual zones", aggZone.Count()) assert.Equal(t, "package", aggZone.Name()) assert.Equal(t, -1, aggZone.Index()) - // Test Power() - should sum power from all package zones + // Test device.Power() - should sum power from all package zones totalPower, err := aggZone.Power() require.NoError(t, err) @@ -989,14 +990,14 @@ func TestHwmonPowerMeter_AggregatedZones(t *testing.T) { // Total should be sum of all package zones found assert.Greater(t, totalPower.Watts(), 0.0, "Aggregated power should be positive") - assert.Equal(t, len(aggZone.zones), 3, + assert.Equal(t, aggZone.Count(), 3, "Should aggregate all package zones from test data") // Expected: 45W + 50W + 30W = 125W assert.InDelta(t, 125.0, totalPower.Watts(), 0.01, "Aggregated power should be sum of all package zones (45W + 50W + 30W = 125W)") t.Logf(" Total aggregated power: %.2f W from %d zones", - totalPower.Watts(), len(aggZone.zones)) + totalPower.Watts(), aggZone.Count()) } else { t.Logf("Zone 'package' is a single zone (index=%d)", aggregatedZone.Index()) @@ -1010,7 +1011,7 @@ func TestGroupZonesByName(t *testing.T) { } t.Run("single_zone_per_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0}, &hwmonPowerZone{name: "core", index: 1}, &hwmonPowerZone{name: "gpu", index: 2}, @@ -1029,7 +1030,7 @@ func TestGroupZonesByName(t *testing.T) { }) t.Run("multiple_zones_same_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0, path: "/path1"}, &hwmonPowerZone{name: "package", index: 1, path: "/path2"}, &hwmonPowerZone{name: "core", index: 2, path: "/path3"}, @@ -1041,8 +1042,8 @@ func TestGroupZonesByName(t *testing.T) { "Should have 2 zones (package aggregated, core single)") // Find the package zone - var packageZone EnergyZone - var coreZone EnergyZone + var packageZone device.EnergyZone + var coreZone device.EnergyZone for _, zone := range result { if zone.Name() == "package" { packageZone = zone @@ -1068,7 +1069,7 @@ func TestGroupZonesByName(t *testing.T) { }) t.Run("all_zones_same_name", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ &hwmonPowerZone{name: "package", index: 0}, &hwmonPowerZone{name: "package", index: 1}, &hwmonPowerZone{name: "package", index: 2}, @@ -1082,9 +1083,9 @@ func TestGroupZonesByName(t *testing.T) { assert.Equal(t, -1, result[0].Index(), "Should be aggregated zone with index=-1") - aggZone, ok := result[0].(*AggregatedZone) - require.True(t, ok, "Should be AggregatedZone type") - assert.Equal(t, 3, len(aggZone.zones), + aggZone, ok := result[0].(*device.AggregatedZone) + require.True(t, ok, "Should be device.AggregatedZone type") + assert.Equal(t, 3, aggZone.Count(), "Should aggregate all 3 zones") t.Logf("✓ All zones with same name are aggregated into one") @@ -1094,13 +1095,13 @@ func TestGroupZonesByName(t *testing.T) { // TestSysfsHwmonReader_DiscoverZones_Aggregation tests zone discovery with aggregation func TestSysfsHwmonReader_DiscoverZones_Aggregation(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Logf("\n=== Testing Zone Discovery with Aggregation ===") // hwmon_aggregate has two power sensors with the same label "package" - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_aggregate") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_aggregate") require.NoError(t, err, "Should discover zones in hwmon_aggregate") assert.Equal(t, 2, len(zones), "Should find 2 zones with same name") @@ -1161,20 +1162,20 @@ func TestCleanMetricName(t *testing.T) { // mockHwmonReader is a mock implementation of hwmonReader for testing error paths type mockHwmonReader struct { - zones []EnergyZone + zones []device.EnergyZone err error } -func (m *mockHwmonReader) Zones() ([]EnergyZone, error) { +func (m *mockHwmonReader) Zones() ([]device.EnergyZone, error) { return m.zones, m.err } -// TestHwmonPowerMeter_Init_PowerReadError tests Init() error path when Power() fails +// TestHwmonPowerMeter_Init_PowerReadError tests Init() error path when device.Power() fails func TestHwmonPowerMeter_Init_PowerReadError(t *testing.T) { t.Logf("\n=== Testing Init() Error Path: Power Read Failure ===") - // Create a mock zone that fails on both Power() and Energy() calls - mockZone := &mockEnergyZone{ + // Create a mock zone that fails on both device.Power() and device.Energy() calls + mockZone := &testMockEnergyZone{ name: "test_zone", index: 0, path: "/fake/path", @@ -1185,17 +1186,17 @@ func TestHwmonPowerMeter_Init_PowerReadError(t *testing.T) { // Create mock reader that returns the failing zone mockReader := &mockHwmonReader{ - zones: []EnergyZone{mockZone}, + zones: []device.EnergyZone{mockZone}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) - // Init should fail when both Power() and Energy() fail on first zone + // Init should fail when both device.Power() and device.Energy() fail on first zone err = meter.Init() - assert.Error(t, err, "Init() should fail when both Power() and Energy() fail on first zone") + assert.Error(t, err, "Init() should fail when both device.Power() and device.Energy() fail on first zone") t.Logf("✓ Init() correctly failed with error: %v", err) } @@ -1210,7 +1211,7 @@ func TestHwmonPowerMeter_Zones_ReaderError(t *testing.T) { } // Create meter with failing mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Zones() should return the error from reader @@ -1225,7 +1226,7 @@ func TestHwmonPowerMeter_Zones_AllFiltered(t *testing.T) { t.Logf("\n=== Testing Zones() Error Path: All Zones Filtered Out ===") // Create meter with zone filter that excludes all zones - meter, err := NewHwmonPowerMeter("testdata/sys", + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonZoneFilter([]string{"nonexistent_zone"})) require.NoError(t, err) @@ -1249,7 +1250,7 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_ZonesError(t *testing.T) { } // Create meter with failing mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone() should return the error from Zones() @@ -1265,12 +1266,12 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_NoZones(t *testing.T) { // Create mock reader that returns empty zones list mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone() should fail when zones list is empty @@ -1288,7 +1289,7 @@ func TestSysReadFile_ErrorPaths(t *testing.T) { t.Run("nonexistent_file", func(t *testing.T) { // Try to read a file that doesn't exist - data, err := sysReadFile("testdata/nonexistent_file_that_does_not_exist.txt") + data, err := sysReadFile("../testdata/nonexistent_file_that_does_not_exist.txt") assert.Error(t, err, "sysReadFile should fail for nonexistent file") assert.Nil(t, data, "Data should be nil on error") t.Logf("✓ Nonexistent file error: %v", err) @@ -1296,7 +1297,7 @@ func TestSysReadFile_ErrorPaths(t *testing.T) { t.Run("directory_instead_of_file", func(t *testing.T) { // Try to read a directory instead of a file - data, err := sysReadFile("testdata/sys/class/hwmon") + data, err := sysReadFile("../testdata/sys/class/hwmon") assert.Error(t, err, "sysReadFile should fail when trying to read a directory") assert.Nil(t, data, "Data should be nil on error") t.Logf("✓ Directory read error: %v", err) @@ -1309,12 +1310,12 @@ func TestHwmonPowerMeter_Init_NoZonesFound(t *testing.T) { // Create mock reader that returns empty zones mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Init should fail when no zones are found @@ -1331,12 +1332,12 @@ func TestHwmonPowerMeter_Zones_ReaderReturnsEmpty(t *testing.T) { // Create mock reader that returns empty zones list without error mockReader := &mockHwmonReader{ - zones: []EnergyZone{}, + zones: []device.EnergyZone{}, err: nil, } // Create meter with mock reader - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // Zones() should fail when reader returns empty list @@ -1348,7 +1349,7 @@ func TestHwmonPowerMeter_Zones_ReaderReturnsEmpty(t *testing.T) { t.Logf("✓ Zones() correctly failed with: %v", err) } -// TestHwmonPowerZone_Power_FileReadError tests Power() error when file read fails +// TestHwmonPowerZone_Power_FileReadError tests device.Power() error when file read fails func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { t.Logf("\n=== Testing hwmonPowerZone.Power() Error Paths ===") @@ -1356,12 +1357,12 @@ func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { zone := &hwmonPowerZone{ name: "test_zone", index: 0, - path: "testdata/nonexistent_power_file.txt", + path: "../testdata/nonexistent_power_file.txt", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for nonexistent file") - assert.Equal(t, Power(0), power, "Power should be 0 on error") + assert.Error(t, err, "device.Power() should fail for nonexistent file") + assert.Equal(t, device.Power(0), power, "Power should be 0 on error") assert.Contains(t, err.Error(), "failed to read power", "Error should mention power read failure") t.Logf("✓ Nonexistent file error: %v", err) @@ -1381,8 +1382,8 @@ func TestHwmonPowerZone_Power_FileReadError(t *testing.T) { } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid content") - assert.Equal(t, Power(0), power, "Power should be 0 on error") + assert.Error(t, err, "device.Power() should fail for invalid content") + assert.Equal(t, device.Power(0), power, "Power should be 0 on error") assert.Contains(t, err.Error(), "failed to parse power value", "Error should mention parse failure") t.Logf("✓ Invalid content error: %v", err) @@ -1394,7 +1395,7 @@ func TestSysfsHwmonReader_Zones_HwmonNotAvailable(t *testing.T) { t.Logf("\n=== Testing sysfsHwmonReader.Zones() Error: hwmon Not Available ===") reader := &sysfsHwmonReader{ - basePath: "testdata/nonexistent_hwmon_directory", + basePath: "../testdata/nonexistent_hwmon_directory", } zones, err := reader.Zones() @@ -1407,12 +1408,12 @@ func TestSysfsHwmonReader_Zones_HwmonNotAvailable(t *testing.T) { // TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone tests fallback when no priority zones exist func TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone(t *testing.T) { - t.Logf("\n=== Testing PrimaryEnergyZone() Fallback to First Zone ===") + t.Logf("\n=== Testing PrimaryEnergyZone() Fallback to Firstdevice.Zone ===") // Create zones with names that don't match any priority names - mockZones := []EnergyZone{ - &mockEnergyZone{name: "unknown_sensor_1", index: 0, power: 10.0, maxEnergy: 1000}, - &mockEnergyZone{name: "unknown_sensor_2", index: 1, power: 20.0, maxEnergy: 1000}, + mockZones := []device.EnergyZone{ + &testMockEnergyZone{name: "unknown_sensor_1", index: 0, power: 10.0, maxEnergy: 1000}, + &testMockEnergyZone{name: "unknown_sensor_2", index: 1, power: 20.0, maxEnergy: 1000}, } mockReader := &mockHwmonReader{ @@ -1420,7 +1421,7 @@ func TestHwmonPowerMeter_PrimaryEnergyZone_FallbackToFirstZone(t *testing.T) { err: nil, } - meter, err := NewHwmonPowerMeter("testdata/sys", WithHwmonReader(mockReader)) + meter, err := NewHwmonPowerMeter("../testdata/sys", WithHwmonReader(mockReader)) require.NoError(t, err) // PrimaryEnergyZone should fall back to first zone @@ -1477,18 +1478,18 @@ func TestGetHumanReadableChipName_ErrorCases(t *testing.T) { t.Logf("\n=== Testing getHumanReadableChipName() Error Cases ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } t.Run("no_name_file", func(t *testing.T) { // Directory without a name file (using dir_fallback test fixture) - _, err := reader.getHumanReadableChipName("testdata/sys/class/hwmon/hwmon_dir_fallback") + _, err := reader.getHumanReadableChipName("../testdata/sys/class/hwmon/hwmon_dir_fallback") assert.Error(t, err, "Should return error when name file doesn't exist") t.Logf("✓ Correctly returned error for missing name file") }) t.Run("nonexistent_directory", func(t *testing.T) { - _, err := reader.getHumanReadableChipName("testdata/nonexistent_hwmon_path") + _, err := reader.getHumanReadableChipName("../testdata/nonexistent_hwmon_path") assert.Error(t, err, "Should return error for nonexistent directory") t.Logf("✓ Correctly returned error for nonexistent directory") }) @@ -1499,7 +1500,7 @@ func TestDiscoverZones_GetChipNameError(t *testing.T) { t.Logf("\n=== Testing discoverZones() When getChipName Fails ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Test with a path that will cause getChipName to fail @@ -1527,7 +1528,7 @@ func TestDiscoverZones_GetChipNameError(t *testing.T) { // TestHwmonCalculatedPowerZoneInterface ensures hwmonVoltageCurrentZone implements EnergyZone func TestHwmonCalculatedPowerZoneInterface(t *testing.T) { - var _ EnergyZone = (*hwmonVoltageCurrentZone)(nil) + var _ device.EnergyZone = (*hwmonVoltageCurrentZone)(nil) t.Log("✓ hwmonVoltageCurrentZone implements EnergyZone interface") } @@ -1538,14 +1539,14 @@ func TestHwmonCalculatedPowerZone_Power(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "vdd_cpu", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "ina3221", humanName: "ina3221", } power, err := zone.Power() - require.NoError(t, err, "Power() should not return error") + require.NoError(t, err, "device.Power() should not return error") // Expected: 12000 mV × 5000 mA = 60,000,000 µW = 60 W expectedPowerMicrowatts := float64(12000 * 5000) @@ -1584,15 +1585,15 @@ func TestHwmonCalculatedPowerZone_Methods(t *testing.T) { assert.Equal(t, "/path/to/voltage", zone.Path()) t.Logf("✓ Path() = %s", zone.Path()) - // Test Energy() - returns error + // Test device.Energy() - returns error energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for calculated power zones") - assert.Equal(t, Energy(0), energy) - t.Logf("✓ Energy() correctly returns error: %v", err) + assert.Error(t, err, "device.Energy() should return error for calculated power zones") + assert.Equal(t, device.Energy(0), energy) + t.Logf("✓ device.Energy() correctly returns error: %v", err) // Test MaxEnergy() - returns 0 maxEnergy := zone.MaxEnergy() - assert.Equal(t, Energy(0), maxEnergy) + assert.Equal(t, device.Energy(0), maxEnergy) t.Logf("✓ MaxEnergy() = %d", maxEnergy) } @@ -1603,16 +1604,16 @@ func TestHwmonCalculatedPowerZone_Power_VoltageReadError(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/nonexistent_voltage_file", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/nonexistent_voltage_file", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail when voltage file doesn't exist") + assert.Error(t, err, "device.Power() should fail when voltage file doesn't exist") assert.Contains(t, err.Error(), "failed to read voltage") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1623,16 +1624,16 @@ func TestHwmonCalculatedPowerZone_Power_CurrentReadError(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/nonexistent_current_file", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/nonexistent_current_file", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail when current file doesn't exist") + assert.Error(t, err, "device.Power() should fail when current file doesn't exist") assert.Contains(t, err.Error(), "failed to read current") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1650,15 +1651,15 @@ func TestHwmonCalculatedPowerZone_Power_InvalidVoltageContent(t *testing.T) { name: "test_zone", index: 1, voltagePath: invalidFile, - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid voltage content") + assert.Error(t, err, "device.Power() should fail for invalid voltage content") assert.Contains(t, err.Error(), "failed to parse voltage") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1675,16 +1676,16 @@ func TestHwmonCalculatedPowerZone_Power_InvalidCurrentContent(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "test_zone", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", currentPath: invalidFile, chipName: "test_chip", humanName: "test_human", } power, err := zone.Power() - assert.Error(t, err, "Power() should fail for invalid current content") + assert.Error(t, err, "device.Power() should fail for invalid current content") assert.Contains(t, err.Error(), "failed to parse current") - assert.Equal(t, Power(0), power) + assert.Equal(t, device.Power(0), power) t.Logf("✓ Correctly returned error: %v", err) } @@ -1693,11 +1694,11 @@ func TestDiscoverVoltageCurrentZones_MatchByLabel(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Label Matching ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Read files from the test fixture - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1733,11 +1734,11 @@ func TestDiscoverVoltageCurrentZones_NoLabels(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery Without Labels ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has voltage and current but no labels - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_no_labels" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_no_labels" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1755,10 +1756,10 @@ func TestDiscoverVoltageCurrentZones_MultiplePairs(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Multiple Pairs ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_multi" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_multi" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1770,7 +1771,7 @@ func TestDiscoverVoltageCurrentZones_MultiplePairs(t *testing.T) { assert.Equal(t, 2, len(zones), "Should find 2 matched pairs") // Create a map of zone names for verification - zoneNames := make(map[string]EnergyZone) + zoneNames := make(map[string]device.EnergyZone) for _, zone := range zones { zoneNames[zone.Name()] = zone } @@ -1811,7 +1812,7 @@ func TestDiscoverVoltageCurrentZones_PrefersAverageOverInput(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery Prefers _average Over _input ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has both _input and _average files with different values @@ -1819,7 +1820,7 @@ func TestDiscoverVoltageCurrentZones_PrefersAverageOverInput(t *testing.T) { // curr1_input: 5000 mA, curr1_average: 4900 mA // If _average is preferred: Power = 11800 × 4900 = 57,820,000 µW = 57.82 W // If _input was used: Power = 12000 × 5000 = 60,000,000 µW = 60 W - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_average" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_average" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1853,7 +1854,7 @@ func TestDiscoverVoltageCurrentZones_PartialAverageFallsBackToInput(t *testing.T t.Logf("\n=== Testing Partial _average Falls Back to _input for Both ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // This fixture has: @@ -1862,7 +1863,7 @@ func TestDiscoverVoltageCurrentZones_PartialAverageFallsBackToInput(t *testing.T // Since current lacks _average, both should use _input: // Power = 12000 × 5000 = 60,000,000 µW = 60 W // NOT: 11800 × 5000 = 59 W (which would be mixing average/input) - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current_partial_average" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current_partial_average" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -1894,11 +1895,11 @@ func TestDiscoverZones_FallbackToVoltageCurrentWhenNoPower(t *testing.T) { t.Logf("\n=== Testing discoverZones() Fallback to Voltage/Current ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // hwmon_voltage_current has no power sensors, only voltage/current pairs - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_voltage_current") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_voltage_current") require.NoError(t, err, "discoverZones should succeed with voltage/current fallback") require.NotEmpty(t, zones, "Should find zones via voltage/current fallback") @@ -1921,11 +1922,11 @@ func TestDiscoverZones_PreferDirectPowerOverCalculated(t *testing.T) { t.Logf("\n=== Testing discoverZones() Prefers Direct Power ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // hwmon0 has direct power sensors - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon0") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon0") require.NoError(t, err) require.NotEmpty(t, zones) @@ -1949,7 +1950,7 @@ func TestHwmonPowerMeter_WithVoltageCurrentZones(t *testing.T) { require.NoError(t, err) // Copy voltage/current fixture files - srcDir := "testdata/sys/class/hwmon/hwmon_voltage_current" + srcDir := "../testdata/sys/class/hwmon/hwmon_voltage_current" files := []string{"name", "in1_input", "in1_label", "curr1_input", "curr1_label"} for _, f := range files { srcData, err := os.ReadFile(filepath.Join(srcDir, f)) @@ -1995,7 +1996,7 @@ func TestDiscoverVoltageCurrentZones_EmptySensors(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with No Sensors ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Create temporary directory with only temperature sensors @@ -2023,7 +2024,7 @@ func TestDiscoverVoltageCurrentZones_VoltageOnly(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Voltage Only ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } tmpDir := t.TempDir() @@ -2052,7 +2053,7 @@ func TestDiscoverVoltageCurrentZones_CurrentOnly(t *testing.T) { t.Logf("\n=== Testing Voltage/Current Discovery with Current Only ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } tmpDir := t.TempDir() @@ -2083,8 +2084,8 @@ func TestHwmonCalculatedPowerZone_MultipleReadings(t *testing.T) { zone := &hwmonVoltageCurrentZone{ name: "vdd_cpu", index: 1, - voltagePath: "testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", - currentPath: "testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", + voltagePath: "../testdata/sys/class/hwmon/hwmon_voltage_current/in1_input", + currentPath: "../testdata/sys/class/hwmon/hwmon_voltage_current/curr1_input", chipName: "ina3221", humanName: "ina3221", } @@ -2248,10 +2249,10 @@ func TestDiscoverVoltageCurrentZones_INA226ChipRule(t *testing.T) { t.Logf("INA226 has: in0 (shunt), in1 (bus), curr1 - rule: in1 ↔ curr1, skip in0") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina226" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina226" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2279,10 +2280,10 @@ func TestDiscoverVoltageCurrentZones_INA3221ChipRule(t *testing.T) { t.Logf("Rule: in{N} ↔ curr{N} for N=1..3, skip in4-7") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina3221" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina3221" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2308,10 +2309,10 @@ func TestDiscoverVoltageCurrentZones_MAX20730ExceptionRule(t *testing.T) { t.Logf("MAX20730 EXCEPTION: curr1 is output current, pairs with in2 (VOUT), not in1") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_max20730" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_max20730" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2343,10 +2344,10 @@ func TestDiscoverVoltageCurrentZones_SameIndexFallback(t *testing.T) { t.Logf("When chip is unknown, fall back to same-index matching: in{N} ↔ curr{N}") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_same_index" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_same_index" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2370,13 +2371,13 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { t.Logf("\n=== Testing Priority Order: Label > Chip Rule > Same-Index ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } // Test 1: Label matching takes priority // hwmon_voltage_current has labels, should use label matching even for known chip t.Run("LabelMatchingTakesPriority", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_voltage_current" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_voltage_current" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2394,7 +2395,7 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { // Test 2: Chip rule used when no labels t.Run("ChipRuleWhenNoLabels", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_ina226" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_ina226" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2406,7 +2407,7 @@ func TestDiscoverVoltageCurrentZones_PriorityOrder(t *testing.T) { // Test 3: Same-index used when no labels and unknown chip t.Run("SameIndexWhenUnknownChip", func(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_same_index" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_same_index" files, err := os.ReadDir(hwmonPath) require.NoError(t, err) @@ -2453,34 +2454,34 @@ func TestIsSensorEnabled(t *testing.T) { t.Run("enabled_channel", func(t *testing.T) { // in1_enable = 1 in the disabled_channel test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 1, nil) assert.True(t, result, "Channel with enable=1 should be enabled") t.Logf("✓ Channel with enable=1 correctly identified as enabled") }) t.Run("disabled_channel", func(t *testing.T) { // in3_enable = 0 in the disabled_channel test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 3, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_channel", "in", 3, nil) assert.False(t, result, "Channel with enable=0 should be disabled") t.Logf("✓ Channel with enable=0 correctly identified as disabled") }) t.Run("no_enable_file", func(t *testing.T) { // hwmon0 doesn't have enable files - should default to enabled - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon0", "power", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon0", "power", 1, nil) assert.True(t, result, "Channel without enable file should be considered enabled") t.Logf("✓ Channel without enable file correctly defaults to enabled") }) t.Run("enabled_power_channel", func(t *testing.T) { // power1_enable = 1 in the disabled_power test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_power", "power", 1, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_power", "power", 1, nil) assert.True(t, result, "Power channel with enable=1 should be enabled") }) t.Run("disabled_power_channel", func(t *testing.T) { // power3_enable = 0 in the disabled_power test directory - result := isSensorEnabled("testdata/sys/class/hwmon/hwmon_disabled_power", "power", 3, nil) + result := isSensorEnabled("../testdata/sys/class/hwmon/hwmon_disabled_power", "power", 3, nil) assert.False(t, result, "Power channel with enable=0 should be disabled") }) } @@ -2490,10 +2491,10 @@ func TestDisabledChannelSkipping(t *testing.T) { t.Logf("\n=== Testing Disabled Channel Skipping ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_disabled_channel" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_disabled_channel" zones, err := reader.discoverZones(hwmonPath) require.NoError(t, err, "discoverZones should not fail") @@ -2522,10 +2523,10 @@ func TestDisabledPowerChannelSkipping(t *testing.T) { t.Logf("\n=== Testing Disabled Power Channel Skipping ===") reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - hwmonPath := "testdata/sys/class/hwmon/hwmon_disabled_power" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_disabled_power" zones, err := reader.discoverZones(hwmonPath) require.NoError(t, err, "discoverZones should not fail") @@ -2556,12 +2557,12 @@ func TestHwmonEnergyZone_Energy(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } energy, err := zone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(500000000), energy, "Should read 500000000 microjoules") + assert.Equal(t, device.Energy(500000000), energy, "Should read 500000000 microjoules") } // TestHwmonEnergyZone_MaxEnergy tests that MaxEnergy returns 0 for energy zones @@ -2569,10 +2570,10 @@ func TestHwmonEnergyZone_MaxEnergy(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } - assert.Equal(t, Energy(0), zone.MaxEnergy(), "MaxEnergy should return 0 for hwmon energy zones") + assert.Equal(t, device.Energy(0), zone.MaxEnergy(), "MaxEnergy should return 0 for hwmon energy zones") } // TestHwmonEnergyZone_Power tests that Power returns error for energy zones @@ -2580,11 +2581,11 @@ func TestHwmonEnergyZone_Power(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", index: 1, - path: "testdata/sys/class/hwmon/hwmon_energy/energy1_input", + path: "../testdata/sys/class/hwmon/hwmon_energy/energy1_input", } _, err := zone.Power() - assert.Error(t, err, "Power() should return error for energy zones") + assert.Error(t, err, "device.Power() should return error for energy zones") } // TestHwmonEnergyZone_Methods tests Name, Index, Path accessors @@ -2602,7 +2603,7 @@ func TestHwmonEnergyZone_Methods(t *testing.T) { assert.Equal(t, "/sys/class/hwmon/hwmon0/energy1_input", zone.Path()) } -// TestHwmonEnergyZone_Energy_ReadError tests Energy() when the input file is unreadable +// TestHwmonEnergyZone_Energy_ReadError tests device.Energy() when the input file is unreadable func TestHwmonEnergyZone_Energy_ReadError(t *testing.T) { zone := &hwmonEnergyZone{ name: "total", @@ -2611,11 +2612,11 @@ func TestHwmonEnergyZone_Energy_ReadError(t *testing.T) { } energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for unreadable file") - assert.Equal(t, Energy(0), energy, "Energy should be 0 on error") + assert.Error(t, err, "device.Energy() should return error for unreadable file") + assert.Equal(t, device.Energy(0), energy, "Energy should be 0 on error") } -// TestHwmonEnergyZone_Energy_InvalidContent tests Energy() when the input file contains non-numeric data +// TestHwmonEnergyZone_Energy_InvalidContent tests device.Energy() when the input file contains non-numeric data func TestHwmonEnergyZone_Energy_InvalidContent(t *testing.T) { tmpDir := t.TempDir() inputFile := filepath.Join(tmpDir, "energy1_input") @@ -2629,17 +2630,17 @@ func TestHwmonEnergyZone_Energy_InvalidContent(t *testing.T) { } energy, err := zone.Energy() - assert.Error(t, err, "Energy() should return error for non-numeric content") - assert.Equal(t, Energy(0), energy, "Energy should be 0 on parse error") + assert.Error(t, err, "device.Energy() should return error for non-numeric content") + assert.Equal(t, device.Energy(0), energy, "Energy should be 0 on parse error") } // TestDiscoverZones_EnergySensors tests discovery of a single energy sensor func TestDiscoverZones_EnergySensors(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy") require.NoError(t, err) require.Len(t, zones, 1, "Should find 1 energy zone") @@ -2650,16 +2651,16 @@ func TestDiscoverZones_EnergySensors(t *testing.T) { energy, err := zone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(500000000), energy) + assert.Equal(t, device.Energy(500000000), energy) } // TestDiscoverZones_EnergySensorsMultiple tests discovery of multiple energy sensors func TestDiscoverZones_EnergySensorsMultiple(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_multi") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_multi") require.NoError(t, err) require.Len(t, zones, 3, "Should find 3 energy zones") @@ -2679,10 +2680,10 @@ func TestDiscoverZones_EnergySensorsMultiple(t *testing.T) { // TestDiscoverZones_EnergySensorNoLabel tests fallback naming when no label is present func TestDiscoverZones_EnergySensorNoLabel(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_no_label") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_no_label") require.NoError(t, err) require.Len(t, zones, 1, "Should find 1 energy zone") @@ -2694,10 +2695,10 @@ func TestDiscoverZones_EnergySensorNoLabel(t *testing.T) { // TestDiscoverZones_EnergySensorDisabled tests that disabled energy channels are skipped func TestDiscoverZones_EnergySensorDisabled(t *testing.T) { reader := &sysfsHwmonReader{ - basePath: "testdata/sys/class/hwmon", + basePath: "../testdata/sys/class/hwmon", } - zones, err := reader.discoverZones("testdata/sys/class/hwmon/hwmon_energy_disabled") + zones, err := reader.discoverZones("../testdata/sys/class/hwmon/hwmon_energy_disabled") require.NoError(t, err) require.Len(t, zones, 2, "Should find 2 enabled energy zones (energy3 is disabled)") @@ -2785,7 +2786,7 @@ func TestDiscoverZones_EnergyPreferredOverVoltCurr(t *testing.T) { // TestIsSensorEnabled_Energy tests isSensorEnabled with energy sensor enable files func TestIsSensorEnabled_Energy(t *testing.T) { - hwmonPath := "testdata/sys/class/hwmon/hwmon_energy_disabled" + hwmonPath := "../testdata/sys/class/hwmon/hwmon_energy_disabled" // energy1 has enable=1 → enabled assert.True(t, isSensorEnabled(hwmonPath, "energy", 1, nil), "energy1 should be enabled (enable=1)") @@ -2797,6 +2798,25 @@ func TestIsSensorEnabled_Energy(t *testing.T) { assert.False(t, isSensorEnabled(hwmonPath, "energy", 3, nil), "energy3 should be disabled (enable=0)") // No enable file → enabled by default - assert.True(t, isSensorEnabled("testdata/sys/class/hwmon/hwmon_energy", "energy", 1, nil), + assert.True(t, isSensorEnabled("../testdata/sys/class/hwmon/hwmon_energy", "energy", 1, nil), "Should be enabled when no enable file exists") } + +// testMockEnergyZone is a local mock for testing +type testMockEnergyZone struct { + name string + index int + path string + energy device.Energy + maxEnergy device.Energy + power device.Power + err error + powerErr error +} + +func (m *testMockEnergyZone) Name() string { return m.name } +func (m *testMockEnergyZone) Index() int { return m.index } +func (m *testMockEnergyZone) Path() string { return m.path } +func (m *testMockEnergyZone) Energy() (device.Energy, error) { return m.energy, m.err } +func (m *testMockEnergyZone) MaxEnergy() device.Energy { return m.maxEnergy } +func (m *testMockEnergyZone) Power() (device.Power, error) { return m.power, m.powerErr } diff --git a/internal/device/mock_cpu_power_meter.go b/internal/device/cpu/mock_cpu_power_meter.go similarity index 70% rename from internal/device/mock_cpu_power_meter.go rename to internal/device/cpu/mock_cpu_power_meter.go index bee1fee9b6..42eb31d6aa 100644 --- a/internal/device/mock_cpu_power_meter.go +++ b/internal/device/cpu/mock_cpu_power_meter.go @@ -1,38 +1,38 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu // TODO: Move this mock to a separate testutil package import ( + "fmt" "slices" "testing" - "fmt" - "github.com/prometheus/procfs/sysfs" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) const ( - validSysFSPath = "testdata/sys" - badSysFSPath = "testdata/bad_sysfs" + validSysFSPath = "../testdata/sys" + badSysFSPath = "../testdata/bad_sysfs" ) type ( MockRaplZone struct { - energy Energy + energy device.Energy energyErr error name string index int path string - maxMicroJoules Energy + maxMicroJoules device.Energy } MockPowerZone struct { - power Power + power device.Power powerErr error name string @@ -41,7 +41,7 @@ type ( } ) -func NewMockRaplZone(name string, index int, path string, maxMicroJoules Energy) *MockRaplZone { +func NewMockRaplZone(name string, index int, path string, maxMicroJoules device.Energy) *MockRaplZone { return &MockRaplZone{ name: name, index: index, @@ -62,25 +62,25 @@ func (m MockRaplZone) Name() string { return m.name } -func (m MockRaplZone) Energy() (Energy, error) { +func (m MockRaplZone) Energy() (device.Energy, error) { return m.energy, m.energyErr } -func (m MockRaplZone) MaxEnergy() Energy { +func (m MockRaplZone) MaxEnergy() device.Energy { return m.maxMicroJoules } -func (m MockRaplZone) Power() (Power, error) { +func (m MockRaplZone) Power() (device.Power, error) { // Mock RAPL zones don't provide power return 0, fmt.Errorf("mock rapl zones do not provide power readings") } -func (m *MockRaplZone) OnEnergy(j Energy, err error) { +func (m *MockRaplZone) OnEnergy(j device.Energy, err error) { m.energy = j m.energyErr = err } -func (m *MockRaplZone) Inc(delta Energy) { +func (m *MockRaplZone) Inc(delta device.Energy) { m.energy = (m.energy + delta) % m.maxMicroJoules } @@ -104,26 +104,26 @@ func (m MockPowerZone) Name() string { return m.name } -func (m MockPowerZone) Energy() (Energy, error) { +func (m MockPowerZone) Energy() (device.Energy, error) { // Power zones don't provide energy readings return 0, nil } -func (m MockPowerZone) MaxEnergy() Energy { +func (m MockPowerZone) MaxEnergy() device.Energy { // Power zones don't have max energy return 0 } -func (m MockPowerZone) Power() (Power, error) { +func (m MockPowerZone) Power() (device.Power, error) { return m.power, m.powerErr } -func (m *MockPowerZone) OnPower(power Power, err error) { +func (m *MockPowerZone) OnPower(power device.Power, err error) { m.power = power m.powerErr = err } -func (m *MockPowerZone) SetPower(power Power) { +func (m *MockPowerZone) SetPower(power device.Power) { m.power = power } @@ -141,7 +141,7 @@ func invalidSysFSFixtures(t *testing.T) sysfs.FS { return fs } -func sortedZoneNames(zones []EnergyZone) []string { +func sortedZoneNames(zones []device.EnergyZone) []string { names := make([]string, len(zones)) for i, zone := range zones { names[i] = zone.Name() diff --git a/internal/device/rapl_sysfs_power_meter.go b/internal/device/cpu/rapl_sysfs_power_meter.go similarity index 82% rename from internal/device/rapl_sysfs_power_meter.go rename to internal/device/cpu/rapl_sysfs_power_meter.go index 755e3cbb4f..c2fd7ca9ff 100644 --- a/internal/device/rapl_sysfs_power_meter.go +++ b/internal/device/cpu/rapl_sysfs_power_meter.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "fmt" @@ -9,22 +9,23 @@ import ( "strings" "github.com/prometheus/procfs/sysfs" + "github.com/sustainable-computing-io/kepler/internal/device" ) // raplPowerMeter implements CPUPowerMeter using sysfs type raplPowerMeter struct { reader sysfsReader - cachedZones []EnergyZone + cachedZones []device.EnergyZone logger *slog.Logger zoneFilter []string - topZone EnergyZone + topZone device.EnergyZone } type OptionFn func(*raplPowerMeter) // sysfsReader is an interface for a sysfs filesystem used by raplPowerMeter to mock for testing type sysfsReader interface { - Zones() ([]EnergyZone, error) + Zones() ([]device.EnergyZone, error) } // WithSysFSReader sets the sysfsReader used by raplPowerMeter @@ -93,7 +94,7 @@ func (r *raplPowerMeter) needsFiltering() bool { // filterZones applies the configured zone filter // If the filter is empty, all zones are returned -func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { +func (r *raplPowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { if !r.needsFiltering() { return zones } @@ -103,7 +104,7 @@ func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { wanted[strings.ToLower(name)] = true } var included, excluded []string - filtered := make([]EnergyZone, 0, len(zones)) + filtered := make([]device.EnergyZone, 0, len(zones)) for _, zone := range zones { if wanted[strings.ToLower(zone.Name())] { filtered = append(filtered, zone) @@ -116,7 +117,7 @@ func (r *raplPowerMeter) filterZones(zones []EnergyZone) []EnergyZone { return filtered } -func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { +func (r *raplPowerMeter) Zones() ([]device.EnergyZone, error) { // Return cached zones if already initialized if len(r.cachedZones) != 0 { return r.cachedZones, nil @@ -136,9 +137,9 @@ func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { // filter out non-standard zones - stdZoneMap := map[zoneKey]EnergyZone{} + stdZoneMap := map[device.ZoneKey]device.EnergyZone{} for _, zone := range zones { - key := zoneKey{name: zone.Name(), index: zone.Index()} + key := device.ZoneKey{Name: zone.Name(), Index: zone.Index()} // ignore non-standard zones if a standard zone already exists if existingZone, exists := stdZoneMap[key]; exists && isStandardRaplPath(existingZone.Path()) { @@ -154,16 +155,16 @@ func (r *raplPowerMeter) Zones() ([]EnergyZone, error) { // groupZonesByName groups zones by their base name and creates AggregatedZone // instances when multiple zones share the same name (multi-socket systems) -func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []EnergyZone { +func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[device.ZoneKey]device.EnergyZone) []device.EnergyZone { // Group zones by base name (e.g., "package", "dram") - zoneGroups := make(map[string][]EnergyZone) + zoneGroups := make(map[string][]device.EnergyZone) for _, zone := range stdZoneMap { name := zone.Name() zoneGroups[name] = append(zoneGroups[name], zone) } - var result []EnergyZone + var result []device.EnergyZone for name, zones := range zoneGroups { if len(zones) == 1 { result = append(result, zones[0]) @@ -171,7 +172,7 @@ func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []E } // Multiple zones with same name - create AggregatedZone - aggregated, _ := NewAggregatedZone(zones) + aggregated, _ := device.NewAggregatedZone(zones) result = append(result, aggregated) r.logger.Debug("Created aggregated zone", "name", name, @@ -182,7 +183,7 @@ func (r *raplPowerMeter) groupZonesByName(stdZoneMap map[zoneKey]EnergyZone) []E } // zoneNames returns a slice of zone names for logging -func (r *raplPowerMeter) zoneNames(zones []EnergyZone) []string { +func (r *raplPowerMeter) zoneNames(zones []device.EnergyZone) []string { names := make([]string, len(zones)) for i, zone := range zones { names[i] = fmt.Sprintf("%s-%d", zone.Name(), zone.Index()) @@ -191,7 +192,7 @@ func (r *raplPowerMeter) zoneNames(zones []EnergyZone) []string { } // PrimaryEnergyZone returns the zone with the highest energy coverage/priority -func (r *raplPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (r *raplPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { // Return cached zone if already initialized if r.topZone != nil { return r.topZone, nil @@ -206,7 +207,7 @@ func (r *raplPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { return nil, fmt.Errorf("no energy zones available") } - zoneMap := map[string]EnergyZone{} + zoneMap := map[string]device.EnergyZone{} for _, zone := range zones { zoneMap[strings.ToLower(zone.Name())] = zone } @@ -236,14 +237,14 @@ type sysfsRaplReader struct { fs sysfs.FS } -func (r sysfsRaplReader) Zones() ([]EnergyZone, error) { +func (r sysfsRaplReader) Zones() ([]device.EnergyZone, error) { raplZones, err := sysfs.GetRaplZones(r.fs) if err != nil { return nil, fmt.Errorf("failed to read rapl zones: %w", err) } // convert sysfs.RaplZones to EnergyZones - energyZones := make([]EnergyZone, 0, len(raplZones)) + energyZones := make([]device.EnergyZone, 0, len(raplZones)) for _, zone := range raplZones { energyZones = append(energyZones, sysfsRaplZone{zone}) } @@ -273,18 +274,18 @@ func (s sysfsRaplZone) Path() string { } // Energy returns the current energy value -func (s sysfsRaplZone) Energy() (Energy, error) { +func (s sysfsRaplZone) Energy() (device.Energy, error) { mj, err := s.zone.GetEnergyMicrojoules() - return Energy(mj), err + return device.Energy(mj), err } // MaxEnergy returns the maximum energy value before wraparound -func (s sysfsRaplZone) MaxEnergy() Energy { - return Energy(s.zone.MaxMicrojoules) +func (s sysfsRaplZone) MaxEnergy() device.Energy { + return device.Energy(s.zone.MaxMicrojoules) } // Power returns the current power consumption // RAPL zones provide cumulative energy, not instantaneous power -func (s sysfsRaplZone) Power() (Power, error) { +func (s sysfsRaplZone) Power() (device.Power, error) { return 0, fmt.Errorf("RAPL zones do not provide instantaneous power readings") } diff --git a/internal/device/rapl_sysfs_power_meter_test.go b/internal/device/cpu/rapl_sysfs_power_meter_test.go similarity index 83% rename from internal/device/rapl_sysfs_power_meter_test.go rename to internal/device/cpu/rapl_sysfs_power_meter_test.go index 136fd9786b..e304cf39eb 100644 --- a/internal/device/rapl_sysfs_power_meter_test.go +++ b/internal/device/cpu/rapl_sysfs_power_meter_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "errors" @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/sustainable-computing-io/kepler/internal/device" ) // TestCPUPowerMeterInterface ensures that raplPowerMeter properly implements the CPUPowerMeter interface @@ -23,7 +24,7 @@ func TestCPUPowerMeterInterface(t *testing.T) { } func TestNewCPUPowerMeter(t *testing.T) { - meter, err := NewCPUPowerMeter("testdata/sys") + meter, err := NewCPUPowerMeter("../testdata/sys") assert.NotNil(t, meter, "NewCPUPowerMeter should not return nil") assert.NoError(t, err, "NewCPUPowerMeter should not return error") assert.IsType(t, &raplPowerMeter{}, meter, "NewCPUPowerMeter should return a *cpuPowerMeter") @@ -89,15 +90,15 @@ func TestSysFSRaplZone_Power(t *testing.T) { zone := sysfsRaplZone{zone: pkg} - // Test that Power() returns an error for RAPL zones + // Test that device.Power() returns an error for RAPL zones power, err := zone.Power() - assert.Error(t, err, "Power() should return an error for RAPL zones") - assert.Equal(t, Power(0), power, "Power() should return 0 when error occurs") + assert.Error(t, err, "device.Power() should return an error for RAPL zones") + assert.Equal(t, device.Power(0), power, "device.Power() should return 0 when error occurs") assert.Contains(t, err.Error(), "RAPL zones do not provide instantaneous power readings", "Error message should explain that RAPL zones don't provide power readings") } -// TestSysFSRaplZone_Power_AllZones tests Power() for multiple RAPL zone types +// TestSysFSRaplZone_Power_AllZones tests device.Power() for multiple RAPL zone types func TestSysFSRaplZone_Power_AllZones(t *testing.T) { testCases := []struct { name string @@ -148,10 +149,10 @@ func TestSysFSRaplZone_Power_AllZones(t *testing.T) { zone := sysfsRaplZone{zone: raplZone} - // All RAPL zones should return an error for Power() + // All RAPL zones should return an error for device.Power() power, err := zone.Power() - assert.Error(t, err, "%s should return error for Power()", tc.zoneName) - assert.Equal(t, Power(0), power, "%s should return 0 for power", tc.zoneName) + assert.Error(t, err, "%s should return error for device.Power()", tc.zoneName) + assert.Equal(t, device.Power(0), power, "%s should return 0 for power", tc.zoneName) assert.Contains(t, err.Error(), "RAPL zones do not provide instantaneous power readings", "%s error message should be correct", tc.zoneName) }) @@ -189,7 +190,7 @@ func TestSysFSRaplPowerMeter(t *testing.T) { // Test that each zone implements the interface correctly assert.NoError(t, err) - // With aggregation: two package zones become one AggregatedZone + one core zone = 2 total + // With aggregation: two package zones become one device.AggregatedZone + one core zone = 2 total assert.Equal(t, 2, len(zones), "find 2 zones after aggregation (package + core)") assert.Equal(t, []string{"core", "package"}, sortedZoneNames(zones), "Expected to find aggregated zones in test fixtures") @@ -197,17 +198,17 @@ func TestSysFSRaplPowerMeter(t *testing.T) { for _, zone := range zones { assert.NotEmpty(t, zone.Name(), "Zone name should not be empty") assert.NotEmpty(t, zone.Path(), "Zone path should not be empty") - assert.GreaterOrEqual(t, zone.MaxEnergy(), 1000.0*Joule, "Max energy should not be negative") + assert.GreaterOrEqual(t, zone.MaxEnergy(), 1000.0*device.Joule, "Max energy should not be negative") - // Zone could be either sysfsRaplZone or AggregatedZone + // Zone could be either sysfsRaplZone or device.AggregatedZone switch z := zone.(type) { case sysfsRaplZone: // Individual zone assert.NotNil(t, z) - case *AggregatedZone: + case *device.AggregatedZone: // Aggregated zone assert.NotNil(t, z) - assert.Equal(t, -1, z.Index(), "AggregatedZone should have index -1") + assert.Equal(t, -1, z.Index(), "device.AggregatedZone should have index -1") default: t.Fatalf("Unexpected zone type: %T", zone) } @@ -217,14 +218,14 @@ func TestSysFSRaplPowerMeter(t *testing.T) { energy, err := zone.Energy() assert.NoError(t, err, zone.Path()) - assert.GreaterOrEqual(t, energy, 1000.0*Joule, "Energy should not be negative") + assert.GreaterOrEqual(t, energy, 1000.0*device.Joule, "Energy should not be negative") } } func TestAggregatedZoneIntegration(t *testing.T) { - // Test that RAPL reader creates AggregatedZone for multiple zones with same name + // Test that RAPL reader creates device.AggregatedZone for multiple zones with same name mockReader := &mockSysFSReader{ - response: []EnergyZone{ + response: []device.EnergyZone{ // Two package zones with same name but different indices and one core zone mockZone{name: "package", index: 0, path: "/intel-rapl:0", energy: 1000, maxEnergy: 100000}, mockZone{name: "package", index: 1, path: "/intel-rapl:1", energy: 2000, maxEnergy: 100000}, @@ -243,9 +244,9 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Should have 2 zones: 1 aggregated package zone + 1 core zone assert.Equal(t, 2, len(zones), "Expected 2 zones after aggregation") - // Find the package zone - should be AggregatedZone - var packageZone EnergyZone - var coreZone EnergyZone + // Find the package zone - should be device.AggregatedZone + var packageZone device.EnergyZone + var coreZone device.EnergyZone for _, zone := range zones { if zone.Name() == "package" { packageZone = zone @@ -256,11 +257,11 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Verify package zone is aggregated require.NotNil(t, packageZone, "Package zone should exist") - aggregated, isAggregated := packageZone.(*AggregatedZone) - assert.True(t, isAggregated, "Package zone should be AggregatedZone") + aggregated, isAggregated := packageZone.(*device.AggregatedZone) + assert.True(t, isAggregated, "Package zone should be device.AggregatedZone") assert.Equal(t, "package", aggregated.Name()) assert.Equal(t, -1, aggregated.Index()) - assert.Equal(t, Energy(200000), aggregated.MaxEnergy()) // Sum of both package zones + assert.Equal(t, device.Energy(200000), aggregated.MaxEnergy()) // Sum of both package zones // Verify core zone is not aggregated require.NotNil(t, coreZone, "Core zone should exist") @@ -270,32 +271,32 @@ func TestAggregatedZoneIntegration(t *testing.T) { // Test energy aggregation packageEnergy, err := packageZone.Energy() require.NoError(t, err) - assert.Equal(t, Energy(3000), packageEnergy) // 1000 + 2000 from both package zones + assert.Equal(t, device.Energy(3000), packageEnergy) // 1000 + 2000 from both package zones } type mockZone struct { name string index int path string - energy Energy - maxEnergy Energy + energy device.Energy + maxEnergy device.Energy } -func (m mockZone) Name() string { return m.name } -func (m mockZone) Index() int { return m.index } -func (m mockZone) Path() string { return m.path } -func (m mockZone) Energy() (Energy, error) { return m.energy, nil } -func (m mockZone) MaxEnergy() Energy { return m.maxEnergy } -func (m mockZone) Power() (Power, error) { +func (m mockZone) Name() string { return m.name } +func (m mockZone) Index() int { return m.index } +func (m mockZone) Path() string { return m.path } +func (m mockZone) Energy() (device.Energy, error) { return m.energy, nil } +func (m mockZone) MaxEnergy() device.Energy { return m.maxEnergy } +func (m mockZone) Power() (device.Power, error) { return 0, fmt.Errorf("rapl zones do not provide power readings") } type mockSysFSReader struct { - response []EnergyZone + response []device.EnergyZone err error } -func (m *mockSysFSReader) Zones() ([]EnergyZone, error) { +func (m *mockSysFSReader) Zones() ([]device.EnergyZone, error) { return m.response, m.err } @@ -346,9 +347,9 @@ type mockRaplReader struct { mock.Mock } -func (m *mockRaplReader) Zones() ([]EnergyZone, error) { +func (m *mockRaplReader) Zones() ([]device.EnergyZone, error) { args := m.Called() - return args.Get(0).([]EnergyZone), args.Error(1) + return args.Get(0).([]device.EnergyZone), args.Error(1) } // TestStandardPathPreference tests that standard paths are preferred over non-standard ones @@ -365,13 +366,13 @@ func TestStandardPathPreference(t *testing.T) { index: 0, } tt := []struct { - zones []EnergyZone - expected EnergyZone + zones []device.EnergyZone + expected device.EnergyZone }{ - {[]EnergyZone{stdPkg}, stdPkg}, - {[]EnergyZone{mmio}, mmio}, - {[]EnergyZone{mmio, stdPkg}, stdPkg}, - {[]EnergyZone{stdPkg, mmio}, stdPkg}, + {[]device.EnergyZone{stdPkg}, stdPkg}, + {[]device.EnergyZone{mmio}, mmio}, + {[]device.EnergyZone{mmio, stdPkg}, stdPkg}, + {[]device.EnergyZone{stdPkg, mmio}, stdPkg}, } for _, test := range tt { @@ -413,7 +414,7 @@ func TestZoneCaching(t *testing.T) { path: "/sys/class/powercap/intel-rapl/intel-rapl:0:0", index: 1, } - raplZones := []EnergyZone{pkg, core} + raplZones := []device.EnergyZone{pkg, core} mockReader := &mockRaplReader{} mockReader.On("Zones").Return(raplZones, nil).Once() @@ -437,7 +438,7 @@ func TestZoneCaching_Error(t *testing.T) { rapl, err := NewCPUPowerMeter(validSysFSPath, WithSysFSReader(mockReader)) t.Run("Zone Read Error", func(t *testing.T) { - mockReader.On("Zones").Return([]EnergyZone(nil), errors.New("error")).Once() + mockReader.On("Zones").Return([]device.EnergyZone(nil), errors.New("error")).Once() assert.NoError(t, err) zones, err := rapl.Zones() assert.Error(t, err) @@ -456,7 +457,7 @@ func TestZoneCaching_Error(t *testing.T) { path: "/sys/class/powercap/intel-rapl/intel-rapl:0:0", index: 1, } - raplZones := []EnergyZone{pkg, core} + raplZones := []device.EnergyZone{pkg, core} t.Run("Zone Read Succeeds", func(t *testing.T) { mockReader.On("Zones").Return(raplZones, nil).Once() for range 3 { @@ -475,7 +476,7 @@ func TestZone_None(t *testing.T) { rapl, err := NewCPUPowerMeter(validSysFSPath, WithSysFSReader(mockReader)) assert.NoError(t, err) - mockReader.On("Zones").Return([]EnergyZone(nil), nil).Once() + mockReader.On("Zones").Return([]device.EnergyZone(nil), nil).Once() zones, err := rapl.Zones() assert.Error(t, err) assert.Equal(t, 0, len(zones)) @@ -493,7 +494,7 @@ func TestNewCPUPowerMeter_InvalidPath(t *testing.T) { func TestCPUPowerMeter_ZonesError(t *testing.T) { mockReader := &mockRaplReader{} expectedErr := errors.New("error") - mockReader.On("Zones").Return([]EnergyZone{}, expectedErr) + mockReader.On("Zones").Return([]device.EnergyZone{}, expectedErr) meter := &raplPowerMeter{reader: mockReader} zones, err := meter.Zones() @@ -507,7 +508,7 @@ func TestCPUPowerMeter_ZonesError(t *testing.T) { // TestCPUPowerMeter_NoZones tests that Zones returns an error when no zones are found func TestCPUPowerMeter_NoZones(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader} zones, err := meter.Zones() @@ -521,7 +522,7 @@ func TestCPUPowerMeter_NoZones(t *testing.T) { // TestCPUPowerMeter_InitNoZones tests that Start returns an error when no zones are found func TestCPUPowerMeter_InitNoZones(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader} err := meter.Init() @@ -536,11 +537,11 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Priority hierarchy", func(t *testing.T) { tests := []struct { name string - zones []EnergyZone + zones []device.EnergyZone expected string }{{ name: "psys has highest priority", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "package", index: 0}, mockZone{name: "psys", index: 0}, mockZone{name: "core", index: 0}, @@ -548,7 +549,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "psys", }, { name: "package has priority over core", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "core", index: 0}, mockZone{name: "package", index: 0}, mockZone{name: "dram", index: 0}, @@ -556,7 +557,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "package", }, { name: "core has priority over dram", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "dram", index: 0}, mockZone{name: "core", index: 0}, mockZone{name: "uncore", index: 0}, @@ -564,7 +565,7 @@ func TestPrimaryEnergyZone(t *testing.T) { expected: "core", }, { name: "dram has priority over uncore", - zones: []EnergyZone{ + zones: []device.EnergyZone{ mockZone{name: "uncore", index: 0}, mockZone{name: "dram", index: 0}, }, @@ -588,7 +589,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Case insensitive matching", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{ + mockReader.On("Zones").Return([]device.EnergyZone{ mockZone{name: "PACKAGE", index: 0}, mockZone{name: "Core", index: 0}, }, nil) @@ -602,7 +603,7 @@ func TestPrimaryEnergyZone(t *testing.T) { }) t.Run("Fallback to first zone", func(t *testing.T) { - zones := []EnergyZone{ + zones := []device.EnergyZone{ mockZone{name: "unknown1", index: 0}, mockZone{name: "unknown2", index: 1}, } @@ -623,7 +624,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Caching behavior", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{ + mockReader.On("Zones").Return([]device.EnergyZone{ mockZone{name: "package", index: 0}, }, nil).Once() @@ -645,7 +646,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Error handling", func(t *testing.T) { t.Run("Zones() returns error", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, errors.New("zones error")) + mockReader.On("Zones").Return([]device.EnergyZone{}, errors.New("zones error")) meter := &raplPowerMeter{reader: mockReader, logger: slog.Default()} zone, err := meter.PrimaryEnergyZone() @@ -658,7 +659,7 @@ func TestPrimaryEnergyZone(t *testing.T) { t.Run("Empty zones list", func(t *testing.T) { mockReader := &mockRaplReader{} - mockReader.On("Zones").Return([]EnergyZone{}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) meter := &raplPowerMeter{reader: mockReader, logger: slog.Default()} zone, err := meter.PrimaryEnergyZone() diff --git a/internal/device/rapl_zone_filtering_test.go b/internal/device/cpu/rapl_zone_filtering_test.go similarity index 94% rename from internal/device/rapl_zone_filtering_test.go rename to internal/device/cpu/rapl_zone_filtering_test.go index 773f08d6ee..f5afddd441 100644 --- a/internal/device/rapl_zone_filtering_test.go +++ b/internal/device/cpu/rapl_zone_filtering_test.go @@ -1,13 +1,14 @@ // SPDX-FileCopyrightText: 2025 The Kepler Authors // SPDX-License-Identifier: Apache-2.0 -package device +package cpu import ( "log/slog" "testing" "github.com/stretchr/testify/assert" + "github.com/sustainable-computing-io/kepler/internal/device" ) func TestRaplZoneFiltering(t *testing.T) { @@ -33,7 +34,7 @@ func TestRaplZoneFiltering(t *testing.T) { index: 3, } - allZones := []EnergyZone{packageZone, coreZone, dramZone, uncoreZone} + allZones := []device.EnergyZone{packageZone, coreZone, dramZone, uncoreZone} tests := []struct { name string @@ -118,7 +119,7 @@ func TestRaplZoneFiltering_Init(t *testing.T) { energy: 50000, } - allZones := []EnergyZone{packageZone, coreZone} + allZones := []device.EnergyZone{packageZone, coreZone} t.Run("Init succeeds with valid filter", func(t *testing.T) { mockReader := &mockRaplReader{} @@ -166,7 +167,7 @@ func TestRaplZoneFiltering_Zones(t *testing.T) { energy: 50000, } - allZones := []EnergyZone{packageZone, coreZone} + allZones := []device.EnergyZone{packageZone, coreZone} tests := []struct { name string @@ -239,7 +240,7 @@ func TestRaplZoneFiltering_WithOptions(t *testing.T) { maxMicroJoules: 1000000, energy: 50000, } - mockReader.On("Zones").Return([]EnergyZone{packageZone, coreZone}, nil) + mockReader.On("Zones").Return([]device.EnergyZone{packageZone, coreZone}, nil) // Create meter with WithZoneFilter option meter, err := NewCPUPowerMeter( diff --git a/internal/device/cpu_power_meter_test.go b/internal/device/cpu_power_meter_test.go deleted file mode 100644 index d35cad7c0b..0000000000 --- a/internal/device/cpu_power_meter_test.go +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The Kepler Authors -// SPDX-License-Identifier: Apache-2.0 - -package device - -import ( - "log/slog" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/sustainable-computing-io/kepler/config" -) - -func discardLogger() *slog.Logger { - return slog.New(slog.DiscardHandler) -} - -func TestCreateCPUMeter_FakeOnly(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"fake"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_UnknownBackend(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"rappl"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "unknown cpu meter") -} - -func TestCreateCPUMeter_FallthroughToFake(t *testing.T) { - // Unknown name, then fake — exercises the factory-error path - // and the success-after-continue branch. - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = []string{"rappl", "fake"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_EmptyMeters(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Cpu.PreferredMeters = nil - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "cpu.preferredMeters is empty") -} - -func TestCreateCPUMeter_RaplFactoryError_FallsThroughToFake(t *testing.T) { - // A bogus sysfs path makes NewCPUPowerMeter (rapl) fail at construction - // because sysfs.NewFS validates the path. Falls through to fake. - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"rapl", "fake"} - cfg.Rapl.Zones = []string{"package"} // exercise the "rapl zones are filtered" log line - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_HwmonInitError_FallsThroughToFake(t *testing.T) { - // hwmon's NewHwmonPowerMeter constructs successfully on any path; Init - // fails when the path has no hwmon zones. Exercises the Init-error path - // in CreateCPUMeter and the experimental-config wiring in buildCPUMeter - // (zones + chip rules). - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"hwmon", "fake"} - cfg.Experimental = &config.Experimental{} - cfg.Experimental.Hwmon.Zones = []string{"power1"} - cfg.Experimental.Hwmon.ChipRules = []config.ChipPairingRule{ - {Name: "ina3221", UseSameIndex: true}, - } - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestCreateCPUMeter_AllFail_AggregatedError(t *testing.T) { - // Both rapl and hwmon fail (bogus sysfs); no fallback. Aggregated error. - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent/sysfs/path" - cfg.Cpu.PreferredMeters = []string{"rapl", "hwmon"} - - meter, err := CreateCPUMeter(discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "rapl") - assert.Contains(t, err.Error(), "hwmon") -} - -func TestBuildCPUMeter_Fake(t *testing.T) { - cfg := config.DefaultConfig() - - meter, err := buildCPUMeter("fake", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "fake-cpu-meter", meter.Name()) -} - -func TestBuildCPUMeter_Rapl_FactoryFails(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Host.SysFS = "/nonexistent" - - meter, err := buildCPUMeter("rapl", discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) -} - -func TestBuildCPUMeter_Hwmon_ConstructsWithExperimentalConfig(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Experimental = &config.Experimental{} - cfg.Experimental.Hwmon.Zones = []string{"power1", "power2"} - cfg.Experimental.Hwmon.ChipRules = []config.ChipPairingRule{ - {Name: "ltc2945", Pairings: map[int]int{1: 1}}, - } - - meter, err := buildCPUMeter("hwmon", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) - assert.Equal(t, "hwmon", meter.Name()) -} - -func TestBuildCPUMeter_Hwmon_NilExperimental(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Experimental = nil - - meter, err := buildCPUMeter("hwmon", discardLogger(), cfg) - require.NoError(t, err) - require.NotNil(t, meter) -} - -func TestBuildCPUMeter_Unknown(t *testing.T) { - cfg := config.DefaultConfig() - - meter, err := buildCPUMeter("nope", discardLogger(), cfg) - require.Error(t, err) - assert.Nil(t, meter) - assert.Contains(t, err.Error(), "unknown cpu meter") -} diff --git a/internal/device/energy_zone.go b/internal/device/energy_zone.go index b2d73cb5aa..9e8e47e3d6 100644 --- a/internal/device/energy_zone.go +++ b/internal/device/energy_zone.go @@ -22,10 +22,10 @@ const ( ZonePP1 Zone = "pp1" // Power Plane 1 - uncore (e.g., integrated GPU) ) -// zoneKey uniquely identifies a zone by name and index -type zoneKey struct { - name string - index int +// ZoneKey uniquely identifies a zone by name and index +type ZoneKey struct { + Name string + Index int } // AggregatedZone implements EnergyZone interface by aggregating multiple zones @@ -36,7 +36,7 @@ type AggregatedZone struct { name string index int zones []EnergyZone - lastReadings map[zoneKey]Energy + lastReadings map[ZoneKey]Energy currentEnergy Energy // Aggregated energy counter maxEnergy Energy // Cached sum of all zone MaxEnergy values mu sync.RWMutex @@ -70,7 +70,7 @@ func NewAggregatedZone(zones []EnergyZone) (*AggregatedZone, error) { name: name, index: -1, // Indicates this is an aggregated zone zones: zones, - lastReadings: make(map[zoneKey]Energy), + lastReadings: make(map[ZoneKey]Energy), currentEnergy: 0, maxEnergy: totalMax, // Cache the combined MaxEnergy }, nil @@ -86,6 +86,11 @@ func (az *AggregatedZone) Index() int { return az.index } +// Count returns the number of zones aggregated +func (az *AggregatedZone) Count() int { + return len(az.zones) +} + // Path returns path for the aggregated zone func (az *AggregatedZone) Path() string { // TODO: decide if all the paths should be returned @@ -106,7 +111,7 @@ func (az *AggregatedZone) Energy() (Energy, error) { return 0, fmt.Errorf("no valid energy readings from aggregated zones - %s: %w", zone.Name(), err) } - zoneID := zoneKey{zone.Name(), zone.Index()} + zoneID := ZoneKey{zone.Name(), zone.Index()} if lastReading, exists := az.lastReadings[zoneID]; exists { diff --git a/internal/device/energy_zone_test.go b/internal/device/energy_zone_test.go index e30c5c4c64..fdf1197503 100644 --- a/internal/device/energy_zone_test.go +++ b/internal/device/energy_zone_test.go @@ -485,7 +485,7 @@ func TestAggregatedZone_StateManagement(t *testing.T) { _, err = az.Energy() require.NoError(t, err) - zoneID := zoneKey{"package", 0} + zoneID := ZoneKey{"package", 0} lastReading := az.lastReadings[zoneID] assert.Equal(t, Energy(500), lastReading) @@ -509,7 +509,7 @@ func TestAggregatedZone_StateManagement(t *testing.T) { assert.Len(t, az.lastReadings, 1) // Verify zone ID format - expectedZoneID := zoneKey{"package", 0} + expectedZoneID := ZoneKey{"package", 0} _, exists := az.lastReadings[expectedZoneID] assert.True(t, exists, "Expected zone ID %v to exist", expectedZoneID) }) @@ -540,8 +540,8 @@ func TestAggregatedZone_StateManagement(t *testing.T) { assert.Len(t, az.lastReadings, 2) // Verify both zone IDs exist - zoneKey0 := zoneKey{"package", 0} - zoneKey1 := zoneKey{"package", 1} + zoneKey0 := ZoneKey{"package", 0} + zoneKey1 := ZoneKey{"package", 1} _, exists0 := az.lastReadings[zoneKey0] _, exists1 := az.lastReadings[zoneKey1] diff --git a/internal/device/power_meter.go b/internal/device/power_meter.go index 7b3c2aba7d..fd592ae835 100644 --- a/internal/device/power_meter.go +++ b/internal/device/power_meter.go @@ -14,3 +14,30 @@ type PowerMeter interface { service.Service // Name() service.Initializer // Init() } + +// EnergyZone represents a measurable energy or power zone/domain exposed by a power meter. +// An EnergyZone typically represents a logical zone of the hardware unit, e.g. cpu core, cpu package +// dram, uncore etc. +// Reference: https://firefox-source-docs.mozilla.org/performance/power_profiling_overview.html +type EnergyZone interface { + // Name() returns the zone name + Name() string + + // Index() returns the index of the zone + Index() int + + // Path() returns the path from which the energy usage value ie being read + Path() string + + // Energy() returns energy consumed by the zone. + Energy() (Energy, error) + + // MaxEnergy returns the maximum value of energy usage that can be read. + // When energy usage reaches this value, the energy value returned by Energy() + // will wrap around and start again from zero. + MaxEnergy() Energy + + // Power() returns the current power consumption by the zone. + // This method is used for zones that provide instantaneous power readings. + Power() (Power, error) +} diff --git a/internal/exporter/prometheus/collector/power_collector_concurrency_test.go b/internal/exporter/prometheus/collector/power_collector_concurrency_test.go index b8f50f2061..6f4c988ab3 100644 --- a/internal/exporter/prometheus/collector/power_collector_concurrency_test.go +++ b/internal/exporter/prometheus/collector/power_collector_concurrency_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/sustainable-computing-io/kepler/config" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" ) @@ -38,7 +39,7 @@ func TestPowerCollectorConcurrency(t *testing.T) { ri.SetExpectations(t, tr) ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) collector := NewPowerCollector(fakeMonitor, "test-node", newLogger(), config.MetricsLevelAll) @@ -114,9 +115,9 @@ func TestPowerCollectorConcurrency(t *testing.T) { func TestPowerCollectorWithRegistry(t *testing.T) { mockMonitor := NewMockPowerMonitor() - package0Zone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - package1Zone := device.NewMockRaplZone("package", 1, "/sys/class/powercap/intel-rapl/intel-rapl:1", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + package0Zone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + package1Zone := cpu.NewMockRaplZone("package", 1, "/sys/class/powercap/intel-rapl/intel-rapl:1", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgDelta := 123 * device.Joule @@ -241,7 +242,7 @@ func TestUpdateDuringCollection(t *testing.T) { collectingCh := make(chan struct{}) allowCollectCh := make(chan struct{}) - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) mockMonitor.On("Snapshot").Run(func(args mock.Arguments) { // NOTE: this waits for allow collect to close @@ -336,7 +337,7 @@ func TestConcurrentRegistration(t *testing.T) { ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) @@ -407,7 +408,7 @@ func TestFastCollectAndDescribe(t *testing.T) { ri.On("Refresh").Return(nil) fakeMonitor := monitor.NewPowerMonitor( - musT(device.NewFakeCPUMeter(nil)), + musT(cpu.NewFakeCPUMeter(nil)), monitor.WithResourceInformer(ri), ) collector := NewPowerCollector(fakeMonitor, "test-node", newLogger(), config.MetricsLevelAll) diff --git a/internal/exporter/prometheus/collector/power_collector_test.go b/internal/exporter/prometheus/collector/power_collector_test.go index 3a33b059cf..52440d74fd 100644 --- a/internal/exporter/prometheus/collector/power_collector_test.go +++ b/internal/exporter/prometheus/collector/power_collector_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/config" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" "github.com/sustainable-computing-io/kepler/internal/resource" ) @@ -179,8 +180,8 @@ func TestPowerCollector(t *testing.T) { mockMonitor := NewMockPowerMonitor() // Setup test zones - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgDelta := 123 * device.Joule @@ -606,7 +607,7 @@ func TestTerminatedProcessExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -712,7 +713,7 @@ func TestEnhancedErrorReporting(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -846,7 +847,7 @@ func TestPowerCollector_MetricsLevelFiltering(t *testing.T) { mockMonitor := NewMockPowerMonitor() // Create test data with all types of metrics - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testData := &monitor.Snapshot{ Timestamp: time.Now(), Node: &monitor.Node{ @@ -958,7 +959,7 @@ func TestTerminatedContainerExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), @@ -1092,7 +1093,7 @@ func TestTerminatedVMExport(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) mockMonitor := NewMockPowerMonitor() - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) testSnapshot := &monitor.Snapshot{ Timestamp: time.Now(), diff --git a/internal/exporter/stdout/stdout_test.go b/internal/exporter/stdout/stdout_test.go index c5dddbc126..7013e9673a 100644 --- a/internal/exporter/stdout/stdout_test.go +++ b/internal/exporter/stdout/stdout_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/monitor" ) @@ -152,8 +153,8 @@ func getTestNodeSnapshot() *monitor.Snapshot { func getTestNodeData() *monitor.Node { // Setup test zones - packageZone := device.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) - dramZone := device.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) + packageZone := cpu.NewMockRaplZone("package", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000) + dramZone := cpu.NewMockRaplZone("dram", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0:1", 1000) nodePkgAbs := 12300 * device.Joule nodePkgPower := 12 * device.Watt diff --git a/internal/monitor/mock_utils.go b/internal/monitor/mock_utils.go index 162d11abbb..3f83228bdd 100644 --- a/internal/monitor/mock_utils.go +++ b/internal/monitor/mock_utils.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" ) @@ -18,14 +19,14 @@ type MockCPUPowerMeter struct { mock.Mock } -func (m *MockCPUPowerMeter) Zones() ([]EnergyZone, error) { +func (m *MockCPUPowerMeter) Zones() ([]device.EnergyZone, error) { args := m.Called() - return args.Get(0).([]EnergyZone), args.Error(1) + return args.Get(0).([]device.EnergyZone), args.Error(1) } -func (m *MockCPUPowerMeter) PrimaryEnergyZone() (EnergyZone, error) { +func (m *MockCPUPowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { args := m.Called() - return args.Get(0).(EnergyZone), args.Error(1) + return args.Get(0).(device.EnergyZone), args.Error(1) } func (m *MockCPUPowerMeter) Name() string { @@ -62,17 +63,17 @@ func (m *MockEnergyZone) Path() string { return args.String(0) } -func (m *MockEnergyZone) Energy() (Energy, error) { +func (m *MockEnergyZone) Energy() (device.Energy, error) { args := m.Called() return args.Get(0).(Energy), args.Error(1) } -func (m *MockEnergyZone) MaxEnergy() Energy { +func (m *MockEnergyZone) MaxEnergy() device.Energy { args := m.Called() return args.Get(0).(Energy) } -func (m *MockEnergyZone) Power() (Power, error) { +func (m *MockEnergyZone) Power() (device.Power, error) { args := m.Called() return args.Get(0).(Power), args.Error(1) } @@ -154,14 +155,14 @@ var _ resource.Informer = (*MockResourceInformer)(nil) // Helper functions for creating test data // CreateTestZones creates mock energy zones for testing -func CreateTestZones() []EnergyZone { - pkg := device.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) - core := device.NewMockRaplZone("core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 500*Joule) - return []EnergyZone{pkg, core} +func CreateTestZones() []device.EnergyZone { + pkg := cpu.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) + core := cpu.NewMockRaplZone("core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 500*Joule) + return []device.EnergyZone{pkg, core} } // createNodeSnapshot creates a node snapshot with realistic power values -func createNodeSnapshot(zones []EnergyZone, timestamp time.Time, usageRatio float64) *Node { +func createNodeSnapshot(zones []device.EnergyZone, timestamp time.Time, usageRatio float64) *Node { node := &Node{ Timestamp: timestamp, UsageRatio: usageRatio, diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index eb608e3aaa..883929c7af 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -11,7 +11,7 @@ import ( "sync/atomic" "time" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/device/gpu" "github.com/sustainable-computing-io/kepler/internal/resource" "github.com/sustainable-computing-io/kepler/internal/service" @@ -40,7 +40,7 @@ type Service interface { type PowerMonitor struct { // passed externally logger *slog.Logger - cpu device.CPUPowerMeter + cpu cpu.CPUPowerMeter gpuMeters []gpu.GPUPowerMeter // optional, empty if no GPUs available interval time.Duration @@ -87,7 +87,7 @@ type PowerMonitor struct { var _ Service = (*PowerMonitor)(nil) // NewPowerMonitor creates a new PowerMonitor instance -func NewPowerMonitor(meter device.CPUPowerMeter, applyOpts ...OptionFn) *PowerMonitor { +func NewPowerMonitor(meter cpu.CPUPowerMeter, applyOpts ...OptionFn) *PowerMonitor { opts := DefaultOpts() for _, apply := range applyOpts { apply(&opts) diff --git a/internal/monitor/monitor_concurrency_test.go b/internal/monitor/monitor_concurrency_test.go index 6edd2b1c87..da72ac5641 100644 --- a/internal/monitor/monitor_concurrency_test.go +++ b/internal/monitor/monitor_concurrency_test.go @@ -17,13 +17,14 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" testingclock "k8s.io/utils/clock/testing" ) // TestSnapshotThreadSafety tests that multiple goroutines can call Snapshot concurrently without races. func TestSnapshotThreadSafety(t *testing.T) { fakeClock := testingclock.NewFakeClock(time.Now()) - fakeMeter, err := device.NewFakeCPUMeter(nil) + fakeMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) tr := CreateTestResources() resourceInformer := &MockResourceInformer{} @@ -143,7 +144,7 @@ func TestFreshSnapshotCaching(t *testing.T) { func TestStaleSnapshotRefreshing(t *testing.T) { // repeat the above using fake cpu meter fakeClock := testingclock.NewFakeClock(time.Now()) - fakeMeter, err := device.NewFakeCPUMeter(nil) + fakeMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) tr := CreateTestResources() diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 4f9ea04ac3..5ef3b95b6d 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" testingclock "k8s.io/utils/clock/testing" ) @@ -82,15 +83,15 @@ func TestPowerMonitor_Init(t *testing.T) { mockPowerMeter.On("PrimaryEnergyZone").Return(pkg, nil) mockPowerMeter.On("Name").Return("mock-cpu") - fakePowerMeter, err := device.NewFakeCPUMeter(nil) + fakePowerMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err) - powerMeters := []device.CPUPowerMeter{ + powerMeters := []cpu.CPUPowerMeter{ mockPowerMeter, fakePowerMeter, } - zoneNamesFromMeter := func(meter device.CPUPowerMeter) []string { + zoneNamesFromMeter := func(meter cpu.CPUPowerMeter) []string { energyZones, err := meter.Zones() if err != nil { log.Fatal(err) @@ -172,7 +173,7 @@ func TestPowerMonitor_Snapshot(t *testing.T) { } func TestPowerMonitor_InitZones(t *testing.T) { - fakePowerMeter, err := device.NewFakeCPUMeter(nil) + fakePowerMeter, err := cpu.NewFakeCPUMeter(nil) require.NoError(t, err, "failed to create fake power meter") monitor := NewPowerMonitor(fakePowerMeter) @@ -407,11 +408,11 @@ func TestPowerMonitor_FullInitRunShutdownCycle(t *testing.T) { func TestMonitorRefreshSnapshot(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -612,11 +613,11 @@ func TestRefreshSnapshotError(t *testing.T) { t.Run("Fix first read", func(t *testing.T) { mockCPUPowerMeter.ExpectedCalls = nil - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -648,11 +649,11 @@ func TestRefreshSnapshotError(t *testing.T) { t.Run("Fix computePower", func(t *testing.T) { mockCPUPowerMeter.ExpectedCalls = nil - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} diff --git a/internal/monitor/node_test.go b/internal/monitor/node_test.go index f0fef526f8..b99edebcf6 100644 --- a/internal/monitor/node_test.go +++ b/internal/monitor/node_test.go @@ -12,15 +12,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/sustainable-computing-io/kepler/internal/device" + "github.com/sustainable-computing-io/kepler/internal/device/cpu" "github.com/sustainable-computing-io/kepler/internal/resource" test_clock "k8s.io/utils/clock/testing" ) type ( - MockRaplZone = device.MockRaplZone - MockPowerZone = device.MockPowerZone + MockRaplZone = cpu.MockRaplZone + MockPowerZone = cpu.MockPowerZone ) // TestNodePowerCollection tests the PowerMonitor.collectNodePower method @@ -29,11 +29,11 @@ func TestNodePowerCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create test zones - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -213,11 +213,11 @@ func TestNodeErrorHandling(t *testing.T) { // Create a logger that writes to nowhere for testing logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - core := device.NewMockRaplZone( + core := cpu.NewMockRaplZone( "core-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:0", 150*Joule) testZones := []EnergyZone{pkg, core} @@ -375,7 +375,7 @@ func TestCalculateEnergyDelta(t *testing.T) { func TestNodeActiveEnergyCounterBehavior(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) @@ -551,7 +551,7 @@ func TestNodeActiveEnergyTotalAccumulation(t *testing.T) { // over multiple measurements, and that activeEnergy represents only the current interval logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - pkg := device.NewMockRaplZone( + pkg := cpu.NewMockRaplZone( "package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 1000*Joule) @@ -695,8 +695,8 @@ func TestPowerSensorCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create test power zones (hwmon-style) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") - hwmon1 := device.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon1 := cpu.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") testZones := []EnergyZone{hwmon0, hwmon1} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -827,8 +827,8 @@ func TestMixedSensorCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) // Create mixed zones: RAPL (energy) and hwmon (power) - raplPkg := device.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + raplPkg := cpu.NewMockRaplZone("package-0", 0, "/sys/class/powercap/intel-rapl/intel-rapl:0", 200*Joule) + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") testZones := []EnergyZone{raplPkg, hwmon0} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -909,8 +909,8 @@ func TestMixedSensorCollection(t *testing.T) { func TestPowerSensorErrorHandling(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") - hwmon1 := device.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon1 := cpu.NewMockPowerZone("hwmon1", 1, "/sys/class/hwmon/hwmon1") testZones := []EnergyZone{hwmon0, hwmon1} mockCPUPowerMeter := &MockCPUPowerMeter{} @@ -1000,7 +1000,7 @@ func TestPowerSensorErrorHandling(t *testing.T) { func TestPowerSensorActiveIdleSplit(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - hwmon0 := device.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") + hwmon0 := cpu.NewMockPowerZone("hwmon0", 0, "/sys/class/hwmon/hwmon0") testZones := []EnergyZone{hwmon0} mockCPUPowerMeter := &MockCPUPowerMeter{} From 2c97a8066f1f0ffa258f2932c2db29e75ceb2ebb Mon Sep 17 00:00:00 2001 From: Sivaprasad Tummala Date: Mon, 28 Sep 2026 05:16:54 +0000 Subject: [PATCH 2/3] feat(cpu): add AMD ESMI power meter support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for AMD Energy System Management Interface (E-SMI) for CPU power monitoring on AMD platforms. This enables accurate power measurements on AMD EPYC processors through E-SMI C library and sysfs fallback, including per-socket DIMM power consumption monitoring. Key changes: - Add ESMI CPU power meter implementation (internal/device/cpu/esmi/) - Support goamdsmi backend via E-SMI C library (libe_smi64.so) - Support per-socket DIMM power consumption monitoring (dimm backend) - Include sysfs fallback backend (pure Go, no dependencies) - Add experimental.esmi.enabled config flag - Integrate ESMI into CPU meter selection via cpu.preferredMeters - Shared E-SMI library initialization to prevent re-init crashes - Individual zone naming (package-N, core-N, dimm-N) matching sysfs behavior The ESMI meter is automatically prepended to cpu.preferredMeters when experimental.esmi.enabled is set to true, making it the first-tried CPU power source on supported AMD hardware. Zone types: - package-N: Per-socket energy (µJ) + instantaneous power (W) - core-N: Per-logical-thread energy (µJ) - dimm-N: Per-socket aggregated DIMM instantaneous power (W) Build options: - Default build: Uses sysfs backend only (no external dependencies) - With -tags goamdsmi: Adds E-SMI C library backends (requires libe_smi64.so) Installation: /opt/e-sms/e_smi/ or adjust CGO paths in source files Signed-off-by: Sivaprasad Tummala --- Makefile | 22 +- config/config.go | 60 ++- config/config_cpu_test.go | 19 +- hack/config.yaml | 3 + internal/device/cpu/create_meter.go | 7 + internal/device/cpu/esmi/backends_default.go | 17 + internal/device/cpu/esmi/backends_goamdsmi.go | 23 ++ internal/device/cpu/esmi/dimm_reader.go | 166 ++++++++ internal/device/cpu/esmi/esmi_init.go | 36 ++ internal/device/cpu/esmi/goamdsmi_reader.go | 195 +++++++++ internal/device/cpu/esmi/power_meter.go | 370 ++++++++++++++++++ internal/device/cpu/esmi/sysfs_reader.go | 290 ++++++++++++++ 12 files changed, 1204 insertions(+), 4 deletions(-) create mode 100644 internal/device/cpu/esmi/backends_default.go create mode 100644 internal/device/cpu/esmi/backends_goamdsmi.go create mode 100644 internal/device/cpu/esmi/dimm_reader.go create mode 100644 internal/device/cpu/esmi/esmi_init.go create mode 100644 internal/device/cpu/esmi/goamdsmi_reader.go create mode 100644 internal/device/cpu/esmi/power_meter.go create mode 100644 internal/device/cpu/esmi/sysfs_reader.go diff --git a/Makefile b/Makefile index fe3d2a822a..9215f71cb1 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ GOOS ?= $(shell go env GOOS) GOARCH ?= $(shell go env GOARCH) CGO_ENABLED ?= 1 +CC ?= cc +CXX ?= c++ # Cross-compilation sysroot auto-detection # On Fedora, the cross-compiler's default sysroot lacks headers. @@ -74,6 +76,24 @@ endif LDFLAGS=-ldflags "$(LD_STRIP_DEBUG_SYMBOLS) $(LD_VERSION_FLAGS)" +# Build tags (e.g., TAGS=goamdsmi) +TAGS ?= +ifneq ($(TAGS),) + BUILD_ARGS=-tags $(TAGS) +endif + +# When goamdsmi tag is used, ensure C++ stdlib is linked +# The goamdsmi library requires C++ linking +ifneq (,$(findstring goamdsmi,$(TAGS))) + BUILD_ENV_VARS=CC="$(CC)" CXX="$(CXX)" CGO_ENABLED=1 + # Preserve user CGO_CFLAGS and CGO_LDFLAGS if set, add C++ stdlib + export CGO_CFLAGS + export CGO_LDFLAGS += -lstdc++ + export CGO_CXXFLAGS=-std=c++11 +else + BUILD_ENV_VARS=CC="$(CC)" +endif + BUILD_DEBUG_ARGS ?= # Docker parameters @@ -130,7 +150,7 @@ help: ## Show this help message .PHONY: build build: ## Build binary mkdir -p $(BINARY_DIR) - GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(CGO_ENABLED) CC=$(CC) \ + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(CGO_ENABLED) $(BUILD_ENV_VARS) \ $(GOBUILD) $(BUILD_ARGS) \ $(LDFLAGS) \ -o $(BINARY_DIR)/$(BINARY_NAME) \ diff --git a/config/config.go b/config/config.go index b96517d52d..15b17a3cba 100644 --- a/config/config.go +++ b/config/config.go @@ -41,6 +41,9 @@ const ( // ExperimentalGPUFeature represents GPU power monitoring (experimental) ExperimentalGPUFeature Feature = "gpu" + + // ExperimentalEsmiFeature represents the CPU ESMI power monitoring feature + ExperimentalEsmiFeature Feature = "esmi" ) // Config represents the complete application configuration @@ -189,11 +192,17 @@ type ( DCGMEndpoint string `yaml:"dcgmEndpoint"` } + // ESMI configuration (Experimental) + Esmi struct { + Enabled *bool `yaml:"enabled"` //Development mode (capability auto detection in future) + } + // Experimental contains experimental features (no stability guarantees) Experimental struct { Platform Platform `yaml:"platform"` Hwmon Hwmon `yaml:"hwmon"` GPU ExperimentalGPU `yaml:"gpu"` + Esmi Esmi `yaml:"esmi"` } Config struct { @@ -311,6 +320,9 @@ const ( ExperimentalHwmonForceEnabledFlag = "experimental.hwmon.force-enabled" ExperimentalHwmonZonesFlag = "experimental.hwmon.zones" + // Experimental ESMI flags + ExperimentalEsmiEnabledFlag = "experimental.esmi.enabled" + // Experimental GPU flags ExperimentalGPUEnabledFlag = "experimental.gpu.enabled" ExperimentalGPUIdlePowerFlag = "experimental.gpu.idle-power" @@ -390,6 +402,10 @@ func DefaultConfig() *Config { // operators who set them today expect the legacy behavior. When both legacy // keys are set, fake takes precedence over hwmon. The legacy keys will stop // working in a future release. +// +// When experimental.esmi.enabled is set, "esmi" is prepended to the +// cpu.preferredMeters list (unless already present), making ESMI the +// first-tried CPU meter. func (c *Config) ApplyCpuMeterDeprecations(logger *slog.Logger) { switch { case ptr.Deref(c.Dev.FakeCpuMeter.Enabled, false): @@ -399,6 +415,23 @@ func (c *Config) ApplyCpuMeterDeprecations(logger *slog.Logger) { logger.Warn(`experimental.hwmon.forceEnabled is deprecated; set cpu.preferredMeters: ["hwmon"] instead`) c.Cpu.PreferredMeters = []string{"hwmon"} } + + // When ESMI is explicitly enabled, inject it as the first CPU meter to try + if c.Experimental != nil && ptr.Deref(c.Experimental.Esmi.Enabled, false) { + // Check if "esmi" is already in the list + hasEsmi := false + for _, m := range c.Cpu.PreferredMeters { + if m == "esmi" { + hasEsmi = true + break + } + } + if !hasEsmi { + // Prepend "esmi" to try it first + c.Cpu.PreferredMeters = append([]string{"esmi"}, c.Cpu.PreferredMeters...) + logger.Info("esmi CPU meter enabled", "preferredMeters", c.Cpu.PreferredMeters) + } + } } // Load loads configuration from an io.Reader @@ -501,6 +534,9 @@ func RegisterFlags(app *kingpin.Application) ConfigUpdaterFn { hwmonForceEnabled := app.Flag(ExperimentalHwmonForceEnabledFlag, "Force hwmon as the power meter, skipping RAPL auto-detection").Default("false").Bool() hwmonZones := app.Flag(ExperimentalHwmonZonesFlag, "Hwmon zone filter (power labels to monitor)").Strings() + // experimental ESMI + esmiEnabled := app.Flag(ExperimentalEsmiEnabledFlag, "Enable experimental ESMI power monitoring").Default("false").Bool() + // experimental GPU gpuEnabled := app.Flag(ExperimentalGPUEnabledFlag, "Enable experimental GPU power monitoring").Default("false").Bool() gpuIdlePower := app.Flag(ExperimentalGPUIdlePowerFlag, "GPU idle power in Watts (0 = auto-detect from idle observations)").Default("0").Float64() @@ -578,6 +614,15 @@ func RegisterFlags(app *kingpin.Application) ConfigUpdaterFn { return err } + // Apply experimental ESMI settings + if flagsSet[ExperimentalEsmiEnabledFlag] { + // Initialize experimental section if needed + if cfg.Experimental == nil { + cfg.Experimental = &Experimental{} + } + cfg.Experimental.Esmi.Enabled = esmiEnabled + } + // Apply experimental GPU settings applyGPUConfig(cfg, flagsSet, gpuEnabled, gpuIdlePower, gpuDCGMEndpoint) @@ -785,6 +830,11 @@ func (c *Config) IsFeatureEnabled(feature Feature) bool { return ptr.Deref(c.Exporter.Stdout.Enabled, false) case PprofFeature: return ptr.Deref(c.Debug.Pprof.Enabled, false) + case ExperimentalEsmiFeature: + if c.Experimental == nil { + return false + } + return ptr.Deref(c.Experimental.Esmi.Enabled, false) case ExperimentalGPUFeature: if c.Experimental == nil { return false @@ -811,6 +861,11 @@ func (c *Config) experimentalFeatureEnabled() bool { return true } + // Check if ESMI is enabled + if ptr.Deref(c.Experimental.Esmi.Enabled, false) { + return true + } + // Check if GPU is enabled if ptr.Deref(c.Experimental.GPU.Enabled, false) { return true @@ -924,15 +979,16 @@ func (c *Config) Validate(skips ...SkipValidation) error { } } { // cpu.preferredMeters - // Keep this list in sync with the switch in internal/device/cpu_power_meter.go. + // Keep this list in sync with the switch in internal/device/cpu/cpu_power_meter.go. validCpuMeters := map[string]bool{ "rapl": true, "hwmon": true, + "esmi": true, "fake": true, } for _, name := range c.Cpu.PreferredMeters { if !validCpuMeters[name] { - errs = append(errs, fmt.Sprintf("invalid cpu.preferredMeters entry %q, must be one of %q, %q, %q", name, "rapl", "hwmon", "fake")) + errs = append(errs, fmt.Sprintf("invalid cpu.preferredMeters entry %q, must be one of %q, %q, %q, %q", name, "rapl", "hwmon", "esmi", "fake")) } } } diff --git a/config/config_cpu_test.go b/config/config_cpu_test.go index e8c32c48b5..8a1a379763 100644 --- a/config/config_cpu_test.go +++ b/config/config_cpu_test.go @@ -59,6 +59,23 @@ func TestCpuPreferredMeters(t *testing.T) { }, want: []string{"rapl"}, }, + { + name: "esmi enabled prepends to preferredMeters", + setup: func(c *Config) { + c.Experimental = &Experimental{} + c.Experimental.Esmi.Enabled = ptr.To(true) + }, + want: []string{"esmi", "rapl", "hwmon"}, + }, + { + name: "esmi already in list: no duplication", + setup: func(c *Config) { + c.Cpu.PreferredMeters = []string{"esmi", "rapl"} + c.Experimental = &Experimental{} + c.Experimental.Esmi.Enabled = ptr.To(true) + }, + want: []string{"esmi", "rapl"}, + }, } logger := slog.New(slog.DiscardHandler) @@ -81,7 +98,7 @@ func TestCpuPreferredMetersValidation(t *testing.T) { }{ { name: "all known backends", - meters: []string{"rapl", "hwmon", "fake"}, + meters: []string{"rapl", "hwmon", "esmi", "fake"}, }, { name: "unknown backend", diff --git a/hack/config.yaml b/hack/config.yaml index 636c8a9e61..154b10496c 100644 --- a/hack/config.yaml +++ b/hack/config.yaml @@ -36,6 +36,7 @@ cpu: # Ordered preference list of CPU power-meter backends. The first backend # that initializes successfully and reports zones is used. # Built-in backends: rapl, hwmon, fake. + # To enable ESMI (AMD CPU power monitoring), set experimental.esmi.enabled: true preferredMeters: [rapl, hwmon] rapl: @@ -111,3 +112,5 @@ experimental: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) dcgmEndpoint: "" # dcgm-exporter metrics URL for MIG (auto-discovered if empty) + esmi: + enabled: false # Enable experimental AMD ESMI CPU power monitoring (AMD EPYC platforms) diff --git a/internal/device/cpu/create_meter.go b/internal/device/cpu/create_meter.go index b07078680c..cd107d82e1 100644 --- a/internal/device/cpu/create_meter.go +++ b/internal/device/cpu/create_meter.go @@ -9,6 +9,7 @@ import ( "log/slog" "github.com/sustainable-computing-io/kepler/config" + "github.com/sustainable-computing-io/kepler/internal/device/cpu/esmi" ) // CreateCPUMeter walks cfg.Cpu.PreferredMeters in preference order, builds @@ -93,6 +94,12 @@ func buildCPUMeter(name string, logger *slog.Logger, cfg *config.Config) (CPUPow WithHwmonChipRules(rules), ) + case "esmi": + return esmi.NewCPUPowerMeter( + cfg.Host.SysFS, + esmi.WithLogger(logger), + ) + case "fake": return NewFakeCPUMeter(cfg.Dev.FakeCpuMeter.Zones, WithFakeLogger(logger)) diff --git a/internal/device/cpu/esmi/backends_default.go b/internal/device/cpu/esmi/backends_default.go new file mode 100644 index 0000000000..b7dc915519 --- /dev/null +++ b/internal/device/cpu/esmi/backends_default.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !goamdsmi + +package esmi + +// availableReaders returns the ordered list of backend candidates when the +// goamdsmi build tag is NOT active (default / CI build). +// +// Only the sysfs backend is available in this configuration. +// To also enable the goamdsmi backend, build with: -tags goamdsmi +func availableReaders(sysfsPath string) []backendCandidate { + return []backendCandidate{ + sysfsBackend(sysfsPath), + } +} diff --git a/internal/device/cpu/esmi/backends_goamdsmi.go b/internal/device/cpu/esmi/backends_goamdsmi.go new file mode 100644 index 0000000000..e4a8ded565 --- /dev/null +++ b/internal/device/cpu/esmi/backends_goamdsmi.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// availableReaders returns all backend candidates when the goamdsmi build tag +// is active. +// +// Primary backends (mutually exclusive — first working one wins): +// 1. goamdsmi — socket + core energy counters, instantaneous socket power +// 2. sysfs — pure-Go socket + core energy counters via amd_energy driver +// +// Additive backends (always attempted, zones merged alongside primary): +// - dimm — per-DIMM instantaneous power via e-smi C library +func availableReaders(sysfsPath string) []backendCandidate { + return []backendCandidate{ + goamdsmiBackend(), + {name: "dimm", reader: NewDimmReader(), additive: true}, + sysfsBackend(sysfsPath), + } +} diff --git a/internal/device/cpu/esmi/dimm_reader.go b/internal/device/cpu/esmi/dimm_reader.go new file mode 100644 index 0000000000..de4c53f221 --- /dev/null +++ b/internal/device/cpu/esmi/dimm_reader.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// DimmReader provides per-DIMM power readings via the AMD E-SMI C library +// (libe_smi64.so) through CGo. It is compiled only with -tags goamdsmi. +// +// The E-SMI library exposes esmi_dimm_power_consumption_get(socket, dimmAddr, *power) +// which returns instantaneous DIMM power in milliwatts. DIMM addresses are +// enumerated per-socket; the library uses the SPD address (0x80–0x8b). +// +// Unlike the energy-counter backends, DimmReader zones only support Power() +// (instantaneous mW → W). Energy() returns an error directing callers to +// integrate Power() readings over time. +// +// Build requirements: +// The E-SMI library (libe_smi64.so) and headers must be installed. +// If installed in a non-standard location (e.g., /opt/e-sms/e_smi/), +// update the CGO directives below with the correct paths. + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +// esmi_dimm_power_read is a thin C helper that calls +// esmi_dimm_power_consumption_get and returns the power in milliwatts, +// or UINT32_MAX on any error. +static uint32_t esmi_dimm_power_read(uint32_t socket, uint8_t dimm_addr) { + struct dimm_power dp = {0}; + esmi_status_t ret = esmi_dimm_power_consumption_get(socket, dimm_addr, &dp); + if (ret != ESMI_SUCCESS) { + return UINT32_MAX; + } + return dp.power; // milliwatts +} + +// esmi_num_sockets returns the number of sockets or 0 on error. +static uint32_t esmi_num_sockets(void) { + uint32_t n = 0; + esmi_number_of_sockets_get(&n); + return n; +} +*/ +import "C" + +import ( + "fmt" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// Standard SPD DIMM addresses on the SMBus: 0x80 through 0x8b +// (up to 8 DIMMs per socket channel). +var dimmAddrs = []uint8{0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b} + +const dimmPowerFailure = uint32(0xFFFFFFFF) + +// ── DimmReader ──────────────────────────────────────────────────────────────── + +// DimmReader enumerates all responding DIMM addresses across all sockets and +// returns one EsmiDimmZone per DIMM that responds to a power query. +type DimmReader struct{} + +// NewDimmReader returns a DimmReader. +func NewDimmReader() *DimmReader { return &DimmReader{} } + +// Zones initialises the e-smi library and probes all known DIMM SPD addresses +// on every socket. Returns one aggregated zone per socket that sums power from +// all responding DIMMs on that socket. +func (r *DimmReader) Zones() ([]device.EnergyZone, error) { + if !esmiInit() { + // Library or driver not available — return nil so the caller can + // skip this reader without treating it as a hard error. + return nil, nil + } + + numSockets := int(C.esmi_num_sockets()) + if numSockets == 0 { + return nil, nil + } + + var zones []device.EnergyZone + for socket := 0; socket < numSockets; socket++ { + // Collect all responding DIMM addresses for this socket + var dimmAddresses []uint8 + for _, addr := range dimmAddrs { + pw := uint32(C.esmi_dimm_power_read(C.uint32_t(socket), C.uint8_t(addr))) + if pw != dimmPowerFailure { + dimmAddresses = append(dimmAddresses, addr) + } + } + + // Only create a zone if at least one DIMM responded + if len(dimmAddresses) > 0 { + zones = append(zones, &EsmiDimmZone{ + socketIdx: socket, + dimmAddresses: dimmAddresses, + }) + } + } + return zones, nil +} + +// ── EsmiDimmZone ────────────────────────────────────────────────────────────── + +// EsmiDimmZone implements device.EnergyZone for aggregated DIMM power per socket. +// It sums power from all responding DIMMs on the socket. +// Only Power() is supported; Energy() returns an error. +type EsmiDimmZone struct { + socketIdx int + dimmAddresses []uint8 +} + +func (z *EsmiDimmZone) Name() string { + return fmt.Sprintf("dimm-%d", z.socketIdx) +} +func (z *EsmiDimmZone) Index() int { return z.socketIdx } +func (z *EsmiDimmZone) Path() string { + return fmt.Sprintf("esmi://cpu/socket/%d/dimm", z.socketIdx) +} + +// Energy is not supported for DIMM zones. The E-SMI library exposes only +// instantaneous power; integrate Power() readings over time to get energy. +func (z *EsmiDimmZone) Energy() (device.Energy, error) { + return 0, fmt.Errorf("esmi/dimm: DIMM zones do not provide energy counters; integrate Power() readings over time") +} + +// MaxEnergy returns 0 (not applicable). +func (z *EsmiDimmZone) MaxEnergy() device.Energy { return 0 } + +// Power returns the aggregated instantaneous DIMM power for all DIMMs on this +// socket in watts. The E-SMI library returns milliwatts per DIMM. +func (z *EsmiDimmZone) Power() (device.Power, error) { + var totalPowerMw uint64 + var readErrors int + + for _, addr := range z.dimmAddresses { + pw := uint32(C.esmi_dimm_power_read(C.uint32_t(z.socketIdx), C.uint8_t(addr))) + if pw == dimmPowerFailure { + readErrors++ + continue + } + totalPowerMw += uint64(pw) + } + + // Return error only if ALL DIMMs failed to read + if readErrors == len(z.dimmAddresses) { + return 0, fmt.Errorf("esmi/dimm: all DIMM reads failed for socket %d", z.socketIdx) + } + + return device.Power(float64(totalPowerMw) / 1000.0), nil +} + +// ── backend registration ────────────────────────────────────────────────────── + +// dimmBackend returns a backend candidate for DIMM power readings. +// It is included in availableReaders() alongside the goamdsmi backend. +func dimmBackend() backendCandidate { + return backendCandidate{name: "dimm", reader: NewDimmReader()} +} diff --git a/internal/device/cpu/esmi/esmi_init.go b/internal/device/cpu/esmi/esmi_init.go new file mode 100644 index 0000000000..b7aa914999 --- /dev/null +++ b/internal/device/cpu/esmi/esmi_init.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +static int esmi_init_wrapper(void) { + return (int)esmi_init(); +} +*/ +import "C" +import "sync" + +// Shared E-SMI library initialization state. +// The E-SMI library must be initialized only once per process. +var ( + esmiInitOnce sync.Once + esmiInitOk bool +) + +// esmiInit ensures the E-SMI library is initialized exactly once. +// Returns true if initialization succeeded (or was already successful). +func esmiInit() bool { + esmiInitOnce.Do(func() { + esmiInitOk = (C.esmi_init_wrapper() == 0) + }) + return esmiInitOk +} diff --git a/internal/device/cpu/esmi/goamdsmi_reader.go b/internal/device/cpu/esmi/goamdsmi_reader.go new file mode 100644 index 0000000000..f24d9c1af0 --- /dev/null +++ b/internal/device/cpu/esmi/goamdsmi_reader.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:build goamdsmi + +package esmi + +// This file is only compiled when built with -tags goamdsmi. +// It requires: +// - E-SMI C library (libe_smi64.so) installed (typically /opt/e-sms/e_smi/) +// - amd_hsmp kernel driver loaded +// +// Zone types provided by this backend: +// - socket : accumulated energy (µJ) + instantaneous power (W) per socket +// - core : accumulated energy (µJ) per logical thread/core + +/* +#cgo CFLAGS: -I/opt/e-sms/e_smi/include +#cgo LDFLAGS: -L/opt/e-sms/e_smi/lib -le_smi64 + +#include +#include + +// Wrapper functions to safely call E-SMI library functions +static uint32_t esmi_num_sockets(void) { + uint32_t n = 0; + esmi_number_of_sockets_get(&n); + return n; +} + +static uint32_t esmi_num_cpus(void) { + uint32_t n = 0; + esmi_number_of_cpus_get(&n); + return n; +} + +static uint32_t esmi_threads_per_core_get_safe(void) { + uint32_t n = 0; + esmi_threads_per_core_get(&n); + return n; +} + +// Returns 0 on success, non-zero on failure +static int esmi_socket_energy_read(uint32_t socket_idx, uint64_t *energy) { + return (int)esmi_socket_energy_get(socket_idx, energy); +} + +// Returns 0 on success, non-zero on failure +static int esmi_socket_power_read(uint32_t socket_idx, uint32_t *power) { + return (int)esmi_socket_power_get(socket_idx, power); +} + +// Returns 0 on success, non-zero on failure +static int esmi_core_energy_read(uint32_t core_ind, uint64_t *energy) { + return (int)esmi_core_energy_get(core_ind, energy); +} +*/ +import "C" + +import ( + "fmt" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// ── GoamdsmiReader ──────────────────────────────────────────────────────────── + +// GoamdsmiReader implements Reader using the AMD E-SMI C library. +// It returns one "socket" zone per physical CPU socket and one "core" zone +// per logical thread exposed by the library. +type GoamdsmiReader struct{} + +// NewGoamdsmiReader returns a GoamdsmiReader. +func NewGoamdsmiReader() *GoamdsmiReader { return &GoamdsmiReader{} } + +// Zones initialises the E-SMI library and returns all available +// energy zones. Returns nil (no error) when the library cannot initialise so +// NewCPUPowerMeter falls through to the next backend. +func (r *GoamdsmiReader) Zones() ([]device.EnergyZone, error) { + if !esmiInit() { + return nil, nil + } + + var zones []device.EnergyZone + + // ── socket zones ───────────────────────────────────────────────────────── + numSockets := int(C.esmi_num_sockets()) + for i := 0; i < numSockets; i++ { + var energy C.uint64_t + if C.esmi_socket_energy_read(C.uint32_t(i), &energy) != 0 { + continue + } + zones = append(zones, &GoamdsmiPackageZone{socketIdx: i}) + } + + // ── core zones ──────────────────────────────────────────────────────────── + // The library indexes cores by CPU (logical thread index). + numCPUs := int(C.esmi_num_cpus()) + threadsPerCore := int(C.esmi_threads_per_core_get_safe()) + if threadsPerCore < 1 { + threadsPerCore = 1 + } + for i := 0; i < numCPUs; i++ { + var energy C.uint64_t + if C.esmi_core_energy_read(C.uint32_t(i), &energy) != 0 { + continue + } + zones = append(zones, &GoamdsmiCoreZone{ + threadIdx: i, + coreIdx: i / threadsPerCore, + }) + } + + return zones, nil +} + +// ── GoamdsmiPackageZone ──────────────────────────────────────────────────────── + +// GoamdsmiPackageZone implements device.EnergyZone for one AMD CPU socket. +// Uniquely among ESMI zones, it also supports Power() for instantaneous watts. +type GoamdsmiPackageZone struct { + socketIdx int +} + +func (z *GoamdsmiPackageZone) Name() string { + return fmt.Sprintf("package-%d", z.socketIdx) +} +func (z *GoamdsmiPackageZone) Index() int { return z.socketIdx } +func (z *GoamdsmiPackageZone) Path() string { + return fmt.Sprintf("esmi://cpu/socket/%d", z.socketIdx) +} + +// Energy returns accumulated socket energy in microjoules. +func (z *GoamdsmiPackageZone) Energy() (device.Energy, error) { + var energy C.uint64_t + if C.esmi_socket_energy_read(C.uint32_t(z.socketIdx), &energy) != 0 { + return 0, fmt.Errorf("esmi: esmi_socket_energy_get failed for socket %d", z.socketIdx) + } + return device.Energy(energy), nil +} + +// MaxEnergy returns 0 — the library does not expose a counter maximum. +func (z *GoamdsmiPackageZone) MaxEnergy() device.Energy { return 0 } + +// Power returns instantaneous socket power in watts. +// esmi_socket_power_get returns milliwatts. +func (z *GoamdsmiPackageZone) Power() (device.Power, error) { + var power C.uint32_t + if C.esmi_socket_power_read(C.uint32_t(z.socketIdx), &power) != 0 { + return 0, fmt.Errorf("esmi: esmi_socket_power_get failed for socket %d", z.socketIdx) + } + return device.Power(float64(power) / 1000.0), nil +} + +// ── GoamdsmiCoreZone ────────────────────────────────────────────────────────── + +// GoamdsmiCoreZone implements device.EnergyZone for a single logical core +// (SMT thread). Energy is cumulative µJ; instantaneous power is not available +// at per-core granularity via this library. +type GoamdsmiCoreZone struct { + threadIdx int // library index (SMT thread) + coreIdx int // physical core index (threadIdx / threadsPerCore) +} + +func (z *GoamdsmiCoreZone) Name() string { + return fmt.Sprintf("core-%d", z.threadIdx) +} +func (z *GoamdsmiCoreZone) Index() int { return z.threadIdx } +func (z *GoamdsmiCoreZone) Path() string { + return fmt.Sprintf("esmi://cpu/core/%d", z.threadIdx) +} + +// Energy returns accumulated core energy in microjoules. +func (z *GoamdsmiCoreZone) Energy() (device.Energy, error) { + var energy C.uint64_t + if C.esmi_core_energy_read(C.uint32_t(z.threadIdx), &energy) != 0 { + return 0, fmt.Errorf("esmi: esmi_core_energy_get failed for thread %d", z.threadIdx) + } + return device.Energy(energy), nil +} + +// MaxEnergy returns 0 — no counter maximum is exposed. +func (z *GoamdsmiCoreZone) MaxEnergy() device.Energy { return 0 } + +// Power is not available at per-core granularity via E-SMI. +// Use socket-level Power() or derive from successive Energy() readings. +func (z *GoamdsmiCoreZone) Power() (device.Power, error) { + return 0, fmt.Errorf("esmi: per-core instantaneous power is not available; derive from successive Energy() calls") +} + +// ── backend registration ────────────────────────────────────────────────────── + +func goamdsmiBackend() backendCandidate { + return backendCandidate{name: "goamdsmi", reader: NewGoamdsmiReader()} +} diff --git a/internal/device/cpu/esmi/power_meter.go b/internal/device/cpu/esmi/power_meter.go new file mode 100644 index 0000000000..255a053946 --- /dev/null +++ b/internal/device/cpu/esmi/power_meter.go @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package esmi implements a CPUPowerMeter for AMD processors. +// +// Two primary backends are supported and selected automatically at runtime: +// +// 1. goamdsmi (priority 1) — Go bindings over the AMD e-smi / HSMP C library +// (github.com/ROCm/amdsmi). Provides socket and core energy counters plus +// instantaneous socket power. Only compiled with the "goamdsmi" build tag. +// +// 2. sysfs amd_energy (priority 2) — pure-Go, zero CGo. Reads hwmon counters +// from the amd_energy kernel module (CONFIG_AMD_ENERGY, Linux ≥ 5.8). +// Provides socket and core energy counters only. +// +// One additive backend is always attempted independently: +// +// - dimm — per-DIMM instantaneous power via the e-smi C library +// (esmi_get_dimm_power). Compiled only with the "goamdsmi" build tag. +// DIMM zones expose Power() only; Energy() returns an error. +// +// NewCPUPowerMeter selects the first primary backend that works, then merges +// in any DIMM zones from the dimm backend (if available). Zone types: +// +// package – highest priority for PrimaryEnergyZone(); normalized from AMD +// "socket" to match RAPL naming; supports Power() via goamdsmi backend +// core – per-logical-thread energy +// dimm – per-DIMM power (Power() only, no Energy() counter) +package esmi + +import ( + "fmt" + "log/slog" + "strings" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// PowerMeter implements device.CPUPowerMeter for AMD CPUs. +type PowerMeter struct { + reader Reader + cachedZones []device.EnergyZone + logger *slog.Logger + zoneFilter []string + topZone device.EnergyZone + backendName string +} + +// OptionFn is a functional option for PowerMeter. +type OptionFn func(*PowerMeter) + +// WithReader forces a specific Reader backend, bypassing auto-detection. +// Primarily useful in tests. +func WithReader(r Reader) OptionFn { + return func(pm *PowerMeter) { pm.reader = r } +} + +// WithLogger sets a structured logger on the meter. +func WithLogger(logger *slog.Logger) OptionFn { + return func(pm *PowerMeter) { pm.logger = logger.With("service", "esmi") } +} + +// WithZoneFilter restricts monitoring to the named zone types. +// Valid names: "package", "core", "dimm", "l3cache". Empty means all zones are included. +// Note: "package" is the normalized name for AMD "socket" zones to align with RAPL. +func WithZoneFilter(zones []string) OptionFn { + return func(pm *PowerMeter) { pm.zoneFilter = zones } +} + +// NewCPUPowerMeter constructs a PowerMeter, auto-detecting the best available +// backends. sysfsPath is the sysfs root (normally "/sys"). +func NewCPUPowerMeter(sysfsPath string, opts ...OptionFn) (*PowerMeter, error) { + pm := &PowerMeter{ + logger: slog.Default().With("service", "esmi"), + zoneFilter: []string{}, + } + for _, opt := range opts { + opt(pm) + } + + // WithReader injected a backend (e.g. in tests) — use it directly. + if pm.reader != nil { + pm.backendName = "injected" + return pm, nil + } + + candidates := availableReaders(sysfsPath) + + // Split candidates into primary (energy-capable) and additive (power-only). + var primaryCandidates, additiveCandidates []backendCandidate + for _, c := range candidates { + if c.additive { + additiveCandidates = append(additiveCandidates, c) + } else { + primaryCandidates = append(primaryCandidates, c) + } + } + + // Select the first working primary backend. + var primaryReader Reader + for _, candidate := range primaryCandidates { + zones, err := candidate.reader.Zones() + if err != nil { + pm.logger.Debug("esmi primary backend failed", + "backend", candidate.name, "err", err) + continue + } + if len(zones) == 0 { + pm.logger.Debug("esmi primary backend found no zones", + "backend", candidate.name) + continue + } + // Probe the first zone for either Energy() or Power() support + _, errEnergy := zones[0].Energy() + _, errPower := zones[0].Power() + if errEnergy != nil && errPower != nil { + pm.logger.Debug("esmi primary backend probe failed", + "backend", candidate.name, + "energy_err", errEnergy, + "power_err", errPower) + continue + } + primaryReader = candidate.reader + pm.backendName = candidate.name + pm.logger.Info("esmi primary backend selected", "backend", candidate.name) + break + } + + if primaryReader == nil { + return nil, fmt.Errorf( + "esmi: no working primary backend found; " + + "load the amd_energy kernel module or install libamd_smi.so " + + "and rebuild with -tags goamdsmi", + ) + } + + // Probe additive backends (e.g. DIMM) and merge their zones. + // These are probed via Power() rather than Energy(). + var additiveReaders []Reader + for _, candidate := range additiveCandidates { + zones, err := candidate.reader.Zones() + if err != nil || len(zones) == 0 { + pm.logger.Debug("esmi additive backend unavailable", + "backend", candidate.name, "err", err) + continue + } + if _, err := zones[0].Power(); err != nil { + pm.logger.Debug("esmi additive backend probe failed", + "backend", candidate.name, "err", err) + continue + } + additiveReaders = append(additiveReaders, candidate.reader) + pm.logger.Info("esmi additive backend available", "backend", candidate.name) + } + + if len(additiveReaders) == 0 { + pm.reader = primaryReader + } else { + pm.reader = &mergedReader{ + primary: primaryReader, + additive: additiveReaders, + } + } + + return pm, nil +} + +// backendCandidate pairs a display name with a Reader for the detection loop. +type backendCandidate struct { + name string + reader Reader + // additive marks backends whose zones supplement (rather than replace) the + // primary backend. Additive zones are probed via Power(), not Energy(). + additive bool +} + +// mergedReader combines a primary Reader with one or more additive Readers. +type mergedReader struct { + primary Reader + additive []Reader +} + +func (m *mergedReader) Zones() ([]device.EnergyZone, error) { + zones, err := m.primary.Zones() + if err != nil { + return nil, err + } + for _, r := range m.additive { + extra, err := r.Zones() + if err != nil { + continue // additive failures are non-fatal + } + zones = append(zones, extra...) + } + return zones, nil +} + +// Reader enumerates ESMI energy zones. +type Reader interface { + Zones() ([]device.EnergyZone, error) +} + +// ── device.CPUPowerMeter interface ─────────────────────────────────────────── + +// Name identifies the meter and the active backend. +func (pm *PowerMeter) Name() string { return "esmi/" + pm.backendName } + +// Init validates that the active backend can enumerate and read at least one zone. +func (pm *PowerMeter) Init() error { + zones, err := pm.reader.Zones() + if err != nil { + return fmt.Errorf("esmi: failed to enumerate zones: %w", err) + } + if len(zones) == 0 { + return fmt.Errorf("esmi: no energy zones found (backend: %s)", pm.backendName) + } + // Find the first energy-capable or power-capable zone to probe. + for _, z := range zones { + // Try Energy() first (preferred) + if _, err = z.Energy(); err == nil { + return nil + } + // Fall back to Power() for power-only zones (e.g., HSMP) + if _, err = z.Power(); err == nil { + return nil + } + } + return fmt.Errorf("esmi: no readable energy or power zone found (backend: %s)", pm.backendName) +} + +// Zones returns the filtered and aggregated list of energy zones. +// Results are cached after the first successful call. +func (pm *PowerMeter) Zones() ([]device.EnergyZone, error) { + if len(pm.cachedZones) != 0 { + return pm.cachedZones, nil + } + + zones, err := pm.reader.Zones() + if err != nil { + return nil, err + } + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones found") + } + + zones = pm.filterZones(zones) + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones remaining after filtering") + } + + pm.cachedZones = pm.groupZonesByName(zones) + return pm.cachedZones, nil +} + +// PrimaryEnergyZone returns the highest-priority energy-capable zone. +// Priority: package > core > l3cache > first available. +// Zone names are normalized to match RAPL conventions (socket → package). +// DIMM zones are excluded since they have no energy counter. +func (pm *PowerMeter) PrimaryEnergyZone() (device.EnergyZone, error) { + if pm.topZone != nil { + return pm.topZone, nil + } + + zones, err := pm.Zones() + if err != nil { + return nil, err + } + if len(zones) == 0 { + return nil, fmt.Errorf("esmi: no energy zones available") + } + + zoneMap := make(map[string]device.EnergyZone, len(zones)) + for _, z := range zones { + // Only index energy-capable zones for primary selection. + if _, err := z.Energy(); err == nil { + zoneMap[strings.ToLower(z.Name())] = z + } + } + + // Priority order aligned with RAPL: package > core > l3cache + for _, name := range []string{"package", "core", "l3cache"} { + if z, ok := zoneMap[name]; ok { + pm.topZone = z + return z, nil + } + } + + // Fallback: first energy-capable zone. + for _, z := range zones { + if _, err := z.Energy(); err == nil { + pm.topZone = z + return z, nil + } + } + + // Last resort: accept power-only zones (e.g., HSMP) + // Power can be integrated over time to derive energy + for _, z := range zones { + if _, err := z.Power(); err == nil { + pm.logger.Info("using power-only zone as primary", + "zone", z.Name(), + "note", "energy will be derived from power readings") + pm.topZone = z + return z, nil + } + } + + return nil, fmt.Errorf("esmi: no energy-capable or power-capable zone found") +} + +// ── internal helpers ────────────────────────────────────────────────────────── + +func (pm *PowerMeter) needsFiltering() bool { return len(pm.zoneFilter) != 0 } + +func (pm *PowerMeter) filterZones(zones []device.EnergyZone) []device.EnergyZone { + if !pm.needsFiltering() { + return zones + } + wanted := make(map[string]bool, len(pm.zoneFilter)) + for _, name := range pm.zoneFilter { + wanted[strings.ToLower(name)] = true + } + var included, excluded []string + filtered := make([]device.EnergyZone, 0, len(zones)) + for _, z := range zones { + if wanted[strings.ToLower(z.Name())] { + filtered = append(filtered, z) + included = append(included, z.Name()) + } else { + excluded = append(excluded, z.Name()) + } + } + pm.logger.Debug("filtered ESMI zones", "included", included, "excluded", excluded) + return filtered +} + +func (pm *PowerMeter) groupZonesByName(zones []device.EnergyZone) []device.EnergyZone { + groups := make(map[string][]device.EnergyZone) + for _, z := range zones { + groups[z.Name()] = append(groups[z.Name()], z) + } + result := make([]device.EnergyZone, 0, len(zones)) + + for name, grp := range groups { + if len(grp) == 1 { + // Single zone: add as-is (matches RAPL behavior for unique names like package-0, package-1) + result = append(result, grp[0]) + continue + } + + // Multiple zones with same name: create aggregated zone + // This handles cases where multiple sensors share the same name + agg, err := device.NewAggregatedZone(grp) + if err != nil { + pm.logger.Warn("failed to create aggregated zone", + "name", name, "err", err) + continue + } + result = append(result, agg) + } + return result +} + +func zoneNames(zones []device.EnergyZone) []string { + names := make([]string, len(zones)) + for i, z := range zones { + names[i] = fmt.Sprintf("%s-%d", z.Name(), z.Index()) + } + return names +} diff --git a/internal/device/cpu/esmi/sysfs_reader.go b/internal/device/cpu/esmi/sysfs_reader.go new file mode 100644 index 0000000000..a93d19b563 --- /dev/null +++ b/internal/device/cpu/esmi/sysfs_reader.go @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package esmi + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + + device "github.com/sustainable-computing-io/kepler/internal/device" +) + +// ── SysfsReader ─────────────────────────────────────────────────────────────── + +// SysfsReader reads AMD energy zones from the amd_energy platform driver. +// +// The amd_energy driver (CONFIG_AMD_ENERGY, Linux ≥ 5.8) exposes hwmon-style +// attributes under: +// +// /sys/bus/platform/drivers/amd_energy/amd_energy./hwmon/hwmon/ +// energy_input – accumulated energy in µJ (uint64, wraps at max) +// energy_label – human-readable label, e.g. "socket0", "core3" +// +// A fallback via /sys/class/hwmon is tried when the platform driver path +// contains no devices (some distributions symlink the nodes differently). +type SysfsReader struct { + sysfsPath string +} + +// NewSysfsReader returns a SysfsReader rooted at sysfsPath (normally "/sys"). +func NewSysfsReader(sysfsPath string) *SysfsReader { + return &SysfsReader{sysfsPath: sysfsPath} +} + +func (r *SysfsReader) driverRoot() string { + return filepath.Join(r.sysfsPath, "bus", "platform", "drivers", "amd_energy") +} + +// Zones discovers all energy zones exposed by the amd_energy driver. +func (r *SysfsReader) Zones() ([]device.EnergyZone, error) { + driverRoot := r.driverRoot() + + deviceDirs, err := filepath.Glob(filepath.Join(driverRoot, "amd_energy.*")) + if err != nil { + return nil, fmt.Errorf("esmi/sysfs: glob driver root: %w", err) + } + + if len(deviceDirs) == 0 { + return r.zonesFromHwmonClass() + } + + var zones []device.EnergyZone + for _, dev := range deviceDirs { + zs, err := r.zonesFromDevice(dev) + if err != nil { + slog.Default().Warn("esmi/sysfs: skipping device", "path", dev, "err", err) + continue + } + zones = append(zones, zs...) + } + return zones, nil +} + +func (r *SysfsReader) zonesFromDevice(deviceDir string) ([]device.EnergyZone, error) { + hwmonDirs, err := filepath.Glob(filepath.Join(deviceDir, "hwmon", "hwmon*")) + if err != nil || len(hwmonDirs) == 0 { + return nil, fmt.Errorf("esmi/sysfs: no hwmon dir under %s", deviceDir) + } + var zones []device.EnergyZone + socketIdx := 0 + for _, hwmon := range hwmonDirs { + // For driver root path, assume amd_energy (not HSMP) + zs, err := r.zonesFromHwmonDir(hwmon, "amd_energy", &socketIdx) + if err != nil { + return nil, err + } + zones = append(zones, zs...) + } + return zones, nil +} + +func (r *SysfsReader) zonesFromHwmonClass() ([]device.EnergyZone, error) { + hwmonClass := filepath.Join(r.sysfsPath, "class", "hwmon") + + entries, err := os.ReadDir(hwmonClass) + if err != nil { + return nil, fmt.Errorf("esmi/sysfs: cannot read %s: %w", hwmonClass, err) + } + + var zones []device.EnergyZone + // Track socket index across multiple HSMP hwmon devices + socketIdx := 0 + for _, entry := range entries { + hwmonDir := filepath.Join(hwmonClass, entry.Name()) + nameBytes, err := os.ReadFile(filepath.Join(hwmonDir, "name")) + if err != nil { + continue + } + deviceName := strings.TrimSpace(string(nameBytes)) + + // Support both amd_energy (energy counters) and amd_hsmp_hwmon (power readings) + if deviceName != "amd_energy" && deviceName != "amd_hsmp_hwmon" { + continue + } + zs, err := r.zonesFromHwmonDir(hwmonDir, deviceName, &socketIdx) + if err != nil { + slog.Default().Warn("esmi/sysfs: skipping hwmon dir", "path", hwmonDir, "err", err) + continue + } + zones = append(zones, zs...) + } + + return zones, nil +} + +func (r *SysfsReader) zonesFromHwmonDir(hwmonDir, deviceName string, socketIdx *int) ([]device.EnergyZone, error) { + var zones []device.EnergyZone + + // First, try energy counters (amd_energy driver) + energyInputs, err := filepath.Glob(filepath.Join(hwmonDir, "energy*_input")) + if err != nil { + return nil, err + } + for _, inputPath := range energyInputs { + base := filepath.Base(inputPath) // "energy3_input" + trimmed := strings.TrimPrefix(base, "energy") // "3_input" + trimmed = strings.TrimSuffix(trimmed, "_input") // "3" + idx, err := strconv.Atoi(trimmed) + if err != nil { + continue + } + labelPath := filepath.Join(hwmonDir, fmt.Sprintf("energy%d_label", idx)) + label := r.readLabel(labelPath, idx) + + maxPath := filepath.Join(hwmonDir, fmt.Sprintf("energy%d_max", idx)) + maxVal := r.readUint64OrZero(maxPath) + + zones = append(zones, &SysfsEnergyZone{ + name: ZoneLabelToName(label), + index: idx, + path: inputPath, + maxEnergy: device.Energy(maxVal), + }) + } + + // If no energy counters found, try power sensors (amd_hsmp_hwmon driver) + if len(zones) == 0 { + powerInputs, err := filepath.Glob(filepath.Join(hwmonDir, "power*_input")) + if err != nil { + return nil, err + } + for _, inputPath := range powerInputs { + // For HSMP, each hwmon device represents one socket + // Use unique names (package-0, package-1) to match RAPL behavior + zones = append(zones, &SysfsPowerZone{ + name: fmt.Sprintf("package-%d", *socketIdx), + index: *socketIdx, + path: inputPath, + }) + // Increment socket index for next HSMP device + if deviceName == "amd_hsmp_hwmon" { + *socketIdx++ + } + } + } + + return zones, nil +} + +func (r *SysfsReader) readLabel(path string, fallbackIdx int) string { + b, err := os.ReadFile(path) + if err != nil { + return fmt.Sprintf("unknown%d", fallbackIdx) + } + return strings.TrimSpace(string(b)) +} + +func (r *SysfsReader) readUint64OrZero(path string) uint64 { + b, err := os.ReadFile(path) + if err != nil { + return 0 + } + v, _ := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + return v +} + +// ZoneLabelToName strips trailing digits from an AMD hwmon label and +// normalizes zone names to match RAPL naming conventions. +// +// "socket0" → "package" (normalized from "socket" to match RAPL) +// "core23" → "core" +// "l3cache1" → "l3cache" +func ZoneLabelToName(label string) string { + label = strings.ToLower(strings.TrimSpace(label)) + i := len(label) + for i > 0 && label[i-1] >= '0' && label[i-1] <= '9' { + i-- + } + if i == 0 { + return label + } + baseName := label[:i] + + // Normalize AMD zone names to match RAPL conventions + if baseName == "socket" { + return "package" + } + return baseName +} + +// ── SysfsEnergyZone ─────────────────────────────────────────────────────────── + +// SysfsEnergyZone implements device.EnergyZone for a single amd_energy counter. +type SysfsEnergyZone struct { + name string + index int + path string + maxEnergy device.Energy +} + +func (z *SysfsEnergyZone) Name() string { return z.name } +func (z *SysfsEnergyZone) Index() int { return z.index } +func (z *SysfsEnergyZone) Path() string { return z.path } + +// Energy reads the accumulated energy counter in microjoules. +func (z *SysfsEnergyZone) Energy() (device.Energy, error) { + b, err := os.ReadFile(z.path) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: read %s: %w", z.path, err) + } + val, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: parse energy from %s: %w", z.path, err) + } + return device.Energy(val), nil +} + +// MaxEnergy returns the counter rollover value; 0 means unknown. +func (z *SysfsEnergyZone) MaxEnergy() device.Energy { return z.maxEnergy } + +// Power is unsupported; derive from successive Energy() readings. +func (z *SysfsEnergyZone) Power() (device.Power, error) { + return 0, fmt.Errorf("esmi/sysfs: amd_energy zones do not provide instantaneous power; derive from successive Energy() calls") +} + +// ── SysfsPowerZone ──────────────────────────────────────────────────────────── + +// SysfsPowerZone implements device.EnergyZone for AMD HSMP power sensors. +// These zones provide instantaneous power readings but no energy counters. +type SysfsPowerZone struct { + name string + index int + path string +} + +func (z *SysfsPowerZone) Name() string { return z.name } +func (z *SysfsPowerZone) Index() int { return z.index } +func (z *SysfsPowerZone) Path() string { return z.path } + +// Power reads the instantaneous power in microwatts. +func (z *SysfsPowerZone) Power() (device.Power, error) { + b, err := os.ReadFile(z.path) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: read %s: %w", z.path, err) + } + val, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) + if err != nil { + return 0, fmt.Errorf("esmi/sysfs: parse power from %s: %w", z.path, err) + } + return device.Power(val), nil +} + +// MaxEnergy returns 0 since power-only zones have no energy counter. +func (z *SysfsPowerZone) MaxEnergy() device.Energy { return 0 } + +// Energy is unsupported for power-only zones. +func (z *SysfsPowerZone) Energy() (device.Energy, error) { + return 0, fmt.Errorf("esmi/sysfs: amd_hsmp_hwmon zones do not provide energy counters; use Power() for instantaneous power") +} + +// ── backend registration ────────────────────────────────────────────────────── + +// sysfsBackend is called by availableReaders() in backends_*.go. +func sysfsBackend(sysfsPath string) backendCandidate { + return backendCandidate{name: "sysfs", reader: NewSysfsReader(sysfsPath)} +} From 2b8be942811486c4ef69acbc76d9bbbad635d0b3 Mon Sep 17 00:00:00 2001 From: Sivaprasad Tummala Date: Mon, 5 Oct 2026 08:25:09 +0000 Subject: [PATCH 3/3] feat(cpu): add configuration docs and tests for ESMI - Update configuration documentation for experimental.esmi.enabled - Add config examples in compose/dev, compose/default, k8s, and helm - Add comprehensive unit tests for ESMI power meter (16.9% coverage) - Tests cover PowerMeter interface, Init, Zones, and caching behavior Signed-off-by: Sivaprasad Tummala --- .gitignore | 1 + compose/default/kepler/etc/kepler/config.yaml | 2 + compose/dev/kepler-dev/etc/kepler/config.yaml | 2 + docs/user/configuration.md | 24 ++ internal/device/cpu/esmi/power_meter_test.go | 222 ++++++++++++++++++ manifests/helm/kepler/values.yaml | 2 + manifests/k8s/configmap.yaml | 2 + 7 files changed, 255 insertions(+) create mode 100644 internal/device/cpu/esmi/power_meter_test.go diff --git a/.gitignore b/.gitignore index 76f0e7e5a0..b5a896bb7c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ tmp coverage.* *.patch +/metrics diff --git a/compose/default/kepler/etc/kepler/config.yaml b/compose/default/kepler/etc/kepler/config.yaml index 9828d59748..d99d3d64a8 100644 --- a/compose/default/kepler/etc/kepler/config.yaml +++ b/compose/default/kepler/etc/kepler/config.yaml @@ -93,6 +93,8 @@ experimental: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) diff --git a/compose/dev/kepler-dev/etc/kepler/config.yaml b/compose/dev/kepler-dev/etc/kepler/config.yaml index 07d8532d30..ff6ad15c25 100644 --- a/compose/dev/kepler-dev/etc/kepler/config.yaml +++ b/compose/dev/kepler-dev/etc/kepler/config.yaml @@ -93,6 +93,8 @@ experimental: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false # Enable experimental GPU power monitoring idlePower: 0 # GPU idle power in Watts (0 = auto-detect) diff --git a/docs/user/configuration.md b/docs/user/configuration.md index ea78145de7..3ea46bc67e 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -38,6 +38,7 @@ You can configure Kepler by passing flags when starting the service. The followi | `--experimental.platform.redfish.config-file` | Path to experimental Redfish BMC configuration file | `""` | Any valid file path | | `--experimental.hwmon.force-enabled` | Force hwmon as power meter, skipping RAPL auto-detection | `false` | `true`, `false` | | `--experimental.hwmon.zones` | hwmon zones to be enabled (can be specified multiple times) | All available zones | Any valid hwmon zone name | +| `--experimental.esmi.enabled` | Enable experimental AMD ESMI power monitoring for AMD EPYC CPUs | `false` | `true`, `false` | | `--experimental.gpu.enabled` | Enable experimental GPU power monitoring | `false` | `true`, `false` | | `--experimental.gpu.idle-power` | GPU idle power in Watts (0 = auto-detect) | `0` | Any non-negative float | | `--experimental.gpu.dcgm-endpoint` | dcgm-exporter metrics endpoint for MIG power attribution | `""` (auto-discover) | URL (e.g., `http://10.0.0.1:9400/metrics`) | @@ -249,6 +250,7 @@ Built-in backends: - `rapl`: Intel RAPL via sysfs (default first choice) - `hwmon`: hwmon power sensors (default second choice) +- `esmi`: AMD ESMI (E-SMI) for AMD EPYC processors (experimental, opt-in) - `fake`: synthetic readings for development and testing Examples: @@ -444,6 +446,8 @@ experimental: forceEnabled: false zones: [] chipRules: [] + esmi: + enabled: false gpu: enabled: false ``` @@ -546,6 +550,26 @@ experimental: skipVoltages: [0] # Skip shunt voltage at in0 ``` +#### ESMI CPU Power Monitoring (AMD) + +- **enabled**: Enable experimental AMD ESMI power monitoring (default: false) + - When enabled, Kepler will prepend `esmi` to `cpu.preferredMeters`, making it the first-tried CPU power source + - Collects power metrics from AMD EPYC processors via E-SMI library or sysfs + - Provides per-socket energy (package-N), per-thread energy (core-N), and per-socket DIMM power (dimm-N) + - Supports two backends: + - **goamdsmi**: Uses E-SMI C library (`libe_smi64.so`) - requires build with `-tags goamdsmi` + - **sysfs**: Pure Go fallback using `amd_energy` kernel module (default, no dependencies) + +**Example:** + +```yaml +experimental: + esmi: + enabled: true +``` + +When enabled, this configuration automatically prepends `esmi` to the CPU power meter priority list, so on AMD EPYC systems with ESMI support, it becomes the first choice before RAPL or hwmon. + #### GPU Power Monitoring - **enabled**: Enable experimental GPU power monitoring (default: false) diff --git a/internal/device/cpu/esmi/power_meter_test.go b/internal/device/cpu/esmi/power_meter_test.go new file mode 100644 index 0000000000..8a4ec01d7e --- /dev/null +++ b/internal/device/cpu/esmi/power_meter_test.go @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2025 The Kepler Authors +// SPDX-License-Identifier: Apache-2.0 + +package esmi + +import ( + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/sustainable-computing-io/kepler/internal/device" +) + +// MockReader is a mock implementation of Reader for testing +type MockReader struct { + mock.Mock +} + +func (m *MockReader) Zones() ([]device.EnergyZone, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]device.EnergyZone), args.Error(1) +} + +// MockZone is a mock implementation of EnergyZone for testing +type MockZone struct { + mock.Mock + name string + index int +} + +func (m *MockZone) Index() int { + return m.index +} + +func (m *MockZone) Path() string { + args := m.Called() + return args.String(0) +} + +func (m *MockZone) Name() string { + return m.name +} + +func (m *MockZone) MaxEnergy() device.Energy { + args := m.Called() + return args.Get(0).(device.Energy) +} + +func (m *MockZone) Energy() (device.Energy, error) { + args := m.Called() + return args.Get(0).(device.Energy), args.Error(1) +} + +func (m *MockZone) Power() (device.Power, error) { + args := m.Called() + return args.Get(0).(device.Power), args.Error(1) +} + +func TestPowerMeter_Name(t *testing.T) { + mockReader := new(MockReader) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test-backend", + } + + name := meter.Name() + assert.Equal(t, "esmi/test-backend", name) +} + +func TestPowerMeter_Init_Success(t *testing.T) { + mockReader := new(MockReader) + mockZone := &MockZone{name: "package-0", index: 0} + + mockZone.On("Energy").Return(device.Energy(1000*device.Joule), nil) + mockReader.On("Zones").Return([]device.EnergyZone{mockZone}, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.NoError(t, err) + mockReader.AssertExpectations(t) + mockZone.AssertExpectations(t) +} + +func TestPowerMeter_Init_NoZones(t *testing.T) { + mockReader := new(MockReader) + mockReader.On("Zones").Return([]device.EnergyZone{}, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no energy zones found") + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Init_ZonesError(t *testing.T) { + mockReader := new(MockReader) + mockReader.On("Zones").Return(nil, errors.New("zones failed")) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + err := meter.Init() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to enumerate zones") + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Zones(t *testing.T) { + mockReader := new(MockReader) + mockZone1 := &MockZone{name: "package-0", index: 0} + mockZone2 := &MockZone{name: "core-0", index: 0} + + expectedZones := []device.EnergyZone{mockZone1, mockZone2} + mockReader.On("Zones").Return(expectedZones, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + zones, err := meter.Zones() + assert.NoError(t, err) + assert.Len(t, zones, 2) + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_Zones_Cached(t *testing.T) { + mockReader := new(MockReader) + mockZone := &MockZone{name: "package-0", index: 0} + + expectedZones := []device.EnergyZone{mockZone} + mockReader.On("Zones").Return(expectedZones, nil).Once() + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + } + + // First call + zones1, err1 := meter.Zones() + assert.NoError(t, err1) + assert.Equal(t, expectedZones, zones1) + + // Second call should return cached zones + zones2, err2 := meter.Zones() + assert.NoError(t, err2) + assert.Equal(t, expectedZones, zones2) + + // Zones() should only be called once (caching works) + mockReader.AssertExpectations(t) +} + +func TestPowerMeter_PrimaryEnergyZone(t *testing.T) { + mockReader := new(MockReader) + mockZone1 := &MockZone{name: "core-0", index: 0} + mockZone2 := &MockZone{name: "package-0", index: 0} + + // package should be selected as primary over core + zones := []device.EnergyZone{mockZone1, mockZone2} + mockReader.On("Zones").Return(zones, nil) + + meter := &PowerMeter{ + reader: mockReader, + logger: slog.Default(), + backendName: "test", + cachedZones: zones, + topZone: mockZone2, // Pre-set the topZone to avoid probing + } + + primaryZone, err := meter.PrimaryEnergyZone() + assert.NoError(t, err) + assert.Equal(t, "package-0", primaryZone.Name()) +} + +func TestPowerMeter_WithZoneFilter(t *testing.T) { + mockReader := new(MockReader) + + meter, err := NewCPUPowerMeter("/sys", + WithReader(mockReader), + WithZoneFilter([]string{"package", "dimm"}), + ) + + assert.NoError(t, err) + assert.NotNil(t, meter) + assert.Equal(t, []string{"package", "dimm"}, meter.zoneFilter) +} + +func TestPowerMeter_WithLogger(t *testing.T) { + mockReader := new(MockReader) + customLogger := slog.Default().With("test", "value") + + meter, err := NewCPUPowerMeter("/sys", + WithReader(mockReader), + WithLogger(customLogger), + ) + + assert.NoError(t, err) + assert.NotNil(t, meter) + assert.NotNil(t, meter.logger) +} diff --git a/manifests/helm/kepler/values.yaml b/manifests/helm/kepler/values.yaml index c726f90507..ce041023fc 100644 --- a/manifests/helm/kepler/values.yaml +++ b/manifests/helm/kepler/values.yaml @@ -142,6 +142,8 @@ config: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: # Enabling this also triggers the chart's NVML init container and # LD_LIBRARY_PATH injection (daemonset.nvidia.*). Leave false if you diff --git a/manifests/k8s/configmap.yaml b/manifests/k8s/configmap.yaml index 5cc3f1a5f3..ebe67ac9af 100644 --- a/manifests/k8s/configmap.yaml +++ b/manifests/k8s/configmap.yaml @@ -64,6 +64,8 @@ data: forceEnabled: false # DEPRECATED: set cpu.preferredMeters: ["hwmon"] instead. Emits a deprecation warning when true. zones: [] # List of zones to enable (default enable all) chipRules: [] # User-defined chip pairing rules (override/add to hardcoded defaults) + esmi: + enabled: false # Enable experimental AMD ESMI power monitoring (prepends "esmi" to cpu.preferredMeters) gpu: enabled: false idlePower: 0 # GPU idle power in Watts (0 = auto-detect)