Skip to content

Batch UDP send with sendmmsg to cut per-packet syscall overhead (server) #3823

Description

@mcfnord

Split out from the profiling discussion in #3662 (the MixEncodeTransmitData refactor thread) at @ann0see's request. This is the UDP-send finding from that profiling — out of scope for the mixer refactor, but a larger lever.

Context

While profiling the headless server for the mixer vectorization work (#3662 / #3739), the bigger surprise was where the rest of the CPU goes. Once the mix loop is vectorized, a large share of server CPU is the UDP socket path, not the audio code — and the send side scales with packet count × client count.

The server issues one sendto per client per frame: about N sends per frame for N connected clients. The payloads are tiny (tens to a few hundred bytes), so the fixed per-syscall cost (user/kernel mode switch, socket lookup, UDP framing) dominates the actual data movement. At N=50 that is ~50 send syscalls per frame, tens of thousands per second, most of it fixed overhead that grows linearly with the number of clients.

Proposal

Batch the per-frame outgoing packets with the Linux sendmmsg(2) call. The server already produces one outgoing packet per client per frame in CServer::MixEncodeTransmitData; instead of sending each one immediately, queue them and emit the whole frame with a single sendmmsg at the end of OnTimer. Each mmsghdr carries its own destination msg_name, so the different client addresses are handled in one call. This turns N send syscalls per frame into 1.

Measured result (working prototype)

I built a prototype and measured it. Batch storage + one sendmmsg flush per frame at the CSocket layer; CChannel::PrepAndSendPacket routes server sends into the batch, the client stays per-packet, and non-Linux platforms fall back to the existing per-packet path (compile-guarded).

Load = 20 synthetic tone clients, server pinned to 1 core (AMD EPYC 7R13, Zen3), Linux, GCC 13.3, on top of the vectorized mixer (#3739). Metric = server-process task-clock (all threads) over a fixed 40 s window of steady load; 6 interleaved runs per build; syscall counts from perf tracepoints.

build server CPU / 40 s as CPUs send syscalls / 40 s
baseline (#3739) 12.50 s ± 0.20 0.313 152,986 sendto, 0 sendmmsg
+ sendmmsg batch 11.12 s ± 0.11 0.278 2,980 sendto, 15,000 sendmmsg
  • Server CPU −11% at 20 clients (1.38 s less CPU per 40 s of load). The two arms do not overlap: min baseline > max batch, gap ≈ 7× the baseline sd.
  • Audio send syscalls 150k → 15k (10× fewer) — exactly one sendmmsg per 2.67 ms frame. The ~3k residual sendto are non-audio protocol/ping sends, left per-packet on purpose.
  • Load identical (20 connected every run); audio is byte-identical (the same packet bytes go to the same destinations, only the syscall boundary moves to end-of-frame).

A follow-up profile of the batched build confirms the mechanism: the send path drops to ~6% of server CPU (FlushSendBatch → a single __sys_sendmmsg per frame), and Opus encode/decode becomes the dominant remaining cost.

The saving grows with client count — the collapse factor is ~N, so a 50-client server sees a bigger cut than the 10× here. This is a larger lever than the mixer work because it scales with the thing that hurts big servers.

Scope / caveats

  • sendmmsg is Linux-specific. macOS and Windows keep the current per-packet path via a compile guard — no behaviour change there. The Linux server (the common self-hosted / public-server case) is what benefits.
  • Touches CSocket (batch buffer + flush), the one send call site in CChannel::PrepAndSendPacket, and one flush call in CServer::OnTimer. No audio math changes, so bit-exactness is not a concern; the risk surface is socket handling (partial sends, EINTR, per-message errors in the mmsghdr array — the prototype handles the first two and drops the tail of a frame on a hard per-message error, which UDP / the jitter buffer already tolerate).
  • Receive batching with recvmmsg is a possible follow-up, but the current receive loop already drains the socket to EAGAIN per wakeup, so the send side is the clear first win. Left out of this change to keep it small.

I have the patch and the perf harness; happy to open a PR against this, or iterate on the design here first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions