thirst 0.3.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.
- thirst/__init__.py +3 -0
- thirst/__main__.py +153 -0
- thirst/client.py +196 -0
- thirst/commands.py +348 -0
- thirst/config.py +150 -0
- thirst/context.py +98 -0
- thirst/data/gpus.json +176 -0
- thirst/errors.py +49 -0
- thirst/gpus.py +145 -0
- thirst/jobrun.py +258 -0
- thirst/jobsetup.py +97 -0
- thirst/ledger.py +66 -0
- thirst/meter.py +252 -0
- thirst/modelled.py +110 -0
- thirst/nvml.py +170 -0
- thirst/outbox.py +134 -0
- thirst/planning.py +74 -0
- thirst/powercap.py +409 -0
- thirst/process.py +217 -0
- thirst/receipt.py +301 -0
- thirst/runner.py +393 -0
- thirst/schedule.py +143 -0
- thirst/sweep.py +299 -0
- thirst-0.3.0.dist-info/METADATA +53 -0
- thirst-0.3.0.dist-info/RECORD +28 -0
- thirst-0.3.0.dist-info/WHEEL +5 -0
- thirst-0.3.0.dist-info/entry_points.txt +2 -0
- thirst-0.3.0.dist-info/top_level.txt +1 -0
thirst/__init__.py
ADDED
thirst/__main__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Thirst CLI entry point: argument parsing, dispatch and plain-words error handling (plan 04.0).
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 2 bad arguments, 3 config, 4 API, 5 GPU, 130 Ctrl-C. An expected failure prints
|
|
4
|
+
one line ("thirst: ...") and never a stack trace; a surprise prints a generic line and, with
|
|
5
|
+
``--verbose``, the log (which never contains the site key or a signature).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from . import __version__, commands
|
|
16
|
+
from .context import Context, default_context, stderr_writer, stdout_writer
|
|
17
|
+
from .errors import EXIT_BAD_ARGS, EXIT_INTERRUPTED, EXIT_OK, CliError
|
|
18
|
+
from .planning import MAX_WORK_HOURS
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("thirst")
|
|
21
|
+
logger.addHandler(logging.NullHandler())
|
|
22
|
+
|
|
23
|
+
HANDLERS = {
|
|
24
|
+
"register": "cmd_register",
|
|
25
|
+
"plan": "cmd_plan",
|
|
26
|
+
"run": "cmd_run",
|
|
27
|
+
"sweep": "cmd_sweep",
|
|
28
|
+
"doctor": "cmd_doctor",
|
|
29
|
+
"sync": "cmd_sync",
|
|
30
|
+
"receipt": "cmd_receipt",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _job_options(sub: argparse.ArgumentParser) -> None:
|
|
35
|
+
sub.add_argument(
|
|
36
|
+
"--hours", type=float, required=True, help=f"work at full power (max {MAX_WORK_HOURS:g})"
|
|
37
|
+
)
|
|
38
|
+
sub.add_argument("--deadline", required=True, help='e.g. "2026-10-10 09:00" (India time)')
|
|
39
|
+
sub.add_argument("--pausable", action="store_true", help="allow pauses (single-process jobs)")
|
|
40
|
+
sub.add_argument("--alpha", type=float, default=0.5, help="0 = least CO2, 1 = least water")
|
|
41
|
+
sub.add_argument("--gpu-model", default="auto", help="GPU model id; default: detect")
|
|
42
|
+
sub.add_argument("--gpus", type=int, default=None, help="number of GPUs; default: detect")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(prog="thirst", description="Plan, run and measure GPU jobs.")
|
|
47
|
+
parser.add_argument("--version", action="version", version=f"thirst {__version__}")
|
|
48
|
+
parser.add_argument("--verbose", action="store_true", help="log requests (never secrets)")
|
|
49
|
+
commands_ = parser.add_subparsers(dest="command_name")
|
|
50
|
+
|
|
51
|
+
reg = commands_.add_parser("register", help="create this lab's site")
|
|
52
|
+
reg.add_argument("--api-url", default=None, help="API address (or THIRST_API_URL)")
|
|
53
|
+
reg.add_argument("--name", required=True)
|
|
54
|
+
reg.add_argument("--city", required=True)
|
|
55
|
+
reg.add_argument("--cooling", required=True, choices=["room_ac", "air_chiller", "evaporative"])
|
|
56
|
+
reg.add_argument("--gpus", type=int, required=True, help="GPUs in the lab")
|
|
57
|
+
reg.add_argument("--org", default=None)
|
|
58
|
+
reg.add_argument("--coverage", type=float, default=0.0, help="share of work covered, 0 to 1")
|
|
59
|
+
reg.add_argument("--private", action="store_true", help="keep the site off the leaderboard")
|
|
60
|
+
reg.add_argument("--public", action="store_true", help="list the site (the default)")
|
|
61
|
+
reg.add_argument("--force", action="store_true", help="replace an existing registration")
|
|
62
|
+
|
|
63
|
+
plan = commands_.add_parser("plan", help="show the plan for a job without running it")
|
|
64
|
+
_job_options(plan)
|
|
65
|
+
plan.add_argument("--json", action="store_true", help="print the raw server response")
|
|
66
|
+
|
|
67
|
+
run = commands_.add_parser("run", help="plan, then run your command: thirst run ... -- cmd")
|
|
68
|
+
_job_options(run)
|
|
69
|
+
run.add_argument("--now", action="store_true", help="start right away (no waiting)")
|
|
70
|
+
run.add_argument("--modelled", action="store_true", help="no GPU: estimate the energy")
|
|
71
|
+
run.add_argument("--no-cap", action="store_true", help="never change the power limit")
|
|
72
|
+
run.add_argument("--dry-run", action="store_true", help="show the plan, start nothing")
|
|
73
|
+
run.add_argument("--fallback-clocks", action="store_true", help="lock clocks instead of power")
|
|
74
|
+
run.add_argument("--slot-seconds", type=float, default=None, help=argparse.SUPPRESS)
|
|
75
|
+
|
|
76
|
+
swp = commands_.add_parser("sweep", help="measure the power-cap sweep (needs a GPU and torch)")
|
|
77
|
+
swp.add_argument("--levels", default="100,85,70")
|
|
78
|
+
swp.add_argument("--steps", type=int, default=300)
|
|
79
|
+
swp.add_argument("--warmup", type=int, default=30)
|
|
80
|
+
swp.add_argument("--idle-seconds", type=float, default=60.0)
|
|
81
|
+
swp.add_argument("--gpu-model", default="auto")
|
|
82
|
+
swp.add_argument("--out", default=None, help="powercap_sweep.json to update")
|
|
83
|
+
|
|
84
|
+
doc = commands_.add_parser("doctor", help="check the setup")
|
|
85
|
+
doc.add_argument("--restore", action="store_true", help="undo a power cap left behind")
|
|
86
|
+
|
|
87
|
+
commands_.add_parser("sync", help="post receipts waiting in the outbox")
|
|
88
|
+
|
|
89
|
+
rec = commands_.add_parser("receipt", help="saved receipts")
|
|
90
|
+
rec_sub = rec.add_subparsers(dest="receipt_action", required=True)
|
|
91
|
+
show = rec_sub.add_parser("show", help="print a saved receipt")
|
|
92
|
+
show.add_argument("job_id")
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _split_command(argv: list[str]) -> tuple[list[str], list[str] | None]:
|
|
97
|
+
"""Split ``thirst run ... -- cmd args`` at the first ``--`` (only for `run`)."""
|
|
98
|
+
if "--" in argv and "run" in argv[: argv.index("--")]:
|
|
99
|
+
cut = argv.index("--")
|
|
100
|
+
return argv[:cut], argv[cut + 1 :]
|
|
101
|
+
return argv, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse(argv: list[str], ctx: Context) -> argparse.Namespace | int:
|
|
105
|
+
head, command = _split_command(argv)
|
|
106
|
+
parser = build_parser()
|
|
107
|
+
try:
|
|
108
|
+
args = parser.parse_args(head)
|
|
109
|
+
except SystemExit as exc:
|
|
110
|
+
return exc.code if isinstance(exc.code, int) else EXIT_BAD_ARGS
|
|
111
|
+
if args.command_name is None:
|
|
112
|
+
for line in parser.format_help().rstrip("\n").split("\n"):
|
|
113
|
+
ctx.out(line)
|
|
114
|
+
return EXIT_OK
|
|
115
|
+
args.command = command
|
|
116
|
+
return args
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _setup_logging(verbose: bool) -> None:
|
|
120
|
+
if verbose:
|
|
121
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
122
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
|
|
123
|
+
logger.addHandler(handler)
|
|
124
|
+
logger.setLevel(logging.INFO)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: Sequence[str] | None = None, *, context: Context | None = None) -> int:
|
|
128
|
+
"""Run the CLI and return the process exit code."""
|
|
129
|
+
ctx = context or default_context(stdout_writer(), stderr_writer())
|
|
130
|
+
parsed = _parse(list(sys.argv[1:] if argv is None else argv), ctx)
|
|
131
|
+
if isinstance(parsed, int):
|
|
132
|
+
return parsed
|
|
133
|
+
_setup_logging(parsed.verbose)
|
|
134
|
+
handler = getattr(commands, HANDLERS[parsed.command_name])
|
|
135
|
+
try:
|
|
136
|
+
return int(handler(parsed, ctx))
|
|
137
|
+
except CliError as err:
|
|
138
|
+
ctx.err(f"thirst: {err.message}")
|
|
139
|
+
return err.exit_code
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
ctx.err("thirst: cancelled.")
|
|
142
|
+
return EXIT_INTERRUPTED
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.exception("unexpected error")
|
|
145
|
+
ctx.err(
|
|
146
|
+
"thirst: something unexpected went wrong. "
|
|
147
|
+
"Run again with --verbose and share the output with your team."
|
|
148
|
+
)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__": # pragma: no cover
|
|
153
|
+
raise SystemExit(main())
|
thirst/client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""HTTP client for the Thirst API (plan 04.2, contract 08 §3, TRD 7.1).
|
|
2
|
+
|
|
3
|
+
Receipts are signed: ``signature = hex(HMAC_SHA256(token_ascii, ts + "." + raw_body))``. The body is
|
|
4
|
+
serialised once to bytes and exactly those bytes are signed and sent. Retries (1, 2, 4 s) only
|
|
5
|
+
happen on network errors and 5xx, never on 4xx. The token and signature are never logged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from email.utils import parsedate_to_datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .errors import ApiError
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
CONNECT_TIMEOUT_S = 5.0
|
|
30
|
+
READ_TIMEOUT_S = 15.0
|
|
31
|
+
BACKOFF_SECONDS = (1.0, 2.0, 4.0)
|
|
32
|
+
_SITE_ID = re.compile(r"s_[a-z2-7]{10}")
|
|
33
|
+
_GENERIC = "The Thirst server had a problem. Please try again in a few minutes."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ReceiptPost:
|
|
38
|
+
"""Result of posting a receipt. ``duplicate`` means the server already had this job."""
|
|
39
|
+
|
|
40
|
+
response: dict[str, Any] | None
|
|
41
|
+
duplicate: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sign(token: str, timestamp: str, body: bytes) -> str:
|
|
45
|
+
"""Hex HMAC-SHA256 of ``timestamp + "." + body`` keyed by the token's ASCII bytes."""
|
|
46
|
+
message = timestamp.encode("ascii") + b"." + body
|
|
47
|
+
return hmac.new(token.encode("ascii"), message, hashlib.sha256).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_body(payload: Mapping[str, Any]) -> bytes:
|
|
51
|
+
"""The single serialisation of a request body. Refuses NaN and Infinity."""
|
|
52
|
+
try:
|
|
53
|
+
text = json.dumps(payload, separators=(",", ":"), sort_keys=True, allow_nan=False)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
raise ApiError(
|
|
56
|
+
"Could not prepare the request: a value was missing or not a finite number.",
|
|
57
|
+
code="bad_request",
|
|
58
|
+
) from None
|
|
59
|
+
return text.encode("ascii") # ensure_ascii is on, so this cannot fail
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _error_for(response: httpx.Response) -> ApiError:
|
|
63
|
+
"""Turn an error response into a readable ``ApiError``, using the server's own words."""
|
|
64
|
+
status = response.status_code
|
|
65
|
+
try:
|
|
66
|
+
data = response.json()
|
|
67
|
+
except ValueError:
|
|
68
|
+
data = None
|
|
69
|
+
if isinstance(data, dict) and isinstance(data.get("error"), str):
|
|
70
|
+
message = data.get("message")
|
|
71
|
+
text = message if isinstance(message, str) and message else _GENERIC
|
|
72
|
+
return ApiError(text, code=data["error"], status=status)
|
|
73
|
+
if status >= 500:
|
|
74
|
+
return ApiError(_GENERIC, code="server_error", status=status)
|
|
75
|
+
return ApiError(f"The server refused the request (HTTP {status}).", "http_error", status)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Client:
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
api_url: str,
|
|
82
|
+
token: str = "",
|
|
83
|
+
site_id: str = "",
|
|
84
|
+
*,
|
|
85
|
+
transport: httpx.BaseTransport | None = None,
|
|
86
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
87
|
+
clock: Callable[[], float] = time.time,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._base = api_url.rstrip("/")
|
|
90
|
+
self._token = token
|
|
91
|
+
self._site_id = site_id
|
|
92
|
+
self._sleep = sleep
|
|
93
|
+
self._clock = clock
|
|
94
|
+
self._http = httpx.Client(
|
|
95
|
+
transport=transport,
|
|
96
|
+
timeout=httpx.Timeout(READ_TIMEOUT_S, connect=CONNECT_TIMEOUT_S),
|
|
97
|
+
follow_redirects=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"Client(api_url={self._base!r}, site_id={self._site_id!r})"
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self._http.close()
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------ public calls
|
|
107
|
+
|
|
108
|
+
def post_plan(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
109
|
+
return self._json_call("POST", "/plan", encode_body(payload), signed=False)
|
|
110
|
+
|
|
111
|
+
def post_site(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
112
|
+
return self._json_call("POST", "/sites", encode_body(payload), signed=False)
|
|
113
|
+
|
|
114
|
+
def get_site(self, site_id: str) -> dict[str, Any]:
|
|
115
|
+
if not _SITE_ID.fullmatch(site_id):
|
|
116
|
+
raise ApiError("That is not a valid site id.", code="bad_request")
|
|
117
|
+
return self._json_call("GET", f"/sites/{site_id}", None, signed=False)
|
|
118
|
+
|
|
119
|
+
def post_receipt(self, payload: Mapping[str, Any]) -> ReceiptPost:
|
|
120
|
+
"""Post a signed receipt. A 409 ``duplicate_job`` is not an error: it was recorded."""
|
|
121
|
+
try:
|
|
122
|
+
data = self._json_call("POST", "/receipts", encode_body(payload), signed=True)
|
|
123
|
+
except ApiError as err:
|
|
124
|
+
if err.code == "duplicate_job":
|
|
125
|
+
return ReceiptPost(response=None, duplicate=True)
|
|
126
|
+
raise
|
|
127
|
+
return ReceiptPost(response=data, duplicate=False)
|
|
128
|
+
|
|
129
|
+
def get_server_time(self) -> datetime:
|
|
130
|
+
"""The server's clock, from the ``Date`` header of ``GET /assumptions``."""
|
|
131
|
+
response = self._send("GET", "/assumptions", None, signed=False)
|
|
132
|
+
header = response.headers.get("date")
|
|
133
|
+
try:
|
|
134
|
+
if not header:
|
|
135
|
+
raise ValueError("no date")
|
|
136
|
+
return parsedate_to_datetime(header)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
raise ApiError(
|
|
139
|
+
"The server did not report its time.", code="bad_response", status=200
|
|
140
|
+
) from None
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------ plumbing
|
|
143
|
+
|
|
144
|
+
def _json_call(
|
|
145
|
+
self, method: str, path: str, body: bytes | None, *, signed: bool
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
response = self._send(method, path, body, signed=signed)
|
|
148
|
+
try:
|
|
149
|
+
data = response.json()
|
|
150
|
+
except ValueError:
|
|
151
|
+
data = None
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ApiError(
|
|
154
|
+
"The server sent a reply the CLI could not read.",
|
|
155
|
+
code="bad_response",
|
|
156
|
+
status=response.status_code,
|
|
157
|
+
)
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
def _headers(self, body: bytes | None, signed: bool) -> dict[str, str]:
|
|
161
|
+
headers = {"User-Agent": f"thirst-cli/{__version__}"}
|
|
162
|
+
if body is not None:
|
|
163
|
+
headers["Content-Type"] = "application/json"
|
|
164
|
+
if signed:
|
|
165
|
+
stamp = str(int(self._clock()))
|
|
166
|
+
headers["X-Thirst-Timestamp"] = stamp
|
|
167
|
+
headers["X-Thirst-Signature"] = sign(self._token, stamp, body or b"")
|
|
168
|
+
headers["X-Thirst-Site"] = self._site_id
|
|
169
|
+
return headers
|
|
170
|
+
|
|
171
|
+
def _send(self, method: str, path: str, body: bytes | None, *, signed: bool) -> httpx.Response:
|
|
172
|
+
delays = (*BACKOFF_SECONDS, None)
|
|
173
|
+
for delay in delays:
|
|
174
|
+
started = time.monotonic()
|
|
175
|
+
try:
|
|
176
|
+
response = self._http.request(
|
|
177
|
+
method, self._base + path, content=body, headers=self._headers(body, signed)
|
|
178
|
+
)
|
|
179
|
+
except httpx.HTTPError as exc:
|
|
180
|
+
logger.info("%s %s -> network error (%s)", method, path, type(exc).__name__)
|
|
181
|
+
failure = ApiError(
|
|
182
|
+
"Could not reach the Thirst server. Check your internet connection.",
|
|
183
|
+
code="network_error",
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
took_ms = int((time.monotonic() - started) * 1000)
|
|
187
|
+
logger.info("%s %s -> %d in %d ms", method, path, response.status_code, took_ms)
|
|
188
|
+
if response.status_code < 400:
|
|
189
|
+
return response
|
|
190
|
+
failure = _error_for(response)
|
|
191
|
+
if response.status_code < 500:
|
|
192
|
+
raise failure
|
|
193
|
+
if delay is None:
|
|
194
|
+
raise failure
|
|
195
|
+
self._sleep(delay)
|
|
196
|
+
raise AssertionError("unreachable") # pragma: no cover
|