afxdp

package module
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: BSD-3-Clause Imports: 23 Imported by: 0

README

go-afxdp

A small, easy to use Go library for AF_XDP sockets. It moves packets between a NIC and userspace at high rates, bypassing the kernel network stack, for DPDK-like speeds with the convenience of ordinary Go.

import "github.com/atoonk/go-afxdp"

It binds every rx queue for you, installs an in-kernel filter so you only take the traffic you want, auto-selects zero copy where the driver supports it, and is safe to drive from a receive and a transmit goroutine at once. It is a friendlier, concurrency-safe fork of asavie/xdp.

New to AF_XDP? It is a different beast from net.UDPConn. Read How AF_XDP works for the one-minute mental model (especially why there is a filter), then come back.

Performance: about 13 Mpps transmitting 64-byte frames from userspace Go on one Intel ixgbe NIC, roughly 92% of 10G line rate.

Status: validated on Intel ixgbe (zero copy) and AWS ENA. The API is still settling, so expect minor changes before a v1.0 tag.

Install

go get github.com/atoonk/go-afxdp

Linux, Go 1.22+, and CAP_NET_RAW (or root) with enough locked memory (ulimit -l) for the BPF maps and UMEM.

Quick start

Receive

Pick the traffic you want with a filter, open the interface, read packets. Open attaches the XDP program, binds one socket per rx queue, and registers them, all in one call.

fleet, err := afxdp.Open("eth0", afxdp.WithUDPPorts(4789)) // capture UDP/4789
if err != nil {
    log.Fatal(err)
}
defer fleet.Close()

for _, xsk := range fleet.Sockets() {
    go func(xsk *afxdp.Socket) {
        for {
            xsk.Fill(xsk.NumFreeFillSlots()) // give the kernel buffers
            n, _ := xsk.Poll(-1)             // block until packets arrive
            descs := xsk.Receive(n)
            for _, d := range descs {
                frame := xsk.GetFrame(d)     // the received bytes
                _ = frame
            }
            xsk.Recycle(descs)               // return frames to be filled again
        }
    }(xsk)
}

The whole receive model is Fill, Poll, Receive, Recycle. Only UDP/4789 reaches your sockets; everything else (SSH included) keeps flowing through the kernel normally.

Transmit

Hand SendBatch (or SendFunc) your packets and it does the ring bookkeeping for you, reclaiming sent frames, kicking the kernel, never stalling on a full ring. Just call it in a loop. A loop that sends many small packets per batch can use SendFuncNoKick and one KickIfNeeded at the end of the batch: one syscall per batch instead of one per packet.

fleet, _ := afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchNone())) // transmit only
xsk := fleet.Sockets()[0]
for {
    n, err := xsk.SendBatch(packets) // returns how many were queued this call
    ...
}

A filter is required, Open returns an error without one, so you cannot accidentally redirect every packet and cut off your own box. Pass WithUDPPorts/WithFilter for specific traffic, MatchAll() to take everything on purpose, or MatchNone() for transmit only.

How AF_XDP works (and why there's a filter)

If you have only used net.UDPConn and friends, AF_XDP works differently enough to be worth a paragraph before you start.

A normal socket (the AF_INET family) hands you data after the kernel's network stack has processed the packet. AF_XDP is its own socket family that receives raw Ethernet frames straight from the driver, before the stack, and that is where the speed comes from.

But the driver has to be told which frames go to your socket instead of up the normal stack. That decision is an XDP program, a small eBPF program that runs in the driver on every received packet and returns either XDP_PASS (let the kernel handle it normally) or XDP_REDIRECT (hand it to an AF_XDP socket). So receiving with AF_XDP is always two pieces working together, the socket, and an eBPF/XDP filter that redirects the traffic you want into it.

Writing, compiling, and loading that eBPF is the part most libraries leave to you. This library installs it for you. WithUDPPorts(53) (or the more general WithFilter(...)) compiles to the XDP program, attaches it to the interface, and points its redirect at your sockets. Everything that does not match keeps flowing up the normal kernel stack untouched. Transmit is the mirror image, you write frames into shared memory (the UMEM) and the driver sends them.

The takeaway: a filter is not an optional extra. For receive it is how packets reach an AF_XDP socket at all, so choosing it is the main thing you configure. (MatchNone covers the transmit-only case, where you want the datapath but no redirect.)

Seeing what's installed. Fleet.Info() reports the active filter and mode (... filter udp/53). From a shell, ip link show <iface> shows whether an XDP program is attached, and bpftool net show dev <iface> lists it. If expected traffic is not arriving, check that Info().Filter matches it and that Stats() is not reporting rx_ring_full/fill_empty, which mean the rings could not keep up.

When to use AF_XDP

AF_XDP is for when you need packets in userspace. If all you do is reflect, forward, drop, or mirror packets, do it in the XDP program itself (XDP_TX, bpf_redirect(), XDP_DROP), it stays in the driver and is faster than a userspace round trip. Reach for AF_XDP when the per-packet logic does not fit in eBPF: crypto and tunnels (WireGuard, IPsec, QUIC), a userspace TCP/TLS or app-protocol stack, stateful deep packet inspection, traffic generation, or anything that needs real Go libraries. The sweet spot is to let XDP cheaply pass the bulk to the kernel and lift only the flows you care about up to Go.

Performance

Two bare-metal boxes, AMD EPYC 9275F (24 cores / 48 threads), 100 Gbit/s Mellanox ConnectX (mlx5_core) with 48 combined queues, native XDP and zero-copy on both ends. examples/blast on one, examples/drop on the other, over a tagged VLAN.

wire frame line rate transmit receive
64 B 148.8 Mpps 147.8 Mpps, 99.3 Gbit/s — 16 queues, 16.0 cores 148.4 Mpps, no drops — 8 queues, 9.3 cores
128 B 84.5 Mpps 84.0 Mpps, 99.5 Gbit/s — 16 queues, 16.0 cores 84.4 Mpps, no drops — 4 queues, 5.6 cores
512 B 23.5 Mpps 23.5 Mpps, 100.1 Gbit/s — 4 queues, 4.0 cores 23.5 Mpps, no drops — 1 queue, 1.3 cores
1500 B 8.2 Mpps 8.2 Mpps, 99.9 Gbit/s — 4 queues, 4.0 cores 8.2 Mpps, no drops — 1 queue, 0.9 cores

Line rate in both directions at every frame size, on one 100G port, with the defaults and no drops anywhere. Frames are as they appear on the wire, FCS included; blast takes four less than that, since its -size excludes the FCS the NIC appends.

Bigger frames cost less, as they should: the work here is per packet, not per byte, so a 1500-byte line rate of 8.2 Mpps is eighteen times less work than a 64-byte line rate of 148.8. Receive at 1500 bytes fills the wire with under a core; at 64 bytes it needs 9.3 of 48.

The two sides are measured differently, and the difference matters:

  • Receive cores are work. The sink blocks in poll when there is nothing to do, so its CPU is what the packets actually cost. Every processor on the machine is counted — userspace and softirq together — which is the honest way to measure a path that does half its work in the kernel.
  • Transmit cores are occupancy. A generator busy-polls: blast loops on SendFunc, so a worker whose transmit ring is full spins, and a spinning core is indistinguishable from a busy one in every CPU metric. It occupies one core per queue whatever the frame size, which is why the transmit column always reads "N queues, N cores". At 1500 bytes those four cores are nearly idle — a single queue at 512 bytes already reaches 96.1 Gbit/s, and two at 1500 bytes reach 94.5.

Transmit is not more expensive per packet than receive — the columns above only make it look that way. Measured where neither side is spinning, transmitting a 64-byte frame costs 288 cycles and receiving one costs 270: within 7% of each other.

What differs is how much a single queue can carry. A transmit queue tops out near 15 Mpps, and that is the queue's limit rather than the core's — giving one queue a second core raises it only from 14.1 to 16.2 Mpps. A receive queue carries about 34 Mpps. So the same 148 Mpps needs sixteen queues one way and eight the other, and since a busy-polling generator occupies a core per queue, the queue count is what sets the transmit column.

The last fifth is also the expensive part: eight transmit queues already reach 119.7 Mpps on 8 cores (14.9 Mpps per core, the same efficiency as receive). The eight further queues that take it to line rate add only 28 Mpps, because by then the wire, not the CPU, is what is left to fight.

Why 128-byte transmit costs more than 64-byte

The table has an oddity worth explaining, because it looks like an error: sending 84 Mpps of 128-byte frames takes the same sixteen queues as 148 Mpps of 64-byte ones, while receiving them takes four. Bigger frames should be easier.

They are — for the wire and for the receiver. It is transmit that has a hole in it, and it belongs to the driver rather than to this library. Sweeping frame size on a single transmit queue:

wire frame 64 B 72 B 96 B 128 B 192 B 256 B 384 B 512 B 1024 B
Gbit/s 9.4 6.2 5.7 5.2 4.8 4.7 79.0 96.9 77.2

From roughly 72 to 256 bytes one queue is stuck near 5 Gbit/s, and then jumps sixteenfold at 384. The reason is that mlx5 copies a small frame into the transmit descriptor instead of pointing at it, so what runs out is the card's descriptor bandwidth — and that gets worse as the frame grows, which is why the rate falls with size instead of rising. Above the driver's inline threshold the descriptor becomes a pointer and the wire takes over again.

That is measurable rather than inferred. Driving the same card through packetio, where the descriptor format is ours to choose, one queue at a time:

one transmit queue 64 B 128 B 256 B
packet copied into the descriptor 16.9 Mpps 8.8 Mpps
descriptor points at the packet 18.5 Mpps 18.6 Mpps 18.5 Mpps

Pointing gives a flat packets-per-second ceiling that does not care about frame size, which is what a queue limit should look like. Copying halves it at 128 bytes.

Receive has no equivalent, which is the whole of the asymmetry in the table above: the card DMAs straight into a buffer the driver posted in advance, so nothing is copied per frame and the frame size costs nothing.

Copying is not simply the wrong choice, though — it is what makes 64-byte line rate possible at all. Pointer descriptors cap the whole card near 75 Mpps no matter how many queues you give them, where copying reaches 148. It is only small fleets and the middle sizes where the trade goes the wrong way — and the switch turns out to be reachable after all: the driver exposes it as the xdp_tx_mpwqe private flag, and since v0.11.0 Open sets it the way the queue count wants. Four transmit queues or fewer run with pointer descriptors (17.5 Mpps per core against the copied path's 8.5–14.3, measured at 64- and 68-byte wire frames) and the original state comes back on Close; five or more keep the kernel default, the only way past the ~75 Mpps wall. WithoutMPWQETune declines, and Fleet.Info reports what was chosen. No ethtool binary is involved — the flag is driven through the ethtool ioctl directly.

The per-queue table below was taken under different NIC channel counts, so compare its rows to each other rather than to the single-queue sweeps here.

Scaling per queue

One socket and one goroutine per queue, so queue count is roughly core count. Setting the NIC to N queues on both ends with ethtool -L eno2 combined N (reset the RSS table first with ethtool -X eno2 equal N, or the change is refused), 64-byte frames. Cores are every busy tick on the machine, with the softirq share — the kernel's half of AF_XDP — shown beside it, because a receive path that does half its work in NAPI is not honestly measured any other way. Medians of three passes with all of today's defaults (placement, NAPI batching, the descriptor-format choice above):

queues sent TX cores (softirq) received of 148.8 offered RX cores (softirq)
1 18.8 Mpps 1.0 (0.5) 32.3 Mpps 1.6 (1.0)
2 35.9 Mpps 2.0 (1.0) 65.1 Mpps 3.2 (2.0)
4 71.1 Mpps 4.0 (2.1) 128.3 Mpps 6.5 (4.0)
8 120.3 Mpps 8.0 (5.5) 148.8 Mpps 11.7 (7.7)
16 147.3 Mpps 16.0 (9.6) 140.4 Mpps 11.6 (7.2)

Transmit runs pointer descriptors to four queues (~17.9 Mpps per core), takes the copied path from six, and reaches line rate at sixteen. Receive takes the whole wire on eight queues and costs about 1.5 cores per queue — a worker and most of a soft interrupt; past its line-rate point more queues only spend more machine (the 16-queue receive row is the same NIC limit costing more). A transmit worker is busy-polling, so its user half reads as occupancy; the receive rows block in poll and their cores are real work.

More queues is not better. Past 16 nothing gets faster — transmit is at line rate and receive is at the NIC's limit — but the receiver keeps costing more CPU. Measured box-wide, 16 queues carried 116 Mpps using 19.5 of 48 cores, while 48 queues carried 119 Mpps using 24.2 — noticeably more machine for 2% more throughput.

NAPI batching (done for you)

Left alone, the kernel wakes an AF_XDP receiver once per NAPI poll, and at these rates that is millions of times a second — far more often than it needs to. The receiver then burns its CPU on syscall entry and poll machinery instead of on packets: profiling the 48-queue sink put os_xsave, sock_poll, eventfd_poll and interrupt dispatch at the top, with the functions that actually move packets (__xsk_map_redirect, __xsk_rcv_zc) nowhere near it.

Open fixes this for you. On a native-mode NIC it sets napi_defer_hard_irqs=2 and gro_flush_timeout=20µs so the kernel lets packets accumulate, and restores your previous values on Close. Taking everything on the wire from a sender offering 132 Mpps of 64-byte frames, across 48 queues:

packets per poll polls/s kept box busy
kernel default 21 5.4M 114.7 Mpps 29.4 of 48 cores
what Open applies 53 2.5M 130.6 Mpps 14.1 of 48 cores

More throughput for half the machine, which is why this is on by default rather than a tuning tip. Fleet.Info() reports it (napi defer=2 flush=20µs) so it is never invisible.

The flush timeout is a speed limit, so keep it short. It is not only a batching hint: a queue delivers about a thousand packets per flush interval and no more, whatever is offered — roughly 5 Mpps per queue at 200µs, which is a third of what one queue can actually do. Spread thin across 48 queues that ceiling is invisible; concentrated on 8 it is brutal. Same NIC, same 132 Mpps offered, packets kept and whole-machine cores:

flush 8 queues 16 queues 24 queues 48 queues
none 132.2, 9.9c 132.1, 9.0c 131.2, 15.6c 114.7, 29.4c
20µs 132.2, 9.2c 132.2, 9.7c 132.1, 12.6c 130.6, 14.1c
50µs 123.7, 7.2c 132.2, 9.2c 132.0, 11.4c 130.4, 11.6c
100µs 78.4, 4.0c 122.3, 7.3c 132.0, 10.6c 130.3, 10.2c
200µs 43.1, 1.9c 68.9, 3.9c 103.4, 8.4c 130.2, 9.7c

Every step down that table buys CPU by lowering the ceiling. 20µs puts it near 50 Mpps per queue — above anything a single queue reaches — so it costs nothing at any queue count while still halving the machine at 48. Versions of this library before this measurement defaulted to 200µs, which was tuned at 48 queues and silently capped everything below that.

Transmit packing

mlx5 packs several packets into one work queue entry and, under load, copies each into it rather than pointing at it. Copying means the card fetches one thing per packet instead of two, which is the only way past its roughly 75 million packets a second ceiling for separately fetched packets. But an inlined 64-byte packet takes five of the entry's sixteen-byte segments where a pointer takes one, so five times fewer packets share an entry and each queue runs at about half speed. Measured on a ConnectX-6 Dx at 64 bytes, Mpps:

queues packing off packing on
1 17.6 8.5
4 69.8 46.0
6 75.3 69.2
8 75.4 92.5
16 74.2 140.8

Off is twice as fast per queue and stops dead at the port's ceiling; on is half as fast per queue and has no ceiling. Neither is right on its own, and the library does not choose for you: the ceiling turned out to be a property of the adapter rather than of the link, so a rule measured on one card would be a guess on another. If you drive six queues or fewer on an mlx5 NIC and want small frames, it is worth trying:

ethtool --set-priv-flags <iface> xdp_tx_mpwqe off

and putting it back afterwards. Above that queue count, leave it on.

Worth knowing, since these are properties of the interface rather than of your process:

  • They are restored on Close, but a crash leaves them applied. To reset by hand: echo 0 > /sys/class/net/<iface>/napi_defer_hard_irqs and the same for gro_flush_timeout.
  • Deferring can hold a packet for up to the flush timeout when traffic is sparse, so it trades a little idle latency for a lot of loaded throughput.
  • Generic/SKB mode is never touched, so veth and test setups are unaffected.
  • Opt out with WithoutAutoTune(), or change the values with WithNAPITuning(deferIRQs, flush). Raise the flush only if you know your per-queue packet rate stays under the ceiling above: with many thin queues a longer flush is free CPU, and with a few busy ones it is a cliff.

Interrupt coalescing (ethtool -C rx-usecs) does not substitute for this — most wakeups here are NAPI flushes rather than hardware interrupts, so raising rx-usecs changed nothing at all.

One curiosity this explains: untuned, throughput becomes oddly sensitive to how long the XDP program takes, and a filter that reads packet headers batches better — and so runs faster — than one that does nothing. Tuned, that inversion disappears and the simplest program is the cheapest, as it should be.

CPU affinity (done for you)

A queue is two halves. The kernel half — the hardware interrupt, the NAPI poll, your XDP program, the redirect into the socket — runs wherever that queue's interrupt is routed. The userspace half — your goroutine calling Poll, Receive, SendFunc — runs wherever the Go scheduler happens to put it. Left alone the two land on unrelated cores, and every packet then crosses a cache boundary that it did not have to.

On a chiplet CPU that is expensive. One transmit worker, one queue, interrupt on core 0, worker moved by hand (EPYC 9275F: 24 cores in L3 complexes of 3, SMT siblings n and n+24):

worker on Mpps cores
core 1 same complex, another core 16.3 2.0
core 24 the interrupt's SMT sibling 16.2 2.0
core 0 the interrupt's own core 15.0 1.0
core 12 a different complex 6.2 2.0

Leaving the complex costs 2.6× the throughput for the same CPU — and on a 24-core chiplet CPU an unplaced goroutine lands outside the right complex almost every time.

Open places them for you. It finds each queue's interrupt and locks the goroutine driving that queue to a core beside it — the first time that goroutine calls Poll, Receive, ReceivePackets, Transmit, SendFunc or SendBatch, which is the moment it identifies itself as that queue's worker. Interrupts with no free core beside them are moved to one, and put back on Close.

A generatorWithFilter(MatchNone()), which never receives — is placed on its interrupts instead, for the reason below. Transmitting 64-byte frames, whole-machine CPU, everything else identical:

queues before after
1 6.5 Mpps, 2.0 cores 14.3 Mpps, 1.0 core
4 33.0 Mpps, 8.0 cores 59.6 Mpps, 4.0 cores
8 55.2 Mpps, 16.0 cores 118.7 Mpps, 8.0 cores
16 112.2 Mpps, 32.1 cores 147.8 Mpps, 16.0 cores
24 145.9 Mpps, 45.9 cores 148.5 Mpps, 24.1 cores

Twice the packets for half the CPU, 1300 cycles per packet down to 290, and at 16 queues 99.3 Gbit/s — 64-byte line rate on sixteen logical CPUs, which is eight physical cores, or 18.5 Mpps per physical core. This library did not reach line rate at all before. The run-to-run spread goes too: the unplaced arm wandered between 12.9 and 31.0 Mpps at two queues depending on where the scheduler happened to put things, the placed one between 29.4 and 29.5.

Receiving, with a sender offering 132 Mpps of 64-byte frames the receiver is free to drop (that was the generator's ceiling, not the receiver's — fed a full 148.7 Mpps the placed receiver keeps 148.5 of it on 9.4 cores, dropping nothing):

queues before after
1 34.0 Mpps kept, 1.5 cores 35.5 Mpps, 1.5 cores
2 65.2 Mpps kept, 3.1 cores 66.6 Mpps, 3.2 cores
4 125.9 Mpps kept, 6.2 cores 129.5 Mpps, 6.3 cores
8 45.6 Mpps kept, 2.2 cores 129.6 Mpps, 9.2 cores
16 66.8 Mpps kept, 3.7 cores 132.0 Mpps, 9.6 cores
24 107.9 Mpps kept, 10.6 cores 132.0 Mpps, 12.6 cores
48 129.9 Mpps kept, 10.3 cores 130.5 Mpps, 13.8 cores

Everything offered, with no drops at all, from four queues up. (These arms also carry the flush-timeout change above, which is what the 8- and 16-queue rows are mostly showing; at 48 queues the old 200µs was the cheaper of the two, which is why it was chosen there.)

Beside, not on top — unless you only transmit. Sharing the interrupt's core is the best possible arrangement for transmit, and it is what the generator numbers above are doing: one core carries a whole queue instead of half of one. But it is ruinous for receive, where the kernel half needs a whole core to itself at 30 Mpps and a receive loop asked to share with it gets almost nothing:

one receive queue, worker kept cores
beside the interrupt 30.5 Mpps 1.50 (1.00 softirq)
on the interrupt's core 0.99 Mpps 1.01 (0.99 softirq)

So a fleet that can receive never shares, and its preference order is: same complex, another physical core first, then the interrupt's SMT sibling, then nothing at all. A queue with nowhere to go is left to the scheduler, because stacking it on its own interrupt is worse than not placing it. Only a fleet that redirects nothing — MatchNone(), which cannot receive — shares.

Worth knowing:

  • Interrupt routing is host state, like the NAPI settings: restored on Close, but a crash leaves it applied. To reset by hand, restart irqbalance, or write the driver's own hints back with /proc/irq/<n>/smp_affinity_list.
  • The goroutine stays locked to its OS thread. Don't drive a socket from a goroutine that does anything else of substance.
  • A goroutine driving several queues pins once, for the first of them, instead of being dragged between them.
  • If the kernel refuses the placement — a taskset or cpuset that excludes the CPU — that is taken as the operator being more specific than the library, and the queue is left alone.
  • Generic/SKB mode is never placed, so veth and test setups are untouched.
  • Opt out with WithoutAffinity(), or name the CPUs yourself with WithAffinity(4, 6, 8) — queue q runs on cpus[q % len(cpus)] and its interrupt is brought to a core beside it.
  • Fleet.Affinity() returns the CPU per queue (-1 for unplaced), and Fleet.Info() summarises it.

Driver interrupt names are recognised for mlx5, ixgbe, i40e/ice, bnxt, ena, igb, mlx4 and virtio-net. On anything else no per-queue interrupt is found, nothing is placed, and Info says so.

On a machine with more than one NUMA node, Open also registers each queue's UMEM from a thread bound to that queue's worker CPU: the kernel pins UMEM pages on the node of the thread that registers them, and that is the memory the NIC writes every received frame into, so the frames live where the worker runs.

Queue count

Tune this on the NIC, not in the application. Reduce the channel count so RSS only spreads over the queues you want, and keep binding all of them (the default):

ethtool -X eno2 equal 16      # shrink the RSS table first
ethtool -L eno2 combined 16   # then the channels

Using WithQueues(16) while the NIC still has 48 RSS queues does something quite different and usually wrong: the flows hashed to the other 32 queues have no socket bound, so the XDP program passes them to the kernel and your application never sees them. You cannot choose which queue a flow lands on, which is why binding every available queue is the default.

Filtering

A filter decides which packets are handed to your sockets. Only matching packets go to userspace; everything else continues to the normal kernel stack. That is what lets you run on a live interface without stealing SSH or unrelated traffic, and it is why Open requires one.

The shorthand for UDP:

fleet, _ := afxdp.Open("eth0", afxdp.WithUDPPorts(4789)) // VXLAN, say

For anything richer, WithFilter takes a set of matches, and a packet is redirected if it satisfies any of them (logical OR):

// WireGuard on two ports, plus let ping through:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchUDPPort(51820, 51821),
    afxdp.MatchICMPv4Echo(),
))

