User1342 commited on
Commit
26a7c18
·
1 Parent(s): 32021fd

Measure GPU and CPU energy from hardware counters and sum them; drop the modelled fallback

Browse files
distinct_agent/energy.py CHANGED
@@ -1,46 +1,59 @@
1
- """Honest energy accounting for local inference windows.
2
 
3
- Every backend reports its measurement scope. RAPL is CPU-package energy, not
4
- whole-computer electricity.
 
 
5
 
6
- The backend ladder runs measured-first, modelled-last, and the two never mix:
7
 
8
- 1. hardware counters report **measured** joules on a hardware scope: Zeus
9
- where it is installed and finds a device (the only backend here that can
10
- see a GPU), the Energy Meter Interface or the Energy Meter performance
11
- counter on Windows, and pyRAPL or direct ``/sys/class/powercap`` RAPL on
12
- Linux;
13
- 2. when no counter exists, :class:`CpuLoadModelMeter` reports **modelled**
14
- joules from observed busy-CPU seconds and a stated per-core power figure,
15
- on its own ``system-cpu-modelled`` scope.
16
 
17
- WSL2, and why an agent should not run there
18
- -------------------------------------------
 
 
 
 
 
 
 
 
 
 
19
 
20
- This came up as a plausible simplification: run the agent under WSL2, get the
21
- Linux RAPL reader and the executed bubblewrap sandbox, and drop the Windows
22
- code entirely. **It does not work, and it would replace a working measurement
23
- with an absence.** Two independent reasons, either sufficient:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  * The Microsoft WSL2 kernel is built with ``CONFIG_POWERCAP`` unset on the
26
- current rolling branch, so ``/sys/class/powercap`` does not exist to read.
27
- * Even with a rebuilt kernel, Hyper-V does not pass the RAPL energy MSRs
28
- through to the utility VM. ``intel_rapl_msr`` matches the CPU (the host's
29
- CPUID is visible), attempts ``MSR_RAPL_POWER_UNIT``, fails, and registers no
30
- zones. The perf path is gated by the same read. Being root inside WSL2 buys
31
- nothing, which is the reverse of bare-metal Linux.
32
-
33
- NVML GPU power does work under WSL2, so a GPU worker there would measure its
34
- GPU and nothing else. For a CPU worker the answer is simply that the energy
35
- figure disappears, which on a project whose claim is measurement is the worst
36
- available trade. The Windows path stays.
37
-
38
- A modelled figure is a real number about a real run, and it is labelled as a
39
- model everywhere it appears. Because it carries its own scope, the session
40
- roll-up in the UI will never sum it with a measured figure: different scopes
41
- are different measurement boundaries and stay in separate lines. What this
42
- module still never does is report a modelled value *as* a measurement, or
43
- report zero for a window it knows nothing about.
44
  """
45
 
46
  from __future__ import annotations
@@ -68,6 +81,9 @@ class EnergyToken:
68
  available: bool
69
  reason: str | None = None
70
  counter_value: float | None = None
 
 
 
71
  opaque: Any = field(default=None, repr=False, compare=False)
72
 
73
 
@@ -223,6 +239,11 @@ class CumulativeEnergyMeter:
223
  False,
224
  f"counter read failed: {type(exc).__name__}",
225
  )
 
 
 
 
 
226
  return EnergyToken(
227
  self._meter_id,
228
  self.provider,
@@ -230,6 +251,7 @@ class CumulativeEnergyMeter:
230
  started,
231
  True,
232
  counter_value=value,
 
233
  )
234
 
235
  def stop(self, token: EnergyToken) -> EnergyUsage:
@@ -249,6 +271,21 @@ class CumulativeEnergyMeter:
249
  None,
250
  f"counter read failed: {type(exc).__name__}",
251
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  delta = end_value - token.counter_value
253
  if delta < 0:
254
  return EnergyUsage(
@@ -261,7 +298,10 @@ class CumulativeEnergyMeter:
261
  "counter reset or wrapped during measurement",
262
  )
263
  joules = delta * self._scale
264
- average = joules / duration if duration > 0 else None
 
 
 
265
  return EnergyUsage(
266
  self.provider,
267
  self.scope,
@@ -637,6 +677,81 @@ class _EmiReader:
637
  return float(self._measure_raw())
638
 
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  class WindowsPerformanceCounterRaplMeter(CumulativeEnergyMeter):
641
  """Optional Windows RAPL package meter via the Energy Meter counter set.
642
 
@@ -659,13 +774,20 @@ class WindowsPerformanceCounterRaplMeter(CumulativeEnergyMeter):
659
  available = platform.system() == "Windows" or reader is not None
660
  reason: str | None = None
661
  if reader is None:
662
- executable = shutil.which("typeperf") if available else None
663
- if executable is None:
664
- available = False
665
- reason = "Windows typeperf is unavailable"
666
- reader = _missing_reader
667
- else:
668
- reader = _TypeperfRaplReader(executable, counter_path)
 
 
 
 
 
 
 
669
  if available and probe:
670
  try:
671
  _finite_counter(reader())
@@ -673,7 +795,11 @@ class WindowsPerformanceCounterRaplMeter(CumulativeEnergyMeter):
673
  available = False
674
  reason = f"Windows RAPL counter unavailable: {type(exc).__name__}"
675
  super().__init__(
676
- provider="windows-performance-counter-rapl",
 
 
 
 
677
  scope="cpu-package",
678
  reader=reader,
679
  joules_per_counter_unit=self.PICOWATT_HOUR_TO_JOULES,
@@ -767,71 +893,238 @@ class PowercapRaplMeter(CumulativeEnergyMeter):
767
  self._reason = f"RAPL counter probe failed: {type(exc).__name__}"
768
 
769
 
770
- #: The stated assumption behind the modelled backend, overridable by the
771
- #: operator who knows their hardware. 12.5 W per busy core is a mid-range
772
- #: figure for a loaded x86 server core including its share of uncore.
773
- MODEL_WATTS_PER_BUSY_CORE_ENV = "DISTINCT_MODEL_WATTS_PER_CORE"
774
- DEFAULT_MODEL_WATTS_PER_BUSY_CORE = 12.5
775
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
 
777
- class CpuLoadModelMeter:
778
- """Modelled energy from system-wide busy-CPU seconds, clearly labelled.
 
 
 
 
 
 
 
 
 
 
 
 
 
779
 
780
- Used only when no hardware counter is available, so a run still reports a
781
- real figure about itself rather than nothing. The model is deliberately
782
- simple and deliberately stated: busy core-seconds across the whole system
783
- during the window, multiplied by a per-core power figure the operator can
784
- override with ``DISTINCT_MODEL_WATTS_PER_CORE``.
785
 
786
- It reports on its own scope, ``system-cpu-modelled``, so the session
787
- roll-up never sums it with a measured cpu-package figure. Whole-system
788
- busy time is the honest choice over per-process time because inference
789
- runs in a supervised child process; the cost is that other activity on
790
- the machine during the window is included, and the scope name says so.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  """
792
 
793
- provider = "cpu-load-model"
794
- scope = "system-cpu-modelled"
795
 
796
  def __init__(
797
  self,
798
  *,
799
- watts_per_busy_core: float | None = None,
800
  monotonic: Callable[[], float] = time.monotonic,
801
- stat_path: str = "/proc/stat",
802
  ) -> None:
803
- import os
804
-
805
  self._monotonic = monotonic
 
806
  self._meter_id = uuid.uuid4().hex
807
- self._stat_path = stat_path
808
- if watts_per_busy_core is None:
809
- raw = os.environ.get(MODEL_WATTS_PER_BUSY_CORE_ENV, "")
 
 
 
 
 
 
810
  try:
811
- watts_per_busy_core = float(raw) if raw else DEFAULT_MODEL_WATTS_PER_BUSY_CORE
812
- except ValueError:
813
- watts_per_busy_core = DEFAULT_MODEL_WATTS_PER_BUSY_CORE
814
- if not math.isfinite(watts_per_busy_core) or not 0.5 <= watts_per_busy_core <= 500:
815
- watts_per_busy_core = DEFAULT_MODEL_WATTS_PER_BUSY_CORE
816
- self.watts_per_busy_core = float(watts_per_busy_core)
817
- self._clock_ticks = float(os.sysconf("SC_CLK_TCK")) if hasattr(os, "sysconf") else 100.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  self._available = True
819
- self._reason: str | None = None
 
 
 
820
  try:
821
- _finite_counter(self._busy_seconds())
822
- except Exception as exc:
823
- self._available = False
824
- self._reason = f"/proc/stat unavailable: {type(exc).__name__}"
825
-
826
- def _busy_seconds(self) -> float:
827
- with open(self._stat_path, "r", encoding="ascii") as handle:
828
- first = handle.readline().split()
829
- if not first or first[0] != "cpu":
830
- raise ValueError("unexpected /proc/stat format")
831
- values = [float(item) for item in first[1:11]]
832
- # busy = everything except idle (4th) and iowait (5th).
833
- busy_ticks = sum(values) - values[3] - (values[4] if len(values) > 4 else 0.0)
834
- return busy_ticks / self._clock_ticks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
 
836
  @property
837
  def available(self) -> bool:
@@ -847,53 +1140,92 @@ class CpuLoadModelMeter:
847
  return EnergyToken(
848
  self._meter_id, self.provider, self.scope, started, False, self._reason
849
  )
850
- try:
851
- value = _finite_counter(self._busy_seconds())
852
- except Exception as exc:
 
 
 
 
 
 
 
 
 
853
  return EnergyToken(
854
- self._meter_id,
855
- self.provider,
856
- self.scope,
857
- started,
858
- False,
859
- f"busy-time read failed: {type(exc).__name__}",
860
  )
 
 
 
 
861
  return EnergyToken(
862
- self._meter_id, self.provider, self.scope, started, True, counter_value=value
863
  )
864
 
865
  def stop(self, token: EnergyToken) -> EnergyUsage:
866
  _check_token_owner(token, self._meter_id)
867
  duration = max(0.0, self._monotonic() - token.started_monotonic)
868
- if not token.available or token.counter_value is None:
869
  return _unavailable_usage(token, duration)
870
- try:
871
- end_value = _finite_counter(self._busy_seconds())
872
- except Exception as exc:
873
- return EnergyUsage(
874
- self.provider,
875
- self.scope,
876
- False,
877
- duration,
878
- None,
879
- None,
880
- f"busy-time read failed: {type(exc).__name__}",
881
- )
882
- busy_core_seconds = max(0.0, end_value - token.counter_value)
883
- joules = busy_core_seconds * self.watts_per_busy_core
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
  average = joules / duration if duration > 0 else None
885
- return EnergyUsage(
886
- self.provider,
887
- self.scope,
888
- True,
889
- duration,
890
- joules,
891
- average,
892
- # Available AND carrying a reason: the reason states the model so
893
- # the assumption travels with every figure derived from it.
894
- f"modelled: busy core-seconds x {self.watts_per_busy_core:g} W per core",
895
- )
896
 
 
 
 
 
 
 
 
 
897
 
898
  class ZeusEnergyMeter:
899
  """Measured energy via Zeus (https://ml.energy/zeus/measure/).
@@ -1246,85 +1578,242 @@ class PowermetricsProbeMeter:
1246
  return _unavailable_usage(token, max(0.0, time.monotonic() - token.started_at))
1247
 
1248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1250
- """Choose a backend: measured counters first, the stated model last.
1251
 
1252
- A run should virtually always come back with an energy figure; that is
1253
- the product. What varies, and is always labelled, is whether the figure
1254
- was measured off a hardware counter or modelled from CPU load.
1255
 
1256
- Zeus is tried before everything else on the platforms it supports, because
1257
- it is the only backend here that can see a GPU, and a GPU is where the
1258
- energy is on any worker that has one. It is optional: when it is absent, or
1259
- finds no device, or lacks the privileges RAPL needs, the ladder below is
1260
- unchanged.
 
 
 
 
 
 
 
 
 
 
1261
  """
1262
 
1263
  operating_system = platform.system() if system is None else system
 
1264
  if operating_system != "Windows":
1265
  # Upstream does not support Windows, so it is not attempted there
1266
- # rather than being attempted and failing on every start-up.
1267
  zeus = ZeusEnergyMeter()
1268
  if zeus.available:
1269
  return zeus
1270
- if operating_system == "Darwin":
1271
- # Zeus above already covers a Mac with zeus-apple-silicon installed.
1272
- # Below it, powermetrics is the OS's own meter but needs root; the
1273
- # probe meter names that requirement, and the load model keeps a Mac
1274
- # worker reporting a labelled figure rather than nothing.
1275
- probe = PowermetricsProbeMeter()
1276
- if probe.available:
1277
- return probe
1278
- model = CpuLoadModelMeter()
1279
- if model.available:
1280
- return model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1281
  return UnavailableEnergyMeter(
1282
- probe.unavailable_reason or "no macOS energy backend is available"
1283
  )
 
 
 
 
 
 
 
1284
  if operating_system == "Linux":
1285
- # Direct powercap first: same hardware counter pyRAPL wraps, without
1286
- # importing an abandoned package that logs warnings at import time.
1287
- powercap = PowercapRaplMeter()
1288
- if powercap.available:
1289
- return powercap
1290
- pyrapl = PyRaplEnergyMeter()
1291
- if pyrapl.available:
1292
- return pyrapl
1293
- # A machine whose inference runs on the GPU: the board figure is the
1294
- # honest one when the CPU counters are fenced off (as they are in
1295
- # containers), and it is measured rather than modelled.
1296
- smi = NvidiaSmiPowerMeter()
1297
- if smi.available:
1298
- return smi
1299
- model = CpuLoadModelMeter()
1300
- if model.available:
1301
- return model
1302
- return UnavailableEnergyMeter(powercap.unavailable_reason or "Linux RAPL unavailable")
1303
  if operating_system == "Windows":
1304
- # EMI first: the same RAPL counters, read through the documented driver
1305
- # interface rather than through a typeperf subprocess and a CSV parse.
1306
- emi = WindowsEmiMeter()
1307
- if emi.available:
1308
- return emi
1309
- counter = WindowsPerformanceCounterRaplMeter()
1310
- if counter.available:
1311
- return counter
1312
- smi = NvidiaSmiPowerMeter()
1313
- if smi.available:
1314
- return smi
1315
- # Modelled, and labelled as modelled. A Windows worker with no usable
1316
- # counter used to report nothing at all, which made every one of its
1317
- # runs an absence; the model is a real number about a real run and it
1318
- # carries its own scope so it can never be summed with a measurement.
1319
- model = CpuLoadModelMeter()
1320
- if model.available:
1321
- return model
1322
- return UnavailableEnergyMeter(
1323
- emi.unavailable_reason
1324
- or counter.unavailable_reason
1325
- or "no Windows energy counter is available"
1326
- )
1327
- return UnavailableEnergyMeter(f"no energy backend for {operating_system}")
1328
 
1329
 
1330
  def _check_token_owner(token: EnergyToken, meter_id: str) -> None:
 
1
+ """Measured energy for a local inference window.
2
 
3
+ Everything here is a hardware counter. There is no model, no estimate and no
4
+ default: a component either has a counter this can read, in which case its
5
+ joules are real, or it does not, in which case it is named as absent and the
6
+ run reports no figure rather than a plausible one.
7
 
8
+ WHAT IS MEASURED
9
 
10
+ A run's energy is the sum of the components that did the run, each read from
11
+ its own counter over the same window:
 
 
 
 
 
 
12
 
13
+ * **NVIDIA GPU**, through NVML (``nvidia-ml-py``). Where the driver exposes
14
+ ``nvmlDeviceGetTotalEnergyConsumption`` (Volta and later) that counter is an
15
+ integrating energy register in millijoules, so the interval energy is an
16
+ exact difference with no sampling error. Older boards fall back to
17
+ integrating ``nvmlDeviceGetPowerUsage`` on a 50 ms timer, and say so in the
18
+ scope. Every board on the machine is summed.
19
+ * **CPU package**, through RAPL: ``/sys/class/powercap`` directly on Linux
20
+ (with pyRAPL as a secondary reader), and on Windows the Energy Meter
21
+ Interface driver, or the Energy Meter performance counter where EMI is
22
+ absent.
23
+ * **Apple silicon**, through ``powermetrics``, which needs root and says so
24
+ when it does not have it.
25
 
26
+ For inference on a GPU the board is nearly all of the energy, which is why the
27
+ GPU is read first and why reading only the CPU package -- as this module used
28
+ to -- understates a GPU run by roughly an order of magnitude.
29
+
30
+ WHAT IS NOT MEASURED, AND IS NEVER GUESSED
31
+
32
+ Power-supply conversion loss, fans, storage, the mainboard and anything else
33
+ between the wall and these components. No multiplier is applied to reach for a
34
+ whole-machine figure, because that would convert a measurement into a model
35
+ while leaving the word "measured" on it. The reported number is therefore a
36
+ floor for the machine's true draw, and the scope string names exactly which
37
+ components it covers.
38
+
39
+ Where a component is expected but silent, its absence is attached to the
40
+ figure, so a total missing its GPU can be told apart from a total that never
41
+ had one.
42
+
43
+ WSL2, AND WHY AN AGENT SHOULD NOT RUN THERE
44
+
45
+ Running the agent under WSL2 to get the Linux RAPL reader and the bubblewrap
46
+ sandbox does not work, for two independent reasons:
47
 
48
  * The Microsoft WSL2 kernel is built with ``CONFIG_POWERCAP`` unset on the
49
+ current rolling branch, so ``/sys/class/powercap`` does not exist.
50
+ * Even with a rebuilt kernel, Hyper-V does not pass the RAPL energy MSRs to the
51
+ utility VM. ``intel_rapl_msr`` matches the CPU, attempts
52
+ ``MSR_RAPL_POWER_UNIT``, fails, and registers no zones. Being root inside
53
+ WSL2 buys nothing, which is the reverse of bare-metal Linux.
54
+
55
+ NVML does work under WSL2, so a GPU worker there measures its GPU and nothing
56
+ else. For a CPU worker the figure disappears entirely. The Windows path stays.
 
 
 
 
 
 
 
 
 
 
57
  """
58
 
59
  from __future__ import annotations
 
81
  available: bool
82
  reason: str | None = None
83
  counter_value: float | None = None
84
+ #: When the counter was actually sampled, which is not when start() was
85
+ #: called. See CumulativeEnergyMeter.stop for why the difference matters.
86
+ counter_at: float | None = None
87
  opaque: Any = field(default=None, repr=False, compare=False)
88
 
89
 
 
239
  False,
240
  f"counter read failed: {type(exc).__name__}",
241
  )
242
+ # Taken AFTER the read, and paired with it. Some readers are slow --
243
+ # the Windows performance-counter path shells out to typeperf, whose
244
+ # first sample costs the better part of a second -- and the interval
245
+ # the counter difference describes begins when the counter was
246
+ # sampled, not when this method was entered.
247
  return EnergyToken(
248
  self._meter_id,
249
  self.provider,
 
251
  started,
252
  True,
253
  counter_value=value,
254
+ counter_at=self._monotonic(),
255
  )
256
 
257
  def stop(self, token: EnergyToken) -> EnergyUsage:
 
271
  None,
272
  f"counter read failed: {type(exc).__name__}",
273
  )
