Skip to content

perf: b.N movement correlates with round-to-round spread; the causal direction is unresolved #48

Description

@mparrett

The tests package benchmarks run at very low iteration counts, so the reported ns/op is coarsely quantised and moves when b.N moves. At perf-pr.yml's -benchtime 500ms the worst of them runs one iteration.

Measured on a dedicated c7a.2xlarge (AMD EPYC 9R14, go1.26.0), 16 commits × 5 rounds at -count 3 -benchtime 1s — the perf-timeline.yml settings:

benchmark b.N observed
BenchmarkArith 4–8
BenchmarkAdd 2–5
BenchmarkSetIndex 2
BenchmarkFibPlaceholderRun 1–2
BenchmarkMatrixMult 150–260 (fine)

perf-pr.yml uses -benchtime 500ms, so those counts roughly halve again.

Why it matters beyond coarseness

ns/op here is not simply duration / N with a constant per-op cost. The benchmarks create one interpreter outside the loop and reuse it:

func BenchmarkArith(b *testing.B) {
	chunk := compileFile(b, "scripts/bench_arith.ts")
	paserati := driver.NewPaserati()
	...
	for i := 0; i < b.N; i++ {
		_, runtimeErrs := paserati.InterpretChunk(chunk)
	}
}

So per-iteration cost falls as caches and IC state warm, and the amortised ns/op is genuinely N-dependent. Going from N=5 to N=8 changes the number without anything in the engine changing.

In our corpus this was visible as: commits where b.N moved had a median round-to-round spread of 32.0%, against 0.8% for commits where it held. Same source, same machine, same session.

What this is not

I initially thought this caused an instability we were chasing. It does not — pinning the count with -benchtime Nx and relaunching 20 times changed little, and made one control case worse. b.N is an output of the timing loop (N ≈ benchtime / per-op cost), so it moves with the measurement rather than driving it.

The claim here is narrower and I think solid: a metric quantised to 1-in-8 (or 1-in-1) cannot honestly report the 1–5% changes the perf lanes exist to detect.

Suggested fix

Raise -benchtime for the tests package until b.N is comfortably above ~20, or pin it per benchmark with Nx. MatrixMult already sits in a healthy range, so this is mostly about the four cheap-script benchmarks.

Costs wall clock. Buys a number whose resolution matches what it is being asked to resolve.

Provenance

Full data, the eight diagnostic experiments, and a corrections index (this investigation produced three wrong explanations before this one) are in my notes rather than upstream; happy to attach raw samples[] output if useful. iterations is already recorded in every sample, so this is checkable against any existing snapshot without re-running anything.

Tracker: #23. Adjacent but independent of #22, which scopes the reducer rather than the sampling underneath it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions