giul 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
giul/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """giul — one ruler for what an answer cost in joules.
2
+
3
+ *giúl*, Irish for joule. Aigne, Tuiscint and Gléas each need to say what a
4
+ request cost in energy; two rulers make a comparison an argument instead of
5
+ evidence, so there is one, and all three pin it.
6
+
7
+ from giul import Meter, Node
8
+
9
+ node = Node(name="iris", gpu_index=0, idle_w=38.0, is_local=True)
10
+ async with Meter.for_node(node, joules_per_1k_hint=4100.0) as m:
11
+ await retrieve(); m.mark("retrieve")
12
+ await generate(); m.mark("generate")
13
+ e = m.result(tokens=412)
14
+
15
+ Three rules the library will not bend:
16
+
17
+ 1. A request is charged above idle — for the power it caused, not the power
18
+ the box burns existing. ``idle_w`` comes from the caller; giul never
19
+ guesses it.
20
+ 2. A window that measured <= 0 J above idle failed to measure a request; it
21
+ did not measure a free one. It degrades to ``estimated`` or ``unknown``,
22
+ never a ``sampled`` zero.
23
+ 3. Estimates are labelled as estimates. A sampler that cannot be reached
24
+ costs a measurement, never the request.
25
+ """
26
+
27
+ from .backends import NvmlCounter, RemoteAgent, SmiSampler
28
+ from .meter import Energy, EnergyMeter, Meter, Node
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "Energy",
34
+ "EnergyMeter",
35
+ "Meter",
36
+ "Node",
37
+ "NvmlCounter",
38
+ "RemoteAgent",
39
+ "SmiSampler",
40
+ "__version__",
41
+ ]
giul/agent.py ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """Expose a node's GPU power draw over HTTP, so giul can meter remote tiers.
3
+
4
+ `nvidia-smi` only sees the card in the machine it runs on, which meant every
5
+ node except the gateway's own reported "energy not measured here". That makes
6
+ the fleet's headline metric a single-node metric. This closes it.
7
+
8
+ Deliberately stdlib-only and dependency-free: it has to run on any fleet node
9
+ without provisioning a venv, and it must not be able to break a serving node.
10
+ It reads power and nothing else — no control surface, no writes.
11
+
12
+ giul-agent --port 9402
13
+ curl localhost:9402/power?gpu=1 -> {"watts": 231.4, "gpu": 1, ...}
14
+
15
+ Bind to the Féith mesh address, not 0.0.0.0, unless the node is otherwise
16
+ firewalled.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import subprocess
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+ from urllib.parse import parse_qs, urlparse
26
+
27
+ SMI = "nvidia-smi"
28
+ QUERY_TIMEOUT_S = 5.0
29
+
30
+
31
+ def read_gpu(index: int) -> dict:
32
+ """Instantaneous board draw for one GPU, plus enough to identify it.
33
+
34
+ The name is included so a caller can assert it is metering the card it
35
+ thinks it is — a wrong `gpu_index` otherwise silently reports an idle
36
+ neighbour, which reads as "this model is astonishingly efficient".
37
+ """
38
+ out = subprocess.run(
39
+ [SMI, f"--id={index}",
40
+ "--query-gpu=name,power.draw,memory.used,utilization.gpu",
41
+ "--format=csv,noheader,nounits"],
42
+ capture_output=True, text=True, timeout=QUERY_TIMEOUT_S, check=True,
43
+ ).stdout.strip()
44
+ name, watts, mem, util = (p.strip() for p in out.split(",", 3))
45
+ return {
46
+ "gpu": index,
47
+ "name": name,
48
+ "watts": float(watts),
49
+ "memory_used_mib": int(float(mem)),
50
+ "utilization_pct": int(float(util)),
51
+ }
52
+
53
+
54
+ class Handler(BaseHTTPRequestHandler):
55
+ default_gpu = 0
56
+
57
+ def _send(self, code: int, payload: dict) -> None:
58
+ body = json.dumps(payload).encode()
59
+ self.send_response(code)
60
+ self.send_header("Content-Type", "application/json")
61
+ self.send_header("Content-Length", str(len(body)))
62
+ self.end_headers()
63
+ self.wfile.write(body)
64
+
65
+ def do_GET(self) -> None: # noqa: N802 (stdlib naming)
66
+ url = urlparse(self.path)
67
+ if url.path not in ("/power", "/healthz"):
68
+ return self._send(404, {"error": "try /power or /healthz"})
69
+
70
+ params = parse_qs(url.query)
71
+ try:
72
+ index = int(params.get("gpu", [self.default_gpu])[0])
73
+ except ValueError:
74
+ return self._send(400, {"error": "gpu must be an integer"})
75
+
76
+ try:
77
+ self._send(200, read_gpu(index))
78
+ except subprocess.CalledProcessError as exc:
79
+ self._send(404, {"error": f"no gpu {index}", "detail": exc.stderr.strip()[:200]})
80
+ except (subprocess.SubprocessError, FileNotFoundError, ValueError) as exc:
81
+ # Never 500 into the gateway's request path: an unreadable sampler
82
+ # must degrade to "unmeasured", not to a failed completion.
83
+ self._send(503, {"error": f"{type(exc).__name__}: {exc}"})
84
+
85
+ def log_message(self, *args) -> None:
86
+ """Silent: this is polled several times a second per request."""
87
+
88
+
89
+ def main() -> None:
90
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
91
+ ap.add_argument("--host", default="0.0.0.0")
92
+ ap.add_argument("--port", type=int, default=9402)
93
+ ap.add_argument("--gpu", type=int, default=0, help="default GPU when ?gpu= is omitted")
94
+ args = ap.parse_args()
95
+
96
+ Handler.default_gpu = args.gpu
97
+ print(f"power-agent on {args.host}:{args.port}, default gpu {args.gpu}", flush=True)
98
+ ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
giul/backends.py ADDED
@@ -0,0 +1,232 @@
1
+ """Three ways to ask a GPU what it drew, and one that cannot ask.
2
+
3
+ Two shapes of answer, not one:
4
+
5
+ - A **counter** backend (``nvml_counter``) reads a cumulative millijoule
6
+ register the driver maintains. Two reads and a subtraction give a true
7
+ delta over the window, with no sampling resolution to fall through. This
8
+ is why sub-second requests can be measured at all.
9
+ - A **sampler** backend (``smi_sampler``, ``remote_agent``) reads
10
+ instantaneous watts and the meter integrates them. Fine for multi-second
11
+ windows, blind below its poll interval.
12
+
13
+ Every read returns ``None`` rather than raising. A sampler that cannot be
14
+ reached must degrade the request to unmeasured, never fail it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import shutil
21
+ import subprocess
22
+
23
+ SMI = shutil.which("nvidia-smi")
24
+
25
+ COUNTER = "counter"
26
+ SAMPLER = "sampler"
27
+
28
+
29
+ def read_power_w(gpu_index: int = 0) -> float | None:
30
+ """Instantaneous board draw in watts, or None if nvidia-smi can't say."""
31
+ if not SMI:
32
+ return None
33
+ try:
34
+ out = subprocess.run(
35
+ [SMI, f"--id={gpu_index}", "--query-gpu=power.draw",
36
+ "--format=csv,noheader,nounits"],
37
+ capture_output=True, text=True, timeout=5, check=True,
38
+ ).stdout.strip()
39
+ return float(out.splitlines()[0])
40
+ except (subprocess.SubprocessError, ValueError, IndexError, OSError):
41
+ return None
42
+
43
+
44
+ class Backend:
45
+ """Something that can be asked about a card, without saying how."""
46
+
47
+ name = "none"
48
+ kind = "none"
49
+
50
+ def read_watts(self) -> float | None:
51
+ return None
52
+
53
+ async def aread_watts(self) -> float | None:
54
+ loop = asyncio.get_running_loop()
55
+ return await loop.run_in_executor(None, self.read_watts)
56
+
57
+ def read_joules(self) -> float | None:
58
+ """Cumulative joules since driver load. Counter backends only."""
59
+ return None
60
+
61
+ async def aread_joules(self) -> float | None:
62
+ loop = asyncio.get_running_loop()
63
+ return await loop.run_in_executor(None, self.read_joules)
64
+
65
+ def close(self) -> None:
66
+ pass
67
+
68
+ async def aclose(self) -> None:
69
+ self.close()
70
+
71
+
72
+ class NvmlCounter(Backend):
73
+ """``nvmlDeviceGetTotalEnergyConsumption`` — a real energy register.
74
+
75
+ Volta and later. Cumulative millijoules since the driver loaded, so the
76
+ window's joules are one subtraction and carry no sampling error. The
77
+ caller is responsible for synchronising the GPU before the closing read
78
+ (see ``Meter``); an unsynchronised read charges the next window for work
79
+ this one queued.
80
+ """
81
+
82
+ name = "nvml_counter"
83
+ kind = COUNTER
84
+
85
+ def __init__(self, gpu_index: int = 0) -> None:
86
+ import pynvml # noqa: PLC0415 — optional, probed at construction
87
+
88
+ self._nvml = pynvml
89
+ self.gpu_index = gpu_index
90
+ self._handle = pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
91
+
92
+ def read_joules(self) -> float | None:
93
+ try:
94
+ mj = self._nvml.nvmlDeviceGetTotalEnergyConsumption(self._handle)
95
+ except Exception:
96
+ return None
97
+ return mj / 1000.0
98
+
99
+ async def aread_joules(self) -> float | None:
100
+ # Two register reads; no reason to leave the event loop for it.
101
+ return self.read_joules()
102
+
103
+ def read_watts(self) -> float | None:
104
+ try:
105
+ return self._nvml.nvmlDeviceGetPowerUsage(self._handle) / 1000.0
106
+ except Exception:
107
+ return None
108
+
109
+ async def aread_watts(self) -> float | None:
110
+ return self.read_watts()
111
+
112
+
113
+ class SmiSampler(Backend):
114
+ """nvidia-smi on this host. Blocking, so async reads go off the loop."""
115
+
116
+ name = "smi_sampler"
117
+ kind = SAMPLER
118
+
119
+ def __init__(self, gpu_index: int = 0) -> None:
120
+ self.gpu_index = gpu_index
121
+
122
+ def read_watts(self) -> float | None:
123
+ return read_power_w(self.gpu_index)
124
+
125
+
126
+ class RemoteAgent(Backend):
127
+ """A fleet node running ``giul-agent``.
128
+
129
+ Still a measurement of the real card — only the process running
130
+ nvidia-smi differs — so it is recorded as ``sampled``, with ``source``
131
+ distinguishing it from the local path.
132
+ """
133
+
134
+ name = "remote_agent"
135
+ kind = SAMPLER
136
+
137
+ def __init__(self, endpoint: str, gpu_index: int = 0, timeout_s: float = 2.0) -> None:
138
+ import httpx # noqa: PLC0415 — optional, only under [remote]
139
+
140
+ self._httpx = httpx
141
+ self.url = endpoint.rstrip("/") + "/power"
142
+ self.gpu_index = gpu_index
143
+ self.timeout_s = timeout_s
144
+ self._aclient: object | None = None
145
+ self._client: object | None = None
146
+
147
+ def read_watts(self) -> float | None:
148
+ if self._client is None:
149
+ self._client = self._httpx.Client(timeout=self.timeout_s)
150
+ try:
151
+ resp = self._client.get(self.url, params={"gpu": self.gpu_index})
152
+ if resp.status_code != 200:
153
+ return None
154
+ return float(resp.json()["watts"])
155
+ except (self._httpx.HTTPError, KeyError, ValueError, TypeError):
156
+ return None
157
+
158
+ async def aread_watts(self) -> float | None:
159
+ if self._aclient is None:
160
+ self._aclient = self._httpx.AsyncClient(timeout=self.timeout_s)
161
+ try:
162
+ resp = await self._aclient.get(self.url, params={"gpu": self.gpu_index})
163
+ if resp.status_code != 200:
164
+ return None
165
+ return float(resp.json()["watts"])
166
+ except (self._httpx.HTTPError, KeyError, ValueError, TypeError):
167
+ return None
168
+
169
+ def close(self) -> None:
170
+ if self._client is not None:
171
+ self._client.close()
172
+ self._client = None
173
+
174
+ async def aclose(self) -> None:
175
+ self.close()
176
+ if self._aclient is not None:
177
+ await self._aclient.aclose()
178
+ self._aclient = None
179
+
180
+
181
+ # --- backend availability, probed once per card -----------------------------
182
+ #
183
+ # Whether a card supports the energy counter is a property of the silicon and
184
+ # the driver, not of the request, so it is asked once and remembered. A card
185
+ # that does not support it (pre-Volta, or a driver that says NotSupported)
186
+ # falls through to the sampler silently — never into a request.
187
+
188
+ _nvml_started: bool | None = None
189
+ _counter_support: dict[int, NvmlCounter | None] = {}
190
+
191
+
192
+ def _nvml_init() -> bool:
193
+ """Initialise NVML once. False if pynvml is absent or NVML won't start."""
194
+ global _nvml_started
195
+ if _nvml_started is None:
196
+ try:
197
+ import pynvml
198
+
199
+ pynvml.nvmlInit()
200
+ _nvml_started = True
201
+ except Exception:
202
+ _nvml_started = False
203
+ return _nvml_started
204
+
205
+
206
+ def counter_for(gpu_index: int = 0) -> NvmlCounter | None:
207
+ """An ``NvmlCounter`` for this card, or None if it has no energy register.
208
+
209
+ Support is confirmed by actually taking a reading: a handle that exists
210
+ but whose counter raises ``NVMLError_NotSupported`` is not support.
211
+ """
212
+ if gpu_index in _counter_support:
213
+ return _counter_support[gpu_index]
214
+
215
+ backend: NvmlCounter | None = None
216
+ if _nvml_init():
217
+ try:
218
+ candidate = NvmlCounter(gpu_index)
219
+ if candidate.read_joules() is not None:
220
+ backend = candidate
221
+ except Exception:
222
+ backend = None
223
+
224
+ _counter_support[gpu_index] = backend
225
+ return backend
226
+
227
+
228
+ def reset_probe_cache() -> None:
229
+ """Forget what was probed. For tests, and for a driver reload."""
230
+ global _nvml_started
231
+ _nvml_started = None
232
+ _counter_support.clear()
giul/meter.py ADDED
@@ -0,0 +1,401 @@
1
+ """Windows, marks, backend selection — joules behind every request.
2
+
3
+ Every number carries how it was obtained. ``method`` says whether it was
4
+ measured, ``source`` says by whom, ``backend`` says with what:
5
+
6
+ - ``sampled`` — real joules over the request window, minus the node's idle
7
+ draw, so a request is charged for the power it *caused* and not for the
8
+ power the box burns existing. Either a counter delta (``nvml_counter``) or
9
+ an integral of samples (``smi_sampler``, ``remote_agent``).
10
+ - ``estimated`` — nothing could measure it. Tokens x the tier's
11
+ joules-per-1k hint. Honest, coarse, and flagged so it can never be
12
+ mistaken for a measurement.
13
+ - ``unknown`` — not measured and not estimable.
14
+
15
+ Never silently substitute one for the other: an estimate that reads as a
16
+ measurement is exactly how an efficiency claim stops being evidence.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import threading
23
+ import time
24
+ from collections.abc import Callable, Mapping
25
+ from dataclasses import dataclass, field
26
+
27
+ from .backends import SMI, Backend, RemoteAgent, SmiSampler, counter_for
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Energy:
32
+ """What a request cost, and how confidently we know it."""
33
+
34
+ joules: float
35
+ method: str # "sampled" | "estimated" | "unknown"
36
+ seconds: float
37
+ samples: int = 0
38
+ mean_watts: float = 0.0
39
+ idle_watts: float = 0.0
40
+ source: str = "none" # "local" | "remote" | "none"
41
+ backend: str = "none" # "nvml_counter" | "smi_sampler" |
42
+ # "remote_agent" | "none"
43
+ stages: Mapping[str, "Energy"] = field(default_factory=dict)
44
+
45
+ def per_token(self, tokens: int) -> float | None:
46
+ return self.joules / tokens if tokens > 0 else None
47
+
48
+ def as_dict(self, tokens: int = 0) -> dict:
49
+ d = {
50
+ "joules": round(self.joules, 3),
51
+ "method": self.method,
52
+ "seconds": round(self.seconds, 4),
53
+ "samples": self.samples,
54
+ "mean_watts": round(self.mean_watts, 2),
55
+ "idle_watts": round(self.idle_watts, 2),
56
+ "source": self.source,
57
+ "backend": self.backend,
58
+ }
59
+ if (jpt := self.per_token(tokens)) is not None:
60
+ d["joules_per_token"] = round(jpt, 5)
61
+ d["tokens_per_joule"] = round(1.0 / jpt, 3) if jpt else None
62
+ # Only when marks were actually made, so an unmarked window serialises
63
+ # exactly as it did before stages existed.
64
+ if self.stages:
65
+ d["stages"] = {k: v.as_dict() for k, v in self.stages.items()}
66
+ return d
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Node:
71
+ """The card a window is metering, and what it costs at rest.
72
+
73
+ ``idle_w`` is supplied by the caller, per node. The library never guesses
74
+ it: a wrong idle floor silently rewrites every joule figure that node
75
+ ever reports.
76
+ """
77
+
78
+ name: str = ""
79
+ gpu_index: int = 0
80
+ idle_w: float = 0.0
81
+ is_local: bool = False
82
+ power_endpoint: str | None = None
83
+
84
+
85
+ def _grade(
86
+ joules: float | None,
87
+ *,
88
+ seconds: float,
89
+ samples: int,
90
+ mean_watts: float,
91
+ idle_w: float,
92
+ source: str,
93
+ backend: str,
94
+ tokens: int = 0,
95
+ hint: float = 0.0,
96
+ stages: Mapping[str, Energy] | None = None,
97
+ ) -> Energy:
98
+ """Turn a candidate measurement into an ``Energy``, honestly labelled.
99
+
100
+ A window that measures <= 0 J above idle did not measure a free request,
101
+ it failed to measure one. Sub-second windows are below a sampler's
102
+ resolution: the reading lands before the card ramps, comes back under the
103
+ idle floor, and charging above idle yields exactly zero. Reporting that
104
+ as ``sampled`` would put a fabricated zero into the very series the
105
+ efficiency claim rests on, so it degrades to an estimate instead.
106
+ """
107
+ common = dict(seconds=seconds, samples=samples, mean_watts=mean_watts,
108
+ idle_watts=idle_w, source=source, backend=backend,
109
+ stages=dict(stages or {}))
110
+
111
+ if joules is not None and joules > 0:
112
+ return Energy(joules=joules, method="sampled", **common)
113
+
114
+ if tokens > 0 and hint > 0:
115
+ return Energy(joules=hint * tokens / 1000.0, method="estimated", **common)
116
+
117
+ return Energy(joules=0.0, method="unknown", **common)
118
+
119
+
120
+ class Meter:
121
+ """Integrates GPU energy across the life of one request.
122
+
123
+ Constructed via :meth:`for_node` so the measured/estimated decision is
124
+ made in exactly one place. Usable as an async or a sync context manager;
125
+ ``result()`` is called after the window closes, either way::
126
+
127
+ async with Meter.for_node(node) as m:
128
+ await retrieve(); m.mark("retrieve")
129
+ await generate(); m.mark("generate")
130
+ e = m.result(tokens=412)
131
+
132
+ **The caller synchronises the GPU before the closing read.** On the
133
+ counter backend the register only counts work the card has finished, so
134
+ an in-flight kernel is charged to the *next* window. Pass
135
+ ``sync=torch.cuda.synchronize`` when the work is local; an HTTP upstream
136
+ needs nothing, because the completion returning is the sync point.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ backend: Backend | None,
142
+ idle_w: float = 0.0,
143
+ interval_s: float = 0.25,
144
+ joules_per_1k_hint: float = 0.0,
145
+ source: str = "none",
146
+ sync: Callable[[], None] | None = None,
147
+ ) -> None:
148
+ self.backend = backend
149
+ self.idle_w = idle_w
150
+ self.interval_s = interval_s
151
+ self.joules_per_1k_hint = joules_per_1k_hint
152
+ self.source = source
153
+ self._sync = sync
154
+
155
+ self._watts: list[float] = [] # every watt reading seen
156
+ self._samples: list[tuple[float, float]] = [] # (t, w), for stages
157
+ self._marks: list[tuple[str, float, float | None]] = []
158
+ self._task: asyncio.Task | None = None
159
+ self._thread: threading.Thread | None = None
160
+ self._stop = threading.Event()
161
+ self._t0 = 0.0
162
+ self._elapsed = 0.0
163
+ self._j0: float | None = None
164
+ self._j1: float | None = None
165
+
166
+ # --- construction -------------------------------------------------------
167
+
168
+ @classmethod
169
+ def for_node(
170
+ cls,
171
+ node,
172
+ tier=None,
173
+ interval_s: float = 0.25,
174
+ *,
175
+ joules_per_1k_hint: float | None = None,
176
+ sync: Callable[[], None] | None = None,
177
+ timeout_s: float = 2.0,
178
+ ) -> "Meter":
179
+ """Pick a backend: energy counter, this host's nvidia-smi, a remote
180
+ agent, or neither.
181
+
182
+ Selection is runtime and needs no per-node configuration. Counter
183
+ support is probed once per card and cached; a card without it falls
184
+ through to the sampler silently.
185
+ """
186
+ hint = joules_per_1k_hint
187
+ if hint is None:
188
+ hint = getattr(tier, "joules_per_1k_hint", 0.0) or 0.0
189
+
190
+ if node is None:
191
+ return cls(None, joules_per_1k_hint=hint, interval_s=interval_s, sync=sync)
192
+
193
+ common = dict(idle_w=node.idle_w, interval_s=interval_s,
194
+ joules_per_1k_hint=hint, sync=sync)
195
+ gpu_index = getattr(node, "gpu_index", 0)
196
+
197
+ if node.is_local:
198
+ if (counter := counter_for(gpu_index)) is not None:
199
+ return cls(counter, source="local", **common)
200
+ if SMI is not None:
201
+ return cls(SmiSampler(gpu_index), source="local", **common)
202
+
203
+ if getattr(node, "power_endpoint", None):
204
+ try:
205
+ agent = RemoteAgent(node.power_endpoint, gpu_index, timeout_s)
206
+ except ImportError: # httpx absent: no [remote] extra
207
+ return cls(None, **common)
208
+ return cls(agent, source="remote", **common)
209
+
210
+ return cls(None, **common)
211
+
212
+ def sync(self) -> "Meter":
213
+ """Read as a sync context manager. Same meter, same ``result()``."""
214
+ return self
215
+
216
+ @property
217
+ def sampling(self) -> bool:
218
+ return self.backend is not None
219
+
220
+ @property
221
+ def backend_name(self) -> str:
222
+ return self.backend.name if self.backend is not None else "none"
223
+
224
+ # --- marks --------------------------------------------------------------
225
+
226
+ def mark(self, name: str) -> None:
227
+ """Close the current stage at now, and open the next.
228
+
229
+ The first stage runs from window open to the first mark. Whatever
230
+ follows the last mark is part of the total but is not a named stage.
231
+ """
232
+ now = time.perf_counter()
233
+ reading = None
234
+ if self.backend is not None and self.backend.kind == "counter":
235
+ reading = self.backend.read_joules()
236
+ self._marks.append((name, now, reading))
237
+
238
+ # --- the window ---------------------------------------------------------
239
+
240
+ def _open(self) -> None:
241
+ self._t0 = time.perf_counter()
242
+ if self.backend is not None and self.backend.kind == "counter":
243
+ self._j0 = self.backend.read_joules()
244
+
245
+ def _close_clock(self) -> None:
246
+ """Synchronise, then stop the clock, then take the closing reading.
247
+
248
+ In that order: the sync wait is time the request spent, and a counter
249
+ read before the card has drained charges this window's tail to the
250
+ next one.
251
+ """
252
+ if self._sync is not None:
253
+ try:
254
+ self._sync()
255
+ except Exception: # a failed sync must not break the request
256
+ pass
257
+ self._elapsed = time.perf_counter() - self._t0
258
+ if self.backend is not None and self.backend.kind == "counter":
259
+ self._j1 = self.backend.read_joules()
260
+
261
+ def _record(self, w: float | None, t: float | None = None) -> None:
262
+ if w is None:
263
+ return
264
+ self._watts.append(w)
265
+ self._samples.append((t if t is not None else time.perf_counter(), w))
266
+
267
+ async def _poll(self) -> None:
268
+ assert self.backend is not None
269
+ while True:
270
+ self._record(await self.backend.aread_watts())
271
+ await asyncio.sleep(self.interval_s)
272
+
273
+ def _poll_sync(self) -> None:
274
+ assert self.backend is not None
275
+ while not self._stop.is_set():
276
+ self._record(self.backend.read_watts())
277
+ self._stop.wait(self.interval_s)
278
+
279
+ @property
280
+ def _polls(self) -> bool:
281
+ return self.backend is not None and self.backend.kind == "sampler"
282
+
283
+ async def __aenter__(self) -> "Meter":
284
+ self._open()
285
+ if self._polls:
286
+ self._task = asyncio.create_task(self._poll())
287
+ return self
288
+
289
+ async def __aexit__(self, *exc) -> None:
290
+ self._close_clock()
291
+ # One last reading before tearing down. A sub-second request would
292
+ # otherwise be characterised entirely by the sample taken at t=0,
293
+ # which is before the GPU has ramped.
294
+ if self._polls:
295
+ try:
296
+ self._record(await self.backend.aread_watts())
297
+ except Exception: # a failed final read must not break the request
298
+ pass
299
+ if self._task is not None:
300
+ self._task.cancel()
301
+ try:
302
+ await self._task
303
+ except asyncio.CancelledError:
304
+ pass
305
+ self._task = None
306
+ if self.backend is not None:
307
+ await self.backend.aclose()
308
+
309
+ def __enter__(self) -> "Meter":
310
+ self._open()
311
+ if self._polls:
312
+ self._stop.clear()
313
+ self._thread = threading.Thread(target=self._poll_sync, daemon=True)
314
+ self._thread.start()
315
+ return self
316
+
317
+ def __exit__(self, *exc) -> None:
318
+ self._close_clock()
319
+ if self._polls:
320
+ try:
321
+ self._record(self.backend.read_watts())
322
+ except Exception:
323
+ pass
324
+ if self._thread is not None:
325
+ self._stop.set()
326
+ self._thread.join(timeout=self.interval_s + 5.0)
327
+ self._thread = None
328
+ if self.backend is not None:
329
+ self.backend.close()
330
+
331
+ # --- the answer ---------------------------------------------------------
332
+
333
+ def result(self, tokens: int = 0) -> Energy:
334
+ """Joules for the window just closed, charged above idle.
335
+
336
+ The total is computed over the whole window, never by summing stages,
337
+ so a stage the sampler was too slow to see cannot corrupt it.
338
+ """
339
+ seconds = self._elapsed or (time.perf_counter() - self._t0)
340
+ joules, mean, samples = self._window(seconds)
341
+
342
+ return _grade(
343
+ joules, seconds=seconds, samples=samples, mean_watts=mean,
344
+ idle_w=self.idle_w, source=self.source, backend=self.backend_name,
345
+ tokens=tokens, hint=self.joules_per_1k_hint,
346
+ stages=self._stages(),
347
+ )
348
+
349
+ def _window(self, seconds: float) -> tuple[float | None, float, int]:
350
+ """Joules above idle for the whole window, plus what was observed."""
351
+ if self._j0 is not None and self._j1 is not None:
352
+ measured = self._j1 - self._j0
353
+ mean = measured / seconds if seconds > 0 else 0.0
354
+ # Two reads and a subtraction: a delta, not an integral.
355
+ return measured - self.idle_w * seconds, mean, 2 + len(self._marks)
356
+
357
+ if not self._watts:
358
+ return None, 0.0, 0
359
+
360
+ mean = sum(self._watts) / len(self._watts)
361
+ above_idle = max(mean - self.idle_w, 0.0)
362
+ return above_idle * seconds, mean, len(self._watts)
363
+
364
+ def _stage_spans(self) -> list[tuple[str, float, float, float | None, float | None]]:
365
+ """(name, t_start, t_end, counter_at_start, counter_at_end) per stage."""
366
+ spans = []
367
+ t_prev, j_prev = self._t0, self._j0
368
+ for name, t, j in self._marks:
369
+ spans.append((name, t_prev, t, j_prev, j))
370
+ t_prev, j_prev = t, j
371
+ return spans
372
+
373
+ def _stages(self) -> dict[str, Energy]:
374
+ """One ``Energy`` per mark. Empty when no marks were made."""
375
+ stages: dict[str, Energy] = {}
376
+ for name, t0, t1, j0, j1 in self._stage_spans():
377
+ dt = max(t1 - t0, 0.0)
378
+
379
+ if j0 is not None and j1 is not None:
380
+ joules = (j1 - j0) - self.idle_w * dt
381
+ mean = (j1 - j0) / dt if dt > 0 else 0.0
382
+ samples = 2
383
+ else:
384
+ # Samples that landed inside this stage. A stage shorter than
385
+ # the poll interval catches none, and reports `unknown` — the
386
+ # total is unaffected, being computed over the whole window.
387
+ inside = [w for t, w in self._samples if t0 <= t < t1]
388
+ samples = len(inside)
389
+ mean = sum(inside) / samples if samples else 0.0
390
+ joules = max(mean - self.idle_w, 0.0) * dt if samples else None
391
+
392
+ stages[name] = _grade(
393
+ joules, seconds=dt, samples=samples, mean_watts=mean,
394
+ idle_w=self.idle_w, source=self.source,
395
+ backend=self.backend_name,
396
+ )
397
+ return stages
398
+
399
+
400
+ #: Aigne imported the meter under this name; keep it working across the swap.
401
+ EnergyMeter = Meter
giul/probe.py ADDED
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """`giul-probe` — what this host's cards can actually be metered with.
3
+
4
+ Backend selection is runtime, so nothing needs configuring. But the
5
+ acceptance tests need to know *which* backend they are exercising on which
6
+ card, and "the counter is unsupported on that one" is a fact about the
7
+ silicon worth writing down rather than rediscovering. Run this on each fleet
8
+ node and paste the output into `notes/DECISIONS.md`.
9
+
10
+ For every GPU it reports the name and driver, whether the power gauge and
11
+ the energy counter answer, and — the point of the exercise — a counter delta
12
+ over a fixed window against the mean of power samples across the same
13
+ window. Those two numbers being close is what says the counter and the
14
+ sampler are the same ruler. On an idle card both are small and the ratio is
15
+ noise; run something on the card if you want the comparison to mean much.
16
+
17
+ giul-probe --seconds 2.0
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import platform
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import time
28
+
29
+
30
+ def _nvml():
31
+ try:
32
+ import pynvml
33
+ except ImportError:
34
+ return None
35
+ try:
36
+ pynvml.nvmlInit()
37
+ except Exception as exc:
38
+ print(f"pynvml present but NVML would not start: {exc}")
39
+ return None
40
+ return pynvml
41
+
42
+
43
+ def probe_gpu(nvml, index: int, seconds: float, interval: float) -> None:
44
+ handle = nvml.nvmlDeviceGetHandleByIndex(index)
45
+ name = nvml.nvmlDeviceGetName(handle)
46
+ if isinstance(name, bytes):
47
+ name = name.decode()
48
+ print(f"\ngpu {index}: {name}")
49
+
50
+ try:
51
+ watts = nvml.nvmlDeviceGetPowerUsage(handle) / 1000.0
52
+ print(f" nvmlDeviceGetPowerUsage: ok, {watts:.1f} W")
53
+ gauge = True
54
+ except Exception as exc:
55
+ print(f" nvmlDeviceGetPowerUsage: {type(exc).__name__}: {exc}")
56
+ gauge = False
57
+
58
+ try:
59
+ nvml.nvmlDeviceGetTotalEnergyConsumption(handle)
60
+ print(" nvmlDeviceGetTotalEnergyConsumption: ok -> backend nvml_counter")
61
+ counter = True
62
+ except Exception as exc:
63
+ print(f" nvmlDeviceGetTotalEnergyConsumption: {type(exc).__name__}: {exc}")
64
+ print(" -> no energy counter on this card; selection falls through to smi_sampler")
65
+ counter = False
66
+
67
+ if not (gauge and counter):
68
+ return
69
+
70
+ # The comparison: one delta from the register, one integral from the
71
+ # gauge, over exactly the same window.
72
+ samples: list[float] = []
73
+ j0 = nvml.nvmlDeviceGetTotalEnergyConsumption(handle) / 1000.0
74
+ t0 = time.perf_counter()
75
+ while (elapsed := time.perf_counter() - t0) < seconds:
76
+ samples.append(nvml.nvmlDeviceGetPowerUsage(handle) / 1000.0)
77
+ time.sleep(interval)
78
+ elapsed = time.perf_counter() - t0
79
+ j1 = nvml.nvmlDeviceGetTotalEnergyConsumption(handle) / 1000.0
80
+
81
+ counter_j = j1 - j0
82
+ mean_w = sum(samples) / len(samples) if samples else 0.0
83
+ sampled_j = mean_w * elapsed
84
+ ratio = counter_j / sampled_j if sampled_j else float("nan")
85
+
86
+ print(f" window: {elapsed:.3f} s, {len(samples)} power samples at {interval}s")
87
+ print(f" counter delta: {counter_j:9.3f} J ({counter_j / elapsed:7.2f} W mean)")
88
+ print(f" sampled mean: {sampled_j:9.3f} J ({mean_w:7.2f} W mean)")
89
+ print(f" counter/sampled: {ratio:.3f}")
90
+
91
+
92
+ def main() -> None:
93
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
94
+ ap.add_argument("--seconds", type=float, default=2.0, help="comparison window")
95
+ ap.add_argument("--interval", type=float, default=0.1, help="power sample interval")
96
+ ap.add_argument("--gpu", type=int, default=None, help="probe one GPU (default: all)")
97
+ args = ap.parse_args()
98
+
99
+ print(f"giul-probe on {platform.node()} ({platform.platform()})")
100
+ print(f"python {sys.version.split()[0]}")
101
+ print(f"nvidia-smi: {shutil.which('nvidia-smi') or 'not on PATH'}")
102
+
103
+ nvml = _nvml()
104
+ if nvml is None:
105
+ print("pynvml: not installed -> no nvml_counter backend on this host")
106
+ if shutil.which("nvidia-smi"):
107
+ out = subprocess.run(
108
+ ["nvidia-smi", "--query-gpu=name,driver_version",
109
+ "--format=csv,noheader"],
110
+ capture_output=True, text=True, check=False,
111
+ ).stdout.strip()
112
+ print(f"cards (via nvidia-smi):\n{out}")
113
+ print("-> backend smi_sampler")
114
+ else:
115
+ print("-> backend none; giul will report estimated/unknown here")
116
+ return
117
+
118
+ driver = nvml.nvmlSystemGetDriverVersion()
119
+ if isinstance(driver, bytes):
120
+ driver = driver.decode()
121
+ count = nvml.nvmlDeviceGetCount()
122
+ print(f"driver {driver}, {count} gpu(s)")
123
+
124
+ indices = [args.gpu] if args.gpu is not None else range(count)
125
+ for i in indices:
126
+ probe_gpu(nvml, i, args.seconds, args.interval)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: giul
3
+ Version: 0.1.0
4
+ Summary: One ruler for what an answer cost in joules, across a GPU fleet.
5
+ Project-URL: Homepage, https://github.com/todd427/giul
6
+ Author: Todd McCaffrey
7
+ License-Expression: Apache-2.0
8
+ Keywords: energy,gpu,joules,nvml,telemetry
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Topic :: System :: Monitoring
13
+ Requires-Python: >=3.11
14
+ Provides-Extra: nvml
15
+ Requires-Dist: nvidia-ml-py>=12.535; extra == 'nvml'
16
+ Provides-Extra: remote
17
+ Requires-Dist: httpx>=0.24; extra == 'remote'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # giul
21
+
22
+ *giúl* — Irish for joule. One ruler for "what did this answer cost in energy"
23
+ across the FoxxeLabs fleet: Aigne, Tuiscint, Gléas.
24
+
25
+ Two rulers make a comparison an argument instead of evidence. This is one,
26
+ and all three pin it.
27
+
28
+ ```bash
29
+ pip install giul # no dependencies; imports fine with no GPU
30
+ pip install giul[nvml] # + nvidia-ml-py, for the energy counter
31
+ pip install giul[remote] # + httpx, for metering a remote node
32
+ ```
33
+
34
+ ## What a joule means here
35
+
36
+ The number a request is charged is **energy above idle**:
37
+
38
+ ```
39
+ joules = measured − idle_w × seconds
40
+ ```
41
+
42
+ A request pays for the power it *caused*, not for the power the box burns
43
+ existing. `idle_w` is supplied by the caller, per node — giul never guesses
44
+ it, because a wrong idle floor silently rewrites every figure that node
45
+ reports.
46
+
47
+ ## Three rules
48
+
49
+ 1. **Charged above idle.** As above.
50
+ 2. **No fabricated zeros.** A window that measured ≤ 0 J above idle did not
51
+ measure a free request, it failed to measure one. It degrades to
52
+ `estimated` (with a hint) or `unknown` — never a `sampled` zero.
53
+ 3. **Estimates are labelled as estimates.** An estimate that reads as a
54
+ measurement is exactly how an efficiency claim stops being evidence. A
55
+ sampler that cannot be reached costs a measurement, never the request.
56
+
57
+ ## Use
58
+
59
+ ```python
60
+ from giul import Meter, Node
61
+
62
+ node = Node(name="iris", gpu_index=0, idle_w=38.0,
63
+ is_local=True, power_endpoint=None)
64
+
65
+ # async
66
+ async with Meter.for_node(node, joules_per_1k_hint=4100.0) as m:
67
+ await retrieve(); m.mark("retrieve")
68
+ await generate(); m.mark("generate")
69
+ e = m.result(tokens=412)
70
+ # e.joules, e.method, e.backend, e.stages == {"retrieve": Energy, "generate": Energy}
71
+
72
+ # sync
73
+ with Meter.for_node(node, sync=torch.cuda.synchronize).sync() as m:
74
+ ...; m.mark("verify")
75
+ e = m.result(tokens=n)
76
+ ```
77
+
78
+ `mark(name)` closes the current stage and opens the next. The total is always
79
+ computed over the whole window, never by summing stages, so a stage the
80
+ sampler was too slow to see cannot corrupt it.
81
+
82
+ **The caller synchronises the GPU before the closing read.** On the counter
83
+ backend the register only counts work the card has *finished*; pass
84
+ `sync=torch.cuda.synchronize` when the work is local. An HTTP upstream needs
85
+ nothing — the completion returning is the sync point.
86
+
87
+ ## Backends
88
+
89
+ Chosen at runtime in one place (`Meter.for_node`), needing no per-node
90
+ configuration. Support is probed once per card and cached.
91
+
92
+ | `backend` | when | how |
93
+ |---|---|---|
94
+ | `nvml_counter` | local card, `pynvml` present, counter answers | `nvmlDeviceGetTotalEnergyConsumption` delta — a true measurement of a sub-second window |
95
+ | `smi_sampler` | local card, `nvidia-smi` on PATH | integral of `power.draw` samples |
96
+ | `remote_agent` | `node.power_endpoint` set | the same integral, sampled by `giul-agent` on that node |
97
+ | `none` | otherwise | `estimated` with a hint, else `unknown` |
98
+
99
+ `method` stays `sampled` / `estimated` / `unknown`. The counter *is* a
100
+ measurement, so it reports `method="sampled"`; the distinction lives in
101
+ `backend`.
102
+
103
+ ## Tools
104
+
105
+ ```bash
106
+ giul-probe # what this host's cards support; counter vs sampler
107
+ giul-agent --port 9402 # expose a node's power draw; stdlib only, read-only
108
+ ```
109
+
110
+ Bind `giul-agent` to the mesh address, not `0.0.0.0`, unless the node is
111
+ otherwise firewalled.
112
+
113
+ ## Not in 0.1
114
+
115
+ CPU/RAPL, carbon, € cost, storing series, any UI.
@@ -0,0 +1,9 @@
1
+ giul/__init__.py,sha256=eFtyR3SqY_pnFGCvj03Qu_Hu6IAIKikm0YufLk01oNc,1343
2
+ giul/agent.py,sha256=AxfTuNybNr0D_h2vuzW_9x2rXrd0kJAJWpkz9d--d_g,3818
3
+ giul/backends.py,sha256=SrWfybPepJorm8MPnPzP-wQsFB_-IMOCfQk1XAlO-vQ,7499
4
+ giul/meter.py,sha256=F2TZW6ZGcmS1KDfdTP2AZiDSnjqjHffWKr4ZDi17cYc,15107
5
+ giul/probe.py,sha256=aAnacXsUp2aBZZwQoIFIJBU7OdjvYuB0pUYlzwX-iqI,4866
6
+ giul-0.1.0.dist-info/METADATA,sha256=CvuYSatIRC6n0RPGjD6JZ54UI9CpOvF6_AE9ixhHTvI,4120
7
+ giul-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ giul-0.1.0.dist-info/entry_points.txt,sha256=FFD-JPRL56kAHbj5uSb9bmNO-OZ2pU69jReoTwC-15g,76
9
+ giul-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ giul-agent = giul.agent:main
3
+ giul-probe = giul.probe:main