mirror of
https://github.com/zizmorcore/zizmor.git
synced 2025-12-23 08:47:33 +00:00
feat: CLI benchmarking harness (#1038)
This commit is contained in:
parent
14961ac826
commit
349cbcdd26
6 changed files with 380 additions and 0 deletions
62
.github/workflows/benchmark-base.yml
vendored
Normal file
62
.github/workflows/benchmark-base.yml
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# benchmark-base.yml: submit benchmarks to Bencher.
|
||||||
|
#
|
||||||
|
# This workflow provides baseline results, via the main branch.
|
||||||
|
|
||||||
|
name: Benchmark baseline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
benchmark_base_branch:
|
||||||
|
name: Continuous Benchmarking with Bencher
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: bencher
|
||||||
|
url: https://bencher.dev/console/projects/zizmor
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Bencher
|
||||||
|
uses: bencherdev/bencher@f89d454e74a32a81b2eab29fe0afdb2316617342 # v0.5.3
|
||||||
|
|
||||||
|
- name: Installer hyperfine
|
||||||
|
run: |
|
||||||
|
sudo apt-get remove --purge man-db
|
||||||
|
sudo apt install -y hyperfine
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6.4.1
|
||||||
|
|
||||||
|
- name: Run benchmarks
|
||||||
|
run: make bench
|
||||||
|
|
||||||
|
- name: Upload benchmark results
|
||||||
|
# Take each result file in bench/results/*.json and use
|
||||||
|
# `bencher run` to upload it.
|
||||||
|
run: |
|
||||||
|
for file in bench/results/*.json; do
|
||||||
|
bencher run \
|
||||||
|
--project zizmor \
|
||||||
|
--token "${BENCHER_API_TOKEN}" \
|
||||||
|
--branch main \
|
||||||
|
--testbed ubuntu-latest \
|
||||||
|
--err \
|
||||||
|
--adapter shell_hyperfine \
|
||||||
|
--github-actions "${GITHUB_TOKEN}" \
|
||||||
|
--file "${file}"
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
69
.github/workflows/benchmark-pr-1p.yml
vendored
Normal file
69
.github/workflows/benchmark-pr-1p.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# benchmark-pr-1p.yml: submit benchmarks to Bencher.
|
||||||
|
#
|
||||||
|
# This workflow covers "first party" pull requests specifically,
|
||||||
|
# i.e. those created from branches within the same repository.
|
||||||
|
|
||||||
|
name: Benchmark PRs (first-party)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, edited, synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
benchmark-pr-1p:
|
||||||
|
name: Continuous Benchmarking PRs with Bencher
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: bencher
|
||||||
|
url: https://bencher.dev/console/projects/zizmor
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Bencher
|
||||||
|
uses: bencherdev/bencher@f89d454e74a32a81b2eab29fe0afdb2316617342 # v0.5.3
|
||||||
|
|
||||||
|
- name: Installer hyperfine
|
||||||
|
run: |
|
||||||
|
sudo apt-get remove --purge man-db
|
||||||
|
sudo apt install -y hyperfine
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@7edac99f961f18b581bbd960d59d049f04c0002f # v6.4.1
|
||||||
|
|
||||||
|
- name: Run benchmarks
|
||||||
|
run: make bench
|
||||||
|
|
||||||
|
- name: Upload benchmark results
|
||||||
|
# Take each result file in bench/results/*.json and use
|
||||||
|
# `bencher run` to upload it.
|
||||||
|
run: |
|
||||||
|
for file in bench/results/*.json; do
|
||||||
|
bencher run \
|
||||||
|
--project zizmor \
|
||||||
|
--token "${BENCHER_API_TOKEN}" \
|
||||||
|
--branch "${GITHUB_HEAD_REF}" \
|
||||||
|
--start-point "${GITHUB_BASE_REF}" \
|
||||||
|
--start-point-hash "${PULL_REQUEST_BASE_SHA}" \
|
||||||
|
--start-point-clone-thresholds \
|
||||||
|
--start-point-reset \
|
||||||
|
--testbed ubuntu-latest \
|
||||||
|
--err \
|
||||||
|
--adapter shell_hyperfine \
|
||||||
|
--github-actions "${GITHUB_TOKEN}" \
|
||||||
|
--file "${file}"
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PULL_REQUEST_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
5
Makefile
5
Makefile
|
|
@ -44,3 +44,8 @@ crates/zizmor/data/codeql-injection-sinks.json: support/codeql-injection-sinks.p
|
||||||
.PHONY: pinact
|
.PHONY: pinact
|
||||||
pinact:
|
pinact:
|
||||||
pinact run --update --verify
|
pinact run --update --verify
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: bench
|
||||||
|
bench:
|
||||||
|
uv run bench/benchmark.py
|
||||||
|
|
|
||||||
1
bench/.gitignore
vendored
Normal file
1
bench/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
results/
|
||||||
227
bench/benchmark.py
Normal file
227
bench/benchmark.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, NoReturn, TypedDict
|
||||||
|
|
||||||
|
_DEPS = ["hyperfine", "curl", "unzip"]
|
||||||
|
|
||||||
|
_HERE = Path(__file__).parent
|
||||||
|
_PROJECT_ROOT = _HERE.parent
|
||||||
|
_ZIZMOR = _PROJECT_ROOT / "target" / "release" / "zizmor"
|
||||||
|
|
||||||
|
assert (_PROJECT_ROOT / "Cargo.toml").is_file(), "Missing project root?"
|
||||||
|
|
||||||
|
_BENCHMARKS = _HERE / "benchmarks.json"
|
||||||
|
_RESULTS = _HERE / "results"
|
||||||
|
|
||||||
|
assert _BENCHMARKS.is_file(), f"Benchmarks file not found: {_BENCHMARKS}"
|
||||||
|
_RESULTS.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
_CACHE_DIR = Path(tempfile.gettempdir()) / "zizmor-benchmark-cache"
|
||||||
|
_CACHE_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Log:
|
||||||
|
def __init__(self, scope: str | None) -> None:
|
||||||
|
self.scopes = [scope] if scope else []
|
||||||
|
|
||||||
|
def info(self, message: str) -> None:
|
||||||
|
scopes = " ".join(f"[{s}]" for s in self.scopes)
|
||||||
|
print(f"[+] {scopes} {message}", file=sys.stderr)
|
||||||
|
|
||||||
|
def warn(self, message: str) -> None:
|
||||||
|
scopes = " ".join(f"[{s}]" for s in self.scopes)
|
||||||
|
print(f"[!] {scopes} {message}", file=sys.stderr)
|
||||||
|
|
||||||
|
def error(self, message: str) -> NoReturn:
|
||||||
|
self.warn(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def scope(self, new_scope: str) -> Iterator[None]:
|
||||||
|
"""Create a new logging scope."""
|
||||||
|
self.scopes.append(new_scope)
|
||||||
|
try:
|
||||||
|
yield None
|
||||||
|
finally:
|
||||||
|
self.scopes.pop()
|
||||||
|
|
||||||
|
|
||||||
|
LOG = Log("benchmarks")
|
||||||
|
|
||||||
|
|
||||||
|
def _curl(url: str, expected_sha256: str) -> Path:
|
||||||
|
"""Download a URL and cache it using content addressing with SHA256."""
|
||||||
|
cached_file = _CACHE_DIR / expected_sha256
|
||||||
|
if cached_file.exists():
|
||||||
|
LOG.info("Using cached file")
|
||||||
|
return cached_file
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-fsSL", url],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = result.stdout
|
||||||
|
content_hash = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
if content_hash != expected_sha256:
|
||||||
|
LOG.error(f"Hash mismatch: {expected_sha256} != {content_hash}")
|
||||||
|
|
||||||
|
cached_file.write_bytes(content)
|
||||||
|
|
||||||
|
return cached_file
|
||||||
|
|
||||||
|
|
||||||
|
def _unzip(archive_path: Path, extract_name: str) -> Path:
|
||||||
|
"""Extract an archive to a directory in the cache."""
|
||||||
|
extract_dir = _CACHE_DIR / extract_name
|
||||||
|
|
||||||
|
if extract_dir.exists():
|
||||||
|
LOG.info("Using cached extraction")
|
||||||
|
return extract_dir
|
||||||
|
|
||||||
|
extract_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["unzip", "-q", str(archive_path), "-d", str(extract_dir)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.info(f"Extracted {archive_path.name} to {extract_dir}")
|
||||||
|
return extract_dir
|
||||||
|
|
||||||
|
|
||||||
|
class Benchmark(TypedDict):
|
||||||
|
name: str
|
||||||
|
source_type: str
|
||||||
|
source: str
|
||||||
|
source_sha256: str
|
||||||
|
stencil: str
|
||||||
|
|
||||||
|
|
||||||
|
Plan = list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Bench:
|
||||||
|
def __init__(self, benchmark: Benchmark) -> None:
|
||||||
|
self.benchmark = benchmark
|
||||||
|
|
||||||
|
def plan(self) -> Plan:
|
||||||
|
match self.benchmark["source_type"]:
|
||||||
|
case "archive-url":
|
||||||
|
url = self.benchmark["source"]
|
||||||
|
sha256 = self.benchmark["source_sha256"]
|
||||||
|
archive = _curl(url, sha256)
|
||||||
|
inputs = [str(_unzip(archive, self.benchmark["name"]))]
|
||||||
|
case _:
|
||||||
|
LOG.error(f"Unknown source type: {self.benchmark['source_type']}")
|
||||||
|
|
||||||
|
stencil = self.benchmark["stencil"]
|
||||||
|
command = stencil.replace("$ZIZMOR", str(_ZIZMOR)).replace(
|
||||||
|
"$INPUTS", " ".join(inputs)
|
||||||
|
)
|
||||||
|
return shlex.split(command)
|
||||||
|
|
||||||
|
def run(self, plan: Plan, *, dry_run: bool) -> None:
|
||||||
|
command = shlex.join(plan)
|
||||||
|
|
||||||
|
result_file = _RESULTS / f"{self.benchmark['name']}.json"
|
||||||
|
if result_file.exists() and not dry_run:
|
||||||
|
LOG.warn("clobbering existing result file")
|
||||||
|
|
||||||
|
hyperfine_command = [
|
||||||
|
"hyperfine",
|
||||||
|
"--warmup",
|
||||||
|
"3",
|
||||||
|
# NOTE: not needed because we use --no-exit-codes in the stencil
|
||||||
|
# "--ignore-failure",
|
||||||
|
"--export-json",
|
||||||
|
str(result_file),
|
||||||
|
command,
|
||||||
|
]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
LOG.warn(f"would have run: {shlex.join(hyperfine_command)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
hyperfine_command,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
LOG.error("run failed, see above for details")
|
||||||
|
|
||||||
|
# Stupid hack: fixup each result file's results[0].command
|
||||||
|
# to be a more useful benchmark identifier, since bencher
|
||||||
|
# apparently keys on these.
|
||||||
|
result_json = json.loads(result_file.read_bytes())
|
||||||
|
result_json["results"][0]["command"] = f"zizmor::{self.benchmark['name']}"
|
||||||
|
result_file.write_text(json.dumps(result_json))
|
||||||
|
|
||||||
|
LOG.info(f"run written to {result_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="Show plans without running them"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for dep in _DEPS:
|
||||||
|
if not shutil.which(dep):
|
||||||
|
missing.append(dep)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
LOG.error(
|
||||||
|
f"Missing dependencies: {', '.join(missing)}. "
|
||||||
|
"Please install them before running benchmarks."
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.info("ensuring we have a benchable zizmor build")
|
||||||
|
subprocess.run(
|
||||||
|
["cargo", "build", "--release", "-p", "zizmor"],
|
||||||
|
check=True,
|
||||||
|
cwd=_PROJECT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _ZIZMOR.is_file():
|
||||||
|
LOG.error("zizmor build presumably failed, see above for details")
|
||||||
|
|
||||||
|
LOG.info(f"using cache dir: {_CACHE_DIR}")
|
||||||
|
|
||||||
|
benchmarks: list[Benchmark] = json.loads(_BENCHMARKS.read_text(encoding="utf-8"))
|
||||||
|
LOG.info(f"found {len(benchmarks)} benchmarks in {_BENCHMARKS.name}")
|
||||||
|
|
||||||
|
benches = [Bench(benchmark) for benchmark in benchmarks]
|
||||||
|
plans = []
|
||||||
|
with LOG.scope("plan"):
|
||||||
|
for bench in benches:
|
||||||
|
with LOG.scope(bench.benchmark["name"]):
|
||||||
|
LOG.info("beginning plan")
|
||||||
|
plans.append(bench.plan())
|
||||||
|
|
||||||
|
with LOG.scope("run"):
|
||||||
|
for bench, plan in zip(benches, plans):
|
||||||
|
with LOG.scope(bench.benchmark["name"]):
|
||||||
|
bench.run(plan, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
bench/benchmarks.json
Normal file
16
bench/benchmarks.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "grafana-9f212d11d0ac",
|
||||||
|
"source_type": "archive-url",
|
||||||
|
"source": "https://github.com/grafana/grafana/archive/9f212d11d0ac9c38ada62a7db830844bb9b02905.zip",
|
||||||
|
"source_sha256": "c6d42b52c8d912db2698d8b06f227de46f0c2d04cc757841792ed6567f0c56c7",
|
||||||
|
"stencil": "$ZIZMOR --offline --format=plain --no-exit-codes --no-config $INPUTS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cpython-48f88310044c",
|
||||||
|
"source_type": "archive-url",
|
||||||
|
"source": "https://github.com/python/cpython/archive/48f88310044c6ef877f3b0761cf7afece2f8fb3a.zip",
|
||||||
|
"source_sha256": "a52a67f1dd9cfa67c7d1305d5b9639629abe247b2c32f01b77f790ddf8b49503",
|
||||||
|
"stencil": "$ZIZMOR --offline --format=plain --no-exit-codes --no-config $INPUTS"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue