Skip to content

test - #1802

Draft
qmonnet wants to merge 16 commits into
mainfrom
test/qmonnet/afxdp-driver
Draft

test#1802
qmonnet wants to merge 16 commits into
mainfrom
test/qmonnet/afxdp-driver

Conversation

@qmonnet

@qmonnet qmonnet commented Sep 4, 2026

Copy link
Copy Markdown
Member

No description provided.

@qmonnet qmonnet added ci:+release Enable VLAB release tests ci:+vlab Enable VLAB tests ci:+cross run cross compile jobs ci:+debug-images Build and push the debug container images on this PR labels Sep 4, 2026
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

qmonnet and others added 16 commits September 4, 2026 20:03
The kernel driver reads packets with AF_PACKET, which copies every frame
through a socket buffer and caps what one worker can move. AF_XDP lets the
kernel put received packets straight into memory we share with it, and take
transmitted ones from the same place, but nothing in the tree describes that
memory or presents it to the pipeline.

The crate starts with the part that has no dependency on libxdp: how a frame
is laid out, and a packet buffer that reads and writes one in place. Because
prepending and trimming only move offsets within the frame, a packet can cross
the pipeline without being copied anywhere.

The libxdp-dependent parts sit behind the "runtime" feature, so the default
build, and the buffer's own tests, need neither libxdp nor the C toolchain it
is built with.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The AF_XDP support in the dataplane-xdp crate binds sockets through libxdp,
which the build compiles from source along with the libbpf it bundles. That
build needs three things we do not have: libelf and zlib to link against, m4
to preprocess libxdp's dispatcher program, and a compiler that will accept
"-target bpf" for the BPF programs libxdp carries.

The last one is the awkward one. Our clang is wrapped, and the wrapper hands
it hardening flags such as -fzero-call-used-regs, which clang rejects outright
for the bpf target. The build honours CLANG for exactly those compilations, so
point it at the compiler under the wrapper and leave everything else building
the way it did.

The dev shell and the packaged build each need all three, so both get them.

Only the static libraries go in the sysroot, as for everything else there: the
dataplane ships in an image with no shared libraries of its own to find, and
one that wanted libelf.so at startup would not run. Static linking is what
makes elfutils' own crc32 collide with zlib's, and what makes its optional
compressors -- which are its business, not libbpf's -- pull three more
libraries into the link, so both are dropped. elfutils is also built without
debuginfod, which would want curl for a feature we have no use for, and with
its test suite off: it does not survive our stdenv and covers nothing on the
path we use.

None of that is quite the same for a cross build, where the tools left in the
environment under their plain names are the build host's. The ar that drops
elfutils' crc32 is not there at all; libbpf's build looks for an unprefixed
pkg-config and finds none; and the ld and objcopy libxdp wraps its BPF
programs with hand back objects for the build host, which the link for the
target then refuses. Each is pointed at the tool for the target instead. On
aarch64 there is one more: gcc reaches into libgcc for its atomics, and
nothing in the sysroot carries it, so libbpf is told to emit them inline.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
A worker needs a socket on every interface it serves, not just the one it
receives from, or a packet could not be forwarded. Binding each of those to a
UMEM of its own would give the worker one free-frame pool per interface, and
leave a packet arriving on one interface unable to leave by another without a
copy between mappings.

So the UMEM comes first and the sockets are bound to it: XskUmem owns the
mapping and the free list, and hands out an XskSocket per (interface, queue)
pair, each with its own four rings. A frame is on exactly one ring, on the
free list, or in a buffer somewhere in the pipeline, and finds its way back to
the free list from wherever it ends up -- including from a buffer the pipeline
drops, which returns its frame over a channel.

Zero-copy is tried first and copy mode used if the driver refuses, so a NIC
without zero-copy support runs slower rather than not at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
libxdp loads a redirect program when a socket is bound, which is enough to get
packets to userspace but leaves the redirect decision in a program we do not
own and cannot change. Anything we later want to do before a packet reaches
the pipeline -- filtering, steering, counting -- has to live in that program.
So we bring our own, and tell libxdp not to load one.

The program itself is a few lines: look the RX queue up in a map of sockets,
redirect if there is one, and pass to the kernel stack if there is not, so
traffic on queues we do not serve is untouched. Around it are the pieces that
get it built and loaded: a crate outside the workspace, because it is compiled
for the BPF target with a nightly toolchain and bpf-linker; a just recipe to
drive that build; and a build script that compiles the resulting object into
the loader.

Nothing runs the recipe yet, so for now the object is whatever whoever is
building has left in the target directory, or points DATAPLANE_XDP_EBPF at.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The program in xdp-ebpf/ has to be compiled by hand, by whoever has a
bpf-linker and a nightly toolchain, which is not a thing a build can depend
on: nothing that needs the driver can be built without it.

Three things stood in the way, and none of them needed a nightly toolchain in
the end. RUSTC_BOOTSTRAP is enough for the -Zbuild-std the BPF target needs
because it has no prebuilt std. bpf-linker is in nixpkgs, but built against
the LLVM of the nixpkgs rustc, and it reads the bitcode ours emits -- so it is
rebuilt against the LLVM our own rustc reports, which is what llvmPackages'
already is. And the program is built by a derivation of its own rather than by
the workspace, since it is a different target linked by a different linker;
the result reaches the dataplane build through DATAPLANE_XDP_EBPF.

The profile it is built with is not a matter of taste. The verifier refuses a
program whose last instruction is a call, and a debug build puts the null and
alignment checks for a raw pointer dereference after the program's exit; it
would not load at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
Kif is how a driver names the interfaces it was told to serve: the ifindex to
tag packets with, and the name to bind on. None of that is specific to
AF_PACKET, and a second driver needs the same table built the same way, from
the same command line, with the same interfaces brought up first.

Along with the move it gains the RX queue count, which a driver that binds one
socket per queue needs to decide how many workers to run and which queues to
serve. Interfaces whose queue count sysfs will not report are treated as
having one, which is what the kernel gives them anyway.

The af-xdp feature is declared here so the queue count, which only that driver
uses, is not compiled into a build that has no use for it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
start_router was generic over the buffer type, which meant the buffer type of
the driver decided the type of everything it returned -- the router handle,
the writers management holds, the stats collector -- even though none of those
depend on it. With one driver the compiler inferred it from the driver's own
signature and nobody noticed. With two, whose buffers differ, the same call
cannot serve both.

Only building a pipeline actually needs to know the buffer type, so that is
where the parameter goes: start_router hands back a factory holding what a
pipeline is assembled from, and the driver asks it for a pipeline over the
buffer it reads into. The stages themselves already work for any buffer.

The reader factories the pipeline is built from were public types in private
modules, nameable only as the return type of a getter, so a struct could not
hold them. Re-export the three that were not already.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The supervisor watches worker threads: it joins the ones that end, calls stuck
the ones whose rx tasks stop patting their watchdogs, and publishes what they
all report. None of that depends on how a worker gets its packets, but it was
written inside the kernel driver, where a second driver cannot reach it.

Move it, along with the monitors it watches through, and give it the periods
it works to rather than reading them off one driver. The status it publishes
carried the same assumption -- it printed the kernel driver's batch size and
periods whatever driver was running -- so those become part of what a driver
reports about itself, next to its name.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The kernel driver moves packets with AF_PACKET, which copies every frame
through a socket buffer on the way in and again on the way out. AF_XDP does
away with both copies: the kernel writes received packets into memory the
worker already has mapped, and reads transmitted ones from the same place.

An AF_XDP socket is bound to one RX queue of one interface, so the workers are
organised by queue rather than by fanout: worker q owns a socket on queue q of
every interface, and the queue count decides how many workers there are.
Behind those sockets is one UMEM per worker, so a packet arriving on one
interface can leave by another without being copied between mappings, and the
worker draws frames from a single pool rather than one per interface.

Where the kernel driver waits on tokio, this one blocks in poll(2) across its
sockets. That poll's timeout is also what bounds how long the worker takes to
notice it has been cancelled, so there is no async machinery in the loop at
all.

The driver is behind the af-xdp feature, since libxdp has to be built from
source and needs a C toolchain the default build does without.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
libxdp pins the redirect program it loads under /sys/fs/bpf, and uses those
pins to count the sockets on an interface so it knows when the last one has
gone and the program can be detached. Without the filesystem it says so, falls
back to a program it cannot later detach, and every interface the dataplane
ran on keeps one after it exits -- which the next run then has to displace.

Most systems have the filesystem mounted. A container often does not, and a
container is where the dataplane runs, so mount it rather than making whoever
launches us do it. Failing to is not fatal: it costs the clean detach, not the
ability to move packets, so it is reported and the driver carries on.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The AF_XDP driver asks no more of a NIC than the kernel driver does, and moves
packets without the copy through a socket buffer that the kernel driver pays
for on every frame. There is no reason to prefer the kernel driver where both
will run, and every reason for what CI and the VLAB exercise to be what we
ship, so it becomes what the dataplane runs when it is not told otherwise.

Being the default means being built in, so the af-xdp feature joins the
default set. That makes libxdp, and the XDP program built for the BPF target,
part of an ordinary build; a build without the toolchain for them can still
turn the feature off, and then has to be told to use the kernel driver, which
it says plainly rather than reporting the driver it defaults to as unknown.

--driver kernel still selects the kernel driver, for anywhere the process
cannot be given the capabilities AF_XDP needs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The dataplane has had a --driver flag with more than one answer for a while,
and now has a third, which it uses unless told otherwise. Nothing said what
they are, which one runs by default, or what that one asks of the system it
runs on. Say so.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
A packet the pipeline marks Local is one it wants the host stack to have. No
driver delivers it: nothing in userspace can inject into an interface's
receive path, so both of them drop it. The kernel driver gets away with that,
because AF_PACKET leaves the kernel its own copy of every frame regardless of
what we do with ours. The AF_XDP driver does not: an XDP program that
redirects a packet to a socket is the last thing to see it.

So under AF_XDP these are packets the host was supposed to receive and did
not, and they were leaving no trace at all -- not counted as pipeline drops,
since the pipeline handed them back, and not as TX drops, since no send was
attempted. Give them a counter of their own, reported next to the drops, so
the gap is a number rather than a mystery. Both drivers set it, so it means
the same thing whichever is running, even though only one of them is losing
anything.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
An XDP program that redirects a packet to an AF_XDP socket is the last thing
to see it: the network stack never does. libxdp's program redirects everything
on a queue with a socket, so with the driver running, anything on those
interfaces that the host itself was expecting -- routing sessions, neighbour
discovery, anyone pinging the gateway -- stopped arriving. Nothing in
userspace can put those packets back: an AF_PACKET write transmits, and only a
tap can be written into, which is not the interface the packet came in on. The
decision has to be made before the redirect, which means in the program.

So the program classifies. What is not IP goes to the kernel, which is what
the pipeline does with it anyway. What is addressed to one of the gateway's
own addresses goes to the kernel, which is what the pipeline marks Local for.
VXLAN is the exception on both counts: it arrives addressed to the gateway and
it is exactly what the dataplane exists to handle. Everything else, which is
the traffic being forwarded, is redirected as before.

Which addresses are the gateway's own is not known at startup -- they arrive
with its configuration and change with it -- so the driver reads them back
from the kernel and hands them to the program repeatedly. Polling rather than
following netlink is deliberate: the set is rebuilt from what the kernel holds
every time, so a missed message cannot leave the program permanently wrong,
and the cost is a bounded delay before a new address stops being redirected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
The kernel refuses to register a UMEM whose chunks are larger than a page, so
a frame holds about 3.8KB of packet and no more. That is well under the 9100
the fabric runs at, and the consequences were not subtle: the kernel will not
attach an XDP program that has not said it copes with fragments to an
interface whose MTU exceeds a buffer, so ours only ever attached in generic
mode, and anything too long to fit was dropped without reaching the pipeline.
Measured on a veth at MTU 9100, a 1.4KB ping arrived and an 8KB one did not.

Multi-buffer is the kernel's answer: a long packet arrives as several
descriptors in a row, each but the last saying there is more to come, and
leaves the same way. The socket asks for it at bind, and the program declares
it so that a native attach is allowed again.

The pipeline needs a packet as one run of bytes -- it parses and rewrites
headers, and prepends to them -- and the fragments are in frames nowhere near
each other, so a packet that arrives in pieces is gathered into a buffer of
its own and split again on the way out. That is a copy each way, paid only by
packets too long for a frame; a packet that fits one still crosses the
pipeline without being copied at all. Doing better would mean teaching
PacketBufferMut about scatter-gather, which is a change to every buffer in the
tree rather than to this driver.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
Making AF_XDP the default did not change what the VLAB or CI run. The gateway
controller in the fabric repo decides the driver from whether a gateway's
interfaces are named by PCI address or by kernel name, and for the latter it
appends --driver kernel whatever else is true. An explicit flag wins over the
default, as it should, so nothing here can reach that deployment: asking for
AF_XDP there means a change in fabric and another in fabricator, whose list of
gateway drivers has two entries in it.

Until those land, serve that name from the AF_XDP driver. It is a stopgap and
reads like one -- one arm, one warning saying what happened, and the AF_PACKET
driver still reachable by its own name, which is what it should have been
called all along.

Keeping the name is also what makes the rest of the deployment work: the same
controller only runs the init container that sets the interface MTU, turns GRO
off and brings the links up when it thinks the driver is "kernel". Asking it
for "af-xdp" would have skipped all of that, and the MTU it sets is the one
the multi-buffer work exists to carry.

A build without the AF_XDP driver still reads --driver kernel as the AF_PACKET
one, since there it cannot mean anything else.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Quentin Monnet <qmo@qmon.net>
@qmonnet
qmonnet force-pushed the test/qmonnet/afxdp-driver branch from d66dbc2 to 1bbcc33 Compare September 5, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:+cross run cross compile jobs ci:+debug-images Build and push the debug container images on this PR ci:+release Enable VLAB release tests ci:+vlab Enable VLAB tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant