abench 0.1.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.
- abench/Dockerfile +10 -0
- abench/__init__.py +3 -0
- abench/__main__.py +3 -0
- abench/attempts.py +66 -0
- abench/cli.py +563 -0
- abench/common.py +22 -0
- abench/experiments.py +269 -0
- abench/failures.py +103 -0
- abench/flow_cache.py +65 -0
- abench/profiles/mtc.yaml +46 -0
- abench/profiles/sandag.yaml +65 -0
- abench/profiles.py +139 -0
- abench/report.py +483 -0
- abench/runtime/__init__.py +1 -0
- abench/runtime/build_sources.py +109 -0
- abench/runtime/cache_identity.py +37 -0
- abench/runtime/instrumentation.py +79 -0
- abench/runtime/worker.py +318 -0
- abench/sources.py +93 -0
- abench-0.1.0.dist-info/METADATA +381 -0
- abench-0.1.0.dist-info/RECORD +25 -0
- abench-0.1.0.dist-info/WHEEL +5 -0
- abench-0.1.0.dist-info/entry_points.txt +2 -0
- abench-0.1.0.dist-info/licenses/LICENSE +29 -0
- abench-0.1.0.dist-info/top_level.txt +1 -0
abench/Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ARG PYTHON_IMAGE=python:3.11-slim-bookworm
|
|
2
|
+
FROM ${PYTHON_IMAGE}
|
|
3
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git build-essential libhdf5-dev \
|
|
4
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
5
|
+
COPY build_sources.py dependencies.json /opt/build/
|
|
6
|
+
RUN python /opt/build/build_sources.py /opt/build/dependencies.json
|
|
7
|
+
ENV PYTHONUNBUFFERED=1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \
|
|
8
|
+
NUMEXPR_NUM_THREADS=1 NUMBA_NUM_THREADS=1 PYTHONHASHSEED=0 DASK_SCHEDULER=synchronous
|
|
9
|
+
WORKDIR /model
|
|
10
|
+
ENTRYPOINT ["python", "/benchmark/worker.py"]
|
abench/__init__.py
ADDED
abench/__main__.py
ADDED
abench/attempts.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Retry completed runs that prepared new flow signatures, never model errors."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from .common import write_json
|
|
6
|
+
from .failures import BenchmarkFailure, describe_failure
|
|
7
|
+
from .report import load_run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def measured_attempts(spec, output, run_phase, publish_cache):
|
|
11
|
+
"""Select only a compilation-free attempt for the report.
|
|
12
|
+
|
|
13
|
+
Each attempt starts in a new container with fresh outputs and model caches.
|
|
14
|
+
Only compiled flows survive; failed preparation timings never enter reports.
|
|
15
|
+
"""
|
|
16
|
+
spec["attempts"] = []
|
|
17
|
+
for number in range(1, spec.get("cache_retries", 2) + 2):
|
|
18
|
+
model_cache = output / "cache/model"
|
|
19
|
+
if model_cache.exists():
|
|
20
|
+
shutil.rmtree(model_cache)
|
|
21
|
+
staging = output / "attempts" / f"attempt-{number:03d}"
|
|
22
|
+
relative = str(staging.relative_to(output))
|
|
23
|
+
record = {"number": number, "directory": relative, "status": "running"}
|
|
24
|
+
spec["attempts"].append(record)
|
|
25
|
+
spec["measured_directory"] = relative
|
|
26
|
+
write_json(output / "experiment.json", spec)
|
|
27
|
+
print(f"Running measured attempt {number}…", flush=True)
|
|
28
|
+
try:
|
|
29
|
+
run_phase(relative)
|
|
30
|
+
# Check all ordinary validity conditions before deciding to retry.
|
|
31
|
+
if not load_run(output, allow_cache_misses=True)["valid"]:
|
|
32
|
+
raise BenchmarkFailure(
|
|
33
|
+
describe_failure(output, "measured", ignore_cache=True)
|
|
34
|
+
)
|
|
35
|
+
except BaseException:
|
|
36
|
+
record["status"] = "failed"
|
|
37
|
+
write_json(output / "experiment.json", spec)
|
|
38
|
+
raise
|
|
39
|
+
phase = staging
|
|
40
|
+
misses = sum(
|
|
41
|
+
len(path.read_text().splitlines())
|
|
42
|
+
for path in phase.glob("cache-miss-*.txt")
|
|
43
|
+
)
|
|
44
|
+
record.update(
|
|
45
|
+
compilations=misses,
|
|
46
|
+
status="cache preparation" if misses else "accepted",
|
|
47
|
+
)
|
|
48
|
+
publish_cache()
|
|
49
|
+
write_json(output / "experiment.json", spec)
|
|
50
|
+
if not misses:
|
|
51
|
+
# Convenience alias only after containers finish; attempt directories
|
|
52
|
+
# never move or change identity while Docker may cache their paths.
|
|
53
|
+
(output / "measured").symlink_to(relative, target_is_directory=True)
|
|
54
|
+
return
|
|
55
|
+
if number > spec.get("cache_retries", 2):
|
|
56
|
+
raise BenchmarkFailure(
|
|
57
|
+
f"Flow compilation persisted after {number} completed attempts. "
|
|
58
|
+
"No valid benchmark was produced. Inspect attempts/*/cache-miss-details-*.jsonl "
|
|
59
|
+
"for changing signatures; increase --cache-retries if appropriate. "
|
|
60
|
+
f"Diagnostics: {output}"
|
|
61
|
+
)
|
|
62
|
+
print(
|
|
63
|
+
f"Attempt {number} compiled {misses} flow signatures; retained as cache "
|
|
64
|
+
f"preparation at {staging}. Retrying with a fresh model state…",
|
|
65
|
+
flush=True,
|
|
66
|
+
)
|
abench/cli.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Run reproducible ActivitySim benchmarks with model profiles."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import nullcontext
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .attempts import measured_attempts
|
|
20
|
+
from .common import read_json, write_json
|
|
21
|
+
from .failures import BenchmarkFailure, describe_failure
|
|
22
|
+
from .flow_cache import publish_flows, reuse_flows
|
|
23
|
+
from .profiles import load_profile, validate_model
|
|
24
|
+
from .report import load_run, report
|
|
25
|
+
from .sources import resolve_sources
|
|
26
|
+
|
|
27
|
+
PACKAGE = Path(__file__).resolve().parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def command(args, log=None):
|
|
31
|
+
"""Keep build/run output on disk and propagate failures to the caller."""
|
|
32
|
+
if log:
|
|
33
|
+
with log.open("w") as stream:
|
|
34
|
+
subprocess.run(args, stdout=stream, stderr=subprocess.STDOUT, check=True)
|
|
35
|
+
else:
|
|
36
|
+
return subprocess.check_output(args, text=True).strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def git_info(root, args):
|
|
40
|
+
"""Non-Git model directories are supported; snapshots still capture their files."""
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "-C", str(root), *args], capture_output=True, text=True
|
|
43
|
+
)
|
|
44
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def commit(value):
|
|
48
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}", value):
|
|
49
|
+
raise argparse.ArgumentTypeError("provide the full 40-character Git commit SHA")
|
|
50
|
+
return value.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def positive(value):
|
|
54
|
+
value = float(value)
|
|
55
|
+
if not math.isfinite(value) or value <= 0:
|
|
56
|
+
raise argparse.ArgumentTypeError("must be finite and positive")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parser():
|
|
61
|
+
p = argparse.ArgumentParser(
|
|
62
|
+
description=__doc__,
|
|
63
|
+
epilog="Named experiments: abench experiments.yaml; preflight: abench validate experiments.yaml",
|
|
64
|
+
)
|
|
65
|
+
p.add_argument("--version", action="version", version=f"abench {__version__}")
|
|
66
|
+
p.add_argument("--model-dir", type=Path, default=Path.cwd())
|
|
67
|
+
p.add_argument("--profile", default="benchmark.yaml")
|
|
68
|
+
p.add_argument(
|
|
69
|
+
"--source",
|
|
70
|
+
action="append",
|
|
71
|
+
default=[],
|
|
72
|
+
help="distribution=organization/repository@40-character-SHA",
|
|
73
|
+
)
|
|
74
|
+
p.add_argument("--activitysim-commit", type=commit)
|
|
75
|
+
p.add_argument("--sharrow-commit", type=commit)
|
|
76
|
+
mode = p.add_mutually_exclusive_group()
|
|
77
|
+
mode.add_argument("--single-process", dest="multiprocess", action="store_false")
|
|
78
|
+
mode.add_argument("--multiprocess", action="store_true")
|
|
79
|
+
p.set_defaults(multiprocess=False)
|
|
80
|
+
p.add_argument("--processes", type=int, help="required for --multiprocess")
|
|
81
|
+
p.add_argument("--sharrow", action=argparse.BooleanOptionalAction, default=True)
|
|
82
|
+
p.add_argument(
|
|
83
|
+
"--households", type=int, default=1000, help="0 means full population"
|
|
84
|
+
)
|
|
85
|
+
p.add_argument(
|
|
86
|
+
"--warmup-households",
|
|
87
|
+
type=int,
|
|
88
|
+
default=5000,
|
|
89
|
+
help="maximum cache-build households (default: 5000); warmup is always single-process",
|
|
90
|
+
)
|
|
91
|
+
p.add_argument(
|
|
92
|
+
"--cache-retries",
|
|
93
|
+
type=int,
|
|
94
|
+
default=2,
|
|
95
|
+
help="additional attempts after completed runs compile flows (default: 2)",
|
|
96
|
+
)
|
|
97
|
+
p.add_argument("--data-dir", type=Path, default=None)
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"--config-overlay",
|
|
100
|
+
type=Path,
|
|
101
|
+
nargs="+",
|
|
102
|
+
default=[],
|
|
103
|
+
help="extra config directories, highest priority first",
|
|
104
|
+
)
|
|
105
|
+
p.add_argument(
|
|
106
|
+
"--flow-cache-dir",
|
|
107
|
+
type=Path,
|
|
108
|
+
default=Path.home() / ".cache/abench/flows",
|
|
109
|
+
help="persistent compiled-flow cache (default: ~/.cache/abench/flows)",
|
|
110
|
+
)
|
|
111
|
+
p.add_argument(
|
|
112
|
+
"--reuse-flows",
|
|
113
|
+
action=argparse.BooleanOptionalAction,
|
|
114
|
+
default=True,
|
|
115
|
+
help="automatically reuse and update compatible flows; warmup still runs",
|
|
116
|
+
)
|
|
117
|
+
p.add_argument(
|
|
118
|
+
"--cache-from",
|
|
119
|
+
type=Path,
|
|
120
|
+
help="seed flow cache from an experiment with the same revisions and dependencies",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"--output-dir",
|
|
124
|
+
type=Path,
|
|
125
|
+
required=False,
|
|
126
|
+
help="new experiment directory, or report HTML with --report-only",
|
|
127
|
+
)
|
|
128
|
+
p.add_argument("--label", help="experiment label in comparisons")
|
|
129
|
+
p.add_argument(
|
|
130
|
+
"--compare",
|
|
131
|
+
type=Path,
|
|
132
|
+
nargs="+",
|
|
133
|
+
default=[],
|
|
134
|
+
help="previous experiment directories",
|
|
135
|
+
)
|
|
136
|
+
p.add_argument(
|
|
137
|
+
"--report-only",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="rebuild a comparison of --compare directories without Docker",
|
|
140
|
+
)
|
|
141
|
+
p.add_argument(
|
|
142
|
+
"--interval",
|
|
143
|
+
type=positive,
|
|
144
|
+
default=0.5,
|
|
145
|
+
help="memory sampling interval in seconds",
|
|
146
|
+
)
|
|
147
|
+
p.add_argument(
|
|
148
|
+
"--memory", default="16g", help="Docker memory and memory+swap limit"
|
|
149
|
+
)
|
|
150
|
+
p.add_argument(
|
|
151
|
+
"--shm-size", default="8g", help="/dev/shm capacity; charged against --memory"
|
|
152
|
+
)
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"--platform",
|
|
155
|
+
choices=("linux/arm64", "linux/amd64"),
|
|
156
|
+
help="defaults to Docker native architecture",
|
|
157
|
+
)
|
|
158
|
+
return p
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def mount(source, target, readonly=False):
|
|
162
|
+
source = str(source.resolve())
|
|
163
|
+
if "," in source:
|
|
164
|
+
raise ValueError("Docker bind paths cannot contain commas")
|
|
165
|
+
return [
|
|
166
|
+
"--mount",
|
|
167
|
+
f"type=bind,src={source},dst={target}" + (",readonly" if readonly else ""),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def container_phase(spec, output, data, image, phase_name):
|
|
172
|
+
"""Retain Docker exit/OOM state even when the supervisor cannot finish."""
|
|
173
|
+
phase = output / phase_name
|
|
174
|
+
phase.mkdir(parents=True)
|
|
175
|
+
phase_kind = "warmup" if phase_name == "warmup" else "measured"
|
|
176
|
+
name = "abench-" + uuid.uuid4().hex[:12]
|
|
177
|
+
# Each container reads an immutable, uniquely named settings snapshot. Reusing
|
|
178
|
+
# the mutable root manifest across Docker Desktop mounts can expose stale data.
|
|
179
|
+
snapshot_name = f"spec-{name}.json"
|
|
180
|
+
write_json(phase / snapshot_name, spec)
|
|
181
|
+
args = [
|
|
182
|
+
"docker",
|
|
183
|
+
"run",
|
|
184
|
+
"--name",
|
|
185
|
+
name,
|
|
186
|
+
# Native Linux bind mounts preserve ownership. Run as the invoking user
|
|
187
|
+
# so outputs and caches remain writable between attempts and experiments.
|
|
188
|
+
"--user",
|
|
189
|
+
f"{os.getuid()}:{os.getgid()}",
|
|
190
|
+
"--env",
|
|
191
|
+
f"BENCH_SPEC_PATH=/results/{phase_name}/{snapshot_name}",
|
|
192
|
+
"--env",
|
|
193
|
+
f"BENCH_PHASE_NAME={phase_kind}",
|
|
194
|
+
"--cgroupns=private",
|
|
195
|
+
"--memory",
|
|
196
|
+
spec["memory"],
|
|
197
|
+
"--memory-swap",
|
|
198
|
+
spec["memory"],
|
|
199
|
+
"--shm-size",
|
|
200
|
+
spec["shm_size"],
|
|
201
|
+
"--network=none",
|
|
202
|
+
]
|
|
203
|
+
if spec["platform"]:
|
|
204
|
+
args += ["--platform", spec["platform"]]
|
|
205
|
+
args += mount(output / "model", "/model", True)
|
|
206
|
+
args += mount(output / "runner", "/benchmark", True)
|
|
207
|
+
args += mount(data, "/data", True)
|
|
208
|
+
args += mount(output, "/results")
|
|
209
|
+
args += [image, "supervise", f"/results/{phase_name}"]
|
|
210
|
+
try:
|
|
211
|
+
command(args, phase / "console.log")
|
|
212
|
+
finally:
|
|
213
|
+
try:
|
|
214
|
+
state = json.loads(
|
|
215
|
+
command(["docker", "inspect", name, "--format", "{{json .State}}"])
|
|
216
|
+
)
|
|
217
|
+
write_json(phase / "docker-state.json", state)
|
|
218
|
+
finally:
|
|
219
|
+
subprocess.run(
|
|
220
|
+
["docker", "rm", "-f", name],
|
|
221
|
+
stdout=subprocess.DEVNULL,
|
|
222
|
+
stderr=subprocess.DEVNULL,
|
|
223
|
+
check=False,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv=None):
|
|
228
|
+
p = parser()
|
|
229
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
230
|
+
# A file invocation stays separate from model profiles and ordinary flags.
|
|
231
|
+
candidate = argv[1:] if argv and argv[0] in ("run", "validate") else argv
|
|
232
|
+
if (
|
|
233
|
+
candidate
|
|
234
|
+
and not candidate[0].startswith("-")
|
|
235
|
+
and candidate[0] not in ("run", "report", "validate")
|
|
236
|
+
):
|
|
237
|
+
if len(candidate) != 1:
|
|
238
|
+
p.error(
|
|
239
|
+
"an experiment file cannot be mixed with command-line overrides; edit its defaults or runs"
|
|
240
|
+
)
|
|
241
|
+
from .experiments import run_suite
|
|
242
|
+
|
|
243
|
+
return run_suite(Path(candidate[0]), main, validate_only=argv[0] == "validate")
|
|
244
|
+
action = argv.pop(0) if argv and argv[0] in ("run", "report", "validate") else "run"
|
|
245
|
+
args = p.parse_args(argv)
|
|
246
|
+
if action == "report":
|
|
247
|
+
args.report_only = True
|
|
248
|
+
if action != "validate" and args.output_dir is None:
|
|
249
|
+
p.error("--output-dir is required")
|
|
250
|
+
comparisons = [path.expanduser().resolve() for path in args.compare]
|
|
251
|
+
for previous in comparisons:
|
|
252
|
+
load_run(previous)
|
|
253
|
+
output = args.output_dir.expanduser().resolve() if args.output_dir else None
|
|
254
|
+
if args.report_only:
|
|
255
|
+
if output is None:
|
|
256
|
+
p.error("--output-dir is required for reporting")
|
|
257
|
+
if not comparisons:
|
|
258
|
+
p.error("--report-only requires --compare")
|
|
259
|
+
report(comparisons, output)
|
|
260
|
+
print(output)
|
|
261
|
+
return 0
|
|
262
|
+
root = args.model_dir.expanduser().resolve()
|
|
263
|
+
try:
|
|
264
|
+
profile = load_profile(args.profile, root)
|
|
265
|
+
sources = resolve_sources(
|
|
266
|
+
profile.get("sources", []),
|
|
267
|
+
args.source,
|
|
268
|
+
args.activitysim_commit,
|
|
269
|
+
args.sharrow_commit,
|
|
270
|
+
)
|
|
271
|
+
data = (
|
|
272
|
+
args.data_dir.expanduser().resolve()
|
|
273
|
+
if args.data_dir
|
|
274
|
+
else (root / profile.get("data_dir", "data")).resolve()
|
|
275
|
+
)
|
|
276
|
+
validate_model(profile, root, data, args.households)
|
|
277
|
+
except ValueError as error:
|
|
278
|
+
p.error(str(error))
|
|
279
|
+
args.activitysim_commit = next(
|
|
280
|
+
s["commit"] for s in sources if s["name"] == "activitysim"
|
|
281
|
+
)
|
|
282
|
+
args.sharrow_commit = next(
|
|
283
|
+
(s["commit"] for s in sources if s["name"] == "sharrow"), None
|
|
284
|
+
)
|
|
285
|
+
if args.sharrow and not args.sharrow_commit:
|
|
286
|
+
p.error("Sharrow enabled: provide a sharrow source override")
|
|
287
|
+
if args.cache_retries < 0:
|
|
288
|
+
p.error("--cache-retries must be nonnegative")
|
|
289
|
+
if args.warmup_households < 1:
|
|
290
|
+
p.error("--warmup-households must be positive")
|
|
291
|
+
if args.households < 0:
|
|
292
|
+
p.error("--households must be nonnegative")
|
|
293
|
+
if args.multiprocess and (args.processes is None or args.processes < 1):
|
|
294
|
+
p.error("--multiprocess requires --processes >= 1")
|
|
295
|
+
if not args.multiprocess and args.processes not in (None, 1):
|
|
296
|
+
p.error("--processes > 1 requires --multiprocess")
|
|
297
|
+
for value in (args.memory, args.shm_size):
|
|
298
|
+
if not re.fullmatch(r"[1-9][0-9]*[bkmgBKMG]?", value):
|
|
299
|
+
p.error("memory sizes must be positive integer Docker sizes, such as 16g")
|
|
300
|
+
args.data_dir = data
|
|
301
|
+
overlays = [(root / path.expanduser()).resolve() for path in args.config_overlay]
|
|
302
|
+
for path in overlays:
|
|
303
|
+
if not path.is_dir() or (output is not None and output.is_relative_to(path)):
|
|
304
|
+
p.error("config overlays must be existing directories outside --output-dir")
|
|
305
|
+
args.flow_cache_dir = args.flow_cache_dir.expanduser().resolve()
|
|
306
|
+
if args.sharrow and args.reuse_flows and output is not None:
|
|
307
|
+
if args.flow_cache_dir.is_relative_to(output) or output.is_relative_to(
|
|
308
|
+
args.flow_cache_dir
|
|
309
|
+
):
|
|
310
|
+
p.error("--flow-cache-dir and --output-dir must be separate directories")
|
|
311
|
+
seed = args.cache_from.expanduser().resolve() if args.cache_from else None
|
|
312
|
+
if seed:
|
|
313
|
+
prior = read_json(seed / "experiment.json", {})
|
|
314
|
+
for key in ("activitysim_commit", "sharrow_commit"):
|
|
315
|
+
if prior.get(key) != getattr(args, key):
|
|
316
|
+
p.error(f"--cache-from must use the same {key}")
|
|
317
|
+
if not (seed / "cache/flows").is_dir():
|
|
318
|
+
p.error("--cache-from has no flow cache")
|
|
319
|
+
for source in profile["snapshot"]:
|
|
320
|
+
if output is not None and output.is_relative_to(root / source):
|
|
321
|
+
p.error("--output-dir must be outside snapshot source directories")
|
|
322
|
+
if seed and prior.get("sources") != sources:
|
|
323
|
+
p.error("--cache-from must use the same complete source dependency manifest")
|
|
324
|
+
if "," in str(output) or "," in str(data):
|
|
325
|
+
p.error("Docker bind paths cannot contain commas")
|
|
326
|
+
if output is not None and output.exists():
|
|
327
|
+
p.error(
|
|
328
|
+
"--output-dir must not already exist; each experiment needs a separate output directory"
|
|
329
|
+
)
|
|
330
|
+
docker = json.loads(command(["docker", "info", "--format", "{{json .}}"]))
|
|
331
|
+
if docker.get("OSType") != "linux" or str(docker.get("CgroupVersion")) != "2":
|
|
332
|
+
p.error("Docker must run Linux containers using cgroup v2")
|
|
333
|
+
if action == "validate":
|
|
334
|
+
print(
|
|
335
|
+
json.dumps(
|
|
336
|
+
{
|
|
337
|
+
"profile": profile,
|
|
338
|
+
"sources": sources,
|
|
339
|
+
"data_dir": str(data),
|
|
340
|
+
"docker": docker,
|
|
341
|
+
},
|
|
342
|
+
indent=2,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
return 0
|
|
346
|
+
output.mkdir(parents=True)
|
|
347
|
+
spec = vars(args).copy()
|
|
348
|
+
spec.update(
|
|
349
|
+
schema_version=2,
|
|
350
|
+
abench_version=__version__,
|
|
351
|
+
profile=profile,
|
|
352
|
+
profile_name=profile["name"],
|
|
353
|
+
sources=sources,
|
|
354
|
+
label=args.label or output.name,
|
|
355
|
+
processes=args.processes or 1,
|
|
356
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
357
|
+
model_commit=git_info(root, ["rev-parse", "HEAD"]),
|
|
358
|
+
model_git_status=git_info(root, ["status", "--porcelain"]),
|
|
359
|
+
docker={
|
|
360
|
+
key: docker.get(key)
|
|
361
|
+
for key in (
|
|
362
|
+
"ServerVersion",
|
|
363
|
+
"Architecture",
|
|
364
|
+
"NCPU",
|
|
365
|
+
"MemTotal",
|
|
366
|
+
"KernelVersion",
|
|
367
|
+
"CgroupVersion",
|
|
368
|
+
)
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
spec = json.loads(json.dumps(spec, default=str))
|
|
372
|
+
(output / "model").mkdir()
|
|
373
|
+
for config in profile["snapshot"]:
|
|
374
|
+
source, target = root / config, output / "model" / config
|
|
375
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
if source.is_dir():
|
|
377
|
+
shutil.copytree(
|
|
378
|
+
source, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
shutil.copy2(source, target)
|
|
382
|
+
for i, path in enumerate(overlays):
|
|
383
|
+
shutil.copytree(path, output / "model" / f"overlay-{i}")
|
|
384
|
+
shutil.copytree(
|
|
385
|
+
PACKAGE / "runtime",
|
|
386
|
+
output / "runner",
|
|
387
|
+
ignore=shutil.ignore_patterns("__pycache__"),
|
|
388
|
+
)
|
|
389
|
+
shutil.copytree(
|
|
390
|
+
PACKAGE, output / "harness", ignore=shutil.ignore_patterns("__pycache__")
|
|
391
|
+
)
|
|
392
|
+
shutil.copy2(PACKAGE / "Dockerfile", output / "production-benchmark.Dockerfile")
|
|
393
|
+
spec["config_sha256"] = {
|
|
394
|
+
str(path.relative_to(output / "model")): hashlib.sha256(
|
|
395
|
+
path.read_bytes()
|
|
396
|
+
).hexdigest()
|
|
397
|
+
for path in sorted((output / "model").rglob("*"))
|
|
398
|
+
if path.is_file()
|
|
399
|
+
}
|
|
400
|
+
spec["harness_sha256"] = {
|
|
401
|
+
str(path.relative_to(output / "harness")): hashlib.sha256(
|
|
402
|
+
path.read_bytes()
|
|
403
|
+
).hexdigest()
|
|
404
|
+
for path in sorted((output / "harness").rglob("*"))
|
|
405
|
+
if path.is_file()
|
|
406
|
+
}
|
|
407
|
+
spec["input_files"] = {
|
|
408
|
+
str(path.relative_to(data)): {
|
|
409
|
+
"bytes": path.stat().st_size,
|
|
410
|
+
"mtime_ns": path.stat().st_mtime_ns,
|
|
411
|
+
}
|
|
412
|
+
for path in sorted(data.rglob("*"))
|
|
413
|
+
if path.is_file()
|
|
414
|
+
}
|
|
415
|
+
write_json(output / "experiment.json", spec)
|
|
416
|
+
image = "abench:" + uuid.uuid4().hex[:12]
|
|
417
|
+
stage = "build"
|
|
418
|
+
try:
|
|
419
|
+
print(f"Building pinned packages; log: {output / 'build.log'}", flush=True)
|
|
420
|
+
with tempfile.TemporaryDirectory() as context:
|
|
421
|
+
shutil.copy2(
|
|
422
|
+
output / "production-benchmark.Dockerfile", Path(context) / "Dockerfile"
|
|
423
|
+
)
|
|
424
|
+
shutil.copy2(
|
|
425
|
+
PACKAGE / "runtime" / "build_sources.py",
|
|
426
|
+
Path(context) / "build_sources.py",
|
|
427
|
+
)
|
|
428
|
+
write_json(
|
|
429
|
+
Path(context) / "dependencies.json",
|
|
430
|
+
{
|
|
431
|
+
"sources": sources,
|
|
432
|
+
"requirements": profile.get("requirements", []),
|
|
433
|
+
"constraints": profile.get("constraints", []),
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
build = [
|
|
437
|
+
"docker",
|
|
438
|
+
"build",
|
|
439
|
+
"-t",
|
|
440
|
+
image,
|
|
441
|
+
"--build-arg",
|
|
442
|
+
f"PYTHON_IMAGE={profile.get('python_image', 'python:3.11-slim-bookworm')}",
|
|
443
|
+
]
|
|
444
|
+
if args.platform:
|
|
445
|
+
build += ["--platform", args.platform]
|
|
446
|
+
command(build + [context], output / "build.log")
|
|
447
|
+
spec["image_id"] = command(
|
|
448
|
+
["docker", "image", "inspect", image, "--format", "{{.Id}}"]
|
|
449
|
+
)
|
|
450
|
+
write_json(output / "experiment.json", spec)
|
|
451
|
+
freeze = command(
|
|
452
|
+
[
|
|
453
|
+
"docker",
|
|
454
|
+
"run",
|
|
455
|
+
"--rm",
|
|
456
|
+
"--network=none",
|
|
457
|
+
"--entrypoint",
|
|
458
|
+
"cat",
|
|
459
|
+
image,
|
|
460
|
+
"/opt/pip-freeze.txt",
|
|
461
|
+
]
|
|
462
|
+
)
|
|
463
|
+
(output / "pip-freeze.txt").write_text(freeze + "\n")
|
|
464
|
+
provenance = command(
|
|
465
|
+
[
|
|
466
|
+
"docker",
|
|
467
|
+
"run",
|
|
468
|
+
"--rm",
|
|
469
|
+
"--network=none",
|
|
470
|
+
"--entrypoint",
|
|
471
|
+
"cat",
|
|
472
|
+
image,
|
|
473
|
+
"/opt/source-provenance.json",
|
|
474
|
+
]
|
|
475
|
+
)
|
|
476
|
+
(output / "source-provenance.json").write_text(provenance + "\n")
|
|
477
|
+
if seed:
|
|
478
|
+
if (seed / "pip-freeze.txt").read_text().strip() != freeze.strip():
|
|
479
|
+
raise ValueError("Cannot seed cache: installed dependencies differ")
|
|
480
|
+
# Only flow artifacts are reused. A small serial warmup prepares flows;
|
|
481
|
+
# measurement remains responsible for rejecting missing signatures.
|
|
482
|
+
shutil.copytree(seed / "cache/flows", output / "cache/flows")
|
|
483
|
+
cache = nullcontext(None)
|
|
484
|
+
identity = None
|
|
485
|
+
if args.sharrow:
|
|
486
|
+
stage = "warmup"
|
|
487
|
+
print(
|
|
488
|
+
f"Preparing Sharrow cache in single process (up to {args.warmup_households} households)…",
|
|
489
|
+
flush=True,
|
|
490
|
+
)
|
|
491
|
+
cache = nullcontext(None)
|
|
492
|
+
if args.reuse_flows:
|
|
493
|
+
identity = json.loads(
|
|
494
|
+
command(
|
|
495
|
+
[
|
|
496
|
+
"docker",
|
|
497
|
+
"run",
|
|
498
|
+
"--rm",
|
|
499
|
+
"--network=none",
|
|
500
|
+
"--entrypoint",
|
|
501
|
+
"python",
|
|
502
|
+
image,
|
|
503
|
+
"-c",
|
|
504
|
+
(PACKAGE / "runtime/cache_identity.py").read_text(),
|
|
505
|
+
]
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
write_json(output / "flow-cache-identity.json", identity)
|
|
509
|
+
cache = reuse_flows(
|
|
510
|
+
args.flow_cache_dir, identity, output / "cache/flows"
|
|
511
|
+
)
|
|
512
|
+
print(
|
|
513
|
+
"Checking compatible flow cache (waiting for any active warmup)…",
|
|
514
|
+
flush=True,
|
|
515
|
+
)
|
|
516
|
+
with cache as cache_info:
|
|
517
|
+
|
|
518
|
+
def publish_cache():
|
|
519
|
+
if cache_info is not None:
|
|
520
|
+
publish_flows(identity, output / "cache/flows", cache_info)
|
|
521
|
+
|
|
522
|
+
if cache_info is not None:
|
|
523
|
+
spec["flow_cache"] = cache_info
|
|
524
|
+
write_json(output / "experiment.json", spec)
|
|
525
|
+
print(
|
|
526
|
+
f"Reused {cache_info['restored_files']} flow-cache files.",
|
|
527
|
+
flush=True,
|
|
528
|
+
)
|
|
529
|
+
if args.sharrow:
|
|
530
|
+
container_phase(spec, output, data, image, "warmup")
|
|
531
|
+
publish_cache()
|
|
532
|
+
write_json(output / "experiment.json", spec)
|
|
533
|
+
stage = "measured"
|
|
534
|
+
measured_attempts(
|
|
535
|
+
spec,
|
|
536
|
+
output,
|
|
537
|
+
lambda phase_name: container_phase(
|
|
538
|
+
spec, output, data, image, phase_name
|
|
539
|
+
),
|
|
540
|
+
publish_cache,
|
|
541
|
+
)
|
|
542
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
543
|
+
if isinstance(error, subprocess.CalledProcessError):
|
|
544
|
+
error = BenchmarkFailure(describe_failure(output, stage, ignore_cache=True))
|
|
545
|
+
spec["failure"] = {"phase": stage, "error": str(error)}
|
|
546
|
+
write_json(output / "experiment.json", spec)
|
|
547
|
+
raise error from None
|
|
548
|
+
spec["failure"] = {"phase": stage, "error": str(error)}
|
|
549
|
+
write_json(output / "experiment.json", spec)
|
|
550
|
+
raise
|
|
551
|
+
finally:
|
|
552
|
+
report(comparisons + [output], output / "report.html")
|
|
553
|
+
print(f"Report: {output / 'report.html'}", flush=True)
|
|
554
|
+
return 0 if load_run(output)["valid"] else 1
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def entrypoint():
|
|
558
|
+
"""Expose CLI errors without an unnecessary Python traceback."""
|
|
559
|
+
try:
|
|
560
|
+
sys.exit(main())
|
|
561
|
+
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
|
562
|
+
print(f"Benchmark failed: {error}", file=sys.stderr)
|
|
563
|
+
sys.exit(1)
|
abench/common.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Small artifact helpers shared by the host tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_json(path, default=None):
|
|
9
|
+
return json.loads(path.read_text()) if path.exists() else default
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_json(path, value):
|
|
13
|
+
"""Atomically replace metadata so Docker bind readers never see a rewrite."""
|
|
14
|
+
content = json.dumps(value, indent=2) + "\n"
|
|
15
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
18
|
+
stream.write(content)
|
|
19
|
+
os.replace(temporary, path)
|
|
20
|
+
finally:
|
|
21
|
+
if os.path.exists(temporary):
|
|
22
|
+
os.unlink(temporary)
|