The Kernel, Your Code, and the Wall Between Them
The previous chapter was the hardware. This chapter is the software layer that sits between that hardware and every program you’ve ever written — and what it costs to cross into it. HFT’s most famous move, “kernel bypass,” is just refusing to pay that cost. You can’t appreciate the bypass until you’ve priced the wall.
┌─────────────────────────────────────────────────────────────────┐
│ RING 3 — userspace (unprivileged) │
│ │
│ your strategy binary · node · postgres · nginx · everything │
│ you have ever deployed │
│ │
│ allowed: arithmetic, branches, reading/writing ITS OWN memory │
│ forbidden: touching hardware, other processes' memory, the NIC│
├══════════════════ THE WALL (CPU-enforced) ══════════════════════┤
│ RING 0 — kernel (privileged) │
│ │
│ scheduler · TCP/IP stack · filesystems · device drivers · │
│ memory management │
│ │
│ allowed: everything — all memory, all devices, all state │
├─────────────────────────────────────────────────────────────────┤
│ HARDWARE — CPU, RAM, NIC, disks │
└─────────────────────────────────────────────────────────────────┘
Kernel vs userspace — the CPU itself has privilege modes (“rings”); the kernel runs in ring 0 with full hardware access, your code runs in ring 3 where instructions that touch hardware or other processes’ memory physically fault, and the only doorway between them is a controlled trap. Analogy: the kernel is the framework/runtime and your code is the route handlers — Express owns the listener, the parsing, the socket lifecycle; your handler receives a cooked req and returns a cooked res, never touching the raw TCP stream. That division of labor is exactly kernel vs userspace, and it has the same trade: enormous convenience, generality, safety — paid for in per-request overhead you normally never itemize. A trading system cares because at microsecond scale that overhead is the product: the kernel’s network path costs ~2–10 µs per packet, and firms discovered they could rewrite the parts they need in userspace and get the same packet for ~1 µs. The wall is enforced by the CPU’s privilege bits, which is why crossing it has an irreducible hardware cost.
Syscalls: knocking on the wall
your code (ring 3) kernel (ring 0)
────────────────────── ────────────────────
recv(fd, buf, len)
│ args into registers
▼
`syscall` instruction ──── CPU switches privilege mode,
jumps to kernel entry point ──► dispatch table
→ tcp_recvmsg()
→ copy data into
your buf
resume after the ◄────── `sysret`: mode flips back ◄──┘
call, ~50–100 ns+ later (plus the work itself)
Syscall (system call) — the one legal way userspace asks the kernel to do privileged work: your code puts arguments in registers and executes a special CPU instruction (syscall) that flips the privilege mode and jumps to a fixed kernel entry point; the kernel does the work and flips back. Analogy: an RPC (remote procedure call) to a service you don’t own — except the “network” is a privilege-mode flip. Every read, write, send, recv, epoll_wait, every console.log bottoms out here. The mechanical crossing alone costs ~50–100 ns before any useful work — in the human scale from the last chapter, ~5 minutes of overhead per knock — and the flip also disturbs branch predictors and cache state, so the true cost bleeds past the instruction itself. A trading system cares because one syscall in a per-packet hot loop is like an HTTP round-trip inside a render loop: individually invisible, structurally fatal. Watch for the pattern all low-latency APIs share — amortize or eliminate the crossing: epoll (one syscall reports many sockets), io_uring (queue requests in shared memory, syscall rarely or never), and kernel bypass (never knock again).
Mode switch vs context switch — a mode switch is the syscall crossing above: same process, same address space, just a privilege flip (~50–100 ns). A context switch is the scheduler replacing which process/thread owns the core: save all registers, swap the address-space mappings, restore another thread’s state — ~1–5 µs, and the direct cost is the small part. The real bill is cache pollution: the incoming thread evicts your L1/L2/TLB, so when your thread returns it runs at RAM-speed until its working set re-warms. Analogy: a context switch is a cold start — your thread got descheduled, and on resume the warm cache it was relying on is gone. A trading system cares because a context switch at the wrong moment adds 1–10+ µs at a time you don’t choose — hence the standard prescription assembled from last chapter’s parts: one pinned thread per hot core, that core isolated from the scheduler (isolcpus), and the thread never blocks, so the scheduler never has a reason to touch it. A hot thread that calls a blocking syscall has volunteered for a context switch.
Interrupts: the hardware’s webhook
┌───────────────┐
packet lands in NIC ───► │ NIC raises │
│ interrupt │ ── electrical signal over
└──────┬────────┘ PCIe to a specific core
▼
Core 2: mid-instruction in SOMEBODY'S code
│ drop everything, save state
▼
run the driver's interrupt handler (in kernel, ring 0)
│ "packet queue 3 has data"
▼
resume the interrupted code — which just mysteriously lost ~1–2 µs
Interrupt (IRQ, Interrupt Request) — a hardware signal from a device that forces a core to suspend whatever it’s running, mid-instruction-stream, and execute that device’s kernel handler right now. Analogy: webhook vs polling — instead of the CPU asking the NIC “anything yet?” a billion times a second, the NIC pushes a notification when there’s data. The default for all of computing. A trading system cares for two opposite reasons: interrupts add ~1–2 µs of delivery latency to the packet (the “webhook delivery time”), and they victimize whichever thread was running on the receiving core (your pinned strategy thread, unless you steer IRQs away from it — that’s irq affinity — page one of every HFT tuning guide). HFT’s resolution is blunt: on the hot path, don’t use interrupts at all — poll. Push notifications are for machines that are allowed to sleep.
MSI-X (Message Signaled Interrupts, eXtended) — the modern interrupt mechanism where a device signals by writing a message over PCIe, and one device can own many independent interrupt vectors, each targeted at a chosen core. Analogy: instead of one shared webhook endpoint for all events, the NIC gets many endpoints — one per queue — each with its own consumer. A trading system cares because MSI-X is what makes traffic-to-core steering possible at all: the NIC can be told “market-data queue interrupts core 3, order-entry queue interrupts core 5, and nothing interrupts cores 1–2, where strategy threads spin.”
hardirq, softirq, NAPI: splitting the webhook handler
NIC interrupt fires
│
▼
┌─ HARDIRQ (top half) ────────────────────────────┐
│ microseconds matter; other interrupts blocked │
│ do ALMOST NOTHING: ack the device, │
│ schedule the bottom half. return. │ ← "return 200 fast"
└───────────────┬─────────────────────────────────┘
▼
┌─ SOFTIRQ (bottom half) ─────────────────────────┐
│ runs soon after, interruptible │
│ the bulk work: drain the NIC ring, build │ ← "the queue worker"
│ sk_buffs, run the TCP/IP stack, wake sockets │
└─────────────────────────────────────────────────┘
NAPI, under load: interrupt fires ONCE →
"interrupts OFF for this queue; I'll POLL the ring in a loop,
draining batches, until it's empty — then interrupts back ON"
hardirq / softirq — the kernel splits interrupt handling into a minimal urgent part (hardirq: acknowledge the device, schedule follow-up) and a deferred bulk part (softirq: actually process the packets), because while a hardirq runs, other interrupts are blocked and the whole core is hostage. Analogy: a webhook handler that validates the signature, enqueues a job, and returns 200 in two milliseconds — while a worker pool drains the queue. Same pressure, same shape, same reason: keep the front door clear. A trading system cares because “my packet arrived” and “my packet was processed” are now two events at two times — softirq work can be deferred, migrated, or batched, and it’s a classic source of latency jitter that shows up in your p99.9 with no smoking gun in your own code.
NAPI (New API — genuinely its name) — the kernel’s adaptive strategy for high packet rates: after one interrupt, disable that queue’s interrupts and switch to polling the NIC in a loop, draining packets in batches, re-enabling interrupts only when the queue runs dry. Analogy: your Redis pub-sub consumer melting under per-message callbacks, so you switch to batch-draining the queue in a loop under load and only re-subscribe to notifications when it’s empty. When the doorbell never stops ringing, stop answering the door and just keep it open. A trading system cares because NAPI is the intellectual bridge to HFT’s signature move: the kernel itself concedes that under load, polling beats interrupts. HFT extends the logic to its limit — if polling wins under load, and you care only about moments of load, then poll always, from userspace, with a dedicated spinning core, and never take an interrupt at all.
DMA and the descriptor ring: how packets skip the CPU
┌───── NIC ─────┐ ┌────────── RAM ─────────┐
│ DMA engine │ ── writes bytes ─────► │ packet buffer #17 │
└───────┬───────┘ over PCIe, │ packet buffer #18 │
│ NO CPU INVOLVED └────────────────────────┘
│
│ then updates a slot in the shared ring:
▼
RX DESCRIPTOR RING (circular array in RAM, shared NIC ⇄ driver)
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ #15 │ #16 │ #17 │ #18 │empty │empty │
│ done │ done │ FULL │ FULL │ ▲ │ │
└──────┴──────┴──▲───┴──────┴─┼────┴──────┘
driver reads──┘ └── NIC writes next here
from here (driver pre-posted these free
buffers for the NIC to fill)
DMA (Direct Memory Access) — hardware devices writing to (and reading from) RAM directly over PCIe, without the CPU copying a single byte; the CPU only finds out afterwards. Analogy: presigned S3 uploads — the client writes the 5 GB file straight to storage and your API server just receives a small “upload complete” notification, instead of proxying every byte through its own process. A trading system cares because DMA is why packet arrival is nearly free for the CPU — the expensive part of the kernel path is everything that happens after the bytes are already sitting in RAM. It’s also the enabling trick of kernel bypass: DMA doesn’t care whose memory it targets, so point the NIC’s DMA engine at buffers your process owns, and packets materialize directly inside your application. Same trick as the S3 upload: cut out the middleman that was only ever copying bytes.
Descriptor ring — a fixed-size circular array in RAM, shared between driver and NIC, where each slot (“descriptor”) is a small record — pointer to a buffer, length, status flags — through which the two sides coordinate: the driver pre-posts empty buffers, the NIC fills them and flips status bits, the driver harvests and re-posts. Analogy: a bounded SPSC (single-producer single-consumer) work queue where the consumer pre-posts empty envelopes and the producer fills them — backpressure by construction, allocation-free by construction. A trading system cares for three reasons: if the ring fills because software drains too slowly, the NIC drops packets on the floor (that’s rx_missed in NIC stats — check it when your feed gaps); ring size is a latency/loss dial (a big ring absorbs bursts but lets queued packets go stale; a small ring keeps everything fresh or drops it); and the ring is exactly the interface DPDK-style bypass frameworks map into your process — you’ll spend real time with these rings later, so learn the shape now.
sk_buff (socket buffer) — the kernel’s per-packet metadata object: a struct wrapping the packet’s bytes plus headroom, protocol headers, timestamps, routing verdicts, and bookkeeping, allocated for every packet on arrival and freed on delivery. Analogy: Express’s req — one object per request, created by the framework, progressively annotated by each middleware (parsed body, auth context, route params) as it moves down the chain; sk_buffs move through the netfilter/IP/TCP layers accumulating annotations the same way. A trading system cares because the sk_buff is the itemized overhead of the kernel path: allocation, initialization, per-layer bookkeeping, and freeing cost hundreds of nanoseconds per packet — for a market-data payload that might be 40 bytes. Kernel-bypass frameworks’ packet objects are, by design, almost nothing: a pointer and a length. No req object, just the bytes.
Pages, page faults, TLB, hugepages
virtual address (what your pointers hold)
│
▼
┌───────────┐ hit (~free)
│ TLB │ ────────────► physical RAM address, done
│ (a cache) │
└─────┬─────┘
│ miss
▼
page-table walk: up to 4 dependent memory reads ← ~100 ns
│
▼
mapping not present at all? → PAGE FAULT → kernel takes over ← ~1+ µs
hugepages: map 2 MB (or 1 GB) per page instead of 4 KB
→ 512× fewer mappings → TLB almost never misses
Page — the granularity of the virtual-memory illusion: memory is managed in 4 KB chunks, and every address your process uses is a virtual address translated to a physical RAM location through per-process page tables maintained by the kernel. Analogy: the pointer you hold is a DNS name, not an IP — every access resolves through a mapping layer you normally never see. Trading cares because the mapping layer has costs, and they hide in tails.
Page fault — the CPU trap taken when code touches a page with no valid mapping: the kernel intervenes — allocating a zeroed page on first touch, or (catastrophically) reading from disk if the page was swapped out. Analogy: lazy loading — the ORM handed you an object stub, and touching a field fires a query. Costs a microsecond-plus, at first touch — a moment you didn’t schedule. A trading system cares because faults are silent tail-latency landmines inside innocent memory accesses; hence the HFT idiom: allocate everything up front, touch every page once during warm-up, and mlock the lot so the kernel can never page it out. Eager-load the entire object graph at boot; never lazy-load in the hot path.
TLB (Translation Lookaside Buffer) — the tiny hardware cache of recent virtual→physical translations (~1,500 entries) that makes paging affordable; a miss forces a page-table walk of up to four dependent memory reads (~100 ns) before your actual memory access even starts. Analogy: a route cache in front of a slow resolver; hits are free, misses pay the full lookup, and 1,500 entries × 4 KB covers only ~6 MB of hot data. A trading system cares because a large working set (a full order book, say) can thrash the TLB and quietly double effective memory latency — you’re paying two lookups per access and neither shows up in your code.
Hugepages — mapping memory in 2 MB or 1 GB pages instead of 4 KB, so each TLB entry covers 512×–262,144× more memory and misses effectively vanish. One big long-lived mapping replaces thousands of tiny ones, and the same fixed-size TLB now reaches your whole working set. A trading system cares because hugepages are free tail-latency insurance for large in-memory state, and — practical note — DPDK and friends flatly require them for their packet buffer pools; the first line of every DPDK setup guide is reserving hugepages, and now you know why it’s there.
The whole path: a packet arrives — who does what, in order
This is the entire chapter in one diagram, with the bill attached. The next chapter walks this same path from the top down; Part I of the book is about deleting steps 4–9.
1. bits on the wire PHY/MAC ~100 ns
2. NIC validates frame, picks an RX queue NIC ~100 ns
3. DMA writes packet into a ring buffer in RAM NIC/PCIe ~500 ns
4. NIC raises MSI-X interrupt at the queue's core hardware ┐
5. HARDIRQ: ack, schedule NAPI, return kernel ├ ~1–2 µs
6. SOFTIRQ/NAPI: drain ring, allocate sk_buff, kernel ┘
─ then per packet:
7. IP + TCP/UDP processing, socket lookup, kernel ~1–2 µs
enqueue on socket receive queue
8. wake the blocked/epoll-ing process; kernel ~1–5 µs
scheduler gets it back onto a core
9. recv() syscall: mode switch + copy bytes kernel→you ~0.5–1 µs
from sk_buff into your buffer
10. YOUR CODE finally sees the packet ═══════
total: ~ 2–10 µs
(kernel bypass: steps 4–9 replaced by
"your spinning thread reads the ring": ~1 µs total)
What you can now read
- From an Express Handler to the Wire (next) — this same stack, walked top-down from code you already own, with the microsecond bill itemized per layer.
- Part I’s kernel-bypass chapters (DPDK, Onload, ef_vi, AF_XDP) — every one of them is a different answer to “which of steps 4–9 do we delete, and who does the ring-draining instead?”
- The OS tuning chapters — isolcpus, IRQ affinity, NAPI/busy-poll settings, hugepage reservation: each knob now maps to a specific box in the diagrams above.
- The jitter and tail-latency material — softirq scheduling, context switches, page faults, and TLB misses are the standard suspect list, and you’ve now met each one with a price tag.