# Benchmarking on low-power hardware (Raspberry Pi)
`examples/pibench.rs` + `scripts/pibench.sh` compare ferranet against libpnet on the actual target
silicon, where the metric that matters is **CPU time per frame** and **how much traffic a single
core can absorb before dropping** — not peak desktop pps.
The harness has two modes:
- `pibench capture --backend <ring|basic|pnet> --iface <if> --secs N [--snaplen N]` — receive for a
window and report received pps, throughput, kernel drops (ferranet only), and `cpu_us/frame`.
- `pibench flood --iface <if> --secs N --size B` — saturate an interface via ferranet's batched TX.
## 1. Cross-compile from your workstation
Check the Pi's userland: `uname -m` → `aarch64` (64-bit Raspberry Pi OS) or `armv7l` (32-bit).
```sh
cargo install cross # Docker-based cross compiler
# 64-bit:
cross build --release --no-default-features --example pibench --target aarch64-unknown-linux-gnu
# 32-bit:
cross build --release --no-default-features --example pibench --target armv7-unknown-linux-gnueabihf
scp target/<triple>/release/examples/pibench pi@raspberrypi.local:~/
scp scripts/pibench.sh pi@raspberrypi.local:~/
```
`--no-default-features` drops Tokio (the harness is sync), keeping the binary small. Building on the
Pi itself also works (`cargo build --release --no-default-features --example pibench`) — slower, but
only ferranet + libpnet + libc are compiled, not the heavier dev-dependencies.
## 2a. Self-contained run (veth — controlled and repeatable)
```sh
sudo PIBENCH=./pibench SECS=10 SIZE=64 ./pibench.sh
sudo PIBENCH=./pibench MBPS=100 ./pibench.sh # compare CPU load at a fixed 100 Mb/s
sudo PIBENCH=./pibench SNAPLEN=128 ./pibench.sh # header-only ferranet capture
```
Floods one end of a `veth` pair (pinned to the upper cores) and captures on the other (core 0),
printing one line per backend. veth can push far past a USB Ethernet link, so it stresses the
capture stack hardest — ideal for the ferranet-vs-pnet CPU comparison without link/driver variance.
## 2b. Real interface (USB Ethernet) run
Capture on the USB Ethernet adapter (check its name with `ip -br link` — often `eth1`/`eth0` or a
`enx<mac>` name), flooded from another host:
```sh
# on the Pi, once per backend (CPU at the offered rate is the number of interest):
sudo ./pibench capture --backend ring --iface eth1 --secs 10
sudo ./pibench capture --backend basic --iface eth1 --secs 10
sudo ./pibench capture --backend pnet --iface eth1 --secs 10
sudo ./pibench capture --backend pcap --iface eth1 --secs 10 # needs --features pcap-bench (see below)
# from another host on the same segment, offer a fixed wired rate (frames are captured at L2
# before the UDP layer drops them, so no listener is needed on the Pi):
iperf3 -u -b 200M -t 15 -c <pi-ip> # or run `iperf3 -s` on the Pi if you prefer
```
Capture flags worth knowing:
- `--warmup S` (default 0.5) — drains for `S` seconds before the measurement window opens, so
first-touch page faults and cold caches don't skew the numbers.
- `--verify` — runs every captured frame through [`etherparse`](https://crates.io/crates/etherparse):
parses + classifies it (UDP/TCP/ICMP/ARP) and checks the IPv4 header checksum, reporting
`parsed/parse_err/ipv4_bad_csum` and a class histogram. A correctness pass (it adds per-frame CPU),
not a perf number. Best run without `--snaplen` so whole frames are present.
- **`pcap` backend** — a libpcap comparison. libpcap also uses `PACKET_MMAP`, so it lands far closer
to `ring` than to `pnet`; it requires building with `--features pcap-bench` (and arm64 libpcap in
the cross image — see `scripts/Cross.pcap.toml`, used via `CROSS_CONFIG=scripts/Cross.pcap.toml`).
**Start the capture before the flood.** Binding the AF_PACKET socket while a flood is already in
flight causes a one-time burst of kernel ring drops during ring setup — a measurement artifact, not
the backend falling behind. `scripts/pibench-sweep.sh` does this ordering for you.
A USB 2.0 Ethernet adapter on the Zero 2 W realistically does ~100–300 Mb/s — enough to make the
capture path CPU-bound, which is the regime where the backends diverge. Hold the rate fixed
(`iperf3 -b`, or `--mbps` over veth) and compare `proc_cpu` across backends.
## 3. Reading the results — CPU load
```
ring iface=eth1 secs=10.00 frames=2000000 pps=200000 Mb/s=101.6 drops=0 proc_cpu=0.3%core cpu_us/frame=0.015 sys_cpu=0.40cores
basic iface=eth1 secs=10.00 frames=2000000 pps=200000 Mb/s=101.6 drops=0 proc_cpu=42.0%core cpu_us/frame=2.100 sys_cpu=0.85cores
pnet iface=eth1 secs=10.00 frames=2000000 pps=200000 Mb/s=101.6 drops=n/a proc_cpu=55.0%core cpu_us/frame=2.750 sys_cpu=1.00cores
```
- **`proc_cpu`** — the capture process's CPU as a percentage of one core, *the* headline. At a fixed
offered rate this is the cleanest comparison: ferranet's zero-copy `ring` should sit near idle
(one `poll` per block, frames read straight from the mmap) while `pnet` burns a large fraction of
a core (a `poll` + `recvfrom` + copy per packet). The gap widens on a slow in-order A53.
- **`cpu_us/frame`** — the same cost normalised per frame; rate-independent.
- **`sys_cpu`** — system-wide CPU (all cores, including the kernel's softirq/driver RX work) over the
window. Most meaningful in the real-NIC run, where the flooder is external; in the veth run it also
includes the on-Pi flooder, so read `proc_cpu` there.
- **`drops`** (ferranet): zero means it kept up; non-zero means the ring overflowed. libpnet has no
per-socket drop counter (`n/a`) — under a fixed flood, compare it on `proc_cpu` and on how high a
rate it sustains before dropping.
- **`Mb/s`** with `--snaplen` drops sharply for `ring` (less copied) while `proc_cpu` improves
further — the header-only capture win.
The numbers above are illustrative — fill in the real ones from your Pi.
## 4. Sweeping packets-per-second
On a fixed link, achievable pps is set by line rate, so a 64 B frame saturates ~96 kpps no matter
how hard you offer — more bitrate or more iperf streams can't exceed it. The lever for *more* pps is
*smaller* frames. `scripts/pibench-sweep.sh` holds the link saturated and steps the frame size (big
→ small == low → high pps), runs each backend `REPS` times, averages, and writes a CSV:
```sh
# capture-first ordering + parallel streams to saturate + averaged reps, all built in:
BACKENDS="ring basic pnet pcap" REPS=3 ./scripts/pibench-sweep.sh
python3 scripts/pibench-plot.py pibench-sweep.csv out.png # CPU / per-frame / drops vs pps
# etherparse overhead = the verify-minus-plain delta (":verify" suffix adds --verify):
BACKENDS="ring:verify pcap:verify" OUT=verify.csv ./scripts/pibench-sweep.sh
python3 scripts/pibench-plot-verify.py <(cat pibench-sweep.csv; tail -n +2 verify.csv) overhead.png
```
### Measured on a Raspberry Pi Zero 2 W (direct 100 Mbit link, `cpu_us/frame`)
| ~8 kpps (1472 B) | 0.42 | 2.86 | 16.8 | 20.2 |
| ~96 kpps (64 B) | 0.23 | 1.06 | 4.6 | 7.4 |
| ~105 kpps (18 B) | 0.15 | 0.63 | 4.4 | 7.1 |
- **ring drops nothing** across the whole range; `basic` only drops once it saturates a core
(~64 kpps+) — that's real backpressure, not a startup artifact.
- **libpcap sits between ring and basic**: ~4–7× ring's per-frame CPU (both are `PACKET_MMAP`
rings; the gap is ferranet's TPACKET_V3 block batching vs libpcap's per-packet path), but ~4–6×
cheaper than `basic` and ~7–11× cheaper than `pnet`'s recvfrom-per-packet model.
- **etherparse overhead** is ~0.4–0.9 `cpu_us/frame` (parse + classify + IPv4 checksum). Notably,
`ring` *plus* full etherparse parsing is still cheaper than raw libpcap capture alone.