ceteris 0.2.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.
- ceteris/__init__.py +47 -0
- ceteris/__main__.py +3 -0
- ceteris/adapters/__init__.py +315 -0
- ceteris/capture.py +62 -0
- ceteris/certificate.py +98 -0
- ceteris/cli.py +360 -0
- ceteris/collectors/__init__.py +59 -0
- ceteris/collectors/_container.py +55 -0
- ceteris/collectors/_run.py +87 -0
- ceteris/collectors/build.py +137 -0
- ceteris/collectors/deps.py +117 -0
- ceteris/collectors/hardware.py +260 -0
- ceteris/collectors/parallelism.py +99 -0
- ceteris/collectors/runtime.py +106 -0
- ceteris/collectors/scheduler.py +112 -0
- ceteris/collectors/source.py +89 -0
- ceteris/collectors/system.py +188 -0
- ceteris/comparators.py +114 -0
- ceteris/compare.py +364 -0
- ceteris/config.py +134 -0
- ceteris/defaults.json +76 -0
- ceteris/doctor.py +168 -0
- ceteris/execution.py +120 -0
- ceteris/metrics.py +67 -0
- ceteris/model.py +181 -0
- ceteris/nodes.py +203 -0
- ceteris/packs/__init__.py +55 -0
- ceteris/packs/cuda.json +47 -0
- ceteris/packs/go.json +31 -0
- ceteris/packs/hpc.json +46 -0
- ceteris/packs/jvm.json +33 -0
- ceteris/packs/node.json +29 -0
- ceteris/packs/python.json +43 -0
- ceteris/packs/rocm.json +48 -0
- ceteris/packs/rust.json +34 -0
- ceteris/pytest_plugin.py +90 -0
- ceteris/render.py +311 -0
- ceteris/runner.py +192 -0
- ceteris/stats.py +138 -0
- ceteris/store.py +94 -0
- ceteris-0.2.0.dist-info/METADATA +684 -0
- ceteris-0.2.0.dist-info/RECORD +46 -0
- ceteris-0.2.0.dist-info/WHEEL +5 -0
- ceteris-0.2.0.dist-info/entry_points.txt +5 -0
- ceteris-0.2.0.dist-info/licenses/LICENSE +21 -0
- ceteris-0.2.0.dist-info/top_level.txt +1 -0
ceteris/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ceteris -- capture benchmark run identity, then gate comparisons between runs.
|
|
2
|
+
|
|
3
|
+
Named for *ceteris paribus*: all other things being equal. That is precisely
|
|
4
|
+
the claim a benchmark comparison makes, and precisely the claim this tool
|
|
5
|
+
checks.
|
|
6
|
+
|
|
7
|
+
Library use mirrors the CLI:
|
|
8
|
+
|
|
9
|
+
from ceteris import capture, compare, Config
|
|
10
|
+
|
|
11
|
+
a = capture(repo="~/codes/hpx")
|
|
12
|
+
b = capture(repo="~/codes/hpx")
|
|
13
|
+
report = compare([a, b], vary=["runtime.env.LCI_ATTR_PACKET_SIZE"])
|
|
14
|
+
assert report.exit_code == 0
|
|
15
|
+
|
|
16
|
+
`capture()` returning a plain Fingerprint (rather than writing a file) is what
|
|
17
|
+
would let a future `ceteris run -- mpirun ...` wrapper call it before and after
|
|
18
|
+
a job to detect mid-run drift. Nothing in v1 uses that, but the shape is cheap
|
|
19
|
+
to preserve now and expensive to retrofit.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .compare import Report, compare
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .model import Field, Fingerprint, State
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
|
|
28
|
+
def __getattr__(name: str):
|
|
29
|
+
# capture is resolved lazily so that `from ceteris import compare` does not
|
|
30
|
+
# pull in the subprocess-running collectors.
|
|
31
|
+
if name == "capture":
|
|
32
|
+
from .capture import capture
|
|
33
|
+
|
|
34
|
+
return capture
|
|
35
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"capture",
|
|
40
|
+
"compare",
|
|
41
|
+
"Config",
|
|
42
|
+
"Field",
|
|
43
|
+
"Fingerprint",
|
|
44
|
+
"Report",
|
|
45
|
+
"State",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
ceteris/__main__.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Harness adapters: read the numbers the benchmark already produces.
|
|
2
|
+
|
|
3
|
+
`ceteris run -- hyperfine ...` should need no --metric. Each adapter knows
|
|
4
|
+
one harness: how to recognise it on the command line, how to make it write
|
|
5
|
+
machine-readable output if it was not going to, and how to turn that output
|
|
6
|
+
into metrics. The metric values are the harness's own statistics; ceteris
|
|
7
|
+
adds nothing to them.
|
|
8
|
+
|
|
9
|
+
The harness stays in charge of measurement. ceteris only asks where the
|
|
10
|
+
result went.
|
|
11
|
+
|
|
12
|
+
Three fixture formats (hyperfine, Google Benchmark, pytest-benchmark) are
|
|
13
|
+
recorded from real runs. The others were reconstructed from documentation
|
|
14
|
+
and are labelled as such in their tests; a real file from any of them is a
|
|
15
|
+
welcome contribution.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import glob
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
from ..model import Field, unknown, value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _slug(text: str) -> str:
|
|
33
|
+
return re.sub(r"[^A-Za-z0-9._/-]+", "_", text).strip("_")[:60]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _arg_value(argv: list[str], *names: str) -> str | None:
|
|
37
|
+
for i, a in enumerate(argv):
|
|
38
|
+
for n in names:
|
|
39
|
+
if a == n and i + 1 < len(argv):
|
|
40
|
+
return argv[i + 1]
|
|
41
|
+
if a.startswith(n + "="):
|
|
42
|
+
return a[len(n) + 1 :]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _num(x: Any) -> Any:
|
|
47
|
+
try:
|
|
48
|
+
return float(x)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return x
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Plan:
|
|
55
|
+
"""What the adapter decided before the run."""
|
|
56
|
+
|
|
57
|
+
adapter: str
|
|
58
|
+
argv: list[str] # possibly augmented command to actually run
|
|
59
|
+
output: str | None = None # file to read afterwards, if any
|
|
60
|
+
added_output: bool = False # we injected the export flag ourselves
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Adapter:
|
|
64
|
+
name = "base"
|
|
65
|
+
|
|
66
|
+
def detect(self, argv: list[str]) -> bool: # pragma: no cover - interface
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def plan(self, argv: list[str], cwd: str) -> Plan:
|
|
70
|
+
return Plan(self.name, list(argv))
|
|
71
|
+
|
|
72
|
+
def collect(self, plan: Plan, stdout: str, cwd: str, started: float) -> dict[str, Field]: # pragma: no cover
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
# -- helpers -------------------------------------------------------------
|
|
76
|
+
def _read_json(self, path: str | None) -> tuple[Any, str | None]:
|
|
77
|
+
if not path:
|
|
78
|
+
return None, "no output file"
|
|
79
|
+
try:
|
|
80
|
+
with open(path, encoding="utf-8") as h:
|
|
81
|
+
return json.load(h), None
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
return None, f"cannot read {path}: {exc.strerror or exc}"
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
return None, f"{path} is not valid JSON: {exc}"
|
|
86
|
+
|
|
87
|
+
def _failed(self, why: str) -> dict[str, Field]:
|
|
88
|
+
return {f"{self.name}._adapter": unknown(why, provenance=self.name)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _basename(argv: list[str]) -> str:
|
|
92
|
+
return os.path.basename(argv[0]) if argv else ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Hyperfine(Adapter):
|
|
96
|
+
name = "hyperfine"
|
|
97
|
+
|
|
98
|
+
def detect(self, argv):
|
|
99
|
+
return _basename(argv) == "hyperfine"
|
|
100
|
+
|
|
101
|
+
def plan(self, argv, cwd):
|
|
102
|
+
out = _arg_value(argv, "--export-json")
|
|
103
|
+
if out:
|
|
104
|
+
return Plan(self.name, list(argv), os.path.join(cwd, out))
|
|
105
|
+
path = tempfile.mktemp(prefix="ceteris-hyperfine-", suffix=".json", dir=cwd)
|
|
106
|
+
return Plan(self.name, list(argv) + ["--export-json", path], path, added_output=True)
|
|
107
|
+
|
|
108
|
+
def collect(self, plan, stdout, cwd, started):
|
|
109
|
+
data, err = self._read_json(plan.output)
|
|
110
|
+
if err:
|
|
111
|
+
return self._failed(err)
|
|
112
|
+
out: dict[str, Field] = {}
|
|
113
|
+
prov = f"hyperfine --export-json ({'injected' if plan.added_output else 'given'})"
|
|
114
|
+
results = data.get("results", [])
|
|
115
|
+
# Metric names must be stable across configurations, or the noise
|
|
116
|
+
# floor cannot compare them. The command is the thing that varies
|
|
117
|
+
# between configurations, so it must not be in the name; a single
|
|
118
|
+
# command is 'hyperfine.median_s', several are numbered in order.
|
|
119
|
+
for i, r in enumerate(results, 1):
|
|
120
|
+
key = "hyperfine" if len(results) == 1 else f"hyperfine.{i}"
|
|
121
|
+
for stat in ("median", "min"):
|
|
122
|
+
if stat in r:
|
|
123
|
+
out[f"{key}.{stat}_s"] = value(_num(r[stat]), provenance=f"{prov}; command: {r.get('command', '?')}")
|
|
124
|
+
return out or self._failed("no results in export")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class GoogleBenchmark(Adapter):
|
|
128
|
+
name = "gbench"
|
|
129
|
+
|
|
130
|
+
def detect(self, argv):
|
|
131
|
+
return any(a.startswith("--benchmark_") for a in argv[1:])
|
|
132
|
+
|
|
133
|
+
def plan(self, argv, cwd):
|
|
134
|
+
out = _arg_value(argv, "--benchmark_out")
|
|
135
|
+
if out:
|
|
136
|
+
return Plan(self.name, list(argv), os.path.join(cwd, out))
|
|
137
|
+
path = tempfile.mktemp(prefix="ceteris-gbench-", suffix=".json", dir=cwd)
|
|
138
|
+
return Plan(self.name, list(argv) + [f"--benchmark_out={path}", "--benchmark_out_format=json"], path, True)
|
|
139
|
+
|
|
140
|
+
def collect(self, plan, stdout, cwd, started):
|
|
141
|
+
data, err = self._read_json(plan.output)
|
|
142
|
+
if err:
|
|
143
|
+
return self._failed(err)
|
|
144
|
+
out: dict[str, Field] = {}
|
|
145
|
+
for b in data.get("benchmarks", []):
|
|
146
|
+
if b.get("run_type") == "aggregate":
|
|
147
|
+
continue
|
|
148
|
+
unit = b.get("time_unit", "ns")
|
|
149
|
+
out[f"gbench.{b.get('name', '?')}.real_time_{unit}"] = value(_num(b.get("real_time")), provenance="--benchmark_out json")
|
|
150
|
+
return out or self._failed("no benchmarks in output")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PytestBenchmark(Adapter):
|
|
154
|
+
name = "pytest"
|
|
155
|
+
|
|
156
|
+
def detect(self, argv):
|
|
157
|
+
return any(a.startswith("--benchmark") for a in argv[1:]) and (
|
|
158
|
+
_basename(argv) in ("pytest", "py.test") or "pytest" in argv[:3]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def plan(self, argv, cwd):
|
|
162
|
+
out = _arg_value(argv, "--benchmark-json")
|
|
163
|
+
if out:
|
|
164
|
+
return Plan(self.name, list(argv), os.path.join(cwd, out))
|
|
165
|
+
path = tempfile.mktemp(prefix="ceteris-pytest-", suffix=".json", dir=cwd)
|
|
166
|
+
return Plan(self.name, list(argv) + [f"--benchmark-json={path}"], path, True)
|
|
167
|
+
|
|
168
|
+
def collect(self, plan, stdout, cwd, started):
|
|
169
|
+
data, err = self._read_json(plan.output)
|
|
170
|
+
if err:
|
|
171
|
+
return self._failed(err)
|
|
172
|
+
out: dict[str, Field] = {}
|
|
173
|
+
for b in data.get("benchmarks", []):
|
|
174
|
+
st = b.get("stats", {})
|
|
175
|
+
out[f"pytest.{b.get('name', '?')}.median_s"] = value(_num(st.get("median")), provenance="--benchmark-json")
|
|
176
|
+
return out or self._failed("no benchmarks in output")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class JMH(Adapter):
|
|
180
|
+
name = "jmh"
|
|
181
|
+
|
|
182
|
+
def detect(self, argv):
|
|
183
|
+
return "-rf" in argv and _arg_value(argv, "-rf") == "json"
|
|
184
|
+
|
|
185
|
+
def plan(self, argv, cwd):
|
|
186
|
+
out = _arg_value(argv, "-rff") or "jmh-result.json"
|
|
187
|
+
return Plan(self.name, list(argv), os.path.join(cwd, out))
|
|
188
|
+
|
|
189
|
+
def collect(self, plan, stdout, cwd, started):
|
|
190
|
+
data, err = self._read_json(plan.output)
|
|
191
|
+
if err:
|
|
192
|
+
return self._failed(err)
|
|
193
|
+
out: dict[str, Field] = {}
|
|
194
|
+
for b in data if isinstance(data, list) else []:
|
|
195
|
+
name = b.get("benchmark", "?").split(".")[-2:]
|
|
196
|
+
pm = b.get("primaryMetric", {})
|
|
197
|
+
unit = _slug(pm.get("scoreUnit", "")).replace("/", "_")
|
|
198
|
+
out[f"jmh.{'.'.join(name)}.{b.get('mode', 'score')}_{unit}"] = value(_num(pm.get("score")), provenance="jmh -rf json")
|
|
199
|
+
return out or self._failed("no benchmarks in output")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Criterion(Adapter):
|
|
203
|
+
name = "criterion"
|
|
204
|
+
|
|
205
|
+
def detect(self, argv):
|
|
206
|
+
return _basename(argv) == "cargo" and "bench" in argv[1:3]
|
|
207
|
+
|
|
208
|
+
def collect(self, plan, stdout, cwd, started):
|
|
209
|
+
out: dict[str, Field] = {}
|
|
210
|
+
for path in glob.glob(os.path.join(cwd, "target", "criterion", "**", "new", "estimates.json"), recursive=True):
|
|
211
|
+
if os.path.getmtime(path) < started - 1:
|
|
212
|
+
continue
|
|
213
|
+
data, err = self._read_json(path)
|
|
214
|
+
if err:
|
|
215
|
+
continue
|
|
216
|
+
bench = os.path.relpath(os.path.dirname(os.path.dirname(path)), os.path.join(cwd, "target", "criterion"))
|
|
217
|
+
out[f"criterion.{_slug(bench)}.median_ns"] = value(_num(data.get("median", {}).get("point_estimate")), provenance=path)
|
|
218
|
+
return out or self._failed("no fresh target/criterion/*/new/estimates.json")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class OSU(Adapter):
|
|
222
|
+
name = "osu"
|
|
223
|
+
|
|
224
|
+
def detect(self, argv):
|
|
225
|
+
return any(os.path.basename(a).startswith("osu_") for a in argv)
|
|
226
|
+
|
|
227
|
+
def collect(self, plan, stdout, cwd, started):
|
|
228
|
+
out: dict[str, Field] = {}
|
|
229
|
+
unit = "value"
|
|
230
|
+
for line in stdout.splitlines():
|
|
231
|
+
m = re.match(r"^#\s*Size\s+(.+?)\s*$", line)
|
|
232
|
+
if m:
|
|
233
|
+
unit = re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_")
|
|
234
|
+
continue
|
|
235
|
+
m = re.match(r"^\s*(\d+)\s+([0-9.]+)\s*$", line)
|
|
236
|
+
if m:
|
|
237
|
+
out[f"osu.{unit}.{m.group(1)}B"] = value(float(m.group(2)), provenance="osu stdout table")
|
|
238
|
+
return out or self._failed("no size/value rows in stdout")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class NCCLTests(Adapter):
|
|
242
|
+
name = "nccl"
|
|
243
|
+
|
|
244
|
+
def detect(self, argv):
|
|
245
|
+
return any(os.path.basename(a).endswith("_perf") for a in argv)
|
|
246
|
+
|
|
247
|
+
def collect(self, plan, stdout, cwd, started):
|
|
248
|
+
out: dict[str, Field] = {}
|
|
249
|
+
for line in stdout.splitlines():
|
|
250
|
+
m = re.match(r"^\s*(\d+)\s+\d+\s+\S+\s+\S+\s+\S+\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)", line)
|
|
251
|
+
if m:
|
|
252
|
+
out[f"nccl.busbw_GBps.{m.group(1)}B"] = value(float(m.group(4)), provenance="nccl-tests stdout (out-of-place busbw)")
|
|
253
|
+
m = re.search(r"Avg bus bandwidth\s*:\s*([0-9.]+)", stdout)
|
|
254
|
+
if m:
|
|
255
|
+
out["nccl.avg_busbw_GBps"] = value(float(m.group(1)), provenance="nccl-tests stdout")
|
|
256
|
+
return out or self._failed("no result rows in stdout")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class MLPerf(Adapter):
|
|
260
|
+
name = "mlperf"
|
|
261
|
+
|
|
262
|
+
def detect(self, argv):
|
|
263
|
+
return any("mlperf" in a.lower() or "loadgen" in a.lower() for a in argv)
|
|
264
|
+
|
|
265
|
+
def collect(self, plan, stdout, cwd, started):
|
|
266
|
+
candidates = [p for p in glob.glob(os.path.join(cwd, "**", "mlperf_log_summary.txt"), recursive=True)
|
|
267
|
+
if os.path.getmtime(p) >= started - 1]
|
|
268
|
+
if not candidates:
|
|
269
|
+
return self._failed("no fresh mlperf_log_summary.txt under the working directory")
|
|
270
|
+
path = max(candidates, key=os.path.getmtime)
|
|
271
|
+
try:
|
|
272
|
+
text = open(path, encoding="utf-8").read()
|
|
273
|
+
except OSError as exc:
|
|
274
|
+
return self._failed(str(exc))
|
|
275
|
+
out: dict[str, Field] = {}
|
|
276
|
+
for label, key in (("Samples per second", "samples_per_second"), ("QPS w/ loadgen overhead", "qps"),
|
|
277
|
+
("90th percentile latency \\(ns\\)", "p90_latency_ns"), ("Completed samples per second", "completed_samples_per_second")):
|
|
278
|
+
m = re.search(rf"{label}\s*:\s*([0-9.]+)", text)
|
|
279
|
+
if m:
|
|
280
|
+
out[f"mlperf.{key}"] = value(float(m.group(1)), provenance=path)
|
|
281
|
+
m = re.search(r"Result is\s*:\s*(\w+)", text)
|
|
282
|
+
if m:
|
|
283
|
+
out["mlperf.result"] = value(m.group(1), provenance=path)
|
|
284
|
+
return out or self._failed(f"no recognised fields in {path}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
ADAPTERS: list[Adapter] = [Hyperfine(), GoogleBenchmark(), PytestBenchmark(), JMH(), Criterion(), OSU(), NCCLTests(), MLPerf()]
|
|
288
|
+
BY_NAME = {a.name: a for a in ADAPTERS}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def detect(argv: list[str]) -> Adapter | None:
|
|
292
|
+
for adapter in ADAPTERS:
|
|
293
|
+
if adapter.detect(argv):
|
|
294
|
+
return adapter
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ingest(path: str, fmt: str | None = None) -> dict[str, Field]:
|
|
299
|
+
"""Explicit --ingest FILE[:format]: parse an output file without running."""
|
|
300
|
+
if fmt is None:
|
|
301
|
+
base = os.path.basename(path).lower()
|
|
302
|
+
fmt = "mlperf" if "mlperf" in base else "jmh" if "jmh" in base else None
|
|
303
|
+
if fmt is None:
|
|
304
|
+
data, err = Adapter()._read_json(path)
|
|
305
|
+
if isinstance(data, dict) and "results" in data:
|
|
306
|
+
fmt = "hyperfine"
|
|
307
|
+
elif isinstance(data, dict) and "benchmarks" in data:
|
|
308
|
+
fmt = "gbench" if data["benchmarks"] and "real_time" in data["benchmarks"][0] else "pytest"
|
|
309
|
+
elif isinstance(data, list):
|
|
310
|
+
fmt = "jmh"
|
|
311
|
+
adapter = BY_NAME.get(fmt or "")
|
|
312
|
+
if adapter is None:
|
|
313
|
+
return {"ingest._adapter": unknown(f"cannot determine format of {path}; pass FILE:format", provenance="--ingest")}
|
|
314
|
+
plan = Plan(adapter.name, [], path)
|
|
315
|
+
return adapter.collect(plan, "", os.path.dirname(os.path.abspath(path)), 0)
|
ceteris/capture.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Capture orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import platform
|
|
7
|
+
|
|
8
|
+
from . import nodes
|
|
9
|
+
from .collectors import Context, run_all
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .model import Fingerprint
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def capture(
|
|
15
|
+
repo: str | None = None,
|
|
16
|
+
cmake_cache: str | None = None,
|
|
17
|
+
compiler: str | None = None,
|
|
18
|
+
cxx_flags: str | None = None,
|
|
19
|
+
build_type: str | None = None,
|
|
20
|
+
label: str | None = None,
|
|
21
|
+
cfg: Config | None = None,
|
|
22
|
+
) -> Fingerprint:
|
|
23
|
+
"""Collect a fingerprint of the current environment.
|
|
24
|
+
|
|
25
|
+
Returns a Fingerprint rather than writing a file so the same call is usable
|
|
26
|
+
from a script, a test, or a future wrapper that captures either side of a
|
|
27
|
+
job.
|
|
28
|
+
"""
|
|
29
|
+
from . import __version__
|
|
30
|
+
|
|
31
|
+
cfg = cfg or Config.load()
|
|
32
|
+
ctx = Context(
|
|
33
|
+
cfg=cfg,
|
|
34
|
+
repo=repo,
|
|
35
|
+
cmake_cache=cmake_cache,
|
|
36
|
+
compiler=compiler,
|
|
37
|
+
cxx_flags=cxx_flags,
|
|
38
|
+
build_type=build_type,
|
|
39
|
+
)
|
|
40
|
+
fields = run_all(ctx)
|
|
41
|
+
capture_args = []
|
|
42
|
+
for flag, val in (
|
|
43
|
+
("--repo", repo), ("--cmake-cache", cmake_cache), ("--compiler", compiler),
|
|
44
|
+
("--cxx-flags", cxx_flags), ("--build-type", build_type),
|
|
45
|
+
):
|
|
46
|
+
if val is not None:
|
|
47
|
+
capture_args += [f"{flag}={val}"]
|
|
48
|
+
meta = {
|
|
49
|
+
"label": label or platform.uname().node,
|
|
50
|
+
# captured_at lives in meta and never in the comparable body, so two
|
|
51
|
+
# captures of an identical environment produce an identical hash.
|
|
52
|
+
"captured_at": _dt.datetime.now(_dt.timezone.utc).isoformat(
|
|
53
|
+
timespec="seconds"
|
|
54
|
+
),
|
|
55
|
+
"tool": "ceteris",
|
|
56
|
+
"tool_version": __version__,
|
|
57
|
+
}
|
|
58
|
+
fingerprint = Fingerprint(fields=fields, meta=meta)
|
|
59
|
+
# Under a multi-node allocation this fans out one capture per node and
|
|
60
|
+
# merges the node-local fields; on a single host it only normalises the
|
|
61
|
+
# hostname fields so single- and multi-node records share a schema.
|
|
62
|
+
return nodes.apply(fingerprint, capture_args)
|
ceteris/certificate.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The certificate: one line that says a comparison was valid, and enough to
|
|
2
|
+
recompute that claim from the records.
|
|
3
|
+
|
|
4
|
+
ceteris-certified v1 configs=2 n=5,5 vary=build.cxx_flags waive= verdict=ok noise=15% sha256:<h>
|
|
5
|
+
|
|
6
|
+
The hash covers the records' content hashes, the declarations, and the
|
|
7
|
+
verdict. `ceteris verify LINE FILES...` re-runs the comparison with the
|
|
8
|
+
declarations parsed out of the line and checks the hash. A record edited
|
|
9
|
+
after the fact, a declaration quietly widened, or a different set of files
|
|
10
|
+
all fail verification.
|
|
11
|
+
|
|
12
|
+
This is the thing that gets pasted into a README, a PR, or a paper's
|
|
13
|
+
artifact appendix, which is how a check turns into a norm.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Sequence
|
|
23
|
+
|
|
24
|
+
from .compare import Report
|
|
25
|
+
|
|
26
|
+
VERSION = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hash(report: Report) -> str:
|
|
30
|
+
payload = {
|
|
31
|
+
"records": sorted(g.content_hash for g in report.configs for _ in g.members),
|
|
32
|
+
"vary": sorted(report.declared),
|
|
33
|
+
"waive": sorted(report.waived.items()),
|
|
34
|
+
"strict": report.strict,
|
|
35
|
+
"exit_code": report.exit_code,
|
|
36
|
+
"noise": [(v.metric, v.assessed, v.within_noise) for v in report.noise],
|
|
37
|
+
}
|
|
38
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _noise_summary(report: Report) -> str:
|
|
42
|
+
assessed = [v for v in report.noise if v.assessed]
|
|
43
|
+
if not assessed:
|
|
44
|
+
return "unassessed"
|
|
45
|
+
return f"{max(v.noise for v in assessed):.0%}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def issue(report: Report) -> str:
|
|
49
|
+
verdict = {0: "ok", 1: "confounded", 2: "indeterminate", 4: "within-noise"}.get(report.exit_code, "invalid")
|
|
50
|
+
n = ",".join(str(g.n) for g in report.configs)
|
|
51
|
+
vary = ",".join(report.declared)
|
|
52
|
+
waive = ";".join(f"{k}:{v}" for k, v in report.waived.items()).replace(" ", "_")
|
|
53
|
+
return (
|
|
54
|
+
f"ceteris-certified v{VERSION} configs={len(report.configs)} n={n} "
|
|
55
|
+
f"vary={vary} waive={waive} strict={'1' if report.strict else '0'} "
|
|
56
|
+
f"verdict={verdict} noise={_noise_summary(report)} sha256:{_hash(report)}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Parsed:
|
|
62
|
+
vary: list[str]
|
|
63
|
+
waive: dict[str, str]
|
|
64
|
+
strict: bool
|
|
65
|
+
verdict: str
|
|
66
|
+
digest: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse(line: str) -> Parsed:
|
|
70
|
+
m = re.match(
|
|
71
|
+
r"ceteris-certified v(\d+) configs=\d+ n=[\d,]* vary=(\S*) waive=(\S*) strict=([01]) "
|
|
72
|
+
r"verdict=(\S+) noise=\S+ sha256:([0-9a-f]{64})\s*$",
|
|
73
|
+
line.strip(),
|
|
74
|
+
)
|
|
75
|
+
if not m:
|
|
76
|
+
raise ValueError("not a ceteris certificate line")
|
|
77
|
+
if int(m.group(1)) != VERSION:
|
|
78
|
+
raise ValueError(f"certificate version {m.group(1)} not supported")
|
|
79
|
+
waive = {}
|
|
80
|
+
if m.group(3):
|
|
81
|
+
for item in m.group(3).split(";"):
|
|
82
|
+
k, _, v = item.partition(":")
|
|
83
|
+
waive[k] = v.replace("_", " ")
|
|
84
|
+
return Parsed(
|
|
85
|
+
vary=[v for v in m.group(2).split(",") if v],
|
|
86
|
+
waive=waive,
|
|
87
|
+
strict=m.group(4) == "1",
|
|
88
|
+
verdict=m.group(5),
|
|
89
|
+
digest=m.group(6),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def verify(line: str, report: Report) -> tuple[bool, str]:
|
|
94
|
+
parsed = parse(line)
|
|
95
|
+
actual = _hash(report)
|
|
96
|
+
if actual != parsed.digest:
|
|
97
|
+
return False, "hash mismatch: the records, declarations or verdict differ from what was certified"
|
|
98
|
+
return True, f"verified: {parsed.verdict}"
|