Continuous benchmarking (Bencher)
rag-rat tracks performance over time with Bencher. Two lightweight
performance signals are recorded on every push to main and gated on every pull request; a
search-quality signal (commit-replay recall) is tracked on main only; and a heavyweight
Linux-kernel indexing signal runs only on a published release:
| Signal | Harness | When | What it measures | Why |
|---|---|---|---|---|
rag_pipeline | iai-callgrind | push + PR | CPU instruction counts for index rebuild + cold/warm query | Deterministic — immune to CI runner noise, so it catches small relative regressions a wall-clock bench would drown out. |
index_time | criterion | push + PR | Wall-clock time for a full index rebuild (whole cargo checkout) | The number users actually feel. Noisy, so it’s gated statistically (t-test), not on a tight percentage. |
search_replay | commit-replay eval → tools/eval-report-bmf.py | push to main only | recall@3 / recall@10 / mrr@10 of hybrid search over the self-index at HEAD | The retrieval quality an agent actually feels (“did the right chunk land in the first 3 reads”). ~8–10 min + noisy (the replay gold is recent commit paths), so it’s tracked + alerted on main, never a PR gate. |
linux-kernel-v7.0/full-index | single-shot (tools/bench-kernel.sh) | release only | Wall-clock + throughput + peak memory to index the whole Linux kernel | The headline “indexes the Linux kernel in X seconds” number on a huge real C codebase. Too slow (~tens of minutes) for per-push. |
The push/PR harnesses run on different corpus sizes on purpose — see “Corpus” below.
Benches
Both benches live in crates/rag-rat-core/benches/ and share a corpus harness
(benches/shared/mod.rs, kept in a subdirectory so cargo doesn’t auto-detect it as a bench):
rag_pipeline.rs(iai-callgrind,harness = false) — three#[library_benchmark]s:index(rebuild),query_cold(open+ search from disk),query_warm(search on an open db).index_time.rs(criterion,harness = false) — onefull_rebuildbenchmark.
Run them locally:
# Instruction counts (needs valgrind + iai-callgrind-runner on PATH — see below)
cargo bench --no-default-features --bench rag_pipeline
# Wall-clock full-index time
cargo bench --no-default-features --bench index_time
--no-default-features builds the hash embedder only — no ONNX / model downloads, no network,
fully deterministic. The benches assert nothing about embedding quality; they measure the
indexing and retrieval machinery, which must stay model-agnostic to be reproducible in CI.
Local prerequisites for the iai bench
iai-callgrind runs the benched code under valgrind/callgrind and needs a matching runner binary:
sudo apt-get install -y valgrind # or your distro's package
cargo install --locked [email protected] # MUST match the iai-callgrind lib version in Cargo.toml
The criterion bench has no special prerequisites.
Corpus
Both benches index a pinned snapshot of an external repo so the workload is realistic and stable
across runs. The corpus is not vendored into git; the harness shallow-clones it on first use and
caches it under target/bench-corpus/:
- Repo:
rust-lang/cargo, pinned by commit SHA (tag0.97.1). - Shallow fetch of just that commit (
git init+fetch --depth 1 origin <sha>), so the clone is small and the content can never drift. - Override the location with
RAG_RAT_BENCH_CORPUS=/path/to/checkoutto point at an existing checkout (useful offline).
The iai bench indexes a small subtree (src/cargo/core/resolver, ~10 files) because callgrind’s
~50x slowdown applies even to the uncounted setup index builds — a large subtree would time the
job out. The criterion bench indexes the whole checkout (~1.3k .rs files) — the realistic
“index this repo” workload rag-rat index actually performs — since it runs at native speed; it
reports the indexed file_count_by_language and files/sec throughput so the output shows real usage.
If you bump the pinned SHA, update the cache key (bench-corpus-cargo-<version>) in all three
workflow files too.
Search-quality: commit-replay recall
bench.yml’s eval_recall job tracks how well search retrieves the right code over time, using the
commit-replay eval (#120): each recent commit becomes a case (commit message = query, the commit’s
changed paths = recall gold), scored against the hybrid (lexical + semantic) index at HEAD. Three
measures go to Bencher under the search_replay benchmark:
recall_at_3— the headline: |gold ∩ top-3| / |gold|, “did the right chunk land in the first 3 reads”. It carries a statistical lower-boundary t-test threshold (a recall regression is a drop, unlike the latency/instruction signals, which alert on an increase).recall_at_10,mrr_at_10— tracked without a threshold (context for a recall@3 move).
Values are fractions in [0, 1] — the same numbers eval prints — so a Bencher plot reads identically
to a local run.
The job builds an embedded index at HEAD (index --discover → models install sentence-transformers/all-MiniLM-L6-v2 → reconcile), then rag-rat --json eval --replay --replay-max-cases 200 → tools/eval-report-bmf.py → bencher run --adapter json. Two
footguns it has to handle:
- Off-head ⇒ bogus 0.000.
reconcileonly embeds at-head chunks, so the index must be built + embedded at HEAD before scoring; a stale/absent index makes every recall read 0.000. - Shallow checkout ⇒ no cases. The replay walks git history, so the job checks out with
fetch-depth: 0— the default depth-1 checkout would leave it with only HEAD and zero cases.
Main-only, no --err, by design. At ~8–10 min over 200 cases it’s too slow for the PR critical
path, and the metric is noisy — the gold is recent commit paths, so a single PR’s recall delta is
usually below the per-case noise floor (≥200 cases are needed for signal). So it’s tracked +
alerted on main (the statistical threshold flags a real regression in Bencher) rather than
hard-failing a run — the same “headline signal, not a gate” stance as the iai/criterion series. Reset
the baseline after an embedder or replay-parameter change with Actions → bench → Run workflow (the
workflow_dispatch reruns --thresholds-reset).
Run it locally (needs the eval feature + an embedded at-head index):
cargo build --release -p rag-rat --features eval
target/release/rag-rat --json eval --replay --replay-max-cases 200 > eval.json
python3 tools/eval-report-bmf.py eval.json > eval_bmf.json
CI workflows
Four workflows under .github/workflows/:
| Workflow | Trigger | Has the token? | Role |
|---|---|---|---|
bench.yml | push to main | yes | Records the lightweight perf signals and the search_replay recall signal (a separate eval_recall job) against main; sets/refreshes the thresholds new PRs are compared against. |
bench-pr-run.yml | pull_request (incl. forks) | no | Runs the benches, uploads raw output + the PR event as an artifact. Never sees secrets, so a fork PR can’t exfiltrate the token. |
bench-pr-track.yml | workflow_run of bench-pr-run | yes | Runs in the base-repo context: downloads the artifact, uploads results to Bencher, comments the comparison on the PR. |
bench-release.yml | release: published + manual workflow_dispatch | yes | The heavyweight Linux-kernel headline bench (below). Not a gate — tracks latency/throughput/memory over releases. |
The pull_request → workflow_run split is Bencher’s documented fork-safe pattern: untrusted fork
code runs without secrets, and only the trusted base-repo workflow (which never executes fork code)
holds the API token. PR runs use --start-point main --start-point-clone-thresholds --start-point-reset so each PR is measured against a fresh clone of main’s baseline + thresholds.
Release headline: indexing the Linux kernel
tools/bench-kernel.sh (run by bench-release.yml) is the “indexes the Linux kernel in X seconds”
benchmark. It shallow-clones a pinned kernel tag (KERNEL_TAG, default v7.0), indexes its C/H
sources once with the release rag-rat binary (index --full, model = "none", built
--no-default-features so there is no model download), and writes a Bencher Metric Format JSON file
with nine measures:
latency— wall-clock seconds to index, in nanoseconds (Bencher’s built-in Latency measure).throughput— indexed files per second.memory— peak resident set size in bytes, fromgetrusage(RUSAGE_CHILDREN).ru_maxrss(the kernel-true peak, catches sub-sample spikes).memory_sampled— peak of a 1 Hz/proc/<pid>/statusVmRSS time-series (also written tokernel_rss_samples.csvas the before/after curve).symbols,edges,resolved_edges,chunks— extracted-fact counts, so a run is comparable per fact and not just per file (the unresolved-edge taxonomy goes tokernel_unresolved_by_kind.csv).db_size— on-disk index size in bytes, post-WAL-checkpoint. The structure-only half of the size split (#78): no vectors.
Setting RAG_RAT_KERNEL_VECTORS=1 runs a second pass that installs the hash embedder and embeds
every chunk the embedding policy admits (not every chunk yields a vector — the policy skips e.g.
generated and low-signal chunks), adding two more measures — db_size_with_vectors and
db_size_vectors_delta (the marginal bytes the embedding tier costs). Off by default: at kernel
scale it sweeps ~1.6M chunks, and hash-embedder vectors over the kernel are plumbing-validation, not
retrieval value. When the pass doesn’t run, both measures are omitted rather than reported as
zero. See
benchmarks.md.
It’s a single cold rebuild, not a criterion loop: the whole kernel is ~63k C/H files and takes tens of minutes, so criterion’s 10-sample minimum (×10) is a non-starter. One run is also exactly the number a user sees.
Run it locally or trigger it manually before relying on it for a release:
cargo build --release --no-default-features --bin rag-rat
RAG_RAT_BIN=target/release/rag-rat bash tools/bench-kernel.sh # whole tree (~tens of min, several GB RAM)
# Bound the scope (faster, less memory) while iterating:
RAG_RAT_KERNEL_SUBDIRS="kernel mm fs net lib" RAG_RAT_BIN=target/release/rag-rat bash tools/bench-kernel.sh
Or in CI: Actions → bench-release → Run workflow (the workflow_dispatch input sets the subtree).
Runner sizing. This job runs on the self-hosted [self-hosted, bigmem] runner, not a
hosted one — the whole-tree index peaks at ~5.5 GiB (see benchmarks.md), which
plus the OS page cache over a ~10 GB index DB is borderline for a hosted runner and, more to the
point, would give a noisy, contended wall-clock. If a run OOMs or overruns timeout-minutes on a
constrained box, bound RAG_RAT_KERNEL_SUBDIRS to the core subsystems. Validate with a manual
dispatch first.
Persistent runner cache retention
CI, evaluation, and PR benchmark jobs finish with runner-cache-cleanup, after tests and artifact
uploads. On Hetzner it caps the job’s configured Cargo target at 20 GB using cargo sweep; it never
sweeps a sibling runner. Coverage uses its shared cycle lock, and cleanup respects Cargo’s profile
locks. Busy locks produce an explicit warning and defer cleanup to the next job. Cleanup failures
fail the step. Hosted runners skip this action.
The six-hour runner-maintenance workflow remains a backstop. A busy sibling can make it skip a
cache, so a successful maintenance run does not imply every target is below the cap. The per-job
step keeps each busy runner bounded between completed jobs without waiting for a host-wide idle
window. Hard termination can bypass that step; the scheduled sweep remains necessary. A single
build can still grow beyond the target cap while it runs.