giul 0.1.0__tar.gz

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-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ *.egg-info/
giul-0.1.0/PKG-INFO ADDED
@@ -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.
giul-0.1.0/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # giul
2
+
3
+ *giúl* — Irish for joule. One ruler for "what did this answer cost in energy"
4
+ across the FoxxeLabs fleet: Aigne, Tuiscint, Gléas.
5
+
6
+ Two rulers make a comparison an argument instead of evidence. This is one,
7
+ and all three pin it.
8
+
9
+ ```bash
10
+ pip install giul # no dependencies; imports fine with no GPU
11
+ pip install giul[nvml] # + nvidia-ml-py, for the energy counter
12
+ pip install giul[remote] # + httpx, for metering a remote node
13
+ ```
14
+
15
+ ## What a joule means here
16
+
17
+ The number a request is charged is **energy above idle**:
18
+
19
+ ```
20
+ joules = measured − idle_w × seconds
21
+ ```
22
+
23
+ A request pays for the power it *caused*, not for the power the box burns
24
+ existing. `idle_w` is supplied by the caller, per node — giul never guesses
25
+ it, because a wrong idle floor silently rewrites every figure that node
26
+ reports.
27
+
28
+ ## Three rules
29
+
30
+ 1. **Charged above idle.** As above.
31
+ 2. **No fabricated zeros.** A window that measured ≤ 0 J above idle did not
32
+ measure a free request, it failed to measure one. It degrades to
33
+ `estimated` (with a hint) or `unknown` — never a `sampled` zero.
34
+ 3. **Estimates are labelled as estimates.** An estimate that reads as a
35
+ measurement is exactly how an efficiency claim stops being evidence. A
36
+ sampler that cannot be reached costs a measurement, never the request.
37
+
38
+ ## Use
39
+
40
+ ```python
41
+ from giul import Meter, Node
42
+
43
+ node = Node(name="iris", gpu_index=0, idle_w=38.0,
44
+ is_local=True, power_endpoint=None)
45
+
46
+ # async
47
+ async with Meter.for_node(node, joules_per_1k_hint=4100.0) as m:
48
+ await retrieve(); m.mark("retrieve")
49
+ await generate(); m.mark("generate")
50
+ e = m.result(tokens=412)
51
+ # e.joules, e.method, e.backend, e.stages == {"retrieve": Energy, "generate": Energy}
52
+
53
+ # sync
54
+ with Meter.for_node(node, sync=torch.cuda.synchronize).sync() as m:
55
+ ...; m.mark("verify")
56
+ e = m.result(tokens=n)
57
+ ```
58
+
59
+ `mark(name)` closes the current stage and opens the next. The total is always
60
+ computed over the whole window, never by summing stages, so a stage the
61
+ sampler was too slow to see cannot corrupt it.
62
+
63
+ **The caller synchronises the GPU before the closing read.** On the counter
64
+ backend the register only counts work the card has *finished*; pass
65
+ `sync=torch.cuda.synchronize` when the work is local. An HTTP upstream needs
66
+ nothing — the completion returning is the sync point.
67
+
68
+ ## Backends
69
+
70
+ Chosen at runtime in one place (`Meter.for_node`), needing no per-node
71
+ configuration. Support is probed once per card and cached.
72
+
73
+ | `backend` | when | how |
74
+ |---|---|---|
75
+ | `nvml_counter` | local card, `pynvml` present, counter answers | `nvmlDeviceGetTotalEnergyConsumption` delta — a true measurement of a sub-second window |
76
+ | `smi_sampler` | local card, `nvidia-smi` on PATH | integral of `power.draw` samples |
77
+ | `remote_agent` | `node.power_endpoint` set | the same integral, sampled by `giul-agent` on that node |
78
+ | `none` | otherwise | `estimated` with a hint, else `unknown` |
79
+
80
+ `method` stays `sampled` / `estimated` / `unknown`. The counter *is* a
81
+ measurement, so it reports `method="sampled"`; the distinction lives in
82
+ `backend`.
83
+
84
+ ## Tools
85
+
86
+ ```bash
87
+ giul-probe # what this host's cards support; counter vs sampler
88
+ giul-agent --port 9402 # expose a node's power draw; stdlib only, read-only
89
+ ```
90
+
91
+ Bind `giul-agent` to the mesh address, not `0.0.0.0`, unless the node is
92
+ otherwise firewalled.
93
+
94
+ ## Not in 0.1
95
+
96
+ CPU/RAPL, carbon, € cost, storing series, any UI.
@@ -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
+ ]
@@ -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()
@@ -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()