diff --git a/.github/workflows/build-benchmark.yml b/.github/workflows/build-benchmark.yml new file mode 100644 index 000000000..fb05a583d --- /dev/null +++ b/.github/workflows/build-benchmark.yml @@ -0,0 +1,42 @@ +name: Build benchmark + +on: + pull_request: + workflow_dispatch: + +jobs: + benchmark: + name: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + env: + PSModulePath: "" + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Configure git to trust repository + run: git config --global --add safe.directory /__w/bitbots_main/bitbots_main + + - name: Set up workspace + uses: prefix-dev/setup-pixi@v0.10.0 + with: + pixi-version: v0.72.0 + cache: true + cache-key: pixi-${{ hashFiles('pixi.toml', 'pixi.lock') }}- + + - name: Measure clean and incremental builds + run: >- + python3 scripts/benchmark_build.py + --output build-benchmark-${{ runner.arch }}.json + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v7 + with: + name: build-benchmark-${{ runner.arch }} + path: build-benchmark-${{ runner.arch }}.json + if-no-files-found: warn diff --git a/scripts/benchmark_build.py b/scripts/benchmark_build.py new file mode 100644 index 000000000..755f9923b --- /dev/null +++ b/scripts/benchmark_build.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +"""Measure clean and incremental workspace build times.""" + +import argparse +import json +import os +import subprocess +import time +from datetime import UTC, datetime +from pathlib import Path + +DEFAULT_SOURCE = Path("src/bitbots_motion/bitbots_head_mover/src/move_head.cpp") + + +def run_timed(command: list[str]) -> dict[str, float | int | list[str]]: + """Run a command and return its duration and exit status.""" + start = time.perf_counter() + result = subprocess.run(command, check=False) + return { + "command": command, + "duration_seconds": time.perf_counter() - start, + "return_code": result.returncode, + } + + +def append_github_summary(results: dict[str, object], summary_path: Path) -> None: + """Append the benchmark results to a GitHub Actions step summary.""" + clean = results["clean_build"] + incremental = results["incremental_build"] + assert isinstance(clean, dict) + assert isinstance(incremental, dict) + + with summary_path.open("a", encoding="utf-8") as summary: + summary.write("## Build benchmark\n\n") + summary.write("| Build | Duration | Exit code |\n") + summary.write("| --- | ---: | ---: |\n") + summary.write(f"| Clean | {clean['duration_seconds']:.2f} s | {clean['return_code']} |\n") + summary.write(f"| Incremental | {incremental['duration_seconds']:.2f} s | {incremental['return_code']} |\n\n") + summary.write(f"Touched source: `{results['touched_source']}`\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--environment", + default="default", + help="Pixi environment containing the workspace build task.", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("build-benchmark.json"), + help="JSON file that receives the benchmark results.", + ) + parser.add_argument( + "--source", + type=Path, + default=DEFAULT_SOURCE, + help="Existing C++ source to touch before the incremental build.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.source.is_file(): + raise FileNotFoundError(f"Incremental benchmark source does not exist: {args.source}") + + subprocess.run( + ["pixi", "run", "-e", args.environment, "clean"], + check=True, + ) + clean_build = run_timed(["pixi", "run", "-e", args.environment, "build"]) + + # Updating the timestamp makes the build system reconsider one translation unit. + # The file content stays unchanged, allowing compiler caches to reuse their output. + os.utime(args.source) + incremental_build = run_timed(["pixi", "run", "-e", args.environment, "build"]) + + results = { + "created_at": datetime.now(UTC).isoformat(), + "environment": args.environment, + "touched_source": str(args.source), + "clean_build": clean_build, + "incremental_build": incremental_build, + } + args.output.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8") + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + append_github_summary(results, Path(summary_path)) + + return max(clean_build["return_code"], incremental_build["return_code"]) + + +if __name__ == "__main__": + raise SystemExit(main())