host scan · Xeon E5-2698 v4 · 20 cores · isolated 10–19 · 125 GB

Latency matrix: flavour × transport × acceptor model

Every cell is a FIX round trip measured on the wire between two network namespaces. Every percentile and max is the worst reported value across all sessions.

sessions 32 (multi) · 1 (single) build tuned, -march=broadwell units µs recipe v3 · open · 50 000/s aggregate

Temporary SCAN NIC change (2026-09-08): the Mellanox-only v4 campaign uses Mellanox for new kernel, VMA and SocketXtreme results; Onload/TCPDirect are paused while the Solarflare DAC is removed. Earlier kernel results used Solarflare, including all historical v3 rows. From 2026-09-10 the host runs net.core.busy_read=50 / busy_poll=50 (declared in the machine file; a sitting refuses otherwise), so kernel rows measured from then busy-poll on every scenario. See the campaign note and completed-cell list to identify remeasured v4 rows. The archived C++ kernel and VMA pool results completed at 19:27 and 19:29 UTC carry an additional live CPU-contention qualification: their accept coordinator shared worker 0's core during all three measured phases. They are not clean-pinning comparisons; the latency impact is not quantified. The replacement kernel/VMA pool results at 20:24:51/20:27:20 UTC verify the separate core-19 coordinator, but retain their measured instability qualifications. The rerun's live watcher recorded no placement violation.

Historical v3 archive—not the current results. Open the current shared-epoch v4 matrix.

Applies to Target throughput and the historical diagnostic tables. Every measured cell shows p50, p99, p99.9 and max for the selected latency. Service is actual client start to completed reply; response starts at the scheduled slot and includes scheduler delay. Missing values show —. All four acceptor models stay visible; highlighting compares the selected metric among visible, qualified results.

Temporary values — do not compare flavours on them. Cells marked smoke are behaviour probes, not steady-state latency measurements. The default smoke recipe is 200 warmup / 200 measured / one run, with one session for single and four for mono, pool and shard; the table headings describe the full 32-session recipe.

Use full-recipe measurements for latency comparisons: 20 000 warmup before each run, 20 000 measured, three runs, with the stability and parity gates satisfied.

Maximum throughput

not measured Highest sustained completed request/reply rate across the whole cell, established by an open-load rate sweep. Report the passing/failing rate bracket and response latency at the passing rate. This measures the configured client/server system, not an isolated engine limit.

No validated maximum-throughput sweep is available yet. The historical closed-loop test allows only one outstanding request per session and is not maximum throughput. Existing target-rate observations are not maximum-throughput results either. Candidate-run instructions and remaining validation work.

Historical diagnostics · reply-gated RTT and pacing

These are separate experiments, retained for investigation rather than the two main throughput views. Their rates are not a backend-capacity measurement.

Historical RTT baseline · unpaced, one outstanding request per sessionService latency (µs)
Flavour Transport 1 · Single1 acceptor · 1 session 2 · Mono1 acceptor thread · 32 sessions 3 · PoolThread pool · 32 sessions 4 · ShardSharded acceptors · 32 sessions
Acceptor models measured against a 4-thread client pool — java-pure / kernel, 32 sessions Server threads on cores 10–13, client pool on 15–18, disjoint. Offered load 2000/session (64k/s aggregate), the highest rate the client sustains without backlog. Not comparable to the primary fixed-open table above — a different experiment, not a different day.
Acceptor modelp50p99p99.9maxsessions
thread-per-session (32 thr, 1 core) 2 020 9033 879 632 3 971 2635 287 85632/32
1 multiplexer 219.401706.36 2179.624752.5832/32
4-thread pool (least-loaded) 100.971534.98 7064.2610 050.5232/32

What one number means

A cell is the round-trip time of a FIX NewOrderSingle → ExecutionReport, measured on the wire between two network namespaces on one host. The clock starts at the client's scheduled send, not its actual send, so a client that falls behind is charged for the lateness instead of hiding it.

The parameters, and where they live

All of it is in one versioned file, load-bench-standards.conf, read by the wrapper, by every harness and by the checker. The common recipe is deliberately not per-scenario: the columns are comparable only if the work per message is identical in each.

commonvalue
warmup20 000, discarded
iterations20 000 measured
offered throughput50 000/s aggregate · fixed-rate open
jvm heap192 MB
scenarionos_er
per scenariosessionsacceptorsthreadsruns
1 single1113
2 mono32113
3 pool32143
4 shard3241 each3

