From 63350ba009b0d9969ed319fa29b908fd53c09cb5 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 13 Nov 2025 19:27:19 +0100 Subject: [PATCH 1/7] fix: do not attempt to get cpu share if no pids are provided --- .../sustainability/power/sensors/cpu/PSExtractionStrategy.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java index 71718c15..0011ac72 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java @@ -15,6 +15,9 @@ public class PSExtractionStrategy implements ExtractionStrategy { @Override public Map cpuSharesFor(Set pids) { + if (pids.isEmpty()) { + return Collections.emptyMap(); + } final var cpuShares = new HashMap(pids.size()); final var pidList = String.join(",", pids); final var cmd = new String[] { "ps", "-p", pidList, "-o", "pid=,pcpu=" }; From 1bf034f5bd4ee9dd003238f4724e5f8d3ec2f54b Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 13 Nov 2025 19:29:03 +0100 Subject: [PATCH 2/7] feat: divide cpu percent by full cpu "potential" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not sure this is the appropriate calculation to do here, especially for mono-threaded processes… --- .../sensors/cpu/PSExtractionStrategy.java | 20 ++-- .../sensors/cpu/PSExtractionStrategyTest.java | 102 +++++++++--------- 2 files changed, 62 insertions(+), 60 deletions(-) diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java index 0011ac72..deecfe21 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java @@ -1,6 +1,7 @@ package net.laprun.sustainability.power.sensors.cpu; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -8,10 +9,11 @@ import com.zaxxer.nuprocess.NuProcessBuilder; import io.quarkus.logging.Log; +import io.vertx.core.impl.cpu.CpuCoreSensor; import net.laprun.sustainability.power.nuprocess.BaseProcessHandler; public class PSExtractionStrategy implements ExtractionStrategy { - public static final ExtractionStrategy INSTANCE = new PSExtractionStrategy(); + public static final PSExtractionStrategy INSTANCE = new PSExtractionStrategy(); @Override public Map cpuSharesFor(Set pids) { @@ -36,11 +38,11 @@ public void onStdout(ByteBuffer buffer, boolean closed) { return cpuShares; } - static void extractCPUSharesInto(byte[] bytes, Map cpuShares) { + void extractCPUSharesInto(byte[] bytes, Map cpuShares) { extractCPUSharesInto(new String(bytes), cpuShares); } - static void extractCPUSharesInto(String input, Map cpuShares) { + void extractCPUSharesInto(String input, Map cpuShares) { final var lines = input.split("\n"); for (var line : lines) { if (line.isBlank()) { @@ -50,7 +52,7 @@ static void extractCPUSharesInto(String input, Map cpuShares) { } } - private static void extractCPUShare(String line, Map cpuShares) { + private void extractCPUShare(String line, Map cpuShares) { try { line = line.trim(); int spaceIndex = line.indexOf(' '); @@ -66,8 +68,8 @@ private static void extractCPUShare(String line, Map cpuShares) var pid = line.substring(0, spaceIndex).trim(); var cpuPercentage = line.substring(spaceIndex + 1).trim(); - Log.info(pid + " / " + cpuPercentage); - final var value = Double.parseDouble(cpuPercentage); + Log.infof("pid: %s / cpu: %s%%", pid, cpuPercentage); + final var value = Double.parseDouble(cpuPercentage) / fullCPU(); if (value < 0) { Log.warnf("Invalid CPU share percentage: %s", cpuPercentage); return; @@ -80,4 +82,10 @@ private static void extractCPUShare(String line, Map cpuShares) Log.warnf("Failed to parse CPU percentage for line: '%s', cause: %s", line, e); } } + + int fullCPU() { + final var fullCPU = CpuCoreSensor.availableProcessors() * 100; + Log.infof("'potential' full CPU: %d%%", fullCPU); + return fullCPU; // each core contributes 100% + } } diff --git a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java index cc78b1a1..5a2096ef 100644 --- a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java +++ b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java @@ -8,16 +8,23 @@ import org.junit.jupiter.api.Test; class PSExtractionStrategyTest { + private static final int fullCPU = PSExtractionStrategy.INSTANCE.fullCPU(); @Test void extractCPUSharesInto_shouldParseValidSingleLine() { String input = "12345 25.5"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); + checkCPUShare(cpuShares, "12345", 25.5); + } + + private static void checkCPUShare(Map cpuShares, String pid, double cpuPercentageFromPS) { + final var actual = cpuShares.get(pid); + assertThat(actual).isLessThan(1.0); + assertThat(actual).isEqualTo(cpuPercentageFromPS / fullCPU); } @Test @@ -25,12 +32,12 @@ void extractCPUSharesInto_shouldParseMultipleLines() { String input = "12345 25.5\n67890 15.3\n11111 5.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(3); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); - assertThat(cpuShares.get("11111")).isEqualTo(5.0); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); + checkCPUShare(cpuShares, "11111", 5.0); } @Test @@ -38,11 +45,11 @@ void extractCPUSharesInto_shouldHandleExtraWhitespace() { String input = " 12345 25.5 \n 67890 15.3 "; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); } @Test @@ -50,12 +57,12 @@ void extractCPUSharesInto_shouldSkipEmptyLines() { String input = "12345 25.5\n\n67890 15.3\n \n11111 5.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(3); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); - assertThat(cpuShares.get("11111")).isEqualTo(5.0); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); + checkCPUShare(cpuShares, "11111", 5.0); } @Test @@ -63,10 +70,10 @@ void extractCPUSharesInto_shouldHandleZeroCpuPercentage() { String input = "12345 0.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - assertThat(cpuShares.get("12345")).isEqualTo(0.0); + checkCPUShare(cpuShares, "12345", 0.0); } @Test @@ -74,10 +81,10 @@ void extractCPUSharesInto_shouldHandleHighCpuPercentage() { String input = "12345 354.287"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - assertThat(cpuShares.get("12345")).isEqualTo(354.287); + checkCPUShare(cpuShares, "12345", 354.287); } @Test @@ -85,12 +92,12 @@ void extractCPUSharesInto_shouldIgnoreInvalidLineSpace() { String input = "12345 25.5\n invalidline \n67890 15.3"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); // Only valid lines should be parsed assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); } @Test @@ -98,12 +105,12 @@ void extractCPUSharesInto_shouldIgnoreInvalidNumberFormat() { String input = "12345 25.5\n67890 notanumber\n11111 5.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); // Only valid lines should be parsed assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("11111")).isEqualTo(5.0); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "11111", 5.0); assertThat(cpuShares).doesNotContainKey("67890"); } @@ -112,7 +119,7 @@ void extractCPUSharesInto_shouldHandleEmptyInput() { String input = ""; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).isEmpty(); } @@ -122,7 +129,7 @@ void extractCPUSharesInto_shouldHandleOnlyWhitespace() { String input = " \n \n "; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).isEmpty(); } @@ -132,10 +139,10 @@ void extractCPUSharesInto_shouldOnlyKeepLatestValueOnDuplicatePids() { String input = "12345 25.5\n12345 30.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - assertThat(cpuShares.get("12345")).isEqualTo(30.0); // only newest value is kept + checkCPUShare(cpuShares, "12345", 30.0); } @Test @@ -143,11 +150,11 @@ void extractCPUSharesInto_shouldHandleMultipleSpacesBetweenValues() { String input = "12345 25.5\n67890 15.3"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); } @Test @@ -155,11 +162,11 @@ void extractCPUSharesInto_shouldHandleTabCharacters() { String input = "12345\t25.5\n67890\t15.3"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - assertThat(cpuShares.get("67890")).isEqualTo(15.3); + checkCPUShare(cpuShares, "12345", 25.5); + checkCPUShare(cpuShares, "67890", 15.3); } @Test @@ -168,12 +175,12 @@ void extractCPUSharesInto_shouldHandleRealPsOutput() { String input = " 1234 12.5\n 5678 3.2\n 91011 75.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(3); - assertThat(cpuShares.get("1234")).isEqualTo(12.5); - assertThat(cpuShares.get("5678")).isEqualTo(3.2); - assertThat(cpuShares.get("91011")).isEqualTo(75.0); + checkCPUShare(cpuShares, "1234", 12.5); + checkCPUShare(cpuShares, "5678", 3.2); + checkCPUShare(cpuShares, "91011", 75.0); } @Test @@ -181,33 +188,20 @@ void extractCPUSharesInto_shouldIgnorNegativeCPUShares() { String input = "12345 -5.0"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).isEmpty(); } - @Test - void extractCPUSharesInto_shouldAddToExistingMap() { - Map cpuShares = new HashMap<>(); - cpuShares.put("99999", 50.0); - - String input = "12345 25.5"; - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); - - assertThat(cpuShares).hasSize(2); - assertThat(cpuShares.get("99999")).isEqualTo(50.0); - assertThat(cpuShares.get("12345")).isEqualTo(25.5); - } - @Test void extractCPUSharesInto_shouldHandleScientificNotation() { String input = "12345 1.5e2"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - assertThat(cpuShares.get("12345")).isEqualTo(150.0); + checkCPUShare(cpuShares, "12345", 150.0); } @Test @@ -215,7 +209,7 @@ void extractCPUSharesInto_shouldHandleLineWithOnlyPid() { String input = "12345"; Map cpuShares = new HashMap<>(); - PSExtractionStrategy.extractCPUSharesInto(input, cpuShares); + PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); // Line without space should be ignored assertThat(cpuShares).isEmpty(); From b583d90d737c4d936bc40f126f1a269b3a7dd5e5 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 13 Nov 2025 19:35:53 +0100 Subject: [PATCH 3/7] feat: add PIDRegistry to record tracked pids & provide them as String --- .../power/sensors/MapMeasures.java | 8 +++++++ .../power/sensors/Measures.java | 2 ++ .../power/sensors/PIDRegistry.java | 24 +++++++++++++++++++ .../linux/rapl/SingleMeasureMeasures.java | 12 ++++++---- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/net/laprun/sustainability/power/sensors/PIDRegistry.java diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/MapMeasures.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/MapMeasures.java index 6009a050..f110833b 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/MapMeasures.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/MapMeasures.java @@ -10,17 +10,20 @@ public class MapMeasures implements Measures { private final ConcurrentMap measures = new ConcurrentHashMap<>(); private long lastMeasuredUpdateStartEpoch; + private final PIDRegistry registry = new PIDRegistry(); @Override public RegisteredPID register(long pid) { final var key = RegisteredPID.create(pid); measures.put(key, SensorMeasure.missing); + registry.register(key); return key; } @Override public void unregister(RegisteredPID registeredPID) { measures.remove(registeredPID); + registry.unregister(registeredPID); } @Override @@ -28,6 +31,11 @@ public Set trackedPIDs() { return measures.keySet(); } + @Override + public Set trackedPIDsAsString() { + return registry.pids(); + } + @Override public int numberOfTrackedPIDs() { return measures.size(); diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/Measures.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/Measures.java index 9a9edcca..25896497 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/Measures.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/Measures.java @@ -35,6 +35,8 @@ public interface Measures { */ Set trackedPIDs(); + Set trackedPIDsAsString(); + /** * Retrieves the number of tracked processes * diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/PIDRegistry.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/PIDRegistry.java new file mode 100644 index 00000000..f5675133 --- /dev/null +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/PIDRegistry.java @@ -0,0 +1,24 @@ +package net.laprun.sustainability.power.sensors; + +import java.util.HashSet; +import java.util.Set; + +public class PIDRegistry { + private final Set pids = new HashSet<>(); + + public void register(RegisteredPID registeredPID) { + if (!RegisteredPID.SYSTEM_TOTAL_REGISTERED_PID.equals(registeredPID)) { + pids.add(registeredPID.pidAsString()); + } + } + + public void unregister(RegisteredPID registeredPID) { + if (!RegisteredPID.SYSTEM_TOTAL_REGISTERED_PID.equals(registeredPID)) { + pids.remove(registeredPID.pidAsString()); + } + } + + public Set pids() { + return pids; + } +} diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/SingleMeasureMeasures.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/SingleMeasureMeasures.java index 723b0eea..ad81627a 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/SingleMeasureMeasures.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/SingleMeasureMeasures.java @@ -6,12 +6,13 @@ import net.laprun.sustainability.power.SensorMeasure; import net.laprun.sustainability.power.sensors.Measures; +import net.laprun.sustainability.power.sensors.PIDRegistry; import net.laprun.sustainability.power.sensors.RegisteredPID; class SingleMeasureMeasures implements Measures { private final Set trackedPIDs = new HashSet<>(); - private final Set pids = new HashSet<>(); private SensorMeasure measure; + private final PIDRegistry registry = new PIDRegistry(); void singleMeasure(SensorMeasure sensorMeasure) { this.measure = sensorMeasure; @@ -21,14 +22,14 @@ void singleMeasure(SensorMeasure sensorMeasure) { public RegisteredPID register(long pid) { final var registeredPID = RegisteredPID.create(pid); trackedPIDs.add(registeredPID); - pids.add(registeredPID.pidAsString()); + registry.register(registeredPID); return registeredPID; } @Override public void unregister(RegisteredPID registeredPID) { trackedPIDs.remove(registeredPID); - pids.remove(registeredPID.pidAsString()); + registry.unregister(registeredPID); } @Override @@ -36,8 +37,9 @@ public Set trackedPIDs() { return trackedPIDs; } - public Set pids() { - return pids; + @Override + public Set trackedPIDsAsString() { + return registry.pids(); } @Override From 5ab8bec975566e4a7cda500f144fde4f5454e7d4 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 13 Nov 2025 19:39:24 +0100 Subject: [PATCH 4/7] feat: enable external cpu share recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently done using PSExtractionStrategy, i.e. using ps' output, which isn't super accurate… --- .../power/sensors/AbstractPowerSensor.java | 35 ++++++++++++-- .../power/sensors/PowerSensor.java | 10 +++- .../power/sensors/SamplingMeasurer.java | 47 +++++++++++++++++-- .../sensors/linux/rapl/IntelRAPLSensor.java | 5 +- .../powermetrics/MacOSPowermetricsSensor.java | 20 +++++--- .../ResourceMacOSPowermetricsSensor.java | 6 ++- .../power/sensors/test/TestPowerSensor.java | 5 +- .../linux/rapl/IntelRAPLSensorTest.java | 2 +- .../MacOSPowermetricsSensorTest.java | 10 ++-- 9 files changed, 116 insertions(+), 24 deletions(-) diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java index 759e94eb..dfc1c3e5 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java @@ -1,16 +1,40 @@ package net.laprun.sustainability.power.sensors; +import java.util.Map; +import java.util.Set; + import io.quarkus.logging.Log; +import net.laprun.sustainability.power.SensorMetadata; +import net.laprun.sustainability.power.SensorUnit; public abstract class AbstractPowerSensor implements PowerSensor { protected final M measures; private long lastUpdateEpoch; private boolean started; + protected boolean cpuSharesEnabled = true; + private SensorMetadata metadata; public AbstractPowerSensor(M measures) { this.measures = measures; } + @Override + public SensorMetadata metadata() { + if (metadata == null) { + metadata = nativeMetadata(); + if (cpuSharesEnabled) { + metadata = SensorMetadata.from(metadata) + .withNewComponent(EXTERNAL_CPU_SHARE_COMPONENT_NAME, + "CPU share estimate based on currently configured strategy used in CPUShare", false, + SensorUnit.decimalPercentage) + .build(); + } + } + return metadata; + } + + abstract protected SensorMetadata nativeMetadata(); + @Override public RegisteredPID register(long pid) { Log.debugf("Registered pid: %d", pid); @@ -43,12 +67,17 @@ public void stop() { started = false; } + @Override + public Set getRegisteredPIDs() { + return measures.trackedPIDsAsString(); + } + protected abstract void doStart(long samplingFrequencyInMillis); @Override - public Measures update(Long tick) { + public Measures update(Long tick, Map cpuShares) { final long newUpdateStartEpoch = System.currentTimeMillis(); - final var measures = doUpdate(lastUpdateEpoch, newUpdateStartEpoch); + final var measures = doUpdate(lastUpdateEpoch, newUpdateStartEpoch, cpuShares); lastUpdateEpoch = measures.lastMeasuredUpdateEndEpoch() > 0 ? measures.lastMeasuredUpdateEndEpoch() : newUpdateStartEpoch; return measures; @@ -58,5 +87,5 @@ protected long lastUpdateEpoch() { return lastUpdateEpoch; } - abstract protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch); + abstract protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch, Map cpuShares); } diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/PowerSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/PowerSensor.java index bf4f715a..cc8bec58 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/PowerSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/PowerSensor.java @@ -1,11 +1,16 @@ package net.laprun.sustainability.power.sensors; +import java.util.Map; +import java.util.Set; + import net.laprun.sustainability.power.SensorMetadata; /** * A representation of a power-consumption sensor. */ public interface PowerSensor { + String EXTERNAL_CPU_SHARE_COMPONENT_NAME = "externalCpuShare"; + Double MISSING_CPU_SHARE = -1.0; /** * Whether the sensor supports process attribution of power, i.e. is measured power imputed to each process or does @@ -61,9 +66,10 @@ default void stop() { * * @param tick an ordinal value tracking the number of recorded measures being taken by the sensor since it started * measuring power consumption + * @param cpuShares externally provided (if available) cpu attribution for each process id * @return the {@link Measures} object recording the measures this sensor has taken since it started measuring */ - Measures update(Long tick); + Measures update(Long tick, Map cpuShares); /** * Unregisters the specified {@link RegisteredPID} with this sensor thus signaling that clients are not interested in @@ -73,4 +79,6 @@ default void stop() { * registered with this sensor */ void unregister(RegisteredPID registeredPID); + + Set getRegisteredPIDs(); } diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/SamplingMeasurer.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/SamplingMeasurer.java index ce5266b7..2f2ba0d4 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/SamplingMeasurer.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/SamplingMeasurer.java @@ -1,6 +1,10 @@ package net.laprun.sustainability.power.sensors; import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -12,10 +16,12 @@ import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.infrastructure.Infrastructure; import io.smallrye.mutiny.subscription.Cancellable; +import io.smallrye.mutiny.tuples.Tuple2; import net.laprun.sustainability.power.ProcessUtils; import net.laprun.sustainability.power.SensorMeasure; import net.laprun.sustainability.power.SensorMetadata; import net.laprun.sustainability.power.persistence.Persistence; +import net.laprun.sustainability.power.sensors.cpu.CPUShare; @ApplicationScoped public class SamplingMeasurer { @@ -58,9 +64,40 @@ RegisteredPID track(long pid) throws Exception { if (!sensor.isStarted()) { sensor.start(samplingPeriod.toMillis()); - periodicSensorCheck = Multi.createFrom().ticks() - .every(samplingPeriod) - .map(sensor::update) + + final var overSamplingFactor = 3; + final var cpuSharesMulti = Multi.createFrom().ticks() + // over sample but over a shorter period to ensure we have an average that covers most of the sampling period + .every(samplingPeriod.minus(50, ChronoUnit.MILLIS).dividedBy(overSamplingFactor)) + .runSubscriptionOn(Infrastructure.getDefaultExecutor()) + .map(tick -> CPUShare.cpuSharesFor(sensor.getRegisteredPIDs())) + .group() + .intoLists() + .of(overSamplingFactor) + .map(cpuShares -> { + // first convert list of mappings pid -> cpu to mapping pid -> list of cpu shares + Map> pidToRecordedCPUShares = new HashMap<>(); + cpuShares.forEach(cpuShare -> cpuShare.forEach( + (p, cpu) -> { + if (cpu != null && cpu > 0) { // drop null values to avoid skewing average even more + pidToRecordedCPUShares.computeIfAbsent(p, unused -> new ArrayList<>()).add(cpu); + } + })); + // then reduce each cpu shares list to their average + Map averages = new HashMap<>(pidToRecordedCPUShares.size()); + pidToRecordedCPUShares.forEach((p, values) -> averages.put(p, + values.stream().mapToDouble(Double::doubleValue).average().orElse(0))); + return averages; + }); + + final var samplingTicks = Multi.createFrom().ticks() + .every(samplingPeriod); + periodicSensorCheck = Multi.createBy() + .combining() + .streams(samplingTicks, cpuSharesMulti) + .asTuple() + .log() + .map(this::updateSensor) .broadcast() .withCancellationAfterLastSubscriberDeparture() .toAtLeast(1) @@ -71,6 +108,10 @@ RegisteredPID track(long pid) throws Exception { return registeredPID; } + private Measures updateSensor(Tuple2> tuple) { + return sensor.update(tuple.getItem1(), tuple.getItem2()); + } + /** * Starts measuring even in the absence of registered PID. This will record the system's total energy consumption. * diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensor.java index b1b0b53b..1e8c29d6 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensor.java @@ -30,6 +30,7 @@ public IntelRAPLSensor() { protected IntelRAPLSensor(String... raplFilePaths) { this(fromPaths(raplFilePaths)); + cpuSharesEnabled = false; } private static SortedMap fromPaths(String... raplFilePaths) { @@ -126,12 +127,12 @@ static double computePowerInMilliWatts(long newMicroJoules, long prevMicroJoules } @Override - public SensorMetadata metadata() { + protected SensorMetadata nativeMetadata() { return metadata; } @Override - protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch) { + protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch, Map cpuShares) { final var measure = new double[raplFiles.length]; readAndRecordSensor((value, index) -> measure[index] = computePowerInMilliWatts(index, value, newUpdateStartEpoch), newUpdateStartEpoch); diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensor.java index 156642d5..0bb23728 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensor.java @@ -105,7 +105,7 @@ void initMetadata(InputStream inputStream) { } @Override - public SensorMetadata metadata() { + protected SensorMetadata nativeMetadata() { return cpu.metadata(); } @@ -118,7 +118,8 @@ private static class Section { boolean done; } - Measures extractPowerMeasure(InputStream powerMeasureInput, long lastUpdateEpoch, long newUpdateEpoch) { + Measures extractPowerMeasure(InputStream powerMeasureInput, long lastUpdateEpoch, long newUpdateEpoch, + Map cpuShares) { final long start = lastUpdateEpoch; try { // Should not be closed since it closes the process @@ -133,7 +134,7 @@ Measures extractPowerMeasure(InputStream powerMeasureInput, long lastUpdateEpoch pidsToProcess.remove(RegisteredPID.SYSTEM_TOTAL_REGISTERED_PID); // start measure final var pidMeasures = new HashMap(measures.numberOfTrackedPIDs()); - final var metadata = cpu.metadata(); + final var metadata = metadata(); final var powerComponents = new HashMap(metadata.componentCardinality()); var endUpdateEpoch = -1L; Section processes = null; @@ -208,6 +209,12 @@ Measures extractPowerMeasure(InputStream powerMeasureInput, long lastUpdateEpoch double finalTotalSampledCPU = totalSampledCPU; final var endMs = endUpdateEpoch != -1 ? endUpdateEpoch : newUpdateEpoch; + // handle external cpu share if enabled + if (cpuShares != null && !cpuShares.isEmpty()) { + measures.trackedPIDsAsString().forEach(name -> powerComponents.put(EXTERNAL_CPU_SHARE_COMPONENT_NAME, + cpuShares.getOrDefault(name, MISSING_CPU_SHARE))); + } + // handle total system measure separately measures.record(RegisteredPID.SYSTEM_TOTAL_REGISTERED_PID, new SensorMeasure(getSystemTotalMeasure(metadata, powerComponents), start, endMs)); @@ -227,7 +234,8 @@ private static double[] getSystemTotalMeasure(SensorMetadata metadata, Map { final var index = cm.index(); - final var value = CPU_SHARE.equals(name) ? 1.0 : powerComponents.getOrDefault(name, 0).doubleValue(); + final var value = CPU_SHARE.equals(name) || EXTERNAL_CPU_SHARE_COMPONENT_NAME.equals(name) ? 1.0 + : powerComponents.getOrDefault(name, 0).doubleValue(); measure[index] = value; }); @@ -235,8 +243,8 @@ private static double[] getSystemTotalMeasure(SensorMetadata metadata, Map cpuShares) { + return extractPowerMeasure(getInputStream(), lastUpdateEpoch, newUpdateStartEpoch, cpuShares); } protected abstract InputStream getInputStream(); diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/ResourceMacOSPowermetricsSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/ResourceMacOSPowermetricsSensor.java index fa4f2a9f..ee682007 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/ResourceMacOSPowermetricsSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/macos/powermetrics/ResourceMacOSPowermetricsSensor.java @@ -1,6 +1,7 @@ package net.laprun.sustainability.power.sensors.macos.powermetrics; import java.io.InputStream; +import java.util.Map; import net.laprun.sustainability.power.sensors.Measures; @@ -13,15 +14,16 @@ public ResourceMacOSPowermetricsSensor(String resourceName) { } ResourceMacOSPowermetricsSensor(String resourceName, long expectedStartUpdateEpoch) { + cpuSharesEnabled = false; this.resourceName = resourceName; this.start = expectedStartUpdateEpoch; initMetadata(getInputStream()); } @Override - protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch) { + protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch, Map cpuShares) { // use the expected start measured time (if provided) for the measure instead of using the provided current epoch - return super.doUpdate(start != -1 ? start : lastUpdateEpoch, newUpdateStartEpoch); + return super.doUpdate(start != -1 ? start : lastUpdateEpoch, newUpdateStartEpoch, cpuShares); } @Override diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/test/TestPowerSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/test/TestPowerSensor.java index a2296f35..6ce64d71 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/test/TestPowerSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/test/TestPowerSensor.java @@ -3,6 +3,7 @@ import static net.laprun.sustainability.power.SensorUnit.mW; import java.util.List; +import java.util.Map; import net.laprun.sustainability.power.SensorMeasure; import net.laprun.sustainability.power.SensorMetadata; @@ -28,7 +29,7 @@ public TestPowerSensor(SensorMetadata metadata) { } @Override - public SensorMetadata metadata() { + protected SensorMetadata nativeMetadata() { return metadata; } @@ -38,7 +39,7 @@ public void doStart(long samplingFrequencyInMillis) { } @Override - protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch) { + protected Measures doUpdate(long lastUpdateEpoch, long newUpdateStartEpoch, Map cpuShares) { measures.trackedPIDs().forEach(pid -> measures.record(pid, new SensorMeasure(new double[] { Math.random() }, lastUpdateEpoch, newUpdateStartEpoch))); return measures; diff --git a/backend/src/test/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensorTest.java b/backend/src/test/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensorTest.java index aa713797..b7692a35 100644 --- a/backend/src/test/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensorTest.java +++ b/backend/src/test/java/net/laprun/sustainability/power/sensors/linux/rapl/IntelRAPLSensorTest.java @@ -102,7 +102,7 @@ void wattComputationShouldWork() throws Exception { final var sensor = new TestIntelRAPLSensor(new TreeMap<>(Map.of("sensor", raplFile))); sensor.start(500); final var pid = sensor.register(1234L); - final var measures = sensor.update(1L); + final var measures = sensor.update(1L, Map.of()); final var components = measures.getOrDefault(pid).components(); assertEquals(1, components.length); assertEquals(2, raplFile.callCount()); diff --git a/backend/src/test/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensorTest.java b/backend/src/test/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensorTest.java index 0279da29..9affdda4 100644 --- a/backend/src/test/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensorTest.java +++ b/backend/src/test/java/net/laprun/sustainability/power/sensors/macos/powermetrics/MacOSPowermetricsSensorTest.java @@ -2,6 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Map; + import org.junit.jupiter.api.Test; import net.laprun.sustainability.power.SensorMetadata; @@ -65,7 +67,7 @@ void extractPowerMeasureForM4() { final var cpu = metadata.metadataFor(MacOSPowermetricsSensor.CPU); // re-open the stream to read the measure this time - final var measure = sensor.update(0L); + final var measure = sensor.update(0L, Map.of()); final var totalCPUPower = 420; final var totalCPUTime = 1287.34; @@ -83,7 +85,7 @@ void checkTotalPowerMeasureEvenWhenRegisteredProcessIsNotFound() { sensor.register(-666); // re-open the stream to read the measure this time - final var measure = sensor.update(0L); + final var measure = sensor.update(0L, Map.of()); assertEquals(0, getTotalSystemComponent(measure, metadata, MacOSPowermetricsSensor.ANE)); assertEquals(19, getTotalSystemComponent(measure, metadata, MacOSPowermetricsSensor.DRAM)); @@ -109,7 +111,7 @@ void extractionShouldWorkForLowProcessIds() { final var cpu = metadata.metadataFor(MacOSPowermetricsSensor.CPU); // re-open the stream to read the measure this time - final var measure = sensor.update(0L); + final var measure = sensor.update(0L, Map.of()); // Process CPU power should be equal to sample ms/s divided for process (here: 116.64) by total samples (1222.65) times total CPU power var pidCPUShare = 116.64 / 1222.65; assertEquals(pidCPUShare * 211, getComponent(measure, pid0, cpu)); @@ -127,7 +129,7 @@ private static void checkPowerMeasure(String testFileName, float total, String t final var pid2 = sensor.register(391); // re-open the stream to read the measure this time - final var measure = sensor.update(0L); + final var measure = sensor.update(0L, Map.of()); final var totalMeasureMetadata = metadata.metadataFor(totalMeasureName); final var pid1CPUShare = 23.88 / 1222.65; assertEquals((pid1CPUShare * total), getComponent(measure, pid1, totalMeasureMetadata)); From d246e76668ddb8adc28cbe70328f9937f48d890f Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 13 Nov 2025 20:53:30 +0100 Subject: [PATCH 5/7] fix: make test work in more constrained envs --- .../power/sensors/cpu/PSExtractionStrategyTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java index 5a2096ef..957a7f72 100644 --- a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java +++ b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.Map; +import java.util.Random; +import java.util.random.RandomGenerator; import org.junit.jupiter.api.Test; @@ -78,13 +80,14 @@ void extractCPUSharesInto_shouldHandleZeroCpuPercentage() { @Test void extractCPUSharesInto_shouldHandleHighCpuPercentage() { - String input = "12345 354.287"; + double cpuPercentage = fullCPU < 200 ? 100.0 : 125.287; + String input = "12345 " + cpuPercentage; Map cpuShares = new HashMap<>(); PSExtractionStrategy.INSTANCE.extractCPUSharesInto(input, cpuShares); assertThat(cpuShares).hasSize(1); - checkCPUShare(cpuShares, "12345", 354.287); + checkCPUShare(cpuShares, "12345", cpuPercentage); } @Test From a756a8a5a0b7d20d5289288558ca877afb515ced Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Fri, 14 Nov 2025 10:25:06 +0100 Subject: [PATCH 6/7] fix: failure to extract cpu share should return empty map --- .../power/sensors/cpu/PSExtractionStrategy.java | 8 ++++++++ .../power/sensors/cpu/PSExtractionStrategyTest.java | 9 +++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java index deecfe21..4b2c9f6d 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategy.java @@ -33,6 +33,14 @@ public void onStdout(ByteBuffer buffer, boolean closed) { extractCPUSharesInto(bytes, cpuShares); } } + + @Override + public void onExit(int statusCode) { + if (statusCode != 0) { + Log.warnf("Failed to extract CPU shares for pids: %s", pids); + } + cpuShares.clear(); + } }; new NuProcessBuilder(psHandler, psHandler.command()).run(); return cpuShares; diff --git a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java index 957a7f72..34a4df26 100644 --- a/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java +++ b/backend/src/test/java/net/laprun/sustainability/power/sensors/cpu/PSExtractionStrategyTest.java @@ -4,14 +4,19 @@ import java.util.HashMap; import java.util.Map; -import java.util.Random; -import java.util.random.RandomGenerator; +import java.util.Set; import org.junit.jupiter.api.Test; class PSExtractionStrategyTest { private static final int fullCPU = PSExtractionStrategy.INSTANCE.fullCPU(); + @Test + void failureToExtractCPUSharesShouldReturnEmptyMap() { + final var cpuShares = PSExtractionStrategy.INSTANCE.cpuSharesFor(Set.of("-1")); + assertThat(cpuShares).isEmpty(); + } + @Test void extractCPUSharesInto_shouldParseValidSingleLine() { String input = "12345 25.5"; From 1f632a62b6fc694f81c0255ac1e3c634f241511f Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Fri, 14 Nov 2025 11:52:47 +0100 Subject: [PATCH 7/7] fix: disable external CPU utilization sampling by default --- .../sustainability/power/sensors/AbstractPowerSensor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java b/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java index dfc1c3e5..d79c5182 100644 --- a/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java +++ b/backend/src/main/java/net/laprun/sustainability/power/sensors/AbstractPowerSensor.java @@ -11,7 +11,7 @@ public abstract class AbstractPowerSensor implements PowerSe protected final M measures; private long lastUpdateEpoch; private boolean started; - protected boolean cpuSharesEnabled = true; + protected boolean cpuSharesEnabled = false; private SensorMetadata metadata; public AbstractPowerSensor(M measures) {