- Docs
- Reference
- Benchmarks
Benchmarks
Point-in-time micro-benchmarks for router, ORM, queue, and other hot paths, plus how to reproduce them.
Measured numbers for router, ORM, and queue hot paths, plus how to re-run them. All benchmarks are deterministic (no network, no timing assertions) and live in *_bench_test.go files next to the code they measure. In-process backends (sqlite :memory:, miniredis) keep them offline; their absolute numbers are dominated by the backend’s own parsing, so compare shapes (allocs/op) and relative deltas, not raw ns.
Numbers are a point-in-time snapshot, not a performance contract: they depend on machine, CPU load, and Go version. Re-run locally before drawing conclusions, and treat committed numbers older than a few weeks as stale.
Conventions
Section titled “Conventions”Every benchmark lives in a *_bench_test.go file next to the code it measures and follows these rules:
- Deterministic: no network, no
time.Sleepsync, no unseeded randomness, no timing assertions. In-process backends only (sqlite:memory:). - Allocation visibility:
b.ReportAllocs()on every benchmark. - Setup excluded:
b.ResetTimer()after warmup/setup;b.Cleanupfor teardown (Open/Close). - Context:
b.Context()(orb.Parallel’s implicit context), never a stored context. - Concurrent types: a
b.RunParallelvariant alongside the sequential one, with balanced work per iteration so memory stays flat. - Naming:
Benchmark<Thing>for the hot path,Benchmark<Thing>Parallelfor the concurrent variant,Benchmark<Thing>Before/Afteronly when documenting an optimization. - Target: the operation the module exists to perform (for example
cacheGet/Set,queuePush/Pop,codecencode/decode,ormquery build,dsllex/parse).
Run all benchmarks with make bench (go test -bench=. -benchmem ./...) or per module with go -C <dir> test -run=NONE -bench=. -benchtime=1s -cpu=1,4 -benchmem.
Results (2026-10-03)
Section titled “Results (2026-10-03)”Router (adapters/router/fiber, sequential vs concurrent ServeHTTP). ServeHTTP now runs the adaptor over a pooled *httptest.ResponseRecorder (recorderPool); BenchmarkServeHTTPAdaptor isolates the adaptor wrapping with a no-op handler. “before” is the pre-pool httptest.NewRecorder() path.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkServeHTTP before (cpu=1) | 5,973 | 6,638 | 60 |
| BenchmarkServeHTTP after (cpu=1) | 5,874 | 6,062 | 55 |
| BenchmarkServeHTTP after (cpu=4) | 4,855 | 6,079 | 56 |
| BenchmarkServeHTTPAdaptor (cpu=1) | 4,651 | 5,457 | 45 |
| BenchmarkServeHTTPAdaptor (cpu=4) | 3,622 | 5,461 | 45 |
| BenchmarkServeHTTPParallel (cpu=1) | 8,483 | 11,195 | 65 |
| BenchmarkServeHTTPParallel (cpu=4) | 4,001 | 11,219 | 65 |
The pool removes the recorder’s struct/map/buffer allocations from the hot path: ServeHTTP drops 5 allocs/op and ~576 B/op. BenchmarkServeHTTPAdaptor shows the adaptor wrapping alone costs ~45 allocs/op (the pooled recorder is 3 of them).
Router re-measurement (2026-10-14) and stdhttp default
Section titled “Router re-measurement (2026-10-14) and stdhttp default”Re-ran the fiber benches on the same machine class after the newHTTPHandler rework (which mirrors gofiber/adaptor.FiberApp while skipping its unconditional body buffering and RemoteAddr resolution), plus the stdhttp benches for comparison. Medians of 3 runs, -benchtime 1s:
| Benchmark | ns/op (cpu=1) | B/op | allocs/op |
|---|---|---|---|
fiber BenchmarkServeHTTP |
~5,300 | ~4,700 | 46 |
fiber BenchmarkServeHTTPAdaptor (no-op handler) |
~3,700 | ~4,080 | 35 |
fiber BenchmarkServeHTTPParallel |
~8,200 / ~4,800 (cpu=4) | ~9,800 | 55 |
stdhttp BenchmarkServeHTTPParam |
~1,900 / ~1,500 (cpu=4) | 2,072 | 17 |
stdhttp BenchmarkServeHTTPStatic |
~1,100 / ~970 (cpu=4) | 1,010 | 10 |
stdhttp BenchmarkServeHTTPParallel |
~4,100 / ~3,100 (cpu=4) | ~7,190 | 26 |
Takeaways:
stdhttpis the recommended default router (and thezever newscaffold default): the param path costs 17 allocs/op against fiber’s 46 (~2.7x fewer), and is ~3x faster per request on this machine. The gap is thefasthttpadaptornet/http conversion plus header/status/body merge-back, which every fiber request pays regardless of handler logic.- The older “45–55 allocs/op adaptor cost” note above is stale for the isolated adaptor: it now measures 35 allocs/op. The 45–55 range still brackets the full fiber
ServeHTTP(46) through concurrent (55) paths.
Reproduce:
go test -run=NONE -bench='BenchmarkServeHTTP$|BenchmarkServeHTTPAdaptor|BenchmarkServeHTTPParallel' -benchtime=1s -cpu=1,4 -benchmem ./adapters/router/fiber/go test -run=NONE -bench='BenchmarkServeHTTPParam|BenchmarkServeHTTPStatic|BenchmarkServeHTTPParallel' -benchtime=1s -cpu=1,4 -benchmem ./adapters/router/stdhttp/ORM render (orm, sqlite :memory:). escapeLike and flattenGroupTerms were hoisted to a package-level replacer / a lazy dedup (linear scan for few leaves, map for many). “before” is the per-call strings.NewReplacer / always-map path.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkEscapeLike before (cpu=1) | 1,361 | 6,784 | 8 |
| BenchmarkEscapeLike after (cpu=1) | 51 | 24 | 1 |
| BenchmarkEscapeLike after (cpu=4) | 44 | 24 | 1 |
| BenchmarkFlattenGroupTermsFew before (cpu=1) | 434 | 572 | 7 |
| BenchmarkFlattenGroupTermsFew after (cpu=1) | 354 | 560 | 3 |
| BenchmarkFlattenGroupTermsFew after (cpu=4) | 369 | 560 | 3 |
| BenchmarkFlattenGroupTermsRollup (cpu=1) | 499 | 560 | 3 |
| BenchmarkFlattenGroupTermsMany (cpu=1) | 7,729 | 21,272 | 75 |
The hoisted replacer cuts escapeLike from 8 allocs to 1 (the result string). The lazy dedup cuts flattenGroupTerms for a few leaves from 7 allocs to 3 and is also faster (354 vs 434 ns); past the linear-scan threshold (linearDedupMaxLeaves = 8) it falls back to the map (...Many), the faster shape for many leaves.
Queue (adapters/queue/db, stale-claim sweep over sqlite). The sweep is one SELECT plus one set-based UPDATE for the whole batch: the database computes attempt = attempt + 1 itself (new orm expression assignment, orm.SetExpr(col, orm.Add(col.Expr(), 1))), under the same lease guard (claimed_by != '' AND claimed_until <= now AND id IN (...)), so a row settled between the SELECT and the UPDATE is skipped instead of double-bumped. “before” is the old N+1 loop (one guarded UPDATE per row).
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkReclaimStale before (cpu=1) | 4,748,598 | 331,827 | 5,690 |
| BenchmarkReclaimStale after (cpu=1) | 645,270 | 109,471 | 3,208 |
| BenchmarkReclaimStale before (cpu=4) | 4,869,046 | 331,997 | 5,685 |
| BenchmarkReclaimStale after (cpu=4) | 631,722 | 109,574 | 3,210 |
The single set-based UPDATE cuts the sweep ~7.4x (4.75M ns/op to 645K ns/op at cpu=1): the N per-row round trips and per-row commit overhead collapse into one statement, and the attempt bump moves into the database. B/op drops ~3x (331 KB to 109 KB) and allocs/op ~1.8x (5,690 to 3,208) with it. A second run confirmed the after numbers within noise (646,554 / 637,319 ns/op).
Queue second pass (adapters/queue/db, stale-claim sweep over sqlite). The sweep collapsed from two statements to one: the batch is selected and released by a single set-based UPDATE whose IN subquery projects the same stale-claim set the standalone SELECT read (same lease guard, same id order, same batch LIMIT), so the ids slice, the per-row msgRow scan, and the SELECT-to-UPDATE window are gone. “before” is the two-statement sweep at commit 8a7f3d9. Medians of 10 runs (-count=10), benchstat p=0.000.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkReclaimStale before (cpu=12) | 898,500 | 109,534 | 3,204 |
| BenchmarkReclaimStale after (cpu=12) | 405,700 | 10,834 | 130 |
The single set-based UPDATE cuts the sweep a further ~2.2x (898.5µs to 405.7µs): the SELECT’s per-row driver work and the ids slice disappear, and the subquery plus outer guard evaluate atomically, so a row settled mid-sweep is still skipped rather than double-bumped. B/op drops ~10x and allocs/op ~24x (3,204 to 130). One tradeoff: an idle sweep now issues a 0-row UPDATE instead of skipping the write entirely (~52µs per sweep at the 250ms gate, ~0.2% CPU).
Rate limiter (adapters/ratelimit/memory, striped lock table). The bucket map was sharded into 64 stripes (DefaultStripeCount) keyed by an FNV-1a hash of the limiter key, each with its own sync.RWMutex. The global MaxEntries bound is preserved exactly: new-key admission takes a short admitMu to reserve a slot under the bound (evicting idle-first, then least-recently-used, across stripes) before inserting into the stripe, so existing-key calls never touch admitMu. “before” is the single sync.RWMutex table at commit 9e3a0d0. Medians of 3 runs, -benchtime 2s.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkAllowParallel before (cpu=1) | 86.57 | 0 | 0 |
| BenchmarkAllowParallel after (cpu=1) | 88.08 | 0 | 0 |
| BenchmarkAllowParallel before (cpu=4) | 122.2 | 0 | 0 |
| BenchmarkAllowParallel after (cpu=4) | 40.70 | 0 | 0 |
| BenchmarkAllowParallelHotKey before (cpu=1) | 75.00 | 0 | 0 |
| BenchmarkAllowParallelHotKey after (cpu=1) | 76.21 | 0 | 0 |
| BenchmarkAllowParallelHotKey before (cpu=4) | 112.4 | 0 | 0 |
| BenchmarkAllowParallelHotKey after (cpu=4) | 114.6 | 0 | 0 |
Sharding cuts the many-keys parallel path ~3x at cpu=4 (122.2 to 40.70 ns/op): before, every Allow serialized on one lock and cpu=4 was slower than cpu=1. The single-key benchmark is unchanged (75.00 to 76.21 ns/op at cpu=1) because one key still serializes on its stripe; sharding helps key parallelism, not same-key throughput. Allocations stay at 0 B/op, 0 allocs/op on every row.
Reproduce:
go test -run '^$' -bench 'BenchmarkAllowParallel' -benchtime 2s -cpu 1,4 -count 3 ./adapters/ratelimit/memory/Idempotency (adapters/idempotency/redis, miniredis). Begin and Complete are now one Lua script each (1 RTT) instead of two client calls. Absolute numbers are dominated by miniredis’s in-process command parsing.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkBegin (cpu=1) | 152,587 | 190,137 | 786 |
| BenchmarkBegin (cpu=4) | 128,635 | 190,249 | 786 |
| BenchmarkComplete (cpu=1) | 162,142 | 198,666 | 793 |
| BenchmarkComplete (cpu=4) | 133,553 | 198,746 | 793 |
Shared Redis registry (shared/redisclient, offline, 2026-10-04). Shared now returns a refcounted client keyed by the resolved addr+DB+TLS/password identity, so every Redis-backed battery on the same Redis shares one client/pool. BenchmarkSharedAcquireRelease is a warm registry hit against a pre-seeded entry; BenchmarkPrivateClientBuild is the per-battery goredis.NewClient the registry avoids on every borrower after the first (idle-conn warming disabled to stay offline). Medians of 3 runs, -benchtime 1s.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkSharedAcquireRelease (cpu=1) | 343 | 656 | 5 |
| BenchmarkSharedAcquireRelease (cpu=4) | 306 | 656 | 5 |
| BenchmarkPrivateClientBuild (cpu=1) | 10,155 | 8,786 | 71 |
| BenchmarkPrivateClientBuild (cpu=4) | 9,853 | 18,215 | 71 |
A shared hit is ~30x faster and ~14x fewer allocs than building a private client (343 vs 10,155 ns/op, 5 vs 71 allocs/op at cpu=1): later borrowers skip the client/pool construction entirely. The shared hit still allocates its own *goredis.Options and key string per call, dwarfed by the pool build they replace.
Job worker (core/job, empty-poll backoff). runLoop used to call time.After(wait) on every empty poll, allocating a fresh timer each time; it now creates one *time.Timer for the run and Resets it per poll, stopping it on exit. “before” is the time.After path. Medians of 5 runs.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkWorkerEmptyPoll before (cpu=1) | 379.0 | 248 | 3 |
| BenchmarkWorkerEmptyPoll after (cpu=1) | 241.7 | 0 | 0 |
| BenchmarkWorkerEmptyPoll before (cpu=4) | 335.0 | 248 | 3 |
| BenchmarkWorkerEmptyPoll after (cpu=4) | 237.8 | 0 | 0 |
Reusing the timer removes the 3 allocs/op (timer, channel, runtime timer) and 248 B/op from every idle poll, and cuts the wait path ~1.6x at cpu=1 (379.0 to 241.7 ns/op). Backoff values are unchanged: the first poll in a run waits 0, then 1ms, 2ms, 4ms, …, capped at DefaultMaxPollWait.
ORM render group dedup (orm/render, sqlite). dedupGroupLeaves’s map fallback keyed on a formatted string per leaf; it now keys on a comparable groupLeafKey{fn, col} struct, removing the per-leaf key allocation. Medians of 3 runs, cpu=12.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkFlattenGroupTermsMany before | 6,164 | 21,272 | 75 |
| BenchmarkFlattenGroupTermsMany after | 5,598 | 22,168 | 11 |
The struct key drops 64 allocs/op (one per leaf) on a 64-leaf GROUP BY and cuts ns/op ~9%; B/op rises slightly (keys stored inline) but total allocation count, and thus GC pressure, falls sharply.
Container resolve/close (container, sqlite shared-pool open+close). The shutdown-critical subset (DB, Cache, Queue, Scheduler, Job) is resolved from a cold container and closed. Close now reuses one buffered result channel across successful closes (reallocating only after a timeout abandons a goroutine), runs the shutdown body as a top-level closeAsync (no closure per go), and dedups with a local plain map created lazily. logOpen also drops its intermediate [...] string concatenation. Medians of 5 runs, cpu=12.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkContainer_ResolveSubset before | 11,090 | 11,157 | 57 |
| BenchmarkContainer_ResolveSubset after | 10,884 | 10,289 | 37 |
The close-path rework drops 20 allocs/op (~35%): the per-service result channel and goroutine closure disappear, sync.Map’s per-key entry nodes are replaced by one small map, and the pool log line skips a string concat. ns/op is dominated by config.Default() and the fresh Container, so the delta is modest; B/op falls ~870 bytes/op.
SMTP buildMIME (adapters/mailer/smtp). The header block now writes precomputed strings through buf.Write/WriteString and grows once via estimateSize (was fmt.Fprintf per header, unbounded growth). writeBase64 encodes into a preallocated []byte and writes the line and CRLF separately (was encoded[i:end]+"\r\n" per 76-byte line). joinAddresses builds into a strings.Builder (single-address fast path) instead of a []string plus strings.Join. The generated MIME bytes are unchanged. Medians of 10 runs, cpu=12.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkBuildMIME before | 8,206 | 5,892 | 102 |
| BenchmarkBuildMIME after | 6,711 | 6,421 | 90 |
| BenchmarkBuildMIMEAttachments before | 11,812 | 9,799 | 155 |
| BenchmarkBuildMIMEAttachments after | 9,332 | 6,933 | 135 |
The rework cuts allocs/op ~12% (102 to 90, 155 to 135) and ns/op ~19-21%. B/op rises ~530 bytes/op on BenchmarkBuildMIME (the estimateSize hint over-reserves header slack, still one buffer allocation) and falls ~2.9 KB/op on BenchmarkBuildMIMEAttachments (the old growth path reallocated the buffer ~7 times; the encoded attachment now lives in one preallocated slice).
Mailer log (adapters/mailer/log, io.Discard, 2026-10-05). Send reuses one logMessage (with its address/attachment slices) across calls under the existing mutex, encodes into a reused bytes.Buffer via json.MarshalWrite (struct passed by pointer so encoding/json/v2 skips its reflect.New shallow copy and bytes.Clone result copy), and writes the buffer plus the newline byte in a single Write. mailer.Address.Validate now splits on @ and scans domain labels with strings.IndexByte instead of strings.SplitN/strings.Split, dropping its per-call slices. The checker constructor allocates reusable scratch state once (288 B); the per-send path no longer allocates. “before” is the json.Marshal + append(data, '\n') path. Medians of 10 runs.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkSend before (cpu=1) | 2,034 | 1,088 | 12 |
| BenchmarkSend after (cpu=1) | 1,462 | 0 | 0 |
| BenchmarkSendParallel before (cpu=1) | 2,280 | 1,090 | 12 |
| BenchmarkSendParallel after (cpu=1) | 1,599 | 0 | 0 |
Send drops 12 allocs/op to 0 and ~1 KiB/op to 0, and is ~28% faster at cpu=1 (2,034 to 1,462 ns/op); the concurrent variant tracks it. The per-call work was six allocs from Address.Validate (SplitN+Split per recipient), two address slices, one attachment slice, and the codec’s reflect.New + bytes.Clone + append growth. All are now reused or eliminated; output bytes are unchanged.
Reproduce:
go test -run '^$' -bench=. -benchmem -count=10 ./adapters/mailer/log/TUI closest matcher (cmd/zever, 2026-10-05). closest used to allocate a full (la+1)x(lb+1) DP matrix per candidate (21 top-level commands), and damerauLevenshtein allocated the same matrix per call. The kernel now keeps three rolling rows in one scratch buffer (damerauLevenshteinScratch) that closest sizes to the longest candidate and reuses across the whole scan; damerauLevenshtein delegates with a nil scratch. Distances, the chosen match, and ordering are unchanged. Medians of 10 runs, cpu=12.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkClosest before | 8,611 | 14,008 | 176 |
| BenchmarkClosest after | 5,429 | 416 | 1 |
| BenchmarkDamerauLevenshtein before | 597 | 832 | 9 |
| BenchmarkDamerauLevenshtein after | 413 | 240 | 1 |
The scratch rows cut BenchmarkClosest from 176 to 1 alloc/op (-99.4%) and 14,008 to 416 B/op (-97.0%), and ~37% faster (8.6µs to 5.4µs); the standalone kernel drops 9 to 1 allocs/op, ns/op within noise. The remaining alloc is the single scratch slice per closest call.
Search (adapters/search/db, sqlite file-backed, 2026-10-05). IndexBatch now writes the whole batch as one multi-row INSERT ... ON CONFLICT (id, idx) DO UPDATE SET content = excluded.content, metadata = excluded.metadata (both dialects expose the proposed row as excluded), instead of one typed upsert per document, through the db.Preparer probe so repeated batch shapes reuse the cached statement. Search preallocates the hits slice at limit capacity and aliases the count query’s arg slice to the hits args’ prefix. “before” is the per-document orm loop / append-grown hits path. Medians of 10 runs.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkIndexBatch before | 630,000 | 28,688 | 553 |
| BenchmarkIndexBatch after | 132,800 | 8,951 | 182 |
| BenchmarkSearch before | 379,700 | 11,170 | 253 |
| BenchmarkSearch after | 372,400 | 10,378 | 244 |
The multi-row upsert cuts IndexBatch ~4.7x (630 to 133 ns/op): the eight per-document orm chains collapse into one statement, and allocs/op drop 67% (553 to 182) with B/op down 69% (28.0 to 8.7 KiB). The remaining per-document work is the metadata map clone plus JSON encode. Search drops 9 allocs/op (253 to 244) and ~790 B/op; its ns/op is dominated by the two FTS5 queries and the per-hit JSON metadata decode, so the time delta is within noise.
Vectorstore brute-force query (adapters/vectorstore/db, sqlite leg, 2026-10-05). 256-row scan with topK=10. decodeRow used to JSON-decode metadata for every scanned row, but only the topK survivors leave the bounded heap (~246 wasted decodes per query). The heap item now carries the raw metadata blob, heapToSorted decodes metadata for the topK survivors only, and the heap is preallocated with a bounded constant capacity. “before” is the per-row decode path. Medians of 10 runs, cpu=12.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkQuery before | 506,500 | 181,030 | 4,919 |
| BenchmarkQuery after | 260,500 | 84,930 | 3,439 |
Lazy decode cuts allocs/op ~30% (4,919 to 3,439) and ns/op ~49% (506.5µs to 260.5µs): each skipped decode was a map[string]any plus a JSON parse, and the decode dominated the scan. B/op falls ~53% (176.8 KiB to 82.9 KiB). Semantic note: corrupt metadata in a row that does not survive topK no longer fails the query (TestEdgeQuery_corruptMetadataBelowTopK documents this); a surviving row with corrupt metadata still fails.
Config redaction + env overlay (config, 2026-10-05). Three semantics-preserving changes. (1) isSensitiveKey no longer allocates a strings.ToLower copy per key: already-lowercase ASCII keys match directly, mixed-case ASCII keys fold in place (asciiContainsFold/asciiEqualFold), and only non-ASCII keys fall back to strings.ToLower. (2) Redact merges the deep copy and the reclamation walk into a single pass (redactCopyMap); the public contract (fresh copy, caller map never mutated) is unchanged. (3) RedactedServices redacts the freshly built serviceToMap output in place via redactMap. Separately, applyEnv matches the head case-insensitively against the 34 known service names without allocation (matchService, first-byte candidate groups) and lowers the rest only on a match, and findField compares normalized field names without allocation (normEqualFold). Redaction output is byte-identical to the old implementation. Medians of 10 runs, cpu=12.
| Benchmark | ns/op before | ns/op after | allocs/op before | allocs/op after |
|---|---|---|---|---|
| BenchmarkRedactedServices | 112,600 | 74,060 | 597 | 539 |
| BenchmarkLoadFile | 64,380 | 39,170 | 572 | 202 |
| BenchmarkApplyEnv | 27,335 | 5,419 | 374 | 2 |
| BenchmarkRedact | 1,783 | 1,239 | 12 | 10 |
ApplyEnv drops 374 allocs/op to 2 (the os.Environ slice and the one lowered field name) and is ~5x faster; LoadFile inherits most of that (572 to 202 allocs/op, ~39% faster). RedactedServices is ~34% faster; its remaining allocs/op are the JSON marshal/unmarshal bridge in serviceToMap, kept marshal-based on purpose to preserve each package’s json-tag semantics. Redact drops 2 allocs/op and ~31% ns/op from the merged pass.
DSL lexer (dsl/lexer, 2026-10-05). scanIllegal builds the diagnostic message and the ILLEGAL token literal into one stack buffer and materializes them as a single string. The rune to (msg, lit) string is a pure function, so it is now memoized in a per-Lexer cache (illegalCache, 8 entries): repeated illegal runes share one backing array, cutting the per-rune string alloc to zero on hits. The diag.Diagnostic alloc is unavoidable (each is retained in Lexer.errs). Other token literals (ident, number, comment text) are at floor: each needs its own string. Medians of 10 runs, -count=10.
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkLexIllegal before (cpu=12) | 1,885,509 | 1,471,879 | 18,019 |
| BenchmarkLexIllegal after (cpu=12) | 733,141 | 1,183,961 | 9,022 |
| BenchmarkLexAstral before (cpu=12) | 84,848 | 75,405 | 1,015 |
| BenchmarkLexAstral after (cpu=12) | 38,682 | 59,437 | 516 |
The memoization cuts BenchmarkLexIllegal ~2.6x (1.89M to 733K ns/op) and ~50% of allocs/op (18,019 to 9,022, the remaining 9,000 being the retained Diagnostic structs); BenchmarkLexAstral tracks it (1,015 to 516 allocs/op, ~55% faster). Token output is byte-identical: same tokens, diagnostics, and positions (golden fixtures under dsl/compile/testdata unchanged).
Reproduce:
go test -run '^$' -bench='BenchmarkClosest|BenchmarkDamerauLevenshtein' -benchmem -count=10 ./cmd/zever/go test -run '^$' -bench='BenchmarkQuery$' -benchmem -count=10 ./adapters/vectorstore/db/go test -run '^$' -bench=. -benchmem -count=10 ./adapters/search/db/go test -run '^$' -bench=. -benchmem -count=10 ./config/go test -run '^$' -bench='BenchmarkLexIllegal|BenchmarkLexAstral' -benchmem -count=10 ./dsl/lexer/Commands
Section titled “Commands”Router and queue are fast, so they run 1s per bench; ORM runs 100 iterations per bench:
go test -run=NONE -bench=. -benchtime=1s -cpu=1,4 -benchmem ./adapters/router/fiber/go test -run=NONE -bench=BenchmarkEscapeLike -benchtime=1s -cpu=1,4 -benchmem ./orm/go test -run=NONE -bench=BenchmarkFlattenGroupTerms -benchtime=1s -cpu=1,4 -benchmem ./orm/render/go test -run=NONE -bench=BenchmarkReclaimStale -benchtime=1s -cpu=1,4 -benchmem ./adapters/queue/db/go test -run=NONE -bench=. -benchtime=1s -cpu=1,4 -benchmem ./adapters/idempotency/redis/go test -run=NONE -bench='BenchmarkSharedAcquireRelease|BenchmarkPrivateClientBuild' -benchtime=1s -cpu=1,4 -benchmem ./shared/redisclient/go test -run=NONE -bench=BenchmarkWorkerEmptyPoll -benchtime=1s -cpu=1,4 -benchmem ./core/job/go test -run=NONE -bench=BenchmarkContainer_ResolveSubset -benchtime=200ms -count=5 -benchmem ./container/go test -run=NONE -bench=. -benchtime=100x -cpu=1,4 -benchmem ./orm/Each path is its own Go module; run from the repo root with the go.work workspace active (or go -C <dir> test ... from inside the module). -run=NONE skips functional tests so only benchmarks execute.
Machine
Section titled “Machine”- CPU: 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz, 6 cores / 12 threads
- Caches: L1d 288 KiB, L1i 192 KiB, L2 7.5 MiB, L3 12 MiB
- RAM: 16 GiB (15 GiB usable)
- OS/Go: linux/amd64,
go version go1.27.0 linux/amd64
- Check out the same commit and ensure
go.workresolves (go env GOWORK). - Run the commands above; each prints
goos/goarch/cpulines identifying the machine. - Compare with
benchstatacross runs (-count=5) before claiming a regression or win; single-run ns/op differences under ~5% are noise.
Bench inventory
Section titled “Bench inventory”Concurrent-load (b.RunParallel) coverage:
- Router:
BenchmarkServeHTTPParallel(adapters/router/fiber/fiber_bench_test.go). - ORM:
BenchmarkPreloadConcurrent(orm/preload_bench_test.go). - Queue:
BenchmarkMemoryRoundTripParallel(adapters/queue/memory/memory_bench_test.go). - Rate limiter:
BenchmarkAllowParallel/BenchmarkAllowParallelHotKey(adapters/ratelimit/memory/memory_bench_test.go). - Mailer log:
BenchmarkSendParallel(adapters/mailer/log/log_bench_test.go).
Adaptor-isolation coverage:
- Router:
BenchmarkServeHTTPAdaptor(adapters/router/fiber/fiber_bench_test.go).
Round-trip coverage (in-process backends):
- Queue:
BenchmarkReclaimStale(adapters/queue/db/queue_bench_test.go). - Idempotency:
BenchmarkBegin/BenchmarkComplete(adapters/idempotency/redis/redis_bench_test.go). - Shared Redis registry:
BenchmarkSharedAcquireRelease/BenchmarkPrivateClientBuild(shared/redisclient/shared_test.go). - Job worker:
BenchmarkWorkerEmptyPoll(core/job/worker_bench_test.go). - Vectorstore:
BenchmarkQuery(adapters/vectorstore/db/pgvector_bench_test.go).
Render-coverage (ORM):
BenchmarkEscapeLike(orm/column_bench_test.go).BenchmarkFlattenGroupTermsFew/...Rollup/...Many(orm/render/agg_bench_test.go).
Startup-coverage (CLI):
BenchmarkHelpStartup(cmd/zever/bench_test.go), budgetTestHelpStartupBudget(200ms) +TestHelpStartupAllocs(1500 allocs) incmd/zever/cli_perf_test.go.
Benchmarks use b.Context(); helpers assert t.Helper where applicable.
Cold start & binary size (2026-10-14)
Section titled “Cold start & binary size (2026-10-14)”Release-style build (go build ./cmd/zever, Go 1.27.2, linux/amd64): 309 MB on disk. Process cold start (--help to exit): ~420 ms first run (page cache cold), ~60–90 ms warm; in-process --help (BenchmarkHelpStartup) is ~99 µs/op, 69 KB/op, 625 allocs/op — binary load and package init dominate, not Cobra dispatch. GODEBUG=inittrace=1 shows ~700 init functions but negligible init clock time; the bulk of wall time before main is mapping the binary.
go tool nm -size top contributors by symbol size: third-party adapter/UI dependencies (github.com/... group, ~60 MB: charm UI stack, sqlite driver via modernc.org/...), Go FIPS crypto tables (~33 MB crypto/internal/fips140/drbg), and codegen/grpc (google.golang.org/..., go/types). The CLI already links only the zero-infra adapter set (cmd/zever/adapters.go: memory/log/noop/stdhttp); the weight is transitive (embedded sqlite, TUI, telemetry), not registered adapters.
Repro: go build -o /tmp/zever ./cmd/zever && ls -lh /tmp/zever, GODEBUG=inittrace=1 /tmp/zever --help 2>&1 | grep '^init ' | wc -l, go tool nm -size /tmp/zever | awk 'NF==4 {m[$4]+=$2} END {for (p in m) printf "%d %s\n", m[p], p}' | sort -rn | head.
Slim CLI (zever build --adapters, 2026-10-14)
Section titled “Slim CLI (zever build --adapters, 2026-10-14)”zever build --adapters=a,b compiles a slim CLI registering only the selected adapters (bare battery = every known adapter for it, battery/adapter = one pin; empty = full CLI). Unselected adapter packages leave the binary via a go build -overlay of the central registration list (cmd/zever/adapters.go), so no build-tagged source variants are committed — explicit list filtering, not tags. Unknown names fail listing the choices. The doctor adapters meta-check is scoped to the project’s enabled batteries, so a slim binary passes its own doctor for its project (smoke-check with <out> doctor).
Measured on this machine (Go 1.27.2, linux/amd64, warm page cache, medians of 5 --help process spawns per session; wall time varies with load, so ranges across two sessions):
| Binary | Size | Warm --help |
|---|---|---|
Full CLI (go build ./cmd/zever) |
309.7 MB | ~40–56 ms |
Slim floor (--adapters=log,router: 4 log + 2 router adapters) |
63.3 MB | ~27–38 ms |
The floor build is ~80% smaller (~5x) and ~32% faster to start; the saving is the excluded adapters’ transitive dependencies (pgx, go-redis, fasthttp, otel, cloud SDKs). In-process --help (BenchmarkHelpStartup, ~99 µs/op) is unchanged — the delta is binary mapping and package init, not dispatch.
Repro: go build -o /tmp/zever-full ./cmd/zever && /tmp/zever-full build --adapters=log,router --out /tmp/zever-slim && ls -lh /tmp/zever-full /tmp/zever-slim, then printf 'LOG_ADAPTER=slog\nROUTER_ADAPTER=stdhttp\n' > /tmp/slimdemo/.env && /tmp/zever-slim doctor (exit 0, all OK).