Benchmarks¶
Two harnesses, measuring two different things:
# What an application sees, through the Python reference client.
python scripts/benchmark_transports.py --rounds 5 --iterations 300
# What the server can do, through a native Rust client.
cargo run --release --manifest-path bench/rust-client/Cargo.toml -- \
--worker build-release/conformance/conformance_worker --rounds 5 --iterations 300
Benchmark a release build. The default CMake preset is Debug, which is
about half the speed and understates everything on this page — plausibly
enough that it took the C++ server losing to the Python reference server to
notice. benchmark_transports.py now prefers build-release/ over build/
for exactly that reason.
Method¶
Rounds are interleaved across transports rather than run back to back. A benchmark that measures each transport once, in sequence, compares whatever the machine happened to be doing at the time as much as it compares transports.
Headline figures are the best sample, not the median. Interference on a shared machine is one-sided — it can only make a sample slower — so the minimum is the closest available estimate of the real cost. The median table is kept alongside it to show how much the machine added.
Measured on macOS 15.6 / Apple Silicon, 8 CPUs, load average near 4 — a
working laptop, not a quiet bench. The two harness runs below are back to back
against the same build-release worker, so they can be compared with each
other; absolute numbers will move with the machine, and the ratios between
columns are the point.
Through the Python reference client¶
Encode, transport, C++ dispatch, decode. That is the number an application
sees, and it is what the Python reference's own docs/benchmarks.md reports,
so the two can be read side by side. It is not the C++ server's ceiling —
a large share of each figure is the client, as the next section shows.
Unary round trip¶
best, ms — lower is better
| pipe | unix | tcp | http | pipe+shm | |
|---|---|---|---|---|---|
void_noop() |
0.022 | 0.025 | 0.051 | 0.272 | 0.023 |
add_floats(a,b) |
0.034 | 0.040 | 0.064 | 0.298 | 0.036 |
echo_string(11B) |
0.032 | 0.037 | 0.060 | 0.279 | 0.033 |
echo_all_types() |
0.251 | 0.257 | 0.285 | 0.549 | 0.252 |
Median, to show the spread the machine added:
| pipe | unix | tcp | http | pipe+shm | |
|---|---|---|---|---|---|
void_noop() |
0.025 | 0.030 | 0.076 | 0.304 | 0.025 |
add_floats(a,b) |
0.037 | 0.043 | 0.072 | 0.325 | 0.040 |
echo_string(11B) |
0.035 | 0.040 | 0.070 | 0.321 | 0.036 |
echo_all_types() |
0.260 | 0.264 | 0.304 | 0.587 | 0.261 |
Unary rate¶
calls/sec — higher is better
| pipe | unix | tcp | http | pipe+shm | |
|---|---|---|---|---|---|
void_noop() |
45,627 | 39,409 | 19,624 | 3,672 | 44,199 |
add_floats(a,b) |
29,484 | 24,845 | 15,717 | 3,351 | 27,875 |
echo_string(11B) |
31,704 | 27,335 | 16,552 | 3,589 | 30,456 |
echo_all_types() |
3,991 | 3,893 | 3,506 | 1,821 | 3,964 |
void_noop() is the per-call framing floor — no parameters, no result.
echo_all_types() carries eighteen fields covering every type mapping, so the
gap between the two is encode/decode cost rather than transport cost. That the
transports converge as the payload grows (46k → 4.0k on pipe, 3.7k → 1.8k on
HTTP) says most of the per-call spread is fixed framing overhead.
Streaming¶
best, ms — lower is better
| pipe | unix | tcp | http | pipe+shm | |
|---|---|---|---|---|---|
produce_n(50) |
0.70 | 1.12 | 1.47 | 15.83 | 1.04 |
exchange_scale(20) |
0.35 | 0.57 | 0.82 | 6.67 | 0.39 |
HTTP pays worst here, and for a structural reason: the raw-framing transports hold one connection open for the whole stream, while HTTP maps every producer turn and every exchange onto a separate request/response pair.
Payload throughput¶
MB/s, both directions, at best time — higher is better
| pipe | unix | tcp | http | pipe+shm | |
|---|---|---|---|---|---|
| 1 KiB | 58 | 50 | 28 | 6 | 56 |
| 64 KiB | 2,373 | 2,506 | 1,508 | 344 | 2,222 |
| 1 MiB | 6,773 | 12,069 | 7,423 | 1,522 | 9,086 |
| 16 MiB | 3,573 | 5,467 | 5,160 | 1,727 | 5,243 |
The 1 KiB row is latency-bound, not bandwidth-bound — it is really just
1 / round-trip wearing different units. Shared memory does not engage below
VGI_RPC_SHM_MIN_BATCH_BYTES (128 KiB), which is why its 1 KiB and 64 KiB
figures track the plain pipe.
Through a native client¶
A Python client can only measure a server down to its own cost per call.
Driving the same worker from Rust — reusing vgi-rpc-client from the sibling
vgi-rpc-rust checkout, see
bench/rust-client/ — moves the floor by 2x to 6x:
void_noop(), calls/sec |
Python client | Rust client | |
|---|---|---|---|
| pipe | 45,627 | 91,954 | 2.0x |
| unix | 39,409 | 100,412 | 2.5x |
| tcp | 19,624 | 37,441 | 1.9x |
| http | 3,672 | 21,409 | 5.8x |
HTTP is where the difference is starkest: the Python table reads as though this server costs 0.27 ms per HTTP call, and most of that is the client's HTTP stack. The server's own figure is nearer 47 us.
| MB/s round trip | pipe | unix | tcp | http | pipe+shm |
|---|---|---|---|---|---|
| 1 KiB | 104 | 93 | 57 | 28 | 119 |
| 64 KiB | 3,783 | 4,438 | 2,648 | 905 | 4,038 |
| 1 MiB | 10,025 | 18,597 | 12,807 | 1,854 | 12,324 |
| 16 MiB | 3,543 | 5,369 | 5,164 | 1,589 | 5,569 |
Both harnesses now agree on the ordering: at 1 MiB and 16 MiB a Unix socket is the fastest transport here, ahead of the pipe and — at 1 MiB, by half again — ahead of the shared-memory channel. That is a recent change, and the next section is how it happened.
Everything still peaks near 1 MiB and falls away at 16 MiB, on both harnesses and every transport. That is the cache boundary rather than anything in the protocol: a megabyte of Arrow still fits, sixteen does not, so the larger payload is bounded by main memory no matter how it is carried.
The socket buffer, and how it hid twice¶
macOS defaults a Unix domain socket to 8,192 bytes of send and receive buffer
(net.local.stream.sendspace), against ~64 KiB for a pipe and 128 KiB for
TCP. A megabyte of Arrow therefore crossed the kernel in 128 trips instead of
a handful, and the Unix transport looked like the slowest raw-framing option
on the page.
The fix is one setsockopt pair. It had to be applied three times before it
was actually applied, because an AF_UNIX write is bounded by space in the
receiver's buffer — so any untuned end caps the whole connection:
- The C++ server's accepted socket. Bought +46% at 64 KiB, +79% at 1 MiB, +41% at 16 MiB. A convincing result, and the reason to stop looking.
- The Python reference client. With the server already tuned, this moved the unix/pipe throughput ratio from 0.54 to 1.71 at 1 MiB — a Unix socket went from costing half the pipe's throughput to beating it outright.
- The Rust client (
vgi-rpc-client'sUnixTransport), which was then the only untuned end left, and whoseunixcolumn had to be labelled a floor rather than a measurement until it was fixed:
| Rust client, MB/s | before | after | |
|---|---|---|---|
| 64 KiB | 2,613 | 4,438 | 1.7x |
| 1 MiB | 4,994 | 18,597 | 3.7x |
| 16 MiB | 2,373 | 5,369 | 2.3x |
The pipe control column moved 3% between those two runs, so that is the change and not the laptop.
Ratios against a control column rather than absolute numbers throughout, because the machine drifts further between runs than most of these effects are worth: an early absolute A/B of step 1 moved the pipe column — which the change could not touch — by 26%, and was measuring the laptop.
TCP deliberately does not get the same treatment. It already starts at
128 KiB and grows, an explicit SO_RCVBUF disables Linux's receive-window
auto-tuning and pins the window at whatever constant we guessed, and measuring
it on loopback showed no gain either way. A setting that helps one socket
family is not a setting that helps sockets.
TCP does get TCP_NODELAY, and it costs about 14% here. Nagle coalesces
small writes, which is precisely wrong for request/response: it holds a reply
waiting for more to send, and against a peer's delayed ACK that becomes a
tens-of-milliseconds stall. On loopback that stall never arrives — ACKs are
instant — while the coalescing still saves per-segment work, so the
measurement says the setting is a loss. It is kept because the stall it
prevents needs a real network, and a real network is what the transport is
for. A deployment using TCP only between co-located processes would be faster
without it.
Where shared memory earns its keep¶
Less than it looked like, now that it is being compared against something tuned.
Against the plain pipe the channel is a clear win above its 128 KiB threshold: +34% at 1 MiB and +47% at 16 MiB through the Python client, +23% and +57% through the Rust one.
Against a properly buffered Unix socket it is not. Both harnesses now put a 1 MiB Unix socket ahead of pipe+shm at 1 MiB — by 33% in Python, by 51% in Rust — and score them within about 4% of each other at 16 MiB. Much of what looked like a shared-memory win was the pipe's 64 KiB buffer and the socket's 8 KiB one; a copy the kernel makes in one trip is cheaper than the machinery for avoiding it.
That is not the end of the question, because the comparison is still
unbalanced: shared memory is a side channel that rides any of the raw-framing
transports, and every shm figure here rides the pipe. unix+shm is the
configuration that would settle it, and nothing has measured it — the Rust
client's shm_connect spawns a subprocess and has no way to attach a segment
to an existing Unix connection.
Its history is worth keeping, though, because two real bugs came out of it. The first measurements had shared memory roughly 40% behind the pipe at 1 MiB, from two independent causes:
-
The client never freed a resolved region. Forty calls left forty live allocations — against the Python reference worker exactly as against this one, so it was the reference client's unary path, not this port. A stream hands its batch to the caller, who owns the release; a unary batch never escapes the client, so nobody else could free it. Two consequences: a long session walked toward the segment's 4094-allocation ceiling, and because no region was ever reused, every call faulted in cold pages. Fixed upstream in
_read_unary_response. -
This port copied the batch twice.
allocate_and_writeserialized into a heap buffer and then copied that into the segment, because a region has to be reserved before the exact byte count is known. It now sizes the reservation fromarrow::ipc::GetRecordBatchSize— which reads the buffer layout rather than serializing — plus a measured schema message, and lets Arrow write straight into the mapping. The sink is hard-bounded: an estimate that came up short would otherwise write past the reservation and corrupt whatever was allocated next, so it fails the write and the batch goes inline instead.