feat(1gbps): batched client recv via recvmmsg (increment C)
ci / rust (push) Has been cancelled

Final increment of the 1 Gbps data-plane rework — the recv counterpart of the
sendmmsg work. The client recv path did one recvfrom + one Vec allocation per
packet (and the pump's 300µs idle sleep could let packets pile up at line rate).

- Transport gains recv_batch(&mut [Vec<u8>], &mut [usize]) -> count; default is
  a single scalar recv into out[0] (loopback + non-Linux).
- UdpTransport overrides it on Linux with recvmmsg (MSG_DONTWAIT) draining up to
  N datagrams per syscall into the caller's reused buffers — no per-packet alloc.
- Session::poll_frame owns a lazily-allocated recv ring (RECV_BATCH=32) and
  consumes it one packet at a time across calls, refilling with one recvmmsg when
  drained. Encapsulated: the punktfunk-client-rs + NativeClient pumps are
  unchanged, and draining a batch per syscall means the 300µs sleep no longer
  underdrains. Added UdpTransport::local_addr (used by the test, generally handy).

~125k → ~4k recv syscalls/sec at line rate, zero per-packet recv allocation.
Verified: new recv_batch_drains_over_loopback test (50 datagrams drained intact
via recvmmsg) + the existing loopback round-trip now runs through the batched
poll_frame; full suite (35 + round-trip + 6) + clippy + fmt green.

Decode-in-place (kill the per-packet open_from_wire alloc) is a separate later
optimization. With A (sendmmsg) + B (paced send) + C (recvmmsg), the native data
plane is batched + paced end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:39:51 +00:00
parent 10a932d013
commit 2f4f92a804
3 changed files with 166 additions and 10 deletions
+39 -6
View File
@@ -13,7 +13,7 @@ use crate::crypto::SessionCrypto;
use crate::error::{PunktfunkError, Result};
use crate::fec::{coder_for, ErasureCoder};
use crate::input::InputEvent;
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits};
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport;
@@ -43,8 +43,20 @@ pub struct Session {
stats: StatsCounters,
/// Monotonic wire sequence, also the AES-GCM nonce counter.
next_seq: u64,
/// Client recv ring (reused across [`poll_frame`](Self::poll_frame)): `recvmmsg` drains a batch
/// of datagrams into `recv_scratch` in one syscall, and poll_frame consumes them one at a time
/// across calls (`recv_idx`..`recv_count`), refilling when drained. Allocated lazily on the
/// first client poll so host sessions don't carry it. No per-packet recv alloc at line rate.
recv_scratch: Vec<Vec<u8>>,
recv_lens: Vec<usize>,
recv_count: usize,
recv_idx: usize,
}
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
/// pkt/s this is ~4k syscalls/s instead of 125k; the buffers cost `RECV_BATCH × RECV_BUF` (~64 KB).
const RECV_BATCH: usize = 32;
impl Session {
pub fn new(config: Config, transport: Box<dyn Transport>) -> Result<Session> {
config.validate()?;
@@ -62,6 +74,10 @@ impl Session {
reassembler,
stats: StatsCounters::default(),
next_seq: 0,
recv_scratch: Vec::new(),
recv_lens: Vec::new(),
recv_count: 0,
recv_idx: 0,
config,
})
}
@@ -193,12 +209,29 @@ impl Session {
"poll_frame called on a host session",
));
}
// Lazily allocate the recv ring on first client poll (host sessions never get here).
if self.recv_scratch.is_empty() {
// Each buffer holds a max datagram + 1 (an oversized read fills it → reassembler rejects).
self.recv_scratch = (0..RECV_BATCH)
.map(|_| vec![0u8; MAX_DATAGRAM_BYTES + 1])
.collect();
self.recv_lens = vec![0usize; RECV_BATCH];
}
loop {
let wire = match self.transport.recv()? {
Some(w) => w,
None => return Err(PunktfunkError::NoFrame),
};
let pkt = match self.open_from_wire(&wire) {
// Refill the ring with one `recvmmsg` batch when the current one is drained.
if self.recv_idx >= self.recv_count {
self.recv_count = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
self.recv_idx = 0;
if self.recv_count == 0 {
return Err(PunktfunkError::NoFrame);
}
}
let i = self.recv_idx;
self.recv_idx += 1;
let len = self.recv_lens[i];
let pkt = match self.open_from_wire(&self.recv_scratch[i][..len]) {
Ok(p) => p,
Err(_) => continue,
};