Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions .github/workflows/full-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ name: Full suite
# - pull requests labeled `release` (when the label is added, then on
# every push while it stays). A PR from a release/** branch of this repo
# is already covered by the branch push, so its PR run is skipped.
# - manual workflow_dispatch on main only
# - manual workflow_dispatch on main only, or with perf_gate_only on any
# ref, which runs the performance gate alone (to measure a branch)
# - v* tag pushes, through the Release workflow (publish.yml), which calls
# this workflow and publishes only if it passes
# docker.yml uses the release triggers without manual dispatch.
Expand All @@ -18,6 +19,15 @@ on:
branches: [main]
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
perf_gate_only:
description: Run only the performance gate (allowed on any ref)
type: boolean
default: false
perf_gate_aa:
description: Measure the baseline against itself (the runner's noise check)
type: boolean
default: false
workflow_call:

permissions:
Expand All @@ -37,7 +47,7 @@ jobs:
gate:
name: Release commit
if: >-
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') &&
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' || inputs.perf_gate_only) &&
(github.event_name != 'pull_request' ||
(contains(github.event.pull_request.labels.*.name, 'release') &&
(github.event.action != 'labeled' || github.event.label.name == 'release') &&
Expand All @@ -50,6 +60,7 @@ jobs:
test:
name: Tests (full)
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -96,6 +107,7 @@ jobs:
docs:
name: Documentation
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
Expand All @@ -120,6 +132,7 @@ jobs:
website:
name: Website
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -149,6 +162,7 @@ jobs:
coverage:
name: Coverage
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
Expand Down Expand Up @@ -188,6 +202,7 @@ jobs:
python-langchain-live:
name: Python langchain integration (live server)
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -239,6 +254,7 @@ jobs:
js-sdk-live:
name: JS SDK query examples (live server)
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -275,11 +291,11 @@ jobs:
if-no-files-found: ignore
retention-days: 7

# Lint and unit-test the performance gate tool only: measuring needs a
# quiet same-host runner, not a shared one (perf-gate/README.md).
# Lint and unit-test the performance gate tool.
perf-gate-tool:
name: Performance gate tool
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
Expand All @@ -292,12 +308,73 @@ jobs:
- name: Clippy and unit tests
run: make perf-gate-check

# The performance gate itself: the approved baseline against this commit,
# both built in release mode and measured interleaved on this one runner,
# so the runner's speed cancels out of the paired ratios. FAIL or
# INVALID fails the checkpoint; INCONCLUSIVE is retried once with more
# rounds, then reported as a warning (scripts/perf-gate-ci.sh,
# perf-gate/README.md).
perf-gate:
name: Performance gate
needs: gate
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
with:
# The baseline commit is built from git archive.
fetch-depth: 0

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Toolchain key
id: toolchain
run: echo "key=$(rustc -V | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT"

# Baseline servers are cached per commit and toolchain, as the gate
# caches them locally; the candidate is always built fresh.
- name: Cache baseline server
uses: useblacksmith/cache@v5
with:
path: target/perf-gate/servers
key: perf-gate-baseline-${{ hashFiles('perf-gate/baselines/approved.toml') }}-${{ steps.toolchain.outputs.key }}

- name: Pin the CPU frequency governor
run: |
shopt -s nullglob
govs=(/sys/devices/system/cpu/cpu*/cpufreq/scaling_governor)
if [ ${#govs[@]} -gt 0 ]; then
echo performance | sudo tee "${govs[@]}" > /dev/null || true
fi
if [ -e /sys/devices/system/cpu/cpufreq/boost ]; then
echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost > /dev/null || true
fi
nproc; lscpu | grep -E 'Model name|^CPU\(s\)|MHz' || true
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo "governor: not exposed (virtual machine)"

- name: Run the performance gate
run: scripts/perf-gate-ci.sh ${{ inputs.perf_gate_aa && '--aa' || '' }}

- name: Upload the samples and reports
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-gate-runs
path: |
target/perf-gate/runs/*/run.json
target/perf-gate/runs/*/report.md
target/perf-gate/runs/*/verdict.json
if-no-files-found: ignore
retention-days: 30

# Verified Completions false-alarm/revision gate (issue #88): corpus
# scenarios, controls included, and recorded reference extractions through
# the rule pack on a throwaway engine. No model calls.
verified-completions:
name: Verified Completions gate
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -333,6 +410,7 @@ jobs:
msrv:
name: Minimum Supported Rust Version
needs: gate
if: ${{ !inputs.perf_gate_only }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ CI runs in two tiers so small PRs stay cheap and expensive checks run at release

**Fast gate** (`.github/workflows/ci.yml`) runs only on PRs targeting `main`: format (`make fmt-check`), clippy (`make lint`, which also type-checks every target) and the unit + integration tests (`make unit-test`). The Rust jobs are skipped entirely when a change touches no Rust inputs, as defined by the [workflow's path filter](.github/workflows/ci.yml) (for example `front/`-only or SDK-only PRs). A newer push to a PR cancels its in-progress run.

**Full suite** (`.github/workflows/full-suite.yml`) runs on release commits or a manual run on `main`: the release build with all tests, snapshot specs and the reactive agent path (`make e2e-reactive`), Python and JS SDK tests, the Python live-server integration, coverage, MSRV, `make doc-check`, the website build, the performance gate tool's own checks (`make perf-gate-check`; measuring needs a quiet same-host runner, so `make perf-gate` itself runs locally through `make pre-pr`) and the Verified Completions gate (`make vc-gate`). Docker image builds (`docker.yml`) run on release commits only.
**Full suite** (`.github/workflows/full-suite.yml`) runs on release commits or a manual run on `main`: the release build with all tests, snapshot specs and the reactive agent path (`make e2e-reactive`), Python and JS SDK tests, the Python live-server integration, coverage, MSRV, `make doc-check`, the website build, the performance gate tool's own checks (`make perf-gate-check`), the performance gate itself (approved baseline vs the checkpoint commit on one dedicated 32-vCPU runner, FAIL fails the checkpoint; INCONCLUSIVE is retried once with more rounds, then reported as a warning; see [perf-gate/README.md](perf-gate/README.md#at-release-checkpoints-ci)) and the Verified Completions gate (`make vc-gate`). Docker image builds (`docker.yml`) run on release commits only.

The triggers are:

Expand All @@ -379,7 +379,7 @@ The triggers are:
| PR targeting `main`, labeled `release` | Full suite and Docker build when the label is added, then again on every push while it stays. On PR updates, the fast gate still runs format and clippy when its path filter matches, and leaves the tests to the full suite. Removing the label restores fast-gate tests on the next push, unless the branch is covered by the row below. |
| Push to a `release/**` branch (`make release VERSION=x.y.z`) | Full suite and Docker build. A PR from that branch in this repository is covered by the branch push, so it does not run the suite or Docker build a second time. |
| Push of a `v*` tag | The Release workflow runs the full suite once, including SDK tests and the website build. Only after it passes do the crates.io, PyPI and npm publishing jobs and website deployment run. Deployment reuses the website artifact from the full suite. Docker builds and pushes images in its own workflow. |
| Manual run (Actions tab, or `gh workflow run full-suite.yml --ref main`) | Full suite on `main` only; dispatches on other refs skip all jobs. No Docker build or publishing. Publishing workflows cannot be dispatched manually. |
| Manual run (Actions tab, or `gh workflow run full-suite.yml --ref main`) | Full suite on `main` only; dispatches on other refs skip all jobs. With `-f perf_gate_only=true`, only the performance gate runs, on any ref. No Docker build or publishing. Publishing workflows cannot be dispatched manually. |

Nothing runs on ordinary pushes to `main`; merging does not repeat the PR fast gate.

Expand Down
36 changes: 34 additions & 2 deletions perf-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,40 @@ Results are only as good as the host. Use a host with no other heavy work
running: the report records the load average at the start and end of a run,
and an A/A run (`--aa`) shows whether noise fits the budgets. If you can, pin
the servers with `--server-cpus` (`taskset -c`) and run the gate itself on
other CPUs. A shared CI runner is too noisy to give a verdict, so CI only
lints and tests the tool (`make perf-gate-check`).
other CPUs. Per-PR CI does not measure: a shared runner is too noisy to
give a verdict, so the fast gate only lints and tests the tool
(`make perf-gate-check`).

## At release checkpoints (CI)

The full suite (`.github/workflows/full-suite.yml`, job `Performance gate`)
runs the gate on every release checkpoint, on a dedicated 32-vCPU runner
with nothing else on it. Both arms run on that one runner, so its speed
cancels out of the paired ratios. `scripts/perf-gate-ci.sh` drives it:

- The baseline and the checkout are built with `--release --all-features`.
Baseline servers are cached per commit and toolchain.
- The servers are pinned to CPUs 2 and up, leaving 0-1 to the gate's
clients. The job sets the `performance` frequency governor and turns
boost off where the VM exposes them; the report records the governor.
- It runs 20 rounds. If the verdict is INCONCLUSIVE, it reruns once with 30.
Change the counts with `PERF_GATE_ROUNDS` and `PERF_GATE_RETRY_ROUNDS`.
- FAIL or INVALID fails the checkpoint. A retry that is still INCONCLUSIVE
passes the job with a warning: runner noise alone does not block a
release, but it is not a PASS, and the summary says so. Each attempt's
report is in the job summary. The
`perf-gate-runs` artifact holds every attempt's `run.json`, `report.md`
and `verdict.json` for 30 days.

To measure a branch before its checkpoint, dispatch the workflow with only
the gate: `gh workflow run full-suite.yml --ref <branch> -f perf_gate_only=true`.
Add `-f perf_gate_aa=true` to measure the baseline against itself, which
checks the runner's noise and records a calibration run for the baseline.

The runner is not yet quiet enough for a PASS every time (#198). An A/A run
on it (2026-10-04, 20 then 30 rounds) was INCONCLUSIVE on 7 required
metrics, among them `insert_single.ack_us.p50`, `insert_batch.ack_us.p50`
and the `delta_single` delta percentiles.

## Relationship to the Criterion benches

Expand Down
79 changes: 79 additions & 0 deletions scripts/perf-gate-ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Performance gate at a release checkpoint (full-suite.yml): the approved
# baseline against this checkout on one runner, servers pinned away from the
# gate's own clients. An INCONCLUSIVE first attempt is retried once with more
# rounds. FAIL and INVALID exit non-zero and fail the checkpoint; a retry
# that is still INCONCLUSIVE exits zero with a warning, because the runner's
# noise alone must not block a release, but it is not a PASS.
#
# Usage: scripts/perf-gate-ci.sh [--aa]
# --aa baseline against itself: the runner's noise check
# PERF_GATE_ROUNDS rounds of the first attempt (default 20)
# PERF_GATE_RETRY_ROUNDS rounds of the retry after INCONCLUSIVE (default 30)
#
# Each attempt's run.json, report.md and verdict.json stay in
# target/perf-gate/runs/<utc-time>/. With GITHUB_STEP_SUMMARY set, every
# attempt's report is appended to the job summary.
set -uo pipefail

ROOT=$(git rev-parse --show-toplevel)
cd "$ROOT"
ROUNDS=${PERF_GATE_ROUNDS:-20}
RETRY_ROUNDS=${PERF_GATE_RETRY_ROUNDS:-30}
SUMMARY=${GITHUB_STEP_SUMMARY:-/dev/null}
EXTRA=("$@")

# Keep CPUs 0-1 for the gate's clients and the runner agent; the servers get
# the rest. Too few CPUs to split: leave them unpinned.
CPUS=$(nproc)
PIN=()
if [ "$CPUS" -ge 8 ]; then
PIN=(--server-cpus "2-$((CPUS - 1))")
fi

status_name() {
case "$1" in
0) echo PASS ;; 1) echo FAIL ;; 2) echo INCONCLUSIVE ;; 3) echo INVALID ;;
*) echo "ERROR (exit $1)" ;;
esac
}

attempt() {
local n=$1 rounds=$2 status=0 before
before=$(readlink target/perf-gate/latest 2>/dev/null || true)
echo "=== Attempt $n: $rounds rounds ${PIN[*]} ${EXTRA[*]} ==="
scripts/perf-gate.sh --rounds "$rounds" "${PIN[@]}" "${EXTRA[@]}" || status=$?
{
echo "## Performance gate attempt $n ($rounds rounds): $(status_name "$status")"
echo
# A run that stopped before judging leaves latest on an older run.
if [ "$(readlink target/perf-gate/latest 2>/dev/null)" != "$before" ] &&
[ -f target/perf-gate/latest/report.md ]; then
# The report opens with its own title; the attempt heading replaces it.
sed '1{/^## Performance gate/d}' target/perf-gate/latest/report.md
else
echo "No report: the gate stopped before judging. See the job log."
fi
echo
} >> "$SUMMARY"
return "$status"
}

STATUS=0
attempt 1 "$ROUNDS" || STATUS=$?
if [ "$STATUS" = 2 ]; then
echo "INCONCLUSIVE after $ROUNDS rounds; retrying once with $RETRY_ROUNDS."
STATUS=0
attempt 2 "$RETRY_ROUNDS" || STATUS=$?
fi

case "$STATUS" in
0) echo "Performance gate: PASS" ;;
1) echo "::error title=Performance gate FAIL::A required metric regressed beyond its budget (see the job summary)." ;;
2)
echo "::warning title=Performance gate INCONCLUSIVE::Still too noisy after a retry with $RETRY_ROUNDS rounds. Not a PASS: no regression was shown, and none was ruled out. Read the job summary before releasing."
echo "**Not a PASS.** INCONCLUSIVE after $RETRY_ROUNDS rounds: no supported regression, but the runner was too noisy to rule one out. The checkpoint is not blocked." >> "$SUMMARY"
exit 0 ;;
*) echo "::error title=Performance gate $(status_name "$STATUS")::The run was invalid or did not finish (see the job log)." ;;
esac
exit "$STATUS"
Loading