// A VXLAN tunnel endpoint and its BGP session:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchUDPPort(4789),
    afxdp.MatchTCPPort(179),
))

// Replies rather than requests — match the source port:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchUDPSrcPort(53),  // DNS answers
    afxdp.MatchTCPSrcPort(443), // TLS server -> client
))

// All GRE and all ESP (IPsec), regardless of ports. The protocol matchers are
// per-family, because IPv6 Next Header is not the same question as the IPv4
// protocol field — see the note below:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchIPv4Proto(47),      // GRE over IPv4
    afxdp.MatchIPv6NextHeader(47), // GRE over IPv6
    afxdp.MatchIPv4Proto(50),      // ESP over IPv4
))

// Ping, both families:
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchICMPv4Echo(),
    afxdp.MatchICMPv6Echo(),
))

// A whole address family, by EtherType:
afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchEtherType(afxdp.EtherTypeIPv6)))
afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchEtherType(afxdp.EtherTypeARP)))

// Anything to or from a subnet, by CIDR (IPv4 or IPv6):
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchSrcIP("10.0.0.0/8"),
    afxdp.MatchDstIP("10.0.0.0/8"),
))
afxdp.Open("eth0", afxdp.WithFilter(afxdp.MatchDstIP("2001:db8::/32")))

// Everything except one noisy host — exceptions win over the filter:
afxdp.Open("eth0",
    afxdp.WithFilter(afxdp.MatchAll()),
    afxdp.WithExcept(afxdp.MatchSrcIP("192.0.2.10/32")),
)

// One flow, src AND dst (both directions, OR the two halves):
afxdp.Open("eth0", afxdp.WithFilter(
    afxdp.MatchFlow("10.0.0.1/32", "10.0.0.2/32"),
    afxdp.MatchFlow("10.0.0.2/32", "10.0.0.1/32"),
))

Match builders:

Builder Matches
MatchUDPPort(ports...) UDP to these dest ports, IPv4 and IPv6 (no ports = all UDP)
MatchUDPSrcPort(ports...) UDP from these source ports — replies rather than requests
MatchTCPPort(ports...) TCP to these dest ports, IPv4 and IPv6 (no ports = all TCP)
MatchTCPSrcPort(ports...) TCP from these source ports
MatchIPv4Proto(proto) IPv4 with this protocol number (47 GRE, 50 ESP, ...)
MatchIPv6NextHeader(nh) IPv6 whose Next Header is this — see the note below
MatchICMPv4Echo() / MatchICMPv6Echo() echo request (ping / ping6)
MatchSrcIP(cidr) source IP in this CIDR, IPv4 or IPv6 (e.g. 10.0.0.0/8, 2001:db8::/32)
MatchDstIP(cidr) destination IP in this CIDR, IPv4 or IPv6
MatchFlow(src, dst) src CIDR and dst CIDR together, i.e. one direction of a flow
MatchEtherType(et) this EtherType (0x0806 ARP, 0x86DD IPv6, ...)
MatchAll() every packet, the deliberate "take everything"
MatchNone() nothing, attach without redirecting (e.g. zero copy TX for a sender)

That is every built-in builder. For anything they miss, bpfmatch matches whatever a tcpdump filter matches, and NewMatch takes raw eBPF.

The options that install them, and the two that hold traffic back, are:

Option Effect
WithFilter(matches...) redirect packets matching any of these builders
WithUDPPorts(ports...) shorthand for WithFilter(MatchUDPPort(ports...))
WithExcept(matches...) pass packets matching any of these to the kernel, whatever the filter says
WithKeepManagement(extraTCPPorts...) the inverse: keep ARP, IPv6 ND, SSH and DNS replies out of the capture so a broad filter cannot lock you out of the box (below)

Each match is compiled to eBPF instructions with github.com/cilium/ebpf/asm into a single XDP program, loaded and checked by the kernel verifier (the test suite loads every builder and a composite to prove they verify).

A few things to know. Matches combine with OR, a packet is redirected if it matches any of them. The one built-in AND is MatchFlow; for arbitrary AND, and for anything else these builders miss, use bpfmatch.

The port matchers handle IPv4 and IPv6. The protocol and echo matchers are named for the family they match, because the two are not equivalent: IPv6's Next Header names whatever comes next, which may be an extension header rather than the upper-layer protocol, and ICMP and ICMPv6 are different protocols with different numbers.

All of the port, protocol and echo matchers assume no IP options and no IPv6 extension headers, so a packet carrying either does not match. The cBPF layer is what handles those, because pcap-compiled filters compute the header length instead of assuming it. The IP (CIDR) matchers read fixed offsets and are unaffected by both.

Every matcher transparently skips a single 802.1Q VLAN tag, so the same filter works whether or not the NIC strips the tag before XDP — stacked QinQ tags are not unwound.

Filtering with tcpdump expressions

bpfmatch.Match matches packets accepted by a classic BPF program — the instruction set tcpdump and libpcap compile their filter expressions to. It is compiled to eBPF with cbpfc and spliced into the filter alongside every other match, so a tcpdump filter expression becomes an in-kernel filter:

tcpdump -ddd 'tcp port 22 and not src host 192.0.2.1'

The usual way in is the expression itself, via the optional pcapfilter module:

go get github.com/atoonk/go-afxdp/pcapfilter
fleet, err := afxdp.Open("eth0", afxdp.WithFilter(
    pcapfilter.Match("tcp port 443 and not src net 192.0.2.0/24"),
), afxdp.WithKeepManagement())

A runnable version is in pcapfilter/example.

Underneath it, bpfmatch takes the compiled classic BPF instructions directly, with no libpcap and no cgo:

go get github.com/atoonk/go-afxdp/bpfmatch
// insns is []bpf.Instruction, e.g. the output of
//   tcpdump -ddd 'tcp port 22 and not src host 192.0.2.1'
//
// The first argument is only a label: it names the rule in Fleet.Info and in
// error messages, and has no effect on what matches.
 fleet, err := afxdp.Open("eth0", afxdp.WithFilter(
    bpfmatch.Match("tcp/22 except 192.0.2.1", insns),
 ))
Which one should I use?

pcapfilter is the easier of the two and the right default: you type the expression you already know. Reach for bpfmatch when one of these applies.

pcapfilter bpfmatch
You write "tcp port 22" []bpf.Instruction
Needs libpcap + cgo yes no
CGO_ENABLED=0 / static binary does not build builds
Cross-compile, scratch/distroless image no yes

The deciding question is usually do you ship binaries? A static or cross-compiled build has no cgo, so pcapfilter is not available there at all — that is why Wireblast, which ships binaries users download, cannot require it. The other cases for bpfmatch: compiling the expression somewhere else (build host or control plane) and shipping only the instructions to the data plane, or cBPF that never came from a string in the first place — generated by a controller, stored in a config, or produced by tcpdump -ddd in a script.

They are the same engine. pcapfilter calls libpcap to turn your string into classic BPF and hands those instructions straight to bpfmatch, so both produce identical eBPF and go through identical validation. The only difference is who parses the expression, and what that costs you at build time.

Either way, this is the layer to reach for before writing eBPF by hand. Any normal packet-data expression — anything you would type after tcpdump — works:

Expression Captures
tcp port 22 and not src host 192.0.2.1 SSH, except from one host — and/not, which WithFilter alone cannot express
udp portrange 5000-6000 a port range, without unrolling a thousand compares
tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack = 0 connection openers only
vlan 100 and tcp port 443 one VLAN, and inside it one port
ip6 and tcp port 443 a single address family — like the built-ins, this reads Next Header and does not walk extension headers; protochain 6 does
proto gre / ip proto 47 GRE, either spelling
icmp or icmp6 all ICMP and ICMPv6 traffic — errors, ND and echo alike
ether dst 01:00:5e:00:00:01 one destination MAC, multicast included
ip[6:2] & 0x1fff != 0 IPv4 fragments — an arbitrary byte offset with a mask
udp port 4789 and udp[12:4] & 0xffffff00 = 0x0004d200 one VXLAN VNI — 1234 is 0x0004d2, in the top three of the four loaded bytes
greater 1000 frames over 1000 bytes

Unlike the named builders it also handles IPv4 headers carrying options, because pcap-compiled filters compute the header length rather than assuming it.

One caveat for cBPF from other sources: the program must stick to packet data. Filters compiled against a live capture handle can contain Linux ancillary loads (SKF_AD_*, e.g. for the kernel-stripped VLAN tag) that read socket-buffer metadata XDP does not have; bpfmatch rejects those with an error rather than mismatching, but the message names a negative offset you never wrote. pcapfilter, tcpdump -ddd, and anything else that compiles against a dead handle never produces them.

Semantics are exactly those of the cBPF program you supply, evaluated against the frame as XDP received it. Note XDP is ingress-only, so a filter written expecting both directions of a conversation only sees the inbound half.

Both are separate modules, so the core go-afxdp module stays pure Go: no cgo, no libpcap, no tcpdump binary, and none of the newer Go or cilium/ebpf versions the cBPF compiler needs. You take those on only by importing the layer that uses them.

Custom matches

If neither the named builders nor the cBPF layer cover what you need, NewMatch lets you emit your own eBPF classification block. Most people should not need it — reach for bpfmatch/pcapfilter first. A custom block gets the same treatment as the built-in ones: assembled into the filter program, checked by the verifier, and reported by Fleet.Info.

The builder receives a MatchEnv and returns instructions that jump to env.Redirect on a match and env.Next otherwise. This one reimplements MatchUDPPort(5000):

udp5000 := afxdp.NewMatch("udp/5000", func(e afxdp.MatchEnv) (asm.Instructions, error) {
    ins, frame := e.FrameBase()
    // Ethernet 14 + IPv4 20 (no options) puts the UDP dest port at 36.
    ins = append(ins, e.Bounds(frame, 36+2)...)
    return append(ins,
        asm.LoadMem(asm.R3, frame, afxdp.OffEtherType, asm.Half),
        asm.JNE.Imm(asm.R3, afxdp.NetShort(afxdp.EtherTypeIPv4), e.Next),
        asm.LoadMem(asm.R3, frame, 23, asm.Byte), // IP protocol
        asm.JNE.Imm(asm.R3, 17, e.Next),          // UDP
        asm.LoadMem(asm.R3, frame, 36, asm.Half), // UDP dest port
        asm.JEq.Imm(asm.R3, afxdp.NetShort(5000), e.Redirect),
    ), nil
})

fleet, err := afxdp.Open("eth0", afxdp.WithFilter(udp5000))

e.FrameBase() returns the instructions that establish a frame base with a single VLAN tag skipped, plus the register holding it — so a custom match inherits the same tag handling as the built-ins. e.Bounds(base, n) emits the bounds check the verifier requires before any packet read; skip it and the program is rejected. Falling off the end of the block means "no match", so you only need e.Next for early exits. e.Label(name) gives you a symbol unique to your block if you need your own control flow. A builder that cannot fail returns a nil error; the error exists for builders that compile something, as bpfmatch does.

Registers, which matter because the block is spliced into a larger program:

Register Role Your block may
R6 (e.DataEnd) data_end read
R7 (e.Data) frame start read
R8 rx_queue_index nothing — reserved
R9 the FrameBase() register use once FrameBase() has been called
R0R5 scratch use between helper calls

R8 is the one that used to be dangerous: the redirect tail reads it to pick the destination socket, so a block that overwrote it verified cleanly, attached, and then delivered packets to the wrong queue with no error anywhere. NewMatch now rejects any block that mentions R8 at all, so that mistake is an error from Open instead.

Bounds and FrameBase use scratch registers internally, and which ones is not part of the contract — don't assume a value in R0R5 survives a call to one.

Two failure modes worth knowing: a filter in which no match can ever reach Redirect leaves the redirect path unreachable, which the verifier rejects; and under WithMultiBuffer a read past the first fragment still loads but silently stops matching, so keep reads inside the L2/L3/L4 headers.

Verifier rejections come back from Open as an *ebpf.VerifierError. Printing it with %v gives only its first line (unreachable insn 6); the program listing that shows which instruction was rejected needs the concrete type:

fleet, err := afxdp.Open("eth0", afxdp.WithFilter(myMatch))
if err != nil {
    var ve *ebpf.VerifierError
    if errors.As(err, &ve) {
        log.Fatalf("filter rejected:\n%+v", ve) // %+v, and only on ve
    }
    log.Fatal(err)
}
Testing a custom match

MatchPacket runs a filter against a packet you supply and reports whether it would be redirected. It assembles and executes the same eBPF Open would attach — it does not reimplement matching in Go — so a wrong offset, a missed byte swap or a mishandled VLAN tag shows up exactly as it would on a live NIC:

ok, err := afxdp.MatchPacket(frame, afxdp.WithFilter(udp5000))

