Skip to content

perf: BenchmarkPrototypeMethodAccess builds an engine inside its timed loop, so it measures startup #51

Description

@mparrett

BenchmarkPrototypeMethodAccess constructs an engine and compiles its source inside the timed loop, so what it measures is dominated by startup rather than by the prototype access it is named for.

b.ResetTimer()
for i := 0; i < b.N; i++ {
    p := driver.NewPaserati()          // build engine, builtins, module loader
    _, errs := p.RunString(tc.code)    // lex, parse, check, compile, then run
}

The workload for one of the three cases is:

let str = "hello";
str.length;

The evidence that startup dominates

From a 16-commit same-host session on a dedicated c7a.2xlarge, absolute ns/op at the final commit:

1,021,813 … 1,108,974 ns   all ten compile-in-loop cases
    4,597,556 ns           MatrixMult
  126,747,628 ns           Arith
  712,648,894 ns           FibPlaceholderRun

All ten land within 8% of each other at ~1 ms — across three different workloads (string length, array length, prototype chain) and across the three cache configurations Baseline, WithPrototypeCache and WithDetailedStats, whose difference is the entire point of the benchmark. If the prototype cache changed anything measurable here, Baseline and WithPrototypeCache would separate. They do not, because both are ~1 ms of NewPaserati() plus compiling three tokens.

A b.N sweep on the same host is consistent with that reading: this group moves a median of −37.8% from b.N=1 to b.N=256, which is the fixed first-iteration cost being amortised over more iterations. The five bench_test.go benchmarks, which compile above b.ResetTimer(), are flat over the same sweep.

Why it matters

bench-ratchet's default scope is pkg/vm and ./tests, so these ten cases are in the perf corpus and on the timeline. They are ten of the thirty-five benchmarks there, contributing to any suite aggregate, while measuring engine startup under a name that says prototype access.

Suggested fix

Hoist construction and compilation above b.ResetTimer(), the way tests/bench_test.go already does twenty lines away:

chunk := compileFile(b, ...)     // or compile tc.code once
p := driver.NewPaserati()
b.ResetTimer()
for i := 0; i < b.N; i++ { p.InterpretChunk(chunk) }

Two things that make it not quite mechanical:

  • the cache configuration is applied via os.Setenv plus the vm.EnablePrototypeCache / vm.EnableDetailedCacheStats globals inside b.Run, so the hoist has to preserve that ordering relative to construction;
  • the workloads are small enough that even a correct version may land under the measurement's attribution floor. It may need a bigger workload rather than only a moved timer — worth measuring before assuming.

BenchmarkPrototypeCacheHitRate has the same shape.

Happy to send a PR if you would like it, though the second point above is a design call I would rather you made.

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