The publication harness passes the complete recipe explicitly; bare client or launcher invocations are diagnostics, not substitutes for the harness. Every result carries bench_params (the argument vector), bench_profile (what the server configured) and bench_standards (the defaults in force, with a checksum of the file that supplied them). check-bench-params.sh refuses a table whose cells disagree, or whose profile contradicts the declared expectation.

Pinning policy is machine-independent — cores isolated, server and client sets disjoint, server from the front of the isolated set and clients from the back. The core numbers live in a per-host file, because a standard carrying one box's core list cannot be run on another, and a matrix that cannot be reproduced elsewhere is a single data point.

The four scenarios

1 single
one session, one acceptor. The engine floor, with no concurrency in it. 3 runs.
2 mono
32 sessions, one acceptor on one core. Readiness from poll(2) (or zf_muxer on TCPDirect), drained from a single poll snapshot before re-polling.
3 pool
32 sessions, one acceptor, M threads. Each session is bound to one thread for life, assigned least-loaded at accept.
4 shard
32 sessions, M independent acceptors sharing one port through SO_REUSEPORT, each on its own core, share-nothing. The kernel decides where a connection lands — there is no assignment policy. The standard uses four acceptors; M=1 is a separate diagnostic control.

3 and 4 are not the same thing. The pool and shard columns are separate across all flavours; unsupported native-stack combinations remain explicitly marked no pool / no shard.

When a cell counts

  • All 32 sessions must complete. Where fewer did, the count is printed beside the cell rather than quietly taking the median of whoever connected.
  • Every percentile and max is the worst reported value across all sessions, so one bad session cannot be averaged away.
  • A value shown as ≥ is a saturated clamp, not a reading — latency_u32() tops out at 232−1 ns.
  • Absolute numbers drift between sittings. Retained baselines and newer sweep cells may use separate artifact revisions; consult the handbook's sitting tables and source/artifact manifests before comparing them.

Core pinning

Both sides busy-poll, so a core carrying a server thread and a client starves both. Placement is therefore stated per scenario, not derived at run time.

scenarioserver coresclient cores
1 · single1015
2 · mono1011-13,15-19
3 · pool10-1315-18
4 · shard10-1315-19

isolated: 10-19 · VMA maintenance (reserved, no app threads): 14 · housekeeping (never measured on): 0-9 · Pool coordinator (separate from workers): 19

Server cores come from the front of the isolated set and client cores from the back, so widening the server — a pool, or more shards — eats into the middle and the two sets stay disjoint by construction rather than by arithmetic that has to be got right each time.

Unpinned is not neutral. isolcpus removes the isolated cores from every process's default affinity mask, so a thread that is never pinned does not land on a random isolated core — it lands on the housekeeping cores beside every interrupt, while the cores reserved for measurement sit idle. The JNI and .NET pool workers shipped that way for an hour; it would have read as "the pool is slow".

Not every shared core is contention. java-pure pins its accept loop and its multiplexer to the same core, and the accept loop blocks in accept() — it burns nothing. pinning-audit.sh --live samples each thread's CPU twice and reports a collision only when more than one of them actually ran.

Not every flavour can run every scenario

flavour1 single2 mono3 pool4 shard
cppyesyesyes*yes
java-pureyesyesyes*yes*
java-jniyesyesyes*yes
dotnet-nativeyesyesyes*yes
dotnet-pureyesyes*†yes*yes
rust-pureyesyesyesyes

*newly capable, not yet measured. †dotnet-pure's mono-thread cells served a thread per session — it had no multiplexed mode until now — so they are not comparable with the rest of that column. Thread-per-session with cores to spare pays no multiplexing cost, so it reads as fast rather than as different: its kernel cell sat at 26.14µs against a single-session 25.61 for exactly that reason. java-pure's mono cells are multiplexed (p99 207, not the 81 002 of its thread-per-session default); it simply had to be asked, and now the standards ask.

Three flavours now have a true pool. C++ gained FixAcceptorThreadPool — one listener, M workers, least-loaded at accept, verified serving 8/8 sessions at 2 per worker — and dotnet-pure gained the equivalent in its bench server.

java-jni reuses the very same serve pass the single-acceptor path runs, with accept disabled, so there is one serve implementation rather than two that drift; verified serving 8/8 sessions at p50 25.79µs.

rust-pure is the FIX engine written in Rust: its own client and server, no hftnet. rust-native, the Rust client driving the C++ engine through the hftnet C ABI, was parked on 2026-09-11: its server was cpp's, so its multi-session cells re-measured the C++ server. All six now pool on kernel TCP, Onload and VMA interposition. dotnet-native uses the native FixAcceptorThreadPool, with per-worker connection ids, status, errors and event rings projected through the existing managed session. Stack listener pools (TCPDirect and SocketXtreme) remain unavailable.

