Kernel-bypass networking in C++ without the boilerplate. Write your packet loop once
against four lines of API, then pick ef_vi, DPDK, verbs, AF_XDP or PACKET_MMAP from a
config file — no code change, no recompile.
This project was inspired by CERN's Controls Middleware which uses a system called Remote Device Access(RDA).
Same NIC, same host, same application code — only the transport line in the TOML
changed. One frame in flight, 64-byte frames, RTT measured with rdtscp on an isolated
core. ef_vi (CTPIO): 1.866 µs median — 0.93 µs one-way — with a 3.306 µs max over
44.3 B packets in 24 h. DPDK (sfc PMD): 3.506 µs median over 24.2 B packets. Zero
loss in both.
The full campaign — verbs, DPDK, AF_XDP, DPDK-over-AF_XDP and ef_vi across the Solarflare X2522-Plus (25G), ConnectX-4 Lx (25G), XXV710-DA2 (25G) and I225-V (2.5G copper), with tail percentiles out to P99.999 — is in docs/Benchmarks.md. The driver bugs we hit on the way (and how we proved them) are in docs/Known_driver_issues.md.
Kernel bypass is not hard because the concepts are hard. It's hard because every stack
has a different, sprawling setup ritual: DPDK wants EAL init, hugepages, a vfio-pci bind,
mempools, ethdev queue config and PMD devargs. AF_XDP wants a UMEM, four rings, an XSK
socket, an XDP program, queue steering and busy-poll socket options. PACKET_MMAP wants
TPACKET_V2 ring geometry and a fanout policy. Getting any of them merely working is a
week; getting them fast is where the real traps live, and none of that knowledge
transfers between them.
ABTRDA3 does the ritual for you and hands back one interface:
┌────────────────────────────────────────────┐
your packet loop ──│ acquire / commit + tryReceive / release │
└────────────────────┬───────────────────────┘
│ C++20 concepts, all inlined
┌─────────────────┬──────────────────┼─────────────────┬────────────────┐
ef_vi DPDK AF_XDP PACKET_MMAP verbs
(CTPIO/X2522) (i40e/igc/mlx5/sfc) (zero-copy) (no deps) (RAW_PACKET QP)
- Zero-cost. The transport is a template parameter, not a virtual base. Every hot-path
call inlines straight down to
rte_eth_rx_burst/xsk_ring_cons__peek. There is no vtable, no type erasure and no allocation on the data path. - The fast configuration is the default. Busy-poll, zero-copy, NAPI deferral, the correct TX-kick policy, interrupt-throttle registers, queue steering — all set and, more importantly, verified by reading the state back from the kernel and printed at startup. If you didn't get zero-copy, it tells you.
- Honest measurement built in. An
rdtscp-timed RTT client and a HdrHistogram recorder that survives billion-sample soaks in ~1.5 MB of RAM.
The default build compiles the two transports available on essentially every Linux
system — packet_mmap and af_xdp — and needs only libxdp (+ clang for the BPF
programs; libbpf is fetched and built statically):
cmake -B build/x86_64-release -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build build/x86_64-releaseThe kernel-bypass transports sit on heavyweight vendor stacks, so each is opt-in:
| CMake option | Transport | Extra dependency |
|---|---|---|
-DABTRDA3_WITH_DPDK=ON |
dpdk |
libdpdk (25.11 LTS); intel_iommu=on iommu=pt for the vfio path |
-DABTRDA3_WITH_VERBS=ON |
verbs |
rdma-core (libibverbs + libmlx5) |
-DABTRDA3_WITH_EFVI=ON |
ef_vi |
Onload (libciul + etherfabric headers) |
-DABTRDA3_WITH_I210=ON |
intel_i210 |
none (uses the ABTEdge backend every build already has) |
-DABTRDA3_WITH_ALL=ON |
all of the above | — |
-DABTRDA3_WITH_ALL=ON (or the x86_64-release-full preset) is the full benchmark
build; selecting a transport in the TOML that isn't compiled in fails at startup with
the exact reconfigure flag to use. C++20 compiler (GCC 13+) either way.
Both concepts are ~5 lines of code (src/backends/common/RingConcepts.hpp). Anything that
satisfies them works with every app in this repo:
template <typename T>
concept TxRing = requires(T t, std::span<const std::uint8_t> frame, std::uint32_t len) {
{ t.acquire(len) } noexcept -> std::same_as<std::uint8_t*>; // borrow a TX buffer
{ t.commit() } noexcept; // hand it to the NIC
{ t.send(frame) } noexcept -> std::same_as<bool>; // acquire+copy+commit
{ t.prefillRing(frame) } noexcept; // stamp a header template
};
template <typename T>
concept RxRing = requires(T t) {
{ t.tryReceive() } noexcept -> std::same_as<RxFrame>; // peek, no copy
{ t.release() } noexcept; // give the buffer back
};// ef_vi — Solarflare/AMD kernel bypass. Allocates the virtual interface, maps the
// CTPIO aperture, installs the raw-L2 filter. In-order CTPIO writer, cut-through
// threshold >= frame length: zero fallbacks by construction.
EtherFabricVirtualInterface<EtherFabricMode::RxTx> vi("enp1s0f0");
if (!vi.init()) return 1;
// DPDK — any PMD (i40e / igc / mlx5 / sfc). Binds vfio-pci, inits EAL, sets up mempool + queues.
DPDK<DpdkMode::RxTx> nic("xxv0", /*lcore=*/5, /*driver=*/"i40e");
if (!nic.init()) return 1;
// AF_XDP — zero-copy + busy-poll. Loads the XDP program, builds the UMEM + 4 rings,
// steers the queue, applies the NAPI regime, then reads back what the kernel granted.
AFXDP<AFXDPMode::RxTx> xsk("xxv0", /*queue=*/0);
if (!xsk.init(/*deferral=*/false, /*irqSuspend=*/false)) return 1;// Transmit — acquire a buffer straight in the ring, write in place, commit. No copy.
if (std::uint8_t* buf = tx.acquire(64)) {
std::memcpy(buf + 14, payload, 50); // header was pre-stamped by prefillRing()
tx.commit(); // rings the doorbell
}
// Receive — peek the frame where the NIC DMA'd it, then release the buffer.
if (RxFrame f = rx.tryReceive(); !f.data.empty()) {
handle(f.data); // std::span<const std::uint8_t>
rx.release();
}Because the loop is generic over the concepts, you write it once — this is the actual
shape of the --server reflector, minus the shutdown handling:
template<TxRing Tx, RxRing Rx>
void reflect(Tx& tx, Rx& rx, std::uint32_t frameSize, std::stop_token stop) {
while (!stop.stop_requested()) {
RxFrame rxf = rx.tryReceive();
if (rxf.data.empty()) continue; // busy-poll, no syscall
std::uint8_t* dst = tx.acquire(frameSize); // slot in the TX ring
std::memcpy(dst, &rxf.data[6], 6); // dst MAC = RX src
std::memcpy(dst + 6, &rxf.data[0], 6); // src MAC = RX dst
std::memcpy(dst + 12, &rxf.data[12], frameSize - 12); // rest of the frame
rx.release(); // free the RX slot
tx.commit(); // ring the doorbell
}
}One copy, RX ring straight into the TX ring — no intermediate buffer, no allocation, no
syscall. And it compiles unchanged against DPDK<RxTx>, AFXDP<RxTx>, PacketMmapTx +
PacketMmapRx, and Verbs<RxTx>.
One binary, one config file, four modes:
# Traffic generator — flood N frames from the [client] port
sudo ./abtrda3_test --txgen --count 1000000 --config abtrda3_test.toml
# Packet sink — receive + validate on the [server] port, report loss
sudo ./abtrda3_test --rxsink --config abtrda3_test.toml
# RTT: reflector on one host/port ...
sudo ./abtrda3_test --server --config abtrda3_test.toml
# ... originator on the other. Sends, spins for the echo, records the rdtscp delta.
sudo ./abtrda3_test --client --config abtrda3_test.toml--client runs for duration_sec with one frame in flight (send → busy-poll for the
matching echo → record), which is what makes the number a latency measurement rather
than a throughput one. Samples go over an SPSC queue to a histogram thread on its own
isolated core, so recording never touches the hot path. On exit you get:
=== Round-Trip Latency Results (1645735 samples) ===
Min: 12.630 us P99.9: 13.886 us
Median: 13.388 us P99.99: 13.953 us
P99: 13.798 us P99.999: 14.022 us
Max: 14.080 us Mean: 13.427 us
-- one-way estimate (RTT/2) --
Median 1-way: 6.694 us
plus a percentile CSV and a per-bucket histogram CSV, ready for
test/latency_analysis/plot_latency_hist.py (single run) or plot_latency_compare.py
(the side-by-side above).
Kernel-bypass benchmarks have a lot of knobs, and burying them in command-line arguments makes runs impossible to reproduce six months later. Everything lives in one file you can commit next to the results:
[general]
ether_type = 0x88B5
frame_size = 64 # >= 26 (14B eth hdr + 4B seq + 8B TSC stamp)
send_interval_us = 0 # 0 = back-to-back, 1000 = 1 ms paced
output = "rtt.csv" # writes rtt.csv + rtt.hist.csv
# Tx port — used by --client and --txgen
[client]
transport = "dpdk" # "dpdk" | "af_xdp" | "packet_mmap" | "verbs"
interface = "xxv0"
driver = "i40e" # the port's kernel driver (DPDK only)
mac = "40:a6:b7:02:b3:10"
cpu_core = 5 # the busy-poll core (isolate it: isolcpus=...)
duration_sec = 60 # 60 = smoke test, 86400 = 24 h soak
recorder_core = 7 # histogram thread — must differ from cpu_core
dpdk_bifurcated = false # true for mlx5 (keep the kernel driver, no vfio unbind)
dpdk_afxdp_pmd = false # true to run DPDK *over* an AF_XDP socket
# Rx port — used by --server and --rxsink
[server]
transport = "dpdk"
interface = "xxv1"
driver = "i40e"
mac = "40:a6:b7:02:b3:11"
cpu_core = 6
[af_xdp] # only read when transport = "af_xdp"
enable_deferral = false # napi_defer_hard_irqs + gro_flush_timeout
enable_irq_suspend = false # kernel 6.13+ irq-suspend-timeout regimeSwitching transport is one word. Change transport = "dpdk" to "af_xdp" on both
roles and re-run — same binary, same loop, same measurement methodology. That is what
makes the comparison at the top of this page an apples-to-apples one, and it's the whole
point of the library.
The two roles are independent, so you can also pair different transports across a link
(a DPDK originator against a PACKET_MMAP reflector, for instance) to isolate which side
of the wire a cost lives on.
| Transport | Backend | Notes |
|---|---|---|
ef_vi |
Solarflare/AMD X2522 (Onload libciul) | The fastest transport measured: CTPIO cut-through TX, 1.866 µs RTT / 0.93 µs one-way |
dpdk |
i40e (XXV710), igc (I225-V), mlx5 (ConnectX-4 Lx), sfc (X2522) | vfio-pci pass-through, or bifurcated for mlx5 |
af_xdp |
Any driver with native XDP + zero-copy | Kernel stays in the datapath; i40e needs a one-line busy-poll patch |
packet_mmap |
AF_PACKET / TPACKET_V2 |
No dependencies, no rebinding — the portable baseline |
verbs |
mlx5 RAW_PACKET QP | RDMA userspace path, 2.426 µs RTT on ConnectX-4 Lx |
intel_i210 |
Custom poll-mode driver | Bare-metal register PMD built directly on ABTEdge MMIO + DMA — no vendor SDK underneath |
All published numbers come from one machine: Intel i9-11900K (SMT off, C-states off, fixed
5.0 GHz), Ubuntu 26.04, custom CONFIG_HZ=100 kernel with nohz_full + isolcpus on the
poll cores and every device IRQ steered off them. The two ports of each NIC are cabled
back to back, so a frame's whole journey is silicon and wire — no switch in the path.
Methodology, kernel command line and BIOS settings: docs/Benchmarks.md §2.
ABTRDA3 is licensed under the Apache License 2.0 (SPDX: Apache-2.0).
Every first-party source file carries an SPDX identifier.
GPL-2.0 exceptions. Three files interact with the Linux kernel and are licensed GPL-2.0, marked by their own SPDX headers:
| File | Why GPL |
|---|---|
src/backends/AF_XDP/af_xdp_kern.c |
XDP/BPF program, loaded into the kernel at runtime |
src/backends/bpf/xdp_filter.bpf.c |
XDP/BPF EtherType filter, same runtime-load model |
patches/i40e-afxdp-busypoll.patch |
Patch against the GPL-2.0 in-kernel i40e driver |
The BPF programs are compiled to standalone .o objects and loaded into the kernel at
runtime — they are never linked into the Apache-2.0 binary. This is the same
userspace/BPF license split used by Cilium and the other major XDP projects. The i40e
patch modifies the kernel itself, so it can only ever be GPL-2.0.
Dependencies are fetched at configure time or system-installed, never vendored:
ABTEdge (Apache-2.0), {fmt} (MIT),
toml++ (MIT), rigtorp/SPSCQueue (MIT), HdrHistogram_c (BSD-2-Clause + CC0),
libbpf and libxdp (LGPL-2.1 OR BSD-2-Clause), DPDK (BSD-3-Clause), rdma-core /
libibverbs (GPL-2.0 OR Linux-OpenIB), and Onload's userspace libciul (BSD-2-Clause).
