Benchmarks

Hit-path cost, five load scenarios and garbage-collection cost, each with its machine, its command and what it does not show.

Every number on this page was measured on one machine, in process, with a synthetic origin. They tell you what Weir costs and whether its limits hold under load. They do not tell you what your deployment will do. The tables are rendered from docs/benchmarks.md in the repository, where the raw output files are linked.

The reference machine

Apple M4 Pro, 12 cores, 24 GiB, macOS. Go 1.27.1 unless a section says otherwise. Absolute figures belong to this machine. On a 4-vCPU Linux container the same hit costs roughly two and a half times as long, and that box drifts by 10 to 20% between runs.

One cached hit

BenchmarkServeHitSmall measures Engine.Serve on a fresh 1 KiB entry in the memory store: classify the request, look it up, check freshness, build the response. No network, no TLS, no net/http.

Two back-to-back runs on the M4 Pro, machine otherwise idle, three builds interleaved by benchstat (-count 10 each; raw: benchmarks/hit-path-opt-m4pro.txt). An earlier run with several Claude Code sessions and a Chrome video stream open had a ±38% spread and is discarded.

BuildRun 1 ns/opRun 2 ns/opvs M1 (run 1 / run 2)B/opallocs/op
M1 73e4d2e870.5 ± 1%854.1 ± 2%1 48014
M10 b9095501 087 ± 3%1 070.5 ± 1%+24.9% / +25.3%1 48014
optimized branch833.5 ± 3%821.4 ± 4%-4.3% / -3.8% (p<0.01)1 53612

The optimized hit path is about 4% faster than M1 and 23% faster than M10 on the M4 Pro, with two fewer allocations. The +32% growth since M1 that the first reference run exposed is gone. Bytes per op are 56 above M1 (1 536 against 1 480), not the 160 above M1 of the release candidate, and the allocation count is lower.

The gain is smaller on the M4 Pro than on the Xeon (-30% there against M10-to-branch -23% here, relative to the release candidate), as expected: SHA-256 is cheaper with the ARM instructions, so the two tags no longer hashed per hit saved less.

New baseline. The 20% regression rule is measured from this run, a median of about 0.83 µs. NFR-5 sets the ns/op ceiling at 1.5 times that, 1.25 µs (docs/01), replacing the 1.9 µs derived from the first, noisier reference run. The first reference run above stays as the record of the release candidate.

BenchmarkServeHitSmall, three builds run back to backM1 (first engine): 870.5 nanoseconds per operation, 14 allocations. M10 (all defenses on): 1 087 nanoseconds per operation, 14 allocations. optimized hit path: 833.5 nanoseconds per operation, 12 allocationsM1 (first engine)870.5 nsM10 (all defenses on)1 087 nsoptimized hit path833.5 ns01 200 ns/oplower is better

After the last hardening changes before v1.0.0 the same benchmark measured 852.2 ns with 12 allocations on Go 1.27.2, with the machine not idle. The project's own budget is at most 1.25 µs and 16 allocations per hit on this machine, and a change that slows the hit path by more than 20% fails review.

make bench

Five load scenarios

make load runs each failure mode against a real engine for about eight minutes in total and checks a threshold. This is the idle reference run of 2026-10-11.

ScenarioMeasuredThresholdResult
steady hits26.3 M requests, 438 k/s achieved (target 442 k/s, 50% of saturated), hit ratio 1.0000, p50 2.0 µs, p99 18.4 to 22.5 µs in each of six windows, 0 origin calls after warmratio > 99%, median-window p99 < 200 µspass
synchronized expiry10 000 keys stored in 1.7 s, max-age=60, all 10 000 refetched, origin peak in-flight 32, busiest second 1 485 origin calls, 0 client errorsrefetch ≥ 90%, in-flight ≤ 32, ≤ 2 000 calls/spass
busting flood plus normal99 976 flood requests (71 151 shed), flood in-flight peak 8 (MaxPerPartition 8), normal offered 499/s with 0 drops, normal p99 36.9 µs alone and 41.0 µs under flood (+11%)in-flight ≤ 8, p99 within 20% (50 µs floor)pass
origin brownout (×20)18 breaker open events, ended closed, 52 975 stale responses, every request 2xx before, during and after, peak 48 goroutinesbreaker per config, stale served, no goroutine growthpass
store flap (50%)10.8 M requests, 0 client errors, slowest request 47.1 ms (bound 208.8 ms), store breaker 14 open / 8 closedbounded latency, breaker cyclespass

All five passed, and TestGCAt1MEntries passed as well.

What each one shows:

  • Synchronized expiry. 10,000 entries with the same max-age expire together. The limiter holds origin concurrency at its configured 32 and every entry is refetched.
  • Busting flood. One path is flooded with distinct queries while other paths carry normal traffic. The flood is held to its per-path cap of 8, and normal requests are 11% slower at p99.
  • Origin brownout. The origin fails in bursts. The breaker opens and closes, stale content covers the gaps, and no client sees an error.
  • Store flap. The store fails half its calls. Requests fall through to the origin under the limiter.

Garbage collection at one million entries

The memory store keeps entries as ordinary Go objects, so the garbage collector has to walk them. This measures what that costs per request as the store grows.

10 000 entries1 000 000 entries
GC CPU per forced cycle9.6 ms1 299 ms
paced p50 / p99 (forced cycle every 5 s)4.6 µs / 36.9 µs3.8 µs / 36.9 µs
paced slowest request2.0 ms11.9 ms
saturated requests in 60 s61.4 M43.4 M
allocation per request1 816 B1 824 B
non-GC CPU per request (generator included)11.0 µs14.7 µs
natural cycles in the window4 12736
GC share of busy CPU, runtime-reported3.1%6.2%
GC share of busy CPU, projected5.7%6.8%
GC µs per request0.661.08

The D36 gate (at most 2 µs of GC per request at 1M entries) holds with 1.08 µs, against 1.76 µs on the 4-core Xeon. The share of busy CPU is 6.2% to 6.8%, under the 10% line that PLAN M10.5b set. The pointer-light layout (M16-02) stays deferred.

At 1 million entries the collector costs 1.08 µs per request here, and 1.76 µs on the 4-vCPU Linux container, against a project gate of 2 µs. A layout that stores entries without pointers brought that to 0.014 µs in a prototype, but it added 12 allocations per hit and broke the hit-path budget, so it is deferred (M16-02) until a deployment needs it. GOGC=200 roughly halves the cost for more memory headroom.

What this does not show

  • No comparison with any other cache. We have not run one, so the comparison page is qualitative.
  • No network, TLS or HTTP parsing. A request served through net/http over TLS costs many times the 852 ns measured here, and most of that is outside Weir.
  • No real origin. The synthetic origin answers instantly or fails on cue.
  • One reference machine and one container. No ARM server, no NUMA box, no production trace.
  • The Valkey store is covered by correctness tests, not by these load scenarios.