mirror of
https://github.com/zap-proto/bench.git
synced 2026-10-30 15:06:06 +00:00
The arm this harness called "native ZAP" was a TCP server that read a
4-byte length prefix and wrote the same bytes back without looking at
them. It imported no ZAP package and carried no message format, no
schema and no headers, while the HTTP arm it was ranked against built a
request, parsed a header set and returned a body. Its numbers are where
622x less memory and 115x fewer allocations came from, and 622 is
9954/16: an HTTP allocation floor over a payload size.
The real native arm is a typed zap-proto/go message — body as a bytes
field, the header set as a variable-entry list, a status word — over the
same framing, with the server reading the body and every header and
echoing the X- headers, which is the work the HTTP handler does. It
allocates six objects and twice the payload per round trip, at every
size, because ObjectBuilder.SetBytes heap-copies the payload before
Finish copies it into the message.
The byte echo stays as `floor`, named for what it is: a lower bound on
transport cost that no number may be attributed to. A fasthttp arm joins
it, because fasthttp's object model is cheaper than net/http's on any
wire and a ZAP-vs-net/http ratio otherwise moves for two reasons at once.
Also here:
- zaphttp.NewTransport is gone; cmd/zapbench dials with Dial("tcp", addr)
- the handler moved net/http -> fasthttp and MarshalRequest changed shape
- zap-proto/http comes from v0.3.9 instead of ../http, so one clone and
./run.sh is the whole reproduction; zap-proto/go moves 1.1.0 -> 1.8.3
- TestWireBytes names the direction of its comparison. It used to print
a ZAP frame 86% larger than HTTP's as "0.54x (-86.0% smaller)"
- run.sh regenerates bench-results.txt, records the machine and its load
before and after, and repeats the rate-bearing tests three times
Co-authored-by: Hanzo Dev <[email protected]>
225 lines
6.7 KiB
Go
225 lines
6.7 KiB
Go
// Command zapbench is a bombardier-style load generator for the ZAP binary
|
||
// transport — the load tool HTTP benchmarkers (bombardier, hey, wrk) can't be,
|
||
// because they speak HTTP text and ZAP is a binary frame protocol. It drives a
|
||
// zap-proto/http server with N warm keep-alive connections for a fixed
|
||
// duration and reports req/sec + latency percentiles + client-side allocs/req,
|
||
// so ZAP is measured apples-to-apples with an HTTP tool pointed at the same
|
||
// handler.
|
||
//
|
||
// Design mirrors bombardier: fixed connection count, warm-up window excluded
|
||
// from the measurement, per-connection reused request/response (the ZAP
|
||
// transport's Do is allocation-free after warm-up, so the loop is too), and a
|
||
// merged latency distribution for percentiles.
|
||
//
|
||
// zapbench -addr 192.168.77.2:8391 -c 125 -d 10s
|
||
// zapbench -addr 127.0.0.1:8391 -path /v1/chat -m POST -body @payload.json -c 64 -d 10s
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/valyala/fasthttp"
|
||
zaphttp "github.com/zap-proto/http"
|
||
)
|
||
|
||
func main() {
|
||
addr := flag.String("addr", "127.0.0.1:8391", "ZAP server host:port")
|
||
path := flag.String("path", "/health", "request path")
|
||
method := flag.String("m", "GET", "HTTP method")
|
||
bodyArg := flag.String("body", "", "request body, or @file to read from a file")
|
||
conns := flag.Int("c", 125, "number of warm keep-alive connections (concurrency)")
|
||
dur := flag.Duration("d", 10*time.Second, "measurement duration")
|
||
warmup := flag.Duration("warmup", 1*time.Second, "warm-up duration (excluded from results)")
|
||
maxprocs := flag.Int("gomaxprocs", 0, "GOMAXPROCS override (0 = leave as is)")
|
||
flag.Parse()
|
||
|
||
if *maxprocs > 0 {
|
||
runtime.GOMAXPROCS(*maxprocs)
|
||
}
|
||
|
||
body, err := loadBody(*bodyArg)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "zapbench:", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
r := run(*addr, *method, *path, body, *conns, *warmup, *dur)
|
||
r.print(*addr, *conns, *dur, *method, *path, len(body))
|
||
}
|
||
|
||
// result is the merged outcome of a run.
|
||
type result struct {
|
||
ops int64
|
||
errs int64
|
||
elapsed time.Duration
|
||
lat []time.Duration // merged, unsorted
|
||
allocsPR float64 // client allocs/req during the measured window
|
||
bytesPR float64 // client bytes/req during the measured window
|
||
}
|
||
|
||
func run(addr, method, path string, body []byte, conns int, warmup, dur time.Duration) result {
|
||
// Phase 1: warm up. Each worker opens its own connection (its own hot conn,
|
||
// no shared-pool mutex — a shared pool would serialize N goroutines and
|
||
// dominate the tail) and loops for the warm-up window, so by measurement
|
||
// every conn is established, buffers are grown, and the codec is hot.
|
||
// Warm-up op counts also size each worker's latency buffer so the measured
|
||
// loop never grows a slice.
|
||
type worker struct {
|
||
t *zaphttp.Transport
|
||
req *fasthttp.Request
|
||
res *fasthttp.Response
|
||
lat []time.Duration
|
||
ops int64
|
||
err int64
|
||
}
|
||
workers := make([]*worker, conns)
|
||
|
||
var wg sync.WaitGroup
|
||
warmDeadline := time.Now().Add(warmup)
|
||
for i := 0; i < conns; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
t := zaphttp.Dial("tcp", addr)
|
||
t.SetMaxIdleConns(2)
|
||
t.SetReadTimeout(30 * time.Second)
|
||
req := fasthttp.AcquireRequest()
|
||
res := fasthttp.AcquireResponse()
|
||
req.Header.SetMethod(method)
|
||
req.SetRequestURI(path)
|
||
req.Header.SetHost(addr)
|
||
if len(body) > 0 {
|
||
req.SetBody(body)
|
||
}
|
||
var ops int64
|
||
for time.Now().Before(warmDeadline) {
|
||
if err := t.Do(req, res); err == nil {
|
||
ops++
|
||
}
|
||
}
|
||
workers[i] = &worker{t: t, req: req, res: res, ops: ops}
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
// Size each worker's latency buffer from its warm-up rate (× 1.3 headroom)
|
||
// so appends during measurement don't allocate.
|
||
warmSecs := warmup.Seconds()
|
||
if warmSecs <= 0 {
|
||
warmSecs = 1
|
||
}
|
||
for _, w := range workers {
|
||
est := int(float64(w.ops)/warmSecs*dur.Seconds()*1.3) + 1024
|
||
w.lat = make([]time.Duration, 0, est)
|
||
}
|
||
|
||
// Phase 2: measure. Snapshot MemStats around the window so client allocs/req
|
||
// is reported (should be ~0 with the zero-alloc codec + reused conn buffers).
|
||
runtime.GC()
|
||
var msBefore, msAfter runtime.MemStats
|
||
runtime.ReadMemStats(&msBefore)
|
||
|
||
var totalOps, totalErrs int64
|
||
start := time.Now()
|
||
deadline := start.Add(dur)
|
||
wg.Add(conns)
|
||
for i := 0; i < conns; i++ {
|
||
go func(w *worker) {
|
||
defer wg.Done()
|
||
var ops, errs int64
|
||
for time.Now().Before(deadline) {
|
||
t0 := time.Now()
|
||
if err := w.t.Do(w.req, w.res); err != nil {
|
||
errs++
|
||
continue
|
||
}
|
||
w.lat = append(w.lat, time.Since(t0))
|
||
ops++
|
||
}
|
||
w.ops, w.err = ops, errs
|
||
atomic.AddInt64(&totalOps, ops)
|
||
atomic.AddInt64(&totalErrs, errs)
|
||
}(workers[i])
|
||
}
|
||
wg.Wait()
|
||
elapsed := time.Since(start)
|
||
runtime.ReadMemStats(&msAfter)
|
||
|
||
// Merge latencies and release conns.
|
||
var all []time.Duration
|
||
for _, w := range workers {
|
||
all = append(all, w.lat...)
|
||
w.t.CloseIdleConnections()
|
||
fasthttp.ReleaseRequest(w.req)
|
||
fasthttp.ReleaseResponse(w.res)
|
||
}
|
||
|
||
res := result{ops: totalOps, errs: totalErrs, elapsed: elapsed, lat: all}
|
||
if totalOps > 0 {
|
||
res.allocsPR = float64(msAfter.Mallocs-msBefore.Mallocs) / float64(totalOps)
|
||
res.bytesPR = float64(msAfter.TotalAlloc-msBefore.TotalAlloc) / float64(totalOps)
|
||
}
|
||
return res
|
||
}
|
||
|
||
func (r result) print(addr string, conns int, dur time.Duration, method, path string, bodyLen int) {
|
||
sort.Slice(r.lat, func(i, j int) bool { return r.lat[i] < r.lat[j] })
|
||
pct := func(p float64) time.Duration {
|
||
if len(r.lat) == 0 {
|
||
return 0
|
||
}
|
||
i := int(float64(len(r.lat)) * p)
|
||
if i >= len(r.lat) {
|
||
i = len(r.lat) - 1
|
||
}
|
||
return r.lat[i]
|
||
}
|
||
var sum time.Duration
|
||
for _, d := range r.lat {
|
||
sum += d
|
||
}
|
||
var mean time.Duration
|
||
if len(r.lat) > 0 {
|
||
mean = sum / time.Duration(len(r.lat))
|
||
}
|
||
|
||
fmt.Printf("ZAP %s %s addr=%s c=%d d=%s\n", method, path, addr, conns, r.elapsed.Round(time.Millisecond))
|
||
if bodyLen > 0 {
|
||
fmt.Printf(" Body %d B\n", bodyLen)
|
||
}
|
||
fmt.Printf(" Reqs/sec %.0f\n", float64(r.ops)/r.elapsed.Seconds())
|
||
fmt.Printf(" Requests %d (errors %d)\n", r.ops, r.errs)
|
||
fmt.Printf(" Latency avg=%s p50=%s p90=%s p99=%s max=%s\n",
|
||
mean.Round(time.Microsecond), pct(0.50).Round(time.Microsecond),
|
||
pct(0.90).Round(time.Microsecond), pct(0.99).Round(time.Microsecond),
|
||
durMax(r.lat).Round(time.Microsecond))
|
||
fmt.Printf(" Client %.2f allocs/req %.0f B/req\n", r.allocsPR, r.bytesPR)
|
||
}
|
||
|
||
func durMax(sorted []time.Duration) time.Duration {
|
||
if len(sorted) == 0 {
|
||
return 0
|
||
}
|
||
return sorted[len(sorted)-1]
|
||
}
|
||
|
||
// loadBody returns the request body: empty, a literal string, or the contents
|
||
// of a file when the argument starts with '@'.
|
||
func loadBody(arg string) ([]byte, error) {
|
||
if arg == "" {
|
||
return nil, nil
|
||
}
|
||
if strings.HasPrefix(arg, "@") {
|
||
return os.ReadFile(arg[1:])
|
||
}
|
||
return []byte(arg), nil
|
||
}
|