274
+ # THE AVERAGE IS OVER THE COUNTER'S WINDOW, NOT THIS METHOD'S.
275
+ #
276
+ # `duration` above is wall time from start() being called to stop()
277
+ # returning, which is the right figure to report as how long the run
278
+ # took. It is the wrong denominator for average power, because it
279
+ # includes the cost of the two counter reads that bracket it. On the
280
+ # Windows performance-counter path each read shells out to typeperf,
281
+ # whose first sample takes the better part of a second: a five-second
282
+ # run was reported over a six-and-a-half-second window, so its average
283
+ # watts came out about a fifth low while its joules were exactly right.
284
+ #
285
+ # The energy is the counter difference, and the counter difference
286
+ # describes the interval between the two samples. Dividing by that
287
+ # interval is the only division that gives the power actually drawn.
288
+ sampled_at = self._monotonic()
289
  delta = end_value - token.counter_value
290
  if delta < 0:
291
  return EnergyUsage(
 
298
  "counter reset or wrapped during measurement",
299
  )
300
  joules = delta * self._scale
301
+ counter_window = (
302
+ sampled_at - token.counter_at if token.counter_at is not None else duration
303
+ )
304
+ average = joules / counter_window if counter_window > 0 else None
305
  return EnergyUsage(
306
  self.provider,
307
  self.scope,
 
677
  return float(self._measure_raw())
678
 
679
 
680
+ class _PdhRaplReader:
681
+ """The same counter as typeperf, read through PDH with no sample wait.
682
+
683
+ WHY THIS EXISTS: typeperf COSTS MORE THAN THE RUN IT MEASURES.
684
+
685
+ ``typeperf <counter> -sc 1`` spawns a process and, because its formatted
686
+ output is defined in terms of a sampling interval, waits about a second
687
+ before printing anything. Two of those bracket every run, so a worker on
688
+ this path spent roughly 1.4 s of wall clock per request doing nothing but
689
+ reading a number, and short runs were mostly measurement.
690
+
691
+ ``\\Energy Meter(...)\\Energy`` is a cumulative raw counter, not a rate, so
692
+ it needs no interval at all: one ``PdhCollectQueryData`` and the raw value
693
+ is the total energy so far. The query is opened once and kept, so a read is
694
+ two library calls and no process.
695
+
696
+ Falls back to typeperf when PDH is not usable, because a slow reading is
697
+ better than none.
698
+ """
699
+
700
+ def __init__(self, counter_path: str) -> None:
701
+ self.counter_path = counter_path
702
+ self._pdh: Any = None
703
+ self._query: Any = None
704
+ self._counter: Any = None
705
+ self._open()
706
+
707
+ def _open(self) -> None:
708
+ import ctypes
709
+ from ctypes import wintypes
710
+
711
+ pdh = ctypes.WinDLL("pdh.dll")
712
+ query = wintypes.LPVOID()
713
+ status = pdh.PdhOpenQueryW(None, 0, ctypes.byref(query))
714
+ if status != 0:
715
+ raise OSError(status, "PdhOpenQuery failed")
716
+ counter = wintypes.LPVOID()
717
+ # The English variant so a localised Windows still resolves the path
718
+ # this project hard-codes.
719
+ status = pdh.PdhAddEnglishCounterW(
720
+ query, self.counter_path, 0, ctypes.byref(counter)
721
+ )
722
+ if status != 0:
723
+ pdh.PdhCloseQuery(query)
724
+ raise OSError(status, f"PdhAddEnglishCounter failed for {self.counter_path}")
725
+ self._pdh, self._query, self._counter = pdh, query, counter
726
+
727
+ def __call__(self) -> float:
728
+ import ctypes
729
+ from ctypes import wintypes
730
+
731
+ class _RawCounter(ctypes.Structure):
732
+ _fields_ = [
733
+ ("CStatus", wintypes.DWORD),
734
+ ("TimeStamp", wintypes.FILETIME),
735
+ ("FirstValue", ctypes.c_longlong),
736
+ ("SecondValue", ctypes.c_longlong),
737
+ ("MultiCount", wintypes.DWORD),
738
+ ]
739
+
740
+ status = self._pdh.PdhCollectQueryData(self._query)
741
+ if status != 0:
742
+ raise OSError(status, "PdhCollectQueryData failed")
743
+ counter_type = wintypes.DWORD()
744
+ raw = _RawCounter()
745
+ status = self._pdh.PdhGetRawCounterValue(
746
+ self._counter, ctypes.byref(counter_type), ctypes.byref(raw)
747
+ )
748
+ if status != 0:
749
+ raise OSError(status, "PdhGetRawCounterValue failed")
750
+ if raw.CStatus != 0:
751
+ raise OSError(raw.CStatus, "the energy counter reported an error status")
752
+ return float(raw.FirstValue)
753
+
754
+
755
  class WindowsPerformanceCounterRaplMeter(CumulativeEnergyMeter):
756
  """Optional Windows RAPL package meter via the Energy Meter counter set.
757
 
 
774
  available = platform.system() == "Windows" or reader is not None
775
  reason: str | None = None
776
  if reader is None:
777
+ # PDH first: same counter, no sampling wait, no subprocess. See
778
+ # _PdhRaplReader for why that is worth the ctypes.
779
+ try:
780
+ reader = _PdhRaplReader(counter_path) if available else None
781
+ except Exception: # noqa: BLE001 - fall back rather than fail
782
+ reader = None
783
+ if reader is None:
784
+ executable = shutil.which("typeperf") if available else None
785
+ if executable is None:
786
+ available = False
787
+ reason = "no Windows energy counter is readable by PDH or typeperf"
788
+ reader = _missing_reader
789
+ else:
790
+ reader = _TypeperfRaplReader(executable, counter_path)
791
  if available and probe:
792
  try:
793
  _finite_counter(reader())
 
795
  available = False
796
  reason = f"Windows RAPL counter unavailable: {type(exc).__name__}"
797
  super().__init__(
798
+ provider=(
799
+ "windows-energy-counter-pdh"
800
+ if isinstance(reader, _PdhRaplReader)
801
+ else "windows-energy-counter-typeperf"
802
+ ),
803
  scope="cpu-package",
804
  reader=reader,
805
  joules_per_counter_unit=self.PICOWATT_HOUR_TO_JOULES,
 
893
  self._reason = f"RAPL counter probe failed: {type(exc).__name__}"
894
 
895
 
896
+ # THE MODELLED METER IS GONE, AND THIS IS WHAT IT WAS.
897
+ #
898
+ # The modelled meter multiplied busy core-seconds from /proc/stat by a
899
+ # hard-coded 12.5 watts per core and reported the product as the run's energy,
900
+ # labelled "modelled". It was removed rather than improved, for two reasons
901
+ # that are worth keeping so it is not reinvented:
902
+ #
903
+ # * The constant was invented. Nothing measured it, nothing on the machine
904
+ # validated it, and no two machines would have shared it. A figure derived
905
+ # from it is not a property of the run it describes, which makes it the one
906
+ # thing this project cannot ship: a number about energy that nobody can
907
+ # reproduce, on a site whose whole claim is that its numbers are measured.
908
+ #
909
+ # * It could not run where it was needed. It read /proc/stat, so on Windows,
910
+ # the platform it existed to be the fallback for, it failed at construction
911
+ # and the ladder fell straight through it. It had been the documented
912
+ # Windows fallback for months while being, on Windows, dead code.
913
+ #
914
+ # A run with no counter now reports an absence carrying the reason. The rest of
915
+ # the product already treats an absence as a first-class state -- it is drawn
916
+ # as a dash and the words "No data", never as zero -- so there was somewhere
917
+ # honest for that answer to go.
918
+
919
+
920
+ # ---------------------------------------------------------------------------
921
+ # NVIDIA, THROUGH NVML RATHER THAN A SUBPROCESS
922
+ #
923
+ # For a worker that runs a model on a GPU, the GPU is the measurement. CPU
924
+ # package counters see the tokeniser and the sampling loop and essentially none
925
+ # of the matrix multiplication, so a ladder that reads RAPL and stops has
926
+ # measured the wrong component and reported it as the run's energy.
927
+ #
928
+ # Two ways to ask NVML, and the difference matters:
929
+ #
930
+ # * ``nvmlDeviceGetTotalEnergyConsumption`` is a monotonic ENERGY counter in
931
+ # millijoules, maintained by the driver since the last reset. Volta and
932
+ # later. Reading it before and after and subtracting gives the energy of
933
+ # the interval exactly, with no sampling error at all, and it cannot miss a
934
+ # spike that happened between two samples.
935
+ #
936
+ # * ``nvmlDeviceGetPowerUsage`` is an instantaneous POWER reading in
937
+ # milliwatts. Turning that into energy means sampling it on a timer and
938
+ # integrating, which is an approximation whose error depends on how spiky
939
+ # the load is. LLM decode is spiky.
940
+ #
941
+ # So the counter is used wherever it exists and the sampler is the fallback for
942
+ # Pascal and older, which is stated in the scope rather than hidden. The
943
+ # fallback integrates with the trapezium rule on real elapsed timestamps, not
944
+ # a nominal interval, because a sampling thread that is descheduled for 40ms
945
+ # must not be assumed to have slept for exactly 10.
946
+ # ---------------------------------------------------------------------------
947
+
948
+ NVML_SAMPLE_INTERVAL_SECONDS = 0.05
949
+
950
+
951
+ class _NvmlSampler:
952
+ """Integrates instantaneous board power on a background thread.
953
+
954
+ Only used where the energy counter is absent. Trapezium rule over real
955
+ timestamps: if the thread is late, the gap it actually slept is the gap it
956
+ integrates over.
957
+ """
958
 
959
+ def __init__(
960
+ self,
961
+ read_watts: Callable[[], float],
962
+ *,
963
+ interval: float = NVML_SAMPLE_INTERVAL_SECONDS,
964
+ monotonic: Callable[[], float] = time.monotonic,
965
+ ) -> None:
966
+ self._read = read_watts
967
+ self._interval = interval
968
+ self._monotonic = monotonic
969
+ self._stop = threading.Event()
970
+ self._thread: threading.Thread | None = None
971
+ self._joules = 0.0
972
+ self._samples = 0
973
+ self._failed: str | None = None
974
 
975
+ def start(self) -> None:
976
+ self._thread = threading.Thread(target=self._run, name="distinct-nvml", daemon=True)
977
+ self._thread.start()
 
 
978
 
979
+ def _run(self) -> None:
980
+ try:
981
+ previous_watts = self._read()
982
+ previous_at = self._monotonic()
983
+ self._samples = 1
984
+ except Exception as exc: # noqa: BLE001 - recorded, not raised on a thread
985
+ self._failed = f"{type(exc).__name__}: {exc}"
986
+ return
987
+ while not self._stop.wait(self._interval):
988
+ try:
989
+ watts = self._read()
990
+ except Exception as exc: # noqa: BLE001
991
+ self._failed = f"{type(exc).__name__}: {exc}"
992
+ return
993
+ now = self._monotonic()
994
+ self._joules += (watts + previous_watts) / 2.0 * (now - previous_at)
995
+ previous_watts, previous_at = watts, now
996
+ self._samples += 1
997
+
998
+ def stop(self) -> tuple[float, int, str | None]:
999
+ self._stop.set()
1000
+ if self._thread is not None:
1001
+ self._thread.join(timeout=2.0)
1002
+ return self._joules, self._samples, self._failed
1003
+
1004
+
1005
+ class NvmlGpuEnergyMeter:
1006
+ """Measured GPU energy for every NVIDIA board on this machine.
1007
+
1008
+ Every board is summed, because a worker that spreads a model across two
1009
+ cards has spent the energy of both and reporting one of them would be a
1010
+ measurement of half the machine.
1011
  """
1012
 
1013
+ provider = "nvidia-nvml"
 
1014
 
1015
  def __init__(
1016
  self,
1017
  *,
1018
+ nvml: Any = None,
1019
  monotonic: Callable[[], float] = time.monotonic,
1020
+ sample_interval: float = NVML_SAMPLE_INTERVAL_SECONDS,
1021
  ) -> None:
 
 
1022
  self._monotonic = monotonic
1023
+ self._interval = sample_interval
1024
  self._meter_id = uuid.uuid4().hex
1025
+ self._nvml: Any = None
1026
+ self._handles: list[Any] = []
1027
+ self._counter = False
1028
+ self._available = False
1029
+ self._reason: str | None = None
1030
+ self._names: tuple[str, ...] = ()
1031
+
1032
+ module = nvml
1033
+ if module is None:
1034
  try:
1035
+ import pynvml as module # type: ignore[no-redef]
1036
+ except ImportError:
1037
+ self._reason = (
1038
+ "nvidia-ml-py is not installed, so NVIDIA GPU energy cannot be read"
1039
+ )
1040
+ return
1041
+ try:
1042
+ module.nvmlInit()
1043
+ except Exception as exc: # noqa: BLE001 - any NVML failure means no GPU here
1044
+ self._reason = f"NVML would not start: {_nvml_reason(exc)}"
1045
+ return
1046
+ self._nvml = module
1047
+ try:
1048
+ count = int(module.nvmlDeviceGetCount())
1049
+ names = []
1050
+ for index in range(count):
1051
+ handle = module.nvmlDeviceGetHandleByIndex(index)
1052
+ self._handles.append(handle)
1053
+ names.append(_nvml_text(module.nvmlDeviceGetName(handle)))
1054
+ self._names = tuple(names)
1055
+ except Exception as exc: # noqa: BLE001
1056
+ self._reason = f"NVML found no usable device: {_nvml_reason(exc)}"
1057
+ self._shutdown()
1058
+ return
1059
+ if not self._handles:
1060
+ self._reason = "NVML started but this machine has no NVIDIA GPU"
1061
+ self._shutdown()
1062
+ return
1063
+ # The energy counter is per device and not universal. It is only used
1064
+ # when EVERY board has it, because a total that mixed an exact counter
1065
+ # on one card with an integrated estimate on another would carry the
1066
+ # error of the estimate under the name of the counter.
1067
+ self._counter = all(self._try_energy(handle) is not None for handle in self._handles)
1068
+ if not self._counter and self._try_power(self._handles[0]) is None:
1069
+ self._reason = "NVML reports neither an energy counter nor a power reading"
1070
+ self._shutdown()
1071
+ return
1072
  self._available = True
1073
+
1074
+ # -- NVML calls, each returning None rather than raising ----------------
1075
+
1076
+ def _try_energy(self, handle: Any) -> float | None:
1077
  try:
1078
+ return float(self._nvml.nvmlDeviceGetTotalEnergyConsumption(handle)) / 1000.0
1079
+ except Exception: # noqa: BLE001 - unsupported on pre-Volta
1080
+ return None
1081
+
1082
+ def _try_power(self, handle: Any) -> float | None:
1083
+ try:
1084
+ return float(self._nvml.nvmlDeviceGetPowerUsage(handle)) / 1000.0
1085
+ except Exception: # noqa: BLE001
1086
+ return None
1087
+
1088
+ def _shutdown(self) -> None:
1089
+ if self._nvml is not None:
1090
+ try:
1091
+ self._nvml.nvmlShutdown()
1092
+ except Exception: # noqa: BLE001 - shutting down a dead handle is not news
1093
+ pass
1094
+ self._nvml = None
1095
+
1096
+ def _total_energy_joules(self) -> float:
1097
+ total = 0.0
1098
+ for handle in self._handles:
1099
+ value = self._try_energy(handle)
1100
+ if value is None:
1101
+ raise RuntimeError("energy counter disappeared mid-run")
1102
+ total += value
1103
+ return total
1104
+
1105
+ def _total_watts(self) -> float:
1106
+ total = 0.0
1107
+ for handle in self._handles:
1108
+ value = self._try_power(handle)
1109
+ if value is None:
1110
+ raise RuntimeError("power reading disappeared mid-run")
1111
+ total += value
1112
+ return total
1113
+
1114
+ # -- meter interface ----------------------------------------------------
1115
+
1116
+ @property
1117
+ def scope(self) -> str:
1118
+ """Names the boards and how they were read, because both change the number."""
1119
+
1120
+ how = "energy-counter" if self._counter else "power-sampled"
1121
+ if not self._names:
1122
+ return f"gpu-nvidia-{how}"
1123
+ return f"gpu-nvidia-{how} ({len(self._names)} board{'s' if len(self._names) > 1 else ''})"
1124
+
1125
+ @property
1126
+ def devices(self) -> tuple[str, ...]:
1127
+ return self._names
1128
 
1129
  @property
1130
  def available(self) -> bool:
 
1140
  return EnergyToken(
1141
  self._meter_id, self.provider, self.scope, started, False, self._reason
1142
  )
1143
+ if self._counter:
1144
+ try:
1145
+ value = _finite_counter(self._total_energy_joules())
1146
+ except Exception as exc: # noqa: BLE001
1147
+ return EnergyToken(
1148
+ self._meter_id,
1149
+ self.provider,
1150
+ self.scope,
1151
+ started,
1152
+ False,
1153
+ f"NVML energy counter read failed: {_nvml_reason(exc)}",
1154
+ )
1155
  return EnergyToken(
1156
+ self._meter_id, self.provider, self.scope, started, True, counter_value=value
 
 
 
 
 
1157
  )
1158
+ sampler = _NvmlSampler(
1159
+ self._total_watts, interval=self._interval, monotonic=self._monotonic
1160
+ )
1161
+ sampler.start()
1162
  return EnergyToken(
1163
+ self._meter_id, self.provider, self.scope, started, True, opaque=sampler
1164
  )
1165
 
1166
  def stop(self, token: EnergyToken) -> EnergyUsage:
1167
  _check_token_owner(token, self._meter_id)
1168
  duration = max(0.0, self._monotonic() - token.started_monotonic)
1169
+ if not token.available:
1170
  return _unavailable_usage(token, duration)
1171
+ if self._counter:
1172
+ if token.counter_value is None:
1173
+ return _unavailable_usage(token, duration)
1174
+ try:
1175
+ end = _finite_counter(self._total_energy_joules())
1176
+ except Exception as exc: # noqa: BLE001
1177
+ return EnergyUsage(
1178
+ self.provider,
1179
+ self.scope,
1180
+ False,
1181
+ duration,
1182
+ None,
1183
+ None,
1184
+ f"NVML energy counter read failed: {_nvml_reason(exc)}",
1185
+ )
1186
+ # The driver's counter resets when the driver reloads. A total that
1187
+ # went backwards is that, not negative energy, and is refused.
1188
+ if end < token.counter_value:
1189
+ return EnergyUsage(
1190
+ self.provider,
1191
+ self.scope,
1192
+ False,
1193
+ duration,
1194
+ None,
1195
+ None,
1196
+ "the NVML energy counter was reset during this run",
1197
+ )
1198
+ joules = end - token.counter_value
1199
+ else:
1200
+ sampler = token.opaque
1201
+ if not isinstance(sampler, _NvmlSampler):
1202
+ return _unavailable_usage(token, duration)
1203
+ joules, samples, failed = sampler.stop()
1204
+ if failed is not None:
1205
+ return EnergyUsage(
1206
+ self.provider, self.scope, False, duration, None, None,
1207
+ f"GPU power sampling failed: {failed}",
1208
+ )
1209
+ if samples < 2:
1210
+ return EnergyUsage(
1211
+ self.provider, self.scope, False, duration, None, None,
1212
+ "the run was shorter than one GPU sampling interval",
1213
+ )
1214
  average = joules / duration if duration > 0 else None
1215
+ return EnergyUsage(self.provider, self.scope, True, duration, joules, average)
1216
+
1217
+
1218
+ def _nvml_text(value: Any) -> str:
1219
+ """NVML returns bytes on some builds and str on others."""
 
 
 
 
 
 
1220
 
1221
+ if isinstance(value, bytes):
1222
+ return value.decode("utf-8", "replace")
1223
+ return str(value)
1224
+
1225
+
1226
+ def _nvml_reason(exc: BaseException) -> str:
1227
+ text = str(exc).strip()
1228
+ return text or type(exc).__name__
1229
 
1230
  class ZeusEnergyMeter:
1231
  """Measured energy via Zeus (https://ml.energy/zeus/measure/).
 
1578
  return _unavailable_usage(token, max(0.0, time.monotonic() - token.started_at))
1579
 
1580
 
1581
+
1582
+ # ---------------------------------------------------------------------------
1583
+ # WHY THE METER IS A SUM AND NOT A CHOICE
1584
+ #
1585
+ # This used to pick one backend off a ladder and report it as "the" energy of a
1586
+ # run. On a machine that runs the model on a GPU that is the wrong component:
1587
+ # the CPU package counter sees the tokeniser, the sampler and the scheduler,
1588
+ # and almost none of the arithmetic, so the ladder measured something real,
1589
+ # reported it accurately, and answered a question nobody asked.
1590
+ #
1591
+ # A run's energy is the energy of the parts of the machine that did it. Where
1592
+ # more than one of those parts has a counter, all of them are read and the
1593
+ # figures are added, and the scope says which parts were included.
1594
+ #
1595
+ # What is NOT done here, deliberately:
1596
+ #
1597
+ # * No estimate is ever added to a measurement. Every component in the sum
1598
+ # is a hardware counter. A component with no counter is named in
1599
+ # `missing`, so the total is explicitly a lower bound rather than
1600
+ # silently one.
1601
+ # * Nothing is scaled up to guess at the whole wall socket. Power supply
1602
+ # loss, fans, drives and the board itself are real energy this cannot see,
1603
+ # and inventing a PUE-style multiplier would turn a measurement into a
1604
+ # model while keeping the word "measured" on it.
1605
+ #
1606
+ # So the number is: the energy drawn by the components named in the scope,
1607
+ # measured by their own counters, during the run. That sentence is what the
1608
+ # figure means, and it is the sentence the site prints.
1609
+ # ---------------------------------------------------------------------------
1610
+
1611
+
1612
+ class CompositeEnergyMeter:
1613
+ """Every counter this machine has, read together and summed."""
1614
+
1615
+ provider = "composite"
1616
+
1617
+ def __init__(
1618
+ self,
1619
+ meters: Sequence[EnergyMeter],
1620
+ *,
1621
+ missing: Sequence[str] = (),
1622
+ monotonic: Callable[[], float] = time.monotonic,
1623
+ ) -> None:
1624
+ self._meters = [meter for meter in meters if meter.available]
1625
+ self._missing = tuple(missing)
1626
+ self._monotonic = monotonic
1627
+ self._meter_id = uuid.uuid4().hex
1628
+ self._reason = None if self._meters else "no hardware energy counter is available"
1629
+
1630
+ @property
1631
+ def meters(self) -> tuple[EnergyMeter, ...]:
1632
+ return tuple(self._meters)
1633
+
1634
+ @property
1635
+ def scope(self) -> str:
1636
+ if not self._meters:
1637
+ return "unknown"
1638
+ return " + ".join(meter.scope for meter in self._meters)
1639
+
1640
+ @property
1641
+ def available(self) -> bool:
1642
+ return bool(self._meters)
1643
+
1644
+ @property
1645
+ def unavailable_reason(self) -> str | None:
1646
+ return self._reason
1647
+
1648
+ def start(self) -> EnergyToken:
1649
+ started = self._monotonic()
1650
+ if not self._meters:
1651
+ return EnergyToken(
1652
+ self._meter_id, self.provider, self.scope, started, False, self._reason
1653
+ )
1654
+ # Started in order and stopped in reverse, so the window each component
1655
+ # measures encloses the window of the one started after it. No
1656
+ # component can then report energy from a period another did not cover.
1657
+ tokens = [meter.start() for meter in self._meters]
1658
+ return EnergyToken(
1659
+ self._meter_id, self.provider, self.scope, started, True, opaque=tokens
1660
+ )
1661
+
1662
+ def stop(self, token: EnergyToken) -> EnergyUsage:
1663
+ _check_token_owner(token, self._meter_id)
1664
+ duration = max(0.0, self._monotonic() - token.started_monotonic)
1665
+ tokens = token.opaque
1666
+ if not token.available or not isinstance(tokens, list):
1667
+ return _unavailable_usage(token, duration)
1668
+
1669
+ parts: list[EnergyUsage] = []
1670
+ for meter, part_token in zip(reversed(self._meters), reversed(tokens), strict=True):
1671
+ parts.append(meter.stop(part_token))
1672
+ parts.reverse()
1673
+
1674
+ measured = [part for part in parts if part.available and part.joules is not None]
1675
+ if not measured:
1676
+ reasons = "; ".join(
1677
+ f"{part.provider}: {part.reason}" for part in parts if part.reason
1678
+ )
1679
+ return EnergyUsage(
1680
+ self.provider, self.scope, False, duration, None, None,
1681
+ reasons or "no component returned a measurement",
1682
+ )
1683
+
1684
+ joules = sum(float(part.joules) for part in measured)
1685
+ average = joules / duration if duration > 0 else None
1686
+ scope = " + ".join(part.scope for part in measured)
1687
+ # A component that was expected and did not report is named, because a
1688
+ # total missing its GPU is a different number from a total that never
1689
+ # had one and the reader cannot tell them apart from the figure alone.
1690
+ absent = [part.provider for part in parts if part not in measured]
1691
+ absent.extend(self._missing)
1692
+ reason = None
1693
+ if absent:
1694
+ reason = "not included: " + ", ".join(dict.fromkeys(absent))
1695
+ return EnergyUsage(self.provider, scope, True, duration, joules, average, reason)
1696
+
1697
+
1698
+ def _cpu_meter(operating_system: str) -> EnergyMeter | None:
1699
+ """This machine's CPU-package counter, or None if it has none we can read."""
1700
+
1701
+ if operating_system == "Linux":
1702
+ # Direct powercap first: the same counter pyRAPL wraps, without
1703
+ # importing an abandoned package that logs at import time.
1704
+ for candidate in (PowercapRaplMeter(), PyRaplEnergyMeter()):
1705
+ if candidate.available:
1706
+ return candidate
1707
+ return None
1708
+ if operating_system == "Windows":
1709
+ # EMI is the documented driver interface to the same RAPL counters;
1710
+ # the performance-counter reader is the fallback where it is absent.
1711
+ for candidate in (WindowsEmiMeter(), WindowsPerformanceCounterRaplMeter()):
1712
+ if candidate.available:
1713
+ return candidate
1714
+ return None
1715
+ if operating_system == "Darwin":
1716
+ probe = PowermetricsProbeMeter()
1717
+ return probe if probe.available else None
1718
+ return None
1719
+
1720
+
1721
+ def _gpu_meter() -> EnergyMeter | None:
1722
+ """This machine's GPU counter, preferring the library to the subprocess."""
1723
+
1724
+ nvml = NvmlGpuEnergyMeter()
1725
+ if nvml.available:
1726
+ return nvml
1727
+ # nvidia-smi is the same driver reached the slow way. It stays as a
1728
+ # fallback for a machine where the library is missing but the tool is on
1729
+ # PATH, which is the ordinary state of a fresh CUDA install.
1730
+ smi = NvidiaSmiPowerMeter()
1731
+ if smi.available:
1732
+ return smi
1733
+ return None
1734
+
1735
+
1736
  def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1737
+ """Every counter this machine has, composed into one meter.
1738
 
1739
+ THE OLD LADDER MEASURED THE WRONG COMPONENT AND SAID SO CONFIDENTLY.
 
 
1740
 
1741
+ It returned the first backend that worked, in an order that put CPU package
1742
+ counters above the GPU. On a worker running a 7B model on a graphics card
1743
+ that is close to the worst possible choice: RAPL sees the tokeniser and the
1744
+ sampling loop, the board does the arithmetic, and the run was reported as
1745
+ the energy of the part that did least of the work.
1746
+
1747
+ Now every component with a counter is read and the figures are summed. A
1748
+ machine with a GPU and readable CPU counters reports both; a headless CPU
1749
+ box reports its package; a machine with neither reports an absence with the
1750
+ reason, and never a number.
1751
+
1752
+ Zeus is tried first where it is installed because it wraps several vendors
1753
+ behind one interface and its authors maintain the per-vendor detail. It is
1754
+ optional, and its absence changes nothing: the backends below are this
1755
+ project's own and do not depend on it.
1756
  """
1757
 
1758
  operating_system = platform.system() if system is None else system
1759
+
1760
  if operating_system != "Windows":
1761
  # Upstream does not support Windows, so it is not attempted there
1762
+ # rather than failing on every start.
1763
  zeus = ZeusEnergyMeter()
1764
  if zeus.available:
1765
  return zeus
1766
+
1767
+ parts: list[EnergyMeter] = []
1768
+ missing: list[str] = []
1769
+
1770
+ gpu = _gpu_meter()
1771
+ if gpu is not None:
1772
+ parts.append(gpu)
1773
+
1774
+ cpu = _cpu_meter(operating_system)
1775
+ if cpu is not None:
1776
+ parts.append(cpu)
1777
+ else:
1778
+ missing.append(f"cpu package ({operating_system} counter unavailable)")
1779
+
1780
+ if not parts:
1781
+ # NO COUNTER MEANS NO FIGURE.
1782
+ #
1783
+ # There used to be a modelled meter here: busy core-seconds times a
1784
+ # hard-coded 12.5 watts per core, labelled "modelled". Two things were
1785
+ # wrong with it. The constant was invented, so the number it produced
1786
+ # was not a property of the machine it claimed to describe; and it read
1787
+ # /proc/stat, so on Windows -- the platform it was the fallback for --
1788
+ # it could never initialise at all and the ladder fell through it to
1789
+ # nothing. A figure nobody can reproduce is worse than an absence on a
1790
+ # site whose entire claim is measurement, so the absence is what is
1791
+ # reported, with the reason attached.
1792
+ reasons = "; ".join(
1793
+ reason
1794
+ for reason in (
1795
+ NvmlGpuEnergyMeter().unavailable_reason,
1796
+ _cpu_unavailable_reason(operating_system),
1797
+ )
1798
+ if reason
1799
+ )
1800
  return UnavailableEnergyMeter(
1801
+ reasons or f"no energy counter is readable on {operating_system}"
1802
  )
1803
+
1804
+ if len(parts) == 1 and not missing:
1805
+ return parts[0]
1806
+ return CompositeEnergyMeter(parts, missing=missing)
1807
+
1808
+
1809
+ def _cpu_unavailable_reason(operating_system: str) -> str | None:
1810
  if operating_system == "Linux":
1811
+ return PowercapRaplMeter().unavailable_reason
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1812
  if operating_system == "Windows":
1813
+ return WindowsEmiMeter().unavailable_reason
1814
+ if operating_system == "Darwin":
1815
+ return PowermetricsProbeMeter().unavailable_reason
1816
+ return f"no CPU energy counter is known for {operating_system}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1817
 
1818
 
1819
  def _check_token_owner(token: EnergyToken, meter_id: str) -> None:
distinct_server/comparators.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a number of joules actually amounts to, in things people have done.
2
+
3
+ WHY A SECOND TABLE, WHEN render.COMPARATORS ALREADY EXISTS
4
+
5
+ That one answers "how long would this run an appliance": *twelve seconds of a
6
+ 10 W bulb*. It is exact and it is nearly useless at the small end, because
7
+ nobody has an intuition for twelve seconds of a light. This table answers the
8
+ other question: "how much of a whole, familiar thing is this": *a tenth of a
9
+ cup of tea*. Those are the units people already own.
10
+
11
+ HOW THE NUMBERS WERE GOT
12
+
13
+ Every entry is either derived here from physics, or is a rated figure for a
14
+ common appliance run for a stated time. The working is in the note beside it so
15
+ a reader can check it rather than trust it, which is the same standard the rest
16
+ of this project applies to the figures it republishes.
17
+
18
+ Water is the recurring one, so it is worth stating once. Heating water takes
19
+ ``mass x 4186 J/kg/K x temperature rise``. A 250 ml mug from a 20 C tap to
20
+ 100 C is ``0.25 x 4186 x 80 = 83.7 kJ`` of heat into the water. A domestic
21
+ kettle puts roughly 80% of what it draws into the water, the rest going into
22
+ the element, the body and the steam, so the wall figure is about 105 kJ. Where
23
+ an entry says "in a kettle" it is the wall figure, because that is the
24
+ electricity somebody actually paid for.
25
+
26
+ Anything expressed in kWh is converted at 3.6 MJ per kWh.
27
+
28
+ WHAT IS DELIBERATELY NOT HERE
29
+
30
+ No CO2, no water footprint, no land, no money. Those are conversions with
31
+ assumptions of their own -- grid mix, time of day, jurisdiction -- and this
32
+ project does not make assessor-created estimates. A joule compared to another
33
+ joule needs no assumption at all, which is why every entry on this list is an
34
+ energy and nothing else.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+
41
+ KWH = 3_600_000.0
42
+ #: Specific heat capacity of water, J/(kg K).
43
+ WATER_SPECIFIC_HEAT = 4186.0
44
+ #: A domestic kettle delivers roughly this share of its draw into the water.
45
+ KETTLE_EFFICIENCY = 0.80
46
+ #: Standard gravity, m/s^2.
47
+ G = 9.81
48
+
49
+
50
+ def _heat_water(litres: float, rise_c: float, *, efficiency: float = 1.0) -> float:
51
+ return litres * WATER_SPECIFIC_HEAT * rise_c / efficiency
52
+
53
+
54
+ def _appliance(watts: float, seconds: float) -> float:
55
+ return watts * seconds
56
+
57
+
58
+ def _lift(kilograms: float, metres: float) -> float:
59
+ return kilograms * G * metres
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Comparator:
64
+ """One familiar thing, and what it costs."""
65
+
66
+ #: Reads after a number: "about 2.5 <label>".
67
+ label: str
68
+ #: Energy for exactly one of them, in joules.
69
+ joules: float
70
+ #: How that figure was arrived at.
71
+ note: str
72
+
73
+ #: Plural form where "s" will not do.
74
+ plural: str = ""
75
+
76
+ def name(self, count: float) -> str:
77
+ """Singular only when the number in front of it will print as "1".
78
+
79
+ Matched to :func:`_round_count` rather than to the raw float, because
80
+ "about 0.98 minute" is what a tolerance around 1.0 produces and it
81
+ reads as a mistake.
82
+ """
83
+
84
+ return self.label if _round_count(count) == "1" else (self.plural or self.label + "s")
85
+
86
+
87
+ #: Ordered by size. Roughly one entry every quarter-decade, so a picker can
88
+ #: nearly always find one that puts the figure between 0.1 and 100 of itself.
89
+ COMPARATORS: tuple[Comparator, ...] = (
90
+ # ---- sub-joule to 100 J: the scale of a short CPU-only run -----------
91
+ Comparator("heartbeat", 1.0, "about 1 J of mechanical work per beat at rest"),
92
+ Comparator("apple lifted one metre", 0.98, "0.1 kg x 9.81 m/s^2 x 1 m", "apples lifted one metre"),
93
+ Comparator("second of a smoke alarm", 1.0, "1 W standby draw for 1 s", "seconds of a smoke alarm"),
94
+ Comparator("phone screen-second", 2.0, "about 2 W for the panel, for 1 s", "phone screen-seconds"),
95
+ Comparator("teaspoon of water warmed by 1 C", 20.9, "0.005 kg x 4186 x 1", "teaspoons of water warmed by 1 C"),
96
+ Comparator("second of an LED bulb", 10.0, "10 W for 1 s", "seconds of an LED bulb"),
97
+ Comparator("second of a wifi router", 6.0, "6 W for 1 s", "seconds of a wifi router"),
98
+ Comparator("AA battery's worth of torch light", 15_120.0, "2.8 Ah x 1.5 V alkaline = 4.2 Wh"),
99
+ Comparator("stair step climbed", 132.0, "75 kg x 9.81 x 0.18 m riser", "stair steps climbed"),
100
+ Comparator("second of a laptop", 50.0, "50 W for 1 s", "seconds of a laptop"),
101
+ Comparator("press-up", 200.0, "about 200 J of work for an adult", "press-ups"),
102
+ Comparator("second of a desktop PC", 150.0, "150 W for 1 s", "seconds of a desktop PC"),
103
+ Comparator("minute of an LED bulb", 600.0, "10 W for 60 s", "minutes of an LED bulb"),
104
+ Comparator("second of a gaming GPU at full load", 350.0, "350 W for 1 s", "seconds of a gaming GPU at full load"),
105
+ Comparator("smartphone photo taken", 500.0, "about 0.5 kJ including capture and write", "smartphone photos taken"),
106
+ # ---- 1 kJ to 100 kJ: a normal LLM run --------------------------------
107
+ Comparator("second of a kettle", 3000.0, "3 kW element for 1 s", "seconds of a kettle"),
108
+ Comparator("minute of a wifi router", 360.0, "6 W for 60 s", "minutes of a wifi router"),
109
+ Comparator("minute of a laptop", 3000.0, "50 W for 60 s", "minutes of a laptop"),
110
+ Comparator("Google search", 1080.0, "0.3 Wh, Google's own published figure", "Google searches"),
111
+ Comparator("minute of a desktop PC", 9000.0, "150 W for 60 s", "minutes of a desktop PC"),
112
+ Comparator("litre of water warmed by 1 C", 4186.0, "1 kg x 4186 x 1", "litres of water warmed by 1 C"),
113
+ Comparator("minute of a 55-inch TV", 6000.0, "100 W for 60 s", "minutes of a 55-inch TV"),
114
+ Comparator("phone charged by 1%", 623.0, "4500 mAh x 3.85 V = 17.3 Wh, one hundredth", "% of a phone charge"),
115
+ Comparator("minute of a gaming GPU at full load", 21_000.0, "350 W for 60 s", "minutes of a gaming GPU at full load"),
116
+ Comparator("slice of toast", 96_000.0, "800 W toaster for 2 minutes", "slices of toast"),
117
+ Comparator("mug of tea boiled", 104_650.0, "0.25 l from 20 C to 100 C in a kettle at 80%", "mugs of tea boiled"),
118
+ Comparator("minute in a microwave", 60_000.0, "1 kW draw for 60 s", "minutes in a microwave"),
119
+ Comparator("egg boiled", 130_000.0, "hob heating 0.4 l to 100 C and holding it", "eggs boiled"),
120
+ Comparator("hour of a wifi router", 21_600.0, "6 W for 1 h", "hours of a wifi router"),
121
+ Comparator("minute of an electric shower", 510_000.0, "8.5 kW for 60 s", "minutes of an electric shower"),
122
+ Comparator("hour of an LED bulb", 36_000.0, "10 W for 1 h", "hours of an LED bulb"),
123
+ Comparator("hour of a laptop", 180_000.0, "50 W for 1 h", "hours of a laptop"),
124
+ Comparator("phone charged from flat", 62_300.0, "4500 mAh x 3.85 V = 17.3 Wh", "phones charged from flat"),
125
+ Comparator("pint of water boiled", 237_800.0, "0.568 l from 20 C to 100 C in a kettle at 80%", "pints of water boiled"),
126
+ Comparator("hour of a 55-inch TV", 360_000.0, "100 W for 1 h", "hours of a 55-inch TV"),
127
+ Comparator("piece of bread baked", 250_000.0, "oven share for one loaf's worth of a slice", "pieces of bread baked"),
128
+ # ---- 1 MJ to 100 MJ: a long session, or a machine left running -------
129
+ Comparator("hour of a desktop PC", 540_000.0, "150 W for 1 h", "hours of a desktop PC"),
130
+ Comparator("hour of a gaming GPU at full load", 1_260_000.0, "350 W for 1 h", "hours of a gaming GPU at full load"),
131
+ Comparator("hour of a fridge", 144_000.0, "40 W average duty for 1 h", "hours of a fridge"),
132
+ Comparator("load of washing", 2_520_000.0, "0.7 kWh, a 40 C cotton cycle", "loads of washing"),
133
+ Comparator("dishwasher cycle", 3_600_000.0, "1.0 kWh eco programme", "dishwasher cycles"),
134
+ Comparator("shower taken", 4_080_000.0, "8.5 kW electric shower for 8 minutes", "showers taken"),
135
+ Comparator("hour of an oven", 7_200_000.0, "2 kW for 1 h", "hours of an oven"),
136
+ Comparator("tumble dryer cycle", 9_000_000.0, "2.5 kWh, a condenser dryer", "tumble dryer cycles"),
137
+ Comparator("kilometre driven in an electric car", 648_000.0, "0.18 kWh/km", "kilometres driven in an electric car"),
138
+ Comparator("day of a fridge", 3_456_000.0, "40 W average duty for 24 h", "days of a fridge"),
139
+ Comparator("day of a laptop in constant use", 4_320_000.0, "50 W for 24 h", "days of a laptop in constant use"),
140
+ Comparator("bath run", 7_060_000.0, "150 l from 10 C to 40 C at 90% immersion efficiency", "baths run"),
141
+ Comparator("mile driven in an electric car", 1_043_000.0, "0.18 kWh/km over 1.609 km", "miles driven in an electric car"),
142
+ # ---- filling the band a run actually lands in ------------------------
143
+ Comparator("second of a kettle at half power", 1500.0, "1.5 kW for 1 s", "seconds of a kettle at half power"),
144
+ Comparator("second of a hair dryer", 1800.0, "1.8 kW for 1 s", "seconds of a hair dryer"),
145
+ Comparator("second of a microwave", 1000.0, "1 kW for 1 s", "seconds of a microwave"),
146
+ Comparator("second of a vacuum cleaner", 900.0, "900 W for 1 s", "seconds of a vacuum cleaner"),
147
+ Comparator("second of a 55-inch TV", 100.0, "100 W for 1 s", "seconds of a 55-inch TV"),
148
+ Comparator("second of a games console", 200.0, "200 W for 1 s", "seconds of a games console"),
149
+ Comparator("second of a fridge compressor", 120.0, "120 W while running, for 1 s", "seconds of a fridge compressor"),
150
+ Comparator("minute of a phone charging", 1038.0, "17.3 Wh over about 100 minutes", "minutes of a phone charging"),
151
+ Comparator("minute of a games console", 12_000.0, "200 W for 60 s", "minutes of a games console"),
152
+ Comparator("minute of a fridge compressor", 7200.0, "120 W for 60 s", "minutes of a fridge compressor"),
153
+ Comparator("minute of a hair dryer", 108_000.0, "1.8 kW for 60 s", "minutes of a hair dryer"),
154
+ Comparator("minute of a vacuum cleaner", 54_000.0, "900 W for 60 s", "minutes of a vacuum cleaner"),
155
+ Comparator("minute of an oven", 120_000.0, "2 kW for 60 s", "minutes of an oven"),
156
+ Comparator("minute of a tumble dryer", 150_000.0, "2.5 kW for 60 s", "minutes of a tumble dryer"),
157
+ Comparator("minute of a fan heater", 120_000.0, "2 kW for 60 s", "minutes of a fan heater"),
158
+ Comparator("espresso pulled", 45_000.0, "1.4 kW machine for about 32 s including pre-heat share", "espressos pulled"),
159
+ Comparator("cafetiere of coffee", 167_400.0, "0.4 l from 20 C to 100 C in a kettle at 80%", "cafetieres of coffee"),
160
+ Comparator("hot water bottle filled", 313_950.0, "0.75 l from 20 C to 100 C in a kettle at 80%", "hot water bottles filled"),
161
+ Comparator("pan of pasta water boiled", 837_200.0, "2 l from 20 C to 100 C in a kettle at 80%", "pans of pasta water boiled"),
162
+ Comparator("slice of pizza reheated", 90_000.0, "1 kW microwave for 90 s", "slices of pizza reheated"),
163
+ Comparator("piece of bread toasted lightly", 48_000.0, "800 W toaster for 60 s", "pieces of bread toasted lightly"),
164
+ Comparator("kettle boiled full", 627_900.0, "1.5 l from 20 C to 100 C in a kettle at 80%", "kettles boiled full"),
165
+ Comparator("laptop charged from flat", 216_000.0, "60 Wh battery", "laptops charged from flat"),
166
+ Comparator("tablet charged from flat", 118_800.0, "33 Wh battery", "tablets charged from flat"),
167
+ Comparator("e-bike battery charged", 1_800_000.0, "500 Wh battery", "e-bike batteries charged"),
168
+ Comparator("cordless drill battery charged", 194_400.0, "54 Wh pack", "cordless drill batteries charged"),
169
+ Comparator("hour of a games console", 720_000.0, "200 W for 1 h", "hours of a games console"),
170
+ Comparator("hour of a set-top box", 28_800.0, "8 W for 1 h", "hours of a set-top box"),
171
+ Comparator("hour of a phone charger left plugged in", 1800.0, "0.5 W vampire draw for 1 h", "hours of a phone charger left plugged in"),
172
+ Comparator("hour of an electric blanket", 360_000.0, "100 W for 1 h", "hours of an electric blanket"),
173
+ Comparator("hour of a dehumidifier", 1_080_000.0, "300 W for 1 h", "hours of a dehumidifier"),
174
+ Comparator("hour of a fan heater", 7_200_000.0, "2 kW for 1 h", "hours of a fan heater"),
175
+ Comparator("hour of a heat pump", 3_600_000.0, "1 kW electrical input for 1 h", "hours of a heat pump"),
176
+ Comparator("hour of a server in a rack", 1_800_000.0, "500 W for 1 h", "hours of a server in a rack"),
177
+ Comparator("day of a wifi router", 518_400.0, "6 W for 24 h", "days of a wifi router"),
178
+ Comparator("day of a smart speaker", 259_200.0, "3 W for 24 h", "days of a smart speaker"),
179
+ Comparator("day of a games console left on", 17_280_000.0, "200 W for 24 h", "days of a games console left on"),
180
+ Comparator("week of a fridge", 24_192_000.0, "40 W average duty for 7 days", "weeks of a fridge"),
181
+ Comparator("month of a wifi router", 15_768_000.0, "6 W for 30.4 days", "months of a wifi router"),
182
+ Comparator("10 km drive in an electric car", 6_480_000.0, "0.18 kWh/km x 10", "10 km drives in an electric car"),
183
+ Comparator("100 km drive in an electric car", 64_800_000.0, "0.18 kWh/km x 100", "100 km drives in an electric car"),
184
+ Comparator("litre of petrol burnt", 34_200_000.0, "9.5 kWh of chemical energy per litre", "litres of petrol burnt"),
185
+ Comparator("bag of coal burnt", 700_000_000.0, "25 kg at about 28 MJ/kg", "bags of coal burnt"),
186
+ Comparator("month of a UK home", 821_000_000.0, "2739 kWh a year, divided by twelve", "months of a UK home"),
187
+ Comparator("hour of a domestic solar array at noon", 14_400_000.0, "4 kWp array for 1 h", "hours of a domestic solar array at noon"),
188
+ Comparator("day of a domestic solar array", 43_200_000.0, "about 12 kWh on a good summer day", "days of a domestic solar array"),
189
+ Comparator("year of a fridge", 1_261_440_000.0, "40 W average duty for a year", "years of a fridge"),
190
+ Comparator("lifetime of a AAA battery", 5400.0, "1000 mAh x 1.5 V", "lifetimes of a AAA battery"),
191
+ Comparator("kilogram lifted to the top of Big Ben", 957.0, "1 kg x 9.81 x 96 m", "kilograms lifted to the top of Big Ben"),
192
+ # ---- 100 MJ upwards: fleets, and the honest top of the scale ---------
193
+ Comparator("day of a UK home", 27_000_000.0, "7.5 kWh, Ofgem typical domestic consumption", "days of a UK home"),
194
+ Comparator("week of a UK home", 189_000_000.0, "7.5 kWh x 7", "weeks of a UK home"),
195
+ Comparator("full charge of an electric car", 216_000_000.0, "60 kWh battery", "full charges of an electric car"),
196
+ Comparator("year of a UK home", 9_860_000_000.0, "2739 kWh, Ofgem typical annual electricity", "years of a UK home"),
197
+ )
198
+
199
+ #: The comparator picker aims to put the figure inside this band, so the number
200
+ #: in front of the label is one a person can picture. Below 0.1 the answer is
201
+ #: "hardly any of one", above 100 it stops being a comparison.
202
+ NICE_LOW = 0.1
203
+ NICE_HIGH = 100.0
204
+
205
+
206
+ def choose(joules: float, *, table: tuple[Comparator, ...] = COMPARATORS) -> tuple[Comparator, float] | None:
207
+ """The comparator that puts this figure closest to one whole unit.
208
+
209
+ Closest in log space, because 0.5 of a thing and 2 of a thing are equally
210
+ easy to picture and a linear distance would prefer the wrong one of them.
211
+ """
212
+
213
+ if not isinstance(joules, (int, float)) or isinstance(joules, bool):
214
+ return None
215
+ value = float(joules)
216
+ if value <= 0 or value != value or value in (float("inf"), float("-inf")):
217
+ return None
218
+ import math
219
+
220
+ best: tuple[Comparator, float] | None = None
221
+ best_distance = float("inf")
222
+ for entry in table:
223
+ if entry.joules <= 0:
224
+ continue
225
+ count = value / entry.joules
226
+ distance = abs(math.log10(count))
227
+ if distance < best_distance:
228
+ best, best_distance = (entry, count), distance
229
+ return best
230
+
231
+
232
+ def _round_count(count: float) -> str:
233
+ if count >= 100:
234
+ return f"{count:,.0f}"
235
+ if count >= 10:
236
+ return f"{count:.0f}"
237
+ if count >= 1:
238
+ return f"{count:.1f}".rstrip("0").rstrip(".")
239
+ if count >= 0.1:
240
+ return f"{count:.2f}".rstrip("0").rstrip(".")
241
+ return f"{count:.3f}".rstrip("0").rstrip(".")
242
+
243
+
244
+ def describe(joules: float) -> str:
245
+ """"about 1.4 mugs of tea boiled", or an empty string if there is nothing to say."""
246
+
247
+ chosen = choose(joules)
248
+ if chosen is None:
249
+ return ""
250
+ entry, count = chosen
251
+ if count < 0.01:
252
+ return f"a tiny fraction of {_article(entry.label)}"
253
+ return f"about {_round_count(count)} {entry.name(count)}"
254
+
255
+
256
+ def _article(label: str) -> str:
257
+ return ("an " if label[:1].lower() in "aeiou" else "a ") + label
258
+
259
+
260
+ def basis(joules: float) -> str:
261
+ """The working behind whichever comparator was shown."""
262
+
263
+ chosen = choose(joules)
264
+ return "" if chosen is None else chosen[0].note
distinct_server/presentation.py CHANGED
@@ -826,6 +826,11 @@ footer,.gradio-container footer{display:none !important;}
826
  .c-usage--absent .c-usage__figure{color:var(--c-muted) !important; font-size:18px;}
827
  .c-usage__means{margin:2px 0 0; font-size:var(--t-micro); color:var(--c-sage-dark) !important;
828
  line-height:1.45;}
 
 
 
 
 
829
  .c-usage--absent .c-usage__means{color:var(--c-muted) !important; font-style:italic;}
830
  .c-usage__detail{margin:3px 0 0; font-size:11px; color:var(--c-muted) !important; line-height:1.45;}
831
  .c-usage__more{margin-top:var(--s3); border-top:1px solid var(--c-hairline) !important;
 
826
  .c-usage--absent .c-usage__figure{color:var(--c-muted) !important; font-size:18px;}
827
  .c-usage__means{margin:2px 0 0; font-size:var(--t-micro); color:var(--c-sage-dark) !important;
828
  line-height:1.45;}
829
+ /* The exact span sits under the familiar comparison, quieter than it: the
830
+ first line is what a reader takes away, the second is what they check it
831
+ against. */
832
+ .c-usage__span{margin:0; font-size:var(--t-micro); color:var(--c-muted) !important;
833
+ line-height:1.45;}
834
  .c-usage--absent .c-usage__means{color:var(--c-muted) !important; font-style:italic;}
835
  .c-usage__detail{margin:3px 0 0; font-size:11px; color:var(--c-muted) !important; line-height:1.45;}
836
  .c-usage__more{margin-top:var(--s3); border-top:1px solid var(--c-hairline) !important;
distinct_server/render.py CHANGED
@@ -36,6 +36,8 @@ from html import escape
36
  from pathlib import Path
37
  from typing import Optional
38
 
 
 
39
  __all__ = [
40
  "activity",
41
  "assessment_legend",
@@ -978,8 +980,21 @@ def energy_headline(
978
  f'<p class="c-usage__means">{esc(absent_reason or "not measured, and not counted as zero")}</p>'
979
  "</div>"
980
  )
981
- means = comparator(joules)
982
- means_html = f'<p class="c-usage__means">{esc(means)}</p>' if means else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  detail_html = f'<p class="c-usage__detail">{esc(detail)}</p>' if detail else ""
984
  return (
985
  '<div class="c-usage">'
@@ -1005,11 +1020,19 @@ def format_joules_per_token(value: float) -> str:
1005
  def format_joules(joules: float) -> str:
1006
  """A figure with a unit people read without converting.
1007
 
1008
- Joules below a kilojoule, kilojoules above. Watt-hours are not used as the
1009
- headline: a run costs hundredths of one, and a number that starts 0.00 is
1010
- read as zero however it is labelled.
 
 
 
 
1011
  """
1012
 
 
 
 
 
1013
  if joules >= 1_000:
1014
  return f"{joules / 1_000:.2f} kJ"
1015
  if joules >= 10:
 
36
  from pathlib import Path
37
  from typing import Optional
38
 
39
+ from . import comparators as _comparators
40
+
41
  __all__ = [
42
  "activity",
43
  "assessment_legend",
 
980
  f'<p class="c-usage__means">{esc(absent_reason or "not measured, and not counted as zero")}</p>'
981
  "</div>"
982
  )
983
+ # TWO COMPARISONS, BECAUSE THEY ANSWER DIFFERENT QUESTIONS.
984
+ #
985
+ # "about 1.4 mugs of tea boiled" is the one people actually read: a whole
986
+ # familiar thing, and how much of it this is. "about 12 seconds of a 10 W
987
+ # bulb" is exact and stays underneath it, because for very small figures a
988
+ # fraction of a mug of tea means nothing and a span of time still does.
989
+ equals = _comparators.describe(joules)
990
+ span = comparator(joules)
991
+ means_html = f'<p class="c-usage__means">{esc(equals)}</p>' if equals else ""
992
+ if span and span != equals:
993
+ basis = _comparators.basis(joules)
994
+ # The working travels with the comparison. Somebody who wants to check
995
+ # "one mug of tea is 105 kJ" can, without leaving the page.
996
+ title = f' title="{esc(basis)}"' if basis else ""
997
+ means_html += f'<p class="c-usage__span"{title}>{esc(span)}</p>'
998
  detail_html = f'<p class="c-usage__detail">{esc(detail)}</p>' if detail else ""
999
  return (
1000
  '<div class="c-usage">'
 
1020
  def format_joules(joules: float) -> str:
1021
  """A figure with a unit people read without converting.
1022
 
1023
+ Every three decades gets its own prefix. This used to stop at kilojoules,
1024
+ so a worker's lifetime total rendered as "870000.00 kJ" -- six digits and a
1025
+ prefix, which is the exact thing a prefix exists to prevent. A session
1026
+ figure and a fleet figure are decades apart and both have to be readable.
1027
+
1028
+ Watt-hours are not used as the headline: one run costs hundredths of one,
1029
+ and a number that starts 0.00 reads as zero however it is labelled.
1030
  """
1031
 
1032
+ if joules >= 1_000_000_000:
1033
+ return f"{joules / 1_000_000_000:.2f} GJ"
1034
+ if joules >= 1_000_000:
1035
+ return f"{joules / 1_000_000:.2f} MJ"
1036
  if joules >= 1_000:
1037
  return f"{joules / 1_000:.2f} kJ"
1038
  if joules >= 10:
distinct_server/ui.py CHANGED
@@ -741,16 +741,30 @@ def _measurement_note(readings: Sequence[EnergyReading]) -> str:
741
  if providers:
742
  parts.append("Measured by: " + ", ".join(providers) + ".")
743
  parts.append(
744
- "Measured figures come from hardware counters: Intel RAPL, NVIDIA NVML "
745
- "or nvidia-smi, Apple silicon package counters. Modelled figures come "
746
- "from CPU busy-time and say so in their scope. The two are never added "
747
- "together, because different scopes answer different questions."
 
 
 
 
 
 
 
 
748
  )
749
  parts.append(
750
  "Where the runner reported real token counts, each answer also shows "
751
  "joules per generated token. Token counts are never estimated from "
752
  "characters."
753
  )
 
 
 
 
 
 
754
  parts.append(
755
  "Agent-mode runs cost more than generation alone: tool calls and the "
756
  "safety screen are separate passes, and the guard's cost is recorded "
 
741
  if providers:
742
  parts.append("Measured by: " + ", ".join(providers) + ".")
743
  parts.append(
744
+ "Every figure is a hardware counter reading. There is no model and no "
745
+ "estimate: a component either has a counter the worker can read, or it "
746
+ "is named as absent and no number is invented for it."
747
+ )
748
+ parts.append(
749
+ "On an NVIDIA GPU the driver keeps a running energy register in "
750
+ "millijoules (NVML, Volta and later), so a run's energy is the exact "
751
+ "difference between two reads, with no sampling. Older boards integrate "
752
+ "board power on a 50 ms timer instead and say so. CPU package energy "
753
+ "comes from RAPL: the powercap interface on Linux, and on Windows the "
754
+ "Energy Meter counter read through PDH. Where a machine has both, both "
755
+ "are read over the same window and added."
756
  )
757
  parts.append(
758
  "Where the runner reported real token counts, each answer also shows "
759
  "joules per generated token. Token counts are never estimated from "
760
  "characters."
761
  )
762
+ parts.append(
763
+ "What no counter can see is not guessed at: power-supply loss, fans, "
764
+ "storage and the mainboard are real electricity that no multiplier is "
765
+ "applied to reach. The figure is therefore a floor for the machine's "
766
+ "true draw, covering exactly the components named in the scope."
767
+ )
768
  parts.append(
769
  "Agent-mode runs cost more than generation alone: tool calls and the "
770
  "safety screen are separate passes, and the guard's cost is recorded "
pyproject.toml CHANGED
@@ -27,6 +27,14 @@ dependencies = [
27
  "cryptography>=42",
28
  "gradio_client==2.6.0",
29
  "pyRAPL==0.2.3.1; sys_platform == 'linux'",
 
 
 
 
 
 
 
 
30
  # The worker's own screen. Pinned rather than ranged because the frozen
31
  # build has to be reproducible, and because a Textual minor release can
32
  # change how a widget lays out, which is a visual regression no test in
 
27
  "cryptography>=42",
28
  "gradio_client==2.6.0",
29
  "pyRAPL==0.2.3.1; sys_platform == 'linux'",
30
+ # NVIDIA GPU energy. For inference on a graphics card this is nearly all of
31
+ # the energy a run costs, so it is a base dependency rather than an extra:
32
+ # a worker that silently measured only its CPU package would understate a
33
+ # GPU run by roughly an order of magnitude while still calling the figure
34
+ # measured. The library is a thin ctypes binding to the driver's own NVML
35
+ # and installs on machines with no NVIDIA hardware, where it simply reports
36
+ # that there is none.
37
+ "nvidia-ml-py>=12.535",
38
  # The worker's own screen. Pinned rather than ranged because the frozen
39
  # build has to be reproducible, and because a Textual minor release can
40
  # change how a widget lays out, which is a visual regression no test in
tests/test_comparators.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The comparison under a figure has to be right, or it undoes the figure.
2
+
3
+ A wrong comparator is worse than none: the number above it is measured, and a
4
+ reader who catches the comparison being wrong has no reason to trust the
5
+ measurement either.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import pytest
13
+
14
+ from distinct_server.comparators import (
15
+ COMPARATORS,
16
+ KETTLE_EFFICIENCY,
17
+ KWH,
18
+ WATER_SPECIFIC_HEAT,
19
+ basis,
20
+ choose,
21
+ describe,
22
+ )
23
+
24
+
25
+ def test_there_are_about_a_hundred_of_them() -> None:
26
+ assert len(COMPARATORS) >= 100
27
+
28
+
29
+ def test_every_entry_states_its_working() -> None:
30
+ for entry in COMPARATORS:
31
+ assert entry.note.strip(), entry.label
32
+ assert entry.joules > 0, entry.label
33
+ assert math.isfinite(entry.joules), entry.label
34
+
35
+
36
+ def test_labels_are_unique() -> None:
37
+ labels = [entry.label for entry in COMPARATORS]
38
+ assert len(labels) == len(set(labels))
39
+
40
+
41
+ def test_the_physics_is_the_physics() -> None:
42
+ """Spot-check the derived entries against the formula in the docstring."""
43
+
44
+ by_label = {entry.label: entry.joules for entry in COMPARATORS}
45
+
46
+ # 0.25 l from 20 C to 100 C, in a kettle at 80%.
47
+ expected = 0.25 * WATER_SPECIFIC_HEAT * 80 / KETTLE_EFFICIENCY
48
+ assert by_label["mug of tea boiled"] == pytest.approx(expected, rel=0.01)
49
+
50
+ # 0.568 l (a pint) over the same rise.
51
+ expected = 0.568 * WATER_SPECIFIC_HEAT * 80 / KETTLE_EFFICIENCY
52
+ assert by_label["pint of water boiled"] == pytest.approx(expected, rel=0.01)
53
+
54
+ # 1 kg of water, 1 degree.
55
+ assert by_label["litre of water warmed by 1 C"] == pytest.approx(WATER_SPECIFIC_HEAT, rel=0.001)
56
+
57
+ # A 100 g apple lifted a metre.
58
+ assert by_label["apple lifted one metre"] == pytest.approx(0.1 * 9.81, rel=0.01)
59
+
60
+ # Rated appliances are watts times seconds.
61
+ assert by_label["minute in a microwave"] == pytest.approx(1000 * 60)
62
+ assert by_label["hour of a laptop"] == pytest.approx(50 * 3600)
63
+ assert by_label["load of washing"] == pytest.approx(0.7 * KWH, rel=0.001)
64
+ assert by_label["day of a UK home"] == pytest.approx(7.5 * KWH, rel=0.001)
65
+
66
+
67
+ def test_a_figure_always_finds_a_comparison_it_can_carry() -> None:
68
+ """Across nine decades, the count in front of the label stays picturable."""
69
+
70
+ value = 1.0
71
+ while value <= 1e10:
72
+ chosen = choose(value)
73
+ assert chosen is not None, value
74
+ _, count = chosen
75
+ assert 0.05 <= count <= 20, (value, count)
76
+ value *= 3.0
77
+
78
+
79
+ def test_the_chosen_comparison_is_the_closest_one() -> None:
80
+ entry, count = choose(104_650.0)
81
+ assert entry.label == "mug of tea boiled"
82
+ assert count == pytest.approx(1.0, rel=0.01)
83
+
84
+
85
+ def test_singular_only_when_it_reads_as_one() -> None:
86
+ """"about 0.98 minute" reads as a mistake, so the rule follows the printed
87
+ number rather than a tolerance around 1.0."""
88
+
89
+ entry = next(e for e in COMPARATORS if e.label == "mug of tea boiled")
90
+ assert entry.name(1.0) == "mug of tea boiled"
91
+ assert entry.name(1.04) == "mug of tea boiled"
92
+ assert entry.name(0.98) == "mugs of tea boiled"
93
+ assert entry.name(2.0) == "mugs of tea boiled"
94
+ assert entry.name(0.4) == "mugs of tea boiled"
95
+ assert describe(104_650.0) == "about 1 mug of tea boiled"
96
+
97
+
98
+ def test_nothing_sensible_to_say_says_nothing() -> None:
99
+ for bad in (0, -1, None, float("nan"), float("inf"), True, "12"):
100
+ assert describe(bad) == "" # type: ignore[arg-type]
101
+ assert choose(bad) is None # type: ignore[arg-type]
102
+
103
+
104
+ def test_the_working_travels_with_the_comparison() -> None:
105
+ assert "4186" in basis(4186.0) or "kg" in basis(4186.0)
106
+ assert basis(104_650.0)
107
+
108
+
109
+ def test_no_conversion_this_project_refuses_to_make() -> None:
110
+ """No CO2, water, land or money: those need assumptions a joule does not."""
111
+
112
+ forbidden = ("co2", "carbon", "gco2", "water footprint", "litres of water used", "£", "$")
113
+ for entry in COMPARATORS:
114
+ blob = f"{entry.label} {entry.note}".lower()
115
+ for word in forbidden:
116
+ assert word not in blob, (entry.label, word)
tests/test_energy_measurement.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The energy figure is the product, so these are the tests that matter most.
2
+
3
+ Two properties are load-bearing and everything else follows from them:
4
+
5
+ * a reported figure is a hardware counter reading, never a model, and
6
+ * a component that could not be read is named rather than silently dropped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+
13
+ import pytest
14
+
15
+ from distinct_agent.energy import (
16
+ CompositeEnergyMeter,
17
+ EnergyUsage,
18
+ NvmlGpuEnergyMeter,
19
+ UnavailableEnergyMeter,
20
+ detect_energy_meter,
21
+ )
22
+
23
+
24
+ class FakeNvml:
25
+ """An NVML that behaves like the real one, including its awkward parts."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ devices: int = 1,
31
+ energy_counter: bool = True,
32
+ energy_series: list[float] | None = None,
33
+ power_watts: float = 100.0,
34
+ names: list[bytes] | None = None,
35
+ ) -> None:
36
+ self.devices = devices
37
+ self.energy_counter = energy_counter
38
+ # Millijoules, as NVML reports them.
39
+ self._energy = itertools.count(0, 1_000_000) if energy_series is None else iter(energy_series)
40
+ self._fixed: list[float] | None = energy_series
41
+ self.power_watts = power_watts
42
+ self.names = names or [b"NVIDIA GeForce RTX 4090"] * devices
43
+ self.started = False
44
+ self.shutdown_called = False
45
+
46
+ def nvmlInit(self): # noqa: N802
47
+ self.started = True
48
+
49
+ def nvmlShutdown(self): # noqa: N802
50
+ self.shutdown_called = True
51
+
52
+ def nvmlDeviceGetCount(self): # noqa: N802
53
+ return self.devices
54
+
55
+ def nvmlDeviceGetHandleByIndex(self, index): # noqa: N802
56
+ return f"handle-{index}"
57
+
58
+ def nvmlDeviceGetName(self, handle): # noqa: N802
59
+ return self.names[int(str(handle).rsplit("-", 1)[1])]
60
+
61
+ def nvmlDeviceGetTotalEnergyConsumption(self, handle): # noqa: N802
62
+ if not self.energy_counter:
63
+ raise RuntimeError("Not Supported")
64
+ return next(self._energy)
65
+
66
+ def nvmlDeviceGetPowerUsage(self, handle): # noqa: N802
67
+ return int(self.power_watts * 1000)
68
+
69
+
70
+ def test_the_energy_counter_is_an_exact_difference() -> None:
71
+ """Volta and later expose an integrating register; use it, do not sample."""
72
+
73
+ # Two reads per call (probe at construction, then start, then stop).
74
+ nvml = FakeNvml(energy_series=[0, 5_000, 12_000])
75
+ meter = NvmlGpuEnergyMeter(nvml=nvml, monotonic=iter([0.0, 2.0]).__next__)
76
+ assert meter.available
77
+ assert "energy-counter" in meter.scope
78
+
79
+ token = meter.start()
80
+ usage = meter.stop(token)
81
+ assert usage.available
82
+ # 12 J - 5 J, in joules, from millijoules.
83
+ assert usage.joules == pytest.approx(7.0)
84
+ assert usage.average_watts == pytest.approx(3.5)
85
+
86
+
87
+ def test_a_counter_that_went_backwards_is_refused() -> None:
88
+ """A driver reload resets the register. That is not negative energy."""
89
+
90
+ nvml = FakeNvml(energy_series=[0, 9_000, 10])
91
+ meter = NvmlGpuEnergyMeter(nvml=nvml, monotonic=iter([0.0, 1.0]).__next__)
92
+ usage = meter.stop(meter.start())
93
+ assert not usage.available
94
+ assert usage.joules is None
95
+ assert "reset" in (usage.reason or "")
96
+
97
+
98
+ def test_every_board_is_summed() -> None:
99
+ """A model split across two cards spent the energy of both."""
100
+
101
+ nvml = FakeNvml(devices=2, energy_series=[0, 0, 1_000, 1_000, 3_000, 4_000])
102
+ meter = NvmlGpuEnergyMeter(nvml=nvml, monotonic=iter([0.0, 1.0]).__next__)
103
+ assert "2 boards" in meter.scope
104
+ usage = meter.stop(meter.start())
105
+ # (3 - 1) + (4 - 1)
106
+ assert usage.joules == pytest.approx(5.0)
107
+
108
+
109
+ def test_a_pre_volta_board_samples_power_and_says_so() -> None:
110
+ nvml = FakeNvml(energy_counter=False, power_watts=60.0)
111
+ meter = NvmlGpuEnergyMeter(nvml=nvml, sample_interval=0.01)
112
+ assert meter.available
113
+ assert "power-sampled" in meter.scope
114
+ import time as _time
115
+
116
+ token = meter.start()
117
+ _time.sleep(0.25)
118
+ usage = meter.stop(token)
119
+ assert usage.available
120
+ # 60 W for about a quarter second. Generous bounds: this is a real thread.
121
+ assert 6.0 < usage.joules < 25.0
122
+
123
+
124
+ def test_no_nvidia_hardware_is_an_absence_with_a_reason() -> None:
125
+ class NoDevices(FakeNvml):
126
+ def nvmlDeviceGetCount(self): # noqa: N802
127
+ return 0
128
+
129
+ meter = NvmlGpuEnergyMeter(nvml=NoDevices())
130
+ assert not meter.available
131
+ assert "no NVIDIA GPU" in (meter.unavailable_reason or "")
132
+
133
+
134
+ def test_a_missing_library_never_raises() -> None:
135
+ """A worker without nvidia-ml-py must still start."""
136
+
137
+ meter = NvmlGpuEnergyMeter(nvml=None)
138
+ assert isinstance(meter.available, bool)
139
+ if not meter.available:
140
+ assert meter.unavailable_reason
141
+
142
+
143
+ # -- composition ------------------------------------------------------------
144
+
145
+
146
+ class StubMeter:
147
+ def __init__(self, provider: str, scope: str, joules: float | None, available: bool = True):
148
+ self.provider = provider
149
+ self.scope = scope
150
+ self.available = available
151
+ self.unavailable_reason = None if available else "stub is off"
152
+ self._joules = joules
153
+ self._id = provider
154
+
155
+ def start(self):
156
+ from distinct_agent.energy import EnergyToken
157
+
158
+ return EnergyToken(self._id, self.provider, self.scope, 0.0, self.available)
159
+
160
+ def stop(self, token):
161
+ if self._joules is None:
162
+ return EnergyUsage(self.provider, self.scope, False, 1.0, None, None, "no reading")
163
+ return EnergyUsage(self.provider, self.scope, True, 1.0, self._joules, self._joules)
164
+
165
+
166
+ def test_components_are_summed_and_both_named() -> None:
167
+ meter = CompositeEnergyMeter(
168
+ [StubMeter("nvidia-nvml", "gpu-nvidia-energy-counter", 300.0),
169
+ StubMeter("rapl-powercap", "cpu-package", 40.0)],
170
+ monotonic=iter([0.0, 2.0]).__next__,
171
+ )
172
+ usage = meter.stop(meter.start())
173
+ assert usage.joules == pytest.approx(340.0)
174
+ assert "gpu-nvidia-energy-counter" in usage.scope
175
+ assert "cpu-package" in usage.scope
176
+ assert usage.average_watts == pytest.approx(170.0)
177
+
178
+
179
+ def test_a_silent_component_is_named_not_dropped() -> None:
180
+ """A total missing its GPU must be distinguishable from one that had none."""
181
+
182
+ meter = CompositeEnergyMeter(
183
+ [StubMeter("nvidia-nvml", "gpu", None), StubMeter("rapl", "cpu-package", 40.0)],
184
+ monotonic=iter([0.0, 1.0]).__next__,
185
+ )
186
+ usage = meter.stop(meter.start())
187
+ assert usage.available
188
+ assert usage.joules == pytest.approx(40.0)
189
+ assert "nvidia-nvml" in (usage.reason or "")
190
+
191
+
192
+ def test_a_component_with_no_counter_at_all_is_recorded_as_missing() -> None:
193
+ meter = CompositeEnergyMeter(
194
+ [StubMeter("nvidia-nvml", "gpu", 100.0)],
195
+ missing=["cpu package (Windows counter unavailable)"],
196
+ monotonic=iter([0.0, 1.0]).__next__,
197
+ )
198
+ usage = meter.stop(meter.start())
199
+ assert "cpu package" in (usage.reason or "")
200
+
201
+
202
+ def test_no_component_reporting_is_an_absence_never_a_zero() -> None:
203
+ meter = CompositeEnergyMeter(
204
+ [StubMeter("a", "x", None), StubMeter("b", "y", None)],
205
+ monotonic=iter([0.0, 1.0]).__next__,
206
+ )
207
+ usage = meter.stop(meter.start())
208
+ assert not usage.available
209
+ assert usage.joules is None
210
+
211
+
212
+ def test_nothing_available_is_an_absence_with_a_reason() -> None:
213
+ meter = CompositeEnergyMeter([], monotonic=iter([0.0, 1.0]).__next__)
214
+ assert not meter.available
215
+ usage = meter.stop(meter.start())
216
+ assert usage.joules is None
217
+ assert usage.reason
218
+
219
+
220
+ # -- the ladder itself ------------------------------------------------------
221
+
222
+
223
+ def test_an_unknown_platform_measures_nothing_rather_than_guessing() -> None:
224
+ meter = detect_energy_meter(system="Haiku")
225
+ assert isinstance(meter, UnavailableEnergyMeter)
226
+ assert not meter.available
227
+ usage = meter.stop(meter.start())
228
+ assert usage.joules is None
229
+
230
+
231
+ def test_there_is_no_modelled_backend_left() -> None:
232
+ """The 12.5 W/core constant must not come back by any route."""
233
+
234
+ import distinct_agent.energy as energy
235
+
236
+ assert not hasattr(energy, "CpuLoadModelMeter")
237
+ assert not hasattr(energy, "DEFAULT_MODEL_WATTS_PER_BUSY_CORE")
238
+ source = __import__("pathlib").Path(energy.__file__).read_text()
239
+ # The constant may appear only in prose explaining why it is gone, never
240
+ # in an expression that could produce a figure.
241
+ assert "watts_per_busy_core" not in source
242
+ # The number may survive only in the prose explaining its removal, never
243
+ # anywhere it could be multiplied by anything.
244
+ for line in source.splitlines():
245
+ if "12.5" in line:
246
+ assert line.lstrip().startswith("#"), line
247
+
248
+
249
+ def test_a_reported_figure_is_never_zero_for_an_unmeasured_window() -> None:
250
+ for meter in (UnavailableEnergyMeter("no counter"), CompositeEnergyMeter([])):
251
+ usage = meter.stop(meter.start())
252
+ assert usage.joules is None, "an absence must never render as 0 J"