Files
fips/src/perf_profile.rs
T
Martti MalmiandJohnathan Corgan 0a5c367edc data-plane perf overhaul: off-task encrypt + decrypt, GSO, connected UDP
Moves both AEAD layers (ChaCha20-Poly1305, one round per layer per
packet) plus the sendmsg syscall off the rx_loop task onto a per-shard
worker pool, adds per-peer connect(2)-ed UDP with SO_REUSEPORT, and
uses Linux UDP GSO (sendmsg+UDP_SEGMENT — kernel splits one super-skb
into N on-the-wire datagrams in a single TX-stack walk) when packets
in a batch are uniform-size. Same kernel primitive WireGuard's
in-kernel module and BoringTun use to hit 2.5–3.2 Gbps single-stream.

Single TCP stream on a 5-node docker-bridge mesh, 5 x 15 s x P=1:

  A→D:  1379 → 2708 Mbps  (1.96x, RTT +0.12 ms)
  A→E:  1394 → 2663 Mbps  (1.91x, RTT +0.11 ms)
  E→A:  1406 → 2624 Mbps  (1.87x, RTT +0.19 ms)

Static-peer pairs only — every CoV under 3%, 0 outliers, 0% ICMP
loss. The ~+100 µs RTT is the worker queue handoff cost; AEAD +
sendmmsg now run on a separate core in exchange.

What lands:

- src/node/encrypt_worker.rs: std::thread + crossbeam_channel
  workers; hash-by-destination dispatch pins a TCP flow to one
  worker so wire ordering is preserved; per-worker sendmmsg(2)
  batching up to 32; Linux uses sendmsg(2)+UDP_SEGMENT when
  packets in a group are uniform-size.

- src/node/decrypt_worker.rs: receive-side mirror. Each shard owns
  its session's recv cipher + replay window in a thread-local
  HashMap (no shared RwLock/Mutex). Sessions are handed off at
  promote_connection and re-registered on K-bit flip / rekey
  cutover.

- src/node/handlers/session.rs try_send_session_data_pipelined:
  FSP+FMP both seal in-place in the worker on one wire-buffer
  alloc; no intermediate inner_plaintext / fsp_payload Vecs.

- src/transport/udp/connected_peer.rs + peer_drain.rs: per-peer
  connect(2)-ed UDP socket with SO_REUSEPORT (set on the listen
  socket too — without that, EADDRINUSE on activation and every
  packet falls back to the wildcard path); the worker sends with
  msg_name=NULL and the kernel uses its cached 5-tuple. Tick-
  driven activation in handlers/connected_udp.rs, idempotent.

- src/transport/udp/mod.rs: mem::replace the recvmmsg backing buffer
  instead of buf.to_vec() per packet — single pointer swap, no
  MTU-sized memcpy.

- src/protocol/link.rs SessionDatagramRef: zero-copy borrowed view
  used by handle_session_datagram for the bulk local-delivery
  path; handle_session_payload takes the borrowed payload
  directly (no payload[35..].to_vec()).

- src/transport/mod.rs TransportAddr::from_socket_addr: collapses
  the two-alloc from_string(addr.to_string()) pattern to one.

- src/node/handlers/rx_loop.rs: decrypt-fallback drain promoted
  ahead of packet_rx in the select! (TCP ACK starvation fix);
  interleaved fallback drain every 32 packets inside the rx burst
  loop.

- noise::Session: send_cipher_clone / recv_cipher_clone /
  recv_replay_snapshot_owned / take_send_counter / accept_replay
  so off-task workers can hold a cloned cipher + reserved counter
  while the dispatcher keeps replay/counter sequencing serial.
  CipherState::cipher_clone returns a refcount-bumped LessSafeKey.
  AsyncUdpSocket: AsRawFd so workers issue raw sendmmsg / sendmsg
  without going through the tokio reactor.

