Lab 27: Benchmarks (Milestone 14)
Background
You will build the benchmark suite, commit a baseline, wire the regression gate into CI, and produce the opcode profile that decides Section 7's order.
No optimizations in this lab. The point is to be able to tell whether one worked.
Why This Lab Matters
- Section 7 refuses optimizations without a baseline. This is the baseline.
- The profile, not the literature, decides which optimization to build first. Most people build
ADD_INTfirst because it is the textbook example; the profile usually says field access. - Deterministic instruction counts give you a performance regression test with zero timing noise, which is better than a benchmark for catching accidental pessimization.
Prerequisites
- Lab 26 complete; CI green.
- Performance Engineering read.
- Nothing running on the machine. Close the browser.
Predict First
fib(25)— VM versus tree walker. What ratio? (You predicted this in Lab 7. Look it up.)- Which opcode do you expect to dominate the capstone policy's profile?
t.fieldversust[i]— which is faster, and by how much?- What fraction of
fib_25's time is dispatch? How would you find out? - Does
--statscost anything measurable?
Step 1: The Six Benchmarks
#![allow(unused)] fn main() { // benches/dispatch.rs fn dispatch(c: &mut Criterion) { c.bench_function("loop_10m", |b| { let src = "local n = 0 for i = 1, 10000000 do n = n + 1 end return n"; let proto = compile(black_box(src)).unwrap(); b.iter(|| Vm::new().run_proto(black_box(proto.clone()))); }); } // benches/calls.rs fib_25 (~243k calls) // benches/tables.rs field_access, array_index, table_build, hash_heavy // benches/strings.rs the four from Lab 16 // benches/gc.rs alloc_churn, with the PAUSE DISTRIBUTION reported // benches/policy.rs THE REALISTIC ONE: 10k candidates, the capstone's scoring policy }
policy.rs is the only one that matters for the product. The other five tell you why it moved.
Keep both; optimizing a microbenchmark the real workload does not exercise is the most common way to
waste a week.
Six hazards, from the chapter, and the one that catches the silent failure:
#![allow(unused)] fn main() { #[test] fn benchmarks_scale_with_input_size() { // If doubling the input does not roughly double the time, LLVM deleted the // thing you are measuring and your benchmark measures nothing. let t1 = time_loop(1_000_000); let t2 = time_loop(2_000_000); let ratio = t2.as_secs_f64() / t1.as_secs_f64(); assert!((1.5..2.5).contains(&ratio), "benchmark does not scale: ratio {ratio:.2}"); } }
Step 2: The Baseline, Committed
cargo bench -- --save-baseline v0.1
cp -r target/criterion docs/learning/baselines/v0.1/
git add docs/learning/baselines/v0.1
git commit -m "lab-27: benchmark baseline v0.1 (machine: <cpu>, rustc <version>)"
Record the machine and the toolchain in the commit message. A number without them is not comparable to anything, and six months from now you will not remember.
Then the summary table, in docs/learning/14-performance.md, filled in with your numbers:
| Benchmark | Tree walker | VM | Ratio |
|---|---|---|---|
fib_25 | |||
loop_10m | |||
locals_deep | |||
globals | |||
field_access | |||
policy_10k |
Compare against your Lab 7 predictions and write one sentence about the one you got most wrong. That sentence is worth more than the table.
Step 3: The Opcode Profile
#![allow(unused)] fn main() { // Behind a feature so the counter array costs nothing by default. #[cfg(feature = "profile-opcodes")] self.stats.by_opcode[op.discriminant() as usize] += 1; }
$ cargo run --release --features profile-opcodes -- run --stats benches/policy.ember
--- opcode profile (policy.ember, 10,000 candidates) ---
GET_FIELD 4,120,441 31.2% ← userdata field access
GET_LOCAL 2,041,882 15.5%
CALL 820,110 12.4%
LT 410,055 8.1%
ADD 620,331 7.9%
JUMP_IF_FALSE 410,055 6.2%
MUL 410,055 5.1%
GET_INDEX 205,027 3.9%
...
total 13,204,882
This table decides Section 7's order, and it is worth pausing on:
| If the profile says | Build first |
|---|---|
GET_FIELD dominates | inline caches |
CALL dominates | call-path optimization: argument copying, frame setup |
ADD/LT dominate with uniform types | specialized opcodes |
GET_LOCAL dominates | superinstructions fusing common pairs |
Most people build ADD_INT first because it is the example everyone uses. Your profile probably
says field access, and following the profile rather than the literature is the entire lesson.
Step 4: The Deterministic Regression Gate
Wall-clock benchmarks are too noisy for CI. Instruction counts are not — they are exact:
#![allow(unused)] fn main() { #[test] fn the_corpus_instruction_count_has_not_regressed() { // A performance test with ZERO timing noise. A compiler change that makes // the corpus execute more instructions fails CI and names the program. let baseline: HashMap<String, u64> = read_json("tests/baselines/instructions.json"); let mut regressions = Vec::new(); for case in corpus() { let n = Engine::new().run_counting(&case.src); let b = baseline[&case.name]; if n > b { regressions.push(format!("{}: {} > {} (+{:.1}%)", case.name, n, b, 100.0 * (n as f64 / b as f64 - 1.0))); } } assert!(regressions.is_empty(), "instruction-count regressions:\n{}", regressions.join("\n")); } }
This is strictly better than a benchmark for catching accidental pessimization, because it cannot be noise. It also catches the improvements: when a change reduces counts, you update the baseline deliberately, and the diff records what you gained.
# Regenerate deliberately, never automatically:
cargo test --features regen-baselines the_corpus_instruction_count
git diff tests/baselines/instructions.json # read this before committing
Step 5: Where the Time Goes
cargo flamegraph --bench policy -- --bench
# or, without perf:
perf stat -e instructions,branches,branch-misses,cache-misses \
./target/release/ember run benches/policy.ember
Compute the one ratio that decides whether dispatch is worth optimizing:
branch-misses / VM instructions executed
If it is a small fraction, dispatch is not your problem and threading would buy you nothing — regardless of what the 2003 papers say about 2003 hardware. Two minutes, and it settles a question people argue about for years.
Step 6: Observability Overhead
cargo bench --features no-stats -- fib_25
cargo bench -- fib_25
If the delta is in the noise — it should be; the counters are one += 1 on a value already in
cache — then delete the no-stats feature. Observability with no measurable cost should not
have an off switch, and having measured it is what lets you say so.
Same argument as the instruction budget's. Two features deleted for the same reason is a pattern worth noticing: an off switch for a zero-cost safety or observability feature is a liability, because someone will flip it.
The Trace
$ cargo bench 2>&1 | tee docs/learning/baselines/v0.1/summary.txt
fib_25/interp time: [<your number>]
fib_25/vm time: [<your number>]
loop_10m/interp time: [...]
loop_10m/vm time: [...]
policy_10k/vm time: [...]
--- YOUR summary table, filled in ---
| Benchmark | Interp | VM | Ratio | Predicted in Lab 7 |
|----------------|--------|------|-------|--------------------|
| fib_25 | | | | |
| loop_10m | | | | |
| locals_deep | | | | |
| globals | | | | (the control) |
globals is the control. Both backends do a hash lookup on a string key, so it should improve
least — and if it improved as much as locals_deep, something else changed and your attribution
is wrong. A benchmark suite without a control tells you a number; one with a control tells you a
number you can believe.
And the GC pause distribution, which is the number that decides capstone project 3:
$ cargo bench --bench gc 2>&1 | grep -A6 'pause distribution'
--- gc pause distribution (alloc_churn, 60s) ---
runs: 184 p50: 1.1ms p95: 6.8ms max: 31.4ms
Write the max down. It is what lands in your p99 latency, and it is the honest input to "should I build an incremental collector?" — a question that should be answered by a number, not by whether incremental collectors sound advanced.
Expected Output
$ cargo bench -- --baseline v0.1
fib_25/vm time: [...] change: [-0.4% +0.2%] (p = 0.61 > 0.05) No change in performance
$ cargo test the_corpus_instruction_count_has_not_regressed
test ... ok
$ cargo test benchmarks_scale_with_input_size
test ... ok
Debugging Steps
Run-to-run variance is 30%
Close everything, disable turbo/boost if you can, use criterion's statistics rather than a single
run, and interleave baseline and change rather than running them sequentially (thermal throttling
makes the second one slower regardless of what it is).
A benchmark is suspiciously fast
black_box the inputs and the outputs. Then run the scaling test — if doubling the input does not
double the time, LLVM deleted your loop.
The VM is slower than the tree walker on something
Genuinely possible and worth understanding: on a benchmark dominated by a single large allocation, compilation overhead can exceed the execution saving. Report it; a benchmark suite that only shows wins is not measuring.
CI's numbers do not match yours
Different machine. CI compares CI against CI; your machine compares against your baseline. Never compare across.
The instruction-count test fails after a compiler improvement
That is the gate working in the other direction. Regenerate deliberately and read the diff.
The opcode profile is dominated by an opcode you did not expect
Good — that is why you profiled. Follow it.
Experiment
CLAIM. The opcode profile of a realistic workload differs substantially from that of a microbenchmark, and following the microbenchmark leads you to optimize the wrong thing.
METHOD. Profile fib_25, loop_10m, and policy_10k. Rank the top five opcodes in each.
PREDICTION. How much overlap? Which opcode is top in the policy and where does it rank in
fib_25?
RESULT. Three ranked lists, side by side, in docs/learning/14-performance.md. The expected
shape: fib_25 is dominated by CALL and GET_LOCAL; loop_10m by ADD and JUMP;
policy_10k by GET_FIELD. Three different answers to "what should I optimize?" — and only the
third one is about the product.
Test
#![allow(unused)] fn main() { #[test] fn benchmarks_scale_with_input_size() { /* Step 1 */ } #[test] fn the_corpus_instruction_count_has_not_regressed() { /* Step 4 */ } #[test] fn instruction_counts_are_identical_across_runs_and_seeds() { // The property that makes the regression gate possible. for case in corpus() { let a = Engine::with_seed(1).run_counting(&case.src); let b = Engine::with_seed(0xdeadbeef).run_counting(&case.src); let c = Engine::with_seed(1).run_counting(&case.src); assert_eq!(a, b, "{} count depends on the hash seed", case.name); assert_eq!(a, c, "{} count is not reproducible", case.name); } } #[test] fn the_baseline_file_records_its_machine() { // A number without its machine is not comparable to anything. let meta: BaselineMeta = read_json("docs/learning/baselines/v0.1/meta.json"); assert!(!meta.cpu.is_empty() && !meta.rustc.is_empty() && !meta.profile.is_empty()); assert_eq!(meta.profile, "release"); } #[test] fn every_benchmark_has_a_control_or_is_marked_as_one() { // A suite without a control tells you a number you cannot believe. let suite = benchmark_manifest(); assert!(suite.iter().any(|b| b.is_control), "no control benchmark: add one that should NOT move"); } }
Challenge Extensions
- A CI benchmark bot. Run the suite on a dedicated machine per PR, post the criterion diff as a
comment, and fail on a >5% regression in
policy_10k. - Compare against real Lua.
lua fib.luaversusember run fib.ember. Expect Ember to be slower — Lua is a register machine with twenty years of tuning. Write down the factor; it is your honest position, and Section 7 is about narrowing it. - Memory benchmarks. Peak RSS and allocation count for the policy workload, alongside time. Allocation count is deterministic, so it can join the regression gate.
- A
cargo bench --features nan-boxingcomparison, once capstone project 2 exists. This is the measurement ADR-004 promised. - Instruction counts per candidate.
policy_10kdivided by 10,000 is "instructions per candidate", which is the number a capacity plan actually needs.
Deliverables
-
Six benchmark files, including the realistic
policyone and at least one control. - The scaling test, proving the benchmarks measure something.
- A committed baseline with machine and toolchain metadata.
- The summary table filled in, compared against Lab 7's predictions, with one sentence on the biggest miss.
- The opcode profile for all three workloads, and a stated decision about Section 7's order.
- The deterministic instruction-count regression gate in CI, with deliberate regeneration.
-
The
branch-misses / instructionsratio measured and recorded. -
The observability-overhead measurement, and the
no-statsfeature deleted if it says so. - The GC pause distribution, with the max recorded.
-
docs/learning/14-performance.mdwritten. - Milestone 14, half one, complete. Section 6 done.
Validation / Self-check
- Why is
policy_10kthe only benchmark that matters for the product, and why keep the other five? - What is a control benchmark, and which of yours is it? What did it tell you?
- What test proves a benchmark was not optimized away?
- Why are instruction counts a better CI gate than wall clock? What can they not tell you?
- What did your opcode profile say, and how does that change Section 7's order?
- What is the
branch-misses / instructionsratio for, and what did yours say about dispatch? - Which two features did you delete after measuring, and what is the shared principle?
- Which GC pause number decides the incremental-collector question, and what was yours?
Next: Section 7 — Making It Fast.