The measurement lanes lived in hanzoai/cloud/bench, which meant cloning the thing being measured to run anything — including lanes that measure no binary at all. They are here now, at the root, beside the load and scale harnesses that were already here: eleven lanes, MEASURED.md for what each found, naive.md for what competitors publish and where it was read. Four lanes still need the cloud source, because they measure a binary rather than a library. `cloud_src` in host.sh finds it — $CLOUD_SRC, then ../cloud — or prints how to get one. It does not clone: a benchmark that fetches its own subject decides which revision you measured. AND IT SAYS WHICH SOURCE IT FOUND. hanzoai/cloud and hanzo-inc/cloud are different repositories that both check out as `cloud`, and self/modules.sh reads 127 modules on one and 55 on the other. Neither is wrong about the tree it was handed; a lane that does not name its subject is. It prints path, remote and commit now, the way every timing prints its host. Fixed on the way: egress reached for bench/egress/catcher.py and transport for ./bench/transport/harness, both paths in the repository they left. The harness builds from this module now — its only dependency is zap-proto/zip — and market/bench.mjs and brain/tables.mjs lost a directory level each. Verified from the new root: fleet reproduces 454.6 MB and 477 bytes per agent, and self/modules.sh reproduces 127 of 127 when pointed at the tree the figure came from. Co-authored-by: Hanzo Dev <[email protected]>
16 KiB
bench
One lane per claim a competitor makes about us, and two for the claims they cannot make: that you can have the software, and what it costs to ask it for something.
Every number below was taken between 2026-09-07 and 2026-09-11, and every script here re-runs to produce it. Where we lose, the row says so — a benchmark suite that only contains wins is a brochure.
Say which machine. "An M-series laptop" covered an M1 Max with 10 cores and
an M4 Max with 16, and the goroutine lane reads about twice as fast on the
second — 452 ns to spawn against 187, 347 ns to wake against 114, while the
memory figure moved by five bytes. A timing without its host is not reproducible
and not comparable, including against itself next month. Every lane that
measures time prints its host now, from bench/host.sh, and the tables below
carry it.
Timings are the median of the pass; memory is deterministic and has no range on a quiet machine, which is a condition this line did not used to carry.
On a host at load average 3, the goroutine lane reads 573.0 MB and 601 bytes per agent on every run — twelve for twelve, byte-identical, across three separately built binaries. On a second host at load 17 to 73 the same lane read 594, 595, 596 and 599 bytes. Two things differ between those readings and this pass cannot separate them: the machines are not the same machine, and only one of them was busy. The cross-machine part is already known — an M1 Max and an M4 Max differ by about five bytes here — so the honest statement is that determinism has been observed under quiet conditions and variation of about 1% under load, cause unresolved.
Take memory figures from an idle host, and say which one.
This is the same pass the paper cites, and it carries the ranges:
hanzoai/papers hanzo-dormant-agents. One set of numbers, one run behind
them — if you re-run and get different figures, update both.
bench/self/run.sh # build it, boot it, ask each transport: can you have this
bench/self/modules.sh # and whether anyone else can fetch what it is built from
bench/egress/run.sh # and whether having it means anyone hears about it
bench/cipher/run.sh # write a value through the API, look for it on the disk
bench/transport/run.sh # what an agent pays per call, per envelope
node fleet/fleet.mjs /tmp/fleet # 1M dormant agents: bytes, write rate, resume
node fleet/cost.mjs # the same, as a monthly bill
cd goroutine && go build -o /tmp/g . && /tmp/g # agent as goroutine; goja, gpython, wasm
node sandbox/sandbox.mjs # cold start: V8 context, isolate, hanzo-vm
node pricing/pricing.mjs # what a call costs, and what it could sell for
node bench/platform.mjs runs the five lanes above that measure the software
itself and writes bench/benchmarks-platform.json — the same numbers, as data,
for anything that renders them. Each lane emits its own rows, so a rerun that
changes a number changes the file, and a lane that fails leaves its section out
rather than a stale one in.
What was measured
Ownership — the row a hosted competitor has no way to fill
self/run.sh builds the binary from this repository, starts it, asks it what it
serves, and reaches one operation through every transport it opens.
| measured | |
|---|---|
| private modules required | 0 |
| modules the binary needs, fetchable with no credential | 127 of 127 |
| this module, published as | v1.801.572 — the first semver release the repo has carried |
| build from source | 13 s |
| binary | 80 MB |
| boot to a healthy answer | 1.2 s |
| operations served by default | 13 over 8 paths |
The second row is the load-bearing one. Counting private paths in go.mod is a
property of a file; asking the public Go proxy for every module the binary
actually needs, with no credential, is a property of whether anyone else can
build it. All 127 answer, and the run ends by asking for a module that does not
exist — 404, or the sweep above would not be evidence.
Everything else here measures what the software does; this measures whether you can have it.
Privacy — nothing in the default path phones anyone, including us
egress/run.sh starts the binary with no configuration, asks every transport for an
operation sixty times, and watches what it connects to throughout.
| measured | |
|---|---|
| attempts to leave | 0 |
| peers off this machine | 0 |
Two observers, because one of them can miss. A listener standing in for the
internet takes HTTP_PROXY, so every attempt is recorded with what it asked for
and timing cannot defeat it. lsof sampling watches the sockets directly and
can miss — the run reports how much it missed of traffic it generated
itself, which on this machine is all of it, so that row is corroboration and
never the proof. Both end pointed at something that does reach out, and the lane
fails if either reports nothing.
The claim is narrow and checkable: nothing in the default path phones home. A deployment that enables a subsystem with an upstream connects to it, on purpose.
At rest — the value is not in the file
cipher/run.sh writes a value nothing else could have written through two
operations, stops the server, and searches every file under the data directory.
The same binary runs twice.
| build, given a master key | result |
|---|---|
CGO_ENABLED=0 |
7 distinct headers · canary in 0 files |
CGO_ENABLED=1, as built |
refuses, and names the recipe |
CGO_ENABLED=1 -tags libsqlite3 |
7 distinct headers · canary in 0 files |
control: CLOUD_DEV_UNENCRYPTED=1 |
1 header, SQLite format 3 · canary in 1 file |
The refusal is a result. cgo is on by default on macOS and that build links ordinary SQLite, so given a key it stops rather than writing a plaintext store and reporting success — which is what it did before the check existed.
Each store gets its own data encryption key, so seven files begin with seven different ciphertexts; a plaintext run writes the same magic seven times.
The right-hand column is not a competitor. It is the proof the left-hand one measures anything: same binary, same writes, same search, on a store that is deliberately not encrypted. Without it, a zero could mean the write never reached the disk or that the search does not read binary files — and the second of those happened while the lane was being written, which is why the lane fails if the control ever reads zero.
The per-call tax — same handler, three envelopes
An agent's loop is call, read, decide, call again, so what the envelope costs is
multiplied by every step of every task. transport/run.sh asks the same operation
over each transport, interleaved, n=200.
Three samples of each phase, n=200 interleaved, Apple M1 Max:
| transport | min | p50 | p50 against REST |
|---|---|---|---|
| ZAP unix | 0.07–0.09 ms | 0.14–0.18 ms | 0.67–0.75× |
| ZAP tcp | 0.08–0.12 ms | 0.19–0.24 ms | 0.86–0.90× |
| REST | 0.10–0.13 ms | 0.21–0.28 ms | 1.00 |
| MCP | 0.10–0.14 ms | 0.21–0.25 ms | 0.96–1.09× |
| op-call plane | 0.09–0.12 ms | 0.19–0.23 ms | 1.00× |
ZAP is the fastest transport and the socket is faster than the port — two thirds of REST's median over a unix socket, nine tenths over loopback TCP, ahead of every HTTP transport in every sample. Among the three HTTP transports the differences are inside the noise and they trade places between runs.
A process serves one ZAP address, so the socket is a second phase rather than a fifth row. The three HTTP transports are measured again in that phase to carry the drift between them, and the ratio to REST is the column that survives it. All of it is under a third of a millisecond at the median, so the practical reading is still to pick the transport that fits the caller.
The harness is one Go program calling all four round-robin. The previous table
was measured from Python and read 0.22–0.31 ms at min where the same transports now
read 0.09–0.15 — that difference was the client, silently in every row, and it
had to go before a fourth transport could be added that no script can speak.
Fleet residency — we win, decisively
| Hanzo, measured | Naïve, published | |
|---|---|---|
| state per dormant agent | 477 bytes | ~1 MB (their assumption) |
| 1M agents on disk | 455 MB | ~977 GB |
| storage at list | $0.01/mo | $22/mo |
| write | 309,789 agents/s | — |
| resume | 0.034 ms in-process | — |
Their row is marked "MODELLED, NEVER BILLED." Ours is a file you can ls.
2,198× is the distance between an assumption and a measurement.
Sandbox — the primitives, measured on the runtime we actually ship
| measured here | attributed elsewhere | |
|---|---|---|
| V8 context | 0.13 ms | — |
| V8 isolate, isolated-vm 7.0.1 | 0.35 ms, 1.00 MiB each | 2.79 ms, 1.2 MB |
| hanzo-vm, cold boot | 309 ms (p95 335) | E2B <200 ms · Modal ~1 s · Cloudflare 1–3 s |
| hanzo-vm, from checkpoint | 311 ms (p95 343) | Morph <250 ms resume · E2B ~1 s from pause |
The container rows were docker, and nothing here runs docker. The lane shelled
out to docker run and docker exec on machines that do not have it — the
builds run buildkit inside a microVM, which is why hanzo-vm checkpoint list
has a buildkit entry — so both rows printed "unavailable" while this table
carried numbers anyway. They now measure hanzo-vm, which is what a sandbox on
this stack actually is.
That moves us out of the container column and into the microVM one, and we do not win it. 309 ms is slower than E2B's published <200 ms Firecracker cold start. Their figure is uncontrolled — their hardware, their harness — which is the same objection this file raises when the uncontrolled comparison runs the other way, so it is not a defence, just the reason neither number settles it.
A checkpoint saves the disk, not the boot. Starting from a 403 MB checkpoint
costs 311 ms against a cold boot's 309 ms: no difference. --from hands a VM the
filesystem an earlier run left behind and the kernel boots either way. Morph's
"<250 ms" and E2B's "~1 s from pause" are MEMORY snapshots, a mechanism hanzo-vm
does not have — so that row has no counterpart of ours, and pairing it with this
one would claim the feature by borrowing its name.
The isolate row used to be the context row. A vm.createContext makes a
fresh global inside the isolate already running; it is cheap because it shares
the heap, which is exactly what an isolate does not do. Reporting 0.15 ms
against a published 2.79 ms and calling it 17× was comparing two different
primitives in our favour. Running the library the number is attributed to, on
this laptop, an isolate costs 0.35 ms — under the figure cited at us by 8.0×,
and a measurement rather than an argument. It read 0.59 ms here once; five
consecutive runs give 0.35 ms p50 with a p95 of 0.41, and the first run of a
session reads high for the same reason the goroutine lane now discards a round.
The megabyte is V8's, not a vendor's. A fresh isolate's heap measures 1.00 MiB here, which is close to the 1.2 MB cited — because it is what any V8 isolate costs, including one of ours. It is not a competitor's weakness.
An isolate runs JavaScript: no pytest, no pip, no cargo, no shell — which is what a coding agent was asked to do. A microVM runs all of it behind a kernel boundary and costs three orders of magnitude more to start. The rows are different primitives and the comparison is per workload, not per millisecond.
Agent as goroutine — the execution half of the fleet claim
A dormant agent is a row. An agent that is running still has to hold its place, and that is a goroutine rather than a container.
| measured here | attributed elsewhere | |
|---|---|---|
| per live agent | 601 bytes of heap | isolated-vm: 1.2 MB — and 1.00 MiB measured |
| spawn | 125 ns | 2.79 ms — and 0.35 ms measured |
| wake 1M | 107 ms (107 ns each) | — |
| wazero (WASM) | 7.8 µs instantiate, 20 ns call | — |
| goja (JavaScript) | 2.4 µs per VM, 740 ns warm eval | — |
| gpython (Python) | 25.0 µs per context | — |
601 bytes against a 128 MB container floor is ~223,000×. Against a V8 isolate it is ~1,745× — and that one is a goroutine against a JavaScript VM, so read it as what each primitive costs rather than as one beating the other. Memory was identical on every run; the timings are medians of eight.
The spawn row was 187 ns and is 125 ns, because the old one timed the program
starting. A built binary's FIRST run reads 202, 210 and 201 ns on three fresh
builds and 123, 125 and 123 ns on the next — the same 200,000 goroutines in a 41
ms window rather than a 25 ms one, so it is one fixed cost per binary (text-segment
paging, the kernel's first-exec signature check), not a cost per goroutine. The
lane now discards a warm-up round and names it. This is the neighbour of the note
that used to sit here — benchmark a built binary, under go run the compile is
counted, which reads 253 ns — and that note stopped one step short. The
correction favours us, which is why the method is printed beside it. See
goroutine/README.md.
WASM is not one language: CPython, QuickJS for TypeScript, Rust and Go all target it. A V8 isolate is JavaScript only.
Pricing — compute is not where the difference is
Our costs from railway.com/pricing; their prices from usenaive.ai/pricing,
read 2026-09-11 and recorded verbatim in naive.md; volumes measured
above.
- one call ≈ 51 ms of 0.5 vCPU and 0.25 GB
- that slice at cost: $0.00000025 · at their published rates: $0.00000042
- so their compute carries a 1.68× markup — thin, and not a multiple
Both axes, because ours has both: their $0.0504/vCPU-hr against our vCPU plus memory plus egress would be the same mistake in the other direction. Egress is out of both sides — they publish no price for it and ours contributes four ten-thousandths of a cent.
This row has been wrong twice. It first said they charge $0.05 a credit and called that 79,614× marginal; no per-credit price is published, and $0.0504 is an hour of a vCPU, so reading it as a call overstated them by the ratio of an hour to a call. The correction then compared their compute price against our all-in marginal cost and reported 1.1×, which was the same error smaller. 1.68× is the two rates on the same slice. Both corrections went against us.
Where their published prices do carry a markup is the metered tools —
$0.002158 for one web search — and the model tokens, which every stack bills
and which dominate both columns.
- at $0.005 a call, 1B calls/month returns $3.7M after giving 25% to open source, three availability zones, twelve months of retained history, and twice the infrastructure bill in people
Which numbers are whose. The 25% is not this lane's to set: hanzoai/commerce
declares the OSS developer payout in config/oss-payout.json and ossattr caps
it at 0.25 in code, so raising it anywhere has no effect. What Hanzo charges
lives in commerce's PriceSet and PricingRule; the ladder above is not a price
list but a question about what a price would have to clear. Railway's rails and
naïve's rates are third-party price lists and stay here beside their citations —
a competitor's published prices in our own billing service would be a category
error, not a single source of truth.
The line that grows is not agents, it is history: a dormant agent stays 477 bytes, its transcript does not. At 2 KB retained per call, a billion calls a month is 22 TB after a year, and that eventually exceeds compute.
Excluded, and it dominates all of the above: model tokens. A turn that thinks costs 100–1000× the infrastructure under it. Any per-call price sits beside token billing rather than pretending to include it — which is what Naïve do when they keep LLM routing out of credits.
Method notes
fleet.mjs pipes SQL on stdin rather than argv: a 50,000-row insert is 20 MB
and argv tops out well before that.
sandbox.mjs needs a container runtime for two of its four rows — colima start is enough on macOS — and npm i isolated-vm in bench/sandbox/ for the
isolate row. Each row says so and is skipped rather than guessed when its
dependency is absent.
self/run.sh and transport/run.sh build and start the binary themselves and need
nothing fetched. transport/run.sh measures round-robin rather than transport by transport:
measured in blocks the three rows disagreed about which transport was fastest on
every run, because drift between phases on a busy machine is larger than the
difference being measured.