Files
zip/BENCHMARKS.md
zeekayandHanzo Dev a175872aab The wire does not move with the toolchain
jsonenc chose encoding/json/v2 on the goexperiment.jsonv2 tag, and Go 1.27 sets
that tag by default. Moving a module's go directive to 1.27 therefore changed what
every zip service writes: a zero number or false kept under omitempty, a nil slice
as [] and a nil map as {}, field names matched case-sensitively, map keys unsorted.
hanzoai/cloud went from green to 54 failing wire tests on that bump alone.

jsonenc is stdlib encoding/json and nothing else. Since 1.27 that API runs on the
v2 engine with v1 semantics, so the speed arrives with the toolchain and the bytes
stay put. A test pins the four differences.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:40:42 -07:00

8.9 KiB
Raw Permalink Blame History

Benchmarks — zip (ZAP-native web framework)

zip wraps the zap-proto/fiber v3 fork. This file measures what zip costs on top of raw fiber — the framework tax — against hand-written baselines, so every number is the overhead, not the absolute. Routing performance itself is inherited from fiber and is documented in that repo's BENCHMARKS.md (headline: 0-alloc matching; fork adds zero aggregate match cost vs upstream).

Numbers are from committed benchmarks (bench_test.go); the JSON edge path also has json_bench_test.go.

Environment

CPU Apple M1 Max (10 core)
RAM 64 GB
OS Darwin 25.5.0, arm64 (macOS)
Go go1.26.4 darwin/arm64
Method -benchmem -benchtime=1s -count=6, median via benchstat (golang.org/x/perf)

± is benchstat's across-run variation. Laptop host: treat sec/op differences under ~5% as noise; allocs/op is exact.

§1a's numbers are from a second host — 20-core arm64 Linux (go1.26.5), and a busy one: unrelated builds held it at load 30–140 throughout, and the same benchmark varied up to 3.3× between samples. Wall-clock figures there are therefore reported as the minimum of 24 samples taken alternately from the before and after binaries, so drifting interference hits both equally and the minimum is the sample least polluted by it. Treat them as indicative of direction and rough magnitude, not as calibrated absolutes. The allocs/op and B/op columns carry no such caveat — they are exact counts, identical on every run and every host.

1. Per-request wrapper tax — zip.App vs raw fiber.App

zip routes through toFiberHandler, which materialises a *Ctx{fc, app, log} per request. To isolate exactly that, the handlers are byte-equivalent: zip's c.NoContent(204) is c.fc.Status(204); return nil, and the fiber baseline is c.Status(204); return nil. Same underlying work, only difference is the wrapper. Dispatched via app.Fiber().Handler() on a reused fasthttp.RequestCtx.

route zip ns/op raw fiber ns/op tax zip allocs fiber allocs
static (/v1/health) 80.5 56.1 +24.4 ns 1 (48 B) 0
param (/v1/tracker/:id) 91.3 67.1 +24.3 ns 1 (48 B) 0

The tax is a constant ~24 ns and exactly one 48-byte allocation (the &Ctx), independent of routing shape. On a no-op handler that is +43%; on any real handler it is noise — the next section's typed handler does ~3,500 ns of work, so the 24 ns wrapper is <1%.

1a. …and independent of chain depth (Benchmark_ChainTax)

Routing shape is not the only axis a deployed service moves on: every service runs a Use stack above its leaf. toFiberHandler wraps each zip handler, so the wrapper's cost used to grow linearly with the chain — a 5-middleware stack allocated 6 *Ctx per request, not 1.

It no longer does. One request gets ONE *Ctx, created on first touch and kept in the request's own user-value storage, so every handler in the chain is handed the same value. Measured with allocs/op, which is exact and host-independent:

Use middleware allocs/op before allocs/op now B/op before B/op now
0 1 1 48 48
1 2 1 96 48
3 4 1 192 48
5 6 1 288 48

On a contended 20-core arm64 host (min of 24 alternating samples — see "Environment" caveat below) that was −48.9% wall clock at depth 5 (280.8 → 143.6 ns/op) and +3.3% at depth 0 (76.4 → 78.9 ns/op): a bare single-handler route pays ~2.5 ns to store the value it will not reuse, and every route with middleware — i.e. every real one — wins several times that.

TestServePathAllocsAreChainDepthInvariant pins the property so it cannot regress silently, and TestSetLogReachesDownstream pins its correctness half: because the chain shares one *Ctx, a c.SetLog() in middleware is now actually visible to the handlers below it, which is what middleware.Logger has always meant by attaching request_id / org / user.

