The Rust implementation exists to be faster than the Kotlin one. This gate turns that
sentence into a check that can fail: tools/perf_gate.py runs both binaries over
generated workloads and exits non-zero unless Rust wins.
It is not the same tool as docs/kotlin-vs-rust-benchmark.md:
tools/benchmark.py |
tools/perf_gate.py |
|
|---|---|---|
| Question | how much faster is Rust on this repository? | is Rust still faster than Kotlin at all? |
| Inputs | a real checkout, Bazel, Hyperfine | none -- fixtures are generated |
| Runs in CI | no (manual, needs a workspace) | yes, on every pull request |
| Output | a report | a report and an exit code |
make perf-gate # default workloads, 5 measured rounds
make perf-gate SCALE=4 ROUNDS=9 # bigger graphs, more rounds
make perf-gate JSON=/tmp/perf-gate.json # absolute path: bazel run changes directory
bazel run -c opt //tools:perf-gate -- --list-workloads
Against binaries you built yourself (this is what CI does, and it avoids Bazel competing for cores while timings are taken):
bazel build -c opt //cli:bazel-diff //src:bazel-diff
python3 tools/perf_gate.py \
--kotlin-binary bazel-bin/cli/bazel-diff \
--rust-binary bazel-bin/src/bazel-diff
Always measure -c opt builds of both. Comparing a debug build against an optimized one
measures the build flags.
Exit codes: 0 both fast enough, 1 a workload missed a threshold, 2 the run itself
failed (a binary crashed, the two implementations disagreed about the answer, or the
arguments were wrong).
Every workload is a pure function of its spec, so both implementations parse identical bytes and any difference in timing is a difference in code.
| Workload | What it exercises |
|---|---|
startup |
--version: the fixed cost of launching each CLI (JVM boot vs exec) |
generate-hashes-small |
4,800 targets: proto decode, rule/source hashing, transitive digests |
generate-hashes-large |
24,000 targets: the same path where per-target cost dominates |
generate-hashes-dense |
192,000 targets, fan-in 12: sustained concurrent allocation in the parallel proto decode -- stresses allocator scalability (see below) |
get-impacted-targets |
diffing 150,000 hashed targets |
get-impacted-targets-distances |
the same diff plus build-graph distance metrics over dependency edges |
serve-small |
serve: time-to-ready plus /impacted_targets request latency against a small workspace |
serve-large |
the same, against a larger workspace with more requests fired |
--scale multiplies the sizes; --workload restricts the run (the startup baseline is
always measured, because the other workloads' adjusted numbers derive from it). Pass
--skip-serve to leave out serve-small/serve-large -- starting two HTTP servers per
round is much slower than the hermetic process workloads, which matters most for fast
local iteration. --serve-requests/--serve-concurrency override the request volume and
concurrency the serve workloads run with.
serve does not fit the "run once, time it, diff the output" shape every other workload
uses: it is a long-running HTTP query server, so the measurement is "start it, wait for
/health, fire many requests, then kill it" instead. tools/perf_serve.py is the parallel
pipeline for that: it builds a small real git repository (via
perf_workload.write_git_workspace -- unlike the replay-bazel-shim workloads, serve
actually clones and checks out real revisions), starts both servers against it one at a
time (alternating which one starts first each round, for the same anti-drift reason as the
other workloads), fires a batch of /impacted_targets requests at each with a thread pool,
and diffs the JSON responses for parity before comparing latency.
Its report reuses the request latency's median for the primary speedup and win-rate
columns, and repurposes the "logic" (startup-adjusted) column to carry each server's
time-to-ready (first /health 200) instead -- server boot (git operations, port bind,
HTTP server init) is a different cost from request latency, not the same "subtract process
start-up" adjustment the process workloads make, so read that column as "who starts up
faster," not "who processes faster once warm."
Rust's serve has no --portFile flag (Kotlin's exists for OS-assigned ephemeral ports).
Both implementations are therefore driven through a pre-picked free port
(bind(("127.0.0.1", 0)), read the port, close, reuse the number) -- the same small
bind/release race already accepted by the Rust end-to-end test harness.
tools/perf_workload.py builds the fixtures:
- a synthetic
streamed_protofixture -- a length-delimited stream ofblaze_query.Targetmessages encoded directly in Python, shaped like the output ofbazel query //...:all-targets --output=streamed_proto --proto:instantiation_stack. The graph is layered (package i depends only on the layer below it), so it has real depth and fan-in without degenerating into one long chain; - the workspace those targets name -- source files and
.bzlfiles on disk, so source hashing and.bzl-seed hashing read real bytes; - a replay
bazelshim answeringversion,mod,infoandqueryfrom that fixture. No Bazel server starts, so the measured wall time is bazel-diff's own work; - hash-file pairs for the diff commands, where the second revision changes, adds and removes targets.
generate-hashes decodes the streamed_proto stream in parallel: each batch of messages
is decoded across the Rayon pool, and every proto field (rule_class, each attribute, and
one rule_input per dependency) becomes a short-lived heap allocation. With enough targets
and fan-in, all worker threads allocate at once, so the run's speed becomes a function of
how well the global allocator scales under concurrency.
An allocator with per-thread arenas (glibc's default) absorbs this and the Rust build stays
several times faster than Kotlin. An allocator with a single global lock serializes every
worker on that lock; system time explodes and throughput scales negatively with core
count. The published Rust release is a static musl binary, and musl's malloc uses a
single arena -- so on a many-core host this workload would make the release binary as slow
as, or slower than, Kotlin, while a glibc build of the same source passed comfortably. The
release therefore links mimalloc as its #[global_allocator] (see src/main.rs), whose
per-thread heaps restore scaling; this workload exists to keep it that way.
To reproduce the underlying problem, drop the #[global_allocator] and point --rust-binary
at the static-musl release on a many-core machine, or approximate it with a glibc build under
MALLOC_ARENA_MAX=1. The smaller graphs do not reach a high enough concurrent allocation
rate to surface it, which is why the dense graph exists as a separate load.
The default CI run builds the host glibc binary, which is stable on any runner and never
storms -- so it would not catch an allocator regression by itself. The gate therefore runs as
two legs (see .github/workflows/perf-gate.yaml): the glibc leg is the blocking "Rust is
faster" gate, and a second leg builds the published --config=release-musl binary and runs
the dense load against it. That leg needs a many-core runner -- the storm does not appear
below ~8 cores, so a 2-vCPU runner would pass it regardless of the allocator. Locally,
make perf-gate-musl reproduces it on a many-core host.
The gate is built to avoid the three ways a naive A/B benchmark lies:
- Different work. Before timing anything, both binaries run the workload once and their outputs are normalized and compared (hash maps, sorted label lists, JSON metrics). If they disagree, the run fails with a parity error instead of reporting a speedup over work that was never equivalent.
- Drift. Rounds are interleaved -- both binaries run every round, alternating which
one goes first. A runner that slows down halfway through penalizes both. The first
--warmup-roundsrounds (default 1) are discarded to pay for cold page cache and a cold JIT. - Winning on start-up alone. Rust would beat a JVM on
--versionno matter how slow its hashing became. So each workload also reports a startup-adjusted time: the median wall time minus that implementation's ownstartupmedian. That is the number that reflects the hashing and diffing logic, and it is gated separately. When a workload is too short for the subtraction to mean anything, the report says so rather than inventing a ratio.
| Flag | Default | Meaning |
|---|---|---|
--min-speedup |
1.0 |
required Kotlin/Rust ratio of median wall times |
--min-win-rate |
1.0 |
fraction of paired rounds Rust must win -- 1.0 means every round |
--min-logic-speedup |
1.0 |
the same bar for startup-adjusted medians |
--max-rss-ratio |
unset | with --rss-runs N, fail if Rust's median peak RSS exceeds this multiple of Kotlin's |
The defaults say what the project means by "Rust is faster": faster on the median, faster on the logic once start-up is discounted, and faster in every single paired round.
A run on a 4-core Linux x86_64 container (--rounds 3 --warmup-rounds 1), Kotlin
bazel-diff 40.0.2 against the Rust binary from the same tree:
| Workload | Kotlin median | Rust median | Speedup | Startup-adjusted | Rounds won |
|---|---|---|---|---|---|
startup |
0.246 s | 0.002 s | 139.0x | n/a | 100% |
generate-hashes-small (4,800 targets) |
0.814 s | 0.041 s | 19.8x | 14.4x | 100% |
generate-hashes-large (24,000 targets) |
1.263 s | 0.146 s | 8.6x | 7.0x | 100% |
get-impacted-targets (150,000 targets) |
1.182 s | 0.379 s | 3.1x | 2.5x | 100% |
get-impacted-targets-distances (40,000 targets) |
0.708 s | 0.164 s | 4.3x | 2.9x | 100% |
Every workload also passed its parity check, so the two implementations agreed on the answer before any of these timings were compared. Treat the absolute numbers as machine-specific -- what the gate enforces is the direction, not these values. The startup-adjusted column is the one to watch: it is what remains after the JVM's ~0.24 s boot is discounted, and it is still 2.5x-14x in Rust's favour.
Read the failing line first -- it names which threshold missed and by how much.
median speedup ...x is below the required ...on one workload only: a regression in that code path. Reproduce locally withmake perf-gate WORKLOAD=<name> SCALE=4and profile the Rust side.startup-adjusted speedup ... is below ...while the plain speedup passes: Rust is only ahead because the JVM takes time to boot. The logic itself regressed.rust won N% of paired roundswith a healthy median: the two are close enough that noise decides rounds. That is itself a finding -- Rust is supposed to win comfortably -- but confirm on a quiet machine with--rounds 15before treating it as a regression.implementations disagree(exit 2): not a performance problem. The two implementations computed different results for identical input; fix the correctness bug before the timings mean anything.command failed(exit 2): one binary exited non-zero. The message includes its stderr tail.
Do not "fix" a failure by lowering a threshold in CI. The flags exist for local experimentation on noisy hardware.
//tools:perf_gate_test drives the runner with stub binaries whose relative speed the
test controls, which is the only way to assert that the gate fails when Rust is not
faster -- a run against the real binaries can only ever show that it passes. It also
checks the fixture encoder against an independent protobuf decoder and runs the replay
shim. It is hermetic and fast, and runs in the standard CI matrix alongside
//tools:benchmark_test.
//tools:perf_serve_test covers the serve pipeline the same way, but the stand-in is a
stub HTTP server (Python's http.server) rather than a stub script that exits -- serve
has no output file to diff, so parity is checked over request/response bodies instead.