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 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
@@ -0,0 +1,3 @@
1
+ """Reproducible ActivitySim experiments in Linux containers."""
2
+
3
+ __version__ = "0.1.0"
abench/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import entrypoint
2
+
3
+ entrypoint()
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)