TCPDirect has no reuse-port listener API. SocketXtreme accepts independent SO_REUSEPORT binds, but four process pollers disconnect every client during Logon because they cannot safely own the shared completion stream. Both native-stack shard groups are marked no shard, not measured as one acceptor under a shard label.

Where the cells are not equivalent

Latency comparisons only mean something if the cells do the same work. They do not, and it is invisible in the numbers. All four end up persisting nothing, by four different mechanisms at four different costs:

flavouracceptorstoreaudit
cppFixAcceptorabsent (no template parameter)absent
cpp (--acceptor-impl runner)FixSessionRunnerAcceptornull, compile-timepresent, off
java-jniFixSessionRunnerAcceptorselected at runtime from configpresent, off
dotnet-nativeFixSessionRunnerAcceptorbranches on a null pointer per callpresent, off
dotnet-pureC# FixConnectionseparate implementation—

All flavours now declare this at runtime. Each server prints a bench_profile line stating the acceptor shape, store, audit, schedule, TLS and thread count it actually configured, so the table above is verified rather than read off the source, and check-bench-params.sh asserts it against the declared expectation.

It earns its keep. Wiring java-pure's multiplexer, its thread count was defaulted from the pool scenario's, quietly turning the mono-thread cells into a 4-thread pool. Nothing in the latency showed it; the declaration read threads=4 under a heading that says mono-thread, and that is how it was caught.

best qualified p50 for this model and selected latency > 100 µs > 1 ms — tail failure measuring run in flight not wired capability exists, no bench arm no pool / no shard transport cannot implement that shape n/a not applicable

Historical investigations. These cards retain observations from earlier recipes and topologies, not the current OPEN v4 measurements or general transport-capacity conclusions. Current capabilities and ownership limits, client placement, and repaired-provider evidence supersede the older present-tense descriptions below. Historical values are not borrowed into current cells.

Historical: Java-pure thread-per-session contention

An earlier harness ran 32 busy-polling client threads on one acceptor core, making the tail a scheduler-timeslice measurement. Switching that run to a multiplexed acceptor reduced kernel p99 from 81 002 to 207 µs and p99.9 from 112 001 to 1 819 µs. These are historical observations, not the current matrix's recipe or measurements.

The current load harness uses --multiplexed for Java-pure mono, bounded multiplexer workers for pool, and multiplexed shards for shard. The old 32-threads-on-one-core diagnosis does not describe the current full-recipe v4 captures; each retains its own delivery, stability and placement qualifications.

TCPDirect after the muxer

cpp/tcpdirect was median 78.77 µs at 17.6× spread with 30/32 completing. Giving the acceptor a real readiness signal via zf_muxer took it to 7.02 µs, 1.0×, 32/32. Raising rx_ring_max 8× first moved it 78.77 → 79.61 µs, which is how the buffer explanation was ruled out.

Every bypass row is native on both ends

A row labelled tcpdirect now runs TCPDirect at the acceptor as well as the initiator. Three cells previously paired a bypass client with an Onload, VMA-preload or plain-TCP acceptor, so the bypass acceptor was never on the measured path.

Historical Java/VMA results before offload verification

A plain Java ping-pong with no engine in it: kernel 17.18 µs, onload 4.08 µs (4.2×), vma 17.74 µs (−3%). A 150-line PoC then varied the one thing the engine cannot — NIO against blocking sockets, which take different syscall paths:

NIO 13.82 → 15.66 under VMA (13% worse); blocking 21.37 → 21.54 (1% worse). VMA_THREAD_MODE 0/1/3 changes nothing (14.30 / 14.32 / 15.32). VMA works on this box — C++ goes 22.18 → 6.77, a 3.3× gain on the same NIC — and the same JVM takes 4.2× from Onload. Those historical tests did not establish per-socket offload and do not show that VMA cannot accelerate Java. A later baseline exposed IPv4-mapped IPv6 sockets taking the kernel fallback. After explicit IPv4 selection and ownership repairs, identity-checked VMA statistics confirmed increasing RX/TX offload at all 64 endpoints. The old timings remain historical observations; socket-family evidence was not captured for each of those earlier PoC runs.

The C++ acceptor is not the reason — measured, not assumed

C++ is the fastest flavour at one session and among the slowest at 32. The obvious explanation was that the C++ bench drives CoreAcceptor (483 lines) while java-jni and dotnet-native drive fix::SocketAcceptor (1169), and only the second has had the concurrency work. That explanation was published here, then tested by building the missing arm (--acceptor-impl runner) and running both acceptors against the same clients on the same cores, alternating.

