landauer-gap 0.4.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.
- joules/__init__.py +2 -0
- joules/__main__.py +5 -0
- joules/bench.py +58 -0
- joules/ci.py +175 -0
- joules/cli.py +631 -0
- joules/meters.py +636 -0
- joules/model.py +60 -0
- joules/proxy.py +455 -0
- joules/sampler.py +54 -0
- joules/share.py +164 -0
- landauer_gap-0.4.0.dist-info/METADATA +200 -0
- landauer_gap-0.4.0.dist-info/RECORD +16 -0
- landauer_gap-0.4.0.dist-info/WHEEL +5 -0
- landauer_gap-0.4.0.dist-info/entry_points.txt +3 -0
- landauer_gap-0.4.0.dist-info/licenses/LICENSE +21 -0
- landauer_gap-0.4.0.dist-info/top_level.txt +1 -0
joules/__init__.py
ADDED
joules/__main__.py
ADDED
joules/bench.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Benchmarks a local model server and counts the tokens it produced.
|
|
2
|
+
|
|
3
|
+
Targets:
|
|
4
|
+
ollama:<model> Ollama on http://localhost:11434 (or --url)
|
|
5
|
+
openai:<model> any OpenAI-compatible server: vLLM, llama.cpp, LM Studio, TGI
|
|
6
|
+
(default --url http://localhost:8000/v1)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
DEFAULT_PROMPT = ("Explain, in about 200 words, why data centres measure power usage effectiveness "
|
|
17
|
+
"and what a good value looks like.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Target:
|
|
22
|
+
kind: str # ollama | openai
|
|
23
|
+
model: str
|
|
24
|
+
url: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_target(spec: str, url: str | None) -> Target:
|
|
28
|
+
kind, sep, model = spec.partition(":")
|
|
29
|
+
if not sep or kind not in ("ollama", "openai") or not model:
|
|
30
|
+
raise ValueError("target must be ollama:<model> or openai:<model>, e.g. ollama:llama3.1:8b")
|
|
31
|
+
default = "http://localhost:11434" if kind == "ollama" else "http://localhost:8000/v1"
|
|
32
|
+
return Target(kind, model, (url or default).rstrip("/"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _post(url: str, body: dict, timeout: float) -> dict:
|
|
36
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
39
|
+
return json.loads(r.read().decode())
|
|
40
|
+
except urllib.error.HTTPError as e:
|
|
41
|
+
raise RuntimeError(f"{url} answered HTTP {e.code}: {e.read().decode(errors='replace')[:300]}") from None
|
|
42
|
+
except urllib.error.URLError as e:
|
|
43
|
+
raise RuntimeError(f"cannot reach {url} ({e.reason}). Is the model server running?") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate(t: Target, prompt: str, max_tokens: int, timeout: float = 600) -> dict:
|
|
47
|
+
"""One request. Returns output tokens, prompt tokens and the server-reported generation time."""
|
|
48
|
+
if t.kind == "ollama":
|
|
49
|
+
r = _post(f"{t.url}/api/generate", {"model": t.model, "prompt": prompt, "stream": False,
|
|
50
|
+
"options": {"num_predict": max_tokens, "temperature": 0}}, timeout)
|
|
51
|
+
return {"output_tokens": int(r.get("eval_count", 0)), "prompt_tokens": int(r.get("prompt_eval_count", 0)),
|
|
52
|
+
"gen_s": r.get("eval_duration", 0) / 1e9 or None}
|
|
53
|
+
t0 = time.monotonic()
|
|
54
|
+
r = _post(f"{t.url}/chat/completions", {"model": t.model, "messages": [{"role": "user", "content": prompt}],
|
|
55
|
+
"max_tokens": max_tokens, "temperature": 0}, timeout)
|
|
56
|
+
u = r.get("usage") or {}
|
|
57
|
+
return {"output_tokens": int(u.get("completion_tokens", 0)), "prompt_tokens": int(u.get("prompt_tokens", 0)),
|
|
58
|
+
"gen_s": time.monotonic() - t0}
|
joules/ci.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""joules ci: an energy check for pull requests.
|
|
2
|
+
|
|
3
|
+
Runs a command on this checkout and on the base branch (in a temporary git worktree), several times,
|
|
4
|
+
alternating so drift hits both sides equally, and compares the medians. Writes a Markdown summary
|
|
5
|
+
(the GitHub job summary when it runs in Actions), can post or update one pull request comment, and
|
|
6
|
+
fails when energy grows by more than a budget.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import statistics
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
from .meters import Discovery
|
|
22
|
+
from .sampler import Sampler
|
|
23
|
+
|
|
24
|
+
MARKER = "<!-- landauer-gap-energy-check -->"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def measure(d: Discovery, cmd: list[str], cwd: str, interval: float) -> dict:
|
|
28
|
+
with Sampler(d.meters, interval) as s:
|
|
29
|
+
p = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL)
|
|
30
|
+
return {"energy_j": sum(m.energy_j for m in d.meters), "duration_s": s.duration_s, "exit_code": p.returncode,
|
|
31
|
+
"devices": {m.name: m.energy_j for m in d.meters}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def git(*args: str, cwd: str | None = None) -> str:
|
|
35
|
+
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
36
|
+
if p.returncode != 0:
|
|
37
|
+
raise RuntimeError(f"git {' '.join(args)}: {(p.stderr or p.stdout).strip()[:300]}")
|
|
38
|
+
return p.stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextlib.contextmanager
|
|
42
|
+
def worktree(ref: str):
|
|
43
|
+
"""A clean checkout of ref next to this one, removed afterwards."""
|
|
44
|
+
tmp = tempfile.mkdtemp(prefix="joules-base-")
|
|
45
|
+
path = os.path.join(tmp, "base")
|
|
46
|
+
git("-c", "advice.detachedHead=false", "worktree", "add", "--detach", path, ref)
|
|
47
|
+
try:
|
|
48
|
+
yield path
|
|
49
|
+
finally:
|
|
50
|
+
subprocess.run(["git", "worktree", "remove", "--force", path], capture_output=True)
|
|
51
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_base() -> str | None:
|
|
55
|
+
b = os.environ.get("GITHUB_BASE_REF")
|
|
56
|
+
return f"origin/{b}" if b else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def summarize(head: list[dict], base: list[dict] | None, budget: float | None) -> dict:
|
|
60
|
+
h = statistics.median(r["energy_j"] for r in head)
|
|
61
|
+
out = {"head_j": h, "head_s": statistics.median(r["duration_s"] for r in head),
|
|
62
|
+
"head_range": [min(r["energy_j"] for r in head), max(r["energy_j"] for r in head)], "runs": len(head)}
|
|
63
|
+
if base:
|
|
64
|
+
b = statistics.median(r["energy_j"] for r in base)
|
|
65
|
+
out.update(base_j=b, base_s=statistics.median(r["duration_s"] for r in base),
|
|
66
|
+
base_range=[min(r["energy_j"] for r in base), max(r["energy_j"] for r in base)])
|
|
67
|
+
out["change_pct"] = (h - b) / b * 100 if b > 0 else None
|
|
68
|
+
# The runs overlap: the difference is not bigger than the run-to-run spread.
|
|
69
|
+
out["within_noise"] = out["head_range"][0] <= out["base_range"][1] and out["base_range"][0] <= out["head_range"][1]
|
|
70
|
+
out["over_budget"] = bool(budget is not None and out["change_pct"] is not None and out["change_pct"] > budget
|
|
71
|
+
and not out["within_noise"])
|
|
72
|
+
out["budget_pct"] = budget
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _j(x: float) -> str:
|
|
77
|
+
for unit, k in (("MJ", 1e6), ("kJ", 1e3)):
|
|
78
|
+
if abs(x) >= k:
|
|
79
|
+
return f"{x / k:.3g} {unit}"
|
|
80
|
+
return f"{x:.3g} J"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def markdown(s: dict, *, head_name: str, base_name: str | None, devices: list[str], estimated: bool,
|
|
84
|
+
command: str, track_link: str | None = None) -> str:
|
|
85
|
+
lines = [MARKER, "### โก Energy check", ""]
|
|
86
|
+
if "base_j" in s:
|
|
87
|
+
c = s["change_pct"]
|
|
88
|
+
if c is None:
|
|
89
|
+
verdict = "no comparison (the base used no energy)"
|
|
90
|
+
elif s["over_budget"]:
|
|
91
|
+
verdict = f"โ **{c:+.1f}%**, over the {s['budget_pct']:g}% budget"
|
|
92
|
+
elif s["within_noise"]:
|
|
93
|
+
verdict = f"โ **{c:+.1f}%**, within run-to-run noise"
|
|
94
|
+
elif c > 0:
|
|
95
|
+
verdict = f"๐บ **{c:+.1f}%** more energy"
|
|
96
|
+
else:
|
|
97
|
+
verdict = f"โ
**{c:+.1f}%**, less energy"
|
|
98
|
+
lines += [f"{verdict}", "",
|
|
99
|
+
f"| | energy (median of {s['runs']}) | range | time |", "|---|---|---|---|",
|
|
100
|
+
f"| this change `{head_name}` | **{_j(s['head_j'])}** | {_j(s['head_range'][0])} โ {_j(s['head_range'][1])} | {s['head_s']:.2f} s |",
|
|
101
|
+
f"| base `{base_name}` | {_j(s['base_j'])} | {_j(s['base_range'][0])} โ {_j(s['base_range'][1])} | {s['base_s']:.2f} s |"]
|
|
102
|
+
else:
|
|
103
|
+
lines += [f"| | energy (median of {s['runs']}) | range | time |", "|---|---|---|---|",
|
|
104
|
+
f"| `{head_name}` | **{_j(s['head_j'])}** | {_j(s['head_range'][0])} โ {_j(s['head_range'][1])} | {s['head_s']:.2f} s |"]
|
|
105
|
+
lines += ["", f"Command: `{command.replace('`', chr(39))[:200]}` ", f"Measured with: {', '.join(devices)}"]
|
|
106
|
+
if estimated:
|
|
107
|
+
lines.append("> Estimated from CPU time: this runner has no energy counters. Good for spotting changes; "
|
|
108
|
+
"run on a self-hosted runner for measured joules.")
|
|
109
|
+
if track_link:
|
|
110
|
+
lines.append(f"\n[History of this check on Landauer Gap]({track_link})")
|
|
111
|
+
lines.append("\n<sub>[Landauer Gap](https://landauer-gap.vercel.app) ยท `joules ci`</sub>")
|
|
112
|
+
return "\n".join(lines) + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _api(method: str, url: str, token: str, body: dict | None = None):
|
|
116
|
+
req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None, headers={
|
|
117
|
+
"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "User-Agent": "joules-ci",
|
|
118
|
+
"X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json"})
|
|
119
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
120
|
+
return json.loads(r.read().decode() or "null")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def post_comment(md: str, env: dict | None = None) -> str:
|
|
124
|
+
"""Creates or updates this check's comment on the pull request. Returns what happened."""
|
|
125
|
+
env = env if env is not None else os.environ
|
|
126
|
+
token, repo = env.get("GITHUB_TOKEN"), env.get("GITHUB_REPOSITORY")
|
|
127
|
+
api = (env.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
|
|
128
|
+
try:
|
|
129
|
+
with open(env.get("GITHUB_EVENT_PATH") or "") as f:
|
|
130
|
+
pr = (json.load(f).get("pull_request") or {}).get("number")
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
pr = None
|
|
133
|
+
if not (token and repo and pr):
|
|
134
|
+
return "not a pull request (or no GITHUB_TOKEN): comment skipped"
|
|
135
|
+
try:
|
|
136
|
+
existing = None
|
|
137
|
+
for page in range(1, 11):
|
|
138
|
+
items = _api("GET", f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100&page={page}", token) or []
|
|
139
|
+
existing = next((c for c in items if MARKER in (c.get("body") or "")), None)
|
|
140
|
+
if existing or len(items) < 100:
|
|
141
|
+
break
|
|
142
|
+
if existing:
|
|
143
|
+
_api("PATCH", f"{api}/repos/{repo}/issues/comments/{existing['id']}", token, {"body": md})
|
|
144
|
+
return f"updated the energy comment on #{pr}"
|
|
145
|
+
_api("POST", f"{api}/repos/{repo}/issues/{pr}/comments", token, {"body": md})
|
|
146
|
+
return f"commented on #{pr}"
|
|
147
|
+
except urllib.error.HTTPError as e:
|
|
148
|
+
hint = " (pull requests from forks get a read-only token; give the job `permissions: pull-requests: write`)" if e.code in (403, 404) else ""
|
|
149
|
+
return f"could not comment: HTTP {e.code}{hint}"
|
|
150
|
+
except urllib.error.URLError as e:
|
|
151
|
+
return f"could not comment: {e.reason}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_outputs(s: dict, env: dict | None = None) -> None:
|
|
155
|
+
env = env if env is not None else os.environ
|
|
156
|
+
out = env.get("GITHUB_OUTPUT")
|
|
157
|
+
if not out:
|
|
158
|
+
return
|
|
159
|
+
with open(out, "a") as f:
|
|
160
|
+
f.write(f"energy-j={s['head_j']:.6g}\n")
|
|
161
|
+
if "base_j" in s:
|
|
162
|
+
f.write(f"base-energy-j={s['base_j']:.6g}\n")
|
|
163
|
+
f.write(f"change-pct={'' if s['change_pct'] is None else format(s['change_pct'], '.4g')}\n")
|
|
164
|
+
f.write(f"over-budget={'true' if s.get('over_budget') else 'false'}\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_setup(cmd: str | None, cwd: str) -> None:
|
|
168
|
+
if cmd:
|
|
169
|
+
p = subprocess.run(cmd, shell=True, cwd=cwd)
|
|
170
|
+
if p.returncode != 0:
|
|
171
|
+
raise RuntimeError(f"setup failed in {cwd} (exit {p.returncode}): {cmd}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def now_iso() -> str:
|
|
175
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|