diff --git a/.github/workflows/bench-budget.yml b/.github/workflows/bench-budget.yml new file mode 100644 index 00000000000..2fe01da5379 --- /dev/null +++ b/.github/workflows/bench-budget.yml @@ -0,0 +1,101 @@ +name: Benchmark Budget + +# Enforces the "keep per-iteration execution time under 1 ms" rule from +# docs/developer-guide/benchmarking.md, reported as a comment rather than a failing check. +# +# CodSpeed already measures per-iteration time and publishes it in its own sticky PR +# comment, so this workflow reads that comment instead of building or running anything. +# The trade is scope, not accuracy: CodSpeed only reports benchmarks a PR added or +# changed, so an untouched benchmark that is already over budget is not caught here. +# +# `issue_comment` workflows always run from the default branch, so edits to this file (or +# to the script) only take effect once merged. Use the `workflow_dispatch` entry point to +# try a change against a real PR's report before merging it. + +on: + issue_comment: + types: [created, edited] + workflow_dispatch: + inputs: + pr-number: + description: "Pull request to re-check against the budget" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || inputs.pr-number }} + cancel-in-progress: true + +jobs: + bench-budget: + name: "Check benchmark iteration budget" + # CodSpeed edits one sticky comment per PR, so this fires on every update to its + # report. Anyone can post a comment containing the marker; pinning the author is what + # makes the parsed table trustworthy. + if: >- + github.event_name == 'workflow_dispatch' + || (github.event.issue.pull_request != null + && github.event.comment.user.login == 'codspeed-hq[bot]' + && contains(github.event.comment.body, '__CODSPEED_PERFORMANCE_REPORT_COMMENT__')) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: read + pull-requests: write + env: + PR_NUMBER: ${{ github.event.issue.number || inputs.pr-number }} + steps: + # On `issue_comment` this checks out the default branch, never the pull request's + # head. That is deliberate: the job holds a `pull-requests: write` token, and must + # run this repository's script rather than a version a fork could edit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + # The comment body is untrusted input, so it reaches the script through the + # environment and never through shell interpolation. + - name: Read CodSpeed report from the triggering comment + if: github.event_name != 'workflow_dispatch' + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: printenv COMMENT_BODY > codspeed-comment.md + + - name: Fetch CodSpeed report for the requested pull request + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -Eeuo pipefail + gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --paginate \ + --jq '[.[] | select(.user.login == "codspeed-hq[bot]") | .body] | last' \ + > codspeed-comment.md + test -s codspeed-comment.md + + - name: Check against the budget + id: check + run: | + python3 scripts/check-bench-budget.py \ + --comment-file codspeed-comment.md \ + --output comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Report over-budget benchmarks + if: steps.check.outputs.violations != '0' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3 + with: + file-path: comment.md + comment-tag: bench-budget + pr-number: ${{ env.PR_NUMBER }} + + # Nothing is over budget: update an existing complaint to say so, but never open a + # new all-clear comment on every PR that touches a benchmark. + - name: Clear a resolved report + if: steps.check.outputs.violations == '0' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3 + with: + file-path: comment.md + comment-tag: bench-budget + pr-number: ${{ env.PR_NUMBER }} + create-if-not-exists: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86fa85fd4d6..a3a09b4ac48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,24 @@ jobs: uv run --no-project --with pytest --with xxhash \ pytest scripts/tests/test_measurement_id.py + # Covers scripts/check-bench-budget.py, which parses CodSpeed's PR comment. It runs from + # the `issue_comment` workflow where a parsing bug surfaces as a wrong comment on someone + # else's PR rather than as a red check, so the parser is tested here instead. + bench-budget-script: + name: "Benchmark budget script" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + # sync: false — pure-stdlib script, so skip the ~6 min vortex-data extension build. + - uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + with: + sync: false + - name: Pytest - benchmark budget check + run: | + uv run --no-project --with pytest \ + pytest scripts/tests/test_check_bench_budget.py + python-cuda-test: name: "Python CUDA (test)" if: github.repository == 'vortex-data/vortex' diff --git a/docs/developer-guide/benchmarking.md b/docs/developer-guide/benchmarking.md index 9a35437b764..f1839125cc3 100644 --- a/docs/developer-guide/benchmarking.md +++ b/docs/developer-guide/benchmarking.md @@ -132,7 +132,26 @@ gate it with `#[cfg(not(codspeed))]` if it genuinely cannot be made to fit. The number to check against the budget is the per-iteration time, not the time the whole benchmark binary takes. CodSpeed reports exactly that: its performance report on a pull request lists the per-iteration time under `HEAD` for every benchmark the pull request adds -or changes, so check any new benchmark there before merging. +or changes. + +CI reads that report on every pull request, and comments rather than failing the build. The +`bench-budget` job in `.github/workflows/bench-budget.yml` pulls the per-iteration times out +of CodSpeed's report and comments listing any benchmark over the budget. Nothing is rebuilt +or re-run: the numbers are the ones CodSpeed already published. + +Two limits follow from using that report as the source: + +- Only benchmarks CodSpeed reports as **new or changed** are checked, because those are the + only ones it lists. A benchmark you did not touch is not re-checked, so the budget is + enforced going forward rather than retroactively. +- CodSpeed truncates its table at 20 rows. When it does, the comment says so rather than + implying the rest were checked — open the full report in CodSpeed to see them. + +To check a report by hand, for example while iterating on the check itself: + +```bash +python3 scripts/check-bench-budget.py --comment-file codspeed-comment.md --output comment.md +``` ### Gate CodSpeed-incompatible benchmarks diff --git a/scripts/check-bench-budget.py b/scripts/check-bench-budget.py new file mode 100644 index 00000000000..7225d05734f --- /dev/null +++ b/scripts/check-bench-budget.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Flag benchmarks that exceed the per-iteration budget, using CodSpeed's own PR comment. + +``docs/developer-guide/benchmarking.md`` asks that a single benchmark iteration stay under +1 ms. CodSpeed already measures and publishes exactly that number: its sticky PR comment +lists every benchmark the pull request added or changed, with the per-iteration time under +``HEAD``. This script reads that comment and re-reports the rows that blow the budget, so +nobody has to eyeball a 20-row table of microsecond values to notice a 123 ms benchmark. + +Nothing is rebuilt and nothing is re-run: the input is a comment body, so the check costs +one API-free job and a few milliseconds. + +Two consequences of that trade fall out of the source data, and the rendered report says +both out loud: + +* Only benchmarks CodSpeed reports as new or changed appear in the comment. An untouched + benchmark that was already over budget is invisible here. +* CodSpeed truncates its table to the first 20 rows, so a pull request that adds many slow + benchmarks can hide some of them behind the truncation marker. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +DEFAULT_MAX_NS = 1_000_000 +"""One millisecond, per the benchmarking guide.""" + +CODSPEED_MARKER = "__CODSPEED_PERFORMANCE_REPORT_COMMENT__" +"""Hidden marker CodSpeed puts at the top of the comment it owns.""" + +GUIDE_URL = ( + "https://github.com/vortex-data/vortex/blob/develop/docs/developer-guide/benchmarking.md" + "#keep-per-iteration-execution-time-under-1-ms" +) + +MAX_ROWS = 20 +"""Cap the rendered table. CodSpeed itself never reports more rows than this.""" + +TRUNCATION_MARKER = "Only the first" +"""Substring of CodSpeed's own "only the first N benchmarks are displayed" note.""" + +UNITS_NS = {"ns": 1, "us": 1_000, "µs": 1_000, "μs": 1_000, "ms": 1_000_000, "s": 1_000_000_000} +"""Divan/CodSpeed duration suffixes. Both micro sign variants appear in the wild.""" + +STATUS_BY_EMOJI = { + "🆕": "new", + "⚡": "improved", + "❌": "regressed", + "⚠️": "regressed", +} + +FLAGGED_BY_DEFAULT = frozenset({"new", "regressed", "changed"}) +"""A benchmark this pull request made *faster* is not a reason to open a complaint.""" + +DURATION_RE = re.compile(r"^([0-9][0-9,]*(?:\.[0-9]+)?)\s*(ns|us|µs|μs|ms|s)$") +NAME_RE = re.compile(r"``\s*(.+?)\s*``", re.DOTALL) +SEPARATOR_RE = re.compile(r"^\|[\s\-:|]+\|$") + + +@dataclass(frozen=True) +class Benchmark: + """One row of CodSpeed's "Performance Changes" table.""" + + uri: str + """Fully qualified benchmark URI, e.g. ``vortex-geo/benches/x.rs::contains::points``.""" + + name: str + """Short display name, as CodSpeed renders it.""" + + mode: str + """CodSpeed instrument that produced the number, e.g. ``Simulation``.""" + + status: str + """One of ``new``, ``improved``, ``regressed``, or ``changed``.""" + + head_ns: float + """Per-iteration time on the pull request's head commit, in nanoseconds.""" + + +def parse_duration(text: str) -> float | None: + """Parse a CodSpeed duration such as ``1,182.5 µs`` into nanoseconds. + + Returns ``None`` for anything that is not a duration, including the ``N/A`` CodSpeed + prints for the base side of a new benchmark. + """ + match = DURATION_RE.match(text.strip()) + if match is None: + return None + return float(match.group(1).replace(",", "")) * UNITS_NS[match.group(2)] + + +def format_duration(nanos: float) -> str: + """Render a nanosecond count using the same units as CodSpeed's own output.""" + for limit, unit, scale in ( + (1_000, "ns", 1), + (1_000_000, "µs", 1_000), + (1_000_000_000, "ms", 1_000_000), + ): + if nanos < limit: + return f"{nanos / scale:.4g} {unit}" + return f"{nanos / 1_000_000_000:.4g} s" + + +def split_row(line: str) -> list[str]: + """Split a Markdown table row into its cells.""" + stripped = line.strip() + if not stripped.startswith("|"): + return [] + return [cell.strip() for cell in stripped.strip("|").split("|")] + + +def parse_benchmark_cell(cell: str) -> tuple[str, str]: + """Pull ``(uri, name)`` out of the linked benchmark cell. + + The link target carries a percent-encoded ``uri`` query parameter, which is the only + unambiguous identifier -- the visible label omits the file, so two benchmarks in + different crates can share it. Falls back to the label when the link is missing. + """ + name_match = NAME_RE.search(cell) + name = name_match.group(1) if name_match else cell + uri = "" + link_match = re.search(r"\]\((https?://[^)\s]+)\)", cell) + if link_match: + query = parse_qs(urlparse(link_match.group(1)).query) + if query.get("uri"): + uri = unquote(query["uri"][0]) + return uri or name, name + + +def parse_comment(body: str) -> tuple[list[Benchmark], bool]: + """Parse CodSpeed's comment into benchmarks plus whether its table was truncated. + + Rows CodSpeed emits that carry no measurement -- the header, the ``| --- |`` + separator, and the ``| ... |`` truncation row -- are skipped rather than guessed at. + """ + benchmarks = [] + for line in body.splitlines(): + cells = split_row(line) + # marker, mode, benchmark, base, head, efficiency + if len(cells) != 6 or SEPARATOR_RE.match(line.strip()): + continue + head_ns = parse_duration(cells[4]) + if head_ns is None: + continue + uri, name = parse_benchmark_cell(cells[2]) + status = next( + (status for emoji, status in STATUS_BY_EMOJI.items() if emoji in cells[0]), + "changed", + ) + benchmarks.append(Benchmark(uri=uri, name=name, mode=cells[1] or "unknown", status=status, head_ns=head_ns)) + + return benchmarks, TRUNCATION_MARKER in body + + +def over_budget(benchmarks: list[Benchmark], max_ns: int, include_improved: bool = False) -> list[Benchmark]: + """Select the benchmarks that exceed the budget, slowest first.""" + flagged = FLAGGED_BY_DEFAULT | ({"improved"} if include_improved else set()) + violations = [b for b in benchmarks if b.head_ns > max_ns and b.status in flagged] + return sorted(violations, key=lambda b: b.head_ns, reverse=True) + + +def render_report(violations: list[Benchmark], reported: int, max_ns: int, truncated: bool) -> str: + """Render the body of the sticky comment.""" + budget = format_duration(max_ns) + lines = ["## ⏱️ Benchmark iteration budget", ""] + + if not violations: + lines += [ + f"`✅ {reported}` benchmark(s) changed by this PR are within the **{budget}** per-iteration budget.", + ] + else: + lines += [ + f"`⚠️ {len(violations)}` of the `{reported}` benchmark(s) changed by this PR " + f"exceed the **{budget}** per-iteration budget.", + "", + "| Benchmark | Per-iteration | Over budget | |", + "| --- | --- | --- | --- |", + ] + for violation in violations[:MAX_ROWS]: + marker = "🆕" if violation.status == "new" else "❌" + lines.append( + f"| `{violation.uri}` | {format_duration(violation.head_ns)} " + f"| {violation.head_ns / max_ns:.1f}× | {marker} |" + ) + lines += [ + "", + "Each iteration of a benchmarked closure should finish in under " + f"{budget}. CodSpeed's simulation instrument runs each benchmark exactly once, " + "so a slow iteration spends CI time without buying extra signal. Shrink the " + "input size, or gate the benchmark with `#[cfg(not(codspeed))]`. See " + f"[the benchmarking guide]({GUIDE_URL}).", + ] + + lines += [ + "", + "
How this is measured", + "", + "These numbers are CodSpeed's own, read straight from its performance report on " + "this PR -- nothing is rebuilt or re-run here.", + "", + "That means only benchmarks CodSpeed reports as **new or changed** are checked. A " + "benchmark this PR did not touch is not listed in its report, so an existing " + "benchmark that is already over budget will not show up here.", + ] + if truncated: + lines += [ + "", + "> ⚠️ _CodSpeed truncated its own table, so this PR may change more benchmarks " + "than were checked. [Open the full report in CodSpeed]" + "(https://app.codspeed.io/vortex-data/vortex) to see the rest._", + ] + lines += ["", "
"] + return "\n".join(lines) + "\n" + + +def read_comment(args: argparse.Namespace) -> str: + if args.comment_file is not None: + return args.comment_file.read_text() + body = os.environ.get(args.comment_env) + if body is None: + raise SystemExit(f"environment variable {args.comment_env} is not set") + return body + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group() + source.add_argument("--comment-file", type=Path, help="file holding the CodSpeed comment body") + source.add_argument( + "--comment-env", + default="CODSPEED_COMMENT_BODY", + help="environment variable holding the CodSpeed comment body", + ) + parser.add_argument( + "--max-ns", + type=int, + default=DEFAULT_MAX_NS, + help=f"per-iteration budget in nanoseconds (default: {DEFAULT_MAX_NS})", + ) + parser.add_argument( + "--include-improved", + action="store_true", + help="also flag over-budget benchmarks that this PR made faster", + ) + parser.add_argument("--output", type=Path, required=True, help="path to write the comment to") + parser.add_argument( + "--github-output", + type=Path, + default=os.environ.get("GITHUB_OUTPUT"), + help="path to append the `violations` step output to", + ) + parser.add_argument( + "--fail-on-violation", + action="store_true", + help="exit non-zero when a benchmark is over budget (default: report only)", + ) + args = parser.parse_args() + + body = read_comment(args) + if CODSPEED_MARKER not in body: + print(f"comment does not contain {CODSPEED_MARKER}; refusing to parse", file=sys.stderr) + return 1 + + benchmarks, truncated = parse_comment(body) + violations = over_budget(benchmarks, args.max_ns, args.include_improved) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_report(violations, len(benchmarks), args.max_ns, truncated)) + + if args.github_output is not None: + with Path(args.github_output).open("a") as f: + f.write(f"violations={len(violations)}\n") + + print( + f"{len(benchmarks)} benchmark(s) reported by CodSpeed, " + f"{len(violations)} over the {format_duration(args.max_ns)} budget" + ) + for violation in violations: + print(f" {format_duration(violation.head_ns):>10} {violation.uri}") + + return 1 if violations and args.fail_on_violation else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_bench_budget.py b/scripts/tests/test_check_bench_budget.py new file mode 100644 index 00000000000..28dc1f59be9 --- /dev/null +++ b/scripts/tests/test_check_bench_budget.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Tests for `scripts/check-bench-budget.py`. + +The fixtures below are trimmed copies of real CodSpeed comments on this repository, so the +parser is tested against the markup CodSpeed actually posts rather than an idealised +version of it. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_SPEC = importlib.util.spec_from_file_location( + "check_bench_budget", Path(__file__).parent.parent / "check-bench-budget.py" +) +assert _SPEC is not None and _SPEC.loader is not None +budget = importlib.util.module_from_spec(_SPEC) +sys.modules["check_bench_budget"] = budget +_SPEC.loader.exec_module(budget) + + +def _row(marker: str, name: str, uri: str, base: str, head: str, efficiency: str) -> str: + link = f"https://app.codspeed.io/vortex-data/vortex/branches/x?uri={uri}&runnerMode=Simulation" + return f"| {marker} | Simulation | [`` {name} ``]({link}) | {base} | {head} | {efficiency} |" + + +NEW_BENCHMARKS_COMMENT = "\n".join( + [ + "", + "## Merging this PR will **not alter performance**", + "", + "`✅ 1885` untouched benchmarks ", + "`🆕 75` new benchmarks ", + "", + "### Performance Changes", + "", + "| | Mode | Benchmark | `BASE` | `HEAD` | Efficiency |", + "| --- | ---- | --------- | ------ | ------ | ---------- |", + _row( + "🆕", + "inline[4096]", + "vortex-array%2Fbenches%2Fbyte_length.rs%3A%3Ainline%5B4096%5D", + "N/A", + "61.8 µs", + "N/A", + ), + _row( + "🆕", + "like_per_row_distinct_patterns", + "vortex-array%2Fbenches%2Flike.rs%3A%3Alike_per_row_distinct_patterns", + "N/A", + "1.1 ms", + "N/A", + ), + _row( + "🆕", + "column_x_column_polygons", + "vortex-geo%2Fbenches%2Fbinary_predicates.rs%3A%3Acontains%3A%3Acolumn_x_column_polygons", + "N/A", + "23.8 ms", + "N/A", + ), + _row( + "🆕", + "constant_x_polygons_overlapping", + "vortex-geo%2Fbenches%2Fbinary_predicates.rs%3A%3Acontains%3A%3Aconstant_x_polygons_overlapping", + "N/A", + "123.4 ms", + "N/A", + ), + "| ... | ... | ... | ... | ... | ... |", + "", + "> :information_source: _Only the first 20 benchmarks are displayed._", + "", + "Comparing a (9755708) with develop (c2288dc)", + ] +) + +IMPROVED_COMMENT = "\n".join( + [ + "", + "## Merging this PR will **improve performance by 27.76%**", + "", + "`⚡ 2` improved benchmarks ", + "`✅ 1840` untouched benchmarks ", + "", + "### Performance Changes", + "", + "| | Mode | Benchmark | `BASE` | `HEAD` | Efficiency |", + "| --- | ---- | --------- | ------ | ------ | ---------- |", + _row( + "⚡", + "take_map[(0.1, 1.0)]", + "vortex-array%2Fbenches%2Ftake_patches.rs%3A%3Atake_map", + "2.2 ms", + "1.6 ms", + "+34.9%", + ), + _row( + "⚡", + "take_map[(0.1, 0.5)]", + "vortex-array%2Fbenches%2Ftake_patches.rs%3A%3Atake_map2", + "1,182.5 µs", + "977.3 µs", + "+20.99%", + ), + ] +) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("61.8 µs", 61_800.0), + ("61.8 μs", 61_800.0), # micro sign vs greek mu + ("61.8 us", 61_800.0), + ("1.1 ms", 1_100_000.0), + ("1,182.5 µs", 1_182_500.0), + ("794 ns", 794.0), + ("1.5 s", 1_500_000_000.0), + ("N/A", None), + ("", None), + ("...", None), + ("+34.9%", None), + ], +) +def test_parse_duration(text: str, expected: float | None) -> None: + assert budget.parse_duration(text) == expected + + +@pytest.mark.parametrize( + ("nanos", "expected"), + [(794, "794 ns"), (61_800, "61.8 µs"), (23_800_000, "23.8 ms"), (1_500_000_000, "1.5 s")], +) +def test_format_duration(nanos: float, expected: str) -> None: + assert budget.format_duration(nanos) == expected + + +def test_parse_comment_reads_new_benchmarks() -> None: + benchmarks, truncated = budget.parse_comment(NEW_BENCHMARKS_COMMENT) + + assert truncated + assert [b.name for b in benchmarks] == [ + "inline[4096]", + "like_per_row_distinct_patterns", + "column_x_column_polygons", + "constant_x_polygons_overlapping", + ] + assert {b.status for b in benchmarks} == {"new"} + assert benchmarks[0].mode == "Simulation" + # The URI is percent-decoded, and identifies the file the benchmark lives in. + assert benchmarks[2].uri == ("vortex-geo/benches/binary_predicates.rs::contains::column_x_column_polygons") + assert benchmarks[3].head_ns == 123_400_000 + + +def test_parse_comment_skips_header_separator_and_truncation_rows() -> None: + benchmarks, _ = budget.parse_comment(NEW_BENCHMARKS_COMMENT) + assert len(benchmarks) == NEW_BENCHMARKS_COMMENT.count("app.codspeed.io") + + +def test_parse_comment_without_a_table() -> None: + body = "\nNo performance changes.\n" + assert budget.parse_comment(body) == ([], False) + + +def test_over_budget_flags_only_slow_benchmarks() -> None: + benchmarks, _ = budget.parse_comment(NEW_BENCHMARKS_COMMENT) + violations = budget.over_budget(benchmarks, budget.DEFAULT_MAX_NS) + + # Sorted slowest first; the 61.8 µs benchmark is comfortably inside the budget. + assert [b.name for b in violations] == [ + "constant_x_polygons_overlapping", + "column_x_column_polygons", + "like_per_row_distinct_patterns", + ] + + +def test_over_budget_ignores_improvements_by_default() -> None: + benchmarks, _ = budget.parse_comment(IMPROVED_COMMENT) + + assert budget.over_budget(benchmarks, budget.DEFAULT_MAX_NS) == [] + # Only the 1.6 ms row is over budget; its 977.3 µs sibling is inside it either way. + included = budget.over_budget(benchmarks, budget.DEFAULT_MAX_NS, include_improved=True) + assert [b.name for b in included] == ["take_map[(0.1, 1.0)]"] + + +def test_over_budget_honours_a_custom_budget() -> None: + benchmarks, _ = budget.parse_comment(NEW_BENCHMARKS_COMMENT) + violations = budget.over_budget(benchmarks, 50_000_000) + assert [b.name for b in violations] == ["constant_x_polygons_overlapping"] + + +def test_render_report_lists_violations() -> None: + benchmarks, truncated = budget.parse_comment(NEW_BENCHMARKS_COMMENT) + violations = budget.over_budget(benchmarks, budget.DEFAULT_MAX_NS) + report = budget.render_report(violations, len(benchmarks), budget.DEFAULT_MAX_NS, truncated) + + assert "`⚠️ 3` of the `4` benchmark(s)" in report + assert "123.4 ms" in report + assert "123.4×" in report + assert "vortex-geo/benches/binary_predicates.rs::contains::constant_x_polygons_overlapping" in report + assert "#[cfg(not(codspeed))]" in report + assert "CodSpeed truncated its own table" in report + # A benchmark inside the budget is never named. + assert "inline[4096]" not in report + + +def test_render_report_when_everything_is_within_budget() -> None: + benchmarks, truncated = budget.parse_comment(IMPROVED_COMMENT) + report = budget.render_report([], len(benchmarks), budget.DEFAULT_MAX_NS, truncated) + + assert "`✅ 2` benchmark(s)" in report + assert "| Benchmark |" not in report + assert "CodSpeed truncated its own table" not in report + + +def test_main_writes_comment_and_step_output(tmp_path: Path) -> None: + comment = tmp_path / "codspeed.md" + comment.write_text(NEW_BENCHMARKS_COMMENT) + output, step_output = tmp_path / "comment.md", tmp_path / "github_output" + + argv = [ + "check-bench-budget.py", + "--comment-file", + str(comment), + "--output", + str(output), + "--github-output", + str(step_output), + ] + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sys, "argv", argv) + assert budget.main() == 0 + + assert "123.4 ms" in output.read_text() + assert step_output.read_text() == "violations=3\n" + + +def test_main_reads_the_comment_from_the_environment(tmp_path: Path) -> None: + output = tmp_path / "comment.md" + argv = ["check-bench-budget.py", "--output", str(output), "--github-output", str(tmp_path / "o")] + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sys, "argv", argv) + patch.setenv("CODSPEED_COMMENT_BODY", IMPROVED_COMMENT) + assert budget.main() == 0 + + assert "within the **1 ms** per-iteration budget" in output.read_text() + + +def test_main_rejects_a_comment_that_is_not_codspeeds(tmp_path: Path) -> None: + output = tmp_path / "comment.md" + argv = ["check-bench-budget.py", "--output", str(output), "--github-output", str(tmp_path / "o")] + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sys, "argv", argv) + patch.setenv("CODSPEED_COMMENT_BODY", "This benchmark has a too long runtime") + assert budget.main() == 1 + + assert not output.exists() + + +def test_main_can_fail_the_job(tmp_path: Path) -> None: + output = tmp_path / "comment.md" + argv = [ + "check-bench-budget.py", + "--output", + str(output), + "--github-output", + str(tmp_path / "o"), + "--fail-on-violation", + ] + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sys, "argv", argv) + patch.setenv("CODSPEED_COMMENT_BODY", NEW_BENCHMARKS_COMMENT) + assert budget.main() == 1