It is wrong. Medians are within noise (76.6 against 79.1 over three reps, the runner winning one), and at p99 the runner is worse in all three — 114.5 against 90.1, about 27%. The C++ row is already measured through the better of the two C++ acceptors, so switching it would make the cell look worse, not better. Whatever costs C++ its concurrency headroom is elsewhere.

Measured over three sittings through the same driver, the degradation from 1 to 32 sessions is cpp +14.9µs, java-jni +10.9, dotnet-native +9.6 — consistent in direction every time. The absolute ordering is not: run-to-run spread on these cells is 6–10µs against gaps of 3–7, so cpp came out slowest, then middle, then slowest again. Read the degradation, not the ranking.

A second caveat from the same investigation: the shard harnesses at M=1 measure about twice the latency of this driver on the same cell (cpp 65.95 against 37.21, java-jni 48.74 against 39.33). M=1 controls are therefore valid against their own M=2 cells and must not be read against this table.

A mono-thread cell that was three times too slow

dotnet-native/tcpdirect read 17.87µs mono-thread against 5.05 for a single session — a 3.5× penalty for concurrency that no other TCPDirect row shows. Re-measured at M=1 by the shard harness it reads 5.05 / 7.61 / 11.13, identical to the single-session cell, with all 32 sessions completing. The old figure was wrong, not the transport.

It had stood unquestioned because it was the only number in its row and had no p99 beside it to contradict it. That is the argument for measuring M=1 alongside M=2 everywhere: the control is what makes a cell falsifiable.

TCPDirect shard results were a topology false positive

The old C++, JNI and .NET Native TCPDirect shard rows launched one listener with a reuse-port flag. That is still one acceptor, so session completion did not prove sharding. A direct two-listener probe returns EADDRINUSE from the second zftl_listen: TCPDirect exposes no equivalent of SO_REUSEPORT. Those rows were withdrawn; the current capability contract still marks them no shard. SocketXtreme also remains out: its listeners bind, but independent pollers disconnect clients while competing for the shared completion stream.

TCPDirect's max is a clamp, not a reading

This cell's max was left blank because 4 294 967.29µs looked like corruption. It is not: latency_u32() saturates at 0xffffffff nanoseconds, so the value means at least 4.29 seconds — a real sample, reported at the only bound the type can express. That historical diagnostic treated it as a lower bound, not an exact maximum. It is not a current numeric latency-at-load claim from a complete, unclamped capture.

M=1 and M=2 agree closely (7.04 against 7.02 at p50) and both complete 30 of 32 with the same clamped max, so neither the two missing sessions nor the multi-second sample has anything to do with sharding. Both belong to the TCPDirect arm at this session count, and neither is yet explained.

Historical VMA reuse-port connection failures

Four historical cells were reported over fewer sessions than were offered, and the number that connected is the finding rather than a footnote to it: java-jni/vma 21 of 32, dotnet-native/socketxtreme 12 of 32 at M=2 and 19 of 32 at M=1, and dotnet-pure/vma 26 of 32. The failures are Connection refused or a disconnect during warmup, so the sessions are absent rather than slow, and the quoted percentiles describe only the ones that got in.

dotnet-pure/vma isolates it cleanly, because its M=1 control is healthy: 32/32 at p50 12.90, p99 15.04. Add a second acceptor sharing the port and it becomes 26/32 with p99 28 ms and p99.9 100 ms. One acceptor is fine; two on one port is not. Three flavours, three bindings, one shape — VMA runs its own TCP stack, and SO_REUSEPORT balancing is a kernel facility that its offloaded listeners have no reason to honour between processes.

M=1 fails too, so this is not sharding: a single acceptor is not a shard. It is the VMA-backed acceptor path not sustaining this session count, which the kernel, Onload and TCPDirect arms all do at 32/32 on the same rig in the same sitting. Worth noting that the C++ SocketXtreme acceptor manages 32/32 — so this is specific to the provider bindings, not to SocketXtreme itself.

Always measure M=1 with the same harness

The .NET Native multi-thread cells were nearly published as a regression. Kernel at M=2 reads p50 48.30µs against a mono-thread neighbour of 27.27, which looks like sharding making things worse. Running M=1 — not a shard, so a control — through the same harness gives 51.07. Sharding is a small improvement (51.07 → 48.30); the gap to 27.27 is between two harnesses, not between one and two acceptors.

The two harnesses agree closely where the transport is fast — onload 6.14 against 5.89, vma 6.69 against 6.67 — and disagree only on kernel, the slowest arm, where clients hold their cores longest and the difference in how many cores they are given bites hardest. A cross-harness number is worth what its control says it is worth, and nothing more.

