Hosted runners vary. These charts are informational. Binding ratios compare each wrapper +with Rust on the same host: above 1x means slower or more CPU per delivered byte.
+Each series keeps its source and payload shape. A failed suite contributes no point. +Metrics and local reproduction
+ +''') + (directory / '.nojekyll').touch() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + for command in ('rust', 'js', 'prepare'): + sub = commands.add_parser(command) + sub.add_argument('directory', type=Path) + sub.add_argument('output', type=Path) + sub = commands.add_parser('status') + sub.add_argument('status', choices=('success', 'failure', 'cancelled', 'skipped')) + sub.add_argument('directory', type=Path) + commands.add_parser('index').add_argument('directory', type=Path) + args = parser.parse_args() + if args.command == 'rust': + write(args.output, rust(args.directory)) + elif args.command == 'js': + write(args.output, javascript(args.directory)) + elif args.command == 'prepare': + prepare(args.directory, args.output) + elif args.command == 'status': + args.directory.mkdir(parents=True, exist_ok=True) + (args.directory / 'outcome.json').write_text(json.dumps({'status': args.status}) + '\n') + else: + index(args.directory) + + +if __name__ == '__main__': + main() diff --git a/sh/gh/bench-report.test.py b/sh/gh/bench-report.test.py new file mode 100644 index 00000000000..ea5da4426c7 --- /dev/null +++ b/sh/gh/bench-report.test.py @@ -0,0 +1,139 @@ +"""Nightly charts accept measurements, refuse malformed data, and share one history branch.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('report', Path(__file__).with_name('bench-report.py')) +report = importlib.util.module_from_spec(spec) +spec.loader.exec_module(report) + + +class ReportTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def test_values_refuse_smoke_and_malformed_reports(self): + for entries in ([], {}, [None], [{'name': '', 'unit': 'ns', 'value': 1}]): + with self.assertRaises(ValueError): + report.checked(entries) + for number in (float('nan'), float('inf'), -1, None, True, '2'): + with self.assertRaises(ValueError): + report.value(number) + for entries in ([{'name': 'x', 'unit': 'unknown', 'value': 1}], + [{'name': 'x', 'unit': 'ns', 'value': 1}] * 2): + with self.assertRaises(ValueError): + report.checked(entries) + + def test_js_multiple_schemas_and_interests(self): + source = self.root / 'requests.csv' + source.write_text('bun install v1.3\npaths,active_us,settled_us\n4,1.25,2.5\noperation,ns_per_op\nclose,0\n') + entries = report.javascript_file(source) + self.assertEqual([entry['value'] for entry in entries], [1.25, 2.5, 0]) + self.assertEqual(entries[0]['name'], 'js/requests/paths=4/active_us') + source.write_text('broadcasts=32 tracks=8 acquire+release=1.250 us\n') + self.assertEqual(report.javascript_file(source)[0]['value'], 1.25) + for text in ('Testing case\nSuccess\n', 'paths,time_us\n4\n', 'paths,time_us\n4,nan\n'): + source.write_text(text) + with self.assertRaises(ValueError): + report.javascript_file(source) + + def test_criterion_requires_fresh_measured_estimates(self): + run = self.root / 'runs' / 'one' + run.mkdir(parents=True) + (self.root / 'criterion-runs.txt').write_text(f'{run}\n') + (run / 'metadata.json').write_text(json.dumps({'mode': '--criterion', 'revision': 'abc', + 'environment': {'MOQ_BENCH_LABEL': 'net-cpu', 'SESSION_CPU': '1'}})) + with self.assertRaises(ValueError): + report.rust(self.root) + case = run / 'criterion' / 'delivery' / 'new' + case.mkdir(parents=True) + (case / 'benchmark.json').write_text(json.dumps({'full_id': 'delivery/4/8'})) + (case / 'estimates.json').write_text(json.dumps({'mean': {'point_estimate': 20, + 'confidence_interval': {'lower_bound': 18, 'upper_bound': 22}}})) + entry = report.rust(self.root)[0] + self.assertEqual(entry['name'], 'rust/net-cpu/delivery/4/8') + self.assertEqual(entry['value'], 20) + self.assertIn('abc', entry['extra']) + + def test_only_successful_sources_are_published(self): + source = self.root / 'bench-ffi' + source.mkdir() + entries = [{'name': 'go/round', 'unit': 'MB/s', 'value': 4}, + {'name': 'go/round vs rust-ffi', 'unit': 'x', 'value': 2}] + (source / 'report.json').write_text(json.dumps(entries)) + (source / 'outcome.json').write_text('{"status":"success"}') + # Another nightly artifact with the same prefix and its own report schema. + unrelated = self.root / 'bench-browser' + unrelated.mkdir() + (unrelated / 'report.json').write_text('{"samples": []}') + failed = self.root / 'bench-rust' + failed.mkdir() + (failed / 'outcome.json').write_text('{"status":"failure"}') + (failed / 'report.json').write_text('not a usable report') + output = self.root / 'output' + report.prepare(self.root, output) + self.assertEqual(json.loads((output / 'rates.json').read_text())[0]['value'], 4) + self.assertEqual(json.loads((output / 'costs.json').read_text())[0]['value'], 2) + (source / 'report.json').unlink() + with self.assertRaises(ValueError): + report.prepare(self.root, output) + + def test_landing_page_keeps_existing_charts_and_history(self): + chart = self.root / 'bench' / 'costs' + chart.mkdir(parents=True) + (chart / 'index.html').write_text('chart') + (chart / 'data.js').write_text('history') + report.index(self.root) + self.assertIn('costs/', (chart.parent / 'index.html').read_text()) + self.assertEqual((chart / 'data.js').read_text(), 'history') + self.assertTrue((self.root / '.nojekyll').exists()) + + def test_first_and_subsequent_history_publications(self): + remote, checkout = self.root / 'remote.git', self.root / 'checkout' + def git(*args, cwd=None): + return subprocess.run(['git', *args], cwd=cwd, check=True, capture_output=True, text=True) + git('init', '--bare', str(remote)) + git('clone', str(remote), str(checkout)) + git('config', 'user.name', 'test', cwd=checkout) + git('config', 'user.email', 'test@example.com', cwd=checkout) + (checkout / 'source').write_text('source tree') + git('add', '.', cwd=checkout) + git('commit', '-m', 'Source', cwd=checkout) + commands = self.root / 'commands' + commands.mkdir() + just = commands / 'just' + just.write_text(f'#!/bin/sh\nexec python3 {ROOT}/sh/gh/bench-report.py "$3" "$4"\n') + just.chmod(0o755) + env = dict(os.environ, PATH=f'{commands}:{os.environ["PATH"]}') + publisher = ROOT / 'sh/gh/bench-publish.sh' + for iteration in (1, 2): + subprocess.run(['bash', str(publisher), 'init'], cwd=checkout, env=env, check=True, + capture_output=True) + history = self.root / f'history-{iteration}' + git('worktree', 'add', str(history), 'gh-pages', cwd=checkout) + chart = history / 'bench' / 'costs' + chart.mkdir(parents=True, exist_ok=True) + (chart / 'index.html').write_text('chart') + (chart / 'data.js').write_text(f'run {iteration}') + git('add', '.', cwd=history) + git('commit', '-m', 'Measured result', cwd=history) + git('worktree', 'remove', str(history), cwd=checkout) + site = self.root / f'site-{iteration}' + subprocess.run(['bash', str(publisher), 'finish', str(site)], cwd=checkout, env=env, + check=True, capture_output=True) + self.assertFalse((site / 'source').exists()) + self.assertEqual((site / 'bench/costs/data.js').read_text(), f'run {iteration}') + git('worktree', 'remove', str(site), cwd=checkout) + git('branch', '-D', 'gh-pages', cwd=checkout) + self.assertEqual(git('--git-dir', str(remote), 'show', 'gh-pages:bench/costs/data.js').stdout, 'run 2') + + +if __name__ == '__main__': + unittest.main() diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index 7be7804d203..151a45ad001 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -7,6 +7,25 @@ if [[ $# != 1 ]]; then echo "$usage" >&2 exit 2 fi +# The measured groups write a report for the benchmark site; rust-test only runs. +case "$1" in + rust-test) report= ;; + rust-bench) report=rust ;; + js) report=js ;; + *) + echo "$usage" >&2 + exit 2 + ;; +esac + +if [[ -n $report ]]; then + root=$(git rev-parse --show-toplevel) + out=${MOQ_BENCH_RESULTS:-$root/target/bench/nightly/$1} + mkdir -p "$out" + export MOQ_BENCH_RUNS="$out/criterion-runs.txt" + : >"$MOQ_BENCH_RUNS" + rm -f "$out/report.json" +fi failed=() run() { @@ -14,7 +33,21 @@ run() { if ! "$@"; then failed+=("$*"); fi } -case "${1:-}" in +criterion() { + local label=$1 + shift + run env MOQ_BENCH_LABEL="$label" MOQ_BENCH_ARTIFACTS="$out/runs" just gh criterion "$@" +} + +javascript() { + local label=$1 + shift + mkdir -p "$out/raw" + echo "nightly: $*" + if ! "$@" | tee "$out/raw/$label.csv"; then failed+=("$*"); fi +} + +case "$1" in # Ignored benchmark tests under nextest: the release builds first, then the test-profile ones. rust-test) run just rs bench-latency @@ -34,38 +67,34 @@ case "${1:-}" in --run-ignored only --success-output immediate run just rs test -p moq-cli -E 'test(benchmark_stream_import)' --run-ignored only --success-output immediate ;; - # Cargo bench targets, all in the bench profile. + # Measured Cargo bench targets, all in the bench profile. rust-bench) # Fuzz exposes moq-net's codec benches; it is not a feature of the other crates. - run cargo bench --locked --no-fail-fast -p moq-net --features fuzz --bench '*' -- --test - # Exercise the optional thread-CPU clock as well as the normal elapsed-time smoke. - run env SESSION_CPU=1 cargo bench --locked -p moq-net --bench session -- session_delivery_group_cost_cpu --test - # Each kio routine once, including the contended bench's worker threads. - run cargo bench --locked --no-fail-fast -p kio --bench '*' -- --test + criterion net --no-fail-fast -p moq-net --features fuzz --bench '*' + # Measure the optional thread-CPU clock as well as the normal elapsed time. + SESSION_CPU=1 criterion net-cpu -p moq-net --bench session -- session_delivery_group_cost_cpu + # Measure each kio routine, including the contended bench's worker threads. + criterion kio --no-fail-fast -p kio --bench '*' # One invocation unifies this release build instead of rebuilding shared crates # independently for each target. Keep the explicit targets: '*' would add coverage. - run cargo bench --locked --no-fail-fast -p moq-relay -p moq-stats -p moq-mux -p moq-rtmp -p moq-uring -p moq-pattern \ + criterion shared --no-fail-fast -p moq-relay -p moq-stats -p moq-mux -p moq-rtmp -p moq-uring -p moq-pattern \ -p moq-quic --features moq-stats/bench \ --bench http_idle --bench auth --bench producer_tick --bench decode --bench aggregate \ --bench catalog --bench ts_schedule --bench export --bench containers --bench matching \ --bench chunk_interleaving --bench worker_turns \ - --bench streams --bench receive --bench endpoint --bench wire -- --test + --bench streams --bench receive --bench endpoint --bench wire # moq-gst's catalog bench shares a target name with moq-mux's, so it runs on its own. - run just rs bench-gst-catalog -- --quick + criterion gst -p moq-gst --bench catalog ;; js) for benchmark in broadcasts request-ids requests forward reader frames varint lite-varint track interests priority; do - run just js bench "$benchmark" + javascript "$benchmark" just js bench "$benchmark" done - run just js catalog-bench - run just js arrivals-bench - run just js p2p-bench - run just js e2ee-bench - run just js signals-bench - ;; - *) - echo "$usage" >&2 - exit 2 + javascript catalog just js catalog-bench + javascript arrivals just js arrivals-bench + javascript p2p just js p2p-bench + javascript e2ee just js e2ee-bench + javascript signals just js signals-bench ;; esac @@ -73,3 +102,7 @@ if ((${#failed[@]})); then printf 'nightly: failed: %s\n' "${failed[@]}" >&2 exit 1 fi + +if [[ -n $report ]]; then + just gh benchmark-report "$report" "$out" "$out/report.json" +fi diff --git a/sh/gh/nightly.test.py b/sh/gh/nightly.test.py index eef6e450997..1d5df2cfbe8 100644 --- a/sh/gh/nightly.test.py +++ b/sh/gh/nightly.test.py @@ -24,7 +24,7 @@ def run_group(self, group, fail=""): ) stub.chmod(0o755) env = dict(os.environ, PATH=f"{scratch}:{os.environ['PATH']}", - NIGHTLY_CALLS=str(calls), NIGHTLY_FAIL=fail, BASH_ENV="/dev/null") + MOQ_BENCH_RESULTS=str(scratch / "results"), NIGHTLY_CALLS=str(calls), NIGHTLY_FAIL=fail, BASH_ENV="/dev/null") result = subprocess.run(["bash", "sh/gh/nightly.sh", group], cwd=ROOT, env=env, capture_output=True, text=True) return result, calls.read_text().splitlines() if calls.exists() else [] @@ -39,9 +39,9 @@ def test_failed_js_benchmark_does_not_hide_later_work(self): def test_bench_targets_share_one_invocation(self): result, calls = self.run_group("rust-bench") self.assertEqual(result.returncode, 0, result.stderr) - release = [call for call in calls if call.startswith("cargo bench")] - self.assertEqual(len(release), 4) # moq-net, its CPU clock, kio, then the shared build. - self.assertTrue(any("--bench session -- session_delivery_group_cost_cpu --test" in call for call in release)) + release = [call for call in calls if call.startswith("just gh criterion")] + self.assertEqual(len(release), 5) # moq-net, its CPU clock, kio, the shared build, then GStreamer. + self.assertTrue(any("--bench session -- session_delivery_group_cost_cpu" in call for call in release)) shared = [call for call in release if "-p moq-relay" in call] self.assertEqual(len(shared), 1) self.assertIn("--no-fail-fast", shared[0]) @@ -53,12 +53,13 @@ def test_bench_targets_share_one_invocation(self): self.assertIn("--bench containers", shared[0]) self.assertIn("-p moq-quic", shared[0]) self.assertIn("--bench wire", shared[0]) - self.assertEqual(calls[-1], "just rs bench-gst-catalog -- --quick") + self.assertEqual(release[-1], "just gh criterion -p moq-gst --bench catalog") + self.assertTrue(calls[-1].startswith("just gh benchmark-report rust ")) def test_benchmark_tests_stay_out_of_cargo_bench(self): result, calls = self.run_group("rust-test") self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(any(call.startswith("cargo bench") for call in calls)) + self.assertFalse(any(call.startswith(("cargo bench", "just gh")) for call in calls)) # Release builds run before the test-profile ones. profiles = ["--release" in call or " bench-" in call for call in calls] self.assertEqual(profiles, sorted(profiles, reverse=True)) @@ -67,11 +68,12 @@ def test_benchmark_tests_stay_out_of_cargo_bench(self): def test_failed_release_build_does_not_hide_later_work(self): _, calls = self.run_group("rust-bench") - build = next(call.removeprefix("cargo ") for call in calls - if call.startswith("cargo bench") and "-p moq-relay" in call) + build = next(call.removeprefix("just ") for call in calls + if call.startswith("just gh criterion") and "-p moq-relay" in call) result, calls = self.run_group("rust-bench", build) self.assertEqual(result.returncode, 1) - self.assertEqual(calls[-1], "just rs bench-gst-catalog -- --quick") + # A failed group publishes no report, but every later target still runs. + self.assertEqual(calls[-1], "just gh criterion -p moq-gst --bench catalog") def test_unknown_group_fails_before_running_anything(self): result, calls = self.run_group("unknown")