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.
Split out from the profiling discussion in #3662 (the
MixEncodeTransmitDatarefactor 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
sendtoper client per frame: aboutNsends per frame forNconnected 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 inCServer::MixEncodeTransmitData; instead of sending each one immediately, queue them and emit the whole frame with a singlesendmmsgat the end ofOnTimer. Eachmmsghdrcarries its own destinationmsg_name, so the different client addresses are handled in one call. This turnsNsend syscalls per frame into 1.Measured result (working prototype)
I built a prototype and measured it. Batch storage + one
sendmmsgflush per frame at theCSocketlayer;CChannel::PrepAndSendPacketroutes 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 fromperftracepoints.sendto, 0sendmmsgsendmmsgbatchsendto, 15,000sendmmsgsendmmsgper 2.67 ms frame. The ~3k residualsendtoare non-audio protocol/ping sends, left per-packet on purpose.A follow-up profile of the batched build confirms the mechanism: the send path drops to ~6% of server CPU (
FlushSendBatch→ a single__sys_sendmmsgper 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
sendmmsgis 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.CSocket(batch buffer + flush), the one send call site inCChannel::PrepAndSendPacket, and one flush call inCServer::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 themmsghdrarray — 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).recvmmsgis a possible follow-up, but the current receive loop already drains the socket toEAGAINper 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
perfharness; happy to open a PR against this, or iterate on the design here first.