Kernel Networking Costs
Syscalls, Nagle, buffers — where the microseconds go.
Performance · Measure, Profile, Then Go Fast
Why this week
Your engine now processes an order in well under a microsecond. Your gateway takes tens of microseconds to get that order off the network and the acknowledgement back on. Almost all of that time is the Linux kernel doing exactly what it was designed to do — being fair, efficient and safe for general-purpose workloads — and a fair amount of it is defaults that actively work against latency: Nagle's algorithm holding back small writes, delayed ACKs holding back acknowledgements, generous socket buffers hiding queues, interrupt coalescing batching packets, and a blocking recv that sends your thread to sleep.
This week you learn what the kernel does per packet and per syscall, tune a TCP gateway for latency rather than throughput, and measure the result. It is also the necessary background for the next two weeks: io_uring (week 32) attacks the syscall cost, and kernel bypass (week 33) removes the kernel from the data path altogether. You cannot judge either without knowing what they replace.
Day 1 — The path of a packet
From the wire to recv(), and back
Every step costs something
The networking chapter: the Linux stack's receive and transmit paths, NAPI, socket buffers, TCP tuning, and the observability tools (ss, nstat, tcpdump, perf) you will need to see any of it.
A long, source-level walk through the receive path from the NIC driver to the socket — interrupts, NAPI polling, softirqs, RPS, the backlog, protocol processing. Kernel versions have moved on; the architecture has not.
Receiving a TCP segment on a standard Linux box:
- The NIC DMAs the frame into a ring buffer in host memory and raises an interrupt — possibly after waiting to coalesce several frames (
ethtool -C). - The interrupt handler schedules NAPI polling; a softirq pulls frames off the ring, possibly merging them (GRO), and runs IP and TCP processing.
- The data lands in the socket's receive queue, an ACK is scheduled, and the thread blocked in
recv/epoll_waitis woken — the futex-style wakeup path from week 30. - Your thread returns from the syscall and the kernel copies the bytes into your buffer.
Sending is the mirror image: a syscall, a copy from your buffer into socket memory, TCP output (which may decide not to send yet), the queueing discipline, the driver, and a DMA by the NIC.
Rough costs on current hardware, all highly dependent on CPU, kernel version and security mitigations: a trivial syscall's entry and exit is from under 100 ns to several hundred ns (mitigations such as KPTI on affected CPUs are the difference); a small TCP send or recv costs a few microseconds of CPU through the stack; a thread wakeup is several microseconds or more. A request/response round trip through the kernel on loopback typically lands in the tens of microseconds unless both ends busy-poll.
| kernel | Interrupt + softirq | 3.0 µs |
| kernel | Socket wakeup + copy | 2.5 µs |
| kernel | Application work | 600 ns |
| kernel | send() through the stack | 3.0 µs |
| bypass | NIC → userspace (polling) | 700 ns |
| bypass | Application work | 600 ns |
| bypass | Userspace → NIC | 800 ns |
Illustrative orders of magnitude for a tuned modern server; measure your own.
Step the packet through the kernel path, then switch to busy-poll and see exactly which hops disappear and which stay. Finally switch the reply to Nagle: one socket option outweighs everything else on the path.
List the steps between a frame arriving at the NIC and recv() returning the data to a blocked thread. Which ones does busy polling remove?
Day 2 — Nagle and delayed ACK
Two good ideas that add 40 ms
Drill 1: simulate the interaction exactly
The original. Nagle's algorithm solved the 'small packet problem' on 1980s networks: don't send a small segment while previously sent data is unacknowledged.
Section 4.2.3.2, 'When to Send an ACK Segment': delayed ACKs are allowed, must be under 0.5 s, and an ACK should go out for at least every second full-sized segment.
Read TCP_NODELAY, TCP_QUICKACK and TCP_CORK carefully — especially the sentence saying QUICKACK is not permanent.
Nagle's algorithm (sender side): if any sent data is still unacknowledged, hold new small writes in the send buffer until either the ACK arrives or a full segment's worth (MSS) has accumulated. Delayed ACK (receiver side): do not ACK every segment immediately; wait a little — on Linux at least 40 ms, at most 200 ms — in case there is response data to piggyback the ACK on, but ACK at once when a second full segment arrives.
Each is reasonable. Together they deadlock for the length of the delayed-ACK timer. The classic trigger is write-write-read: a client sends a request as two writes (say, a header and then a body) and waits for the response. The header goes out immediately. The body is small and the header is unacknowledged, so Nagle holds it. The server has received only a partial request, has nothing to send back, and is delaying its ACK. Forty milliseconds later the delayed-ACK timer fires, the ACK arrives, Nagle releases the body, and the request completes — about 40 ms late, on a network with a 100 µs round trip.
The fixes, in order of preference for a trading gateway:
TCP_NODELAYon every socket: disable Nagle; small writes leave immediately. Universal in low-latency systems.- Write whole messages: build the full message (or a batch) in one buffer, or use one vectored write (Day 3), so there is no second small write to hold back. Fewer syscalls, fewer packets.
TCP_QUICKACKon the receiver disables delayed ACKs only temporarily — the kernel may re-enter delayed-ACK mode, so it has to be set again after reads. Useful, but not a substitute for the first two.
One reason this bug survives testing: Linux starts connections in a quick-ACK mode, ACKing immediately for the first few segments, so a short test may never show the stall that appears minutes into a production session.
Predict Nagle + delayed-ACK stalls
use std::cmp::Reverse;
use std::collections::BinaryHeap;
#[derive(Clone, Copy, Debug)]
pub struct Net {
/// One-way delay, each direction (wire + both stacks).
pub one_way_ns: u64,
/// Receiver's delayed-ACK timer (Linux: 40 ms minimum).
pub delack_ns: u64,
/// Bytes in a full segment.
pub mss: u32,
}
#[derive(Clone, Copy, Debug)]
pub struct Write {
pub at_ns: u64,
/// <= mss. Writes are given in non-decreasing at_ns order.
pub len: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Outcome {
/// When each write's bytes arrived at the receiver.
pub delivered_ns: Vec<u64>,
/// Data segments sent.
pub segments: usize,
}
/// Simulate one TCP connection carrying small writes (one direction only).
///
/// Sender
/// - nagle == false: every write is sent at once as its own segment.
/// - nagle == true: a write is sent at once only if no sent segment is
/// unacknowledged AND nothing is buffered; otherwise it joins the buffer.
/// The buffer leaves as ONE segment when (a) it reaches mss bytes, or
/// (b) an ACK arrives that leaves no sent segment unacknowledged.
/// - A segment sent at t arrives at t + one_way_ns (segments stay in order).
/// Receiver
/// - On each segment arrival: if it now holds 2 un-ACKed segments, ACK at
/// once; otherwise start a delack_ns timer unless one is running.
/// - When the timer fires: ACK. Sending an ACK cancels any running timer.
/// - An ACK covers every segment received so far and reaches the sender
/// one_way_ns later.
/// Ties at the same instant are processed in this order: ACK arrivals at the
/// sender, then app writes, then segment arrivals, then timer expiries.
///
/// Hint: a BinaryHeap<Reverse<(time, kind, seq, payload)>> event queue.
pub fn simulate(net: &Net, writes: &[Write], nagle: bool) -> Outcome {
todo!()
}
A client sends each order as a 20-byte header write followed by a 200-byte body write, then waits for the ack. With default socket options, why do some orders take ~40 ms, and what is the right fix?
Why is setting TCP_QUICKACK once at connection setup not enough?
Day 3 — Syscall cost and batching
Do more per kernel entry
Drill 2: vectored writes that survive partial writes
Every send is a syscall plus a trip through TCP output. If the engine produces five execution reports for one session in a single batch, five sends cost five syscalls and likely five packets; one write of all five costs one syscall and, with TCP_NODELAY, typically one packet (if it fits in a segment).
The tools:
writev(Write::write_vectoredin Rust): one syscall, many buffers — a header from here, a payload from there, no copying into a staging buffer. Linux allows up toIOV_MAX(1024) buffers per call.- Coalescing copies: for small messages, copying them into one contiguous, reused buffer and issuing one
writeis often as fast as building an iovec array, and simpler. Usewritevwhen payloads are large or live in separate places. sendmmsg/recvmmsg: many UDP datagrams per syscall — the market-data publisher's equivalent.MSG_MORE/TCP_CORK: tell TCP more data is coming so it should not send a partial segment yet — the opposite ofTCP_NODELAY, useful for bulk transfers, dangerous on an order path.
The catch is partial writes. A non-blocking socket accepts only as many bytes as fit in its send buffer; writev returns how many it took, and that can end in the middle of one of your buffers. You must resume at exactly that byte, skip empty buffers, retry on EINTR, and treat "zero bytes accepted" as an error rather than spinning forever. Rust's IoSlice::advance_slices helps; a write_all_vectored exists in std but is still unstable (as of Rust 1.97), so production code writes this loop itself — which is the drill.
Batch writes with writev
use std::io::{self, IoSlice, Write};
/// Write every byte of `bufs` with as few `write_vectored` calls as possible
/// (one call per partial write — never one call per buffer).
/// - A call may accept fewer bytes than offered, even stopping in the middle
/// of a slice: resume exactly there.
/// - Skip empty slices, and never call write_vectored with nothing to write.
/// - Retry on io::ErrorKind::Interrupted; Ok(0) while bytes remain is an
/// error of kind io::ErrorKind::WriteZero.
/// Return the total number of bytes written.
pub fn write_all_vectored<W: Write>(w: &mut W, bufs: &[&[u8]]) -> io::Result<usize> {
todo!()
}
/// Frame each message as [u16 little-endian length][payload] into `out`
/// (clear it first, reuse its capacity) so a whole batch can go out in one
/// write. Return Err(index) for the first payload longer than u16::MAX.
pub fn frame_batch(msgs: &[&[u8]], out: &mut Vec<u8>) -> Result<(), usize> {
todo!()
}
Your gateway uses non-blocking sockets. writev of three execution reports (300 bytes) returns 170. What must happen next, and what must not?
Day 4 — Buffers, queues and the receive path
Every buffer is a queue
Socket buffers, interrupt coalescing and steering
RSS, RPS, RFS, aRFS and XPS: how packets and their processing are spread across CPUs. You need this to decide which core handles your gateway's NIC queue.
Socket buffers. SO_SNDBUF and SO_RCVBUF size the kernel queues on each side of a socket. Linux doubles the value you set (to account for bookkeeping) and caps it at net.core.wmem_max / rmem_max, and setting a buffer size explicitly turns off the kernel's autotuning for it. For throughput, bigger is better. For latency, a buffer is a queue: a full 4 MB send buffer draining at 10 Gb/s holds 3.2 ms of data, and any new order joins the back of it. In a gateway, keep send buffers modest and make backpressure explicit in the application (week 24): a session that cannot keep up should be throttled, conflated or disconnected — not silently queued for milliseconds.
Interrupt coalescing (ethtool -C rx-usecs …) makes the NIC wait to batch interrupts: less CPU, more latency. GRO/LRO merge consecutive segments into one large one: fewer passes through the stack, but a small packet may wait for company. Latency-tuned hosts reduce both on the trading NIC.
Steering. RSS hashes each flow to one of the NIC's receive queues, each with its own interrupt. Pin the interrupt of your gateway's queue to a core on the NIC's NUMA node — not the busy-polling engine core (week 30) — or, with busy polling (Day 5), process the queue from the gateway thread itself.
Someone sets SO_SNDBUF to 4 MB on every session socket 'for performance'. What does that do to order latency on a 10 Gb/s link when a session falls behind?
Write the exact list of socket options and host settings your gateway will use (TCP_NODELAY, send/receive buffer sizes, busy polling, IRQ affinity, coalescing, GRO), each with the reason and the metric that would tell you it was wrong.
Day 5 — Busy-polling sockets
Don't sleep in recv()
SO_BUSY_POLL and application-level polling
Read SO_BUSY_POLL, SO_SNDBUF/SO_RCVBUF (note the doubling), SO_RCVLOWAT and the timestamping options.
The kernel's own description of NAPI, IRQ mitigation and busy polling — including the newer 'prefer busy poll' mode where the application's polling replaces interrupts.
Two levels of busy polling:
- Application level: put the socket in non-blocking mode and spin on
recv. No sleep, no wakeup — but every empty poll is still a syscall, and the data still has to be delivered by the interrupt/softirq path on whichever core handles the NIC queue. - Kernel socket busy polling:
SO_BUSY_POLL(or thenet.core.busy_read/net.core.busy_pollsysctls forrecvandpoll/epoll) makes a receive with no data spin in the kernel for up to N microseconds, polling the device's queue directly from your thread. With "prefer busy poll" and deferred interrupts, a busy-polling application can drive the queue itself and interrupts stay off. Requires driver support (NAPI) and is per receive queue, so steering (Day 4) matters.
Both cost a core at 100%, which is why the gateway receive thread is in week 30's Hot class. Measure the difference — on loopback and, if you can, on a real NIC between two machines — before deciding it is worth it.
What does SO_BUSY_POLL change compared with spinning on a non-blocking recv() in user space?
Day 6 — Measuring the network path
Ping-pong, timestamps, and what loopback hides
Know which part of the round trip you are measuring
The standard microbenchmark is a ping-pong: client sends a small message, server echoes it, client measures the round trip; repeat a few hundred thousand times into the week 25 histogram (a ping-pong is closed-loop by nature, so it measures per-message latency at low load, not behaviour under load — say so in the report). Run it with blocking receives and with busy-polling receives, pinned and unpinned.
Know what loopback leaves out: there is no NIC, no DMA, no device interrupt or coalescing; packets still traverse TCP and IP and the softirq backlog, so the syscall, copy and wakeup costs are real. Loopback numbers are a floor for the kernel-stack part, not a prediction for a real network.
To split a round trip into parts, use timestamps: SO_TIMESTAMPING can report when the kernel (or, with NIC support, the hardware) sent and received each packet, so you can separate time-in-application from time-in-stack. ss -ti shows per-connection TCP state (RTT, congestion window, retransmits, delivery rate) and is the first thing to look at when a session is slow.
Design your ping-pong benchmark: message size, number of round trips and warmup, thread placement for client and server, blocking vs busy-poll variants, and which socket options each variant sets. What result would convince you busy polling is worth a dedicated core in the gateway?
Capstone
Tune the week 13 gateway in crates/gateway for latency and measure what each change buys.
What to build:
- Socket configuration applied to every accepted session socket explicitly (do not rely on options being inherited from the listening socket):
TCP_NODELAY, send and receive buffer sizes chosen from your Day 4 reasoning, and optionallySO_BUSY_POLLfor the receive path. In the repo this isSocketTuning(net.rs):SocketTuning::latency()setsTCP_NODELAY, requests 64 KiB send and 128 KiB receive buffers, and turns on keepalive after 30 s idle as a backstop for half-open connections.SO_LINGERis deliberately never set.apply_to_socketconfigures a not-yet-connected TokioTcpSocket(the listener),applyconfigures an accepted socket, andsocket_optionsreads the options back from the kernel. The gateway applies the tuning to its listener and every accepted socket whenGatewayConfig::tuningis set; otherwise it sets onlyTCP_NODELAY.std::netand Tokio exposeset_nodelay, but an accepted socket's buffer sizes, keepalive idle time and busy polling needsetsockopt. Tokio is the workspace's only external dependency, so instead ofsocket2orlibcthe repo declaressetsockoptandgetsockoptitself in a few lines of FFI. - Vectored writes for execution reports: gather the reports queued for a session and write them with one
write_vectored(or one coalesced buffer), resuming exactly where a partial write stopped. No message is ever split across separate small writes. The repo'swrite_all_vectored(w, &mut [IoSlice])is your drill against a blocking writer,write_all_vectored_asyncis the Tokio version (it waits for writability instead of returningWouldBlock), andFrameBatchencodes up to its capacity of report frames in fixed slots and writes them with one vectored call per 64 frames. SetGatewayConfig::vectored_writesand the gateway's writer tasks useFrameBatch; by default they copy a batch into one buffer and issue onewrite, which costs about the same for messages this small. - A loopback ping-pong benchmark at the gateway's framing layer, reporting p50/p99/p99.9 for at least two configurations: blocking receive and busy-polling receive. The repo's
pingpongmodule has anEchoServeron a plain thread (spawn(RecvMode, tuning, cpu)) or on a single-threaded Tokio runtime, the gateway's I/O model (spawn_tokio), and aPingClientthat sends one frame perpingand waits for the echo. The server takes an optional CPU to pin to (week 30).
The decisions that matter:
- Where partial writes are handled. The engine thread must never block on a socket. Pending bytes live with the session; the I/O thread drains them when the socket is writable.
- Batch boundaries. Batching reports per session per engine batch reduces syscalls without adding delay. Waiting to accumulate more reports does add delay — do not introduce a timer.
- What is in the round trip. Make the benchmark echo at the gateway's framing layer so you measure the kernel and the gateway's I/O, not the engine; the engine already has its own numbers.
About the target: it is a p99 round trip over loopback TCP. The benchmark (w31.tcp_pingpong) echoes a frame with a 40-byte payload 20,000 times after 2,000 warm-up round trips, with SocketTuning::latency() on both ends and no pinning. The reported value is the blocking variant: plain-thread server, blocking receives on both ends. The busy-polling and Tokio-server variants are printed next to it for comparison. With TCP_NODELAY and small messages the target is reachable with blocking receives on a quiet modern Linux machine, and busy polling should cut it substantially. A loaded machine, or one whose CPU mitigations make syscalls expensive, can miss it without anything being wrong in your code, so read the busy-poll line next to it. Missing it by a lot usually means Nagle is still on somewhere (look for a ~40 ms mode in the histogram) or each message is being written as several small writes.
crates/gatewayA gateway tuned for latency
Session sockets tuned for latency (TCP_NODELAY, deliberate buffer sizes), execution reports written with vectored writes that handle partial writes, and a loopback ping-pong benchmark comparing blocking and busy-polling receive.
cargo xtask test --week 31 # tests named w31_*
cargo xtask bench --week 31 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 31 tests pass w31_*
- TCP ping-pong, loopback w31.tcp_pingpongtarget p99 ≤ 70 µs
Feeds into
- Week 32 attacks the syscall cost head-on: io_uring's submission and completion queues let one kernel entry carry many operations — the logical next step after
writevbatching. - Week 33 removes the kernel from the data path entirely with kernel bypass, and uses this week's packet-path breakdown as the "before" column of its latency budget.