Network Setup — AF_XDP Ingest and Core Pinning¶
Docs-site note (updated 2026-07-11): Part of the Vexor documentation site (Anza-style). This page documents how the live testnet validator ingests the network firehose and how its work is laid out across the CPU. Every value below is read directly from the deploy script (
deploy.sh), the topology map in the binary (src/vex_topo.zig), and the live/procthread affinities — not from memory. As of this update, most runtime feature envs are baked to their proven values at boot (see Environment Variable Reference); the deploy command below shows only the operator-facing, machine-specific settings you still need to pass.
This page is about the datapath and CPU layout: enabling AF_XDP zero-copy ingest on the Mellanox NIC, the NIC/queue/IRQ tuning that makes it work, the static tile→core pinning model, and the runtime verifiers that confirm both. For the hardware these run on see Hardware; for the protocol-level networking design see Architecture: Networking; for the kernel/sysctl knobs see Performance: Tuning.
Two ingest modes: kernel-UDP and AF_XDP zero-copy¶
Vexor can receive Turbine shreds two ways, selected at deploy time by the VEX_ENABLE_AFXDP environment variable:
| Mode | VEX_ENABLE_AFXDP |
Receive path | Active NIC port |
|---|---|---|---|
| Kernel-UDP (default fallback) | 0 (default) |
Standard kernel UDP sockets, NAPI/GRO consumer | enp129s0f0np0 |
| AF_XDP zero-copy (proven, recommended) | 1 |
NIC DMAs shreds straight into a userspace ring (XSK), no per-packet kernel-stack copy | enp129s0f1np1 |
AF_XDP is opt-in — the default deploy is kernel-UDP, which is the proven-safe path on the management port. The
live testnet validator runs with AF_XDP enabled (VEX_ENABLE_AFXDP=1), which is the recommended high-throughput
configuration on the dedicated Mellanox ConnectX-6 Dx
shred port. When AF_XDP is on, all NIC tuning, XDP attach, and the gratuitous-ARP move to the dedicated data
port enp129s0f1np1; the management/SSH port enp129s0f0np0 is never touched.
VEX_BINARY=<pinned> VEX_ENABLE_AFXDP=1 VEX_FRESH_SNAPSHOT=1 VEX_PARALLEL_EXEC=1 \
VEX_PARALLEL_EXEC_CORES=25,26 VEX_TASKSET_CORES=<mask> bash deploy.sh dev
VEX_BINARY, VEX_PARALLEL_EXEC_CORES, and VEX_TASKSET_CORES are machine-specific and are never baked — you
supply them for your own box. VEX_ENABLE_AFXDP, VEX_FRESH_SNAPSHOT, and VEX_PARALLEL_EXEC are shown
explicitly here for clarity, but the last of the three is also one of the ~19 feature envs the binary bakes to
its proven value on boot if you leave it unset (see Environment Variable Reference). With
VEX_ENABLE_AFXDP=1, deploy.sh adds the launch flags --enable-af-xdp --xdp-zero-copy and routes its NIC ops
to enp129s0f1np1. With it unset/0, neither flag is passed and the channel-reopen ethtool operations (which
would reset the link) are skipped entirely.
Prerequisites for AF_XDP¶
AF_XDP needs capabilities, an unlocked-memory limit, and unprivileged-BPF enabled. deploy.sh sets all of these
before launch:
| Requirement | What / why | Set by |
|---|---|---|
| File capabilities | cap_net_raw,cap_net_admin,cap_bpf,cap_perfmon+ep on the binary — lets a non-root validator attach XDP and load the BPF program |
setcap (Step 3) |
ulimit -l unlimited |
AF_XDP's UMEM (the shared frame pool) must be locked in RAM; a 32K-frame × 2048B socket needs ~64 MB locked, and headroom for scaling | ulimit -l (Step 5) |
kernel.unprivileged_bpf_disabled=0 |
Debian defaults to 2 (disabled + locked), which makes BPF_PROG_LOAD return EACCES even with CAP_BPF |
sysctl (Step 5) |
net.core.bpf_jit_enable=1 |
JIT-compile the XDP program for line-rate redirect | sysctl (Step 5) |
If any of these is missing, AF_XDP silently falls back to copy mode (or fails to bind) — the verifier in Confirming the datapath catches this.
NIC, queue, and IRQ tuning for AF_XDP¶
The Mellanox mlx5 driver needs specific settings for AF_XDP zero-copy to engage correctly. deploy.sh Step 5a/5b
clears stale state, then applies these to the data port enp129s0f1np1:
Clean slate first¶
Before re-tuning, deploy.sh detaches any existing XDP program (ip link set dev … xdp off) and wipes Vexor's BPF
pin directory (/sys/fs/bpf/vexor). Stale pins or a leftover XDP program cause rx_xsk_buff_alloc_err to storm and
shreds to be dropped at the NIC→userspace boundary, so the nuke is idempotent and runs every deploy.
Queue layout — the XSK binds queue 0¶
The validator binds its AF_XDP socket (XSK) to queue 0. The tuning reserves q0 exclusively for that socket:
ethtool -L … combined 4— open four combined RX queues.ethtool -X … start 1 equal 3— RSS indirection spreads all non-steered traffic across q1–q3 only, so the default hash never lands anything on q0.- ntuple flow steering routes Vexor's three UDP ports explicitly. In the default
q0steering mode, shred (8003), gossip (8001), and repair (8002) all go to q0; the XDPport_filterprogram on q0 redirects 8003 into the XSK andXDP_PASSes 8001/8002 straight to the kernel UDP sockets (Vexor keeps gossip + repair on kernel-UDP by design — only Turbine rides the XSK). A legacy "spread" mode (VEX_AFXDP_STEER_MODE=spread) puts gossip→q1, repair→q2; it left the XSK starved on this NIC and is kept only as a fallback.
Why queue 0 matters: an AF_XDP socket bound to q0 with traffic steered to a different queue leaves no XSK listener for shreds — repair can never fill missing FEC sets and the validator stalls at boot. Always confirm the XSK is on q0 (see Confirming the datapath).
mlx5 driver knobs (AF_XDP only)¶
| Setting | Value | Why |
|---|---|---|
rx_striding_rq |
off | Striding RQ refills the fill-ring in all-or-nothing 64-frame batches → a low ring storms rx_xsk_buff_alloc_err. Cyclic RQ accepts partial allocation. (The #1 zero-copy fix.) |
rx_cqe_compress |
off | Compressed CQEs fail XSK striding validation. |
ring size (-G rx/tx) |
2048 | 8192 fails XSK validation ("RQ length too big for XSK"). |
| adaptive-rx / adaptive-tx | off | Must be off before setting static rx-usecs, or the driver overrides it. |
rx-usecs / tx-usecs |
20 | Static interrupt coalescing for predictable latency. |
| GRO / LRO / TSO / GSO | off | Shreds are single-datagram; coalescing hurts per-shred latency. |
rx-udp-gro-forwarding |
off | Vexor terminates UDP, it doesn't forward. |
napi_defer_hard_irqs / gro_flush_timeout |
0 under AF_XDP | These are a pair for a busy-poll NAPI consumer; the XSK is drained by Vexor's userspace tile, so deferral just delays RX. (Kernel-UDP keeps the proven 2/200000.) |
IRQ affinity (with the cores-0–4 wall-off)¶
When the optional cgroup-v2 cpuset wall-off is armed (VEX_CPUSET_WALLOFF=1), deploy.sh pins the shred-port q0
completion IRQ (mlx5_comp0) to core 4. Core 4 shares its CCX1 L3 slice with the receive tile on core 5, and is
OS-reserved under the wall-off, so it is a legitimate kernel-IRQ home that lowers RX-completion latency for the
zero-copy turbine path. The IRQ number is derived dynamically from the NIC's PCI address; the pin is a no-op if not
found and is reversible.
The core-pinning / tiling model¶
Vexor runs as a set of pinned tiles — dedicated threads, each owning a specific core, Firedancer-style ("one
tile per core, never reshuffle"). This is implemented statically in the binary (src/vex_topo.zig): each tile
calls sched_setaffinity to pin itself to its assigned core as it spawns. The static map is the live model.
Live tiling table (EPYC 9374F, 32 cores)¶
| Core(s) | Tile | Role |
|---|---|---|
| 5 | recv | AF_XDP TVU receive thread — dedicated, drains the XSK |
| 6 | quic-pump | QUIC ingest pump (may be dormant if gated) |
| 8–15 | verify (×8) | Ed25519 signature-verify workers (8 + worker_id) |
| 16 | replay | The hot replay loop (~95% CPU) — bank execution + PoH |
| 20 | produce | Block production (leader path; dormant unless leader) |
| 24 | gossip | Cluster gossip / CRDS |
| 25, 26 | wave | Parallel-exec WavePool workers (when VEX_PARALLEL_EXEC=1) |
| 28 | txsend | Vote / transaction send; native QUIC vote poller self-pins here |
| 29 | sysvar | Sysvar update tile |
| 30 | repair | Repair request/serve tile |
| 31 | watchdog | Liveness watchdog |
| 4 | ledger tile | VexLedger consumer — cold core, off the consensus path |
| 1–3 | dynamic relief | CCX0 reserve for bursty/floater work — kept off the hot tiles |
| 0 | — | Left to the OS / main thread |
The EPYC 9374F's 8 CCX cache-complexes (4 cores each, 32 MiB L3 per CCX) make this layout cache-aware: consensus-critical tiles sit on cache-warm cores and never share a core with background work; the VexLedger consumer is fenced to a cold core (4) so persistence can never stall replay or voting; and dynamic relief / builds are fenced to CCX0 (cores 1–3) so a compile can't evict the hot working set.
VEX_TASKSET_CORES — the floater carve-out¶
The validator process is launched under taskset -c "$TASKSET_CORES". This mask applies only to unpinned /
floater threads — the static tiles above override it in code via sched_setaffinity, so the mask is not what
places the consensus tiles. Its job is to confine everything else to a safe window:
VEX_ENABLE_AFXDP |
Default TASKSET_CORES |
Note |
|---|---|---|
1 (AF_XDP) |
1-3,5-27 |
CCX0 relief (1–3) included; core 4 excluded so the recv reservation holds |
0 (kernel-UDP) |
1-27 |
No core-4 reservation needed |
Two structural protections apply automatically:
- Wave-core auto-carve: when
VEX_PARALLEL_EXEC=1, the WavePool cores (VEX_PARALLEL_EXEC_CORES, e.g.25,26) are subtracted from the mask so a floater can never preempt a wave worker. This used to be a hand-appliedVEX_TASKSET_CORES=1-3,5-24,27that was easy to forget; it is now done structurally and is idempotent. - Override the whole mask with
VEX_TASKSET_CORES=<list>for diagnostics (e.g. to test whether the core layout, not the zero-copy path, drives a behavior).
Why hot tiles are kept off build/relief cores: the deploy discipline is STOP → BUILD → DEPLOY (the node is brought down before a release build — see Build / Deploy), but even so the relief/build cores are physically fenced to CCX0 (1–3) so any incidental background work lands on a separate cache complex and can never thrash the hot pipeline (verify 8–15, replay 16, produce 20, gossip 24) or the wave workers.
The legacy dynamic re-pinner (OFF by default)¶
tools/vex-fd-pin.sh is an older post-launch poller that re-pinned the hottest threads onto a relief pool. It is
disabled by default (VEX_DYNAMIC_PIN=0) and is now considered redundant and harmful: with the complete
static map in the binary, the poller would migrate the hot replay/gossip tiles off their dedicated cores, fighting
the static pins. It exists only as a stopgap (VEX_DYNAMIC_PIN=1) for hardware without a static map. Do not enable
it on this layout.
Native QUIC and ports¶
Votes are sent over native QUIC (the QUIC vote poller self-pins to core 28). The explicit ports deploy.sh
passes at launch are:
| Port / flag | Value | Purpose |
|---|---|---|
--gossip-port |
8001 |
Gossip / CRDS |
--tvu-port |
8003 |
Turbine shred ingest (the AF_XDP / XSK port) |
--repair-port |
8002 |
Repair |
--rpc-port |
8899 |
JSON-RPC |
--dynamic-port-range |
8100-8200 |
Ephemeral ports (TPU/QUIC ingest, serve) are allocated from this range |
--public-ip |
38.58.183.154 |
Advertised address |
--repair-interface |
the active NIC port | Repair socket bind interface |
TPU/QUIC ingest ports are not fixed numbers — they come out of --dynamic-port-range. The cluster filter is set
with --expected-shred-version (default 1516) and --expected-genesis-hash
(4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY); both are overridable at deploy time without a code change
(VEX_SHRED_VERSION=<n>).
Confirming the datapath¶
deploy.sh runs two background verifiers after launch. Both are advisory — they report, they never kill the
validator.
Tiling verifier — /tmp/vex-tiling-verify.log¶
Step 7b-2 reads the live /proc/<pid>/task/*/status affinity masks (actual runtime placement, not code intent)
once the replay tile has pinned to core 16. It prints a thread→core histogram and flags any thread that is both
unpinned (carrying the wide taskset mask) and busy on a consensus core (recv 5, quic 6, verify 8–15, replay 16,
produce 20, gossip 24, plus the wave cores when parallel-exec is armed). A clean run reports no busy floater on a
consensus core:
cat /tmp/vex-tiling-verify.log
# === TILING VERIFY (pid=…, replay-tile ready=1) … ===
# thread→core mask histogram (count mask):
# …
# ✅ no busy floater on a consensus core (cores 0-4 walled off via cpuset; recv→5, watchdog→31, floaters→6-24,27)
AF_XDP zero-copy gate¶
Step 7c waits up to 360s for the XSK to bind, then samples the per-queue zero-copy packet counter twice to prove
real traffic is flowing on q0. The authoritative bind signal is ss --xdp (the INFO-level "Bound … zero-copy"
banner is WARN-filtered out of the live log, so it is a false negative — do not trust its absence):
# Is the XSK actually bound to queue 0?
sudo ss --xdp | grep enp129s0f1np1
# enp129s0f1np1:q0 …
# Is zero-copy traffic actually flowing? (counter should climb)
sudo ethtool -S enp129s0f1np1 | grep -E 'rx0_xsk_packets|rx_xsk_buff_alloc_err'
# rx0_xsk_packets: <climbing>
# rx_xsk_buff_alloc_err: 0 <-- must stay flat
If rx0_xsk_packets climbs and rx_xsk_buff_alloc_err stays flat, zero-copy is engaged correctly.
See also¶
- Hardware — the EPYC 9374F + Mellanox ConnectX-6 Dx reference box and the CCX cache layout.
- Architecture: Networking — the protocol-level Turbine / gossip / repair design.
- Performance: Tuning — kernel sysctls, socket buffers, and CPU-frequency tuning.
- Build / Deploy — the stop → build → deploy cadence, the deploy contract's ordered steps, and the snapshot/consensus-guard/rollback discipline around it.
- Environment Variable Reference — the full baked-default table and the minimal operator surface this page's deploy command draws from.