bench: store Jolt comparisons and detect regressions
d48248e parent: bdb2eb3 modified
.gitignore +1 -0 | @@ -3,3 +3,4 @@ nimcache/ | ||
| 3 | 3 | .clj-kondo/.cache/ |
| 4 | 4 | .lsp/ |
| 5 | 5 | bench/bin/ |
| 6 | +__pycache__/ | |
| @@ -3,3 +3,4 @@ nimcache/ | |||
| 3 | .clj-kondo/.cache/ | 3 | .clj-kondo/.cache/ |
| 4 | .lsp/ | 4 | .lsp/ |
| 5 | bench/bin/ | 5 | bench/bin/ |
| 6 | +__pycache__/ | ||
modified
README.md +5 -0 | @@ -18,6 +18,11 @@ nim c --hints:off -o:bin/clonim src/clonim.nim # build the compiler | ||
| 18 | 18 | There is a `justfile` too: `just build`, `just test`, `just bench`, |
| 19 | 19 | `just run <file>`, `just emit <file>`, `just accept` (re-record expectations). |
| 20 | 20 | |
| 21 | +`just bench-store` benchmarks against jolt-lang, commits a timestamped result, | |
| 22 | +and flags slowdowns above 10% versus the latest compatible run. Requires Jolt | |
| 23 | +and a clean working tree. Use `just bench-store 15` to change the threshold; | |
| 24 | +see [benchmark history](bench/README.md) for metrics and exit codes. | |
| 25 | + | |
| 21 | 26 | ## Pipeline |
| 22 | 27 | |
| 23 | 28 | | stage | file | what it does | |
| @@ -18,6 +18,11 @@ nim c --hints:off -o:bin/clonim src/clonim.nim # build the compiler | |||
| 18 | There is a `justfile` too: `just build`, `just test`, `just bench`, | 18 | There is a `justfile` too: `just build`, `just test`, `just bench`, |
| 19 | `just run <file>`, `just emit <file>`, `just accept` (re-record expectations). | 19 | `just run <file>`, `just emit <file>`, `just accept` (re-record expectations). |
| 20 | 20 | ||
| 21 | +`just bench-store` benchmarks against jolt-lang, commits a timestamped result, | ||
| 22 | +and flags slowdowns above 10% versus the latest compatible run. Requires Jolt | ||
| 23 | +and a clean working tree. Use `just bench-store 15` to change the threshold; | ||
| 24 | +see [benchmark history](bench/README.md) for metrics and exit codes. | ||
| 25 | + | ||
| 21 | ## Pipeline | 26 | ## Pipeline |
| 22 | 27 | ||
| 23 | | stage | file | what it does | | 28 | | stage | file | what it does | |
added
bench/README.md +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +# Stored benchmarks | |
| 2 | + | |
| 3 | +`just bench-store` invokes `python3 bench/store.py`, with an optional percentage | |
| 4 | +threshold (default **10**): | |
| 5 | + | |
| 6 | +```sh | |
| 7 | +just bench-store | |
| 8 | +just bench-store 15 | |
| 9 | +# Equivalent direct invocation: | |
| 10 | +python3 bench/store.py 15 | |
| 11 | +``` | |
| 12 | + | |
| 13 | +The task builds release binaries for clonim and jolt-lang using the existing | |
| 14 | +`measure jolt` recipe. Recording uses only Python's standard library. | |
| 15 | + | |
| 16 | +## Requirements and recording | |
| 17 | + | |
| 18 | +- Start with a **clean Git working tree and index, including untracked files**. | |
| 19 | + Commit, stash, or remove outstanding work yourself first. Ignored build output | |
| 20 | + does not count as dirty. | |
| 21 | +- `git`, `just`, `nim`, and `jolt` must be available on `PATH`. Missing jolt is an | |
| 22 | + error, not permission to fall back to a clonim-only run. | |
| 23 | +- Run on an otherwise idle machine. Do not edit compiler sources or run another | |
| 24 | + benchmark/recording process concurrently. | |
| 25 | + | |
| 26 | +The script invokes `just measure jolt` from the repository root, setting | |
| 27 | +`BENCH_JSON` to an absolute path in a temporary directory. The producer must write | |
| 28 | +JSON with `schema_version: 1`, `rounds: 11`, and `samples_ms`, `best_ms`, and | |
| 29 | +`compute_ms` mappings keyed by tool (`clonim`, `jolt`) then benchmark (`hello`, | |
| 30 | +`fib`, `loop`, `seqs`). Each raw sample array has 11 nonnegative millisecond | |
| 31 | +values, `best_ms` is its minimum, and `compute_ms` is best minus that tool's hello | |
| 32 | +best. The identically-zero hello compute entry may be omitted. Missing, failed, | |
| 33 | +or malformed measurements are not recorded. | |
| 34 | + | |
| 35 | +A unique UTC timestamp plus UUID names each `bench/results/*.json` record. It | |
| 36 | +contains the raw results, the source Git revision, machine/OS/hostname/CPU | |
| 37 | +information, full `nim --version` and `jolt --version` output, SHA-256 hashes of | |
| 38 | +all `bench/*.clj`, `bench/measure.py`, and `justfile`, and the baseline comparison. | |
| 39 | +Hashing `justfile` also captures changes to the benchmark build commands. | |
| 40 | + | |
| 41 | +The newest compatible valid record by UTC creation time is the baseline. | |
| 42 | +Compatibility requires identical machine information, tool versions, methodology, | |
| 43 | +and input hashes; **source revision is deliberately not a compatibility key**, | |
| 44 | +so compiler changes can be compared. Malformed and incompatible records are | |
| 45 | +skipped. With no compatible baseline, the run establishes one without an alert. | |
| 46 | + | |
| 47 | +## Metrics and exit status | |
| 48 | + | |
| 49 | +Regression checks use clonim's **best total wall-clock milliseconds**, including | |
| 50 | +`hello` startup, independently for all four benchmarks. An increase **strictly | |
| 51 | +greater than** the threshold flags a regression. Jolt results and startup-adjusted | |
| 52 | +compute times are retained for context, not used to trigger alerts. The report | |
| 53 | +and record contain absolute and percentage deltas and a regression flag. If a | |
| 54 | +baseline is zero, any positive value is a regression and its undefined percentage | |
| 55 | +is stored as JSON `null` (zero to zero is 0%). | |
| 56 | + | |
| 57 | +These are noisy performance alerts, not statistical significance tests. | |
| 58 | +Best-of-11 reduces scheduling interference but cannot remove thermal, frequency, | |
| 59 | +background-load, or toolchain variability. Confirm unexpected changes with | |
| 60 | +additional runs before drawing conclusions. A recorded regression can itself | |
| 61 | +become the next baseline; comparisons are to the latest run, not a fixed target. | |
| 62 | + | |
| 63 | +After writing the record, the script stages **only that new file** and runs | |
| 64 | +`git commit --only -m ... -- path` with `GIT_EDITOR=true`. Unrelated changes that | |
| 65 | +appear during the run are neither staged nor included by the script; unrelated | |
| 66 | +index entries are preserved. Git hooks still run normally. The original clean | |
| 67 | +HEAD is stored as `source_revision`, not the subsequent record commit. A changed | |
| 68 | +HEAD or changed benchmark inputs/environment during measurement aborts recording. | |
| 69 | +Concurrent compiler-source edits are unsupported: the initial clean-tree check | |
| 70 | +is not a filesystem snapshot. | |
| 71 | + | |
| 72 | +Exit codes: | |
| 73 | + | |
| 74 | +- **0**: record committed, no regression (or no compatible baseline). | |
| 75 | +- **1**: regression **recorded and committed first**, then reported as failure. | |
| 76 | +- **2**: precondition, measurement, validation, or Git failure. If staging or | |
| 77 | + committing fails, the JSON file remains for inspection/recovery; the script | |
| 78 | + does not reset, delete, or roll back unrelated work. Resolve the Git failure | |
| 79 | + and commit the retained record explicitly before retrying. | |
| 80 | + | |
| 81 | +The record commit provides an auditable link to measured source; it does not | |
| 82 | +claim reproducible timings across machines or capture every external dependency. | |
| 83 | + | |
| 84 | +## Tests | |
| 85 | + | |
| 86 | +```sh | |
| 87 | +python3 -m unittest discover -s bench -p 'test_store.py' -v | |
| 88 | +``` | |
| 89 | + | |
| 90 | +Tests use temporary directories and mock benchmark/Git execution; they never run | |
| 91 | +real benchmarks or create real commits. | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +# Stored benchmarks | ||
| 2 | + | ||
| 3 | +`just bench-store` invokes `python3 bench/store.py`, with an optional percentage | ||
| 4 | +threshold (default **10**): | ||
| 5 | + | ||
| 6 | +```sh | ||
| 7 | +just bench-store | ||
| 8 | +just bench-store 15 | ||
| 9 | +# Equivalent direct invocation: | ||
| 10 | +python3 bench/store.py 15 | ||
| 11 | +``` | ||
| 12 | + | ||
| 13 | +The task builds release binaries for clonim and jolt-lang using the existing | ||
| 14 | +`measure jolt` recipe. Recording uses only Python's standard library. | ||
| 15 | + | ||
| 16 | +## Requirements and recording | ||
| 17 | + | ||
| 18 | +- Start with a **clean Git working tree and index, including untracked files**. | ||
| 19 | + Commit, stash, or remove outstanding work yourself first. Ignored build output | ||
| 20 | + does not count as dirty. | ||
| 21 | +- `git`, `just`, `nim`, and `jolt` must be available on `PATH`. Missing jolt is an | ||
| 22 | + error, not permission to fall back to a clonim-only run. | ||
| 23 | +- Run on an otherwise idle machine. Do not edit compiler sources or run another | ||
| 24 | + benchmark/recording process concurrently. | ||
| 25 | + | ||
| 26 | +The script invokes `just measure jolt` from the repository root, setting | ||
| 27 | +`BENCH_JSON` to an absolute path in a temporary directory. The producer must write | ||
| 28 | +JSON with `schema_version: 1`, `rounds: 11`, and `samples_ms`, `best_ms`, and | ||
| 29 | +`compute_ms` mappings keyed by tool (`clonim`, `jolt`) then benchmark (`hello`, | ||
| 30 | +`fib`, `loop`, `seqs`). Each raw sample array has 11 nonnegative millisecond | ||
| 31 | +values, `best_ms` is its minimum, and `compute_ms` is best minus that tool's hello | ||
| 32 | +best. The identically-zero hello compute entry may be omitted. Missing, failed, | ||
| 33 | +or malformed measurements are not recorded. | ||
| 34 | + | ||
| 35 | +A unique UTC timestamp plus UUID names each `bench/results/*.json` record. It | ||
| 36 | +contains the raw results, the source Git revision, machine/OS/hostname/CPU | ||
| 37 | +information, full `nim --version` and `jolt --version` output, SHA-256 hashes of | ||
| 38 | +all `bench/*.clj`, `bench/measure.py`, and `justfile`, and the baseline comparison. | ||
| 39 | +Hashing `justfile` also captures changes to the benchmark build commands. | ||
| 40 | + | ||
| 41 | +The newest compatible valid record by UTC creation time is the baseline. | ||
| 42 | +Compatibility requires identical machine information, tool versions, methodology, | ||
| 43 | +and input hashes; **source revision is deliberately not a compatibility key**, | ||
| 44 | +so compiler changes can be compared. Malformed and incompatible records are | ||
| 45 | +skipped. With no compatible baseline, the run establishes one without an alert. | ||
| 46 | + | ||
| 47 | +## Metrics and exit status | ||
| 48 | + | ||
| 49 | +Regression checks use clonim's **best total wall-clock milliseconds**, including | ||
| 50 | +`hello` startup, independently for all four benchmarks. An increase **strictly | ||
| 51 | +greater than** the threshold flags a regression. Jolt results and startup-adjusted | ||
| 52 | +compute times are retained for context, not used to trigger alerts. The report | ||
| 53 | +and record contain absolute and percentage deltas and a regression flag. If a | ||
| 54 | +baseline is zero, any positive value is a regression and its undefined percentage | ||
| 55 | +is stored as JSON `null` (zero to zero is 0%). | ||
| 56 | + | ||
| 57 | +These are noisy performance alerts, not statistical significance tests. | ||
| 58 | +Best-of-11 reduces scheduling interference but cannot remove thermal, frequency, | ||
| 59 | +background-load, or toolchain variability. Confirm unexpected changes with | ||
| 60 | +additional runs before drawing conclusions. A recorded regression can itself | ||
| 61 | +become the next baseline; comparisons are to the latest run, not a fixed target. | ||
| 62 | + | ||
| 63 | +After writing the record, the script stages **only that new file** and runs | ||
| 64 | +`git commit --only -m ... -- path` with `GIT_EDITOR=true`. Unrelated changes that | ||
| 65 | +appear during the run are neither staged nor included by the script; unrelated | ||
| 66 | +index entries are preserved. Git hooks still run normally. The original clean | ||
| 67 | +HEAD is stored as `source_revision`, not the subsequent record commit. A changed | ||
| 68 | +HEAD or changed benchmark inputs/environment during measurement aborts recording. | ||
| 69 | +Concurrent compiler-source edits are unsupported: the initial clean-tree check | ||
| 70 | +is not a filesystem snapshot. | ||
| 71 | + | ||
| 72 | +Exit codes: | ||
| 73 | + | ||
| 74 | +- **0**: record committed, no regression (or no compatible baseline). | ||
| 75 | +- **1**: regression **recorded and committed first**, then reported as failure. | ||
| 76 | +- **2**: precondition, measurement, validation, or Git failure. If staging or | ||
| 77 | + committing fails, the JSON file remains for inspection/recovery; the script | ||
| 78 | + does not reset, delete, or roll back unrelated work. Resolve the Git failure | ||
| 79 | + and commit the retained record explicitly before retrying. | ||
| 80 | + | ||
| 81 | +The record commit provides an auditable link to measured source; it does not | ||
| 82 | +claim reproducible timings across machines or capture every external dependency. | ||
| 83 | + | ||
| 84 | +## Tests | ||
| 85 | + | ||
| 86 | +```sh | ||
| 87 | +python3 -m unittest discover -s bench -p 'test_store.py' -v | ||
| 88 | +``` | ||
| 89 | + | ||
| 90 | +Tests use temporary directories and mock benchmark/Git execution; they never run | ||
| 91 | +real benchmarks or create real commits. | ||
modified
bench/measure.py +30 -4 | @@ -14,7 +14,14 @@ Compute is reported as total minus the `hello` time for the same toolchain, | ||
| 14 | 14 | which is how a 0.5 ms native start is kept from flattering clonim against a |
| 15 | 15 | runtime that pays ~105 ms to boot before it reaches main. |
| 16 | 16 | """ |
| 17 | -import collections, subprocess, sys, time | |
| 17 | + | |
| 18 | +import collections | |
| 19 | +import json | |
| 20 | +import os | |
| 21 | +import subprocess | |
| 22 | +import sys | |
| 23 | +import time | |
| 24 | +from pathlib import Path | |
| 18 | 25 | |
| 19 | 26 | BENCHES = ["fib", "loop", "seqs"] |
| 20 | 27 | ROUNDS = 11 |
| @@ -22,7 +29,7 @@ ROUNDS = 11 | ||
| 22 | 29 | |
| 23 | 30 | def timed(cmd): |
| 24 | 31 | start = time.perf_counter() |
| 25 | - subprocess.run(cmd, capture_output=True) | |
| 32 | + subprocess.run(cmd, capture_output=True, check=True) | |
| 26 | 33 | return (time.perf_counter() - start) * 1000 |
| 27 | 34 | |
| 28 | 35 | |
| @@ -33,13 +40,32 @@ def main(argv): | ||
| 33 | 40 | suites["jolt"] = {b: [f"bench/bin/jolt-{b}"] for b in ["hello"] + BENCHES} |
| 34 | 41 | |
| 35 | 42 | best = collections.defaultdict(lambda: float("inf")) |
| 43 | + samples = {label: {b: [] for b in cmds} for label, cmds in suites.items()} | |
| 36 | 44 | for label, cmds in suites.items(): # warm the page cache first |
| 37 | 45 | for cmd in cmds.values(): |
| 38 | - subprocess.run(cmd, capture_output=True) | |
| 46 | + subprocess.run(cmd, capture_output=True, check=True) | |
| 39 | 47 | for _ in range(ROUNDS): |
| 40 | 48 | for label, cmds in suites.items(): |
| 41 | 49 | for bench, cmd in cmds.items(): |
| 42 | - best[label, bench] = min(best[label, bench], timed(cmd)) | |
| 50 | + elapsed = timed(cmd) | |
| 51 | + samples[label][bench].append(elapsed) | |
| 52 | + best[label, bench] = min(best[label, bench], elapsed) | |
| 53 | + | |
| 54 | + if output := os.environ.get("BENCH_JSON"): | |
| 55 | + result = { | |
| 56 | + "schema_version": 1, | |
| 57 | + "rounds": ROUNDS, | |
| 58 | + "samples_ms": samples, | |
| 59 | + "best_ms": { | |
| 60 | + label: {b: best[label, b] for b in cmds} | |
| 61 | + for label, cmds in suites.items() | |
| 62 | + }, | |
| 63 | + "compute_ms": { | |
| 64 | + label: {b: best[label, b] - best[label, "hello"] for b in BENCHES} | |
| 65 | + for label in suites | |
| 66 | + }, | |
| 67 | + } | |
| 68 | + Path(output).write_text(json.dumps(result, indent=2) + "\n") | |
| 43 | 69 | |
| 44 | 70 | width = max(len(l) for l in suites) |
| 45 | 71 | print(f"{'':<{width}} {'startup':>9} " + " ".join(f"{b:>8}" for b in BENCHES)) |
| @@ -14,7 +14,14 @@ Compute is reported as total minus the `hello` time for the same toolchain, | |||
| 14 | which is how a 0.5 ms native start is kept from flattering clonim against a | 14 | which is how a 0.5 ms native start is kept from flattering clonim against a |
| 15 | runtime that pays ~105 ms to boot before it reaches main. | 15 | runtime that pays ~105 ms to boot before it reaches main. |
| 16 | """ | 16 | """ |
| 17 | -import collections, subprocess, sys, time | 17 | + |
| 18 | +import collections | ||
| 19 | +import json | ||
| 20 | +import os | ||
| 21 | +import subprocess | ||
| 22 | +import sys | ||
| 23 | +import time | ||
| 24 | +from pathlib import Path | ||
| 18 | 25 | ||
| 19 | BENCHES = ["fib", "loop", "seqs"] | 26 | BENCHES = ["fib", "loop", "seqs"] |
| 20 | ROUNDS = 11 | 27 | ROUNDS = 11 |
| @@ -22,7 +29,7 @@ ROUNDS = 11 | |||
| 22 | 29 | ||
| 23 | def timed(cmd): | 30 | def timed(cmd): |
| 24 | start = time.perf_counter() | 31 | start = time.perf_counter() |
| 25 | - subprocess.run(cmd, capture_output=True) | 32 | + subprocess.run(cmd, capture_output=True, check=True) |
| 26 | return (time.perf_counter() - start) * 1000 | 33 | return (time.perf_counter() - start) * 1000 |
| 27 | 34 | ||
| 28 | 35 | ||
| @@ -33,13 +40,32 @@ def main(argv): | |||
| 33 | suites["jolt"] = {b: [f"bench/bin/jolt-{b}"] for b in ["hello"] + BENCHES} | 40 | suites["jolt"] = {b: [f"bench/bin/jolt-{b}"] for b in ["hello"] + BENCHES} |
| 34 | 41 | ||
| 35 | best = collections.defaultdict(lambda: float("inf")) | 42 | best = collections.defaultdict(lambda: float("inf")) |
| 43 | + samples = {label: {b: [] for b in cmds} for label, cmds in suites.items()} | ||
| 36 | for label, cmds in suites.items(): # warm the page cache first | 44 | for label, cmds in suites.items(): # warm the page cache first |
| 37 | for cmd in cmds.values(): | 45 | for cmd in cmds.values(): |
| 38 | - subprocess.run(cmd, capture_output=True) | 46 | + subprocess.run(cmd, capture_output=True, check=True) |
| 39 | for _ in range(ROUNDS): | 47 | for _ in range(ROUNDS): |
| 40 | for label, cmds in suites.items(): | 48 | for label, cmds in suites.items(): |
| 41 | for bench, cmd in cmds.items(): | 49 | for bench, cmd in cmds.items(): |
| 42 | - best[label, bench] = min(best[label, bench], timed(cmd)) | 50 | + elapsed = timed(cmd) |
| 51 | + samples[label][bench].append(elapsed) | ||
| 52 | + best[label, bench] = min(best[label, bench], elapsed) | ||
| 53 | + | ||
| 54 | + if output := os.environ.get("BENCH_JSON"): | ||
| 55 | + result = { | ||
| 56 | + "schema_version": 1, | ||
| 57 | + "rounds": ROUNDS, | ||
| 58 | + "samples_ms": samples, | ||
| 59 | + "best_ms": { | ||
| 60 | + label: {b: best[label, b] for b in cmds} | ||
| 61 | + for label, cmds in suites.items() | ||
| 62 | + }, | ||
| 63 | + "compute_ms": { | ||
| 64 | + label: {b: best[label, b] - best[label, "hello"] for b in BENCHES} | ||
| 65 | + for label in suites | ||
| 66 | + }, | ||
| 67 | + } | ||
| 68 | + Path(output).write_text(json.dumps(result, indent=2) + "\n") | ||
| 43 | 69 | ||
| 44 | width = max(len(l) for l in suites) | 70 | width = max(len(l) for l in suites) |
| 45 | print(f"{'':<{width}} {'startup':>9} " + " ".join(f"{b:>8}" for b in BENCHES)) | 71 | print(f"{'':<{width}} {'startup':>9} " + " ".join(f"{b:>8}" for b in BENCHES)) |
added
bench/store.py +222 -0 | new file mode 100644 | ||
| @@ -0,0 +1,222 @@ | ||
| 1 | + | |
| 2 | +"""Record and commit a benchmark run; exit 1 after committing a regression.""" | |
| 3 | + | |
| 4 | +import argparse | |
| 5 | + | |
| 6 | +import hashlib | |
| 7 | +import json | |
| 8 | +import math | |
| 9 | +import os | |
| 10 | + | |
| 11 | +import platform | |
| 12 | +import shutil | |
| 13 | +import subprocess | |
| 14 | +import sys | |
| 15 | +import tempfile | |
| 16 | +import uuid | |
| 17 | +from datetime import datetime, timezone | |
| 18 | +from pathlib import Path | |
| 19 | + | |
| 20 | +ROOT = Path(__file__).resolve().parent.parent | |
| 21 | +BENCHES = ("hello", "fib", "loop", "seqs") | |
| 22 | +LABELS = ("clonim", "jolt") | |
| 23 | + | |
| 24 | + | |
| 25 | +def command(root, *args, env=None): | |
| 26 | + return subprocess.check_output(args, cwd=root, env=env, text=True).strip() | |
| 27 | + | |
| 28 | + | |
| 29 | +def require_clean(root): | |
| 30 | + if command(root, "git", "status", "--porcelain", "--untracked-files=all"): | |
| 31 | + raise ValueError("working tree must be clean, including untracked files") | |
| 32 | + | |
| 33 | + | |
| 34 | +def cpu_info(): | |
| 35 | + try: | |
| 36 | + for line in Path("/proc/cpuinfo").read_text().splitlines(): | |
| 37 | + key, sep, value = line.partition(":") | |
| 38 | + if sep and key.strip() in ("model name", "Hardware"): | |
| 39 | + return value.strip() | |
| 40 | + except OSError: | |
| 41 | + pass | |
| 42 | + return platform.processor() or "unknown" | |
| 43 | + | |
| 44 | + | |
| 45 | +def compatibility(root): | |
| 46 | + paths = sorted((root / "bench").glob("*.clj")) | |
| 47 | + paths += [root / "bench/measure.py", root / "justfile"] | |
| 48 | + return { | |
| 49 | + "machine": { | |
| 50 | + "system": platform.system(), | |
| 51 | + "release": platform.release(), | |
| 52 | + "machine": platform.machine(), | |
| 53 | + "hostname": platform.node(), | |
| 54 | + "cpu": cpu_info(), | |
| 55 | + "logical_cpus": os.cpu_count(), | |
| 56 | + }, | |
| 57 | + "tool_versions": { | |
| 58 | + tool: command(root, tool, "--version") for tool in ("nim", "jolt") | |
| 59 | + }, | |
| 60 | + "methodology": { | |
| 61 | + "schema_version": 1, | |
| 62 | + "rounds": 11, | |
| 63 | + "metric": "best_ms", | |
| 64 | + "schedule": "round-robin", | |
| 65 | + }, | |
| 66 | + "sha256": { | |
| 67 | + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() | |
| 68 | + for path in paths | |
| 69 | + }, | |
| 70 | + } | |
| 71 | + | |
| 72 | + | |
| 73 | +def number(value): | |
| 74 | + return type(value) in (int, float) and math.isfinite(value) | |
| 75 | + | |
| 76 | + | |
| 77 | +def validate_results(results): | |
| 78 | + if not isinstance(results, dict): | |
| 79 | + # Invalid JSON measurements consistently raise ValueError for callers. | |
| 80 | + raise ValueError("measurement must be a JSON object") # noqa: TRY004 | |
| 81 | + if results.get("schema_version") != 1 or results.get("rounds") != 11: | |
| 82 | + raise ValueError("expected measurement schema_version=1 and rounds=11") | |
| 83 | + try: | |
| 84 | + for label in LABELS: | |
| 85 | + for bench in BENCHES: | |
| 86 | + samples = results["samples_ms"][label][bench] | |
| 87 | + best = results["best_ms"][label][bench] | |
| 88 | + if (not isinstance(samples, list) or len(samples) != 11 | |
| 89 | + or not all(number(v) and v >= 0 for v in samples) | |
| 90 | + or not number(best) or best != min(samples)): | |
| 91 | + raise ValueError(f"invalid samples/best for {label}/{bench}") | |
| 92 | + # Some producers omit the identically-zero hello compute metric. | |
| 93 | + if bench != "hello" or bench in results["compute_ms"][label]: | |
| 94 | + compute = results["compute_ms"][label][bench] | |
| 95 | + expected = best - results["best_ms"][label]["hello"] | |
| 96 | + if not number(compute) or not math.isclose( | |
| 97 | + compute, expected, rel_tol=1e-9, abs_tol=1e-9): | |
| 98 | + raise ValueError(f"invalid compute for {label}/{bench}") | |
| 99 | + except (KeyError, TypeError) as exc: | |
| 100 | + raise ValueError("missing or invalid measurement metrics") from exc | |
| 101 | + | |
| 102 | + | |
| 103 | +def latest_baseline(directory, compatible): | |
| 104 | + candidates = [] | |
| 105 | + for path in directory.glob("*.json"): | |
| 106 | + try: | |
| 107 | + record = json.loads(path.read_text()) | |
| 108 | + if record["schema_version"] != 1 or record["compatibility"] != compatible: | |
| 109 | + continue | |
| 110 | + validate_results(record["results"]) | |
| 111 | + created = datetime.fromisoformat(record["created_at"]) | |
| 112 | + if created.utcoffset() is None: | |
| 113 | + continue | |
| 114 | + candidates.append((created, path.name, record)) | |
| 115 | + except (OSError, ValueError, KeyError, TypeError): | |
| 116 | + continue | |
| 117 | + if not candidates: | |
| 118 | + return None | |
| 119 | + _, name, record = max(candidates, key=lambda item: (item[0], item[1])) | |
| 120 | + return name, record | |
| 121 | + | |
| 122 | + | |
| 123 | +def compare(results, baseline, threshold): | |
| 124 | + comparison = {"baseline": None, "threshold_percent": threshold, | |
| 125 | + "deltas": {}, "regression": False} | |
| 126 | + if baseline is None: | |
| 127 | + return comparison | |
| 128 | + name, prior = baseline | |
| 129 | + comparison["baseline"] = name | |
| 130 | + for bench in BENCHES: | |
| 131 | + old = prior["results"]["best_ms"]["clonim"][bench] | |
| 132 | + new = results["best_ms"]["clonim"][bench] | |
| 133 | + delta = new - old | |
| 134 | + percent = 100 * delta / old if old else (0.0 if new == 0 else None) | |
| 135 | + regression = delta > old * threshold / 100 | |
| 136 | + comparison["deltas"][bench] = { | |
| 137 | + "baseline_ms": old, "current_ms": new, "delta_ms": delta, | |
| 138 | + "percent": percent, "regression": regression, | |
| 139 | + } | |
| 140 | + comparison["regression"] |= regression | |
| 141 | + return comparison | |
| 142 | + | |
| 143 | + | |
| 144 | +def report(comparison): | |
| 145 | + if comparison["baseline"] is None: | |
| 146 | + print("No compatible baseline; recording initial run.") | |
| 147 | + else: | |
| 148 | + print(f"Baseline: {comparison['baseline']}") | |
| 149 | + for bench, delta in comparison["deltas"].items(): | |
| 150 | + percent = delta["percent"] | |
| 151 | + change = f"{percent:+.2f}%" if percent is not None else "increase from zero" | |
| 152 | + print(f" {bench}: {delta['delta_ms']:+.3f} ms ({change})" | |
| 153 | + + (" REGRESSION" if delta["regression"] else "")) | |
| 154 | + print(f"Regression: {comparison['regression']}") | |
| 155 | + | |
| 156 | + | |
| 157 | +def store(root, threshold): | |
| 158 | + require_clean(root) | |
| 159 | + for tool in ("jolt", "nim", "just"): | |
| 160 | + if shutil.which(tool) is None: | |
| 161 | + raise ValueError(f"required executable not found: {tool}") | |
| 162 | + revision = command(root, "git", "rev-parse", "HEAD") | |
| 163 | + compatible = compatibility(root) | |
| 164 | + directory = root / "bench/results" | |
| 165 | + baseline = latest_baseline(directory, compatible) | |
| 166 | + with tempfile.TemporaryDirectory(prefix="clonim-bench-") as work: | |
| 167 | + output = Path(work).resolve() / "measurement.json" | |
| 168 | + env = dict(os.environ, BENCH_JSON=str(output)) | |
| 169 | + subprocess.run(["just", "measure", "jolt"], cwd=root, env=env, check=True) | |
| 170 | + results = json.loads(output.read_text()) | |
| 171 | + validate_results(results) | |
| 172 | + if command(root, "git", "rev-parse", "HEAD") != revision: | |
| 173 | + raise ValueError("HEAD changed during measurement; refusing to record") | |
| 174 | + if compatibility(root) != compatible: | |
| 175 | + raise ValueError("benchmark inputs or environment changed during measurement") | |
| 176 | + comparison = compare(results, baseline, threshold) | |
| 177 | + now = datetime.now(timezone.utc) | |
| 178 | + record = { | |
| 179 | + "schema_version": 1, | |
| 180 | + "created_at": now.isoformat(), | |
| 181 | + "source_revision": revision, | |
| 182 | + "compatibility": compatible, | |
| 183 | + "results": results, | |
| 184 | + "comparison": comparison, | |
| 185 | + } | |
| 186 | + directory.mkdir(parents=True, exist_ok=True) | |
| 187 | + path = directory / f"{now.strftime('%Y%m%dT%H%M%S.%fZ')}-{uuid.uuid4().hex}.json" | |
| 188 | + with path.open("x") as stream: | |
| 189 | + json.dump(record, stream, indent=2, allow_nan=False) | |
| 190 | + stream.write("\n") | |
| 191 | + relative = str(path.relative_to(root)) | |
| 192 | + report(comparison) | |
| 193 | + print(f"Record: {relative}", flush=True) | |
| 194 | + subprocess.run(["git", "add", "--", relative], cwd=root, check=True) | |
| 195 | + # --only excludes anything another process staged while benchmarks ran. | |
| 196 | + subprocess.run(["git", "commit", "--only", "-m", f"bench: record {path.stem}", | |
| 197 | + "--", relative], cwd=root, | |
| 198 | + env=dict(os.environ, GIT_EDITOR="true"), check=True) | |
| 199 | + return 1 if comparison["regression"] else 0 | |
| 200 | + | |
| 201 | + | |
| 202 | +def threshold_value(text): | |
| 203 | + value = float(text) | |
| 204 | + if not math.isfinite(value) or value < 0: | |
| 205 | + raise argparse.ArgumentTypeError("threshold must be finite and nonnegative") | |
| 206 | + return value | |
| 207 | + | |
| 208 | + | |
| 209 | +def main(argv=None): | |
| 210 | + parser = argparse.ArgumentParser(description=__doc__) | |
| 211 | + parser.add_argument("threshold", nargs="?", type=threshold_value, default=10.0, | |
| 212 | + help="alert above this percentage increase (default: 10)") | |
| 213 | + args = parser.parse_args(argv) | |
| 214 | + try: | |
| 215 | + return store(ROOT, args.threshold) | |
| 216 | + except (OSError, ValueError, subprocess.CalledProcessError) as exc: | |
| 217 | + print(f"bench-store: {exc}", file=sys.stderr) | |
| 218 | + return 2 | |
| 219 | + | |
| 220 | + | |
| 221 | +if __name__ == "__main__": | |
| 222 | + sys.exit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,222 @@ | |||
| 1 | + | ||
| 2 | +"""Record and commit a benchmark run; exit 1 after committing a regression.""" | ||
| 3 | + | ||
| 4 | +import argparse | ||
| 5 | + | ||
| 6 | +import hashlib | ||
| 7 | +import json | ||
| 8 | +import math | ||
| 9 | +import os | ||
| 10 | + | ||
| 11 | +import platform | ||
| 12 | +import shutil | ||
| 13 | +import subprocess | ||
| 14 | +import sys | ||
| 15 | +import tempfile | ||
| 16 | +import uuid | ||
| 17 | +from datetime import datetime, timezone | ||
| 18 | +from pathlib import Path | ||
| 19 | + | ||
| 20 | +ROOT = Path(__file__).resolve().parent.parent | ||
| 21 | +BENCHES = ("hello", "fib", "loop", "seqs") | ||
| 22 | +LABELS = ("clonim", "jolt") | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def command(root, *args, env=None): | ||
| 26 | + return subprocess.check_output(args, cwd=root, env=env, text=True).strip() | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def require_clean(root): | ||
| 30 | + if command(root, "git", "status", "--porcelain", "--untracked-files=all"): | ||
| 31 | + raise ValueError("working tree must be clean, including untracked files") | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def cpu_info(): | ||
| 35 | + try: | ||
| 36 | + for line in Path("/proc/cpuinfo").read_text().splitlines(): | ||
| 37 | + key, sep, value = line.partition(":") | ||
| 38 | + if sep and key.strip() in ("model name", "Hardware"): | ||
| 39 | + return value.strip() | ||
| 40 | + except OSError: | ||
| 41 | + pass | ||
| 42 | + return platform.processor() or "unknown" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def compatibility(root): | ||
| 46 | + paths = sorted((root / "bench").glob("*.clj")) | ||
| 47 | + paths += [root / "bench/measure.py", root / "justfile"] | ||
| 48 | + return { | ||
| 49 | + "machine": { | ||
| 50 | + "system": platform.system(), | ||
| 51 | + "release": platform.release(), | ||
| 52 | + "machine": platform.machine(), | ||
| 53 | + "hostname": platform.node(), | ||
| 54 | + "cpu": cpu_info(), | ||
| 55 | + "logical_cpus": os.cpu_count(), | ||
| 56 | + }, | ||
| 57 | + "tool_versions": { | ||
| 58 | + tool: command(root, tool, "--version") for tool in ("nim", "jolt") | ||
| 59 | + }, | ||
| 60 | + "methodology": { | ||
| 61 | + "schema_version": 1, | ||
| 62 | + "rounds": 11, | ||
| 63 | + "metric": "best_ms", | ||
| 64 | + "schedule": "round-robin", | ||
| 65 | + }, | ||
| 66 | + "sha256": { | ||
| 67 | + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() | ||
| 68 | + for path in paths | ||
| 69 | + }, | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def number(value): | ||
| 74 | + return type(value) in (int, float) and math.isfinite(value) | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +def validate_results(results): | ||
| 78 | + if not isinstance(results, dict): | ||
| 79 | + # Invalid JSON measurements consistently raise ValueError for callers. | ||
| 80 | + raise ValueError("measurement must be a JSON object") # noqa: TRY004 | ||
| 81 | + if results.get("schema_version") != 1 or results.get("rounds") != 11: | ||
| 82 | + raise ValueError("expected measurement schema_version=1 and rounds=11") | ||
| 83 | + try: | ||
| 84 | + for label in LABELS: | ||
| 85 | + for bench in BENCHES: | ||
| 86 | + samples = results["samples_ms"][label][bench] | ||
| 87 | + best = results["best_ms"][label][bench] | ||
| 88 | + if (not isinstance(samples, list) or len(samples) != 11 | ||
| 89 | + or not all(number(v) and v >= 0 for v in samples) | ||
| 90 | + or not number(best) or best != min(samples)): | ||
| 91 | + raise ValueError(f"invalid samples/best for {label}/{bench}") | ||
| 92 | + # Some producers omit the identically-zero hello compute metric. | ||
| 93 | + if bench != "hello" or bench in results["compute_ms"][label]: | ||
| 94 | + compute = results["compute_ms"][label][bench] | ||
| 95 | + expected = best - results["best_ms"][label]["hello"] | ||
| 96 | + if not number(compute) or not math.isclose( | ||
| 97 | + compute, expected, rel_tol=1e-9, abs_tol=1e-9): | ||
| 98 | + raise ValueError(f"invalid compute for {label}/{bench}") | ||
| 99 | + except (KeyError, TypeError) as exc: | ||
| 100 | + raise ValueError("missing or invalid measurement metrics") from exc | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +def latest_baseline(directory, compatible): | ||
| 104 | + candidates = [] | ||
| 105 | + for path in directory.glob("*.json"): | ||
| 106 | + try: | ||
| 107 | + record = json.loads(path.read_text()) | ||
| 108 | + if record["schema_version"] != 1 or record["compatibility"] != compatible: | ||
| 109 | + continue | ||
| 110 | + validate_results(record["results"]) | ||
| 111 | + created = datetime.fromisoformat(record["created_at"]) | ||
| 112 | + if created.utcoffset() is None: | ||
| 113 | + continue | ||
| 114 | + candidates.append((created, path.name, record)) | ||
| 115 | + except (OSError, ValueError, KeyError, TypeError): | ||
| 116 | + continue | ||
| 117 | + if not candidates: | ||
| 118 | + return None | ||
| 119 | + _, name, record = max(candidates, key=lambda item: (item[0], item[1])) | ||
| 120 | + return name, record | ||
| 121 | + | ||
| 122 | + | ||
| 123 | +def compare(results, baseline, threshold): | ||
| 124 | + comparison = {"baseline": None, "threshold_percent": threshold, | ||
| 125 | + "deltas": {}, "regression": False} | ||
| 126 | + if baseline is None: | ||
| 127 | + return comparison | ||
| 128 | + name, prior = baseline | ||
| 129 | + comparison["baseline"] = name | ||
| 130 | + for bench in BENCHES: | ||
| 131 | + old = prior["results"]["best_ms"]["clonim"][bench] | ||
| 132 | + new = results["best_ms"]["clonim"][bench] | ||
| 133 | + delta = new - old | ||
| 134 | + percent = 100 * delta / old if old else (0.0 if new == 0 else None) | ||
| 135 | + regression = delta > old * threshold / 100 | ||
| 136 | + comparison["deltas"][bench] = { | ||
| 137 | + "baseline_ms": old, "current_ms": new, "delta_ms": delta, | ||
| 138 | + "percent": percent, "regression": regression, | ||
| 139 | + } | ||
| 140 | + comparison["regression"] |= regression | ||
| 141 | + return comparison | ||
| 142 | + | ||
| 143 | + | ||
| 144 | +def report(comparison): | ||
| 145 | + if comparison["baseline"] is None: | ||
| 146 | + print("No compatible baseline; recording initial run.") | ||
| 147 | + else: | ||
| 148 | + print(f"Baseline: {comparison['baseline']}") | ||
| 149 | + for bench, delta in comparison["deltas"].items(): | ||
| 150 | + percent = delta["percent"] | ||
| 151 | + change = f"{percent:+.2f}%" if percent is not None else "increase from zero" | ||
| 152 | + print(f" {bench}: {delta['delta_ms']:+.3f} ms ({change})" | ||
| 153 | + + (" REGRESSION" if delta["regression"] else "")) | ||
| 154 | + print(f"Regression: {comparison['regression']}") | ||
| 155 | + | ||
| 156 | + | ||
| 157 | +def store(root, threshold): | ||
| 158 | + require_clean(root) | ||
| 159 | + for tool in ("jolt", "nim", "just"): | ||
| 160 | + if shutil.which(tool) is None: | ||
| 161 | + raise ValueError(f"required executable not found: {tool}") | ||
| 162 | + revision = command(root, "git", "rev-parse", "HEAD") | ||
| 163 | + compatible = compatibility(root) | ||
| 164 | + directory = root / "bench/results" | ||
| 165 | + baseline = latest_baseline(directory, compatible) | ||
| 166 | + with tempfile.TemporaryDirectory(prefix="clonim-bench-") as work: | ||
| 167 | + output = Path(work).resolve() / "measurement.json" | ||
| 168 | + env = dict(os.environ, BENCH_JSON=str(output)) | ||
| 169 | + subprocess.run(["just", "measure", "jolt"], cwd=root, env=env, check=True) | ||
| 170 | + results = json.loads(output.read_text()) | ||
| 171 | + validate_results(results) | ||
| 172 | + if command(root, "git", "rev-parse", "HEAD") != revision: | ||
| 173 | + raise ValueError("HEAD changed during measurement; refusing to record") | ||
| 174 | + if compatibility(root) != compatible: | ||
| 175 | + raise ValueError("benchmark inputs or environment changed during measurement") | ||
| 176 | + comparison = compare(results, baseline, threshold) | ||
| 177 | + now = datetime.now(timezone.utc) | ||
| 178 | + record = { | ||
| 179 | + "schema_version": 1, | ||
| 180 | + "created_at": now.isoformat(), | ||
| 181 | + "source_revision": revision, | ||
| 182 | + "compatibility": compatible, | ||
| 183 | + "results": results, | ||
| 184 | + "comparison": comparison, | ||
| 185 | + } | ||
| 186 | + directory.mkdir(parents=True, exist_ok=True) | ||
| 187 | + path = directory / f"{now.strftime('%Y%m%dT%H%M%S.%fZ')}-{uuid.uuid4().hex}.json" | ||
| 188 | + with path.open("x") as stream: | ||
| 189 | + json.dump(record, stream, indent=2, allow_nan=False) | ||
| 190 | + stream.write("\n") | ||
| 191 | + relative = str(path.relative_to(root)) | ||
| 192 | + report(comparison) | ||
| 193 | + print(f"Record: {relative}", flush=True) | ||
| 194 | + subprocess.run(["git", "add", "--", relative], cwd=root, check=True) | ||
| 195 | + # --only excludes anything another process staged while benchmarks ran. | ||
| 196 | + subprocess.run(["git", "commit", "--only", "-m", f"bench: record {path.stem}", | ||
| 197 | + "--", relative], cwd=root, | ||
| 198 | + env=dict(os.environ, GIT_EDITOR="true"), check=True) | ||
| 199 | + return 1 if comparison["regression"] else 0 | ||
| 200 | + | ||
| 201 | + | ||
| 202 | +def threshold_value(text): | ||
| 203 | + value = float(text) | ||
| 204 | + if not math.isfinite(value) or value < 0: | ||
| 205 | + raise argparse.ArgumentTypeError("threshold must be finite and nonnegative") | ||
| 206 | + return value | ||
| 207 | + | ||
| 208 | + | ||
| 209 | +def main(argv=None): | ||
| 210 | + parser = argparse.ArgumentParser(description=__doc__) | ||
| 211 | + parser.add_argument("threshold", nargs="?", type=threshold_value, default=10.0, | ||
| 212 | + help="alert above this percentage increase (default: 10)") | ||
| 213 | + args = parser.parse_args(argv) | ||
| 214 | + try: | ||
| 215 | + return store(ROOT, args.threshold) | ||
| 216 | + except (OSError, ValueError, subprocess.CalledProcessError) as exc: | ||
| 217 | + print(f"bench-store: {exc}", file=sys.stderr) | ||
| 218 | + return 2 | ||
| 219 | + | ||
| 220 | + | ||
| 221 | +if __name__ == "__main__": | ||
| 222 | + sys.exit(main()) | ||
added
bench/test_measure.py +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +import contextlib | |
| 2 | +import io | |
| 3 | +import json | |
| 4 | +import os | |
| 5 | + | |
| 6 | +import subprocess | |
| 7 | +import tempfile | |
| 8 | +import unittest | |
| 9 | +from pathlib import Path | |
| 10 | +from unittest.mock import patch | |
| 11 | + | |
| 12 | +import measure | |
| 13 | +from store import validate_results | |
| 14 | + | |
| 15 | + | |
| 16 | +class MeasureTests(unittest.TestCase): | |
| 17 | + def test_json_matches_storage_contract(self): | |
| 18 | + with tempfile.TemporaryDirectory() as directory: | |
| 19 | + output = Path(directory) / "result.json" | |
| 20 | + with patch.dict(os.environ, BENCH_JSON=str(output)), \ | |
| 21 | + patch.object(measure.subprocess, "run") as run, \ | |
| 22 | + patch.object(measure.time, "perf_counter", side_effect=range(176)), \ | |
| 23 | + contextlib.redirect_stdout(io.StringIO()): | |
| 24 | + measure.main(["--with-jolt"]) | |
| 25 | + results = json.loads(output.read_text()) | |
| 26 | + validate_results(results) | |
| 27 | + self.assertEqual(set(results["best_ms"]), {"clonim", "jolt"}) | |
| 28 | + self.assertEqual(run.call_count, 96) | |
| 29 | + self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list)) | |
| 30 | + | |
| 31 | + def test_failed_binary_aborts(self): | |
| 32 | + with patch.object(measure.subprocess, "run", | |
| 33 | + side_effect=subprocess.CalledProcessError(1, "bench")), \ | |
| 34 | + self.assertRaises(subprocess.CalledProcessError): | |
| 35 | + measure.timed(["bench"]) | |
| 36 | + | |
| 37 | + | |
| 38 | +if __name__ == "__main__": | |
| 39 | + unittest.main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +import contextlib | ||
| 2 | +import io | ||
| 3 | +import json | ||
| 4 | +import os | ||
| 5 | + | ||
| 6 | +import subprocess | ||
| 7 | +import tempfile | ||
| 8 | +import unittest | ||
| 9 | +from pathlib import Path | ||
| 10 | +from unittest.mock import patch | ||
| 11 | + | ||
| 12 | +import measure | ||
| 13 | +from store import validate_results | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +class MeasureTests(unittest.TestCase): | ||
| 17 | + def test_json_matches_storage_contract(self): | ||
| 18 | + with tempfile.TemporaryDirectory() as directory: | ||
| 19 | + output = Path(directory) / "result.json" | ||
| 20 | + with patch.dict(os.environ, BENCH_JSON=str(output)), \ | ||
| 21 | + patch.object(measure.subprocess, "run") as run, \ | ||
| 22 | + patch.object(measure.time, "perf_counter", side_effect=range(176)), \ | ||
| 23 | + contextlib.redirect_stdout(io.StringIO()): | ||
| 24 | + measure.main(["--with-jolt"]) | ||
| 25 | + results = json.loads(output.read_text()) | ||
| 26 | + validate_results(results) | ||
| 27 | + self.assertEqual(set(results["best_ms"]), {"clonim", "jolt"}) | ||
| 28 | + self.assertEqual(run.call_count, 96) | ||
| 29 | + self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list)) | ||
| 30 | + | ||
| 31 | + def test_failed_binary_aborts(self): | ||
| 32 | + with patch.object(measure.subprocess, "run", | ||
| 33 | + side_effect=subprocess.CalledProcessError(1, "bench")), \ | ||
| 34 | + self.assertRaises(subprocess.CalledProcessError): | ||
| 35 | + measure.timed(["bench"]) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +if __name__ == "__main__": | ||
| 39 | + unittest.main() | ||
added
bench/test_store.py +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +"""Stdlib tests; git and benchmark execution are mocked, never committed.""" | |
| 2 | + | |
| 3 | +import copy | |
| 4 | +import importlib.util | |
| 5 | +import json | |
| 6 | + | |
| 7 | +import subprocess | |
| 8 | +import tempfile | |
| 9 | +import unittest | |
| 10 | +from pathlib import Path | |
| 11 | +from unittest.mock import patch | |
| 12 | + | |
| 13 | +spec = importlib.util.spec_from_file_location("bench_store", Path(__file__).with_name("store.py")) | |
| 14 | +assert spec is not None and spec.loader is not None | |
| 15 | +store = importlib.util.module_from_spec(spec) | |
| 16 | +spec.loader.exec_module(store) | |
| 17 | + | |
| 18 | + | |
| 19 | +def results(value=10): | |
| 20 | + best = {label: {bench: value for bench in store.BENCHES} for label in store.LABELS} | |
| 21 | + return { | |
| 22 | + "schema_version": 1, "rounds": 11, | |
| 23 | + "samples_ms": {label: {bench: [value] * 11 for bench in store.BENCHES} | |
| 24 | + for label in store.LABELS}, | |
| 25 | + "best_ms": best, | |
| 26 | + "compute_ms": {label: {bench: 0 for bench in store.BENCHES} | |
| 27 | + for label in store.LABELS}, | |
| 28 | + } | |
| 29 | + | |
| 30 | + | |
| 31 | +def record(value=10, compatible=None, date="2026-01-01T00:00:00+00:00"): | |
| 32 | + return {"schema_version": 1, "created_at": date, | |
| 33 | + "source_revision": "old", "compatibility": compatible or {"machine": "a"}, | |
| 34 | + "results": results(value)} | |
| 35 | + | |
| 36 | + | |
| 37 | +class ComparisonTests(unittest.TestCase): | |
| 38 | + def test_no_baseline(self): | |
| 39 | + comparison = store.compare(results(), None, 10) | |
| 40 | + self.assertFalse(comparison["regression"]) | |
| 41 | + self.assertEqual(comparison["deltas"], {}) | |
| 42 | + | |
| 43 | + def test_strict_threshold_and_all_benches(self): | |
| 44 | + baseline = ("prior.json", record()) | |
| 45 | + self.assertFalse(store.compare(results(11), baseline, 10)["regression"]) | |
| 46 | + self.assertFalse(store.compare(results(9), baseline, 10)["regression"]) | |
| 47 | + for bench in store.BENCHES: | |
| 48 | + current = results() | |
| 49 | + current["best_ms"]["clonim"][bench] = 12 | |
| 50 | + comparison = store.compare(current, baseline, 10) | |
| 51 | + self.assertTrue(comparison["regression"]) | |
| 52 | + self.assertEqual(comparison["deltas"][bench]["percent"], 20) | |
| 53 | + current = results() | |
| 54 | + current["best_ms"]["jolt"]["fib"] = 100 | |
| 55 | + self.assertFalse(store.compare(current, baseline, 10)["regression"]) | |
| 56 | + | |
| 57 | + def test_zero_baseline_is_json_safe(self): | |
| 58 | + comparison = store.compare(results(1), ("zero.json", record(0)), 10) | |
| 59 | + self.assertTrue(comparison["regression"]) | |
| 60 | + self.assertIsNone(comparison["deltas"]["hello"]["percent"]) | |
| 61 | + json.dumps(comparison, allow_nan=False) | |
| 62 | + self.assertFalse(store.compare(results(0), ("zero", record(0)), 10)["regression"]) | |
| 63 | + | |
| 64 | + def test_latest_compatible_not_source_revision_or_filename(self): | |
| 65 | + with tempfile.TemporaryDirectory() as work: | |
| 66 | + directory = Path(work) | |
| 67 | + old = record() | |
| 68 | + latest = record(date="2026-02-01T00:00:00+00:00") | |
| 69 | + latest["source_revision"] = "different" | |
| 70 | + incompatible = record(compatible={"machine": "b"}, | |
| 71 | + date="2026-03-01T00:00:00+00:00") | |
| 72 | + for name, data in (("z.json", old), ("a.json", latest), | |
| 73 | + ("new.json", incompatible)): | |
| 74 | + (directory / name).write_text(json.dumps(data)) | |
| 75 | + (directory / "broken.json").write_text("{") | |
| 76 | + self.assertEqual(store.latest_baseline(directory, old["compatibility"])[0], | |
| 77 | + "a.json") | |
| 78 | + self.assertIsNone(store.latest_baseline(directory, {"machine": "c"})) | |
| 79 | + # Every compatibility component matters, not just machine identity. | |
| 80 | + for key in ("tool_versions", "methodology", "sha256"): | |
| 81 | + changed = dict(old["compatibility"], **{key: "changed"}) | |
| 82 | + self.assertIsNone(store.latest_baseline(directory, changed)) | |
| 83 | + | |
| 84 | + def test_validation(self): | |
| 85 | + store.validate_results(results()) | |
| 86 | + mutations = [lambda r: r.update(rounds=10), | |
| 87 | + lambda r: r["best_ms"].pop("jolt"), | |
| 88 | + lambda r: r["samples_ms"]["clonim"].update(hello=[1]), | |
| 89 | + lambda r: r["best_ms"]["clonim"].update(fib=float("nan")), | |
| 90 | + lambda r: r["compute_ms"]["clonim"].update(fib=1)] | |
| 91 | + for mutate in mutations: | |
| 92 | + data = copy.deepcopy(results()) | |
| 93 | + mutate(data) | |
| 94 | + with self.assertRaises(ValueError): | |
| 95 | + store.validate_results(data) | |
| 96 | + | |
| 97 | + def test_non_object_measurement_raises_value_error(self): | |
| 98 | + for value in (None, [], "invalid", 1, True): | |
| 99 | + with self.subTest(value=value), self.assertRaisesRegex( | |
| 100 | + ValueError, "measurement must be a JSON object" | |
| 101 | + ): | |
| 102 | + store.validate_results(value) | |
| 103 | + | |
| 104 | + def test_threshold(self): | |
| 105 | + self.assertEqual(store.threshold_value("10"), 10) | |
| 106 | + for text in ("-1", "nan", "inf"): | |
| 107 | + with self.assertRaises(store.argparse.ArgumentTypeError): | |
| 108 | + store.threshold_value(text) | |
| 109 | + | |
| 110 | + | |
| 111 | +class WorkflowTests(unittest.TestCase): | |
| 112 | + def test_dirty_tree_including_untracked(self): | |
| 113 | + for status in (" M source.nim", "?? new.txt", "A staged.txt"): | |
| 114 | + with patch.object(store, "command", return_value=status) as command: | |
| 115 | + with self.assertRaisesRegex(ValueError, "clean"): | |
| 116 | + store.require_clean(Path(".")) | |
| 117 | + self.assertIn("--untracked-files=all", command.call_args.args) | |
| 118 | + | |
| 119 | + def test_missing_jolt_before_measurement(self): | |
| 120 | + with patch.object(store, "require_clean"), \ | |
| 121 | + patch.object(store.shutil, "which", return_value=None), \ | |
| 122 | + patch.object(store.subprocess, "run") as run: | |
| 123 | + with self.assertRaisesRegex(ValueError, "jolt"): | |
| 124 | + store.store(Path("."), 10) | |
| 125 | + run.assert_not_called() | |
| 126 | + | |
| 127 | + def workflow(self, root, value=10, failure=None): | |
| 128 | + compatible = {"machine": "a"} | |
| 129 | + | |
| 130 | + def run(args, **kwargs): | |
| 131 | + if args == ["just", "measure", "jolt"]: | |
| 132 | + path = Path(kwargs["env"]["BENCH_JSON"]) | |
| 133 | + self.assertTrue(path.is_absolute()) | |
| 134 | + self.assertEqual(kwargs["cwd"], root) | |
| 135 | + if failure == "measure": | |
| 136 | + raise subprocess.CalledProcessError(1, args) | |
| 137 | + if failure == "missing": | |
| 138 | + return | |
| 139 | + path.write_text("{}" if failure == "invalid" else json.dumps(results(value))) | |
| 140 | + elif args[:2] == ["git", "commit"] and failure == "commit": | |
| 141 | + raise subprocess.CalledProcessError(1, args) | |
| 142 | + | |
| 143 | + with patch.object(store, "require_clean"), \ | |
| 144 | + patch.object(store.shutil, "which", return_value="/tool"), \ | |
| 145 | + patch.object(store, "command", return_value="source-sha"), \ | |
| 146 | + patch.object(store, "compatibility", return_value=compatible), \ | |
| 147 | + patch.object(store.subprocess, "run", side_effect=run) as runner: | |
| 148 | + if failure: | |
| 149 | + with self.assertRaises((OSError, ValueError, subprocess.CalledProcessError)): | |
| 150 | + store.store(root, 10) | |
| 151 | + return runner | |
| 152 | + self.assertEqual(store.store(root, 10), 1 if value > 11 else 0) | |
| 153 | + return runner | |
| 154 | + | |
| 155 | + def test_regression_recorded_then_committed_only_its_path(self): | |
| 156 | + with tempfile.TemporaryDirectory() as work: | |
| 157 | + root = Path(work) | |
| 158 | + directory = root / "bench/results" | |
| 159 | + directory.mkdir(parents=True) | |
| 160 | + (directory / "prior.json").write_text(json.dumps(record())) | |
| 161 | + runner = self.workflow(root, 12) | |
| 162 | + paths = [p for p in directory.glob("*.json") if p.name != "prior.json"] | |
| 163 | + self.assertEqual(len(paths), 1) | |
| 164 | + data = json.loads(paths[0].read_text()) | |
| 165 | + self.assertEqual(data["source_revision"], "source-sha") | |
| 166 | + self.assertTrue(data["comparison"]["regression"]) | |
| 167 | + relative = str(paths[0].relative_to(root)) | |
| 168 | + add, commit = runner.call_args_list[-2:] | |
| 169 | + self.assertEqual(add.args[0], ["git", "add", "--", relative]) | |
| 170 | + self.assertEqual(commit.args[0][:3], ["git", "commit", "--only"]) | |
| 171 | + self.assertEqual(commit.args[0][-2:], ["--", relative]) | |
| 172 | + self.assertEqual(commit.kwargs["env"]["GIT_EDITOR"], "true") | |
| 173 | + | |
| 174 | + def test_failed_measurement_creates_no_record(self): | |
| 175 | + for failure in ("measure", "missing", "invalid"): | |
| 176 | + with tempfile.TemporaryDirectory() as work: | |
| 177 | + root = Path(work) | |
| 178 | + runner = self.workflow(root, failure=failure) | |
| 179 | + self.assertFalse((root / "bench/results").exists()) | |
| 180 | + self.assertEqual(runner.call_count, 1) | |
| 181 | + | |
| 182 | + def test_commit_failure_keeps_record(self): | |
| 183 | + with tempfile.TemporaryDirectory() as work: | |
| 184 | + root = Path(work) | |
| 185 | + self.workflow(root, failure="commit") | |
| 186 | + self.assertEqual(len(list((root / "bench/results").glob("*.json"))), 1) | |
| 187 | + | |
| 188 | + def test_unique_records(self): | |
| 189 | + with tempfile.TemporaryDirectory() as work: | |
| 190 | + root = Path(work) | |
| 191 | + self.workflow(root) | |
| 192 | + self.workflow(root) | |
| 193 | + self.assertEqual(len(list((root / "bench/results").glob("*.json"))), 2) | |
| 194 | + | |
| 195 | + | |
| 196 | +if __name__ == "__main__": | |
| 197 | + unittest.main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +"""Stdlib tests; git and benchmark execution are mocked, never committed.""" | ||
| 2 | + | ||
| 3 | +import copy | ||
| 4 | +import importlib.util | ||
| 5 | +import json | ||
| 6 | + | ||
| 7 | +import subprocess | ||
| 8 | +import tempfile | ||
| 9 | +import unittest | ||
| 10 | +from pathlib import Path | ||
| 11 | +from unittest.mock import patch | ||
| 12 | + | ||
| 13 | +spec = importlib.util.spec_from_file_location("bench_store", Path(__file__).with_name("store.py")) | ||
| 14 | +assert spec is not None and spec.loader is not None | ||
| 15 | +store = importlib.util.module_from_spec(spec) | ||
| 16 | +spec.loader.exec_module(store) | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def results(value=10): | ||
| 20 | + best = {label: {bench: value for bench in store.BENCHES} for label in store.LABELS} | ||
| 21 | + return { | ||
| 22 | + "schema_version": 1, "rounds": 11, | ||
| 23 | + "samples_ms": {label: {bench: [value] * 11 for bench in store.BENCHES} | ||
| 24 | + for label in store.LABELS}, | ||
| 25 | + "best_ms": best, | ||
| 26 | + "compute_ms": {label: {bench: 0 for bench in store.BENCHES} | ||
| 27 | + for label in store.LABELS}, | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def record(value=10, compatible=None, date="2026-01-01T00:00:00+00:00"): | ||
| 32 | + return {"schema_version": 1, "created_at": date, | ||
| 33 | + "source_revision": "old", "compatibility": compatible or {"machine": "a"}, | ||
| 34 | + "results": results(value)} | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +class ComparisonTests(unittest.TestCase): | ||
| 38 | + def test_no_baseline(self): | ||
| 39 | + comparison = store.compare(results(), None, 10) | ||
| 40 | + self.assertFalse(comparison["regression"]) | ||
| 41 | + self.assertEqual(comparison["deltas"], {}) | ||
| 42 | + | ||
| 43 | + def test_strict_threshold_and_all_benches(self): | ||
| 44 | + baseline = ("prior.json", record()) | ||
| 45 | + self.assertFalse(store.compare(results(11), baseline, 10)["regression"]) | ||
| 46 | + self.assertFalse(store.compare(results(9), baseline, 10)["regression"]) | ||
| 47 | + for bench in store.BENCHES: | ||
| 48 | + current = results() | ||
| 49 | + current["best_ms"]["clonim"][bench] = 12 | ||
| 50 | + comparison = store.compare(current, baseline, 10) | ||
| 51 | + self.assertTrue(comparison["regression"]) | ||
| 52 | + self.assertEqual(comparison["deltas"][bench]["percent"], 20) | ||
| 53 | + current = results() | ||
| 54 | + current["best_ms"]["jolt"]["fib"] = 100 | ||
| 55 | + self.assertFalse(store.compare(current, baseline, 10)["regression"]) | ||
| 56 | + | ||
| 57 | + def test_zero_baseline_is_json_safe(self): | ||
| 58 | + comparison = store.compare(results(1), ("zero.json", record(0)), 10) | ||
| 59 | + self.assertTrue(comparison["regression"]) | ||
| 60 | + self.assertIsNone(comparison["deltas"]["hello"]["percent"]) | ||
| 61 | + json.dumps(comparison, allow_nan=False) | ||
| 62 | + self.assertFalse(store.compare(results(0), ("zero", record(0)), 10)["regression"]) | ||
| 63 | + | ||
| 64 | + def test_latest_compatible_not_source_revision_or_filename(self): | ||
| 65 | + with tempfile.TemporaryDirectory() as work: | ||
| 66 | + directory = Path(work) | ||
| 67 | + old = record() | ||
| 68 | + latest = record(date="2026-02-01T00:00:00+00:00") | ||
| 69 | + latest["source_revision"] = "different" | ||
| 70 | + incompatible = record(compatible={"machine": "b"}, | ||
| 71 | + date="2026-03-01T00:00:00+00:00") | ||
| 72 | + for name, data in (("z.json", old), ("a.json", latest), | ||
| 73 | + ("new.json", incompatible)): | ||
| 74 | + (directory / name).write_text(json.dumps(data)) | ||
| 75 | + (directory / "broken.json").write_text("{") | ||
| 76 | + self.assertEqual(store.latest_baseline(directory, old["compatibility"])[0], | ||
| 77 | + "a.json") | ||
| 78 | + self.assertIsNone(store.latest_baseline(directory, {"machine": "c"})) | ||
| 79 | + # Every compatibility component matters, not just machine identity. | ||
| 80 | + for key in ("tool_versions", "methodology", "sha256"): | ||
| 81 | + changed = dict(old["compatibility"], **{key: "changed"}) | ||
| 82 | + self.assertIsNone(store.latest_baseline(directory, changed)) | ||
| 83 | + | ||
| 84 | + def test_validation(self): | ||
| 85 | + store.validate_results(results()) | ||
| 86 | + mutations = [lambda r: r.update(rounds=10), | ||
| 87 | + lambda r: r["best_ms"].pop("jolt"), | ||
| 88 | + lambda r: r["samples_ms"]["clonim"].update(hello=[1]), | ||
| 89 | + lambda r: r["best_ms"]["clonim"].update(fib=float("nan")), | ||
| 90 | + lambda r: r["compute_ms"]["clonim"].update(fib=1)] | ||
| 91 | + for mutate in mutations: | ||
| 92 | + data = copy.deepcopy(results()) | ||
| 93 | + mutate(data) | ||
| 94 | + with self.assertRaises(ValueError): | ||
| 95 | + store.validate_results(data) | ||
| 96 | + | ||
| 97 | + def test_non_object_measurement_raises_value_error(self): | ||
| 98 | + for value in (None, [], "invalid", 1, True): | ||
| 99 | + with self.subTest(value=value), self.assertRaisesRegex( | ||
| 100 | + ValueError, "measurement must be a JSON object" | ||
| 101 | + ): | ||
| 102 | + store.validate_results(value) | ||
| 103 | + | ||
| 104 | + def test_threshold(self): | ||
| 105 | + self.assertEqual(store.threshold_value("10"), 10) | ||
| 106 | + for text in ("-1", "nan", "inf"): | ||
| 107 | + with self.assertRaises(store.argparse.ArgumentTypeError): | ||
| 108 | + store.threshold_value(text) | ||
| 109 | + | ||
| 110 | + | ||
| 111 | +class WorkflowTests(unittest.TestCase): | ||
| 112 | + def test_dirty_tree_including_untracked(self): | ||
| 113 | + for status in (" M source.nim", "?? new.txt", "A staged.txt"): | ||
| 114 | + with patch.object(store, "command", return_value=status) as command: | ||
| 115 | + with self.assertRaisesRegex(ValueError, "clean"): | ||
| 116 | + store.require_clean(Path(".")) | ||
| 117 | + self.assertIn("--untracked-files=all", command.call_args.args) | ||
| 118 | + | ||
| 119 | + def test_missing_jolt_before_measurement(self): | ||
| 120 | + with patch.object(store, "require_clean"), \ | ||
| 121 | + patch.object(store.shutil, "which", return_value=None), \ | ||
| 122 | + patch.object(store.subprocess, "run") as run: | ||
| 123 | + with self.assertRaisesRegex(ValueError, "jolt"): | ||
| 124 | + store.store(Path("."), 10) | ||
| 125 | + run.assert_not_called() | ||
| 126 | + | ||
| 127 | + def workflow(self, root, value=10, failure=None): | ||
| 128 | + compatible = {"machine": "a"} | ||
| 129 | + | ||
| 130 | + def run(args, **kwargs): | ||
| 131 | + if args == ["just", "measure", "jolt"]: | ||
| 132 | + path = Path(kwargs["env"]["BENCH_JSON"]) | ||
| 133 | + self.assertTrue(path.is_absolute()) | ||
| 134 | + self.assertEqual(kwargs["cwd"], root) | ||
| 135 | + if failure == "measure": | ||
| 136 | + raise subprocess.CalledProcessError(1, args) | ||
| 137 | + if failure == "missing": | ||
| 138 | + return | ||
| 139 | + path.write_text("{}" if failure == "invalid" else json.dumps(results(value))) | ||
| 140 | + elif args[:2] == ["git", "commit"] and failure == "commit": | ||
| 141 | + raise subprocess.CalledProcessError(1, args) | ||
| 142 | + | ||
| 143 | + with patch.object(store, "require_clean"), \ | ||
| 144 | + patch.object(store.shutil, "which", return_value="/tool"), \ | ||
| 145 | + patch.object(store, "command", return_value="source-sha"), \ | ||
| 146 | + patch.object(store, "compatibility", return_value=compatible), \ | ||
| 147 | + patch.object(store.subprocess, "run", side_effect=run) as runner: | ||
| 148 | + if failure: | ||
| 149 | + with self.assertRaises((OSError, ValueError, subprocess.CalledProcessError)): | ||
| 150 | + store.store(root, 10) | ||
| 151 | + return runner | ||
| 152 | + self.assertEqual(store.store(root, 10), 1 if value > 11 else 0) | ||
| 153 | + return runner | ||
| 154 | + | ||
| 155 | + def test_regression_recorded_then_committed_only_its_path(self): | ||
| 156 | + with tempfile.TemporaryDirectory() as work: | ||
| 157 | + root = Path(work) | ||
| 158 | + directory = root / "bench/results" | ||
| 159 | + directory.mkdir(parents=True) | ||
| 160 | + (directory / "prior.json").write_text(json.dumps(record())) | ||
| 161 | + runner = self.workflow(root, 12) | ||
| 162 | + paths = [p for p in directory.glob("*.json") if p.name != "prior.json"] | ||
| 163 | + self.assertEqual(len(paths), 1) | ||
| 164 | + data = json.loads(paths[0].read_text()) | ||
| 165 | + self.assertEqual(data["source_revision"], "source-sha") | ||
| 166 | + self.assertTrue(data["comparison"]["regression"]) | ||
| 167 | + relative = str(paths[0].relative_to(root)) | ||
| 168 | + add, commit = runner.call_args_list[-2:] | ||
| 169 | + self.assertEqual(add.args[0], ["git", "add", "--", relative]) | ||
| 170 | + self.assertEqual(commit.args[0][:3], ["git", "commit", "--only"]) | ||
| 171 | + self.assertEqual(commit.args[0][-2:], ["--", relative]) | ||
| 172 | + self.assertEqual(commit.kwargs["env"]["GIT_EDITOR"], "true") | ||
| 173 | + | ||
| 174 | + def test_failed_measurement_creates_no_record(self): | ||
| 175 | + for failure in ("measure", "missing", "invalid"): | ||
| 176 | + with tempfile.TemporaryDirectory() as work: | ||
| 177 | + root = Path(work) | ||
| 178 | + runner = self.workflow(root, failure=failure) | ||
| 179 | + self.assertFalse((root / "bench/results").exists()) | ||
| 180 | + self.assertEqual(runner.call_count, 1) | ||
| 181 | + | ||
| 182 | + def test_commit_failure_keeps_record(self): | ||
| 183 | + with tempfile.TemporaryDirectory() as work: | ||
| 184 | + root = Path(work) | ||
| 185 | + self.workflow(root, failure="commit") | ||
| 186 | + self.assertEqual(len(list((root / "bench/results").glob("*.json"))), 1) | ||
| 187 | + | ||
| 188 | + def test_unique_records(self): | ||
| 189 | + with tempfile.TemporaryDirectory() as work: | ||
| 190 | + root = Path(work) | ||
| 191 | + self.workflow(root) | ||
| 192 | + self.workflow(root) | ||
| 193 | + self.assertEqual(len(list((root / "bench/results").glob("*.json"))), 2) | ||
| 194 | + | ||
| 195 | + | ||
| 196 | +if __name__ == "__main__": | ||
| 197 | + unittest.main() | ||
modified
justfile +8 -0 | @@ -57,6 +57,10 @@ clean: | ||
| 57 | 57 | # is installed. Both toolchains get a `hello` binary so startup can be |
| 58 | 58 | # subtracted; see bench/measure.py for why the numbers are best-of-N |
| 59 | 59 | # round-robin rather than averaged. |
| 60 | +# Record a Jolt comparison, flag regressions, and commit only its history record. | |
| 61 | +bench-store threshold="10": | |
| 62 | + python3 bench/store.py {{quote(threshold)}} | |
| 63 | + | |
| 60 | 64 | # Time bench/*.clj as native binaries, optionally against jolt. |
| 61 | 65 | measure mode="solo": release |
| 62 | 66 | #!/usr/bin/env bash |
| @@ -70,6 +74,10 @@ measure mode="solo": release | ||
| 70 | 74 | exec python3 bench/measure.py |
| 71 | 75 | fi |
| 72 | 76 | if ! command -v jolt >/dev/null; then |
| 77 | + if [ -n "${BENCH_JSON:-}" ]; then | |
| 78 | + echo "jolt is required when storing benchmark results" >&2 | |
| 79 | + exit 1 | |
| 80 | + fi | |
| 73 | 81 | echo "jolt is not installed; running clonim only" >&2 |
| 74 | 82 | exec python3 bench/measure.py |
| 75 | 83 | fi |
| @@ -57,6 +57,10 @@ clean: | |||
| 57 | # is installed. Both toolchains get a `hello` binary so startup can be | 57 | # is installed. Both toolchains get a `hello` binary so startup can be |
| 58 | # subtracted; see bench/measure.py for why the numbers are best-of-N | 58 | # subtracted; see bench/measure.py for why the numbers are best-of-N |
| 59 | # round-robin rather than averaged. | 59 | # round-robin rather than averaged. |
| 60 | +# Record a Jolt comparison, flag regressions, and commit only its history record. | ||
| 61 | +bench-store threshold="10": | ||
| 62 | + python3 bench/store.py {{quote(threshold)}} | ||
| 63 | + | ||
| 60 | # Time bench/*.clj as native binaries, optionally against jolt. | 64 | # Time bench/*.clj as native binaries, optionally against jolt. |
| 61 | measure mode="solo": release | 65 | measure mode="solo": release |
| 62 | #!/usr/bin/env bash | 66 | #!/usr/bin/env bash |
| @@ -70,6 +74,10 @@ measure mode="solo": release | |||
| 70 | exec python3 bench/measure.py | 74 | exec python3 bench/measure.py |
| 71 | fi | 75 | fi |
| 72 | if ! command -v jolt >/dev/null; then | 76 | if ! command -v jolt >/dev/null; then |
| 77 | + if [ -n "${BENCH_JSON:-}" ]; then | ||
| 78 | + echo "jolt is required when storing benchmark results" >&2 | ||
| 79 | + exit 1 | ||
| 80 | + fi | ||
| 73 | echo "jolt is not installed; running clonim only" >&2 | 81 | echo "jolt is not installed; running clonim only" >&2 |
| 74 | exec python3 bench/measure.py | 82 | exec python3 bench/measure.py |
| 75 | fi | 83 | fi |