Kernel Bypass
DPDK, AF_XDP, ef_vi — and what really changes.
Performance · Measure, Profile, Then Go Fast
Why this week
Weeks 31 and 32 made the kernel cheaper to talk to. This week you take it out of the conversation. Kernel bypass means the NIC's receive and transmit rings are mapped into your process, and your thread polls them directly. There's no interrupt, no softirq, no socket buffer, no wakeup and no copy. Every serious electronic trading firm runs some form of it on its latency-critical paths, and "what does kernel bypass actually remove?" is a staple interview question. Most candidates answer with a slogan.
You won't write a DPDK application this week. That needs a supported NIC, hugepages and root, and it wouldn't teach much more than the model does. Instead you'll understand the mechanism precisely enough to reason about it: the descriptor rings that sit between the NIC and memory, which stages of the kernel path disappear and which remain, what each bypass technology (DPDK, AF_XDP, Solarflare's Onload and ef_vi) trades away, and how firms timestamp packets in hardware to prove any of it. The deliverable is a decision memo for your own exchange. It should be good enough that someone could approve or reject the spend from it.
Keep one sentence in your head all week: bypass removes the kernel's per-packet work, not physics. Serialisation on the wire, switch hops, the PCIe crossings and your own code are all still there. If your application takes 40 µs per message, saving 5 µs in the stack is a rounding error.
Day 1 — The kernel receive path, stage by stage
From photons to recv()
Every hand-off a UDP packet makes in a stock Linux kernel
Follow one market-data packet through an unmodified Linux box:
- Wire → NIC. The frame is deserialised (a 64-byte frame takes 51.2 ns at 10 Gb/s and about 20 ns at 25 Gb/s), checked, filtered by MAC address, and hashed (RSS) to pick one of the NIC's receive queues.
- NIC → memory (DMA). Each receive queue is a ring of descriptors in host memory. The driver pre-filled each one with the address of an empty buffer. The NIC writes the frame into the next buffer over PCIe, then writes the descriptor back with the length and a descriptor done bit. The PCIe transactions alone cost hundreds of nanoseconds.
- Interrupt. The NIC raises an MSI-X interrupt, possibly after a delay, because interrupt coalescing (
ethtool -C … rx-usecs) deliberately waits to batch interrupts. Defaults are tuned for throughput, not latency. - NAPI and softirq. The interrupt handler schedules NAPI and returns. In the
NET_RXsoftirq the driver's poll function walks the completed descriptors, allocates ansk_bufffor each, refills the ring, and hands packets up the stack (GRO, IP, UDP, socket lookup, any netfilter rules). - Socket queue and wakeup. The packet lands on the socket's receive queue and the kernel wakes the thread blocked in
recvorepoll_wait. If that thread was asleep, the scheduler has to run it. If its core was in a deep C-state, waking the core alone can take tens of microseconds. - Copy out.
recvcopies the payload into your buffer, frees thesk_buffand returns.
Stages 3 to 6 run on the CPU, in the kernel, for every packet. On a carefully tuned server (coalescing off, busy polling, pinned and isolated cores, C-states limited) the whole path is typically a few microseconds. On a default configuration it's tens of microseconds, with a long tail. The work also costs throughput: a kernel stack that spends about a microsecond of CPU per packet can't keep up with a 10 GbE link of minimum-size frames (14.88 million packets per second) on one core, or on ten.
Switch to kernel bypass and watch stages 3 to 6 drop out of the path. Then put the bypass thread back to sleep on an interrupt and see how much of the saving was the wakeup.
The best walk-through of the receive path from driver to socket, with the counters and sysctls at each stage. Kernel internals have moved since 2016, but the stages haven't. Read it with today's list beside you and map each section onto a stage.
How the kernel switches from interrupts to polling under load, what the poll budget means, and the busy-polling and threaded-NAPI knobs. This is the kernel's own partial answer to the same problem bypass solves.
List the stages between a frame arriving at the NIC and recv() returning in a normal Linux kernel. Which of them does kernel bypass remove?
A packet can be dropped in at least four places on the kernel path: the NIC (no free descriptor), the softirq backlog, the socket receive buffer, and your application's own queue. For each, name the counter or command that would reveal it (ethtool -S, /proc/net/softnet_stat, netstat -su / nstat, your own metrics) and the fix.
Day 2 — Descriptor rings and DMA
The contract between the NIC and memory
Post buffers, poll for done bits, recycle fast
Every bypass technology, and the kernel driver itself, speaks the same contract with the NIC. Only who polls the ring changes.
- Software owns a pool of buffers in DMA-able memory (a DPDK mempool of mbufs, an AF_XDP UMEM, an ef_vi registered memory region).
- Software posts buffers by writing their addresses into RX descriptors and then writing the new tail index to a NIC register. That's the doorbell: an MMIO write across PCIe, so drivers batch it.
- The NIC fills posted descriptors in order. For each frame it DMAs the bytes, then writes the descriptor back with the length, status flags and a done (DD) bit.
- Software polls the DD bit of the next descriptor. When it's set, the frame is in memory and the descriptor can be consumed.
- When the application is finished with a buffer, it goes back to the pool and gets re-posted.
Two failure modes fall straight out of this contract. You'll reproduce both in the drill.
The NIC drops in hardware when it runs out of posted descriptors. If your poll loop stalls (a page fault, a log flush, a slow path) for longer than the ring takes to fill, the NIC has nowhere to put the next frame and discards it. It increments a counter like rx_missed_errors in ethtool -S or imissed in DPDK, and nothing else tells you. At 10 million packets per second, a 512-entry ring fills in about 50 µs.
Holding buffers starves the ring. Zero-copy means the application reads the frame in the DMA buffer. If it keeps buffers (queues them for another thread, or batches them for later), they aren't in the pool, so refill can't post them and the ring drains even though the poll loop is running. Size the pool for ring size plus the maximum your pipeline can hold in flight, and treat "pool empty" as an alarm, not a slow path.
Your feed handler polls a 1,024-descriptor RX ring. The feed bursts to 8 million packets per second for 300 µs at the open. What is the longest stall your poll loop can survive without dropping, and what else must be true of the buffer pool?
Simulate a NIC RX descriptor ring
pub const BUF_SIZE: usize = 2048;
/// RX descriptor as the NIC sees it: which buffer to DMA into, plus the
/// fields the NIC writes back (length, "descriptor done").
#[derive(Clone, Copy, Debug, Default)]
pub struct Desc {
pub buf: u32,
pub len: u16,
pub dd: bool,
}
/// A received packet handed to the application. Its bytes still live in
/// the DMA buffer: nothing was copied.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Pkt {
pub buf: u32,
pub len: u16,
}
pub struct RxRing {
descs: Vec<Desc>,
bufs: Vec<[u8; BUF_SIZE]>,
free: Vec<u32>, // the buffer pool (a DPDK mempool, an AF_XDP fill queue)
mask: u32,
hw_head: u32, // next descriptor the NIC fills
sw_next: u32, // next descriptor software checks for dd
tail: u32, // descriptors posted to the NIC are [hw_head, tail)
missed: u64, // frames dropped because no descriptor was posted
}
impl RxRing {
/// `ring_size` is a power of two. All `pool_size` buffers start free;
/// post as many as fit, as a driver does at start-up.
pub fn new(ring_size: usize, pool_size: usize) -> Self {
todo!()
}
/// Driver: post free buffers into empty descriptor slots and advance the
/// tail (the doorbell write). A slot is reusable once software has
/// consumed it. Returns how many descriptors were posted.
pub fn refill(&mut self) -> usize {
todo!()
}
/// NIC: DMA one frame into the next posted descriptor and write back len
/// and dd. No posted descriptor -> drop it and count it in `missed`.
/// Frames longer than BUF_SIZE are truncated.
pub fn nic_receive(&mut self, frame: &[u8]) -> bool {
todo!()
}
/// Application poll loop: hand out up to `max` completed packets in
/// order, clearing dd. No syscall, no copy.
pub fn rx_burst(&mut self, max: usize, out: &mut Vec<Pkt>) -> usize {
todo!()
}
/// Zero-copy view of a packet's bytes.
pub fn data(&self, p: Pkt) -> &[u8] {
todo!()
}
/// The application is done with the packet: its buffer returns to the pool.
pub fn release(&mut self, p: Pkt) {
todo!()
}
pub fn missed(&self) -> u64 {
self.missed
}
}
Read the overview, then the Mempool Library, Mbuf Library and Poll Mode Driver chapters. Map each concept onto your drill: mempool = free list, rte_eth_rx_burst = rx_burst, descriptor refill = refill, imissed = missed.
Day 3 — DPDK and AF_XDP
Take the whole NIC, or borrow one queue
Two ways to own the rings from user space
DPDK (the Data Plane Development Kit) is the maximalist option. You unbind the NIC from its kernel driver and bind it to vfio-pci, which uses the IOMMU so the device can only DMA into memory you mapped for it. A user-space poll-mode driver then programs the hardware directly. Packet buffers come from mempools in hugepages (2 MB or 1 GB pages, so a few TLB entries cover the whole pool), and one or more cores spin in rte_eth_rx_burst, pulling up to a burst (commonly 32) of mbufs per call. That gives you no interrupts, no system calls on the data path and line rate on a handful of cores. It also means the kernel no longer sees the NIC: no ip addr, no tcpdump (DPDK ships its own capture tool), no kernel TCP. You implement or link whatever protocols you need, down to ARP and IGMP for multicast joins. Some NICs, notably NVIDIA/Mellanox ConnectX via the mlx5 driver, use a bifurcated model where the kernel keeps the interface and DPDK takes only the flows you steer to it.
AF_XDP (Linux 4.18 and later) is the kernel's own answer, and it's the pragmatic middle ground. You register a UMEM, a region of your memory split into fixed-size frames, and create an AF_XDP socket with four rings: fill (buffers you give the kernel for receiving), RX (filled frames coming back), TX, and completion (TX buffers you can reuse). Those are the descriptor-ring contract again, one level up. A small XDP program (eBPF, run by the driver before any sk_buff exists) decides per packet whether to redirect it into your socket or let it continue up the normal stack. With driver support in zero-copy mode, the NIC DMAs straight into your UMEM. Without it, copy mode still skips most of the stack. The NIC stays a normal Linux interface, so SSH, monitoring and everything else on that port keep working, and you bypass only the multicast group or port that matters.
| Kernel stack | AF_XDP (zero-copy) | DPDK | |
|---|---|---|---|
| Who owns the NIC | kernel | kernel (you borrow queues) | your process |
| Per-packet kernel work | full stack | an XDP program, no sk_buff | none |
| Interrupts | yes (or busy-poll) | optional (busy-poll mode) | none |
| Protocols | all of Linux | raw frames: you parse | raw frames: you parse, or a user-space stack |
Tooling (tcpdump, ss, firewall) | everything | everything for non-redirected traffic | DPDK's own tools only |
| Typical use | everything else | selective acceleration, mixed traffic | appliances, dedicated feed handlers |
Read the UMEM and rings sections and draw the four rings with arrows showing who produces and who consumes each. Note the XDP_ZEROCOPY / XDP_COPY bind flags and the need-wakeup flag. It's the same SQPOLL idea you saw last week.
Why XDP runs before sk_buff allocation, how it compares with DPDK on throughput, and the explicit design goal of cooperating with the kernel rather than replacing it. Focus on the design section and the comparison figures.
What is the fundamental difference between DPDK and AF_XDP in who controls the NIC, and why might an exchange prefer AF_XDP for its market-data publisher even if DPDK benchmarks slightly faster?
With DPDK on your market-data host, tcpdump, ss and your firewall no longer see the hot traffic. List what you'd build or buy to replace each (capture, per-flow counters, filtering), and what your on-call engineer does at 9:31 when a client says they're missing packets.
Day 4 — How trading firms actually do it
Onload, ef_vi, TCPDirect — and the budget
The socket API you keep, the one you give up, and the arithmetic
The trading world's default for a long time has been Solarflare NICs (Solarflare was bought by Xilinx in 2019, and Xilinx by AMD in 2022), largely because of their software stack:
- Onload is a complete TCP/UDP stack in user space that implements the BSD socket API. You run an unmodified binary under it (
onload ./gateway) and it intercepts socket calls viaLD_PRELOAD, sorecvreads straight from the NIC's rings in your process. You keepepoll, TCP semantics and your code. You can make it spin instead of sleep (EF_POLL_USEC). It's the easiest bypass to adopt, and order-entry gateways that need real TCP often use it. - ef_vi is the layer underneath: a low-level API to a virtual interface with its own RX/TX descriptor rings and event queue. You install filters (for example "this multicast group to my VI") and receive raw Ethernet frames, so parsing IP and UDP and handling IGMP are your job. Feed handlers that want the absolute minimum between NIC and parser use it.
- TCPDirect is a minimal, zero-copy TCP/UDP API built on ef_vi. It's faster than Onload because it drops socket-API compatibility.
Other vendors have equivalents (NVIDIA's VMA for ConnectX, the ExaNIC libraries from Exablaze, now Cisco), and beyond software bypass sit FPGAs that parse the feed and fire orders in hardware, with tick-to-trade measured in hundreds of nanoseconds. The pattern is consistent. Market data (UDP multicast, read-only, parse-heavy) goes to the lowest-level API the team can maintain. Order entry (TCP, stateful, must survive retransmits) goes through Onload or TCPDirect, because writing a correct TCP stack is a project of its own.
What changes in your code when you adopt any of these:
- The receive loop becomes a poll loop on a pinned, isolated core that never sleeps (week 30). Idle costs a whole core.
- Data arrives in NIC-owned buffers. Parse it in place (week 11's zero-copy views), and copy out only what must outlive the buffer.
- Batching is natural: a poll returns every frame that has arrived (week 32's lesson).
- NUMA matters. The NIC hangs off one CPU socket's PCIe lanes, so poll from a core on that socket and allocate buffers in its memory, or every packet crosses the interconnect.
Now do the arithmetic. The visualiser below lays out a typical kernel receive path next to a bypass path. Change the stage costs and watch which ones dominate. The drill then turns the same arithmetic into functions: wire time, line rate, cores needed and a go/no-go verdict.
| 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.
Latency budget: kernel vs bypass
#[derive(Clone, Copy, Debug)]
pub struct Stage {
pub name: &'static str,
pub ns: u64,
pub in_kernel: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
KernelIsFine,
BypassNeeded,
BypassNotEnough,
}
/// Time to clock a frame of `frame_bytes` onto a `gbps` link, in ns,
/// rounded up.
pub fn serialization_ns(frame_bytes: u64, gbps: u64) -> u64 {
todo!()
}
/// Maximum frames per second on the link. Each frame also occupies 8 bytes
/// of preamble + start-of-frame delimiter and a 12-byte inter-frame gap.
/// Rounded down.
pub fn line_rate_pps(frame_bytes: u64, gbps: u64) -> u64 {
todo!()
}
/// Cores needed to absorb `pps` if each packet costs `per_packet_ns` of CPU
/// on one core. Rounded up.
pub fn cores_needed(per_packet_ns: u64, pps: u64) -> u64 {
todo!()
}
/// Wire-to-application latency. With `bypass`, the in-kernel stages vanish
/// and the user-space receive path (`user_rx_ns`: poll + protocol parse)
/// is added instead.
pub fn path_ns(stages: &[Stage], bypass: bool, user_rx_ns: u64) -> u64 {
todo!()
}
/// Fraction of the end-to-end time (path + application work) that bypass
/// removes: (kernel_total - bypass_total) / kernel_total.
pub fn bypass_saving(stages: &[Stage], user_rx_ns: u64, app_ns: u64) -> f64 {
todo!()
}
/// Decide, given the end-to-end target for path + application work.
pub fn verdict(kernel_path_ns: u64, bypass_path_ns: u64, app_ns: u64, target_ns: u64) -> Verdict {
todo!()
}
Bypass takes your receive path from 6.4 µs to 0.95 µs. Why might that be worth millions to one firm and nothing to another?
The open-source Onload and ef_vi tree. Read the README and the ef_vi examples directory: notice how small a receive loop is once the NIC rings are yours, and how much setup (filters, memory registration, event queues) comes before it.
Day 5 — Hardware timestamps
Proving the microseconds
If you can't timestamp at the wire, you can't claim the saving
Bypass claims are claims about time, so measure them at the right place. A software timestamp taken when your code reads the packet includes everything you're trying to measure. Modern NICs can timestamp each frame in hardware as it passes the MAC, using their own clock: the PTP hardware clock (PHC).
On Linux:
- Enable it on the device with the
SIOCSHWTSTAMPioctl (an RX filter, often "all packets", plus TX on), then ask for it per socket withSO_TIMESTAMPING, usingSOF_TIMESTAMPING_RX_HARDWARE | SOF_TIMESTAMPING_RAW_HARDWAREand the TX equivalents. - RX timestamps arrive as ancillary data on
recvmsg(anSCM_TIMESTAMPINGcontrol message carrying three timespecs, where the raw hardware stamp is the third). TX timestamps come back later on the socket's error queue (MSG_ERRQUEUE), because the frame is timestamped when it actually leaves, aftersendhas returned. - Bypass APIs expose the same stamps directly. ef_vi can deliver an RX timestamp with each receive event, and DPDK exposes NIC-dependent RX timestamps through mbuf fields.
A timestamp is only as good as its clock. PTP (IEEE 1588) synchronises the NIC's PHC to a grandmaster clock over the network (ptp4l from linuxptp), and phc2sys then steers the system clock from the PHC. With hardware timestamping on every hop and PTP-aware switches, sub-microsecond agreement is routine. Over a network that isn't PTP-aware, queueing jitter dominates. Regulators care too: under MiFID II (RTS 25), firms using high-frequency algorithmic trading must keep business clocks within 100 µs of UTC with 1 µs timestamp granularity.
The strongest measurements don't use the trading host's clock at all. Firms put passive optical taps on the links into and out of the box and timestamp both directions with an FPGA capture device on one disciplined clock. "Packet in" to "order out", as seen on the wire, is the only tick-to-trade number that can't flatter the software (week 46 builds tooling on exactly those captures).
The SO_TIMESTAMPING flags, SCM_TIMESTAMPING layout, the error-queue mechanism for TX stamps and the SIOCSHWTSTAMP configuration. Note which stamps are software, which are hardware, and which point in the path each one marks.
Why can't a software timestamp taken in your poll loop prove that kernel bypass saved 5 µs, and where would you take the timestamps instead?
Mark every point where you would record a timestamp for one order in your exchange (NIC RX, gateway decode, risk, match, publish, NIC TX). For each, say which clock it uses, its expected accuracy, and how you'd correlate them into one timeline for week 39's latency budget.
Day 6 — Write the decision memo
Decide like it's your money
Hardware, licences, people and risk on one page
Your exchange has two network paths that matter. Market data out is UDP multicast, published by the exchange and consumed by every client. Order entry in is TCP sessions from clients to the gateway. A real exchange also cares about the client side of the same links. A firm connecting to you bypasses on its side, and your publisher's jitter becomes its problem.
Write the memo as if a CTO will approve or reject spending on it. For each path, state the current measured latency and its tail, the budget from week 39's targets (draft them now if you haven't), which bypass option you'd use (none, Onload, ef_vi/TCPDirect, AF_XDP, DPDK), what it costs (NICs, cores burned polling, engineering time, lost tooling, vendor lock-in), how you'd measure the result, and how you'd fall back if the bypass stack misbehaves in production. "Don't bypass yet, and here's the number that would change my mind" is a legitimate conclusion.
Draft the memo's skeleton: the one-sentence recommendation for each path, the three numbers that justify it, and the single biggest risk. Then fill it in for the deliverable below.
Capstone
Kernel-bypass decision memo
An annotated packet path, a bypass decision for both of your exchange's network paths, a timestamping plan, and a two-minute explanation you can give out loud.
- Annotated packet path, kernel vs bypass#packet-pathmark when done
A diagram of the receive path from the wire to your parser, drawn twice: stock kernel and your chosen bypass. Label every stage with an estimated cost and its source (your week 31 measurements, a cited figure, or "guess, to be measured"). Mark which stages vanish, which remain and which change owner.
- Decision memo for market data and order entry#bypass-decisionmark when done
One page. For each path: current numbers, the budget, the chosen option or "not yet", costs (hardware, cores, people, tooling, lock-in), the measurement that would prove it worked, and the fallback. Use the bypass-budget drill's arithmetic for the throughput and Amdahl arguments.
- Hardware timestamping plan#timestamp-planmark when done
Where timestamps are taken (NIC RX/TX, application stages, a tap if you had one), which clock each uses, how clocks are synchronised (PTP, phc2sys), the accuracy you expect, and how the stamps join up into one timeline for week 39.
- Two-minute spoken explanation, timed#explain-aloudmark when done
Record yourself answering "What does kernel bypass actually remove, and when isn't it worth it?" in under two minutes, with no notes. It must name the stages removed, the stages that remain, one concrete number, and one operational cost. Listen back once and redo it if you used the word "faster" without a number.
Feeds into
No later week lists this one as a hard prerequisite, but it comes up again:
- Week 39 builds the tick-to-trade budget. The network stages you estimated here are its first rows.
- Week 46 replays hardware-timestamped captures of the kind described on Day 5.
- Week 50's interview deep dives expect you to explain this memo without notes.