XDP / AF_XDP fast-path for the Aeron C media driver. Receive by
default; send is opt-in (AERON_XDP_SEND=1).
A separate Rust workspace, deliberately decoupled from Aeron's CMake build. Three deployment shapes are supported (see Run aeronmd with XDP below):
- A —
LD_PRELOADagainst stockaeronmdvia the cdyliblibaeron_xdp.so. The Aeron loader finds the v-table throughdlsym(RTLD_DEFAULT, ...). No upstream patch. - B — static link into
aeronmd_svia the staticliblibaeron_xdp.aplus a small upstream-Aeron patch inpatches/. Single-binary deployment with noLD_PRELOAD. - C — embedded media driver for hosts that call
aeron_driver_initin-process (C/C++ or rusteron-based Rust). Uses the C API inbindings/include/aeron_xdp.hor the typed Rust wrapper inrusteron-adapter/; configuration is via setter calls rather than env vars.
Receive path (blue): NIC → XDP eBPF program → XSKS map → AF_XDP
RX socket → RxLoop drain in userspace → libaeron_driver receiver.
Send path, opt-in (green): libaeron_driver sender → send_func in
our v-table → TxLoop → AF_XDP TX ring on a reserved last queue →
wire. Control plane (orange dashed): Aeron's add_endpoint updates
the IPV{4,6}_PORTS BPF map so the eBPF program will match new
transports. Same-host loopback and sender-mode-disabled TX fall
through to the default libaeron_driver UDP path.
Feature surface:
- v4 + v6 receive path, multicast included
- AF_XDP send path (opt-in via
AERON_XDP_SEND=1) using a reserved last RX queue; per-destination MAC resolution via netlinkRTM_GETNEIGHwith asendmmsgcold-cache seed; mlx5 TX checksum offload by default - Dual eBPF variants (frags + no-frags) auto-selected by interface MTU
- Zero-copy fallback chain on RX:
ZC+SG→ZC→copy+SG - Per-RX-queue AF_XDP sockets polled together via one
poll(2); RSS hash steers traffic, noethtool -Nrequired - Embedded eBPF objects in the library — no separate
.ofile to ship; both frags and no-frags variants are baked into the cdylib and staticlib viaaya::include_bytes_aligned! - Three deployment shapes — cdylib +
LD_PRELOAD(Option A), staticlib + Aeron CMake patch (Option B), or embedded media driver viaaeron_xdp.h/aeron-xdp-rusteron(Option C). See Run aeronmd with XDP below. - Bindgen-generated FFI to Aeron's transport headers, with a drift
test against
$AERON_SRC
Performance characterization (cluster0, mlx5 ConnectX-4 Lx, MTU 9000,
copy mode, with AERON_*_IDLE_STRATEGY=noop on sender/receiver/conductor
for the XDP variants; medians of 15 cping/cpong runs at 500 k pings
each — see docs/soak-p1.csv for the raw 225-run dataset):
Latency (p50 µs):
| msg | default | XDP copy | XDP full | XDP win |
|---|---|---|---|---|
| 32 B | 117 | 28 | 27 | 4.3× |
| 200 B | 117 | 28 | 27 | 4.3× |
| 1024 B | 122 | 30 | 30 | 4.1× |
Throughput (median msg/s, single publisher, 5 M msgs/run):
| msg | default | XDP copy | XDP full | XDP win |
|---|---|---|---|---|
| 200 B | 486 k | 1.10 M | 1.10 M | 2.26× |
| 1024 B | 118 k | 236 k | 235 k | 2.00× |
Tail behaviour: 0 / 135 latency runs exceeded a 100 ms maximum
across the three variants; max-of-maxes across all 90 XDP runs was
12.2 ms. The earlier "xdp_full 1024 B throughput collapse" cell came
back clean (15 / 15 runs in 231–300 k msg/s, no sub-100 k outlier).
The noop idle strategy requirement is documented in
docs/perf-investigation.md — without it, Aeron's default backoff
idle calls nanosleep(1µs) which can hit a Linux HRTIMER wake-up
quirk and freeze for ~900 ms. XDP's fast per-iteration loop makes the
parking states more frequent, so the issue surfaces; the fix
(busy-spin idle on dedicated cores) is already the standard config
for Aeron low-latency deployments.
Deployment model: aeronmd loads and attaches the eBPF program in-process,
so the binary needs CAP_BPF, CAP_NET_ADMIN, and CAP_NET_RAW. The
intended production path is a systemd unit with
AmbientCapabilities=CAP_BPF CAP_NET_ADMIN CAP_NET_RAW; on a developer
workstation, sudo works.
Shared UMEM (XDP_SHARED_UMEM) is implemented in
RxSet::new_shared and gated by
AERON_XDP_SHARED_UMEM=1. The default stays per-queue: a latency A/B
on cluster0 (8 × 50k cping pings each side) showed p50/p99/p999
indistinguishable between modes, and total memory is identical with
the current API. The kernel constraint that drove the earlier
EINVAL — secondaries must bind with sxdp_flags == XDP_SHARED_UMEM
exactly — is documented in
AfXdpSocket::create_shared; the bisect
tooling lives in shared_umem_probe.
.
├── bindings/ `libaeron_xdp.{so,dylib,a}` — v-table + thunks
│ ├── include/
│ │ └── aeron_xdp.h public C header for embedded hosts (Option C)
│ ├── src/
│ │ ├── lib.rs passthru + xdp v-tables, extern "C" thunks
│ │ ├── embed.rs C API for the embedded shape (Option C)
│ │ ├── ffi.rs CamelCase aliases for the bindgen output
│ │ ├── aeron_ffi.rs AUTO-GENERATED by scripts/regen-ffi.sh
│ │ └── xdp.rs AF_XDP RX + TX bindings (Linux only)
│ └── tests/
│ ├── smoke.rs dlopen + symbol-lookup
│ └── ffi_drift.rs regen against $AERON_SRC and byte-diff
├── ebpf/ Aya XDP program (nightly + bpfel-unknown-none)
├── runtime/ userspace AF_XDP — Linux only
│ └── src/
│ ├── afxdp.rs AF_XDP socket: UMEM + 4 rings + bind
│ ├── rx.rs RxLoop + RxSet (per-queue or shared UMEM)
│ ├── tx.rs TxLoop — TX ring + CQ + free-chunk stack
│ ├── proto.rs parse_udp{4,6} + build_udp{4,6}
│ ├── neigh.rs NeighborResolver via netlink RTM_GETNEIGH
│ └── spsc.rs SPSC ring used for conductor → receiver admin
├── rusteron-adapter/ `aeron-xdp-rusteron` — typed Rust wrapper for embedded hosts
│ └── src/lib.rs XdpOptions builder + install through rusteron
├── examples/
│ ├── c-embedded/ minimal C host + CMakeLists for Option C
│ └── rusteron-embedded/ minimal rusteron + adapter binary
├── patches/ opt-in Aeron CMake patch for static-link aeronmd_s
│ ├── aeron-static-link.patch
│ └── apply-to-aeron.sh
├── docs/ architecture diagram + perf write-ups
└── scripts/
├── bench.sh per-cell benchmark harness
├── sweep.sh variant × msg_size × n_pubs matrix
├── sweep_summary.py CSV → Markdown table
└── regen-ffi.sh regenerate aeron_ffi.rs from Aeron headers
ebpf/, runtime/, and rusteron-adapter/ are excluded from the
workspace because they have target-platform / toolchain requirements
(bpfel-unknown-none, Linux+Aya, rusteron's heavy build dep) that
would break workspace-level cargo build on macOS. Each has its own
target/.
# Bindings (macOS or Linux). Produces both libaeron_xdp.so/.dylib
# and libaeron_xdp.a from a single build.
cargo build --release
# → target/release/libaeron_xdp.{so,dylib,a}
# eBPF objects — Linux only; needs nightly + rust-src + bpf-linker.
# `bindings/build.rs` invokes this automatically at bindings build
# time, embedding both variants via `aya::include_bytes_aligned!`.
# Building the eBPF crate standalone is only needed when iterating on
# the eBPF program itself.
(cd ebpf && cargo build --release)
# Userspace runtime + standalone probe binaries (Linux only).
(cd runtime && cargo build --release)
# → runtime/target/release/{xdp_probe,afxdp_probe,afxdp_recv,
# afxdp_send_probe,neigh_probe,
# shared_umem_probe,tx_bench}One-time toolchain setup on a Linux host:
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
cargo install bpf-linkerThree deployment shapes — pick one.
No Aeron rebuild required. The cdylib loads ahead of libaeron_driver.so
and the Aeron loader finds the v-table via dlsym(RTLD_DEFAULT, ...).
sudo env \
LD_PRELOAD=$PWD/target/release/libaeron_xdp.so \
AERON_UDP_CHANNEL_TRANSPORT_BINDINGS_MEDIA=aeron_udp_channel_transport_bindings_xdp \
AERON_XDP_INTERFACE=cluster0 \
aeronmdOne-time: apply patches/aeron-static-link.patch to
your Aeron checkout, then reconfigure with AERON_XDP_STATIC_LIB
pointing at our .a. See patches/ for an idempotent applier
script. The patch is gated on AERON_XDP_STATIC_LIB; an unset variable
leaves the Aeron build unchanged.
AERON_SRC=/path/to/aeron patches/apply-to-aeron.sh
cd $AERON_SRC/cppbuild/Release
cmake -DAERON_XDP_STATIC_LIB=$AERON_XDP_ROOT/target/release/libaeron_xdp.a .
make aeronmd_s
# Run — no LD_PRELOAD.
sudo env \
AERON_UDP_CHANNEL_TRANSPORT_BINDINGS_MEDIA=aeron_udp_channel_transport_bindings_xdp \
AERON_XDP_INTERFACE=cluster0 \
$AERON_SRC/cppbuild/Release/binaries/aeronmd_sFor applications that already initialise the Aeron media driver
in-process (aeron_driver_init) rather than launching aeronmd as a
separate daemon. Use bindings/include/aeron_xdp.h
from C/C++, or the aeron-xdp-rusteron adapter
crate from Rust. Both call
aeron_driver_context_set_udp_channel_transport_bindings with a
direct pointer to our v-table — no env var, no dlsym. Configuration
that's env-driven in options A and B (interface, attach mode, send
toggle, etc.) becomes setter calls on a typed options handle.
Two ready-to-build samples:
examples/c-embedded/— minimal C host linked againstlibaeron_xdp.aandlibaeron_driver.so. CMake build. The README documents the--whole-archive/--export-dynamiclink recipe that keeps the v-table statics alive.examples/rusteron-embedded/— Rust binary using rusteron'sAeronDriver::launch_embeddedplus our adapter'sXdpOptions::install.
The application process owns the XDP attachment, so it needs
CAP_BPF + CAP_NET_ADMIN + CAP_NET_RAW. Options A and B let those
caps live on a daemon; the embedded shape doesn't.
The XDP path engages on receiver-affinity transports; conductor and
non-receiver paths tail-call the libaeron_driver default symbols.
Cross-host traffic redirected by XDP and same-host loopback (which
never touches XDP) are both handled — the receiver poll_func runs
both the AF_XDP drain and the default kernel poll. With
AERON_XDP_SEND=1, the send path also goes through AF_XDP via a
reserved last RX queue; without it, sends fall through to
sendmmsg.
Optional knobs (env var for Options A/B; setter on
aeron_xdp_options_t / XdpOptions for Option C — see
aeron_xdp.h):
| Env var (A / B) | aeron_xdp.h setter (C) |
Effect |
|---|---|---|
AERON_XDP_INTERFACE=<name> |
aeron_xdp_options_set_interface |
NIC to engage XDP on (required) |
AERON_XDP_MODE=drv|skb|hw |
aeron_xdp_options_set_attach_mode |
XDP attach mode (default drv) |
AERON_XDP_SEND=1 |
aeron_xdp_options_set_send_enabled |
Enable AF_XDP send path (reserves the last RX queue) |
AERON_XDP_SHARED_UMEM=1 |
aeron_xdp_options_set_shared_umem |
RX sockets share a single UMEM via XDP_SHARED_UMEM |
AERON_XDP_FORCE_COPY=1 |
aeron_xdp_options_set_force_copy |
Skip ZC attempts, go straight to copy+SG |
AERON_XDP_OBJECT=/path/aeron_xdp |
aeron_xdp_options_set_ebpf_object_frags |
Override the embedded frags eBPF |
AERON_XDP_OBJECT_NOFRAGS=/path/aeron_xdp_nofrags |
aeron_xdp_options_set_ebpf_object_nofrags |
Override the embedded no-frags eBPF |
AERON_RECEIVER_CPU_AFFINITY=N |
(Aeron-side) | Pin Aeron's receiver thread (improves tail latency) |
The two paths are mutually exclusive on a per-process basis: the first
configuration access latches the snapshot. Calling aeron_xdp_install
before aeron_driver_init overrides any env vars set at the same time.
# Bindings unit + smoke tests (cross-platform). The smoke test is
# macOS-only at runtime — the cdylib has DF_1_NOW from rustc's default
# cdylib link flags, so dlopen on Linux rejects the unresolved
# libaeron_driver refs at load time. Use `cargo test --release --lib`
# on Linux to skip it.
cargo test --release
# Standalone adapter crate (excluded from the workspace).
(cd rusteron-adapter && cargo test --release)
# FFI drift check — requires an Aeron source checkout.
AERON_SRC=/path/to/aeron cargo test --release --test ffi_drift
# End-to-end benchmark cell (Linux host with cluster0 set up, see
# the project memory for the test rig).
AERON_XDP_ROOT=$PWD \
AERON_BUILD_DIR=/path/to/aeron/cppbuild/Release/binaries \
PIN_DRIVER=1 PIN_IRQS=1 \
VARIANT=xdp_copy WORKLOAD=latency MSG_SIZE=100 N_MESSAGES=50000 \
bash scripts/bench.shWhen bumping the supported Aeron version, regenerate aeron_ffi.rs:
AERON_SRC=/path/to/aeron ./scripts/regen-ffi.shRequires bindgen-cli (cargo install bindgen-cli) and libclang. The
drift test catches when the committed bindings disagree with what
regen-ffi.sh would produce, so cargo test --test ffi_drift after
the bump confirms the regeneration is clean.
The hand-rolled ffi.rs is a thin layer of CamelCase aliases over the
generated raw types — that's where to add Rust-idiomatic names if more
are needed.