Last week you learned what the bytes mean. This week you stop paying full price for them. Two costs sit between the wire and your strategy — the syscall that fetches the bytes and the codec that turns them into numbers — and both of them are usually larger than the strategy itself. You will make one thread service many sockets without ever blocking, and you will make a decode that touches only the fields you trade on.
EAGAIN is the normal case rather than an error.select, poll and epoll with the O(n) versus O(ready) argument, and say what edge-triggering obliges you to do.std::to_string with a twenty-line u64toa, and beat a DOM parser with a string_view.include/u64toa.hpp — edges, fuzz, and a ns/op metric
Then: targeted extract on the arena's book_snapshot
Batching buys throughput with the latency you are graded on
A blocking recv() parks your thread in the kernel until bytes arrive. That
is fine for one socket and catastrophic for many: a quiet connection freezes the thread that a busy
connection needs. The fix is to invert the question — instead of “give me bytes from this
socket”, ask “which of my sockets has bytes right now?” and do the
reads yourself. That is readiness, and it is what select, poll and
epoll answer.
—
The chart plots multiplexing overhead only —
the cost of finding out which sockets are ready, before a single recv — at a fixed ready
fraction, sweeping the connection count. select and poll hand the kernel the whole interest set on
every call and get the whole set back, so their cost grows with registered descriptors —
which is exactly the wrong variable. epoll registers once with epoll_ctl and
returns only the ready ones, so its cost grows with ready descriptors. At two per cent
activity that is a fiftyfold difference in the wrong direction.
Level-triggered means “this socket is still readable”; edge-triggered means
“this socket became readable”. Edge gives you fewer wake-ups and one hard
obligation: you must drain the socket until EAGAIN, because nothing will
remind you. Step the two side by side.
—
Once you are non-blocking, the remaining cost is the syscall itself: about 600 ns of round trip into the kernel and back, per call, doing nothing useful. There are exactly three ways to pay less of it — call it less often per message, amortise it over many messages, or stop calling it at all.
—
recvmmsg is the zero-copy-adjacent idea you can
actually reach from portable code: one syscall fills an array of mmsghdr structures, so
64 datagrams cost one kernel transition instead of 64. Note what the widget shows about it, though —
a batch of 64 only helps if 64 messages are already waiting. If they are not, the
call either returns short (no gain) or waits for them (pure added latency). Batching converts
throughput headroom into latency, and on a tick path you have the opposite preference.
TCP does not preserve your send boundaries. A single recv can deliver half a message,
two messages, or two and a half. The buffer below receives the same three messages in awkward chunks;
watch the framing loop cope, and switch the framing strategy.
—
Between the byte arriving and your strategy seeing a number sits the codec, and on the
arena's JSON wire it is the single largest item in your tick-to-trade budget. Four
ways to turn the same book_snapshot into a best bid and ask. Race them, then read the
allocation column, because that is where your p99.9 lives.
book_snapshot → bid, ask, mid| Approach | ns | Allocs | Copied | p99.9 |
|---|
—
Look at the p99.9 column rather than the mean. The DOM parser's median is bad and its tail is far worse, because every one of its twenty-two allocations can be the one that walks a free list or asks the kernel for a page. A codec with zero allocations has a tail that looks like its median — that is the whole reason to hunt allocations rather than instructions.
string_view is the whole trick// SLOW: every call constructs and destroys a string std::string get_symbol(const json& j) { return j["symbol"].get<std::string>(); } // 1 malloc + 1 free + a copy // FAST: a view into bytes you already have std::string_view get_symbol(std::string_view frame) { auto k = frame.find("\"symbol\":\""); if (k == frame.npos) return {}; k += 10; auto e = frame.find('"', k); return frame.substr(k, e - k); } // 0 malloc, 0 copy
A string_view is a pointer and a length. Returning one costs sixteen
bytes in registers; returning a std::string costs an allocation, a copy, and a
destructor — three times, if the value passes through two more functions on its way to your
strategy.
The obligation you take on is lifetime. The view is only valid
while the receive buffer holds those bytes, so you must not store it past the end of
on_book, and you must not compact the buffer while a view into it is live. That is a
real constraint and it is the reason this is a deliberate technique rather than a default: you are
trading a guarantee the type system was giving you for about 500 ns.
The same reasoning covers std::span for binary payloads,
from_chars instead of stod (no locale, no allocation, and it tells you
where it stopped), and passing const T& rather than T through your
own call chain.
u64toaSerializing is the half people forget. Every outbound order stamps an id, a quantity and a price
into a buffer, and the obvious tools are both terrible: snprintf parses a format string
and consults the locale at run time, and std::to_string allocates a
std::string and destroys it. Twenty lines beat both. Step the digits.
tmp[20] — least significant digit firstout — flipped, most significant firstThe grader checks 0, 7, 12345, 1000000,
9999999999, UINT64_MAX, fuzzes 200,000 values against std::to_string, and
prints your u64toa_ns_per_op next to
std_to_string_ns_per_op.
do/while, not whileA while (v) loop emits nothing for v == 0 and returns
length zero, so your order id prints as an empty string and the venue rejects the message. A
do/while runs the body once regardless, so 0 becomes
"0". It is a one-word fix and it is the first edge case the grader checks.
Two processes on one box do not need a network stack to talk. A ring in a shared-memory region —
the starter's shm_ring.hpp — is the same SPSC structure from Week 10 with one extra
constraint: no pointers, because the region maps at a different address in each
process. Compare it against the alternatives.
// Project Phase 4 — a POD ring in a shared region. // NO pointers: they mean different things in // each process. Offsets and indices only. struct ShmRing { static constexpr uint32_t CAPACITY = 1024; void init(); // creator, pre-fork bool push(uint64_t v); // producer process bool pop(uint64_t& out); // consumer process std::atomic<uint32_t> head, tail; uint64_t buf[CAPACITY]; };
Two details that catch everyone. First,
std::atomic<uint32_t> is fine across processes only because it is lock-free and
address-free on every platform you will use — check is_always_lock_free and do not put a
std::mutex in shared memory. Second, the struct must be standard-layout and contain no
pointers, no std::vector, no std::string: the region maps at a different
virtual address in each process, so a pointer written by one is garbage to the other. Indices are
position-independent; addresses are not.
Two of these hang, two of them lie, and one of them corrupts memory. Open them.
recv per event — and the connection hangsYou registered with EPOLLET because it was described as the fast option, then wrote
the obvious handler: one recv, process it, return to epoll_wait. Three
messages arrive in one segment. You read the first. The socket is still readable, but it did not
become readable again, so no further event fires — and the remaining two messages sit in
the kernel buffer forever while your loop waits patiently for nothing.
It is worse than a plain hang, because it is load-dependent. At low rates one message per segment is the norm and everything works; at high rates messages coalesce and the connection stalls. So this bug appears exactly when the system is busy.
Fix: with edge-triggering, always for (;;) recv(...) until it returns
-1 with EAGAIN. If you do not want that obligation, use level-triggering,
which will simply re-notify you — a handful of extra wake-ups is a cheap price for a loop you
cannot deadlock.
EAGAIN is treated as an error and the connection is closedThe mirror-image bug. On a non-blocking socket, recv returning -1 with
errno == EAGAIN (or EWOULDBLOCK) means “nothing right now” —
the single most common outcome, and completely normal. Code that treats every negative return as
fatal tears down healthy connections under light load.
The neighbouring case is EINTR: the call was interrupted by a signal and must
simply be retried. And recv returning 0 is not an error at all — it is an
orderly peer close, which is a different code path from a failure.
Fix: three explicit branches. n > 0 is data;
n == 0 is peer close; n < 0 splits into EAGAIN /
EWOULDBLOCK (stop reading, return to the loop), EINTR (retry), and
everything else (real error, close). Never a bare if (n < 0) close(fd);.
string_view outlives the buffer it points intoYou did the right thing and switched to views instead of strings — then stored one in a member
to use on the next tick. The next recv overwrites the buffer, or your framing loop
compacts the partial tail forward, and the view now describes bytes that mean something else
entirely. There is no crash and no warning: the symbol field just becomes a fragment of the
following message.
The subtler version is a view returned from a function whose buffer was a local. That one at
least has a chance of being caught by -Wdangling or a sanitizer, but only sometimes.
Fix: treat a view as valid only within the callback that received the frame. If you
must keep a value, copy it into a fixed char[16] — the same choice the starter's
NewOrder makes for symbol while keeping clordid a view.
Run the build under ASan with a compaction step and it will find these immediately.
You timed both codecs in a tight loop over the same frame two million times. The frame is in L1, the branch predictor has learned it, and the allocator's free list is perfectly warm — so the DOM parser looks only three times slower instead of ten, and your targeted extract looks like a modest win. Then you deploy, every frame is cold, and the real gap appears.
The opposite error is just as common: reporting a mean. A codec that allocates has a bimodal distribution, and the mean sits in the empty valley between the two modes, describing nothing that ever happens.
Fix: benchmark on a replay tape of real frames, not one frame repeated. Report p50,
p99 and p99.9 side by side. And use the same harness the grade uses —
latency_replay.py feeding your bot, then latency_report.py — so you are
optimising the number you are scored on.
std::string is put in the shared-memory ringThe ring itself is correct, the atomics are right, and the payload type is
std::string — which holds a pointer into the writing process's heap. The reading
process maps the region at a different virtual address, dereferences that pointer, and either
reads unrelated memory or takes a fault. The same applies to std::vector, any type
with a vtable, and any struct holding a pointer to something else in the region.
The related trap is a std::mutex in the region. It is not required to be
process-shareable, and on some platforms it stores a thread id or an address, so it silently fails
to provide mutual exclusion across processes.
Fix: the payload must be a POD — fixed-size, standard-layout, no pointers, no
virtuals. Store offsets rather than addresses if you need indirection, use
std::atomic types whose is_always_lock_free is true, and
static_assert(std::is_standard_layout_v<T>) on the payload so the compiler
enforces it for you.
Three questions, instant feedback, nothing recorded.
Q1You have 10,000 registered connections and about 20 are readable on any given wake-up. Why does epoll beat poll here?
Q2Why must u64toa's digit loop be a do/while rather than a while?
Q3recvmmsg with a batch of 64 clearly raises throughput. Why is it usually the wrong default on a market-data socket?
u64toa in include/u64toa.hpp until make u64toa
passes u64toa_edges and u64toa_fuzz. Confirm
u64toa_ns_per_op beats std_to_string_ns_per_op.O_NONBLOCK on a socket and drain it until EAGAIN. Then read one
message per event under edge-triggering and watch the connection stall — that contrast is the
lesson.nlohmann::json::parse against a targeted extract on one real
book_snapshot frame, with warm-up, a sink, and percentiles.on_book parse with a targeted extract: scan for the two or
three keys you trade on, read them in place with string_view, allocate nothing.place_order and cancel_order into a
reused buffer, using your u64toa for the integer fields.python scripts/latency_replay.py --cmd
"hft/cpp_client/build/hft_bot --replay" then python
scripts/latency_report.py. Show the p50 / p99 / p99.9 table for both codecs.