Skip to content

xdp-forward: add userspace VLAN forwarding support - #584

Open
Strykar wants to merge 1 commit into
xdp-project:mainfrom
Strykar:vlans-userspace
Open

Strykar wants to merge 1 commit into
xdp-project:mainfrom
Strykar:vlans-userspace

Conversation

@Strykar

@Strykar Strykar commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

This picks up #504 as agreed there: the userspace half now, the
kernel-assisted half once the kernel support reaches a released kernel.
@enhaut proposed the approach. The implementation is fresh rather than a
rebase: the parsing-helpers refactor (c5ebaea, 006e306) conflicts with the
original patches, and rewriting on top of the VLAN-aware parse_ethhdr()
was simpler than porting.

The first commit fixes a TTL bug found on the way, #586.

How the feature works: the kernel FIB resolves a route through a VLAN
upper to that device's ifindex, which XDP cannot redirect through. At
load time, xdp-forward enumerates the VLAN uppers of the configured
interfaces over netlink into a BPF map (VLAN ifindex -> vid + physical
ifindex). When the FIB returns an ifindex that is not a TX-port, the
program translates it through the map and rewrites the frame to match
the egress (tag push, pop or rewrite via bpf_xdp_adjust_head()).

The feature is always built and arms itself at runtime: the loader
freezes the usable-VLAN count into rodata before the object loads, so
with no VLANs configured the verifier removes the VLAN code (only a
cheap runtime check remains) and the map is not created. The rodata
prune relies on frozen-rodata scalar tracking (kernel v5.5+); the
commit message states the trade if that floor matters.

  • The map is consulted only after a TX-port miss, so untagged traffic
    takes exactly the current code path.
  • Fail closed before the frame is touched: 802.1ad and stacked (QinQ)
    ingress goes back to the stack, and the map only holds devices the
    program can actually transmit through (no 802.1ad uppers,
    VLAN-on-VLAN, cross-netns parents, or devices with a master).
  • Egress tags carry the VID only; ingress PCP/DEI are not copied over.

Limitations are documented in the README: the VLAN list is a load-time
snapshot, single-tagged 802.1Q only, VLAN offload must be off on
attached interfaces, and policy routing keyed on the VLAN iif does not
match.

Throughput on veth (measured on the compile-gated build this respin
replaces; the armed datapath is unchanged):

path Mpps delta
stock build, untagged 3.397 baseline
feature build, untagged 3.392 -0.1% vs stock, noise
feature build, push 3.114 -8.2% vs feature untagged
vlan-compared

Three harness tests cover push/pop, rewrite, and QinQ fail-closed,
running unconditionally on every matrix cell. The datapath is also
wire-verified on a two-port ConnectX-6 Dx with native XDP (mlx5
counters attribute both paths to XDP; the pushed tag is visible in
wire captures).

Per-packet costs, the measurement methodology, and the corrected
numbers replacing the earlier cycles table are in the review
discussion below.

The kernel-assisted mode follows as its own PR once BPF_FIB_LOOKUP_VLAN
(bpf-next commits 35dac1daeb3c and 217828aad80d) reaches a released
kernel; it also removes the policy-routing limitation.

@enhaut

enhaut commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

lgtm

@tohojo tohojo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of high-level comments:

  • I don't think it makes sense to hide this behind a compile-time flag. Most people use xdp-tools from distro packages, so recompiling to get the feature is not feasible. I think we should rather just make it always-available, but disable the bpf-side code if no VLAN interfaces are relevant to the configured interface set (via a volatile const global variable so the verifier can just remove the code if it's not used).
  • It seems odd that the cost of rewriting an existing tag is way higher than pushing/popping tags. Makes me suspect that the measurement methodology is dodgy?
  • Some of the comment blocks just reiterate what the codes does; please trim those.
  • Let's merge the initial bugfix separately; please open a separate PR for that (you can keep the commit in this PR as well as a dependency - rebase-then-merge should do the right thing with this).

@Strykar Strykar changed the title xdp-forward: add opt-in userspace VLAN forwarding support xdp-forward: add userspace VLAN forwarding support Aug 15, 2026
@Strykar

Strykar commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor Author

All four points taken. The respin will carry the always-built volatile-const rework and the comment trims; the TTL fix goes to its own PR.

