← Posts

Moving work off the hot path

September 24, 2026

Part of Naoto Exchange

In Naoto the hot path is everything between an order arriving at a gateway and the matching engine being done with it. Time spent there shows up directly in someone's latency. The cold path is the rest, work that still has to happen but that no order is waiting on at that moment.

Before writing any code I went through every action an order sets off and asked whether it really had to happen on the hot path. Answering that took two angles at once. There's the context the action lives in: what it's for, which other service it involves, and what actually breaks if it happens earlier or on another thread. And there's what the hardware does with it, whether it's a syscall, an allocation, a network round trip, or a cache line that has to cross from one core to another.

Most of the work could move, in one of two directions. Earlier, to compile time, startup or login, before the order even exists. Or sideways, onto another thread that runs next to the hot one and never makes it wait. What's left on the hot path is five short steps.

MOVED EARLIEREACH ORDERCOMPILE TIMEpool, queue andbatch sizesrecords per packet,from the MTUCRTP loops, novirtual callsSTARTUPevery pooledobject allocatedthreads pinnedto their coresengines found inetcd, connectedLOGINbalance snapshot,fetched oncereplayed up tothe last reportHOT PATH1read into a pooled batchepoll thread2copy state, check, reserverisk thread3stamp id, send, queue ack4match against the bookengine thread5push report pointersMOVED SIDEWAYSother threads,running fromstartup onwardstates writerapplies fills and snapshots2etcd watcherconnects engines, swaps fds3ack sendersends confirmations3DPDK emitterpacks reports into packets5
Work moved earlier, before any order exists, or sideways, onto threads the order never waits on.

Moving it earlier

The easiest moves are to compile time. Every pool, queue and batch is sized there, in one config header, and so is the number of records in each multicast packet, which is derived from the MTU. The shared loops (the epoll server, the queue consumer, the multicast emitter and receiver) are CRTP templates constrained with C++20 concepts, so which handler runs is decided by the compiler instead of by a virtual call on every message.

Startup takes what can't be decided at compile time but only needs doing once. Every pool is filled, so an order is read off the socket into a batch that already exists and goes back to the pool when the risk thread is done with it, and nothing on the path allocates. Threads are pinned to their own cores. The gateway also looks up every matching engine registered in etcd and connects to it then, so the first order for an asset doesn't pay for a TCP handshake.

The account service, asked once per login

The biggest move is to login. The account service owns every balance, so the straightforward design asks it about every order: check the funds, reserve them, wait for the answer. That's a network round trip per order, and the account service's latency, and whether it happens to be up, becomes part of everyone's order latency.

In Naoto the gateway asks once, when the client logs in. It forwards the credentials and gets back a snapshot of the client's positions, tagged with the sequence number of the last trade report it reflects. From then on the gateway keeps that state current on its own, from the same multicast stream of trade reports the account service reads.

ASK ON EVERY ORDERASK ONCE, AT LOGINclientgatewayaccount svcclientgatewayaccount svcorderreserve fundsevery order waits a round triploginsnapshot + seqreplaytrade reports, multicastorderorders only wait on the gateway
The account service answers once per session instead of once per order, and never sees the order path again.

The snapshot is usually a little behind by the time it arrives, which is fine, because the gateway keeps the recent reports and replays the ones after that sequence number onto it (replaying a stale snapshot). This one was mostly a question of context. The balance has to be right, but nothing requires the account service to be the one answering on every order, as long as only one gateway can ever spend a given client's balance (one writer for every piece of state).

The same reasoning keeps the risk check from waiting on the engine: it reserves funds the moment it accepts an order instead of waiting for a confirmation (reliable risk checking).

Moving it sideways

Some work has to happen for every order, just not while the order waits on it. That work runs on other threads, and the hot thread's side of each handoff is pushing a pointer onto a single-producer single-consumer queue.

The ack to the client is the simplest case. The risk thread doesn't call send on the client's socket, it queues the confirmation and moves on to the next order, and a separate sender thread makes the syscall. The engine does the same with what it produces: the matching thread fills a pooled trade report and pushes its pointer, and the DPDK emitter packs reports into multicast packets on another core. Discovery goes the other way. When an engine joins or leaves, an etcd watcher thread opens the new connection and swaps it in as a versioned fd, so all the risk thread does about it per order is one atomic load.

Applying fills to client state is where the hardware angle mattered most. Handing work to a second thread only helps if the two don't end up fighting over the same memory, because a cache line written on one core and read on another has to travel between them, and a line both of them write keeps bouncing back and forth. So the states writer is the only thread that writes client state. It writes into one of three copies and publishes it with a single store to an index (triple buffering), and the risk thread only reads. The reservations the risk thread makes live in memory no other thread touches, so they never leave its core (a thread-local counter).

states writerCOLDtrade reports inrisk threadHOTreservationsONE CLIENT'S STATE, THREE COPIES320 B each = 5 cache linesbeing writtenpreviouspublishedidxfillsrelease storecopyacquire load
Client state only flows from the writer's core to the risk thread's. The risk thread writes nothing the writer reads.

Client state is indexed by the client's socket fd, so finding it is an array offset rather than a hash lookup, and each copy is 64-byte aligned and 320 bytes long: five cache lines, copied once per order.

What stays

The five steps left are the order itself, so they can't move anywhere. They got made cheap where they are instead.

The order book is the biggest of them. It answers two questions constantly, whether a price level exists and which price is best, and no single structure is good at both, so it keeps a hand-written SIMD hash map and a skip list side by side (one order book, two data structures, and the SIMD key-value store under it). Stamping the order id is a shift and an increment, with no id service to ask (order ids without an id service).

The transport is picked per link. Client orders come in on edge-triggered epoll, read straight into pooled batches. When a gateway and an engine share a machine, orders cross in a shared-memory SPSC queue instead of a socket. Trade reports and book updates go out through DPDK as fixed-size packed structs, so a receiver never parses a length.

Measuring it

P99 is 600 ns in matching and 1.1 µs to gateway ack at 40k msg/s. Each order and report carries four timestamps: ingested by the gateway, routed, received by the engine, emitted as an update. Those are tracked at min, P50, mean, P95, P99 and max, so a regression shows up as a specific segment getting slower rather than one number drifting.