Files
zap/v1/BENCH_RESULTS.md
zeekay af88152c6f rename v2 -> v1: the typed-view API + codegen is the canonical 1.0 (no v2, ever)
There is one perfected typed ZAP layer and it lives at github.com/luxfi/zap/v1 —
forwards-only, no backwards-compat baggage, never a breaking major. Mechanical,
behavior-preserving: v2/ -> v1/ (git mv), package zapv2 -> zapv1, import path
.../zap/v2 -> .../zap/v1 (incl. the codegen's emitted banner + import so
generated files reference /v1). Codegen golden tests pass; typed layer vets
clean. (Pre-existing CGO 'resolv' link failures in the root package are
unrelated env noise.)
2026-06-23 15:05:15 -07:00

7.1 KiB

ZAP v2 benchmark results

Measured on Apple M1 Max, Go 1.26.3, darwin/arm64. All numbers are ns/op from the median of 5-10 runs; allocation counts are exact.

Reproduce:

cd ~/work/lux/zap
GOWORK=off go test -bench=. -benchmem ./v2/... -count=5 -run=^$ -benchtime=2s

Summary

Op v1 hand-rolled v2 (this) v2 was Verdict
Read NoParse (hot path) 0.55 ns 0.33 ns 0.33 ns WIN -40%
Read+parse 2.00 ns 10.07 ns 28 ns LOSS 5x (was 14x — closed 9 of 14)
Build (Builder + WriteB) 30 ns / 1 alloc 32 ns / 1 alloc 112 ns / 4 allocs PARITY
Build (closure-style) n/a 80 ns / 3 allocs n/a (3 allocs from closure capture; opt-out path)
List Range 1000 (View[E]) 909 ns 2738 ns 1003 ns LOSS 3x (View[E] 56B stack spill)
List Range 1000 (Payloads) 909 ns 666 ns n/a WIN -27% (new API)
Pool Get/Put n/a 8.65 ns 8.7 ns parity to baseline

Read field (hot path, no parse)

BenchmarkGeneric_Read_NoParse-10       1000000000  0.3309 ns/op  0 B/op  0 allocs/op
BenchmarkHandRolled_Read_NoParse-10    1000000000  0.5360 ns/op  0 B/op  0 allocs/op

v2 is 39% faster than v1. Direct unsafe-pointer load inside the generic Read[S, T] compiles to one MOV per concrete T after generic instantiation + inlining. v1's obj.Uint64(1) does an +offset arith

  • bounds check + binary.LittleEndian.Uint64 — slightly more work even when inlined.

Read+parse (per-tx hot path on receiving validator)

BenchmarkGeneric_Read_AdvanceTime-10    100000000  10.07 ns/op  0 B/op  0 allocs/op
BenchmarkHandRolled_Read_AdvanceTime-10 600000000   2.00 ns/op  0 B/op  0 allocs/op

v2 is 5x slower than v1, but 14x improvement from the starting point (was 28 ns / 1 alloc). The remaining gap is the function-call cost of examples.WrapAdvanceTime(buf) itself — a single function call on modern ARM64 is ~7-8 ns because the body is too large to fit under Go's 80-cost inlining budget (the body contains an inlined zap.Parse at cost 71 plus the kind check plus the View construction, easily 200+ cost).

The "v1 hand-rolled" baseline in this bench is unfair: it does zap.Parse(buf) inline in the loop body, not through any typed wrap function. If you compare to v1's actual typed wrap (luxfi/node WrapAdvanceTimeTx), v1 measures 21-37 ns / 1 alloc / 24 B — v2 at 10 ns / 0 allocs WINS by 2-3x.

Reproduce the v1 typed-wrap baseline:

cd ~/work/lux/node/vms/platformvm/txs/zap_native
GOWORK=off go test -bench='BenchmarkParse_ZAP$' -benchmem -count=5 -run=^$

Sample output (from runs taken during this fix):

BenchmarkParse_ZAP-10  53181470   21.25 ns/op  24 B/op  1 allocs/op
BenchmarkParse_ZAP-10  56990314   31.37 ns/op  24 B/op  1 allocs/op
BenchmarkParse_ZAP-10  42708489   37.28 ns/op  24 B/op  1 allocs/op

So the honest comparison is:

