mirror of
https://github.com/hanzoai/papers.git
synced 2026-10-22 17:20:17 +00:00
Every author block and the authorship standard said Los Angeles; Hanzo has been in San Francisco since at least 2016. The five catalogue PDFs whose sources changed are rebuilt so papers.hanzo.ai says so too. Co-authored-by: Hanzo Dev <[email protected]>
344 lines
18 KiB
TeX
344 lines
18 KiB
TeX
\documentclass[11pt,twocolumn]{article}
|
|
\usepackage{shared/hanzocover}
|
|
\usepackage[utf8]{inputenc}
|
|
\usepackage{amsmath,amssymb}
|
|
\usepackage{graphicx}
|
|
\usepackage{booktabs}
|
|
\usepackage{hyperref}
|
|
\usepackage{xcolor}
|
|
\usepackage{listings}
|
|
\input{shared/lstlang}
|
|
|
|
\title{The Hanzo Cloud Data Plane: Benchmarking \texttt{zip}, ZAP, and the
|
|
Road to a Million Requests per Second}
|
|
|
|
\author{
|
|
Hanzo AI Research\thanks{Corresponding author: [email protected]} \\
|
|
\textit{Hanzo Industries Inc (Techstars '17)} \\
|
|
San Francisco, California \\
|
|
\texttt{https://hanzo.ai}
|
|
}
|
|
|
|
\date{2026}
|
|
|
|
\begin{document}
|
|
|
|
\hanzocoverpage
|
|
|
|
\begin{abstract}
|
|
We present a reproducible benchmark of the Hanzo cloud data plane --- the
|
|
\texttt{zip} web framework and its native ZAP binary transport --- and a
|
|
systems-tuning study on a two-node 2.5\,GbE testbed (an NVIDIA GB10 ``spark''
|
|
server, 20 cores, aarch64; a 32-core x86\_64 ``evo'' load generator). We
|
|
establish that \texttt{zip} carries a negligible tax over the \texttt{fasthttp}
|
|
engine it wraps ($\approx$24\,ns and one 48-byte allocation per request;
|
|
$0.95\times$ raw throughput on a no-op handler). We then show that the naive
|
|
distributed result --- 194{,}000 req/s over the wire --- is not a CPU or
|
|
bandwidth limit but a \emph{single hardware RX-queue} on the 2.5\,GbE NIC, whose
|
|
driver refuses hardware multi-queue. Enabling Receive Packet Steering (RPS) plus
|
|
a deeper backlog and larger socket buffers lifts the same link to
|
|
\textbf{518{,}000 req/s (a $2.67\times$ gain)}, at which point the tuned network
|
|
result exceeds loopback because the server's cores no longer contend with the
|
|
load generator. Finally we characterize the ZAP transport. It initially ran
|
|
\emph{slower} than HTTP (91k vs.\ 481k req/s loopback), which we localize by
|
|
construction to a non-zero-allocation codec --- three allocation sites, the
|
|
largest an un-pooled request context, driving 2203 garbage collections in six
|
|
seconds (relaxing \texttt{GOGC} $100\to800$ alone more than doubles throughput).
|
|
Pooling the context and making the marshalers zero-allocation --- a pure codec
|
|
rewrite with a byte-identical wire --- lifts ZAP $6.3\times$ to \textbf{570k
|
|
req/s, overtaking HTTP by $1.18\times$}, with GC falling $2203\to62$. We show
|
|
this is a CPU/allocation win, not a wire win on trivial payloads (the ZAP frame
|
|
is \emph{larger} there: 124 vs.\ 75 bytes), and that over the saturated 2.5\,GbE
|
|
link both transports tie at $\approx$530k. Separately, on the serialization axis
|
|
--- native typed RPC vs.\ HTTP+JSON on a shared record --- ZAP \emph{decodes}
|
|
$35$--$174\times$ faster at zero allocations (13\,ns vs.\ 473--2334\,ns), a wash
|
|
on encode, netting $1.1$--$1.3\times$ end-to-end once the socket dominates. We
|
|
release the framework as \texttt{hanzoai/benchmarks} and the tuning as a single
|
|
idempotent script.
|
|
\end{abstract}
|
|
|
|
\section{Introduction}
|
|
The Hanzo control plane ships as a single Go binary that mounts every subsystem
|
|
(AI gateway, commerce, IAM, KMS) and serves \texttt{api.hanzo.ai}~\cite{hip0106}.
|
|
Its HTTP surface is \texttt{zip}~\cite{zip}, a framework built on a fork of
|
|
\texttt{fiber}~/ \texttt{fasthttp}, and its inter-subsystem calls travel over
|
|
\emph{ZAP}, a typed binary transport that reuses \texttt{fasthttp} request and
|
|
response objects over a length-framed wire~\cite{zaphttp}. Two questions follow
|
|
naturally: \emph{(i)} how much does the framework cost over the bare engine, and
|
|
\emph{(ii)} where is the real ceiling of the data plane on modern hardware ---
|
|
is it the framework, the CPU, the network stack, or the link?
|
|
|
|
Benchmarking answers only when it is fair. We hold the handler, the payload, the
|
|
concurrency, and the tuning identical across every measurement; we report
|
|
allocations, not just throughput; and we separate the \emph{load generator's}
|
|
limits from the \emph{server's}. All artifacts, scripts, and raw numbers are
|
|
public in \texttt{hanzoai/benchmarks}.
|
|
|
|
\section{Methodology}
|
|
\paragraph{Testbed.} \emph{spark} is an NVIDIA GB10 (Grace--Blackwell) developer
|
|
system: 20 aarch64 cores, running the server. \emph{evo} is a 32-core x86\_64
|
|
box, running the load generator. They are joined by a direct 2.5\,GbE wired link
|
|
(\texttt{enP7s7}$\leftrightarrow$\texttt{eno1}, both \texttt{Speed: 2500Mb/s},
|
|
subnet 192.168.77.0/24) --- confirmed with \texttt{ethtool}, distinct from the
|
|
WiFi path used for control SSH. A second host, \emph{dbc}, is wired on a 1\,GbE
|
|
USB-ethernet port for future dual-loader aggregation.
|
|
|
|
\paragraph{Framework.} \texttt{hanzoai/benchmarks} is two-tier. The
|
|
\emph{property/micro} tier (\texttt{cloud/shard}, \texttt{framework/zip}) is pure
|
|
Go, deterministic, and cluster-free, so it runs in CI and isolates a primitive
|
|
(routing ns/op) or proves an invariant. The \emph{integration} tier
|
|
(\texttt{k3s/}, \texttt{distributed.sh}) stands the real binary up and drives it
|
|
over the network. Load is generated with \texttt{bombardier} (a
|
|
\texttt{fasthttp}-based client that actually saturates the server; \texttt{hey},
|
|
a \texttt{net/http} client, caps near 100k and is unsuitable) and, for ZAP, with
|
|
a purpose-built client (\S\ref{sec:zap}) since no HTTP tool speaks the protocol.
|
|
|
|
\paragraph{Fairness.} Every server exposes the identical handler --- \texttt{GET
|
|
/health} returning the two-byte body \texttt{"ok"}. Connections are warm
|
|
(keep-alive) so we measure steady-state, not handshakes. We report the median of
|
|
sustained throughput and the p50/p99 latency; where garbage collection is
|
|
implicated we report allocations per operation from \texttt{-benchmem}.
|
|
|
|
\section{The Framework Tax: \texttt{zip} vs.\ \texttt{fasthttp}}
|
|
The in-process cost of \texttt{zip} over raw \texttt{fiber} is a constant
|
|
$\approx$24\,ns and exactly one 48-byte allocation (the \texttt{*zip.Ctx}),
|
|
independent of route shape~\cite{zipbench}; on a handler doing $\sim$3500\,ns of
|
|
real work it is under 1\%. End to end, driven by \texttt{bombardier} on loopback
|
|
(Table~\ref{tab:framework}), \texttt{zip} sustains $0.95\times$ of raw
|
|
\texttt{fasthttp} --- the wrapper is noise.
|
|
|
|
\begin{table}[t]
|
|
\centering
|
|
\caption{End-to-end HTTP, loopback, spark, \texttt{-c 125}. Same handler.}
|
|
\label{tab:framework}
|
|
\begin{tabular}{lrr}
|
|
\toprule
|
|
framework & req/s & vs.\ fasthttp \\
|
|
\midrule
|
|
raw \texttt{fasthttp} & 365--400k & $1.00\times$ \\
|
|
\texttt{zip} (HTTP) & 338--348k & $0.95\times$ \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\end{table}
|
|
|
|
\section{The Network Ceiling Is Not the Box}
|
|
\label{sec:ceiling}
|
|
Moving the load off-box \emph{lowered} throughput --- and adding connections made
|
|
it worse (Table~\ref{tab:ceiling}). Throughput fell from 194k at \texttt{-c 1000}
|
|
to 154k at \texttt{-c 8000} while p99 latency rose $5\to52$\,ms: classic
|
|
congestion collapse. Yet the link ran at $\sim$70\,MB/s, only 22\% of the
|
|
2.5\,GbE bandwidth, and spark's cores sat mostly idle. By Little's law the
|
|
194k/5.1\,ms operating point is latency-bound, not bandwidth-bound.
|
|
|
|
The root cause is structural: \texttt{ethtool -l} reports the NIC configured with
|
|
a \emph{single hardware RX queue}, and the driver rejects reconfiguration
|
|
(\texttt{ethtool -L}: ``Operation not supported''). All inbound packets are
|
|
therefore serviced by one core's softirq, and that single core is the ceiling.
|
|
|
|
\begin{table}[t]
|
|
\centering
|
|
\caption{Untuned evo$\to$spark over 2.5\,GbE. Congestion collapse.}
|
|
\label{tab:ceiling}
|
|
\begin{tabular}{lrr}
|
|
\toprule
|
|
loader $\to$ target & req/s & p99 latency \\
|
|
\midrule
|
|
loopback (\texttt{-c 500}) & 365k & --- \\
|
|
evo$\to$spark \texttt{-c 1000} & 194k & 5.1\,ms \\
|
|
evo$\to$spark \texttt{-c 4000} & 167k & 24\,ms \\
|
|
evo$\to$spark \texttt{-c 8000} & 154k & 52\,ms \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\end{table}
|
|
|
|
\section{System Tuning: $2.67\times$ on the Same Wire}
|
|
The software remedy for a single-queue NIC is Receive Packet Steering, which
|
|
hashes flows across cores in the kernel. We apply RPS (and RFS/XPS) across all 20
|
|
cores, raise \texttt{net.core.netdev\_max\_backlog} from 1000 to 300000, set
|
|
256\,MiB socket buffers, widen the ephemeral port range, and pin the performance
|
|
governor --- all in one idempotent \texttt{tune.sh}, run on server and loader
|
|
alike. The result (Table~\ref{tab:tuned}) is $2.67\times$: the same 2.5\,GbE link
|
|
now sustains \textbf{518k req/s}, peaking at 696k.
|
|
|
|
\begin{table}[t]
|
|
\centering
|
|
\caption{evo$\to$spark, both ends tuned. Same link, same handler.}
|
|
\label{tab:tuned}
|
|
\begin{tabular}{lrrr}
|
|
\toprule
|
|
concurrency & untuned & tuned & gain \\
|
|
\midrule
|
|
\texttt{-c 1000} & 194k & 518k & $2.67\times$ \\
|
|
\texttt{-c 2000} & collapsed & 518k & --- \\
|
|
\texttt{-c 4000} & 154k & 474k (peak 696k) & $3.1\times$ \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\end{table}
|
|
|
|
Two observations. First, the tuned \emph{network} figure (518k) now exceeds the
|
|
loopback figure (365--400k): over the wire spark's 20 cores serve full-time,
|
|
whereas on loopback they are shared with the load generator. At 518k spark idles
|
|
only $\sim$12\%, so this is close to the true single-loader serving ceiling; the
|
|
remaining headroom is the province of a second, parallel NIC path.
|
|
|
|
\section{The ZAP Transport: A Zero-Alloc Codec Overtakes HTTP}
|
|
\label{sec:zap}
|
|
\texttt{zip} is ZAP-native --- a bare listen address binds the binary transport;
|
|
\texttt{http://} binds \texttt{fasthttp}. Because HTTP tools cannot speak ZAP we
|
|
built \texttt{zapbench}, a concurrent client over \texttt{zaphttp.Transport}. The
|
|
first result inverted the expectation: ZAP delivered only 91k req/s on loopback
|
|
against HTTP's 481k --- ZAP \emph{lost} by $5\times$.
|
|
|
|
The cause was not the wire but the codec. The tail (p50 303\,\textmu s but p99
|
|
13\,ms) is a garbage-collection signature, and relaxing \texttt{GOGC} $100\to800$
|
|
more than doubled ZAP to 203k --- proof the path was allocation-bound. Three
|
|
allocation sites carried it. \texttt{MarshalRequest}/\texttt{MarshalResponse}
|
|
minted a fresh frame slice per call; header decode performed \texttt{string()}
|
|
copies into a \texttt{map[string][]string}; and structurally, \texttt{zaphttp}'s
|
|
server allocated a fresh \texttt{fasthttp.RequestCtx} \emph{per request}
|
|
(\texttt{server.go}), where \texttt{fasthttp}'s own server pools it per
|
|
connection. Over a six-second run the ZAP server triggered \textbf{2203}
|
|
garbage collections; the HTTP server, 57.
|
|
|
|
The fix (\texttt{zap-proto/http}, branch \texttt{perf/zero-alloc-codec}) is
|
|
mechanical, not architectural: pool the \texttt{RequestCtx} once per connection,
|
|
hand-roll a frame encoder that appends into a pooled buffer, and drain the
|
|
4-byte length prefix through \texttt{bufio.Peek}/\texttt{Discard} so no header
|
|
array escapes to the heap. The serve path serializes with
|
|
\texttt{AppendResponse} into that reused buffer --- \textbf{zero} allocations,
|
|
versus 19 for the old fresh-slice \texttt{MarshalResponse} --- and
|
|
\texttt{UnmarshalRequest} decodes headers in place, also zero-alloc; over a warm
|
|
connection the serve loop allocates nothing per request. Golden tests confirm the
|
|
wire is
|
|
\emph{byte-identical} before and after --- this is a pure codec rewrite, no
|
|
protocol change. The measured effect (Table~\ref{tab:zap}): ZAP rises
|
|
$91\text{k}\to570\text{k}$ req/s, a \textbf{6.3$\times$} lift that
|
|
\textbf{overtakes HTTP at 481k --- ZAP wins $1.18\times$} on loopback, with p99
|
|
collapsing $13\,\text{ms}\to1.19\,\text{ms}$ and GC falling $2203\to62$.
|
|
|
|
Two corrections to intuition survive the fix, and both matter for where ZAP is
|
|
actually worth deploying. \emph{First, the win is CPU, not the wire.} Measured by
|
|
\texttt{TestWireBytes}, the ZAP frame is \emph{larger} than HTTP (124 vs.\ 75
|
|
bytes for a \texttt{/health} request); ZAP overtakes HTTP by spending fewer
|
|
cycles per byte, not fewer bytes. \emph{Second, \texttt{zaphttp} does not skip
|
|
JSON.} It carries the HTTP body as opaque bytes on both transports, so a handler
|
|
pays the same (de)serialization cost either way; on a chat-shaped JSON payload
|
|
ZAP is in fact $0.95\times$ HTTP, a slight loss from copying the body into the
|
|
frame tail. JSON-elision is a property of \emph{native} ZAP typed RPC --- a
|
|
separate axis \texttt{zaphttp} does not exercise. The loopback win is therefore
|
|
real and purely a CPU/allocation win; over the 2.5\,GbE link both transports tie
|
|
at $\approx$530k, saturating the wire (\S\ref{sec:ceiling}) where the codec is no
|
|
longer the bottleneck.
|
|
|
|
\begin{table}[t]
|
|
\centering
|
|
\caption{ZAP vs.\ HTTP, loopback, \texttt{-c 125}, same handler. The zero-alloc
|
|
codec flips ZAP from a $5\times$ loss to a $1.18\times$ win.}
|
|
\label{tab:zap}
|
|
\begin{tabular}{lrl}
|
|
\toprule
|
|
transport & req/s & note \\
|
|
\midrule
|
|
\texttt{zip} HTTP (fasthttp) & 481k & zero-alloc; 57 GC \\
|
|
\texttt{zip} ZAP (\texttt{GOGC=100}) & 91k & alloc-bound; 2203 GC \\
|
|
\texttt{zip} ZAP (\texttt{GOGC=800}) & 203k & $2.2\times$; confirms cause \\
|
|
\textbf{\texttt{zip} ZAP zero-alloc} & \textbf{570k} & \textbf{0 allocs/req; 62 GC; $1.18\times$ HTTP} \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\end{table}
|
|
|
|
\section{Skipping JSON: Typed RPC vs.\ HTTP+JSON}
|
|
\label{sec:json}
|
|
Section~\ref{sec:zap} isolates \emph{framing} CPU: \texttt{zaphttp} carries the
|
|
HTTP body opaque, so both transports pay the same JSON cost and ZAP's win is the
|
|
zero-alloc frame codec alone. That leaves ZAP's larger structural claim untested
|
|
--- that a \emph{native} typed binary message is read field-by-field off the wire
|
|
with no parse step, where JSON must scan text and reflect. We test it directly:
|
|
one shared record (an \texttt{eth\_getBalance}-shaped call, four fields each way)
|
|
round-tripped by \texttt{encoding/json} (stdlib), by \texttt{goccy/go-json} and
|
|
\texttt{sonic} (the fastest third-party JSON), and by \texttt{zap-proto/go}'s
|
|
typed builder/reader. Same fields in, same compute, same fields out; a
|
|
round-trip-equivalence test enforces byte-exact re-encoding and that every field
|
|
is consumed on both sides, so neither codec skips work the other does.
|
|
|
|
The decode path is the headline (Table~\ref{tab:serde}). Reading the four-field
|
|
request costs \textbf{13\,ns and zero allocations} in ZAP --- a zero-copy read at
|
|
a fixed offset --- against 473\,ns/4\,allocs for the fastest JSON
|
|
(\texttt{goccy}) and 2334\,ns/13\,allocs for stdlib: a \textbf{35$\times$ to
|
|
174$\times$} gap, and the difference between allocating nothing and allocating
|
|
per field. This is the ``skip JSON'' advantage made concrete --- there is no
|
|
parse, only a pointer and an offset.
|
|
|
|
Two honest counterweights. \emph{Encode is a wash}, not a win: ZAP serializes in
|
|
629\,ns with \emph{more} allocations than JSON (12 vs.\ 4), because the published
|
|
\texttt{v1.3.0} builder defers variable-length tails through a copy and escapes
|
|
its object builder to the heap --- the pooled fixed-width API that removes this is
|
|
not in the released tag, so we report the tag-reproducible number. Netted out, the
|
|
server handler (decode\,+\,compute\,+\,encode) still favors ZAP at 274\,ns vs.\
|
|
\texttt{goccy}'s 431\,ns, because the decode margin dominates the encode wash.
|
|
\emph{And end-to-end the margin compresses}: over the loopback \texttt{/rpc}
|
|
endpoint ZAP serves 535k req/s against \texttt{sonic}'s 483k ($1.11\times$),
|
|
\texttt{goccy}'s 452k ($1.18\times$), and stdlib's 407k ($1.31\times$) --- real,
|
|
but far below the 174$\times$ microbenchmark gap, because at half a million
|
|
requests per second the socket and scheduler dominate and serialization is a
|
|
shrinking slice of per-request cost. Finally, on a structured record ZAP's wire is
|
|
also \emph{smaller} (162 vs.\ 233 bytes total): JSON's hex-string addresses and
|
|
decimal integers are verbose. This refines \S\ref{sec:zap} --- ZAP's frame is
|
|
larger only on trivial (\texttt{/health}-sized) payloads, and smaller once the
|
|
record carries real typed fields.
|
|
|
|
\begin{table}[t]
|
|
\centering
|
|
\caption{Serialization only, one shared 4-field record, GB10, median of 3,
|
|
default \texttt{GOGC}. Decode is where typed-binary skips JSON entirely; the
|
|
round-trip is fastest on ZAP though it allocates more than \texttt{goccy}.}
|
|
\label{tab:serde}
|
|
\begin{tabular}{lrrr}
|
|
\toprule
|
|
codec & decode (ns) & round-trip (ns) & round-trip allocs \\
|
|
\midrule
|
|
JSON stdlib & 2334 & 3063 & 17 \\
|
|
JSON \texttt{sonic} & 845 & 1460 & 14 \\
|
|
JSON \texttt{goccy} & 473 & 904 & \textbf{8} \\
|
|
\textbf{ZAP typed} & \textbf{13.4} & \textbf{630} & 12 \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\end{table}
|
|
|
|
\section{Aside: Horizontal-Scale Routing}
|
|
The same data plane shards per-tenant SQLite state across pods by
|
|
highest-random-weight (rendezvous) election over a static membership set. A
|
|
million synthetic tenants distribute across pods with 0.05\% standard deviation
|
|
at $N{=}3$ (no hot shard), every tenant resolves to exactly one deterministic
|
|
owner (the no-two-writers invariant, verified over $10^5$ tenants $\times$ 6
|
|
re-elections), a $4\to5$ scale-up remaps only 20.1\% of tenants (the rendezvous
|
|
ideal), and the routing itself costs $\approx$545\,ns per request. Horizontal
|
|
writer scale is thus gated on I/O, not routing.
|
|
|
|
\section{Conclusions and Future Work}
|
|
The Hanzo data plane is not framework-bound (\texttt{zip} $\approx$
|
|
\texttt{fasthttp}), and on a single 2.5\,GbE link it serves 518k req/s once the
|
|
kernel's receive path is tuned --- a $2.67\times$ gain from software steering
|
|
alone, with the box only $\sim$12\% idle. The ZAP codec is now
|
|
zero-allocation and overtakes HTTP by $1.18\times$ on loopback (\S\ref{sec:zap}),
|
|
and its native typed RPC decodes structured records $35$--$174\times$ faster than
|
|
JSON at zero allocations (\S\ref{sec:json}) --- a decode advantage that today is
|
|
masked end-to-end by socket overhead and a not-yet-zero-alloc encode path, and
|
|
that a pooled fixed-width builder would surface. The remaining levers to seven
|
|
figures are orthogonal and physical: a \emph{dual-loader} across the two physical
|
|
NICs (parallel RX paths), faster interconnect (10\,GbE/RDMA), and a
|
|
\texttt{writev} split so ZAP's frame tail avoids one body copy on large payloads.
|
|
We also intend to benchmark the
|
|
post-quantum transports natively available in Go 1.26
|
|
(\texttt{X25519MLKEM768}): PQ-TLS 1.3, ZAP-over-PQ-TLS, and PQ-QUIC, separating
|
|
handshake cost (ML-KEM) from steady-state AEAD throughput.
|
|
|
|
\begin{thebibliography}{9}
|
|
\bibitem{hip0106} Hanzo Industries. \textit{HIP-0106: The Unified Cloud Binary}. 2026.
|
|
\bibitem{zip} \texttt{zap-proto/zip} --- the ZAP-native web framework. \url{https://github.com/zap-proto/zip}.
|
|
\bibitem{zaphttp} \texttt{zap-proto/http} --- ZAP-HTTP binary transport. \url{https://github.com/zap-proto/http}.
|
|
\bibitem{zipbench} \texttt{zap-proto/zip} \texttt{BENCHMARKS.md} --- framework wrapper tax.
|
|
\end{thebibliography}
|
|
|
|
\end{document}
|