perf(mpsc): batch writev flush (threshold=8) for TCP — +7% pps

Add configurable batch flush threshold to SpinSink. When threshold > 1,
MpscTunnelSender::send accumulates packets in FramedWriter's BufList
without flushing. After N packets, poll_flush triggers a single writev()
syscall instead of N individual write() syscalls.

Implementation:
- SpinSink: pending_count + batch_threshold atomics
- MpscTunnelSender::send: flush every N packets via writev
- Default threshold=1 (per-packet flush, safe for handshake/control)
- Settable via set_batch_threshold() through PeerConn → Peer → PeerManager
- Bench: HOTPATH_BATCH env var, set after convergence

Batch threshold must be 1 during handshake (control packets are
request-response, can't be delayed). Bench sets threshold=8 only after
routes converge.

Benchmark (no hotpath, 3 runs avg):
  TCP batch=1:  985K pps
  TCP batch=8:  1,053K pps (+7%)
  Ring:         unchanged (flush is no-op for RingSink)
  UDP:          unchanged (flush is no-op for RingSink)

MpscTunnelSender::send avg: 343ns → 213ns (-38%, with hotpath) —
writev writes 8 Bytes in one syscall vs 8 write() calls.

All 210 peers tests pass. 6 netns tests fail (require root, unchanged).
This commit is contained in:
fanyang
2026-06-29 02:58:08 +08:00
parent 0e665eafc6
commit 90c45d2964
5 changed files with 60 additions and 11 deletions
+11
View File
@@ -126,6 +126,17 @@ async fn main() {
let pm = inst_a.get_peer_manager();
let send_pkt = make_data_packet(src, "10.144.144.2", pkt_size);
let batch_threshold: u32 = std::env::var("HOTPATH_BATCH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
// After convergence, enable batch flush for writev optimization
if converged && batch_threshold > 1 {
pm.set_peer_conn_batch_threshold(batch_threshold);
println!("cpu_hotspot_ring: batch_threshold={}", batch_threshold);
}
let pipeline_depth: usize = std::env::var("HOTPATH_PIPELINE")
.ok()
.and_then(|s| s.parse().ok())
+6
View File
@@ -268,6 +268,12 @@ impl Peer {
self.default_conn_id.load()
}
pub fn set_batch_threshold(&self, n: u32) {
for conn in self.conns.iter() {
conn.value().set_batch_threshold(n);
}
}
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
self.peer_identity_type.load()
}
+4
View File
@@ -451,6 +451,10 @@ impl PeerConn {
self.conn_id
}
pub fn set_batch_threshold(&self, n: u32) {
self.sink.set_batch_threshold(n);
}
pub fn set_is_hole_punched(&mut self, is_hole_punched: bool) {
self.is_hole_punched = is_hole_punched;
}
+12
View File
@@ -1994,6 +1994,18 @@ impl PeerManager {
self.peers.clone()
}
pub fn set_peer_conn_batch_threshold(&self, n: u32) {
let peers = self.peers.clone();
tokio::spawn(async move {
let peer_ids = peers.list_peers();
for peer_id in peer_ids {
if let Some(peer) = peers.get_peer_by_id(peer_id) {
peer.set_batch_threshold(n);
}
}
});
}
pub fn get_relay_peer_map(&self) -> Arc<RelayPeerMap> {
self.relay_peer_map.clone()
}
+27 -11
View File
@@ -4,7 +4,7 @@ use std::{
cell::UnsafeCell,
pin::Pin,
sync::Arc,
sync::atomic::{AtomicBool, Ordering},
sync::atomic::{AtomicBool, AtomicU32, Ordering},
task::Poll,
time::Duration,
};
@@ -26,6 +26,8 @@ use futures::SinkExt;
struct SpinSink {
locked: AtomicBool,
sink: UnsafeCell<Pin<Box<dyn ZCPacketSink>>>,
pending_count: AtomicU32,
batch_threshold: AtomicU32,
}
// SAFETY: access is serialized by the spinlock.
@@ -55,9 +57,15 @@ impl SpinSink {
Self {
locked: AtomicBool::new(false),
sink: UnsafeCell::new(sink),
pending_count: AtomicU32::new(0),
batch_threshold: AtomicU32::new(1),
}
}
fn set_batch_threshold(&self, n: u32) {
self.batch_threshold.store(n, Ordering::Relaxed);
}
fn try_lock(&self) -> Option<SpinGuard<'_>> {
if self
.locked
@@ -89,17 +97,19 @@ impl MpscTunnelSender {
match guard.as_mut().poll_ready(&mut cx) {
Poll::Ready(Ok(())) => {
guard.as_mut().start_send(item)?;
if self.direct_batch_flush {
// RingSink: flush is no-op, data already in ring buffer.
// Skip to allow poll_ready batching at max_buffer_count.
return Ok(());
}
// FramedWriter (TCP): must flush per-packet, otherwise
// noop_waker can't wake when socket is full.
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()),
let count = sink.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
let threshold = sink.batch_threshold.load(Ordering::Relaxed);
if count >= threshold {
sink.pending_count.store(0, Ordering::Relaxed);
// Batch flush: writev all accumulated BufList entries.
// RingSink: no-op. FramedWriter: single writev syscall.
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()),
}
}
// Accumulate in BufList, no flush yet
return Ok(());
}
Poll::Ready(Err(e)) => return Err(e),
Poll::Pending => return Err(TunnelError::BufferFull),
@@ -120,6 +130,12 @@ impl MpscTunnelSender {
})
}
pub fn set_batch_threshold(&self, n: u32) {
if let Some(sink) = &self.direct_sink {
sink.set_batch_threshold(n);
}
}
pub async fn send_async(&self, item: ZCPacket) -> Result<(), TunnelError> {
let tx = self.channel_tx.as_ref().ok_or(TunnelError::Shutdown)?;
match tx.try_send(item) {