ferranet 0.2.0

A modern, async-first, zero-copy datalink-layer (L2) networking library
Documentation
# 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`)

| pps regime          | ring  | pcap  | basic | pnet |
|---------------------|------:|------:|------:|-----:|
| ~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.