It takes the same options Open does, so it can model the filter you actually deploy — exceptions included:

ok, err := afxdp.MatchPacket(frame,
    afxdp.WithFilter(udp5000),
    afxdp.WithExcept(afxdp.MatchSrcIP("192.0.2.10/32")),
)

That makes a custom match unit-testable with no NIC and no traffic. Test the near misses too — a byte-order slip usually still matches something, so a matcher only ever shown packets it should accept looks correct until it ships. It needs the same privileges as Open (CAP_BPF and CAP_NET_ADMIN, or root). Do not blanket-skip on error, though: the kernel reports a verifier rejection as EACCES, so a broken matcher and a missing capability look identical. Unwrap to *ebpf.VerifierError and fail on that; skip on anything else.

Worked examples live in examples/customfilter/. Start with udpsrcport, which is the shortest and exists purely as a tutorial — for real source-port matching use the built-in MatchUDPSrcPort. The other four each capture something no built-in expresses: a VLAN ID, a destination MAC, TCP SYNs, and a VXLAN VNI.

For the tcpdump-expression path — the one most people should reach for — see pcapfilter/example.

Keeping your session alive: WithKeepManagement

MatchAll() on the NIC you are logged in through takes every packet away from the kernel, including the ones carrying your session. WithKeepManagement() leaves those with the kernel and captures the rest:

fleet, err := afxdp.Open("eth0",
    afxdp.WithFilter(afxdp.MatchAll()), // capture everything...
    afxdp.WithKeepManagement(),         // ...except what keeps me logged in
)

What stays with the kernel:

passed through why
ARP, IPv6 ND (ICMPv6 133–137) gateway MAC resolution
TCP to/from port 22, addressed to this interface inbound and outbound SSH
UDP and TCP source port 53, addressed to this interface DNS replies

ARP matters more than the SSH rule. Pass SSH through but swallow ARP and the box still goes dark: the kernel cannot refresh the gateway's link-layer address, and about a minute later it can no longer reply to anything. That is the failure this exists to prevent, and it is why the preset is not just "port 22".

The port rules are scoped to the addresses the interface has when Open is called, so a router still captures transit traffic on port 22 — only traffic addressed to this box is spared. Every address is covered, IPv4 and IPv6, however many of each: a rule is emitted per address, and the address family selects the header offsets. Addresses added later are not covered; reopen the fleet if they change. Pass extra TCP ports for SSH on a non-standard port: WithKeepManagement(2222).

Two things to know. Traffic from port 22 or 53 to this host is not captured, so a sender that picks those source ports evades the capture — irrelevant for measurement, relevant if you are hunting an adversary. And if you administer the box through a different NIC than the one you are capturing on, you do not need this at all.

Transmit

The easy way is SendBatch (copy your buffers in) or SendFunc (fill each frame in place, no copy, ideal for a generator that varies a field per packet). Both handle all the ring bookkeeping, so you just call them in a loop.

// SendFunc fills each frame in place and returns the packet length.
for {
    _, err := xsk.SendFunc(256, func(i int, frame []byte) int {
        n := copy(frame, template)
        // offset 34 is the UDP source port (eth 14 + ip 20); vary it per packet
        binary.BigEndian.PutUint16(frame[34:], srcPort)
        srcPort++
        return n
    })
    if err != nil {
        // A length > FrameSize is a caller bug. Don't crash (or log unbounded)
        // inside a dataplane loop — log it rate-limited and keep going; see
        // the errLog helper in examples/blast.
    }
}

If you want full control, the primitives underneath are exported too, Alloc, build, Transmit, Complete, plus Kick and NumFreeTxSlots. The one rule if you hand-roll the loop: when the ring is full, still call Kick, or copy-mode TX deadlocks (the kernel will not drain it on its own). SendBatch and SendFunc handle that for you.

examples/blast is a line-rate generator built on SendFunc.

Transmit metadata: let the NIC finish checksums

Linux 6.8 added a small header the kernel reads from the bytes just before a transmit frame, through which you ask the NIC to finish a transport checksum (or timestamp the frame). Turn it on with WithTxMetadata(): every transmit chunk is then laid out as 16 bytes of metadata followed by the packet, Alloc and SendFunc hand you the packet part (FrameSize minus 16 bytes), and the descriptor says the metadata is there. A frame that asks for nothing is sent exactly as before, so turning it on costs nothing until you use it.

feat, _ := afxdp.QueryXSKFeatures("eth0") // does the driver honour it?
fleet, err := afxdp.Open("eth0", afxdp.WithTxMetadata(), ...)

xsk.SendFunc(n, func(i int, frame []byte) int {
    n := buildTCPSegment(frame) // checksum field = pseudo-header sum only
    afxdp.TxMetadata{
        Flags:      afxdp.TxMetaChecksum,
        CsumStart:  14 + 20, // start summing at the TCP header
        CsumOffset: 16,      // where in it the checksum field is
    }.Put(xsk.TxMeta(frame))
    return n
})

The contract is Linux's own CHECKSUM_PARTIAL: the checksum field holds the un-complemented pseudo-header sum, and the NIC sums from CsumStart and writes the result at CsumStart+CsumOffset. Ask first with QueryXSKFeatures (the netdev generic-netlink family, Linux 6.3+): a driver that does not implement the request transmits the frame untouched, with a wrong checksum and no error anywhere. On this tree's kernel ixgbe does exactly that; mlx5 honours it. A copy-mode socket (veth, generic XDP) computes the checksum in software, so a program behaves the same in tests as on a NIC that offloads.

WithTxMetadata cannot be combined with WithTxReuseRxFrames (received frames have no room in front of them), and SendBatch payloads are capped at FrameSize minus the 16 bytes.

One UMEM for the whole fleet: WithSharedMemory

By default each socket maps its own UMEM, so a frame address means something only to the socket it belongs to. WithSharedMemory(extra) maps one region for the fleet — every queue's frames in a row, then extra bytes the fleet never touches — and registers that same region with each socket, the way VPP registers its buffer pool with every AF_XDP socket it opens. Each socket still owns its own frames (its fill ring and transmit pool draw only from its slice), but an address is the same on every socket, and a frame in the extra area can be transmitted by any of them without a copy:

fleet, _ := afxdp.Open("eth0", afxdp.WithSharedMemory(256<<20),
    afxdp.WithOnForeignComplete(func(addr uint64) { cache.release(addr) }))
base, mem := fleet.Extra()          // the caller's area and its address
copy(mem[off:], packet)             // build the frame once, in place
xsk.Transmit([]afxdp.Desc{{Addr: base + off, Len: uint32(len(packet))}})

A transmitted address from outside a socket's own frames is reported to WithOnForeignComplete when it completes, from inside Complete, instead of going into a pool — so keep that callback to an atomic and never call back into the socket from it. Fleet.Memory() is the whole region. The same rules as any frame apply to what you build there: a descriptor stays within one FrameSize-aligned chunk, and with WithTxMetadata on it leaves the 16-byte gap before its address. examples/netstack serves a content cache this way.

Options and XDP mode

Everything is configured with functional options on Open:

Option Effect
WithQueues(n) bind n rx queues, from queue 0 (0 or omitted = all)
WithUDPPorts(p...) shorthand for WithFilter(MatchUDPPort(p...))
WithFilter(m...) redirect packets matching any of the given matches
WithNumFrames(n) total UMEM buffers, rx + tx (default 4096)
WithFrameSize(n) bytes per buffer (default 2048; auto 4096 on ENA for zero copy)
WithTxFrames(n) buffers reserved for transmit (default half)
WithRingSize(n) all four ring sizes, power of two (default 2048)
WithZeroCopy() require native zero copy, Open fails if unavailable
WithDriverMode() / WithGenericMode() force native / generic attach (default: auto)
WithMultiBuffer() let packets span several frames — jumbo support, costs zero copy
WithTxReuseRxFrames() let a forwarder transmit the frame it received, no copy (see below)
WithOptions(o) drop in a full Options struct, then override fields

By default Open picks the mode for you. It tries native zero copy, then native copy, then generic copy, using the first the driver accepts, so you get the fast path on a real NIC and it still works on veth without you choosing. Fleet.Info() reports what was selected. You rarely need to override it; WithGenericMode forces generic (and never blips the link), WithDriverMode forces native, and WithZeroCopy requires zero copy.

Heads up: native XDP reinitializes the driver's rings, so attaching or detaching it blips the link. On some 10G NICs (e.g. Intel ixgbe) the PHY then renegotiates for several seconds before the carrier is back, during which nothing can send or receive. So a native-mode program may sit idle for a few seconds at startup; that is the link relinking, not a hang. (The blast example waits for the link to come up first, for exactly this reason.) WithGenericMode does not reset the link, which is handy for quick local tests.

WithFrameSize(4096) gives zero copy on drivers that need page-sized frames; Open already applies it on AWS ENA (see below), so you rarely set it by hand. Each socket has its own UMEM of NumFrames * FrameSize bytes, so memory scales with the queue count; size NumFrames accordingly on many-queue NICs.

Zero-copy forwarding: WithTxReuseRxFrames

By default the receive and transmit frame pools are disjoint, which is what lets one receive goroutine and one transmit goroutine run without locking. The catch: a router that receives a packet and wants to send it back out has to copy it from an rx frame into a tx frame, because you can only transmit tx-pool frames.

WithTxReuseRxFrames() removes that copy. It makes Complete return each finished frame to the pool its address belongs to (an rx frame goes back to the rx pool) instead of always the tx pool, so you can Receive a frame, rewrite it in place, Transmit the same descriptor, and on completion it flows back to the fill ring — no copy either direction.

The tradeoff: Complete runs on the transmit side and may now touch the rx pool, so it is only safe when one goroutine drives both sides of the socket (the forwarder shape — receive, process, transmit in one loop). That is the common router layout, but it is not the default because it relaxes the lock-free split. It also can't combine with WithMultiBuffer() (Open refuses the pair): the completion routing recovers a frame's pool from its address with aligned-chunk arithmetic that assumes one frame per packet.

xsk, _ := afxdp.Open("eth0", afxdp.WithTxReuseRxFrames())
for {
    xsk.Fill(xsk.NumFreeFillSlots())
    for _, d := range xsk.Receive(64) {
        rewrite(xsk.GetFrame(d))   // edit the packet in place
        xsk.Transmit([]afxdp.Desc{d})
    }
    xsk.Complete(xsk.NumCompleted())
}
Need Wakeup

WithNeedWakeup() binds with XDP_USE_NEED_WAKEUP, the recommended operating mode for AF_XDP:

fleet, err := afxdp.Open("eth0",
    afxdp.WithUDPPorts(4789),
    afxdp.WithNeedWakeup(),
)

With the flag set, the kernel parks idle RX/TX queues and asks for an explicit wakeup through the AF_XDP ring flags instead of NAPI-polling in a loop (without it, a buffer-starved driver can burn entire cores in ksoftirqd while forwarding nothing — see the WithNeedWakeup doc comment for the gory details). The library handles the waking for you: Poll wakes the receive path, and the transmit path kicks the kernel as needed.

It also cuts transmit syscalls: when the ring flags show the driver awake and draining (typical in zero-copy mode), Transmit skips the sendto kick entirely. Stats().Kicks and Stats().KicksSuppressed show how often each happens.

It is not the default only because it changes the kernel contract for applications that drive the rings themselves rather than through Poll/Kick. If you use the high-level API, turn it on.

AWS EC2 / ENA

