Skip to content
kurtsiegfriedPublic

About

XDP / AF_XDP receive + send path for the Aeron C media driver

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

67 Commits

Folders and files

Repository files navigation

aeron-xdp

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_PRELOAD against stock aeronmd via the cdylib libaeron_xdp.so. The Aeron loader finds the v-table through dlsym(RTLD_DEFAULT, ...). No upstream patch.
  • B — static link into aeronmd_s via the staticlib libaeron_xdp.a plus a small upstream-Aeron patch in patches/. Single-binary deployment with no LD_PRELOAD.
  • C — embedded media driver for hosts that call aeron_driver_init in-process (C/C++ or rusteron-based Rust). Uses the C API in bindings/include/aeron_xdp.h or the typed Rust wrapper in rusteron-adapter/; configuration is via setter calls rather than env vars.

Architecture

aeron-xdp architecture

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.

Status

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 netlink RTM_GETNEIGH with a sendmmsg cold-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, no ethtool -N required
  • Embedded eBPF objects in the library — no separate .o file to ship; both frags and no-frags variants are baked into the cdylib and staticlib via aya::include_bytes_aligned!
  • Three deployment shapes — cdylib + LD_PRELOAD (Option A), staticlib + Aeron CMake patch (Option B), or embedded media driver via aeron_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.

Layout

.
├── 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/.

Build

# 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-linker

Run aeronmd with XDP

Three deployment shapes — pick one.

Option A — LD_PRELOAD against stock aeronmd

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 \
    aeronmd

Option B — static link into aeronmd_s (no LD_PRELOAD)

One-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_s

Option C — embedded media driver (C/C++ or Rust host)

For 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 against libaeron_xdp.a and libaeron_driver.so. CMake build. The README documents the --whole-archive / --export-dynamic link recipe that keeps the v-table statics alive.
  • examples/rusteron-embedded/ — Rust binary using rusteron's AeronDriver::launch_embedded plus our adapter's XdpOptions::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.

Behaviour

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.

Test

# 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.sh

Regenerating the FFI bindings

When bumping the supported Aeron version, regenerate aeron_ffi.rs:

AERON_SRC=/path/to/aeron ./scripts/regen-ffi.sh

Requires 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.

About

XDP / AF_XDP receive + send path for the Aeron C media driver

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages