Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acc78a943b | |||
| 6f0365ae18 | |||
| f51869fabb | |||
| 27e53670a5 | |||
| ea39a746fd | |||
| 65d5506b5e | |||
| b60c8fcbbb | |||
| 0343d08de9 | |||
| e3603f4722 | |||
| 0da425fcf6 | |||
| 9d3f9bb354 |
@@ -0,0 +1,177 @@
|
||||
name: Benchmark (PR)
|
||||
|
||||
# Runs a lightweight SQLite benchmark when a PR touches migration files or
|
||||
# store-layer code and compares against the latest nightly benchmark artifact
|
||||
# as a baseline. Posts results as a PR comment and blocks the PR if a
|
||||
# regression is detected.
|
||||
#
|
||||
# Only runs on PRs to the main repo (not forks without secrets). Skips
|
||||
# comparison if no nightly baseline artifact is available — the benchmark still
|
||||
# runs and reports results, it just won't block.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "omnigent/db/migrations/**"
|
||||
- "omnigent/stores/**"
|
||||
- ".github/workflows/benchmark-pr.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
|
||||
concurrency:
|
||||
group: benchmark-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
benchmark-pr:
|
||||
name: Benchmark regression check (sqlite)
|
||||
if: github.repository == 'omnigent-ai/omnigent' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra databricks
|
||||
|
||||
# Use the same corpus size as the nightly so baseline numbers are
|
||||
# directly comparable. Cache the seeded DB on the schema head + seed
|
||||
# script hash to avoid re-seeding on every push (same contract as
|
||||
# benchmark.yml).
|
||||
- name: Resolve seed cache key
|
||||
id: seedkey
|
||||
run: |
|
||||
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
|
||||
echo "key=benchdb-sqlite-${HEAD}-5000x200-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore seeded SQLite corpus
|
||||
id: seedcache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: bench.db
|
||||
key: ${{ steps.seedkey.outputs.key }}
|
||||
|
||||
- name: Seed SQLite corpus
|
||||
if: steps.seedcache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--sessions 5000 --items-per-session 200
|
||||
|
||||
- name: Run benchmark (candidate)
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--iterations 100 \
|
||||
--runs 3 \
|
||||
--output candidate.json
|
||||
|
||||
- name: Download latest nightly baseline (sqlite)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
RUN_ID=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/benchmark.yml/runs?status=success&branch=main&per_page=10" \
|
||||
--jq '.workflow_runs[0].id // empty')
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "No successful nightly benchmark run found — skipping comparison."
|
||||
echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
ARTIFACT_ID=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("benchmark-results-sqlite-")) | .id' \
|
||||
| head -1)
|
||||
if [ -z "$ARTIFACT_ID" ]; then
|
||||
echo "No sqlite artifact found on run ${RUN_ID} — skipping comparison."
|
||||
echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \
|
||||
> baseline.zip && \
|
||||
unzip -q baseline.zip -d baseline_dir && \
|
||||
mv baseline_dir/*.json baseline.json && \
|
||||
echo "BASELINE_FOUND=true" >> "$GITHUB_ENV" || \
|
||||
(echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"; echo "Artifact download failed — skipping comparison.")
|
||||
|
||||
- name: Compare baseline vs candidate
|
||||
if: env.BASELINE_FOUND == 'true'
|
||||
id: compare
|
||||
run: |
|
||||
set +e
|
||||
uv run --no-sync dev/benchmarks/omnigent/compare.py \
|
||||
--baseline baseline.json \
|
||||
--candidate candidate.json \
|
||||
--backend sqlite \
|
||||
--threshold 1.0 \
|
||||
--output-markdown comparison.md
|
||||
echo "EXIT_CODE=$?" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PR comment body
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
{
|
||||
echo "<!-- benchmark-pr-comment -->"
|
||||
echo "## Benchmark results (SQLite, PR #${{ github.event.pull_request.number }})"
|
||||
echo ""
|
||||
echo "Commit: \`${{ github.event.pull_request.head.sha }}\`"
|
||||
echo ""
|
||||
if [ "$BASELINE_FOUND" = "true" ]; then
|
||||
cat comparison.md
|
||||
else
|
||||
echo "No nightly baseline artifact found — comparison skipped."
|
||||
echo ""
|
||||
echo "Candidate results recorded in \`candidate.json\` artifact."
|
||||
fi
|
||||
} > comment_body.md
|
||||
|
||||
- name: Post PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "${{ github.event.pull_request.number }}" \
|
||||
--edit-last \
|
||||
--body-file comment_body.md || \
|
||||
gh pr comment "${{ github.event.pull_request.number }}" \
|
||||
--body-file comment_body.md
|
||||
|
||||
- name: Upload candidate results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-sqlite-pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
|
||||
path: candidate.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Fail on regression
|
||||
if: env.BASELINE_FOUND == 'true' && env.EXIT_CODE == '1'
|
||||
run: |
|
||||
echo "Benchmark regression detected. See the PR comment for details."
|
||||
exit 1
|
||||
@@ -42,6 +42,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
skip_benchmark:
|
||||
description: "Skip the pre-cut benchmark regression check (escape hatch — use deliberately)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Nothing here writes with GITHUB_TOKEN; pushes use the App token.
|
||||
permissions:
|
||||
@@ -225,9 +230,171 @@ jobs:
|
||||
echo "| Mode | $([ "$DRY_RUN" = "true" ] && echo "DRY RUN — nothing pushed" || echo "EXECUTE") |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Run benchmark on the release commit vs the previous stable release tag —
|
||||
# same runner, back-to-back, so machine variance cancels out. A detected
|
||||
# regression surfaces as an output flag that gates the benchmark-approve job
|
||||
# (requiring manual sign-off) rather than failing outright. Skipped on dry
|
||||
# runs, already-converged runs, and when skip_benchmark=true.
|
||||
benchmark:
|
||||
needs: [authorize, plan]
|
||||
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !inputs.skip_benchmark }}
|
||||
outputs:
|
||||
regression: ${{ steps.compare.outputs.regression }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
steps:
|
||||
- name: Check out release base
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
|
||||
# Full history so we can re-checkout the previous release tag.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
# Seed once with the same corpus size as the nightly (5000×200) so the
|
||||
# candidate and baseline numbers are on an equal footing and directly
|
||||
# comparable to the nightly trend dashboard. The DB is reused for both
|
||||
# candidate and baseline runs — both check out different code but share
|
||||
# the same pre-populated SQLite file, keeping conditions identical.
|
||||
# Cache key mirrors benchmark.yml: schema head + seed script hash.
|
||||
- name: Resolve seed cache key
|
||||
id: seedkey
|
||||
run: |
|
||||
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
|
||||
echo "key=benchdb-sqlite-${HEAD}-5000x200-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore seeded SQLite corpus
|
||||
id: seedcache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: bench.db
|
||||
key: ${{ steps.seedkey.outputs.key }}
|
||||
|
||||
- name: Seed SQLite corpus
|
||||
if: steps.seedcache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--sessions 5000 --items-per-session 200
|
||||
|
||||
- name: Benchmark candidate (release base)
|
||||
run: |
|
||||
uv sync --extra dev
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--iterations 100 --runs 3 --output candidate.json
|
||||
echo "Candidate benchmark complete." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Find previous stable release tag
|
||||
id: prev
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREV_TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=20" \
|
||||
--jq '[.[] | select(.prerelease == false and .draft == false)] | .[0].tag_name // empty')
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous stable release found — skipping regression check." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Previous stable release: ${PREV_TAG}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Benchmark previous release (${{ steps.prev.outputs.tag }})
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: |
|
||||
git checkout "${{ steps.prev.outputs.tag }}"
|
||||
uv sync --extra dev
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--iterations 100 --runs 3 --output baseline.json
|
||||
# Return to release base so compare.py is available.
|
||||
git checkout -
|
||||
|
||||
- name: Compare results
|
||||
id: compare
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: |
|
||||
set +e
|
||||
uv run --no-sync dev/benchmarks/omnigent/compare.py \
|
||||
--baseline baseline.json \
|
||||
--candidate candidate.json \
|
||||
--threshold 1.0 \
|
||||
--output-markdown comparison.md
|
||||
RC=$?
|
||||
set -e
|
||||
# Expose regression flag as an output so the approval job can gate on it.
|
||||
echo "regression=$([ $RC -ne 0 ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write step summary
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: |
|
||||
REGRESSION="${{ steps.compare.outputs.regression }}"
|
||||
{
|
||||
if [ "$REGRESSION" != "true" ]; then
|
||||
echo "### Benchmark: PASS ✓"
|
||||
else
|
||||
echo "### Benchmark: REGRESSION DETECTED ✗"
|
||||
echo ""
|
||||
echo "> A regression exceeding the threshold was found."
|
||||
echo "> The **benchmark-approve** job is awaiting maintainer sign-off before cut proceeds."
|
||||
fi
|
||||
echo ""
|
||||
cat comparison.md 2>/dev/null || echo "_No comparison report generated._"
|
||||
echo ""
|
||||
echo "_Candidate vs ${{ steps.prev.outputs.tag }} · 100 iterations × 3 runs · SQLite · threshold 100% on P50/P95_"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload benchmark artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-release-${{ github.run_id }}
|
||||
path: |
|
||||
candidate.json
|
||||
baseline.json
|
||||
retention-days: 90
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Pauses for a maintainer to review and approve in the GitHub UI when the
|
||||
# benchmark job detected a regression. Uses an environment with required
|
||||
# reviewers — configure "benchmark-regression-gate" in repo Settings →
|
||||
# Environments. Skipped (passes through) when there is no regression.
|
||||
benchmark-approve:
|
||||
needs: [authorize, plan, benchmark]
|
||||
if: |
|
||||
!inputs.dry_run &&
|
||||
needs.plan.outputs.already_done != 'true' &&
|
||||
!inputs.skip_benchmark &&
|
||||
needs.benchmark.outputs.regression == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
environment: benchmark-regression-gate
|
||||
steps:
|
||||
- name: Regression approved by maintainer
|
||||
run: |
|
||||
echo "Benchmark regression approved. Proceeding with cut." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Stamp + tag + push. Only reached on a real run that isn't already converged.
|
||||
cut:
|
||||
needs: [authorize, plan]
|
||||
needs: [authorize, plan, benchmark, benchmark-approve]
|
||||
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two benchmark JSON reports for performance regressions.
|
||||
|
||||
Usage:
|
||||
uv run --no-sync dev/benchmarks/omnigent/compare.py \\
|
||||
--baseline nightly.json --candidate pr.json [--threshold 0.20] \\
|
||||
[--output-markdown report.md] [--backend sqlite]
|
||||
|
||||
Exits 0 if no regression, 1 if regression detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _fmt_ms(v: float | None) -> str:
|
||||
return f"{v:.1f}" if v is not None else "—"
|
||||
|
||||
|
||||
def _fmt_delta(v: float | None) -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
sign = "+" if v >= 0 else ""
|
||||
return f"{sign}{v * 100:.1f}%"
|
||||
|
||||
|
||||
def compare_reports(
|
||||
baseline: dict,
|
||||
candidate: dict,
|
||||
threshold: float,
|
||||
backend: str | None = None,
|
||||
) -> tuple[bool, list[dict]]:
|
||||
"""Compare journeys between two reports.
|
||||
|
||||
:param baseline: Parsed baseline JSON report.
|
||||
:param candidate: Parsed candidate JSON report.
|
||||
:param threshold: Regression threshold as a fraction (e.g. 0.20 = 20%).
|
||||
:param backend: If set, only compare journeys whose ``backend`` key matches.
|
||||
:returns: ``(passed, rows)`` where *rows* hold per-journey comparison data.
|
||||
"""
|
||||
baseline_journeys = baseline.get("journeys", {})
|
||||
candidate_journeys = candidate.get("journeys", {})
|
||||
rows: list[dict] = []
|
||||
passed = True
|
||||
|
||||
for name, c_data in candidate_journeys.items():
|
||||
if backend is not None and c_data.get("backend") != backend:
|
||||
continue
|
||||
|
||||
c_summary = c_data.get("summary", {})
|
||||
c_p50 = c_summary.get("avg_p50_ms")
|
||||
c_p95 = c_summary.get("avg_p95_ms")
|
||||
|
||||
if name not in baseline_journeys:
|
||||
rows.append(
|
||||
{
|
||||
"journey": name,
|
||||
"status": "new",
|
||||
"b_p50": None,
|
||||
"c_p50": c_p50,
|
||||
"b_p95": None,
|
||||
"c_p95": c_p95,
|
||||
"delta_p50": None,
|
||||
"delta_p95": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
b_data = baseline_journeys[name]
|
||||
if backend is not None and b_data.get("backend") != backend:
|
||||
# Baseline journey exists but for a different backend — treat as new.
|
||||
rows.append(
|
||||
{
|
||||
"journey": name,
|
||||
"status": "new",
|
||||
"b_p50": None,
|
||||
"c_p50": c_p50,
|
||||
"b_p95": None,
|
||||
"c_p95": c_p95,
|
||||
"delta_p50": None,
|
||||
"delta_p95": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
b_summary = b_data.get("summary", {})
|
||||
b_p50 = b_summary.get("avg_p50_ms", 0.0)
|
||||
b_p95 = b_summary.get("avg_p95_ms", 0.0)
|
||||
|
||||
c_p50 = c_p50 or 0.0
|
||||
c_p95 = c_p95 or 0.0
|
||||
delta_p50 = (c_p50 - b_p50) / b_p50 if b_p50 > 0 else 0.0
|
||||
delta_p95 = (c_p95 - b_p95) / b_p95 if b_p95 > 0 else 0.0
|
||||
|
||||
regression = delta_p50 > threshold or delta_p95 > threshold
|
||||
if regression:
|
||||
passed = False
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"journey": name,
|
||||
"status": "regression" if regression else "ok",
|
||||
"b_p50": b_p50,
|
||||
"c_p50": c_p50,
|
||||
"delta_p50": delta_p50,
|
||||
"b_p95": b_p95,
|
||||
"c_p95": c_p95,
|
||||
"delta_p95": delta_p95,
|
||||
}
|
||||
)
|
||||
|
||||
return passed, rows
|
||||
|
||||
|
||||
def _status_style(status: str) -> str:
|
||||
return {"regression": "red", "new": "cyan", "ok": "green"}.get(status, "")
|
||||
|
||||
|
||||
def print_table(rows: list[dict], threshold: float) -> None:
|
||||
"""Render the comparison rows as a rich table."""
|
||||
table = Table(
|
||||
title=f"Benchmark comparison (regression threshold: {threshold * 100:.0f}%)",
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
box=None,
|
||||
padding=(0, 2),
|
||||
title_justify="left",
|
||||
)
|
||||
table.add_column("Journey", no_wrap=True)
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Base P50 ms", justify="right")
|
||||
table.add_column("Cand P50 ms", justify="right")
|
||||
table.add_column("Δ P50", justify="right")
|
||||
table.add_column("Base P95 ms", justify="right")
|
||||
table.add_column("Cand P95 ms", justify="right")
|
||||
table.add_column("Δ P95", justify="right")
|
||||
|
||||
for row in rows:
|
||||
style = _status_style(row["status"])
|
||||
delta_p50_str = _fmt_delta(row["delta_p50"])
|
||||
delta_p95_str = _fmt_delta(row["delta_p95"])
|
||||
|
||||
if row["status"] == "regression":
|
||||
if row["delta_p50"] is not None and row["delta_p50"] > threshold:
|
||||
delta_p50_str = f"[red]{delta_p50_str}[/red]"
|
||||
if row["delta_p95"] is not None and row["delta_p95"] > threshold:
|
||||
delta_p95_str = f"[red]{delta_p95_str}[/red]"
|
||||
|
||||
table.add_row(
|
||||
row["journey"],
|
||||
f"[{style}]{row['status']}[/{style}]" if style else row["status"],
|
||||
_fmt_ms(row["b_p50"]),
|
||||
_fmt_ms(row["c_p50"]),
|
||||
delta_p50_str,
|
||||
_fmt_ms(row["b_p95"]),
|
||||
_fmt_ms(row["c_p95"]),
|
||||
delta_p95_str,
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def build_markdown(rows: list[dict], threshold: float, passed: bool) -> str:
|
||||
"""Render the comparison rows as a GitHub-flavoured markdown table."""
|
||||
lines = [
|
||||
"## Benchmark comparison",
|
||||
"",
|
||||
f"Regression threshold: **{threshold * 100:.0f}%** on avg P50 or avg P95.",
|
||||
"",
|
||||
"| Journey | Status | Base P50 ms | Cand P50 ms | Δ P50"
|
||||
" | Base P95 ms | Cand P95 ms | Δ P95 |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
|
||||
for row in rows:
|
||||
status = row["status"]
|
||||
emoji = {"regression": "🔴", "new": "🆕", "ok": "✅"}.get(status, status)
|
||||
b_p50 = _fmt_ms(row["b_p50"])
|
||||
c_p50 = _fmt_ms(row["c_p50"])
|
||||
d_p50 = _fmt_delta(row["delta_p50"])
|
||||
b_p95 = _fmt_ms(row["b_p95"])
|
||||
c_p95 = _fmt_ms(row["c_p95"])
|
||||
d_p95 = _fmt_delta(row["delta_p95"])
|
||||
lines.append(
|
||||
f"| {row['journey']} | {emoji} {status} "
|
||||
f"| {b_p50} | {c_p50} | {d_p50} "
|
||||
f"| {b_p95} | {c_p95} | {d_p95} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
verdict = (
|
||||
"**PASS** — no regressions detected." if passed else "**FAIL** — regression(s) detected."
|
||||
)
|
||||
lines.append(verdict)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare benchmark JSON reports for performance regressions."
|
||||
)
|
||||
parser.add_argument("--baseline", required=True, type=Path, help="Baseline JSON report")
|
||||
parser.add_argument("--candidate", required=True, type=Path, help="Candidate JSON report")
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Regression threshold as a fraction (default 1.0 = 100%%, checks P50 and P95)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-markdown",
|
||||
type=Path,
|
||||
metavar="FILE",
|
||||
help="Write markdown comparison table to FILE",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
help="Filter to journeys for this backend only (e.g. sqlite, postgres)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
baseline = json.loads(args.baseline.read_text())
|
||||
candidate = json.loads(args.candidate.read_text())
|
||||
|
||||
console.print(
|
||||
f"[bold]Baseline:[/bold] {args.baseline} (git: {baseline.get('git_sha', 'unknown')[:12]})"
|
||||
)
|
||||
sha = candidate.get("git_sha", "unknown")[:12]
|
||||
console.print(f"[bold]Candidate:[/bold] {args.candidate} (git: {sha})")
|
||||
if args.backend:
|
||||
console.print(f"[bold]Backend filter:[/bold] {args.backend}")
|
||||
|
||||
passed, rows = compare_reports(baseline, candidate, args.threshold, backend=args.backend)
|
||||
|
||||
if not rows:
|
||||
console.print("[yellow]No journeys found to compare.[/yellow]")
|
||||
return 0
|
||||
|
||||
print_table(rows, args.threshold)
|
||||
|
||||
regressions = [r for r in rows if r["status"] == "regression"]
|
||||
new_journeys = [r for r in rows if r["status"] == "new"]
|
||||
|
||||
if new_journeys:
|
||||
names = ", ".join(r["journey"] for r in new_journeys)
|
||||
console.print(f"[cyan]New journeys (no baseline):[/cyan] {names}")
|
||||
|
||||
if regressions:
|
||||
console.print(
|
||||
f"[red bold]REGRESSION DETECTED[/red bold] in "
|
||||
f"{len(regressions)} journey(s): "
|
||||
f"{', '.join(r['journey'] for r in regressions)}"
|
||||
)
|
||||
else:
|
||||
console.print("[green bold]PASS[/green bold] — no regressions detected.")
|
||||
|
||||
if args.output_markdown:
|
||||
md = build_markdown(rows, args.threshold, passed)
|
||||
args.output_markdown.write_text(md)
|
||||
console.print(f"Markdown report written to {args.output_markdown}")
|
||||
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user