Files
zeekay 5afa0b2829 bench(zip): JSON-skip axis — native-ZAP-typed RPC vs HTTP+JSON serde
Separate, orthogonal axis from the zaphttp framing-CPU test (that carries the
body opaque and never touches JSON). Isolates ZAP's real structural win: typed
binary fields read zero-copy (no scan/reflect/per-field alloc) vs HTTP+JSON
(de)serializing text every call. One eth-style record, same compute, same field
set both sides; JSON held to its fastest libs (goccy, sonic) + stdlib.

New shared `record` package (DRY: one record + codecs used by the microbench,
the /rpc server, and the ZAP load generator). serde_test.go: decode/encode/
round-trip/handler benches + wire sizes + round-trip equivalence. main.go: POST
/rpc route (zap transport = zero-copy decode; http transport = JSON codec).
zapload: -scenario rpc sends the identical ZAP-typed body.

Numbers (GB10 arm64, go1.26, median of 3, default GOGC/GOMAXPROCS):
- Decode: ZAP 13.4ns/0-alloc vs goccy 473ns/4, sonic 845ns/8, stdlib 2334ns/13
  (35–174x, 0 vs many allocs) — the crush.
- Encode: honest wash — ZAP 629ns/12-alloc vs goccy 424ns/4 (v1.3.0 SetBytes
  defers tail via copy + offsets slice; SetBytesFixed is luxfi/zap-only).
- Round trip: ZAP 648ns vs goccy 904 / sonic 1509 / stdlib 3107.
- Handler (per-req): ZAP 271ns/5-alloc vs goccy 431/4, sonic 736/7, stdlib 1444/9.
- Wire: total ZAP 162B vs JSON 233B.
- End-to-end /rpc c125 d6s: ZAP 534,945 rps vs sonic 483k (1.11x), goccy 452k
  (1.18x), stdlib 407k (1.31x).

Reproducible off published tags (zap-proto/go v1.3.0, no local replaces).
2026-07-17 10:46:10 -07:00

95 lines
3.4 KiB
Go

// Command zipbench stands up a minimal server on either the zip framework or
// raw fasthttp so `hey`/`wrk`/`bombardier` (HTTP) and `./zapload` (ZAP binary
// transport) can measure END-TO-END max req/sec (accept → parse → respond over
// TCP) — the number the in-process Go benches (handler cost only) don't capture.
//
// Two scenarios:
//
// - hello: trivial "ok" handler — the framing/transport tax (existing test).
//
// - rpc: the eth-style /rpc record from the record package — HTTP+JSON vs
// native ZAP, same logical work both sides, isolating the JSON-skip advantage.
//
// go run . -fw zip -addr :8091 -scenario hello
// go run . -fw zip -addr :8091 -scenario rpc -transport http -json goccy # bombardier
// go run . -fw zip -addr :8091 -scenario rpc -transport zap # ./zapload
// go run . -fw fasthttp -addr :8092
package main
import (
"flag"
"log"
rec "github.com/hanzoai/benchmarks/framework/zip/record"
"github.com/valyala/fasthttp"
"github.com/zap-proto/zip"
)
func main() {
fw := flag.String("fw", "zip", "framework: zip | fasthttp")
addr := flag.String("addr", ":8091", "listen address")
transport := flag.String("transport", "http", "zip transport: http | zap")
scenario := flag.String("scenario", "hello", "handler: hello | rpc")
jsonLib := flag.String("json", "stdlib", "rpc JSON codec on the http transport: stdlib | goccy | sonic")
flag.Parse()
switch *fw {
case "zip":
// zip is ZAP-native: a bare addr binds the ZAP binary transport; "http://"
// binds fasthttp HTTP. Same handler either way — only the wire differs.
app := zip.New(zip.Config{ServerHeader: "-"})
app.Get("/health", func(c *zip.Ctx) error { return c.String(200, "ok") })
if *scenario == "rpc" {
h, desc := rpcHandler(*transport, *jsonLib)
app.Post("/rpc", h)
log.Printf("rpc handler: %s", desc)
}
laddr := *addr // bare = ZAP (driven by ./zapload)
if *transport == "http" {
laddr = "http://" + *addr // HTTP (driven by hey/bombardier)
}
log.Printf("zip listening (%s transport, scenario=%s) on %s", *transport, *scenario, *addr)
log.Fatal(app.Listen(laddr))
case "fasthttp":
h := func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(200)
ctx.SetBodyString("ok")
}
log.Printf("fasthttp listening on %s", *addr)
log.Fatal(fasthttp.ListenAndServe(*addr, h))
default:
log.Fatalf("unknown -fw %q", *fw)
}
}
// rpcHandler builds the /rpc handler for the configured wire. On the zap
// transport the body is a native ZAP-typed message (decoded zero-copy); on the
// http transport it is JSON decoded with the selected codec. Both run the same
// serverCompute* path from rpc.go — same fields in, same compute, same fields
// out — so the only difference measured end-to-end is the serialization wire.
func rpcHandler(transport, jsonLib string) (zip.Handler, string) {
if transport == "zap" {
return func(c *zip.Ctx) error {
out, err := rec.ServerComputeZAP(c.Body())
if err != nil {
return c.String(400, "bad zap body")
}
return c.Bytes(200, out)
}, "zap-typed (zero-copy decode)"
}
codec, ok := rec.LookupJSONCodec(jsonLib)
if !ok {
log.Fatalf("unknown/unavailable -json %q", jsonLib)
}
return func(c *zip.Ctx) error {
out, err := rec.ServerComputeJSON(codec, c.Body())
if err != nil {
return c.String(400, "bad json body")
}
return c.Bytes(200, out)
}, "http+json codec=" + codec.Name
}