On point 2: Your suspicion was correct. The defect was the metric (cycles are batching-sensitive on veth), the generator mixing and the labels. The profiling topology also never forwarded a packet (every redirect died at flush); profile counts runs, not delivery. The throughput table is unaffected, its xdp-bench sink kept that path live.

Corrected numbers, from a single run of the rebuilt harness; every arm passes a delivery gate (sink/generator ratio banded at 1.00 +/- 0.03) and a tcpdump wire assertion on the data tuple before it counts:

path added insns/pkt vs untagged what the path pays
pop +73.2 tagged-ingress parse + pop
rewrite +211.2 tagged-ingress parse + egress map translation + in-place TCI store
push +243.3 egress map translation + push
untagged, feature vs stock +34.1 the fast-path check

Each delta reproduces within 0.7 insns across five to six runs.

vlan-insns

The untagged row is +34 insns (about 1.5%) with the feature built in. The volatile-const respin changes its shape, already measured: with no VLANs configured the verifier removes the VLAN code and the residue is +13 insns (the gate itself; xlated 233 vs 251, diff as receipt); with VLANs configured the untagged path pays +54 vs stock (was +34 on the flag build; the delta is codegen ripple from the restructure plus the gate read, not a live branch, since the verifier hard-wires the armed branches too; armed xlated 335 vs 357 as receipt); boundary-crossing costs hold within about 1%. Better than the old parity claim, and measured before posting.

vlan-cycles

The kernel FIB resolves a route through a VLAN upper device to that
device's ifindex, which XDP cannot redirect through, so xdp-forward
cannot serve VLAN topologies on stock kernels.

At load time, enumerate the VLAN uppers of the configured interfaces
over netlink into a BPF hash map keyed by VLAN ifindex. On a FIB
result that is not a TX-port, the XDP program consults the map,
redirects through the underlying physical device, and rewrites the
packet's VLAN encapsulation to match the egress (tag push, pop or
rewrite via bpf_xdp_adjust_head()). The untagged fast path is
unchanged: the map is only consulted after a TX-port miss.

The feature is always built and gates itself at runtime: the loader
counts the usable VLAN uppers before the object loads and freezes the
count into rodata (volatile const). With zero configured, the verifier
removes every VLAN branch and the map is not created; the measured
residue against a build without VLAN support is 13 instructions per
packet (the gate read plus code layout), not zero. With VLANs
configured, the untagged path pays 54 instructions over a stock build
(around 20 of them code-layout ripple relative to a compile-gated
variant of this feature); boundary-crossing packets pay 73 (pop) to
242 (push). The rodata prune relies on the verifier's frozen-rodata
scalar tracking (v5.5 and later); on older kernels the skipped map
creation would fail the load, and always creating the map is the
graceful alternative if that floor matters.

Only untagged and single-tagged 802.1Q frames are reshaped. 802.1ad
and stacked tags bail back to the stack before the frame is touched.
The map only holds devices the program can actually transmit through:
802.1ad uppers, VLAN devices stacked on other VLAN devices, links
whose parent lives in another netns (their IFLA_LINK is meaningless
locally and free to collide with a TX-port ifindex) and links with a
master device are all excluded. The prune is hygiene rather than the
safety property: a stacked entry's parent is itself a VLAN ifindex,
which can never pass the TX-port re-check after translation.

The netlink dump runs before the object loads, so only the map-update
calls remain between attach and populate; a packet arriving in that
window takes a miss path that returns XDP_PASS before the TTL
decrement and reaches the stack unmodified.

Three tests cover it, running unconditionally: test_vlan_userspace
forwards through a VLAN egress (tag push out, pop back),
test_vlan_rewrite forwards between two VLANs (tag rewrite both
directions), both asserting the router's ForwDatagrams count stays
flat, and test_vlan_qinq asserts stacked-VLAN traffic still flows and
does traverse the stack. Also verified by hand on a two-port
ConnectX-6 Dx with native XDP over a 25G wire, where the mlx5
rx_xdp_redirect and tx_xdp_xmit counters attribute both paths to XDP
and the pushed tag is visible in wire captures.

Suggested-by: Samuel Dobron <samuel@dobron.sk>
Signed-off-by: Avinash Duduskar <avinash.duduskar@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants