Measure GPU and CPU energy from hardware counters and sum them; drop the modelled fallback
Browse files- distinct_agent/energy.py +683 -194
- distinct_server/comparators.py +264 -0
- distinct_server/presentation.py +5 -0
- distinct_server/render.py +28 -5
- distinct_server/ui.py +18 -4
- pyproject.toml +8 -0
- tests/test_comparators.py +116 -0
- tests/test_energy_measurement.py +252 -0
distinct_agent/energy.py
CHANGED
|
@@ -1,46 +1,59 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 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 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 27 |
-
* Even with a rebuilt kernel, Hyper-V does not pass the RAPL energy MSRs
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 771 |
-
#
|
| 772 |
-
#
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
|
| 777 |
-
|
| 778 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
during the window, multiplied by a per-core power figure the operator can
|
| 784 |
-
override with ``DISTINCT_MODEL_WATTS_PER_CORE``.
|
| 785 |
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
"""
|
| 792 |
|
| 793 |
-
provider = "
|
| 794 |
-
scope = "system-cpu-modelled"
|
| 795 |
|
| 796 |
def __init__(
|
| 797 |
self,
|
| 798 |
*,
|
| 799 |
-
|
| 800 |
monotonic: Callable[[], float] = time.monotonic,
|
| 801 |
-
|
| 802 |
) -> None:
|
| 803 |
-
import os
|
| 804 |
-
|
| 805 |
self._monotonic = monotonic
|
|
|
|
| 806 |
self._meter_id = uuid.uuid4().hex
|
| 807 |
-
self.
|
| 808 |
-
|
| 809 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 810 |
try:
|
| 811 |
-
|
| 812 |
-
except
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
self._available = True
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
| 820 |
try:
|
| 821 |
-
|
| 822 |
-
except Exception
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 851 |
-
|
| 852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 869 |
return _unavailable_usage(token, duration)
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
self.
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
average = joules / duration if duration > 0 else None
|
| 885 |
-
return EnergyUsage(
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 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 |
-
"""
|
| 1251 |
|
| 1252 |
-
|
| 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 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1267 |
zeus = ZeusEnergyMeter()
|
| 1268 |
if zeus.available:
|
| 1269 |
return zeus
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
return UnavailableEnergyMeter(
|
| 1282 |
-
|
| 1283 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1284 |
if operating_system == "Linux":
|
| 1285 |
-
|
| 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 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 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 |
-
|
| 982 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1009 |
-
|
| 1010 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 745 |
-
"
|
| 746 |
-
"
|
| 747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|