- Worker pool sizing: both default to num_cpus, overridable via
  FIPS_ENCRYPT_WORKERS=N / FIPS_DECRYPT_WORKERS=N. Per-peer
  connected UDP can be disabled via FIPS_CONNECTED_UDP=0.

- src/perf_profile.rs: optional per-stage timing reporter under
  FIPS_PERF=1 (or FIPS_PIPELINE_TRACE=1). Off by default; zero
  overhead when disabled.

- All cfg(unix)-gated. Windows continues on the existing tokio-
  based send/recv.

Decrypt worker session lifecycle:

- Node::unregister_decrypt_worker_session mirrors the existing
  register helper. Wired at the two natural sites that already
  iterate peers_by_index: the rekey drain-completion block in
  handlers/rekey.rs (drops the worker entry for the old our_index
  once the drain window has expired and the cache_key is
  unreachable to any in-flight OLD-K packet), and remove_active_peer
  in handlers/dispatch.rs (drops the worker entry for each of the
  four index slots: current, rekey, pending, previous). Only
  our_index is normally registered; unregister_session is fire-
  and-forget for missing entries, so calling unconditionally on
  all four slots is correct and bounds the cleanup without per-
  slot accounting. Without these callers the per-worker sessions
  HashMap and the Node's decrypt_registered_sessions set would
  grow monotonically per rekey on long-lived peers.

Testing:

- testing/static/scripts/bench-multirun.sh: multi-run iperf3 +
  ping bench. N reruns (default 5), median / min / max / CoV % /
  per-run outlier flag, avg ping RTT, ICMP loss %, TCP retransmit
  total. Plain client→dest labels + topology header. Pre-bench
  peer-convergence check (FIPS_BENCH_CONVERGE_SECS, default 15);
  per-path route verification via stats.bytes_sent deltas — fails
  fast if traffic exits via a non-static-peer link.

- testing/static/docker-compose.yml: passes FIPS_ENCRYPT_WORKERS /
  FIPS_DECRYPT_WORKERS / FIPS_PERF through to containers for A/B
  benchmarking without rebuilds.

- testing/static/scripts/iperf-test.sh: same plain client→dest
  labels + topology header (was multihop/direct/N hop, which
  conflated topology distance with on-wire path).

- .config/nextest.toml: synthetic UDP node tests serialized
  through a max-threads=1 test group. Localhost handshakes drop
  on shared CI runners under parallel load; one-at-a-time keeps
  assertions reliable.

- src/node/tests/spanning_tree.rs: repair_missing_edge_handshakes
  — retries up to 5 times for synthetic edges whose msg1 was
  dropped, with a drain after each edge retry instead of after
  each attempt's full burst.

- src/node/decrypt_worker.rs::tests: two unit tests asserting
  WorkerMsg::UnregisterSession removes the worker-thread session
  HashMap entry (handle_msg_unregister_session_removes_entry) and
  is a no-op for never-seen cache_keys
  (handle_msg_unregister_session_idempotent_on_unknown_key), which
  is the safety invariant the unconditional unregister calls at
  the four index slots in remove_active_peer rely on.

- src/node/encrypt_worker.rs::unix_tests
  pipelined_send_wire_layout_roundtrips_canonical_decoders: mirrors
  the encoder geometry of try_send_session_data_pipelined (no
  coords, the common established-session path), runs the worker's
  real seal + send via flush_direct_batch_sync, and decodes the
  resulting wire packet using only canonical receive-side decoders
  (EncryptedHeader::parse, SessionDatagramRef::decode, FSP header
  parse, noise::open). Any divergence between the hand-rolled
  encoder offsets (fsp_aad_offset, fsp_plaintext_offset) and the
  decoders fails at one of the parse / open / decode steps before
  the inner-plaintext assertion fires. Complements the existing
  fsp_preseal_runs_before_outer_fmp_seal test which covers the
  seal-ordering invariant with synthetic headers but does not
  exercise the wire-layout invariant.

CHANGELOG.md [Unreleased] # Changed entry added describing the
worker-pool threading model, hash-by-destination dispatch,
sendmmsg/UDP_GSO, per-peer connected UDP, the operator-facing env
vars, and the bench numbers above.

Cherry-picks from mmalmi/master (paths translated from
crates/fips-core/src/ to src/): 9b7c723, 0deb5cb, 13f7339, e036c0e,
3740a68, 3792f83, 8510193, 4910b07, e53f545, e4e2896, 5fe4af5,
1d01ada, 8c37008, e12469e, 6eb2860.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-05-19 20:53:31 +00:00

403 lines
14 KiB
Rust

// Some entry points (e.g. `stamp`, `record_since`) are only called from
// paths that aren't yet wired up in this PR (FSP-pipelined dispatch,
// per-stage worker telemetry). Keep them in tree so the follow-up wiring
// PR is a pure call-site change.
#![allow(dead_code)]
//! Runtime perf profiler for the FMP/FSP hot path and queue handoffs.
//!
//! Avoids external dependencies (`perf`, samply, etc.) by instrumenting
//! the key stages directly with `AtomicU64` ns counters, histograms,
//! and packet counts. A background task prints a per-stage breakdown
//! every `FIPS_PERF_INTERVAL_SECS` seconds when `FIPS_PERF=1` or
//! `FIPS_PIPELINE_TRACE=1` is set at runtime.
//!
//! Enabling adds `Instant::now()` plus a few relaxed atomics per
//! measured stage, so the measured numbers are slightly pessimistic vs
//! production. The relative picture is the point: it shows whether a
//! run is spending time in crypto, syscalls, or scheduler/channel
//! waits.
//!
//! Stages tracked, inbound:
//! * `UDP_RECV` — recvmmsg syscall + per-message buffer copy
//! * `FMP_DECRYPT` — outer AEAD open + replay window
//! * `LINK_DISPATCH` — `dispatch_link_message` excluding FSP work
//! * `FSP_DECRYPT` — inner AEAD open + replay window
//! * `TUN_WRITE` — IPv6 shim decompress + tun_tx.send
//!
//! Stages tracked, outbound:
//! * `FSP_ENCRYPT` — inner AEAD seal (`send_session_data`)
//! * `FMP_ENCRYPT` — outer AEAD seal (`send_encrypted_link_message`)
//! * `UDP_SEND` — sendmmsg/sendmsg/sendto flush
//!
//! Handoff waits tracked:
//! * `TRANSPORT_QUEUE_WAIT` — UDP/transport receive loop → rx_loop
//! * `ENDPOINT_COMMAND_WAIT` — FipsEndpoint send → node command loop
//! * `FMP_WORKER_QUEUE_WAIT` — rx_loop FMP job dispatch → worker
//! * `ENDPOINT_EVENT_WAIT` — rx_loop endpoint delivery → endpoint recv
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::time::Instant;
/// Number of measurement buckets. Indices match `Stage`.
const N_STAGES: usize = 16;
const N_EVENTS: usize = 6;
const HIST_BUCKETS: usize = 48;
/// Stage identifier. `as usize` indexes into the counter arrays.
#[derive(Copy, Clone, Debug)]
#[repr(usize)]
pub enum Stage {
UdpRecv = 0,
FmpDecrypt = 1,
LinkDispatch = 2,
FspDecrypt = 3,
TunWrite = 4,
FspEncrypt = 5,
FmpEncrypt = 6,
UdpSend = 7,
/// Whole `Node::process_packet` body. Anchor for "what fraction of
/// the receive hot path is in the non-AEAD parts of the pipeline".
ProcessPacket = 8,
/// Just the `endpoint_event_tx.send()` for inbound application
/// payloads — wakes the embedded-endpoint consumer task.
EndpointDeliver = 9,
/// Whole `handle_encrypted_session_msg` (FSP receive path) minus
/// the `FspDecrypt` sub-span. Surfaces dispatch + ipv6_shim +
/// `Vec::drain` cost on the inner session layer.
FspHandle = 10,
/// Whole `handle_endpoint_data_command` body — the SENDER's
/// per-packet "do everything to push one outbound packet"
/// dispatch. Compare against the sum of `FspEncrypt`,
/// `FmpEncrypt`, and `UdpSend` to see how much of the sender
/// hot path is in state-touching dispatch (sessions/peers
/// lookups, MMP/stats updates, Vec allocs) vs the AEAD/syscall
/// work that's a natural fit for an off-task worker.
EndpointSend = 11,
/// Time spent waiting after `FipsEndpoint::send`/`blocking_send`
/// creates a node command until `rx_loop` starts handling it.
EndpointCommandWait = 12,
/// Time spent waiting after `rx_loop` creates an FMP encrypt/send
/// worker job until the worker thread starts encrypting it.
FmpWorkerQueueWait = 13,
/// Time spent waiting after a transport receives a packet until
/// `rx_loop` starts processing it.
TransportQueueWait = 14,
/// Time spent waiting after `rx_loop` delivers endpoint data until
/// the embedded endpoint consumer receives it.
EndpointEventWait = 15,
}
impl Stage {
const fn name(self) -> &'static str {
match self {
Stage::UdpRecv => "udp_recv",
Stage::FmpDecrypt => "fmp_decrypt",
Stage::LinkDispatch => "link_dispatch",
Stage::FspDecrypt => "fsp_decrypt",
Stage::TunWrite => "tun_write",
Stage::FspEncrypt => "fsp_encrypt",
Stage::FmpEncrypt => "fmp_encrypt",
Stage::UdpSend => "udp_send",
Stage::ProcessPacket => "process_packet",
Stage::EndpointDeliver => "endpoint_deliver",
Stage::FspHandle => "fsp_handle",
Stage::EndpointSend => "endpoint_send",
Stage::EndpointCommandWait => "endpoint_command_wait",
Stage::FmpWorkerQueueWait => "fmp_worker_queue_wait",
Stage::TransportQueueWait => "transport_queue_wait",
Stage::EndpointEventWait => "endpoint_event_wait",
}
}
}
fn stage_from_index(idx: usize) -> Stage {
match idx {
0 => Stage::UdpRecv,
1 => Stage::FmpDecrypt,
2 => Stage::LinkDispatch,
3 => Stage::FspDecrypt,
4 => Stage::TunWrite,
5 => Stage::FspEncrypt,
6 => Stage::FmpEncrypt,
7 => Stage::UdpSend,
8 => Stage::ProcessPacket,
9 => Stage::EndpointDeliver,
10 => Stage::FspHandle,
11 => Stage::EndpointSend,
12 => Stage::EndpointCommandWait,
13 => Stage::FmpWorkerQueueWait,
14 => Stage::TransportQueueWait,
15 => Stage::EndpointEventWait,
_ => unreachable!(),
}
}
/// Count-only events that clarify which hot-path variant is active.
#[derive(Copy, Clone, Debug)]
#[repr(usize)]
pub enum Event {
UdpSendConnected = 0,
UdpSendWildcard = 1,
UdpSendBackpressure = 2,
ConnectedUdpInstalled = 3,
ConnectedUdpActivationFailed = 4,
UdpSendBackpressureSleep = 5,
}
impl Event {
const fn name(self) -> &'static str {
match self {
Event::UdpSendConnected => "udp_send_connected",
Event::UdpSendWildcard => "udp_send_wildcard",
Event::UdpSendBackpressure => "udp_send_backpressure",
Event::ConnectedUdpInstalled => "connected_udp_installed",
Event::ConnectedUdpActivationFailed => "connected_udp_activation_failed",
Event::UdpSendBackpressureSleep => "udp_send_backpressure_sleep",
}
}
}
fn event_from_index(idx: usize) -> Event {
match idx {
0 => Event::UdpSendConnected,
1 => Event::UdpSendWildcard,
2 => Event::UdpSendBackpressure,
3 => Event::ConnectedUdpInstalled,
4 => Event::ConnectedUdpActivationFailed,
5 => Event::UdpSendBackpressureSleep,
_ => unreachable!(),
}
}
static TOTAL_NS: [AtomicU64; N_STAGES] = [const { AtomicU64::new(0) }; N_STAGES];
static COUNT: [AtomicU64; N_STAGES] = [const { AtomicU64::new(0) }; N_STAGES];
static MAX_NS: [AtomicU64; N_STAGES] = [const { AtomicU64::new(0) }; N_STAGES];
static HIST: [AtomicU64; N_STAGES * HIST_BUCKETS] =
[const { AtomicU64::new(0) }; N_STAGES * HIST_BUCKETS];
static EVENTS: [AtomicU64; N_EVENTS] = [const { AtomicU64::new(0) }; N_EVENTS];
/// True iff perf/pipeline tracing is enabled. Read once at startup so
/// the per-packet check is a single cached load.
pub(crate) fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
["FIPS_PERF", "FIPS_PIPELINE_TRACE"].into_iter().any(|key| {
std::env::var(key)
.map(|s| s == "1" || s.eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
})
}
/// Capture a timestamp for a future queue-wait measurement. Returns
/// `None` when tracing is disabled so callers can store it cheaply in
/// packet/job structs without paying `Instant::now()` in production.
#[inline]
pub(crate) fn stamp() -> Option<Instant> {
if enabled() {
Some(Instant::now())
} else {
None
}
}
/// Record time elapsed since a previously captured stamp.
#[inline]
pub(crate) fn record_since(stage: Stage, start: Option<Instant>) {
if let Some(start) = start {
record(stage, start.elapsed().as_nanos() as u64);
}
}
/// Record `elapsed_ns` for the given stage. No-op when disabled.
pub fn record(stage: Stage, elapsed_ns: u64) {
if !enabled() {
return;
}
let idx = stage as usize;
let elapsed_ns = elapsed_ns.max(1);
TOTAL_NS[idx].fetch_add(elapsed_ns, Relaxed);
COUNT[idx].fetch_add(1, Relaxed);
MAX_NS[idx].fetch_max(elapsed_ns, Relaxed);
HIST[(idx * HIST_BUCKETS) + bucket_for_ns(elapsed_ns)].fetch_add(1, Relaxed);
}
#[inline]
pub fn record_event(event: Event) {
record_event_count(event, 1);
}
pub fn record_event_count(event: Event, count: u64) {
if !enabled() || count == 0 {
return;
}
EVENTS[event as usize].fetch_add(count, Relaxed);
}
/// RAII timer — `drop` records the elapsed time into the stage.
/// Use:
/// ```ignore
/// let _t = profile::Timer::start(Stage::FmpDecrypt);
/// // ... AEAD work ...
/// ```
pub struct Timer {
stage: Stage,
start: Option<Instant>,
}
impl Timer {
#[inline]
pub fn start(stage: Stage) -> Self {
let start = if enabled() {
Some(Instant::now())
} else {
None
};
Self { stage, start }
}
}
impl Drop for Timer {
fn drop(&mut self) {
if let Some(t0) = self.start {
let ns = t0.elapsed().as_nanos() as u64;
record(self.stage, ns);
}
}
}
/// Spawn a background task that prints a per-stage breakdown every
/// `FIPS_PERF_INTERVAL_SECS` seconds (default 5). Idempotent — only
/// the first call spawns. No-op when profiling isn't enabled.
pub fn maybe_spawn_reporter() {
if !enabled() {
return;
}
static STARTED: OnceLock<()> = OnceLock::new();
if STARTED.set(()).is_err() {
return;
}
let interval = std::env::var("FIPS_PERF_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(5)
.max(1);
tokio::spawn(async move {
let mut prev_total = [0u64; N_STAGES];
let mut prev_count = [0u64; N_STAGES];
let mut prev_hist = [0u64; N_STAGES * HIST_BUCKETS];
let mut prev_events = [0u64; N_EVENTS];
loop {
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
let mut line = format!("[pipe {}s]", interval);
for i in 0..N_STAGES {
let t = TOTAL_NS[i].load(Relaxed);
let c = COUNT[i].load(Relaxed);
let dt = t.saturating_sub(prev_total[i]);
let dc = c.saturating_sub(prev_count[i]);
prev_total[i] = t;
prev_count[i] = c;
let base = i * HIST_BUCKETS;
let mut hist_delta = [0u64; HIST_BUCKETS];
for (bucket, delta) in hist_delta.iter_mut().enumerate().take(HIST_BUCKETS) {
let idx = base + bucket;
let current = HIST[idx].load(Relaxed);
*delta = current.saturating_sub(prev_hist[idx]);
prev_hist[idx] = current;
}
if dc == 0 {
continue;
}
let stage = stage_from_index(i);
let avg_ns = if dc > 0 { dt / dc } else { 0 };
let pps = if interval > 0 { dc / interval } else { 0 };
let p50 = percentile_ns(&hist_delta, dc, 50);
let p95 = percentile_ns(&hist_delta, dc, 95);
let p99 = percentile_ns(&hist_delta, dc, 99);
let approx_max = interval_max_ns(&hist_delta);
let lifetime_max = MAX_NS[i].load(Relaxed);
line.push_str(&format!(
" {}={}/s avg={} p50<={} p95<={} p99<={} max<={} allmax={}",
stage.name(),
pps,
fmt_ns(avg_ns),
fmt_ns(p50),
fmt_ns(p95),
fmt_ns(p99),
fmt_ns(approx_max),
fmt_ns(lifetime_max),
));
}
for i in 0..N_EVENTS {
let current = EVENTS[i].load(Relaxed);
let delta = current.saturating_sub(prev_events[i]);
prev_events[i] = current;
if delta == 0 {
continue;
}
let event = event_from_index(i);
let per_sec = delta / interval;
line.push_str(&format!(" {}={}/s", event.name(), per_sec));
}
// eprintln so it always lands regardless of RUST_LOG.
eprintln!("{}", line);
}
});
}
fn bucket_for_ns(ns: u64) -> usize {
if ns <= 1 {
return 0;
}
((u64::BITS - (ns - 1).leading_zeros()) as usize).min(HIST_BUCKETS - 1)
}
fn bucket_upper_ns(bucket: usize) -> u64 {
if bucket == 0 {
1
} else if bucket >= 63 {
u64::MAX
} else {
1u64 << bucket
}
}
fn percentile_ns(hist_delta: &[u64; HIST_BUCKETS], total: u64, pct: u64) -> u64 {
if total == 0 {
return 0;
}
let target = total.saturating_mul(pct).saturating_add(99) / 100;
let mut seen = 0u64;
for (idx, count) in hist_delta.iter().enumerate() {
seen = seen.saturating_add(*count);
if seen >= target {
return bucket_upper_ns(idx);
}
}
bucket_upper_ns(HIST_BUCKETS - 1)
}
fn interval_max_ns(hist_delta: &[u64; HIST_BUCKETS]) -> u64 {
for idx in (0..HIST_BUCKETS).rev() {
if hist_delta[idx] != 0 {
return bucket_upper_ns(idx);
}
}
0
}
fn fmt_ns(ns: u64) -> String {
if ns >= 1_000_000_000 {
format!("{:.1}s", ns as f64 / 1_000_000_000.0)
} else if ns >= 1_000_000 {
format!("{:.1}ms", ns as f64 / 1_000_000.0)
} else if ns >= 1_000 {
format!("{:.1}us", ns as f64 / 1_000.0)
} else {
format!("{ns}ns")
}
}