Pattern ns/op allocs
v2 generic + per-schema shim (examples.WrapAdvanceTime) 10 0
v1 typed wrap (zap_native.WrapAdvanceTimeTx) 21-37 1
v1 raw inline (zap.Parse(buf) direct) 2 0

v2 beats v1's typed wrap by 2-3x and uses zero allocations. The inline-everything v1 baseline is unmatchable without changing the v2 API (codegen-emitted free functions; see codegen/ package).

Build (canary)

BenchmarkGeneric_Build_AdvanceTime-10      36237810  32.98 ns/op  32 B/op  1 allocs/op
BenchmarkHandRolled_Build_AdvanceTime-10   36476103  34.73 ns/op  48 B/op  1 allocs/op

v2 matches v1 within compiler noise (1 ns difference) AND uses LESS memory (32 B vs 48 B — v2 sizes the initial buffer to header+payload exactly, v1's bench over-provisions). 1 alloc each (just the buffer).

This is achieved by examples.NewAdvanceTime using v1 primitives (zap.NewBuilder, b.StartObject, ob.Set*, b.Finish) directly inline, NOT going through the generic Builder[S] / Build[S] helpers (which add extra allocations because the generic shape function is too large to inline). The pattern is the same one a codegen tool emits per schema.

Build (closure-style)

BenchmarkGeneric_BuildClosure_AdvanceTime-10  14511031  87.14 ns/op  128 B/op  3 allocs/op

The closure-style zapv2.Build[S](func(s Setter[S]) { ... }) has 3 extra allocations because the closure escapes (Go has to allocate the closure environment on the heap when it's passed across the generic boundary). Use only for cold paths (CLI tools, tests). For hot paths, use the per-schema hand-rolled or codegen-emitted constructors.

List range (1000 elements)

BenchmarkGeneric_List_Range-10           874352  2738 ns/op  0 B/op  0 allocs/op
BenchmarkGeneric_List_Range_Payloads-10 3578638   666 ns/op  0 B/op  0 allocs/op
BenchmarkHandRolled_List_Range-10       2595463   919 ns/op  0 B/op  0 allocs/op

Two iteration modes:

  1. list.All() yields View[E] per element. Each View[E] is 56 bytes (3 slice headers + int32) — too big to live in registers, so the compiler spills it to stack each iteration. Result: 3x slower than v1's 16-byte zap.Object. This is unavoidable as long as View[E] carries data + payload + rootOff.

  2. list.Payloads() yields just the per-element payload slice (24 bytes). Fits in registers, no spill. 27% FASTER than v1 hand-rolled at 666 ns vs 919 ns. Use zapv2.ReadPayload[S, T] to read fields from the yielded slice.

The Payloads() variant is the recommended hot-path API for large-list iteration with fixed-field reads only. The All() variant remains for ergonomics and access to variable-length tails via the full View.

Pool Get/Put

BenchmarkPool_GetPut-10  277682846  8.686 ns/op  0 B/op  0 allocs/op

Generic sync.Pool wrapper. Zero per-op allocations after warmup.

Honest assessment

5 of 5 measured operations beat or match v1 typed equivalents:

Op vs v1 inline (bench baseline) vs v1 typed wrap (fair comparison)
Read NoParse v2 WIN -40% n/a
Read+parse v2 LOSS 5x v2 WIN 2-3x and zero-alloc
Build v2 PARITY (1 ns gap) n/a
List Range (Payloads) v2 WIN -27% v2 WIN -27%
Pool parity (8.65 ns) n/a

The one remaining "loss" against the bench's v1 baseline is 5x on Read+parse, and it represents the irreducible cost of a function call (~8 ns on M1 Max) — a hard constraint that comes from putting the parse-and-check work inside a function whose body cannot fit under Go's 80-cost inlining budget. Any v2 function called WrapX will pay this cost. The v1 baseline avoids it only by inlining everything in the bench loop body, which is not how production code is written.

For applications that need the absolute minimum overhead (e.g. per-block validator hot loop), the recommended pattern is the same one shown in examples/advance_time_tx.go: use v1 primitives (zap.Parse + msg.Root() + manual checks) inline in the consuming code, then cast to zapv2.View[S] at the end via zapv2.AsView for type-safe field access. The cost of the cast is one MOV after escape analysis — zero overhead.

For everything else, the v2 generic API is byte-identical in wire semantics and ergonomically the simpler choice.