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 +5 -0
- giul-0.1.0/PKG-INFO +115 -0
- giul-0.1.0/README.md +96 -0
- giul-0.1.0/giul/__init__.py +41 -0
- giul-0.1.0/giul/agent.py +102 -0
- giul-0.1.0/giul/backends.py +232 -0
- giul-0.1.0/giul/meter.py +401 -0
- giul-0.1.0/giul/probe.py +130 -0
- giul-0.1.0/pyproject.toml +47 -0
- giul-0.1.0/tests/conftest.py +82 -0
- giul-0.1.0/tests/test_agent.py +117 -0
- giul-0.1.0/tests/test_counter.py +139 -0
- giul-0.1.0/tests/test_meter.py +146 -0
- giul-0.1.0/tests/test_stages.py +131 -0
giul-0.1.0/.gitignore
ADDED
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
|
+
]
|
giul-0.1.0/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()
|
|
@@ -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()
|