The ena driver (EC2, including the "network optimized" *n/*gn instances) supports native XDP, but the MTU has to be right, and on driver versions before 2.17.0 the channel count too. Miss either and Open silently falls back to generic XDP, which works but drops packets on the floor under load without any counter showing it. Fleet.Info() tells you which mode you got; if it says generic on ENA, check these:

  1. Lower the MTU. Base XDP hands the program one contiguous, page-sized (4 KB) buffer per packet, so a 9001-byte jumbo frame doesn't fit and ENA rejects the attach. Set the MTU under ~3.5 KB:

    ip link set dev ens5 mtu 3000
    

    (EC2 defaults to jumbo 9001. This is the driver's single-buffer XDP limit, not a library choice.) If you actually need jumbo frames, WithMultiBuffer() plus the driver patch in contrib/ena-jumbo/ lifts this — at the cost of zero-copy, so lowering the MTU stays the faster option otherwise.

  2. Only on ENA older than 2.17.0: free up queues for XDP. Those versions carved a dedicated transmit ring per channel out of the same fixed hardware queue budget as your normal channels, and refused a native attach unless channels were ≤ half the maximum. ENA 2.17.0 added full queue utilization in XDP and the limit is gone: measured on 2.17.2g, full channels (4 of 4) give native zero-copy for both receive and transmit. Check your version first, and only halve the channels if it is below 2.17.0:

    ethtool -i ens5 | grep '^version'
    ethtool -L ens5 combined 2
    

Zero copy on ENA additionally needs page-sized (4096-byte) UMEM frames — with the default 2048 the bind silently drops to native copy mode. Open handles this for you: when it sees the ena driver it defaults FrameSize to 4096, so once the MTU is right the banner reads zero-copy, native XDP with no code change. (Pass WithFrameSize yourself only to override that. It costs twice the UMEM per queue, which is why 4096 is an ena-only default, not the global one.)

These ethtool/ip settings are per-boot; re-apply after a reboot. They are NIC config, so set them yourself rather than have the library reconfigure your interface underneath you. (The frame-size default is the one thing the library can safely pick for you, since it only changes its own UMEM, not your NIC.)

Jumbo frames (multi-buffer)

By default a packet must fit one UMEM frame, which is what forces the MTU step above. WithMultiBuffer() lets a packet span several frames instead: it loads the XDP program with BPF_F_XDP_HAS_FRAGS (so it can attach at a jumbo MTU at all) and binds the socket with XDP_USE_SG (without which the kernel silently drops every multi-buffer packet).

Read chained packets with ReceivePackets rather than ReceiveReceive returns one Desc per frame, so a jumbo packet looks like several unrelated descriptors. SendBatch splits oversized payloads for you.

fleet, _ := afxdp.Open("ens5",
    afxdp.WithFilter(afxdp.MatchUDPPort(4789)),
    afxdp.WithMultiBuffer(),
)
xsk := fleet.Sockets()[0]

pkts := xsk.ReceivePackets(64)      // []Packet, each a []Desc in wire order
for _, p := range pkts {
    n := xsk.CopyOut(p, buf)        // or walk p's fragments to avoid the copy
    _ = buf[:n]
}
xsk.RecyclePackets(pkts)

The trade-off: a device reports its multi-buffer zero-copy limit as xdp-zc-max-segs. Where that is 1 — which is every ENA today — the kernel refuses an XDP_USE_SG bind in zero-copy mode, so Open settles for native copy. On ENA you get jumbo or zero-copy, never both. Native copy still beats the generic fallback comfortably, but if you don't need jumbo frames, lowering the MTU and leaving this option off is faster. Check Info().ZeroCopy.

On AWS this needs a driver patch. A stale compile probe in the ENA driver disables multi-buffer on current kernels, so XDP still won't attach at MTU 9001 no matter what this library does. A one-line fix, the reasoning behind it, and step-by-step instructions are in contrib/ena-jumbo/.

Measured on two c7gn.xlarge (4 vCPU, kernel 6.18, ena 2.17.2g, blastdrop over the private subnet, 64-byte frames), showing why the mode matters:

Receiver mode MTU rx pps CPU per packet
generic XDP (no setup) 9001 4.89M, ~2% loss
native, copy (WithMultiBuffer) 9001 4.29M 0.375 µs
native, copy (WithMultiBuffer) 3000 4.96M 0.349 µs
native + zero copy (auto 4096 frames) 3000 5.00M, 0.16% loss 0.280 µs

With the MTU set and the auto-4096 frames giving a zero-copy, native XDP banner, blast → drop runs a clean, steady 5.0M pps end to end.

Three things that table will mislead you about if you read it too quickly:

  • The ceiling is the instance, not the library. AWS's Nitro network layer polices packets-per-second, so past the instance's allowance (~5M pps on c7gn.xlarge) the pps_allowance_exceeded counter in ethtool -S ens5 climbs and the rate flat-lines there regardless of queues, cores or mode. Bigger instances raise the allowance.
  • Copy and zero copy therefore look closer than they are. They reach nearly the same pps here only because the allowance binds first. The real difference is CPU: copy costs about 1.25× per packet at 64 bytes and 1.76× at 1400 bytes, where there is more to copy. That matters when you are CPU-bound, which on this instance size you are not.
  • The jumbo MTU costs about 13% pps by itself (4.29M vs 4.96M, same mode). If you turn on WithMultiBuffer() but do not actually need jumbo frames, you pay that for nothing.

Also worth knowing: generic used to be far worse. On a 6.1 kernel this same test lost roughly 25% of a 4M pps sender. On 6.18 it loses about 2%. Still worth avoiding, since it drops packets where native does not and cannot do zero copy, but the old figure no longer describes it.

Examples

Example Shows
examples/helloworld the simplest program, Open with an ICMP filter, log Info, print pings, periodic Stats
examples/drop UDP sink that discards everything, minimal per-packet work, for measuring raw receive pps
examples/blast UDP packet generator, builds frames in the UMEM and transmits at line rate, the sender to point at drop
examples/l2fwd the low-level API (NewSocket/NewProgram), reflect frames, per-socket Stats
examples/multiqueue Open across all queues, Info plus aggregate Stats
examples/udpreflector Open plus a UDP-port filter, wire-speed UDP echo with Info/Stats
examples/dns a real scenario, a UDP/53 forwarding DNS resolver: AF_XDP client path, miekg/dns upstream to 8.8.8.8, async worker pool
examples/natlb a NAT-mode TCP load balancer: SNAT/PAT plus DNAT in userspace, a connection table both ways, zero-copy rewrite with WithTxReuseRxFrames, and a namespace demo you can run
examples/netstack a userspace TCP server at 10G line rate: gVisor's TCP/IP stack (netstack) fed straight from the rings through a stack.LinkEndpoint, with segmentation and receive offload done in the endpoint, an echo/HTTP server that terminates TCP off the kernel, a -stack kernel A/B, and the gVisor patches it took
pcapfilter/example filtering by tcpdump expression-filter "tcp port 443 and not src net 192.0.2.0/24"
examples/customfilter/gre the low-level story end to end: a custom Match written with the exported API, MatchError validation, and a unit test that runs it against crafted packets with no NIC
examples/customfilter/ four more custom matchers: a VLAN ID, a destination MAC, TCP SYNs, a VXLAN VNI, plus udpsrcport as the tutorial
go build -o drop ./examples/drop
sudo ./drop -iface eth0 -port 9999

The dns example is its own Go module (so only it pulls in github.com/miekg/dns and the core library stays dependency-minimal). Build it from its directory:

cd examples/dns && go mod tidy && go build .        # also examples/netstack (its own module; pulls in gVisor)
sudo ./dns -iface eth0 -upstream 8.8.8.8:53

Concurrency

A Socket is safe for one receive goroutine concurrent with one transmit goroutine, lock-free. Within a direction it is single-threaded. If multiple goroutines transmit on one socket, serialize the tx-side calls (Alloc, Transmit, Complete) with your own mutex; the rx side still needs none. Or give each producer its own queue. The receive side is single-consumer too.

A common shape is one goroutine per queue handling both directions for that queue, as in the examples.

Introspection: Info and Stats

Fleet.Info() reports how the fleet is actually running, handy to log at startup, and Fleet.Stats() aggregates per-queue counters so you do not have to track them yourself. Both have String methods.

info, _ := fleet.Info()
log.Printf("started: %s", info)
// started: eth0: 8 queues, zero-copy, native XDP, 4096x2048B frames, driver ena, filter udp/4789

s, _ := fleet.Stats() // e.g. once a second
log.Print(s)
// rx=1530244 tx=0 packets, 19 pkt/poll, rx_drops=12

Info exposes the interface, NIC driver, queue count, frame size and count, the XDP attach mode (native, generic, or hardware, read back from the kernel), whether zero copy was actually granted (read from each socket's XDP_OPTIONS, not just what was requested), and the applied filter as a readable summary (udp/53, udp/4789 | icmp-echo, or all when nothing is filtered).

Stats sums received and transmitted packet counts (straight from the rings, no per-packet bookkeeping in your loop) and the kernel's drop and error counters (rx_dropped, rx_ring_full, invalid descriptors), with a PerQueue breakdown when you need to find a hot or dropping queue. All counters are cumulative, so sample twice and subtract for a rate. Byte counts are not included, the kernel does not track them, so sum frame lengths in your loop if you need them.

Syscall counters

Three counters expose what the library is doing with syscalls, which is usually what you want when a loop is slower than expected:

field meaning
Polls blocking poll(2) calls on the receive side
Kicks sendto(2) kicks issued on the transmit side
KicksSuppressed transmit kicks skipped because need-wakeup showed the driver awake

Stats.PacketsPerPoll() divides Received by Polls: how many packets each receive syscall paid for, i.e. how well your loop batches. A drop sink at 12 Mpps over 12 zero-copy ixgbe queues measures about 20 packets per poll; a value near 1 means a syscall per packet, usually because the loop drains less per wakeup than is waiting. KicksSuppressed climbing on a zero-copy link means need-wakeup is doing its job.

s, _ := xsk.Stats()
log.Printf("%.0f pkt/poll, %d polls, %d kicks (%d suppressed)",
    s.PacketsPerPoll(), s.Polls, s.Kicks, s.KicksSuppressed)

examples/drop prints polls/s and pkt/poll on its per-second line — that is where these are easiest to see against real traffic.

Cleanup and lifecycle

Call fleet.Close() (or program.Detach) when you are done. It removes the XDP program from the interface and frees the BPF maps. Wire it up for both normal exit and signals:

fleet, _ := afxdp.Open("eth0", afxdp.WithUDPPorts(7000))
defer fleet.Close()

sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig // then return, so the deferred Close runs

Crash safety: Open/Attach attach the program through a BPF link, which the kernel auto-detaches when the process exits, even on a panic or kill -9 (Linux 5.9+). On older kernels it falls back to the legacy netlink attach, which survives a crash: the program stays bound to the interface, and since the sockets are gone it drops matching traffic until removed. Recover a leftover program with sudo ip link set dev eth0 xdp off. Open also clears any program already attached before attaching its own, so a restart after an unclean exit just works.

Terminology

AF_XDP has its own vocabulary; a quick glossary so the code reads clearly.

AF_XDP is the Linux socket family that delivers packets from a NIC driver straight to userspace, skipping the kernel network stack. An XSK ("XDP socket") is a single AF_XDP socket bound to one NIC receive queue; xsk is the conventional variable name for one (from the kernel and libbpf code), and here an XSK is the Socket type. The UMEM is the region of memory shared with the kernel that holds packet buffers, called frames. The rings are the four single-producer/single-consumer queues between you and the kernel: fill and rx on the receive side, tx and completion on the transmit side, and the library drives them for you. A Fleet (this library's own term, not standard AF_XDP) is a set of XSKs, one per receive queue, bound together under one XDP program so you capture every queue at once.

Under the hood

This is a fork of asavie/xdp. It keeps that project's proven UMEM and ring setup and changes two things that matter in production.

Independent rx/tx frame pools. The upstream library kept a single free-frame list shared by both directions. A receive goroutine refilling the fill ring while a transmit goroutine sent packets could be handed the same UMEM frame, so a frame got overwritten while the NIC was still DMA-ing it, corrupting packets on the wire. The failure is silent: every local counter reads clean and you only see it as drops at the peer (and, under WireGuard, a TCP retransmit collapse). It hits hardest on weak-memory-model CPUs like ARM/Graviton. This fork splits the UMEM into a disjoint receive pool and transmit pool, each owned by one direction, so there is no shared mutable state on the data path, hence the lock-free one-rx plus one-tx guarantee above. The ring indices are also accessed with acquire/release atomics, as the protocol requires, so it is correct on weak-memory CPUs too. (It also replaces an O(N) free-frame scan with an O(1) pool.)

All queues, easily, with optional filtering. Real NICs spread received traffic across several rx queues (RSS); a socket bound to queue 0 sees only its slice. Open binds one socket to every queue (or a subset with WithQueues) under a single XDP program, and WithFilter controls which packets that program redirects versus passes to the kernel, without you hand-writing per-queue maps or eBPF.

If you need the low-level pieces, NewProgram, NewSocket, and Program.Attach / Register are exported too; Open is just the convenient assembly of them.

Requirements

AF_XDP needs CAP_NET_RAW (or root) and enough locked memory for the BPF maps and UMEM (raise RLIMIT_MEMLOCK, e.g. ulimit -l). Open picks native zero copy when the driver supports it and otherwise falls back automatically, so you do not have to, and Fleet.Info() shows what you got. On AWS ENA, zero copy additionally needs page-sized frames (Open defaults FrameSize to 4096 there automatically) and a non-jumbo MTU, since the driver caps XDP MTU at 3502 (ip link set ens5 mtu 1500). Driver versions before 2.17.0 also need halved channels. See the AWS EC2 / ENA section.

Credits and license

Forked from asavie/xdp (BSD-3-Clause); the UMEM/ring mmap and bind logic and the embedded XDP redirect program derive from that project. The descriptor-pool, concurrency, multi-queue, and filter layers are new work here. BSD-3-Clause, see LICENSE.

Documentation

Overview

Package afxdp is a small, easy-to-use Go library for AF_XDP sockets.

AF_XDP delivers packets from a network driver straight into a userspace process, bypassing the kernel network stack, for very high packet rates. See https://www.kernel.org/doc/html/latest/networking/af_xdp.html for background.

Terminology

AF_XDP has its own vocabulary. An XSK ("XDP socket") is a single AF_XDP socket bound to one NIC receive queue; xsk is the conventional variable name for one, and here it is the Socket type. A UMEM is the memory region shared with the kernel that holds packet buffers (frames). Four single-producer/single-consumer rings move frames to and from the kernel: fill and rx on the receive side, tx and completion on the transmit side. A Fleet (this library's term, not standard AF_XDP) is one XSK per receive queue bound together under a single XDP program.

This package is a fork of github.com/asavie/xdp. It keeps that project's proven UMEM and ring setup but changes two things that matter in production:

  • Independent rx/tx frame pools. The UMEM frames are split into a disjoint receive pool and transmit pool, each owned by a single direction. One receive goroutine and one transmit goroutine can therefore run against the same Socket with no lock and no shared mutable state. The upstream library shared one free-frame list across both directions, so a concurrent fill and transmit could hand out the same frame and corrupt packets on the wire — silent, because the corruption only shows up as dropped frames at the peer.

  • All queues, easily. Real NICs spread received traffic across several rx queues. OpenFleet binds one socket to every queue under a single XDP program, so you get all the traffic and N sockets to drive in parallel, without wiring up per-queue maps by hand.

Concurrency

A Socket is safe for one receive goroutine concurrent with one transmit goroutine, lock-free. Within a direction it is single-threaded: if you transmit from multiple goroutines, serialize the transmit calls (Alloc/Transmit/Complete) yourself, or give each producer its own queue via a Fleet. The receive side is likewise single-consumer.

Receiving

Post buffers, wait, read them, recycle them:

for {
	xsk.Fill(xsk.NumFreeFillSlots()) // give the kernel buffers
	n, err := xsk.Poll(-1)           // block until packets arrive
	if err != nil {
		log.Fatal(err)
	}
	descs := xsk.Receive(n)
	for _, d := range descs {
		frame := xsk.GetFrame(d) // the received bytes
		_ = frame
	}
	xsk.Recycle(descs) // return frames so they can be filled again
}

Transmitting

The easy way is SendBatch (or SendFunc), which does all the ring bookkeeping for you — reclaiming sent frames, kicking the kernel, and never stalling on a full ring — so you just call it in a loop:

for {
	n, err := xsk.SendBatch(packets) // copies and transmits; returns the count queued
	...
}

SendFunc avoids the copy and fills each frame in place (for a generator that varies a field per packet). SendFuncNoKick is the same without the kick, for a loop that transmits many small packets per batch and calls KickIfNeeded once at the end. The primitives underneath — Alloc, Transmit, Complete, Kick, NumFreeTxSlots — are exported too if you want to hand-roll the loop; if you do, remember to Kick when the ring is full or copy-mode TX deadlocks.

WithTxMetadata (Linux 6.8+) reserves 16 bytes in front of every transmit frame through which a frame asks the NIC to finish its transport checksum: TxMetadata{...}.Put(xsk.TxMeta(frame)) inside a SendFunc callback. Ask QueryXSKFeatures first — a driver that does not implement the request sends the frame with the checksum as written, silently.

The easy path: Open

For most programs you do not need to wire up sockets and queues by hand. Open attaches the XDP program, binds one socket per rx queue, and registers them, all configured with functional options:

fleet, err := afxdp.Open("eth0",
	afxdp.WithQueues(4),      // bind 4 rx queues (default: all)
	afxdp.WithUDPPorts(4789), // only UDP/4789 to us; the rest to the kernel
)
if err != nil {
	log.Fatal(err)
}
defer fleet.Close()
for q, xsk := range fleet.Sockets() {
	go serveQueue(q, xsk) // one goroutine per queue
}

A filter is required: without one Open would redirect every packet to your sockets and starve the kernel (an easy way to cut off your own SSH), so it returns an error instead. WithUDPPorts (or the more general WithFilter, which composes Match builders like MatchUDPPort, MatchTCPPort, MatchICMPv4Echo, MatchIPv4Proto, MatchSrcIP, MatchDstIP, MatchFlow, MatchEtherType) redirects only matching packets and passes everything else to the normal kernel stack — so you can run on a live interface safely. Use WithFilter(MatchAll()) to deliberately take everything, or WithFilter(MatchNone()) for transmit-only. If none of the builders express what you need, the bpfmatch module matches what a classic-BPF (tcpdump) program matches, and NewMatch below it lets you supply raw eBPF. MatchPacket runs a filter against a packet you supply, so you can unit-test either without a NIC.

Expression strings ("tcp port 22 and not src host 192.0.2.1") need libpcap and so live in a separate module, github.com/atoonk/go-afxdp/pcapfilter; this module stays pure Go.

Note that go-afxdp's public API includes types from github.com/cilium/ebpf: NewMatch's builder returns asm.Instructions and Program embeds an *ebpf.Program, so a major-version change there would be a breaking change here.

The cBPF layer lives in the separate module github.com/atoonk/go-afxdp/bpfmatch and filter expressions in github.com/atoonk/go-afxdp/pcapfilter, so the compiler they need — and, for pcapfilter, libpcap and cgo — stay out of this module entirely.

If you do take everything on the NIC you administer the box through, add WithKeepManagement: it leaves ARP, IPv6 neighbour discovery, SSH and DNS replies with the kernel so the session you are typing in survives. ARP and ND are the important part — without them the kernel loses the gateway's link-layer address within about a minute and the box is unreachable regardless of how carefully its SSH packets were passed through.

Open also defers NAPI on a native-mode NIC (napi_defer_hard_irqs and gro_flush_timeout) so the kernel batches packets instead of waking the receiver millions of times a second — on a 100G NIC taking 130 Mpps across 48 queues that is half the machine. The flush timeout is deliberately short (20µs): it doubles as a ceiling on how fast one queue can be drained, so a longer one is free with many thin queues and a cliff with a few busy ones. Your previous values are restored on Close and reported by Fleet.Info; use WithoutAutoTune to manage them yourself, or WithNAPITuning to change them. Generic/SKB mode is never touched.

Open also places your workers. Each queue is really two halves — the interrupt, NAPI poll and XDP redirect in the kernel, and your goroutine in userspace — and left alone they land on unrelated cores, so every packet crosses a cache boundary. Open works out which CPU each queue's interrupt is on and locks the goroutine driving that queue beside it, the first time it calls any of Poll, Receive, ReceivePackets, Transmit, SendFunc or SendBatch; interrupts with no free core next to them are moved and restored on Close. A transmit-only fleet (WithFilter(MatchNone())) shares the interrupt's core instead, since nothing is competing for it. On a 100G NIC that is 2x the packets for half the CPU, and 64-byte line rate from eight physical cores. Use WithoutAffinity to place threads yourself, WithAffinity to name the CPUs, or Socket.Pin to do it at a moment of your choosing.

Open auto-selects the XDP mode: it tries native zero-copy, then native copy, then generic copy, using the first the driver accepts, so you get the fast path on a real NIC and it still works on veth. Fleet.Info reports the choice. Override it only if needed with WithDriverMode, WithGenericMode, or WithZeroCopy. (Native XDP reinitializes the driver's rings, so attaching it briefly blips the link.) The other options (WithFrameSize, WithNumFrames, WithRingSize) tune the UMEM and rings.

Requirements

Introspection. Fleet.Info reports how the fleet is running (interface, queues, frame budget, XDP mode, and whether zero-copy was granted); Fleet.Stats sums the per-queue packet counts and the kernel's drop/error counters so you don't have to track them yourself. Both have String methods for easy logging.

Cleanup. Call Fleet.Close (or Program.Detach) to remove the XDP program and free the maps. Open and Program.Attach attach through a BPF link, so the kernel auto-detaches the program when the process exits, even on a crash or kill -9 (Linux >= 5.9); on older kernels they fall back to the legacy netlink attach, which survives a crash and must then be removed (Detach, or "ip link set dev <iface> xdp off"). Either way Attach first clears any program left attached, so restarting after an unclean exit just works.

AF_XDP needs CAP_NET_RAW (or root) and enough locked memory for the BPF maps and UMEM (raise RLIMIT_MEMLOCK, e.g. ulimit -l). Native-driver (XDP_FLAGS_DRV_MODE) zero-copy requires driver support; otherwise the kernel falls back to generic (SKB) mode, which still works but is slower. Confirm zero-copy with Socket.ZeroCopy after binding — some drivers need page-sized FrameSize (4096) and a reduced MTU before they will grant it. Open sets FrameSize to 4096 automatically on AWS ENA.

Index

Constants

View Source
const (
	EtherTypeIPv4 = 0x0800
	EtherTypeIPv6 = 0x86DD
	EtherTypeARP  = 0x0806
	EtherTypeVLAN = 0x8100
)

Well-known EtherTypes, in their usual host-order values, for use with NetShort when comparing the EtherType field of a frame.

View Source
const (
	BindZeroCopy = unix.XDP_ZEROCOPY
	BindCopy     = unix.XDP_COPY
)

Zero-copy / copy bind flag re-exports so callers don't have to import golang.org/x/sys/unix just for these.

View Source
const (
	XDPFlagsDrvMode = unix.XDP_FLAGS_DRV_MODE
	XDPFlagsSkbMode = unix.XDP_FLAGS_SKB_MODE
	XDPFlagsHwMode  = unix.XDP_FLAGS_HW_MODE
)

re-export of unix XDP attach-mode flags so callers need not import unix.

View Source
const (
	TxMetaTimestamp = uint64(unix.XDP_TXMD_FLAGS_TIMESTAMP)
	TxMetaChecksum  = uint64(unix.XDP_TXMD_FLAGS_CHECKSUM)
)

Metadata request flags.

View Source
const DescTxMetadata = uint32(unix.XDP_TX_METADATA)

DescTxMetadata is the Desc.Options bit that says the frame carries transmit metadata. Alloc sets it on the descriptors it hands out; a caller building descriptors of its own (a shared-memory frame, see WithSharedMemory) sets it when it has filled the metadata in.

View Source
const OffEtherType = offEtherType

OffEtherType is the offset of the EtherType field from the frame base returned by MatchEnv.FrameBase. The Ethernet header is dst MAC (6 bytes), src MAC (6 bytes), EtherType (2 bytes); L3 payload starts at offset 14.

Variables

This section is empty.

Functions

func CountQueues

func CountQueues(iface string) (int, error)

CountQueues returns the number of rx queues on an interface, i.e. the number of AF_XDP sockets needed to receive all RSS-distributed traffic. It reads /sys/class/net/<iface>/queues, which reflects the live real_num_rx_queues.

func MatchPacket added in v0.9.0

func MatchPacket(pkt []byte, opts ...Option) (bool, error)

MatchPacket reports whether pkt would be redirected by a filter configured with the given options, which are the same options Open takes:

ok, err := afxdp.MatchPacket(frame,
	afxdp.WithFilter(myMatch),
	afxdp.WithExcept(afxdp.MatchSrcIP("192.0.2.10/32")),
)

It is a testing aid. It builds the same classification blocks Open would attach — from the same configuration, through the same validation — and runs them in the kernel with BPF_PROG_TEST_RUN against pkt. It does not reimplement matching in Go, so a wrong offset, a missing byte swap, the wrong protocol number or a mishandled VLAN tag shows up here exactly as it would on a live interface. That makes a filter unit-testable with no NIC and no traffic.

Test the near misses too. A byte-order slip typically still matches something, so a matcher that only ever sees packets it should accept looks correct right up until it is deployed.

Distinguish the two kinds of error, and do not simply skip on any of them. The kernel reports a verifier rejection as EACCES, so a broken matcher and a missing capability both arrive as "permission denied" and `err != nil` alone cannot tell them apart. A verifier rejection unwraps to *ebpf.VerifierError and means the filter is wrong, which is a test failure; anything else means this machine cannot run the check, which is a skip:

ok, err := afxdp.MatchPacket(frame, afxdp.WithFilter(myMatch))
if err != nil {
	var ve *ebpf.VerifierError
	if errors.As(err, &ve) {
		t.Fatalf("filter rejected:\n%+v", ve) // the filter is broken
	}
	t.Skipf("cannot run BPF here: %v", err) // no privileges, no BPF
}

Requirements and limits:

  • It loads and runs a BPF program, so it needs the same privileges Open does (CAP_BPF and CAP_NET_ADMIN, or root) plus locked-memory headroom. A test binary usually needs rlimit.RemoveMemlock() from github.com/cilium/ebpf/rlimit before the first call.
  • The kernel requires pkt to be at least 14 bytes for an XDP test run.
  • Only classification is exercised. The redirect tail that picks the destination socket for a queue is not, so this cannot tell you whether a packet ends up on the queue you expect — only whether it is selected.
  • WithKeepManagement has no interface to read addresses from here, so it expands to the unscoped rules (any destination) that Open falls back to when an interface's addresses cannot be determined. That is wider than what Open installs when it does know them.
  • WithMultiBuffer is honoured: the test program is loaded with BPF_F_XDP_HAS_FRAGS, exactly as Open would load it, so a filter that only verifies without the flag (or only with it) is caught here.
  • Options unrelated to filtering (queue counts, frame sizes, XDP mode) are accepted and ignored.

func NetShort added in v0.9.0

func NetShort(v uint16) int32

NetShort returns the network-byte-order (big-endian) value of a 16-bit field as a little-endian load sees it, so it can be compared against a u16 loaded from the packet with asm.Half. Use it for ports and EtherTypes alike, and for any mask applied to such a field:

asm.JNE.Imm(asm.R3, afxdp.NetShort(afxdp.EtherTypeIPv4), e.Next)

eBPF loads use the host's byte order, so this is a byte swap on a little-endian host and the identity on a big-endian one.

Types

type Desc

type Desc unix.XDPDesc

Desc is an AF_XDP rx/tx descriptor: a frame address within the UMEM and a length. It is layout-compatible with unix.XDPDesc.

type Fleet

type Fleet struct {
	// contains filtered or unexported fields
}

Fleet is a set of AF_XDP sockets (XSKs) — one per rx queue on an interface — bound together under a single XDP program. ("Fleet" is this library's term, not standard AF_XDP vocabulary; the standard names stop at the single socket, the XSK.)

It is the easy path: most NICs spread incoming traffic across several rx queues (RSS), and a socket bound to only queue 0 sees just its share. A Fleet binds every queue so you receive all of the traffic, and gives you N independent sockets to drive from N goroutines.

Each socket follows the per-Socket concurrency rule: one receive goroutine and one transmit goroutine per socket, lock-free. A common pattern is one goroutine per queue handling both directions for that queue.

func Open

func Open(iface string, opts ...Option) (*Fleet, error)

Open is the easy, high-level constructor. It attaches an XDP program to an interface, binds one AF_XDP socket per rx queue, and registers each so the traffic you asked for is delivered. Configure it with functional options:

fleet, err := afxdp.Open("eth0",
	afxdp.WithUDPPorts(4789),   // only UDP/4789 to us, rest to the kernel
	afxdp.WithQueues(4),        // bind 4 queues (default: all)

A filter is REQUIRED: Open returns an error if you don't pass one. Without a filter every packet on the interface would be redirected to your sockets and kept from the kernel — an easy way to cut off your own SSH. Pass WithUDPPorts / WithFilter to capture specific traffic, WithFilter(MatchAll()) to take everything on purpose, or WithFilter(MatchNone()) for transmit-only.

Open auto-selects the XDP mode: it tries native zero-copy, then native copy, then generic copy, using the first the driver accepts. You don't have to reason about modes; check Fleet.Info to see which was chosen. Override with WithDriverMode, WithGenericMode, or WithZeroCopy only if you have a need.

On any error it cleans up whatever it already created. Each socket gets its own UMEM of NumFrames*FrameSize bytes, so total memory scales with the queue count — size NumFrames (WithNumFrames) accordingly on many-queue NICs.

func OpenFleet deprecated

func OpenFleet(iface string, options *Options) (*Fleet, error)

OpenFleet is a thin wrapper around Open for callers that already hold an Options struct. Prefer Open with functional options.

Deprecated: use Open(iface, afxdp.WithOptions(opts)).

func (*Fleet) Affinity added in v0.10.0

func (f *Fleet) Affinity() []int

Affinity reports the CPU each queue's worker is pinned to, indexed by queue ID, with -1 for queues that are not pinned (affinity disabled, no CPU available, or the worker goroutine has not touched the datapath yet). See WithoutAffinity.

func (*Fleet) Close

func (f *Fleet) Close() error

Close unregisters and closes every socket, detaches the XDP program, and releases its maps. It returns the first error encountered but always attempts every step.

func (*Fleet) Extra added in v0.12.0

func (f *Fleet) Extra() (addr uint64, mem []byte)

Extra returns the caller's area of the shared UMEM and its address (the offset of its first byte), or nil and 0 without WithSharedMemory. Frames built there must keep to the same rules as any other: a descriptor stays within one FrameSize-aligned chunk, and with transmit metadata on, leaves the metadata gap before its address.

func (*Fleet) Info

func (f *Fleet) Info() (Info, error)

Info gathers a snapshot describing how the Fleet is running. The zero-copy flag is read from each socket's XDP_OPTIONS (the authoritative source, not just what was requested at bind); the XDP mode is read back from the kernel via netlink.

func (*Fleet) Memory added in v0.12.0

func (f *Fleet) Memory() []byte

Memory returns the fleet's shared UMEM, or nil without WithSharedMemory. Addresses (in Desc, and reported to OnForeignComplete) are offsets into it.

func (*Fleet) NumQueues

func (f *Fleet) NumQueues() int

NumQueues returns how many queues (and sockets) the Fleet manages.

func (*Fleet) Program

func (f *Fleet) Program() *Program

Program returns the underlying XDP program, e.g. to register or unregister queues manually.

func (*Fleet) Socket

func (f *Fleet) Socket(queueID int) *Socket

Socket returns the socket bound to a specific queue ID.

func (*Fleet) Sockets

func (f *Fleet) Sockets() []*Socket

Sockets returns the per-queue sockets, indexed by queue ID.

func (*Fleet) Stats

func (f *Fleet) Stats() (FleetStats, error)

Stats aggregates statistics across all of the Fleet's sockets.

func (*Fleet) WaitLinkUp added in v0.2.0

func (f *Fleet) WaitLinkUp(timeout time.Duration) bool

WaitLinkUp blocks until the Fleet's interface is operationally up, or the timeout elapses; it reports whether the link is up.

Attaching a native XDP program makes many drivers (ixgbe, for one) reinitialize their rings, which bounces the physical link for several seconds while it renegotiates. Until carrier returns nothing is received and anything transmitted is lost, so call this after Open — on senders and receivers alike — before starting traffic or judging counters. The link must hold up for about a second of consecutive readings before this returns: the attach-induced flap can begin a moment after Open returns, so a single instantaneous "up" could race it.

type FleetStats

type FleetStats struct {
	Queues int

	RxPackets uint64 // received descriptors, summed over queues
	TxPackets uint64 // transmitted descriptors, summed over queues

	RxDropped       uint64 // kernel: packets dropped (e.g. no rx ring space)
	RxInvalidDescs  uint64 // kernel: bad descriptors on the fill ring
	TxInvalidDescs  uint64 // kernel: bad descriptors on the tx ring
	RxRingFull      uint64 // kernel: drops because the rx ring was full
	RxFillRingEmpty uint64 // kernel: rx starved because the fill ring was empty
	TxRingEmpty     uint64 // kernel: tx ring had nothing to send

	// PerQueue holds the raw per-socket Stats, indexed by queue id, for when
	// you need to see which queue is hot or dropping.
	PerQueue []Stats
}

FleetStats aggregates per-socket counters across every queue in the Fleet, so you don't have to sum them yourself. Packet counts come from the rings (no work needed in your receive loop); the drop/error counters come from the kernel's XDP_STATISTICS. Byte counts are not included — the kernel does not track them, so count bytes in your receive loop if you need them.

All counters are cumulative since the sockets were opened; sample twice and subtract for a rate.

func (FleetStats) String

func (s FleetStats) String() string

String renders FleetStats as a single human-readable line.

type Info

type Info struct {
	Interface string // interface name
	Ifindex   int    // interface index
	Driver    string // NIC driver (e.g. "ena", "ixgbe", "mlx5_core"); "" if unknown
	NumQueues int    // sockets/queues bound
	FrameSize int    // bytes per UMEM frame
	NumFrames int    // UMEM frames per socket
	ZeroCopy  bool   // true only if every queue is in zero-copy mode
	XDPMode   string // "native", "generic", "hardware", "none", or "unknown"
	Filter    string // the applied XDP filter, e.g. "udp/53", "udp/4789 | icmp-echo", or "all"
	// Tuning describes the NAPI settings Open applied to the interface, e.g.
	// "defer=2 flush=200ms", or "untuned" when it left them alone (generic
	// mode, WithoutAutoTune, or no permission to write /sys). See
	// WithoutAutoTune — these are host settings, so they are worth seeing.
	Tuning string
	// Affinity describes the CPU placement, e.g. "4/4 workers pinned
	// (following driver hints)", or "off" when disabled. Workers count as
	// pinned once their goroutine has touched the datapath, so read this
	// after traffic has started if you want the final answer. See
	// WithoutAffinity.
	Affinity string
}

Info is a snapshot of how a Fleet is running: which interface, how many queues, the frame budget, the XDP attach mode, and whether the kernel granted zero-copy. It is meant to be logged at startup. Info has a String method, so:

info, _ := fleet.Info()
log.Print(info) // eth0: 4 queues, zero-copy, native XDP, 4096x2048B frames

func (Info) String

func (i Info) String() string

String renders Info as a single human-readable line.

type Match

type Match struct {
	// contains filtered or unexported fields
}

A Match is one packet-classification rule for WithFilter. A packet is redirected to the AF_XDP sockets if it satisfies ANY of the matches (logical OR); everything else is passed to the kernel network stack. Build matches with MatchUDPPort, MatchTCPPort, MatchICMPv4Echo, MatchIPv4Proto, MatchEtherType, or MatchAll, and combine freely:

afxdp.Open("eth0", afxdp.WithFilter(
	afxdp.MatchUDPPort(4789, 51820), // two UDP ports
	afxdp.MatchICMPv4Echo(),           // ...and ICMP echo requests
))

Matches operate on Ethernet + IPv4/IPv6. The port and ICMP builders read L4 fields at fixed offsets and guard that read: an IPv4 packet with options (IHL > 5) or a non-initial fragment does not match, by an explicit header check rather than by accident — without it, bytes at the fixed offset could spell the requested port and false-match. IPv6 packets with extension headers do not match either (Next Header is compared directly). The IP (CIDR) builders read fixed address offsets and are unaffected by all of this. A single 802.1Q VLAN tag is skipped transparently, so the same match works whether or not the NIC strips the tag before XDP; stacked QinQ tags are not unwound and such frames do not match.

For classification beyond these builders, NewMatch takes a caller-supplied eBPF block and is checked and assembled exactly like the built-ins. Failing that, redirect everything and classify in your receive loop.

func MatchAll

func MatchAll() Match

MatchAll matches every packet. Use it as a catch-all, or on its own it is equivalent to running with no filter at all.

func MatchDstIP

func MatchDstIP(cidr string) Match

MatchDstIP matches packets whose destination IP is inside the given CIDR. See MatchSrcIP for the CIDR format.

func MatchError added in v0.9.0

func MatchError(desc string, err error) Match

MatchError returns a Match that makes Open fail with err. It is the way a custom matcher constructor reports invalid arguments, mirroring what the built-in builders do internally: return a Match rather than an error, and let Open surface the problem.

func MatchSrcMAC(s string) afxdp.Match {
	mac, err := net.ParseMAC(s)
	if err != nil {
		return afxdp.MatchError("src-mac(invalid)", err)
	}
	if len(mac) != 6 {
		return afxdp.MatchError("src-mac(invalid)",
			fmt.Errorf("need a 6-byte Ethernet address, got %d bytes", len(mac)))
	}
	return afxdp.NewMatch("src-mac/"+mac.String(), func(e afxdp.MatchEnv) (asm.Instructions, error) {
		...
	})
}

desc is what Fleet.Info would have reported; it appears in the error message.

func MatchEtherType

func MatchEtherType(etherType uint16) Match

MatchEtherType matches packets with the given EtherType (e.g. 0x0806 for ARP, 0x86DD for IPv6). Pass the value in host order; MatchEtherType handles the byte order. A single 802.1Q tag is skipped first, so this matches the inner (encapsulated) EtherType — MatchEtherType(0x0806) catches ARP whether or not the frame is tagged.

func MatchFlow

func MatchFlow(srcCIDR, dstCIDR string) Match

MatchFlow matches packets whose source IP is in srcCIDR AND whose destination IP is in dstCIDR, i.e. one direction of a flow. Both CIDRs must be the same address family (IPv4 with IPv4, or IPv6 with IPv6); a single host on a side is "/32" or "/128". To match a flow in either direction, OR two of them:

afxdp.WithFilter(
	afxdp.MatchFlow("10.0.0.1/32", "10.0.0.2/32"),
	afxdp.MatchFlow("10.0.0.2/32", "10.0.0.1/32"),
)

func MatchICMPv4Echo added in v0.9.0

func MatchICMPv4Echo() Match

MatchICMPv4Echo matches IPv4 ICMP echo-request (ping) packets. For IPv6 see MatchICMPv6Echo — the two are different protocols with different numbers, so there is no single "ICMP" match.

func MatchICMPv6Echo added in v0.9.0

func MatchICMPv6Echo() Match

MatchICMPv6Echo matches ICMPv6 echo-request (ping6) packets, i.e. Next Header 58 and type 128. Like MatchIPv6NextHeader it does not walk extension headers.

func MatchIPv4Proto added in v0.9.0

func MatchIPv4Proto(proto uint8) Match

MatchIPv4Proto matches IPv4 packets whose Protocol field is proto (e.g. 47 for GRE, 50 for ESP).

It is IPv4-only by name because the IPv6 equivalent is not equivalent: IPv6's Next Header names whatever comes next, which may be an extension header rather than the upper-layer protocol. MatchIPv6NextHeader matches that field honestly; neither walks the extension-header chain. Note ordinary tcpdump expressions ("ip6 proto 6") do not walk it either — the pcap expression that does is "protochain 6", usable through the bpfmatch/pcapfilter layer.

func MatchIPv6NextHeader added in v0.9.0

func MatchIPv6NextHeader(nh uint8) Match

MatchIPv6NextHeader matches IPv6 packets whose Next Header field is nh.

Next Header names what immediately follows the fixed 40-byte IPv6 header, which is the upper-layer protocol only when no extension headers are present. A packet carrying a Hop-by-Hop or Routing header has nh naming *that*, so MatchIPv6NextHeader(6) does not match TCP behind an extension chain. That is the honest reading of the field. Ordinary tcpdump expressions read it the same way; the pcap expression that does walk the chain is "protochain", via the bpfmatch/pcapfilter layer.

func MatchNone

func MatchNone() Match

MatchNone matches nothing: every packet is passed to the kernel, none is redirected. On its own (afxdp.WithFilter(afxdp.MatchNone())) it attaches the XDP program — which is what enables a driver's zero-copy datapath — without stealing any receive traffic. That's exactly what a transmit-only program (a packet generator) wants: zero-copy TX without disturbing the host's RX.

func MatchSrcIP

func MatchSrcIP(cidr string) Match

MatchSrcIP matches packets whose source IP is inside the given CIDR. The CIDR chooses the address family: "10.0.0.0/8" matches IPv4, "2001:db8::/32" matches IPv6, a single host is "/32" (v4) or "/128" (v6).

func MatchTCPPort

func MatchTCPPort(ports ...uint16) Match

MatchTCPPort matches TCP packets whose destination port is one of ports, over both IPv4 and IPv6. With no ports it matches all TCP traffic.

Prior to the dual-family change this matched IPv4 only, silently.

func MatchTCPSrcPort added in v0.9.0

func MatchTCPSrcPort(ports ...uint16) Match

MatchTCPSrcPort matches TCP packets whose *source* port is one of ports, over both IPv4 and IPv6. With no ports it matches all TCP traffic.

func MatchUDPPort

func MatchUDPPort(ports ...uint16) Match

MatchUDPPort matches UDP packets whose destination port is one of ports, over both IPv4 and IPv6. With no ports it matches all UDP traffic.

Prior to the dual-family change this matched IPv4 only, silently. See MatchUDPSrcPort for the source-port equivalent.

func MatchUDPSrcPort added in v0.9.0

func MatchUDPSrcPort(ports ...uint16) Match

MatchUDPSrcPort matches UDP packets whose *source* port is one of ports, over both IPv4 and IPv6 — the direction for replies rather than requests (DNS answers, NTP responses). With no ports it matches all UDP traffic.

func NewMatch added in v0.9.0

func NewMatch(desc string, build func(MatchEnv) (asm.Instructions, error)) Match

NewMatch returns a custom Match for WithFilter, classifying packets with caller-supplied eBPF. desc is the short human-readable summary reported by Fleet.Info (e.g. "udp/5000"). build must return the instructions for one classification block: jump to env.Redirect on a match, jump to env.Next (or simply fall through) otherwise. A builder that cannot fail returns a nil error; one that compiles something (see the bpfmatch module, which compiles classic BPF) reports the failure and Open surfaces it. See MatchEnv for the register contract — in particular, R8 is reserved and a block that mentions it is rejected.

build is called while Open assembles the filter program, and may be called more than once for a single Open: each attach mode Open tries (native, then generic) reassembles the program. It must therefore be deterministic and free of side effects — do not count invocations, mutate captured state, or use sync.Once inside it.

To reject bad arguments, return MatchError instead of calling NewMatch.

A typical block starts with env.FrameBase, bounds-checks with env.Bounds, then loads and compares header fields:

udp5000 := afxdp.NewMatch("udp/5000", func(e afxdp.MatchEnv) (asm.Instructions, error) {
	ins, frame := e.FrameBase()
	// Ethernet 14 + IPv4 20 (no options) puts the UDP dst port at 36.
	ins = append(ins, e.Bounds(frame, 36+2)...)
	return append(ins,
		asm.LoadMem(asm.R3, frame, afxdp.OffEtherType, asm.Half),
		asm.JNE.Imm(asm.R3, afxdp.NetShort(afxdp.EtherTypeIPv4), e.Next),
		asm.LoadMem(asm.R3, frame, 23, asm.Byte), // IP protocol
		asm.JNE.Imm(asm.R3, 17, e.Next),          // UDP
		asm.LoadMem(asm.R3, frame, 36, asm.Half), // UDP dst port
		asm.JEq.Imm(asm.R3, afxdp.NetShort(5000), e.Redirect),
	), nil
})

Mistakes in the instructions surface from Open as an *ebpf.VerifierError. Printing it with %v gives only its first line; use errors.As and %+v to get the program listing that shows which instruction the verifier rejected. One failure is worth knowing in advance: if no match in the filter ever jumps to Redirect, the redirect path is unreachable code and the verifier rejects the whole program with "unreachable insn".

type MatchEnv added in v0.9.0

type MatchEnv struct {
	// Next is the symbol to jump to when the packet does NOT match this rule
	// (evaluation continues with the next rule, or the packet is passed to the
	// kernel if this was the last one). Reaching the end of the block without
	// jumping is equivalent: NewMatch appends a jump to Next after the
	// builder's instructions, so ordinary fall-through means "no match".
	Next string
	// Redirect is the symbol to jump to when the packet DOES match. The
	// packet is then redirected to the AF_XDP socket for its queue.
	Redirect string
	// Data holds the address of the first byte of the frame, exactly as the
	// XDP program received it (any VLAN tag included). Treat as read-only;
	// use FrameBase for a base register you can work from.
	Data asm.Register
	// DataEnd holds the address one past the last readable byte. Treat as
	// read-only. Every packet load must be bounds-checked against it first —
	// use Bounds; the verifier rejects an unchecked load outright.
	//
	// With WithMultiBuffer (xdp.frags) DataEnd covers only the first fragment.
	// A read past it still loads fine, it just never passes its bounds check
	// on a fragmented packet, so the match silently stops firing. Keep reads
	// within the L2/L3/L4 headers, which always live in the first fragment.
	DataEnd asm.Register
	// contains filtered or unexported fields
}

MatchEnv is passed to a custom match builder (see NewMatch). It carries the jump targets the block must use and the registers holding the packet bounds.

The register contract for a custom block:

R6      data_end (MatchEnv.DataEnd)   read-only
R7      frame start (MatchEnv.Data)   read-only
R8      rx_queue_index                RESERVED — must not be referenced
R9      the FrameBase register        yours once FrameBase has been called
R0-R5   scratch                       freely yours

R8 carries the queue the packet arrived on, which the redirect tail reads to pick the destination socket. A block that overwrote it would load and verify cleanly and then deliver packets to the wrong queue's socket — silently, at line rate. Rather than rely on that being remembered, NewMatch rejects any block that mentions R8 at all, so the mistake surfaces as an error from Open.

R0-R5 are freely yours — there is no need to look further afield for scratch space. The only rule is ordering: the helpers (Bounds, FrameBase) use some of them internally, and exactly which is deliberately not part of this contract, so load what you need *after* your last helper call rather than before it.

func (MatchEnv) Bounds added in v0.9.0

func (e MatchEnv) Bounds(base asm.Register, n int32) asm.Instructions

Bounds emits "if base + n > data_end, jump to Next", bounds-checking a read of the first n bytes starting at base. The eBPF verifier rejects any packet load that is not preceded by such a check.

func (MatchEnv) FrameBase added in v0.9.0

func (e MatchEnv) FrameBase() (asm.Instructions, asm.Register)

FrameBase emits the instructions that set a register to the base of the Ethernet frame with a single 802.1Q VLAN tag transparently skipped, and returns that register. Field offsets relative to it (EtherType at OffEtherType, L3 header at 14, ...) resolve the same whether the frame reaches XDP tagged or already stripped by the NIC, which is how the built-in matchers behave. It bounds-checks its own EtherType read and jumps to Next on a runt frame, so the returned instructions are safe to use as the start of a block.

Note this is a frame base with a tag stepped over, not a pointer to the L3 header: on an untagged frame it is Data, on a tagged frame Data+4. The MAC addresses and the VLAN tag itself are therefore NOT at their usual offsets from it — read those from Data instead.

Call it at most once per block; a second call emits a duplicate symbol and Open fails with a duplicate-symbol error. The returned register is owned by the block from here on.

func (MatchEnv) Label added in v0.9.0

func (e MatchEnv) Label(name string) string

Label returns a symbol name unique to this block, for custom control flow (e.g. a landing pad inside the block). name must be unique within the block.

type Option

type Option func(*config)

Option configures the high-level Open constructor using the functional options pattern. Compose them: afxdp.Open("eth0", afxdp.WithQueues(4), afxdp.WithUDPPorts(4789), afxdp.WithZeroCopy()).

func WithAffinity added in v0.10.0

func WithAffinity(cpus ...int) Option

WithAffinity names the CPUs to run the queue workers on: queue q gets cpus[q % len(cpus)], and that queue's interrupt is steered to a core beside it — never onto it, for the reason in WithoutAffinity — and restored on Close. Passing a CPU that does not exist on this machine makes Open fail rather than silently leave the queue unplaced. Use it when the machine is partitioned and the automatic choice would land on cores that belong to something else. Passing no CPUs is the same as WithoutAffinity.

Choose full physical cores on the NIC's NUMA node. An SMT sibling is not a second core's worth of packets, and a core on the wrong node — or in the wrong L3 complex on chiplet CPUs like EPYC — costs more than not pinning at all.

func WithBusyPoll added in v0.7.1

func WithBusyPoll(usecs, budget int) Option

WithBusyPoll enables XSK preferred busy polling (see SocketOptions.BusyPollUsecs). usecs is the SO_BUSY_POLL duration, budget the per-syscall descriptor budget (the kernel caps it at 512; 256 is a sensible start). Pair with WithNeedWakeup: the need-wakeup flag is what tells the caller its next syscall must drive NAPI.

func WithDriverMode

func WithDriverMode() Option

WithDriverMode forces native (driver) XDP, using zero-copy when the driver supports it and copy otherwise. Native XDP reinitializes the driver's queues, which briefly blips the link on attach and detach.

func WithExcept added in v0.9.0

func WithExcept(matches ...Match) Option

WithExcept passes packets matching any of these to the kernel instead of redirecting them, whatever the filter says. Exceptions are evaluated first and win, so this is how you express "capture X, but never Y":

afxdp.Open("eth0",
	afxdp.WithFilter(afxdp.MatchUDPPort(4789)),
	afxdp.WithExcept(afxdp.MatchSrcIP("192.0.2.10/32")), // ...but not from this host
)

A single cBPF filter can also say "A and not B" (see the bpfmatch module), which is usually clearer for one expression. WithExcept is for excluding across several matches at once, and composes with WithKeepManagement — both feed the same exception list.

func WithFilter

func WithFilter(matches ...Match) Option

WithFilter installs an XDP packet filter built from one or more Matches. A packet is redirected to the AF_XDP sockets if it satisfies ANY match; everything else continues to the normal kernel stack. With no filter, every packet on the bound queues is redirected.

afxdp.Open("eth0", afxdp.WithFilter(
	afxdp.MatchUDPPort(4789, 51820),
	afxdp.MatchICMPv4Echo(),
))

See Match for the available builders and their limitations.

func WithFrameSize

func WithFrameSize(n int) Option

WithFrameSize sets the size of each UMEM buffer in bytes. Default 2048; use 4096 for zero-copy on drivers that require page-sized frames. On AWS ENA Open already defaults to 4096, so you only need this to override that or to handle another such driver.

func WithGenericMode

func WithGenericMode() Option

WithGenericMode forces generic (SKB) XDP with copy semantics. It is slower and never zero-copy, but works on any interface — including veth and other virtual devices that have no native XDP — and does not blip the link.

func WithKeepManagement added in v0.6.0

func WithKeepManagement(extraTCPPorts ...uint16) Option

WithKeepManagement keeps the traffic that keeps you logged in out of the capture, so you can point a broad filter — MatchAll() in particular — at the same NIC you are administering the box through without cutting yourself off:

afxdp.Open("eth0",
	afxdp.WithFilter(afxdp.MatchAll()), // capture everything...
	afxdp.WithKeepManagement(),         // ...except what keeps me logged in
)

These are passed to the kernel instead of being redirected:

  • ARP, and IPv6 neighbour discovery (ICMPv6 types 133-137)
  • TCP to and from port 22 (plus any extraTCPPorts), addressed to this interface
  • DNS replies: UDP and TCP with source port 53, addressed to this interface

ARP and ND matter more than the SSH rule does. Without them the kernel cannot refresh the gateway's link-layer address, and roughly a minute later the box is unreachable however carefully its SSH packets were passed through.

The port rules are scoped to the addresses the interface has when Open is called, so a router still captures transit traffic on port 22 — only traffic addressed to this box is spared. Addresses added afterwards are not covered; reopen the fleet if they change. If the addresses cannot be determined the rules fall back to matching any destination, which captures less but will not strand you.

Pass extraTCPPorts for SSH on a non-standard port, or another admin service you need to survive: WithKeepManagement(2222).

Two caveats worth knowing. Traffic *from* port 22 or 53 to this host is not captured, so a sender that picks those source ports can dodge the capture — irrelevant for measurement, relevant if you are hunting an adversary. And if you administer the box through a different NIC than the one you are capturing on, you do not need this at all.

func WithMultiBuffer added in v0.7.0

func WithMultiBuffer() Option

WithMultiBuffer enables multi-buffer (scatter-gather) mode, which lets a packet span several UMEM frames instead of being limited to one. It is what makes jumbo frames work: at the default 4096-byte frame size a 9001-byte packet arrives as three chained descriptors.

Two things change. The XDP program is loaded with BPF_F_XDP_HAS_FRAGS, which is also what lets it attach at all on drivers that otherwise cap the MTU for XDP (AWS ENA refuses a native attach above 3502 bytes without it). And the socket binds with XDP_USE_SG, without which the kernel silently drops every multi-buffer packet.

Use ReceivePackets rather than Receive to read chained packets: Receive returns one Desc per *frame*, so a jumbo packet looks like several unrelated descriptors. SendBatch splits oversized payloads across frames for you.

The cost is zero-copy. A device reports its multi-buffer zero-copy limit as xdp-zc-max-segs; where that is 1 (AWS ENA today) the kernel refuses an XDP_USE_SG bind in zero-copy mode, so Open settles for native copy mode. Check Info().ZeroCopy if that matters to you — on such a NIC, lowering the MTU and leaving this option off is faster than turning it on.

func WithNAPITuning added in v0.6.0

func WithNAPITuning(deferIRQs int, flush time.Duration) Option

WithNAPITuning overrides the auto-tuning values. deferIRQs sets napi_defer_hard_irqs and flush sets gro_flush_timeout; the defaults are 2 and 20µs.

A longer flush batches harder and costs less CPU, but it is also a ceiling on how fast one queue can be drained — roughly a thousand packets per flush interval, so about 5 Mpps per queue at 200µs. Raise it only if you know your per-queue packet rate stays under that: on a 100G NIC offering 132 Mpps across 8 queues, 200µs delivered 43 Mpps where 20µs delivered 132. Across 48 queues, where each one takes 2.7 Mpps, the same 200µs costs nothing and saves a further 4 cores. See WithoutAutoTune and tune.go for the full table.

func WithNeedWakeup added in v0.4.0

func WithNeedWakeup() Option

WithNeedWakeup binds with XDP_USE_NEED_WAKEUP, letting the driver stop polling when it has no receive buffers (or nothing to transmit) and wait to be woken.

Turn this on. Without it the driver cannot tell us it is starved, so instead of sleeping it reports work==budget on every NAPI poll, napi_complete is never reached, and ksoftirqd re-polls the queue in a tight loop. Measured on an ixgbe 10G NIC with 8 queues: 25 million NAPI polls per second and 65% of a 12-core box consumed in softirq while forwarding ZERO packets. The waking is handled for you — Poll wakes the receive side, Kick the transmit side.

It is not the default only because it changes the kernel contract for callers that drive the rings themselves rather than through Poll/Kick.

func WithNumFrames

func WithNumFrames(n int) Option

WithNumFrames sets the total number of UMEM buffers (rx + tx). Default 8192.

func WithOnForeignComplete added in v0.12.0

func WithOnForeignComplete(f func(addr uint64)) Option

WithOnForeignComplete names the function told, from within Complete, of each transmitted frame that is not from a socket's own pool — a frame in the shared region's extra area. It runs on the goroutine that called Complete (a transmit path), so it must be quick and must not call back into the socket; an atomic decrement is the intended shape.

func WithOptions

func WithOptions(o Options) Option

WithOptions replaces the whole Options struct, for full manual control. Apply it before other With* options, which then override individual fields.

func WithQueues

func WithQueues(n int) Option

WithQueues limits how many rx queues to bind, starting from queue 0. The default (or 0) binds every rx queue on the interface, which is usually what you want so no RSS-distributed traffic is missed.

func WithReceiveHeavy

func WithReceiveHeavy() Option

WithReceiveHeavy is an optional optimization for receive-only sockets (sinks, sniffers, taps that never transmit). The default splits the UMEM evenly between rx and tx pools; a pure receiver never uses the tx half, so this reserves just 64 tx frames and hands the rest to rx. That is not required to reach line rate — the default rings already do — but it gives the fill ring generous slack (the rx pool ends up far larger than the fill ring) so the driver never starves under bursts, and reclaims the otherwise-idle tx memory. Don't use it on a socket that also transmits.

func WithRingSize

func WithRingSize(n int) Option

WithRingSize sets all four ring sizes (fill, completion, rx, tx) at once. Must be a power of two. Default 4096. Use WithOptions for per-ring control.

func WithSharedMemory added in v0.12.0

func WithSharedMemory(extra int) Option

WithSharedMemory gives the fleet one UMEM for all its sockets, with extra bytes (rounded up to whole frames) after the sockets' frames for the caller's use; Extra returns that area. Zero extra is allowed: the sockets then simply share one mapping.

func WithTxFrames

func WithTxFrames(n int) Option

WithTxFrames sets how many of NumFrames are reserved for the transmit pool. Default half. Lower it for receive-heavy workloads, raise it for senders.

func WithTxMetadata added in v0.12.0

func WithTxMetadata() Option

WithTxMetadata reserves transmit metadata space in front of every transmit frame and marks every transmit descriptor as carrying it. Needs Linux 6.8+ (the bind fails otherwise) and, for the NIC to act on a request, a driver that reports the feature (QueryXSKFeatures). Incompatible with WithTxReuseRxFrames: received frames have no gap in front of them.

func WithTxReuseRxFrames added in v0.8.0

func WithTxReuseRxFrames() Option

WithTxReuseRxFrames allows transmitting RECEIVE-pool frames in place: Complete routes each completed frame back to the pool its address belongs to (rx region or tx region) instead of pushing everything to the transmit pool. That makes forward-in-place legal: Receive a frame, rewrite it, Transmit the same descriptor, and on completion the frame returns to the receive pool for the fill ring — no copy, no pool imbalance.

It changes the concurrency contract: without it, the rx pool is touched only by the receive side and the tx pool only by the transmit side (the lock-free 1RX+1TX split). With it, Complete — which runs on the transmit side — may push to the rx pool. Use it only when ONE goroutine drives both sides of the socket (the router/forwarder shape).

func WithUDPPorts

func WithUDPPorts(ports ...uint16) Option

WithUDPPorts is shorthand for WithFilter(MatchUDPPort(ports...)): redirect only UDP packets to these destination ports, IPv4 and IPv6, pass the rest to the kernel. For mixing protocols (e.g. UDP ports plus ICMP) use WithFilter.

func WithZeroCopy

func WithZeroCopy() Option

WithZeroCopy requires native zero-copy mode: Open fails if the driver can't provide it. Use this when you must know you're getting the fast path.

func WithoutAffinity added in v0.10.0

func WithoutAffinity() Option

WithoutAffinity leaves CPU placement entirely to the scheduler and the interrupts exactly where it found them.

By default a Fleet colocates each queue with its worker: it works out which CPU that queue's NIC interrupt is on, and the goroutine driving the queue is locked to a core beside it the first time it calls any of Poll, Receive, ReceivePackets, Transmit, SendFunc or SendBatch. When the interrupts are somewhere unusable — irqbalance has scattered them, or several queues share a core — the interrupt is moved to a free core instead and restored when the Fleet closes. A transmit-only fleet (WithFilter(MatchNone())) is placed on its interrupts rather than beside them, since no receive softirq is competing for the core; that is what lets a generator drive a queue per core instead of per pair.

It is worth doing because a queue whose two halves sit on different cores pays a cache miss on every packet. On a 100G ConnectX-6 Dx one transmit worker moved 15.0 Mpps using 1.01 cores when it shared a core with its interrupt, and 6.2 Mpps using 2.01 cores when the interrupt was on a different L3 complex — 2.4x the packets for half the CPU. See affinity.go for the full table.

Use this option if you place threads yourself, if something else on the box owns the interrupt routing, or if your worker goroutine does more than drive the socket and should not be tied to one core. Locking is also skipped automatically for a goroutine that already drives another queue, and pinning that the kernel refuses (a taskset or cpuset that excludes the CPU) is treated as the operator being more specific than us and left alone.

func WithoutAutoTune added in v0.6.0

func WithoutAutoTune() Option

WithoutAutoTune leaves the interface's NAPI settings exactly as it found them.

By default Open defers NAPI on a native-mode NIC (napi_defer_hard_irqs and gro_flush_timeout under /sys/class/net/<iface>/) so the kernel batches packets instead of waking the receiver thousands of times a second. On a 100G Mellanox sink that took the receiver from 36.5 to 24.2 of 48 cores for the same 118.8 Mpps — the single biggest tuning win we measured, and the sort of thing this library is meant to get right for you rather than leave in a README.

The settings are restored when the Fleet is closed, and Fleet.Info reports what was applied. They are properties of the interface rather than of this process, though, so use this option if you would rather manage them yourself, if something else on the box owns that interface's tuning, or if you need the lowest possible latency at low packet rates (deferring can hold a packet for up to the flush timeout when traffic is sparse). Generic/SKB mode is never tuned, so veth and test setups are untouched either way.

func WithoutMPWQETune added in v0.11.0

func WithoutMPWQETune() Option

WithoutMPWQETune leaves the mlx5 driver's xdp_tx_mpwqe private flag exactly as it is. By default a fleet of four queues or fewer turns it off while it runs — pointer descriptors move ~17.5 Mpps per core against the copied path's 8.5-14.3 — and restores it on Close; five queues or more leave the kernel default alone, because only the copied path scales past the card's ~75 Mpps pointer wall. See the measurement in mpwqe.go. WithoutAutoTune disables this along with the NAPI tuning.

type Options

type Options struct {
	// NumFrames is the total number of buffers in the UMEM (rx + tx).
	// Must be > 0. Default 8192.
	NumFrames int

	// FrameSize is the size in bytes of each UMEM buffer. Default 2048.
	//
	// For AF_XDP zero-copy on some drivers the frame size must equal the page
	// size, i.e. 4096; with a smaller frame the bind silently falls back to
	// copy mode. Open detects this for AWS ENA and defaults FrameSize to 4096
	// there automatically (unless you set it or force generic mode). On any
	// other driver whose zero-copy bind needs page-sized chunks, set 4096 here.
	FrameSize int

	// TxFrames is how many of NumFrames are reserved for the transmit pool.
	// Must be < NumFrames. Default NumFrames/2. Set it lower if your workload
	// is receive-heavy (e.g. a pure sniffer can set TxFrames to a small value),
	// or higher for a transmit-heavy generator.
	TxFrames int

	// Ring sizes. Each must be a power of two. Defaults: 4096 for every ring.
	// FillRingNumDescs and RxRingNumDescs are the receive rings;
	// TxRingNumDescs and CompletionRingNumDescs are the transmit rings.
	// A ring set to zero disables that direction (you cannot disable both rx
	// and tx).
	FillRingNumDescs       int
	CompletionRingNumDescs int
	RxRingNumDescs         int
	TxRingNumDescs         int

	// BindFlags are passed to bind(2) in SockaddrXDP.Flags. Useful values:
	// unix.XDP_ZEROCOPY to demand zero-copy (bind fails if the driver can't),
	// unix.XDP_COPY to force copy mode, 0 to let the kernel choose. Default 0.
	BindFlags uint16

	// TxReuseRxFrames routes completions by address region (rx frames back to the
	// rx pool) so received frames can be transmitted in place. Requires a
	// single goroutine driving both sides of the socket; see WithTxReuseRxFrames.
	TxReuseRxFrames bool

	// BusyPollUsecs/BusyPollBudget enable XSK preferred busy polling
	// (SO_PREFER_BUSY_POLL, kernel 5.11+): the application's own poll and
	// recvfrom syscalls drive NAPI directly with up to BusyPollBudget
	// descriptors per call, decoupling RX descriptor posting from the
	// interrupt rate. Without it a driver posts XSK descriptors only during
	// interrupt-clocked NAPI cycles (budget 64), which caps per-queue
	// delivery at 64 x the moderated interrupt rate no matter how fast
	// userspace drains (measured: exactly 128k pps/queue on mlx5).
	// Effective only alongside the per-device sysctls napi_defer_hard_irqs
	// and gro_flush_timeout. Zero values leave busy polling off.
	BusyPollUsecs  int
	BusyPollBudget int

	// Memory, when set, is the UMEM to register instead of mapping one: a
	// region the caller (normally the Fleet, see WithSharedMemory) owns and
	// unmaps. This socket's NumFrames frames start FrameBase frames into it;
	// the rest of the region belongs to others, and a transmitted address
	// from outside the socket's own frames is handed to OnForeignComplete
	// when it completes rather than to a pool.
	Memory            []byte
	FrameBase         int
	OnForeignComplete func(addr uint64)

	// TxMetadataLen reserves this many bytes of transmit metadata in front of
	// every transmit frame (0 = none). Set with WithTxMetadata; see txmeta.go.
	TxMetadataLen int

	// XDPFlags are passed when the BPF program is attached to the link.
	// Useful values: unix.XDP_FLAGS_DRV_MODE (native driver XDP),
	// unix.XDP_FLAGS_SKB_MODE (generic, works everywhere but slow),
	// unix.XDP_FLAGS_HW_MODE. Default 0 (kernel picks native, falls back to
	// generic). Used by Program.Attach and OpenFleet.
	XDPFlags uint32
}

Options configures a Socket's UMEM and rings.

The zero value is not valid; use DefaultOptions() and adjust, or rely on NewSocket / OpenFleet filling in defaults for any field left at zero.

Frame budget. The UMEM holds NumFrames buffers of FrameSize bytes each. Those frames are split into two disjoint pools: TxFrames buffers are reserved for transmit, the remaining NumFrames-TxFrames for receive. The split is what lets one receive goroutine and one transmit goroutine run against the same Socket without locking or corrupting each other — they never touch the same frames (see the package doc).

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns Options with sane defaults for a balanced rx/tx workload: 8192 frames of 2048 bytes, split evenly, with 4096-entry rings.

The ring depth matters for line-rate receive: a shallow rx/fill ring overflows between drains (visible as XDP_STATISTICS rx_ring_full) and caps throughput well below what the NIC can deliver. 4096-entry rings backed by a large enough frame pool keep the driver fed at 10G+ small-packet rates; the ring memory this costs is negligible next to the UMEM. A pure receiver can hand almost all frames to the rx pool with WithReceiveHeavy.

type Packet added in v0.7.0

type Packet []Desc

Packet is one received packet: a single Desc when it fits one UMEM frame, or several chained descriptors when the packet is larger than the frame size and the socket was bound with multi-buffer enabled (see WithMultiBuffer).

The fragments are in wire order, so concatenating their frames reconstructs the packet. Only the first fragment holds the headers.

func (Packet) Len added in v0.7.0

func (p Packet) Len() int

Len returns the packet's total length in bytes, summed over its fragments.

type Program

type Program struct {
	Program *ebpf.Program
	Queues  *ebpf.Map // qidconf_map: queue id -> enabled
	Sockets *ebpf.Map // xsks_map: queue id -> socket fd
	// contains filtered or unexported fields
}

Program is the small XDP BPF program that redirects packets on a given rx queue to the AF_XDP socket registered for that queue. It is the standard xsk redirect program (a translation of xsk_load_xdp_prog from libbpf).

func NewMultiBufferProgram added in v0.7.0

func NewMultiBufferProgram(maxQueues int) (*Program, error)

NewMultiBufferProgram is NewProgram with BPF_F_XDP_HAS_FRAGS set, so the program can be attached on an interface whose MTU exceeds one frame and can be handed multi-buffer packets. Pair it with sockets bound with XDP_USE_SG; see WithMultiBuffer for the high-level path.

func NewProgram

func NewProgram(maxQueues int) (*Program, error)

NewProgram builds the redirect program with room for maxQueues queue entries. Register one socket per queue with Register before traffic will be delivered.

func (*Program) Attach

func (p *Program) Attach(ifindex int, xdpFlags uint32) error

Attach attaches the program to the interface using the given XDP flags (0, unix.XDP_FLAGS_DRV_MODE, unix.XDP_FLAGS_SKB_MODE, ...). Any program left over from a previous run is removed first.

It prefers a BPF link, which the kernel auto-detaches when this process exits — so the redirect program is cleaned up even if the process is killed or crashes. On kernels too old for XDP links (< 5.9) it falls back to the legacy netlink attach, which survives a crash and must then be removed manually (Detach, or "ip link set dev <iface> xdp off").

func (*Program) Close

func (p *Program) Close() error

Close releases the program and its maps. If the program is still attached via a BPF link, the link is closed too (detaching it); a netlink-attached program must be removed with Detach.

func (*Program) Detach

func (p *Program) Detach(ifindex int) error

Detach removes the program from the interface. For a link-attached program it closes the link; otherwise it removes the netlink-attached program.

func (*Program) Register

func (p *Program) Register(queueID, fd int) error

Register routes packets arriving on queueID to the socket with the given fd. For a pass-only program (no redirect maps, e.g. from MatchNone) it is a no-op: there is nothing to route.

func (*Program) Unregister

func (p *Program) Unregister(queueID int) error

Unregister stops routing packets for queueID to any socket. It is a no-op for a pass-only program (no redirect maps).

type Socket

type Socket struct {
	// contains filtered or unexported fields
}

Socket is an AF_XDP socket — commonly called an "XSK" (XDP socket) — bound to one (interface, queue) pair. Its methods use the receiver name xsk, the conventional shorthand for an XDP socket throughout the kernel and libbpf code, and you'll see local variables named xsk in the examples for the same reason.

A Socket owns a UMEM (the memory region shared with the kernel that holds packet frames) and the four rings that move frames to and from the kernel.

Concurrency model. A Socket is safe for exactly one receive goroutine running concurrently with one transmit goroutine, with no locking — this is the guarantee the upstream asavie/xdp could not make. The receive side (Fill, Poll, Receive, Recycle) and the transmit side (Alloc, Transmit, Complete, Kick) own disjoint frame pools and disjoint rings, so they never touch shared mutable state.

  • If multiple goroutines transmit on one Socket, guard the transmit-side calls with your own mutex (the receive side still needs none), or give each producer its own Socket/queue via a Fleet.
  • The receive side is single-consumer; likewise guard it if you fan out receive across goroutines.
  • Close is the exception: it may be called from any goroutine at any time. It wakes a blocked Poll (which returns net.ErrClosed) and waits for in-flight calls to finish before releasing the shared memory.

func NewSocket

func NewSocket(ifindex, queueID int, options *Options) (*Socket, error)

NewSocket creates an AF_XDP socket on the given interface index and queue ID. Pass nil options for defaults (see DefaultOptions). After creating the socket you must register its FD with an attached Program (or use OpenFleet, which does both for every queue).

func (*Socket) Alloc

func (xsk *Socket) Alloc(n int) []Desc

Alloc reserves up to n transmit frames and returns descriptors for them. Build your packet into GetFrame(desc), set desc.Len to the packet length, then pass the descriptors to Transmit. The returned slice is owned by the Socket and reused by the next Alloc; do not retain it.

Alloc returns fewer than n descriptors (possibly zero) when the transmit pool is drained (call Complete to reclaim sent frames first) OR when the tx ring is full. Capping to the free ring space means Transmit can always queue every descriptor Alloc returns, so none are dropped and leaked back out of the pool.

func (*Socket) Close

func (xsk *Socket) Close() error

Close releases the socket, its UMEM, and ring mappings. It is safe to call while another goroutine is blocked in Poll: that Poll is woken and returns net.ErrClosed, and Close waits for in-flight operations to finish before tearing down the memory they use. After Close, all methods are inert (Poll/Kick/Stats return net.ErrClosed; the rest return zero values) — but frame slices previously handed out by GetFrame must no longer be touched.

func (*Socket) Complete

func (xsk *Socket) Complete(n int) int

Complete reclaims up to n transmitted frames from the completion ring and returns them to the transmit pool, making them available to Alloc again. It returns how many frames were reclaimed.

In hairpin mode (WithTxReuseRxFrames) each frame instead returns to the pool its address belongs to, so a receive-pool frame that was transmitted in place becomes fill-ring-eligible again rather than leaking into the tx pool.

func (*Socket) CopyOut added in v0.7.0

func (xsk *Socket) CopyOut(p Packet, dst []byte) int

CopyOut flattens a packet's fragments into dst and returns the number of bytes written, which is min(p.Len(), len(dst)). Use it when downstream code needs one contiguous buffer; walk the fragments yourself to avoid the copy.

func (*Socket) FD

func (xsk *Socket) FD() int

FD returns the socket file descriptor, e.g. for registering with a Program or for your own polling.

func (*Socket) Fill

func (xsk *Socket) Fill(n int) int

Fill moves up to n free receive frames onto the fill ring, where the kernel will write incoming packets into them. It returns the number of frames submitted, which may be less than n if the receive pool or the fill ring is short on space. Call it before Poll so the kernel always has buffers.

func (*Socket) FrameSize added in v0.3.0

func (xsk *Socket) FrameSize() int

FrameSize returns the UMEM frame size the socket was configured with (after defaulting and driver-specific adjustment, e.g. 4096 on ENA) — the stride for interpreting UMEM() frame-by-frame.

func (*Socket) FreeRxFrames

func (xsk *Socket) FreeRxFrames() int

FreeRxFrames returns how many receive frames are idle in the receive pool (neither on the fill ring nor held by the application).

func (*Socket) FreeTxFrames

func (xsk *Socket) FreeTxFrames() int

FreeTxFrames returns how many transmit frames are idle in the transmit pool.

func (*Socket) GetFrame

func (xsk *Socket) GetFrame(d Desc) []byte

GetFrame returns the UMEM buffer for a descriptor. Writing to the returned slice writes the frame that will be transmitted (or reads what was received). The slice aliases the UMEM; do not retain it past the point you hand the descriptor back to the kernel.

func (*Socket) Kick

func (xsk *Socket) Kick() error

Kick asks the kernel to process the tx ring. Transmit calls it for you after queueing frames (skipping the syscall when a need-wakeup bind shows the driver already awake), so you normally don't call it directly — with one important exception: if the tx ring fills up (NumFreeTxSlots returns 0) so you can't Transmit more, call Kick anyway to keep the kernel draining the ring and producing completions. In copy mode the kernel will not drain the ring without a kick, so a tight "if full, continue" loop that skips the kick deadlocks. (In zero-copy the driver drains on its own, but kicking is harmless.)

func (*Socket) KickIfNeeded added in v0.12.0

func (xsk *Socket) KickIfNeeded() error

KickIfNeeded issues the transmit kick that SendFuncNoKick left out, if the driver is asking for one (or always, without WithNeedWakeup, where it cannot say). It is cheap when nothing is pending: one atomic load.

func (*Socket) MaxPacket added in v0.11.2

func (xsk *Socket) MaxPacket() int

MaxPacket is the largest packet a single frame receives: the frame size less the kernel's XDP_PACKET_HEADROOM and the headroom the UMEM was registered with. It is the receive limit; a packet larger than this arrives only in several frames (see MultiBuffer), or not at all. Transmit may use the whole frame.

func (*Socket) MultiBuffer added in v0.11.2

func (xsk *Socket) MultiBuffer() bool

MultiBuffer reports whether this socket was bound with XDP_USE_SG, so a received packet may span several frames, XDP_PKT_CONTD set on every fragment but the last. Without it the kernel drops every multi-buffer packet before it reaches the rx ring, so chains can only ever appear when this is true.

func (*Socket) NeedsWakeupRx added in v0.4.0

func (xsk *Socket) NeedsWakeupRx() bool

NeedsWakeupRx reports whether the driver has stopped polling the receive side and is waiting to be woken.

This is the whole point of binding with XDP_USE_NEED_WAKEUP (WithNeedWakeup). WITHOUT that flag, a driver that cannot get buffers from the fill ring has no way to say so: ixgbe_clean_rx_irq_zc returns `budget` instead of the real packet count, so napi_complete is never called, NAPI reschedules itself immediately, and ksoftirqd spins in net_rx_action forever — on this hardware that burned 65% of a 12-core box at ZERO packets per second, with every poll reporting work==budget. WITH the flag the driver returns the true count, NAPI completes and sleeps, and it is our job to wake it after refilling. Poll does that automatically; call this directly only if you drive the rings yourself.

func (*Socket) NeedsWakeupTx added in v0.4.0

func (xsk *Socket) NeedsWakeupTx() bool

NeedsWakeupTx reports whether the driver has stopped polling the transmit side and needs a Kick to resume. Only meaningful with WithNeedWakeup.

func (*Socket) NumCompleted

func (xsk *Socket) NumCompleted() int

NumCompleted returns how many transmitted frames are waiting on the completion ring to be reclaimed by Complete.

func (*Socket) NumFilled

func (xsk *Socket) NumFilled() int

NumFilled returns how many frames are currently posted on the fill ring awaiting incoming packets.

func (*Socket) NumFreeFillSlots

func (xsk *Socket) NumFreeFillSlots() int

NumFreeFillSlots returns how many descriptors can still be put on the fill ring before it is full.

func (*Socket) NumFreeTxSlots

func (xsk *Socket) NumFreeTxSlots() int

NumFreeTxSlots returns how many descriptors can still be put on the tx ring.

func (*Socket) NumReceived

func (xsk *Socket) NumReceived() int

NumReceived returns how many received descriptors are waiting on the rx ring.

func (*Socket) NumTransmitted

func (xsk *Socket) NumTransmitted() int

NumTransmitted returns how many frames are on the tx ring not yet confirmed sent (i.e. not yet on the completion ring).

func (*Socket) Pin added in v0.10.0

func (xsk *Socket) Pin() (int, error)

Pin locks the calling goroutine to its OS thread and moves that thread onto the CPU handling this queue's NIC interrupt, so the kernel half of the queue (hard IRQ, NAPI poll, XDP program, redirect) and the userspace half (this goroutine) share a core and a cache instead of passing every packet between two. It returns the CPU, or -1 if it did not pin.

You do not normally need to call this: a socket from Fleet.Open pins its worker automatically the first time that goroutine calls Poll, Receive, ReceivePackets, Transmit, SendFunc or SendBatch, which is the point at which the goroutine has identified itself as the worker for this queue. Call it explicitly if you want the pinning to happen at a known moment, or want to see the error when it cannot.

It does nothing, without error, when affinity is off (WithoutAffinity, a generic-mode Fleet, or a socket built by NewSocket rather than Open), when no CPU could be worked out for this queue, or when the calling goroutine is already pinned for another queue. It returns an error only when the kernel refuses the placement, which normally means a taskset or cpuset excludes the CPU — the operator being more specific than us, and left alone.

The goroutine stays locked to its thread afterwards. Do not call it from a goroutine that does anything else of substance.

func (*Socket) PinError added in v0.10.0

func (xsk *Socket) PinError() error

PinError reports why this socket's worker is not pinned, or nil if it is pinned or was never asked to be. It is how a queue that the kernel refused (a taskset or cpuset excluding the chosen CPU) is told apart from one that simply has no plan.

func (*Socket) PinnedCPU added in v0.10.0

func (xsk *Socket) PinnedCPU() int

PinnedCPU reports the CPU this socket's worker was pinned to, or -1 if it is not pinned. See Pin.

func (*Socket) Poll

func (xsk *Socket) Poll(timeout time.Duration) (numReceived int, err error)

Poll blocks until the kernel has received frames, the timeout elapses, or the Socket is closed. A negative timeout waits forever; zero returns immediately. It returns the number of received frames now available to Receive. Poll only watches the receive direction; the transmit side drives completions via Complete/Kick.

Poll doubles as the RX wakeup: when the driver has parked itself (see NeedsWakeupRx) the poll(2) call is what restarts its NAPI poll, so a receive loop that calls Poll whenever it finds no packets needs no other change to work correctly with WithNeedWakeup.

Close from another goroutine wakes a blocked Poll, which then returns net.ErrClosed — so a receive loop that stops on any Poll error shuts down cleanly.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) PollWith added in v0.3.1

func (xsk *Socket) PollWith(extra []int32, timeout time.Duration) (bool, error)

PollWith parks like Poll but also wakes when any of the caller's extra descriptors becomes readable (a wake eventfd another goroutine signals, say), all in one syscall. It keeps the two safety properties of Poll that a hand-rolled poll over FD() silently loses: the socket fd is refcounted so a concurrent Close cannot recycle it mid-poll, and Close's wake descriptor is in the set so a closing socket never leaves the caller parked for the full timeout. Like Poll, it returns immediately when the fill ring is empty (the kernel cannot deliver without fill descriptors; refill and come back), except when the driver is parked awaiting an RX wakeup. Reports whether it was woken by readiness rather than the timeout.

func (*Socket) QueueID added in v0.12.0

func (xsk *Socket) QueueID() int

QueueID is the NIC queue this socket is bound to.

func (*Socket) Receive

func (xsk *Socket) Receive(max int) []Desc

Receive consumes up to max received descriptors from the rx ring. The returned slice is owned by the Socket and reused on the next Receive call; copy out anything you need to keep. After you are done reading the frames, return them with Recycle so they can be filled again.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) ReceivePackets added in v0.7.0

func (xsk *Socket) ReceivePackets(maxFrames int) []Packet

ReceivePackets consumes up to maxFrames descriptors from the rx ring and groups them into whole packets. It is the multi-buffer-aware counterpart of Receive: use it whenever WithMultiBuffer is in play, because Receive returns one Desc per *frame* and a jumbo packet would look like several unrelated descriptors.

The budget is in frames, not packets, so with chained packets the returned slice is shorter than maxFrames. When multi-buffer is off every packet has exactly one fragment and this is equivalent to Receive.

A chain can straddle two calls when the batch boundary falls mid-packet. Such a partial chain is held inside the Socket and completed on a later call — a packet is never returned before its last fragment has arrived, so the caller never sees a truncated packet.

The returned slice and the Descs it points at are owned by the Socket and reused on the next call; copy out anything you need to keep. Return the frames with RecyclePackets when done.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) Recycle

func (xsk *Socket) Recycle(descs []Desc)

Recycle returns received frames to the receive pool so a later Fill can hand them back to the kernel. Pass the descriptors you got from Receive once you have finished reading their frames.

func (*Socket) RecyclePackets added in v0.7.0

func (xsk *Socket) RecyclePackets(pkts []Packet)

RecyclePackets returns every fragment of every packet to the receive pool so a later Fill can hand them back to the kernel. It is Recycle for the packets ReceivePackets produced.

func (*Socket) SendBatch

func (xsk *Socket) SendBatch(payloads [][]byte) (int, error)

SendBatch transmits up to len(payloads) packets, copying each into a transmit frame. It is the easy, high-level transmit call: it does all the ring bookkeeping for you — reclaiming completed frames, kicking the kernel, and never deadlocking when the ring is full — so you can just call it in a loop without touching Alloc/Transmit/Complete/Kick.

It returns the number of packets actually queued this call, which may be fewer than len(payloads) (possibly zero) when the ring is momentarily full; queue the rest on a later call. Like the rest of the transmit side it is for a single transmit goroutine (or guard it with your own mutex).

A payload longer than FrameSize cannot fit in a UMEM frame; SendBatch rejects the whole batch up front with an error rather than truncating it on the wire, and queues nothing.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) SendFunc

func (xsk *Socket) SendFunc(count int, build func(i int, frame []byte) int) (int, error)

SendFunc is SendBatch without the intermediate copy: it transmits up to count packets, calling build to fill each frame in place (build writes into frame and returns the packet length). Use it when you want to construct packets directly in the UMEM or vary a field per packet (e.g. a packet generator). It handles the same ring bookkeeping as SendBatch and returns the number queued.

build must return a length in [0, len(frame)]. Anything else is reported as an error and the whole batch is abandoned unqueued — the length would have described bytes that were never written to the frame.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) SendFuncNoKick added in v0.12.0

func (xsk *Socket) SendFuncNoKick(count int, build func(i int, frame []byte) int) (int, error)

SendFuncNoKick is SendFunc without the kick: frames are queued on the tx ring but the driver is not told, except when the ring is full (where a kick is what makes room). Call KickIfNeeded afterwards — once, after several calls — to send everything queued. A caller that transmits many small packets in a burst pays one syscall for the burst instead of one each.

The frames sit unsent until that kick, so it must come promptly: a receive loop that transmits during its batch and kicks at the end is the intended shape. Same goroutine rules as SendFunc.

func (*Socket) Stats

func (xsk *Socket) Stats() (Stats, error)

Stats returns ring counters plus the kernel's XDP_STATISTICS for this socket (which reports e.g. invalid descriptors and rx ring full drops).

Stats may be called from a separate monitoring goroutine while the rx/tx loops run — a sample can be momentarily stale, but it never disturbs the data path (the data path takes no locks; only concurrent Stats callers serialize against each other). The kernel's ring indices are 32-bit, so the 64-bit counters here are maintained across wrap-arounds by sampling; call Stats at least once per 2^32 packets per socket (at 10G line rate on one queue that's every ~5 minutes — any periodic stats loop is plenty).

func (*Socket) Transmit

func (xsk *Socket) Transmit(descs []Desc) int

Transmit puts the given descriptors on the tx ring and kicks the kernel to send them. It returns how many were actually queued (capped by free tx ring space). Frames that are queued are owned by the kernel until they appear on the completion ring; reclaim them with Complete.

The first call from a given goroutine also locks that goroutine to its OS thread and moves it onto this queue's CPU; see Pin.

func (*Socket) TransmitNoKick added in v0.12.0

func (xsk *Socket) TransmitNoKick(descs []Desc) int

TransmitNoKick is Transmit without the kick: the descriptors go on the ring and the caller kicks later with KickIfNeeded, once for a whole batch. Same ownership rule as Transmit.

func (*Socket) TxMeta added in v0.12.0

func (xsk *Socket) TxMeta(frame []byte) []byte

TxMeta returns the metadata slice for a transmit frame obtained from GetFrame or a SendFunc build callback: the TxMetadataLen bytes just before it in the UMEM. It returns nil when the socket has no transmit metadata.

func (*Socket) TxMetadataLen added in v0.12.0

func (xsk *Socket) TxMetadataLen() int

TxMetadataLen is the size of the metadata in front of each transmit frame, zero when WithTxMetadata was not used.

func (*Socket) UMEM added in v0.3.0

func (xsk *Socket) UMEM() []byte

UMEM returns the socket's entire UMEM as one slice (NumFrames*FrameSize bytes; frame i occupies [i*FrameSize, (i+1)*FrameSize)). It lets an application build its own frame-granular structures — e.g. a forwarding dataplane whose packet-buffer pool IS the UMEM, avoiding a copy on receive. The same aliasing rules as GetFrame apply, per frame: a frame's bytes are yours only between receiving it (Receive) and handing it back (Recycle/Fill), or between Alloc and Transmit on the transmit side.

func (*Socket) Unpin added in v0.10.0

func (xsk *Socket) Unpin() error

Unpin releases the calling goroutine from the CPU and the OS thread Pin gave it, restoring the affinity mask it had before. Call it from the same goroutine that was pinned, and only when that goroutine is going to outlive its use of the socket — a worker that is about to return does not need it, because a goroutine that exits while locked takes its thread with it.

It exists so that a long-lived goroutine borrowed for a while as a queue worker can be given back unchanged.

func (*Socket) WakeupRx added in v0.7.1

func (xsk *Socket) WakeupRx() error

WakeupRx is Kick's receive-side twin: it wakes a driver that has parked with the fill-ring NEED_WAKEUP flag set, so it resumes posting the descriptors a preceding Fill made available. Poll performs this wakeup as a side effect, which hides the contract from callers that block; a caller that drives the rings directly (Fill without ever blocking in Poll) MUST call this after refilling whenever NeedsWakeupRx reports true, or the newly filled descriptors sit unposted until its next idle Poll -- the NIC then drops arriving packets for want of RX descriptors (rx_out_of_buffer) while the fill ring is provably full. Measured on a 48-core mlx5 router: the workers' idle-poll rate became the descriptor-posting clock, capping delivery at ~5.5M pps with the box two-thirds idle.

The syscall is a zero-length non-blocking recvfrom, the canonical XSK RX wakeup. Call only when NeedsWakeupRx is true; the flag check is one atomic load, so the syscall is paid only when the driver is actually parked.

func (*Socket) ZeroCopy

func (xsk *Socket) ZeroCopy() (bool, error)

ZeroCopy reports whether the kernel granted zero-copy mode on this socket. It reads XDP_OPTIONS, the authoritative source — bind flags only request a mode, they do not confirm it.

type Stats

type Stats struct {
	Filled      uint64 // fill descriptors consumed by the kernel
	Received    uint64 // frames received (consumed from the rx ring)
	Transmitted uint64 // frames sent (consumed by the kernel from the tx ring)
	Completed   uint64 // completions reaped via Complete; trails Transmitted if
	// you reap lazily, so prefer Transmitted for a "packets sent" count.
	Kicks           uint64 // tx kick syscalls issued (sendto), including drain retries
	KicksSuppressed uint64 // Transmit kicks skipped because need-wakeup showed the driver awake
	// Polls counts rx poll(2) syscalls — one per Poll call that actually
	// blocks. Divided by Received it gives packets per syscall, i.e. how well
	// your receive loop is batching: a healthy loaded queue reads in the
	// dozens or hundreds, while a value near 1 means you are paying a syscall
	// per packet and should drain more per wakeup.
	Polls uint64
	// RxKicks counts WakeupRx syscalls: explicit fill-ring wakeups issued by a
	// caller driving the rings directly instead of blocking in Poll.
	RxKicks uint64
	// TX submission accounting (SendFunc). SendReq = packets the caller asked
	// to send; SendGot = descriptors actually reserved (< SendReq when the ring
	// or tx-frame pool was short); Submitted = descriptors Transmit queued;
	// RingFull = full-ring early returns; InflightHW = peak un-completed depth.
	SendReq, SendGot, Submitted, RingFull, InflightHW uint64
	KernelStats                                       unix.XDPStatistics
}

Stats holds cumulative counters for a Socket. KernelStats carries the kernel's drop/error counters. It has a String method for easy logging.

func (Stats) PacketsPerPoll added in v0.5.2

func (s Stats) PacketsPerPoll() float64

PacketsPerPoll returns how many frames were received per blocking poll(2) on average — the receive loop's batching efficiency. Higher is better: each syscall is amortized over that many packets. A value near 1 means a syscall per packet, usually because the loop drains less than what is waiting. It returns 0 before the first poll.

func (Stats) String

func (s Stats) String() string

String renders Stats as a single human-readable line.

type TxMetadata added in v0.12.0

type TxMetadata struct {
	Flags      uint64 // TxMetaChecksum and/or TxMetaTimestamp
	CsumStart  uint16 // offset from the start of the packet of the header to sum from
	CsumOffset uint16 // offset from CsumStart of the 16-bit checksum field
}

TxMetadata is what TxMeta holds, in the kernel's layout.

func (TxMetadata) Put added in v0.12.0

func (m TxMetadata) Put(b []byte)

Put writes m into a metadata slice from TxMeta. For a checksum request the checksum field of the packet must already hold the un-complemented pseudo-header sum (Linux's CHECKSUM_PARTIAL); the NIC adds the rest.

type XSKFeatures added in v0.12.0

type XSKFeatures struct {
	XDPFeatures uint64 // NETDEV_XDP_ACT_* bits: BASIC 1, REDIRECT 2, NDO_XMIT 4, XSK_ZEROCOPY 8, HW_OFFLOAD 16, RX_SG 32, NDO_XMIT_SG 64
	ZCMaxSegs   uint32 // largest multi-buffer packet, in frames, a zero-copy socket may receive (1 = none)
	RXMetadata  uint64 // NETDEV_XDP_RX_METADATA_* bits: TIMESTAMP 1, HASH 2, VLAN_TAG 4
	TxTimestamp bool   // the driver honours TxMetaTimestamp on transmit
	TxChecksum  bool   // the driver honours TxMetaChecksum on transmit
}

XSKFeatures is what a device reports about its XDP and AF_XDP support, from the kernel's netdev generic-netlink family (Linux 6.3+; the transmit metadata bits need 6.8+).

func QueryXSKFeatures added in v0.12.0

func QueryXSKFeatures(iface string) (XSKFeatures, error)

QueryXSKFeatures asks the kernel what iface can do. It is the only way to learn, before sending, whether a transmit checksum request will be honoured: a driver that ignores it transmits the frame silently as is.

func (XSKFeatures) ZeroCopy added in v0.12.0

func (f XSKFeatures) ZeroCopy() bool

ZeroCopy reports whether the device offers zero-copy AF_XDP at all.

Directories

Path Synopsis
bpfmatch module
examples
blast command
blast is a UDP packet generator built on AF_XDP: it builds a UDP frame directly in the UMEM and transmits it as fast as the NIC will go, across every tx queue.
blast is a UDP packet generator built on AF_XDP: it builds a UDP frame directly in the UMEM and transmits it as fast as the NIC will go, across every tx queue.
customfilter/dstmac command
dstmac captures frames addressed to one destination MAC — useful on a mirror or SPAN port, where you are seeing another host's traffic and want only the part addressed to a particular NIC.
dstmac captures frames addressed to one destination MAC — useful on a mirror or SPAN port, where you are seeing another host's traffic and want only the part addressed to a particular NIC.
customfilter/gre command
gre captures one GRE tunnel by its key — the case that motivated the low-level API in the first place (issue #9): a field no built-in matcher reaches, matched in the kernel rather than in your receive loop.
gre captures one GRE tunnel by its key — the case that motivated the low-level API in the first place (issue #9): a field no built-in matcher reaches, matched in the kernel rather than in your receive loop.
customfilter/tcpsyn command
tcpsyn captures TCP connection openers — SYN set, ACK clear — which is what you want for spotting scans or counting inbound connection attempts without taking the established traffic with them.
tcpsyn captures TCP connection openers — SYN set, ACK clear — which is what you want for spotting scans or counting inbound connection attempts without taking the established traffic with them.
customfilter/udpsrcport command
udpsrcport is the simplest NewMatch example, and the one to read first.
udpsrcport is the simplest NewMatch example, and the one to read first.
customfilter/vlan command
vlan captures traffic on one 802.1Q VLAN and prints a line per packet.
vlan captures traffic on one 802.1Q VLAN and prints a line per packet.
customfilter/vxlan command
vxlan captures one VXLAN tunnel by VNI and prints the inner frame.
vxlan captures one VXLAN tunnel by VNI and prints the inner frame.
drop command
drop is a UDP packet sink: it receives every packet sent to a UDP port and throws it away.
drop is a UDP packet sink: it receives every packet sent to a UDP port and throws it away.
fwd command
fwd is a multi-queue IPv4 forwarder: it takes the packets steered to it, decrements the TTL, rewrites the Ethernet header for a fixed next hop, and transmits each packet back out the same interface — in the frame it arrived in.
fwd is a multi-queue IPv4 forwarder: it takes the packets steered to it, decrements the TTL, rewrites the Ethernet header for a fixed next hop, and transmits each packet back out the same interface — in the frame it arrived in.
helloworld command
helloworld is the smallest afxdp program: it captures ICMP echo (ping) packets on an interface and prints a one-line summary of each, plus periodic stats.
helloworld is the smallest afxdp program: it captures ICMP echo (ping) packets on an interface and prints a one-line summary of each, plus periodic stats.
l2fwd command
l2fwd is a minimal L2 reflector: it receives frames on one queue, swaps the source and destination MAC addresses, and transmits them back out the same interface.
l2fwd is a minimal L2 reflector: it receives frames on one queue, swaps the source and destination MAC addresses, and transmits them back out the same interface.
multiqueue command
multiqueue captures traffic across every rx queue on an interface at once.
multiqueue captures traffic across every rx queue on an interface at once.
natlb command
natlb is a small NAT-mode TCP load balancer on AF_XDP.
natlb is a small NAT-mode TCP load balancer on AF_XDP.
udpreflector command
udpreflector bounces UDP packets back to their sender.
udpreflector bounces UDP packets back to their sender.
pcapfilter module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL