afxdp

package
v0.1.11 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

afxdp - AF_XDP, the express lane

Fast, and it leaves your NIC where it is. This backend wraps go-afxdp behind packetio's API.

A small eBPF program runs inside the NIC driver, before the kernel allocates an sk_buff and before the network stack. For each packet it decides: up the normal stack, or into a socket that shares a chunk of memory (the UMEM) with your program. On drivers that support it the NIC writes packets directly into your memory, and you read them where they landed.

Everything the filter does not match carries on into the kernel. ip, ethtool and tcpdump all keep working.

What you need

  • A driver with XDP support (Intel, Mellanox, Broadcom and others; zero-copy varies).
  • Root, or CAP_NET_RAW + CAP_NET_ADMIN + CAP_BPF: attaching the XDP program needs more than opening the socket does. Plus a memory lock limit for the UMEM (ulimit -l unlimited, or root).
  • No build tag, no hugepages, no unbinding.

Open it

AF_XDP is the one backend that makes you say what you want:

go-afxdp's package is also called afxdp, so the snippets below alias it:

import (
        "github.com/atoonk/packetio/afxdp"
        xdp "github.com/atoonk/go-afxdp"   // the options passed through WithXDP
)
d, err := afxdp.Open("eth0", afxdp.WithSteering(packetio.SteeringFilter{
        Match: packetio.MatchDstPort(packetio.IPProtoUDP, 9000),
}))
if err != nil {
        log.Fatal(err)
}
defer d.Close()

A bare afxdp.Open("eth0") is refused, on purpose. Without a filter the XDP program would redirect every packet on the interface to your sockets and keep it from the kernel - cutting off SSH and everything else. There is no safe default to fall back to, so it asks. To take everything deliberately:

afxdp.WithSteering(packetio.SteeringFilter{Promiscuous: true})

Options

The ones every backend spells the same way:

WithQueues(n)          how many queues to bind, from queue 0
WithFrames(n)          frames in the UMEM
WithFrameSize(n)       2048 by default; some drivers need 4096 for zero-copy.
                       Capabilities().MaxFrameSize is the largest packet a
                       frame takes: the frame less the 256 bytes the kernel
                       keeps in front of every packet it writes
WithMultiBuffer()      a packet may span several frames, OptContinued on all
                       but the last; Capabilities().MultiBuffer reports it
WithSteering(f)        required - see above
WithAffinity(cpus...)
WithoutAffinity()

WithXDP(opts ...xdp.Option) passes go-afxdp's own options through, for what this package does not name - busy-poll, wakeup flags, XDP mode, NAPI tuning:

afxdp.Open("eth0", steer, afxdp.WithXDP(xdp.WithBusyPoll(50, 64)))

None of the named options changes a default. go-afxdp picks the frame geometry, binds every queue, tunes the driver's interrupt behaviour and places each worker beside its queue's interrupt on its own. Those options exist for overriding a decision, not as setup you have to perform.

The one option this backend sets for you is need-wakeup, bound before anything you pass so a WithXDP can still override it. It lets the driver park when idle and say so, rather than spinning its NAPI loop for nothing: go-afxdp measured 25 million polls a second and 65% of a twelve-core box in soft interrupt while forwarding zero packets - and it spares transmit a sendto per batch. go-afxdp does not default it because a caller driving the rings directly must then wake the driver itself; this backend is that caller, and Fill does it (below).

Gotchas

These are the ones that cost real afternoons.

Bind the queues you will actually drive. One socket covers one hardware queue, and a queue with no socket goes to the kernel instead - silently. A single flow lands on one queue, so opening 4 of a card's 48 means a 1-in-12 chance of seeing anything, and the failure looks like an idle link. Either bind them all, or narrow the card's hash to match:

sudo ethtool -L eth0 combined 4      # then open 4 queues

Binding every queue turns placement off. Automatic placement seats each worker on a free core in the same complex as its queue's interrupt. Bind all 48 queues on a 48-core box and every core is an interrupt core, so there is nowhere free and it declines rather than seating a worker on top of a softirq - which measured worse. Measured on a ConnectX-6 Dx: WithQueues(4) places 4 of 4 workers (CPUs 0, 5, 7, 10); binding all 48 places none.

On a tagged interface, register the VLAN. A driver with rx-vlan-filter on (the default on ConnectX) drops a tag no VLAN sub-interface has claimed, in the card, before the XDP program runs. Zero packets, no error. Create the VLAN interface or turn the filter off.

AF_XDP filters cannot match a VLAN id. The tag is skipped transparently so the same program works whether or not the NIC strips it. Match on ports or addresses instead; packetio refuses a VLAN match rather than installing a wider filter.

The cost lives outside your process. The kernel driver still runs the hardware, and its half shows up as softirq. Receiving or forwarding costs two cores per queue: a worker and its soft interrupt. Measure the machine, not the process.

Forwarding needs WithTxReuseRxFrames. Capabilities().HandsBackFrames is false here: the completion ring is drained into a pool without saying which frames came back, so Reclaim cannot name them. Without that option a receive frame transmitted here leaks into the transmit pool and the receive side starves.

afxdp.Open("eth0", steer, afxdp.WithXDP(xdp.WithTxReuseRxFrames()))

A parked driver needs a kick, and this backend's Fill delivers it. With need-wakeup on - which is this backend's default - a driver whose NAPI loop completes parks itself and posts no more receive descriptors until a syscall wakes it. go-afxdp's own Poll does that as a side effect, but the packetio contract is Fill/Receive with no Poll - so this backend's Fill checks NeedsWakeupRx (one atomic load) and calls WakeupRx when the driver parked. Before that kick existed, seven of eight queues took exactly their initial 1,024 frames and then nothing, while the port dropped 2.5 billion packets and every fill ring sat provably full - and forwarding masked it entirely, because transmit's own kick happens to drive the same NAPI. If you bypass this wrapper and drive a Socket directly without ever blocking in Poll, call WakeupRx yourself; Stats().Backend["rx_kicks"] is the counter that keeps this visible.

Performance

64-byte frames, ConnectX-6 Dx at 100G, whole-machine CPU accounting with the soft-interrupt share - the kernel's half of AF_XDP - shown alongside. Medians of three passes, defaults only.

rate cores (softirq)
transmit, one queue 18.7 Mpps 1.0 (0.5)
transmit, best 147.9 Mpps 20 queues, 20.0 (14.0)
receive, one queue 31.8 Mpps 1.6 (1.0)
receive, best 146.6 Mpps 16 queues, 11.6 (7.2)
forwarding, one queue 17.5 Mpps 2.0 (1.0)
forwarding, best 138.4 Mpps 16 queues, 28.7 (12.6)

Transmit has two slopes, and the backend picks between them for you (via go-afxdp v0.11.0): up to four queues it runs the driver's pointer descriptors at ~17.7 Mpps per core (18.7 / 35.7 / 70.7 on 1 / 2 / 4), then switches back to the kernel's copied multi-packet path, whose per-core cost is higher but whose ceiling scales to the wire: 120.0 / 147.1 / 147.6 at 8 / 12 / 16. Receive and forward cost about 1.5-2 cores per queue, a worker and its soft interrupt - which is why sixteen forwarding queues spend 29 machine cores, thirteen of them in softirq.

Placement matters more here than anywhere else: with go-afxdp choosing, one transmit queue does 18.7 Mpps on a single core; pinned by hand to a core of our choosing it did 6.5 on two. Let it place its own workers.

Receive gets to 146.5 Mpps on 8 queues (11.4 machine cores) and no further: 31.8 / 67.3 / 121.9 Mpps at 1 / 2 / 4. Expect ±15% run-to-run spread on this backend - it is the widest of the four. An earlier version degraded down to 5.9 Mpps at sixteen queues; the root cause and the three-line fix are in the gotcha above.

Examples

The examples in examples/ target mlx5 and dpdk, but the API is the same; swap the import and the Open call. Start with hello, which runs anywhere.

For AF_XDP-specific tooling - packet generators, tcpdump-expression filters - see go-afxdp.

Documentation

Overview

Package afxdp is the packetio backend for Linux AF_XDP.

It is a thin adapter over github.com/atoonk/go-afxdp: the two libraries were designed around the same model -- a region of fixed-size frames, descriptors that name one frame each, and the four verbs that move a frame between the pool, the application and the NIC -- so almost every method here forwards.

Where it differs from the mlx5 backend, and why it matters:

  • Each queue has its own frame region, because each AF_XDP socket maps its own. Capabilities reports SharedRegion false, and a frame received on one queue cannot be transmitted on another without a copy.

  • The kernel is in the path. It owns the descriptors after Transmit and does the work in a soft interrupt, which is real CPU this library cannot see. Measure it with the whole machine, not with this process.

  • Opening is backend-specific: the filter that decides what reaches the sockets is an AF_XDP concept with no equivalent elsewhere, so Open takes options of its own. WithXDP carries a go-afxdp option through for anything this package does not name.

  • **A socket is bound per queue, and an unbound queue goes to the kernel.** The XDP program delivers a packet to the socket on the queue the card hashed it to; a queue with no socket is passed up the stack, silently and by design. Opening fewer queues than the card has therefore means the card's hash decides whether anything arrives: one flow lands on exactly one queue, and if that queue is outside the range opened, the receive loop returns nothing and nothing is wrong. Measured on a 48-queue ConnectX: four queues received none of 200 kpps, forty-eight received all of it. Open every queue, or narrow what the card spreads with `ethtool -X`. mlx5 has no equivalent problem -- a steering rule delivers to the queue group whatever the hash decided.

  • **On a tagged interface, the kernel's own VLAN filter comes first.** A driver with `rx-vlan-filter on` -- the default on mlx5e -- drops a tag no VLAN sub-interface has registered, in the card, before the XDP hook runs. The program never sees the packet, the filter matches nothing, and a receive loop reports zero with no error anywhere. Register the id (`ip link add link eth0 name eth0.100 type vlan id 100`) or turn the filter off (`ethtool -K eth0 rx-vlan-filter off`). Direct Verbs does not have this problem: its steering rule carries the id and lives in this process's own flow table, below the netdev filter.

  • Opening attaches an XDP program to the interface, and Close detaches it. A process killed between the two leaves it attached, and the next Open fails with "already attached ... likely owned by another running process". Nothing in userspace can prevent that -- the kernel keeps the program because the link holds a reference, not the process -- so a program that may be killed should say how to clear it:

    sudo ip link set dev eth0 xdp off

    This is worth knowing before it happens: the interface keeps working for the kernel's own traffic, so the only symptom is that this library will not open.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SteeringOption

func SteeringOption(f packetio.SteeringFilter) (xdp.Option, error)

SteeringOption compiles a backend-neutral filter into a go-afxdp option.

go-afxdp's matches are ORed, and its only AND is a source-and-destination prefix pair, so not every SteeringFilter has an XDP form. What is expressible: promiscuous; any number of ports of one protocol; any number of source or destination prefixes; exactly one source and one destination prefix together; one EtherType; one IP protocol. A filter that needs an AND go-afxdp cannot express is refused with packetio.ErrUnsupported rather than installed as something wider.

Two different things about VLANs, and it is worth keeping them apart.

A VLAN cannot be matched at all: go-afxdp skips an 802.1Q tag transparently so the same program works whether or not the NIC strips it, and there is no match on the identifier. A filter naming one is refused; on a tagged interface the traffic is usually distinguished by port or address anyway. For anything beyond this vocabulary, pass go-afxdp's own matches to Open directly.

Separately, and more likely to waste an afternoon: on a tagged interface the packets may never reach the program at all. See the note on the package doc about rx-vlan-filter.

Types

type Device

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

Device is a NIC opened for AF_XDP, one socket per queue.

func NewDevice

func NewDevice(fleet *xdp.Fleet) *Device

NewDevice wraps a fleet the caller already has, for an application that manages the fleet's lifetime itself -- keeping one attached across several runs, say. Close then closes that fleet, so do not also close it directly.

func Open

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

Open attaches to an interface and binds one socket per queue.

func (*Device) Capabilities

func (d *Device) Capabilities() packetio.Capabilities

Capabilities reports what this backend and this NIC can do.

func (*Device) Close

func (d *Device) Close() error

Close detaches the program and closes every socket, unless Detach was called first, in which case the fleet belongs to somebody else and is left alone.

It is safe to call twice, but not from two goroutines at once.

func (*Device) Detach

func (d *Device) Detach()

Detach drops the reference to the fleet without closing it, for an application that owns the fleet and is only done with this view of it.

func (*Device) Fleet

func (d *Device) Fleet() *xdp.Fleet

Fleet is the underlying go-afxdp fleet, for the things this API does not describe: link state, the attached program, kernel ring counters.

func (*Device) NumRxQueues

func (d *Device) NumRxQueues() int

NumRxQueues is how many receive queues were opened.

func (*Device) NumTxQueues

func (d *Device) NumTxQueues() int

NumTxQueues and NumRxQueues are how many queues were opened. AF_XDP binds one socket per queue and it both sends and receives, so these are equal.

func (*Device) Rx

func (d *Device) Rx(i int) *RxQueue

Rx returns the concrete receive queue i, or nil when i is out of range.

func (*Device) RxQueue

func (d *Device) RxQueue(i int) packetio.RxQueue

RxQueue returns receive queue i, or nil if there is no such queue.

func (*Device) Tx

func (d *Device) Tx(i int) *TxQueue

Tx and Rx return a queue as its concrete type, for what is specific here.

func (*Device) TxQueue

func (d *Device) TxQueue(i int) packetio.TxQueue

TxQueue returns transmit queue i, or nil if there is no such queue.

type Option

type Option func(*config)

An Option configures a device at Open.

It is this package's own type rather than go-afxdp's so that go-afxdp's API is not inside packetio's compatibility promise: an option changing shape there would otherwise be a breaking change here, for callers who never named that package. WithXDP is the way through for anything not named here.

func WithAffinity

func WithAffinity(cpus ...int) Option

WithAffinity places the workers on the given processors, one per queue in order, instead of letting the backend choose.

Placement is automatic without this: go-afxdp puts each worker beside the processor its queue's interrupt lands on, which is most of the difference between a good AF_XDP number and a poor one. Reach for this only when the machine is partitioned and the automatic choice would take cores that belong to something else. Passing no processors is WithoutAffinity.

func WithFrameSize

func WithFrameSize(n int) Option

WithFrameSize sets the size of one frame in bytes. The default is 2048; some drivers need 4096 to run zero-copy, and go-afxdp already picks 4096 where it knows that to be true.

func WithFrames

func WithFrames(n int) Option

WithFrames sets how many frames the region holds, across both directions. The default is go-afxdp's, currently 8192.

func WithMultiBuffer added in v0.1.4

func WithMultiBuffer() Option

WithMultiBuffer binds the sockets for packets that span several frames -- a jumbo frame over a 2048-byte frame size -- marked with OptContinued on every frame but the last, as packetio.Capabilities.MultiBuffer describes. It is go-afxdp's WithMultiBuffer under the name every backend here uses.

Receive counts its batch in frames and may end it part-way through a chain, the rest arriving next call; RxQueue.ReceivePackets returns whole packets.

func WithQueues

func WithQueues(n int) Option

WithQueues limits how many queues to bind, starting from queue 0. The default binds every queue on the interface, which is what keeps RSS-distributed traffic from landing on a queue nobody is reading.

AF_XDP binds one socket per queue and that socket both sends and receives, so unlike the other backends there is no separate transmit and receive count: this sets both.

func WithSteering

func WithSteering(f packetio.SteeringFilter) Option

WithSteering asks for only the packets a filter matches, leaving the rest to the kernel. It is the backend-neutral spelling of a receive filter; see SteeringOption for what AF_XDP can and cannot express.

func WithXDP

func WithXDP(opts ...xdp.Option) Option

WithXDP passes go-afxdp options straight through, for what this package does not name: the XDP program, the ring geometry, the wakeup flags.

afxdp.Open("eth0", afxdp.WithXDP(xdp.WithBusyPoll(50, 64)))

Options are applied in the order given, here and above, so a WithXDP that repeats one of the named options above wins if it comes after it.

func WithoutAffinity

func WithoutAffinity() Option

WithoutAffinity leaves the workers wherever the scheduler puts them.

For a goroutine that drives a queue and nothing else this costs throughput. It is the right choice when something else on the machine owns processor placement, or when the worker does enough other work that tying it to one core is wrong.

type RxQueue

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

RxQueue is one AF_XDP socket's receive side.

func (*RxQueue) Close

func (q *RxQueue) Close() error

Close releases the queue; the socket is closed with the device.

func (*RxQueue) Err

func (q *RxQueue) Err() error

Err reports that the queue is out of service, or nil.

AF_XDP has no failure that outlives one call: the kernel drops what it has nowhere to put and counts it. So this reports only that the socket is closed.

func (*RxQueue) Fill

func (q *RxQueue) Fill(n int) int

Fill posts up to n frames for the kernel to receive into and returns how many it posted. A receiver that stops filling stops receiving.

Fill also wakes the driver when it has parked. go-afxdp's Fill only writes the ring -- its Poll is what restarts a parked driver -- but the packetio contract is that Fill/Receive with no Poll must work, because that is how every other backend is driven. Without this kick, a queue whose NAPI loop completed while the ring was quiet never posts another descriptor: measured on the ConnectX-6 Dx, seven of eight queues took exactly their initial 1,024 frames and then nothing, for hours, while the port dropped 2.5 billion packets and the fill rings sat provably full. The check is one atomic load when the driver is running; the recvfrom is paid only when it parked.

func (*RxQueue) NumFreeFillSlots

func (q *RxQueue) NumFreeFillSlots() int

NumFreeFillSlots is how many more frames the receive ring can hold.

func (*RxQueue) NumFreeFrames

func (q *RxQueue) NumFreeFrames() int

NumFreeFrames is how many frames are in the free pool.

func (*RxQueue) NumReceived

func (q *RxQueue) NumReceived() int

NumReceived is how many packets are ready right now. AF_XDP has no way to ask without taking them, so this polls without waiting.

func (*RxQueue) Pin

func (q *RxQueue) Pin() (int, error)

Pin places the calling goroutine on this queue's processor and reports which.

func (*RxQueue) Poll

func (q *RxQueue) Poll(timeout time.Duration) (int, error)

Poll waits until at least one packet has arrived or timeout elapses. Unlike the mlx5 backend this really sleeps rather than spinning.

func (*RxQueue) Receive

func (q *RxQueue) Receive(max int) []packetio.Desc

Receive takes up to max received packets. The returned slice is owned by the queue and reused by the next call.

func (*RxQueue) ReceivePackets

func (q *RxQueue) ReceivePackets(maxFrames int) []xdp.Packet

ReceivePackets takes up to maxFrames frames' worth of packets, each returned as the run of frames it occupies. Do not mix it with Receive on one queue: they consume the same ring and count differently.

func (*RxQueue) Recycle

func (q *RxQueue) Recycle(descs []packetio.Desc)

Recycle returns received frames to the pool.

func (*RxQueue) RecyclePackets

func (q *RxQueue) RecyclePackets(pkts []xdp.Packet)

RecyclePackets returns whole packets, however many frames each took.

func (*RxQueue) Region

func (q *RxQueue) Region() packetio.Region

Region is the frame memory this queue receives into.

func (*RxQueue) Socket

func (q *RxQueue) Socket() *xdp.Socket

Socket is the underlying go-afxdp socket.

func (*RxQueue) Stats

func (q *RxQueue) Stats() (packetio.RxStats, error)

Stats reports what this queue has done.

type TxQueue

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

TxQueue is one AF_XDP socket's transmit side.

It is owned by one goroutine, as everywhere in this API. The first call from that goroutine also places it on the queue's processor; see go-afxdp's Pin.

func (*TxQueue) Alloc

func (q *TxQueue) Alloc(n int) []packetio.Desc

Alloc takes up to n frames from the queue's free pool.

func (*TxQueue) Close

func (q *TxQueue) Close() error

Close releases the queue. The socket is shared with the receive side and is closed with the device.

func (*TxQueue) Complete

func (q *TxQueue) Complete(max int) int

Complete reclaims up to max frames the kernel has finished with, returning them to the pool, and reports how many.

func (*TxQueue) Err

func (q *TxQueue) Err() error

Err reports that the queue is out of service, or nil.

AF_XDP has no failure that outlives one call: the kernel refuses a bad descriptor and carries on, and its counters say how often. So this reports only that the socket is closed, which is the honest answer here.

func (*TxQueue) Free

func (q *TxQueue) Free(descs []packetio.Desc)

Free returns frames to the pool without transmitting them.

A descriptor this queue's region does not contain is refused and counted, not appended: it would come back from a later Alloc as an address the kernel rejects, or worse, one belonging to another socket. Each AF_XDP queue maps its own region, so "another queue's frame" is a real and easy mistake here.

func (*TxQueue) NumCompleted

func (q *TxQueue) NumCompleted() int

NumCompleted is how many frames Complete would reclaim right now.

func (*TxQueue) NumFreeFrames

func (q *TxQueue) NumFreeFrames() int

NumFreeFrames is how many frames are in the free pool.

func (*TxQueue) NumFreeSlots

func (q *TxQueue) NumFreeSlots() int

NumFreeSlots is how many more frames the transmit ring can accept.

func (*TxQueue) NumInFlight

func (q *TxQueue) NumInFlight() int

NumInFlight is how many frames the kernel currently owns.

func (*TxQueue) Pin

func (q *TxQueue) Pin() (int, error)

Pin places the calling goroutine on this queue's processor and reports which one, or -1 if placement is off. The packet path does it on first use; a caller wanting to know before the first batch asks here.

func (*TxQueue) Reclaim

func (q *TxQueue) Reclaim(max int, out []packetio.Desc) []packetio.Desc

Reclaim is Complete for frames that belong somewhere else.

AF_XDP cannot name the frames it reclaimed: Complete drains the completion ring straight into a pool, and there is no way to see which addresses came back. So this completes and returns nothing, and a forwarder on this backend must be opened with go-afxdp's WithTxReuseRxFrames, which makes Complete return each frame to the pool its address belongs to rather than to the transmit pool. Without it a receive frame transmitted here leaks into the transmit pool and the receive side starves -- go-afxdp keeps a separate pool per direction, so this is a real trap and not a theoretical one.

Capabilities.HandsBackFrames is false here, which is how a caller written against the interface finds this out rather than discovering it as a stall.

func (*TxQueue) Region

func (q *TxQueue) Region() packetio.Region

Region is the frame memory this queue draws on. Each queue has its own.

func (*TxQueue) SendBatch

func (q *TxQueue) SendBatch(payloads [][]byte) (int, error)

SendBatch transmits whole payloads, splitting any that do not fit a frame across several. It reports how many payloads went out.

func (*TxQueue) SendFunc

func (q *TxQueue) SendFunc(count int, build func(i int, frame []byte) int) (int, error)

SendFunc is the whole transmit cycle in one call.

func (*TxQueue) Socket

func (q *TxQueue) Socket() *xdp.Socket

Socket is the underlying go-afxdp socket, for what this API does not describe: the wakeup flags, the kernel's own ring counters, placement.

func (*TxQueue) Stats

func (q *TxQueue) Stats() (packetio.TxStats, error)

Stats reports what this queue has done.

func (*TxQueue) Transmit

func (q *TxQueue) Transmit(descs []packetio.Desc) int

Transmit hands descriptors to the kernel and returns how many it took, always a prefix. It publishes the batch before returning; there is no separate kick.

A descriptor that does not name at least one byte inside one frame ends the batch: the prefix before it is transmitted and the rest is left with the caller. That is checked here rather than left to the kernel, which counts a bad descriptor in tx_invalid_descs and carries on -- so the caller would get a full-length return and no way to know which descriptor was dropped.

Jump to

Keyboard shortcuts

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