The remaining one allocation per request is the price of the clean *zip.Ctx surface. Removing it would mean pooling ctx values across requests, which buys 48 B at the cost of a use-after-free footgun — not worth it.

Measuring this correctly: these benchmarks reuse one fasthttp.RequestCtx, so each iteration must clear the previous request's user values (fctx.ResetUserValues()) exactly as both transports do before dispatching — zap-proto/http's serveConn, and fasthttp's keep-alive loop via Request.Reset. Omit it and an iteration inherits the last one's request-scoped *Ctx: the wrapper then reports 0 allocs/op for a path that really allocates one per request.

2. Typed routes — Get/Post[In,Out] vs hand-written

The generic Post[In,Out] decodes the JSON body → In, validates, runs the handler, and encodes Out → JSON, through the transport-agnostic op.invoke core that also feeds the OpenAPI and MCP projections. Compared against an idiomatic hand-written zip handler doing c.Bind + c.JSON — the same work without generics. Identical chatRequest/chatResponse payload (shared with json_bench_test.go). Median of 6.

implementation ns/op B/op allocs/op
typed Post[In,Out] 3,507 1,220 27
hand-written c.Bind+c.JSON 3,581 1,268 28

The generic sugar is free — in fact marginally leaner (−2% time, −1 alloc). Post[In,Out] calls the JSON decoder directly on c.Body(), while the hand-written c.Bind() goes through fiber's content-type–sniffing bind machinery, which is where the extra allocation comes from. So the ergonomic, OpenAPI/MCP-generating typed API costs nothing versus writing the decode/encode by hand. The ~3.5 µs is dominated by JSON marshal/unmarshal of the chat payload (25+ of the 27 allocs), not by zip.

3. What a plugin costs per request — the hop

zip.Load runs a service as its own binary and reaches it over a unix socket. That buys independent build, release, restart and per-process measurement, and charges a round trip for it. Benchmark_PluginHop prices the round trip by serving the SAME route two ways through the same host — once by a handler linked in, once by a child process — so the delta is the hop.

Measured on the 20-core arm64 Linux host of §1a, and a far worse one: load average ~100 throughout, and the same benchmark varied 9× between samples. Reported as the minimum of 18 samples, 8 of them A/B interleaved one round per process — the sample least polluted by interference. Direction and rough magnitude only; these are not calibrated absolutes.

route served by ns/op (min) B/op allocs/op
linked into the host 410 482 7
a loaded plugin (child + unix socket) 10,360 51 1

The hop is ~10 µs, about 25× this route. The ratio is the wrong number to carry around, though, because the denominator here is a handler that does nothing but encode a two-field JSON object. Against work a service actually does — one SQLite query is ~10–100 µs, one network call or LLM token is milliseconds — 10 µs is 0.01–10%. A plugin is affordable exactly where the handler does real work, and expensive where it does not, which is why the boundary belongs at a service, not at a function.

The allocation columns move the other way and are exact on any host: the host allocates 7 → 1 per request and 482 → 51 B, because the response is now encoded in the child. A host that stops doing a subsystem's work stops paying for its garbage too.

Verdict

claim verdict
zip's per-request overhead over fiber ~24 ns + 1 alloc (48 B), constant; <1% of real handler cost (§1)
…and that 1 alloc holds however deep the middleware chain is True — one *Ctx per request, not per handler (§1a)
Typed Get/Post[In,Out] costs extra vs hand-written False — on par / marginally leaner (§2)
A plugin is free False — ~10 µs per request, and the host's own allocs drop 7 → 1 (§3)
Routing performance Inherited from zap-proto/fiber — see its BENCHMARKS.md (0-alloc match)

Reproduce

go test -run='^$' -bench='Benchmark_ZipTax|Benchmark_TypedRoute' -benchmem -count=6 .

# The plugin hop. One round per process, so A and B interleave under drifting
# load; take the minimum across rounds rather than the mean.
for i in $(seq 8); do
  go test -run='^$' -bench=Benchmark_PluginHop -benchmem -count=1 -benchtime=3000x .
done

# JSON edge path (stdlib encoding/json)
go test -run='^$' -bench='BenchmarkJSON' -benchmem -count=6 .

# Medians (requires golang.org/x/perf/cmd/benchstat)
go test -run='^$' -bench='Benchmark_ZipTax|Benchmark_TypedRoute' -benchmem -count=6 . | benchstat -