Historical JNI/VMA multi-process shard failures

The retired java-jni driver reached this shape as M acceptor processes sharing one listening port. Kernel and Onload take it cleanly — 32/32 sessions each, Onload at p50 7.27µs. VMA completes only 21 of 32, and the stragglers are not slow but absent, with a worst sample of 3.08 seconds. VMA implements its own TCP stack, and SO_REUSEPORT load balancing is a kernel facility; there is no reason to assume it is honoured across processes that each hold their own offloaded listener. The historical report included the numbers the 21 that connected produced, and the completion count stated, rather than a clean median that hides a third of the sessions never arriving.

The current JNI/VMA shard implementation instead keeps M listeners and M pinned native serve threads inside one JVM. The old 21/32 result does not describe that topology; neither it nor the old reuse-port hypothesis establishes the cause of a new failure.

All three were measured in one sitting with identical parameters, verified by check-bench-params.sh. The kernel control read 27.50 here against the 23.95 already in the column, so this column carries roughly 15% of harness spread between the two shard drivers — enough to compare transports within it, not enough to read a 15% difference as real.

SocketXtreme shard completion was a topology false positive

Adding --zerocopy fixed the original client startup failure, but the replacement harness still launched one listener with a reuse-port flag. Its 32/32 completion proved a healthy mono acceptor, not sharding. A real four-listener probe gets all four binds and all four affinity records, then disconnects every client during Logon: independent SocketXtreme pollers cannot safely own the shared completion stream. The native-backed SocketXtreme shard cells now read no shard until a multi-listener completion dispatcher exists.

The pool wins once the client stops moving

The multi-thread column in the table above is confounded: clients and server shared ten isolated cores, so every core given to an acceptor thread was taken from 32 client JVMs. Replacing them with one client process driving all 32 sessions from a 4-thread pool fixes that, and reverses the result — the server pool now beats a single multiplexer 2.2× on the median (100.97 against 219.40) and matches it at p99. The earlier "pooling loses" reading was an artefact of the measurement, not a property of the design.

Two caveats stated rather than buried. Offered load had to drop to 2000/session: four client threads cannot sustain 320k/s, and at that rate every arm reads in whole seconds of backlog. And thread-per-session collapses entirely here — 32 spinning threads on one core against a client that no longer matches them one-for-one — which is a different failure from the one the table above records for it, and is not yet explained.

The multi-thread column is confounded on this box

Clients and server share ten isolated cores, so every core given to an acceptor thread is taken from the client processes. The pure-Java pool shows it plainly: one multiplexer with 9 client cores reaches p99 207 µs; four threads with 5 client cores reaches 1 689. That is not the acceptor model, it is 32 client JVMs on five cores. The same wall appears in java-jni (M=4 wins the median and loses p99.9 by 350×) and in the earlier placement work.

These rows are honest measurements of this historical configuration and should not be read as a verdict on pooling. The current same-host recipe instead uses disjoint client/server cores and one client process per client core, multiplexing its local sessions.

Correction. An earlier version of this page said the rows above no longer reproduced and blamed the machine. That was wrong. They reproduce exactly — p50 26.78 published against 26.78 / 26.22 / 26.08 re-measured — once the published recipe is used. The failed re-measurement had been run at 5000/5000 paced to 10000/s instead of unpaced at 20000/20000, where the path goes cold between messages and the same code reads 31.7. A code regression, machine drift and background load were each advanced and believed before the recipe was checked. bench-run now stamps every result with its parameters and check-bench-params.sh refuses a matrix whose cells disagree.

Sharding helps only the kernel, and costs tail everywhere

Four shards against one, both at 32 sessions. kernel p50 33.52 → 23.50 (a real 30% gain, since the syscall-bound path saturates a core) but p99.9 47.50 → 947. onload 5.92 → 7.53 and vma 6.80 → 11.35, both worse throughout. The bypass transports had headroom on one core, so extra shards only add contention. A pool is an ordinary-socket tool, not a general user-space-stack one.

The shard defect that hid all this

Every shard bound the port twice: Start() calls Listen() itself, and the shard called Listen() first. With SO_REUSEPORT both binds succeed, so each shard left an orphaned listener nothing ever accepted from while the kernel hashed a share of connections to it — M=2 lost 14 of 32, M=4 lost 15, the half you would predict from twice as many listeners as acceptors. Removing the redundant Listen() gave 32/32 in the retested configuration, not proof that unsupported transport/model combinations work.

The multi-thread column is capacity, not a fix

The gate for building shards — a 32-session median above 2× the same transport's single-session p50 — is closed on all five transports (1.03×–1.60×). One thread is not the bottleneck at this session count.