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 +41 -0
- giul/agent.py +102 -0
- giul/backends.py +232 -0
- giul/meter.py +401 -0
- giul/probe.py +130 -0
- giul-0.1.0.dist-info/METADATA +115 -0
- giul-0.1.0.dist-info/RECORD +9 -0
- giul-0.1.0.dist-info/WHEEL +4 -0
- giul-0.1.0.dist-info/entry_points.txt +3 -0
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,,
|