Skip to content

Optimise allocation patterns for recvmmsg - #11387

Open
NelsonVides wants to merge 3 commits into
erlang:masterfrom
NelsonVides:esock/recvmmsg-buffer-pool
Open

Optimise allocation patterns for recvmmsg#11387
NelsonVides wants to merge 3 commits into
erlang:masterfrom
NelsonVides:esock/recvmmsg-buffer-pool

Conversation

@NelsonVides

@NelsonVides NelsonVides commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Reduces per-call allocation on the socket recvmmsg path. These optimise the case when batches aren't saturated, that is, when we request 64 datagrams but actually receive 3 or 4, we skip preparing the work for the requested 64 datagrams as it is currently done and prepare only for the 3-4 received. In my load-tests saturated batches are so much faster than inet but when barely loaded, socket falls long behind.

Three commits:

  1. Per-thread scratch pool — instead of malloc/memzero/free-ing the vlen*(bufSz+ctrlSz)+metadata block every call (~197KB at vlen=64/2K/1K, even for one datagram), keep one grow-to-fit block per scheduler thread via TSD. No locking and memory bounded by scheduler-thread count, not socket count.
  2. O(messages) per-call setup — the stable mmsghdr/iovec fields are set once per layout; a matching reuse only restores the kernel/post-processing-mutated fields for the slots the previous call used. Drops the per-call memzero too. Setup cost is now O(messages received), not O(vlen).
  3. Right-size output binaries — allocate the data binary at msgLen (no realloc-down) and the control binary at the actual received length (no ~1 KB control alloc for data-only datagrams).

Testing: socket_SUITE recvmmsg cases pass, plus new recvmmsg_pool_reuse_udp4 (reuse/grow/varying-count) and recvmmsg_ctrl_udp4 (ip pktinfo control path — first non-data-only coverage). Each commit compiles standalone.

A benchmark on Linux 6.18, AMD Ryzen 9 9950X3D:

base : stock OTP-29.0.3 release  |  8.72s cpu, 143 MB rss
ours : OTP-30/erts-17.0.3        |  9.15s cpu, 172 MB rss

v1_small         v=1    buf=2048   ctrl=0    k=1    | ns/dg    790.0 ->    630.0  (1.25x)  | ns/call     790.0 ->     630.0
v64_1dg          v=64   buf=2048   ctrl=0    k=1    | ns/dg   1000.0 ->    740.0  (1.35x)  | ns/call    1000.0 ->     740.0
v256_1dg         v=256  buf=2048   ctrl=0    k=1    | ns/dg   2000.0 ->   1340.0  (1.49x)  | ns/call    2000.0 ->    1340.0
v1024_1dg        v=1024 buf=2048   ctrl=0    k=1    | ns/dg   3260.0 ->   1320.0  (2.47x)  | ns/call    3260.0 ->    1320.0
v64_64k_1dg      v=64   buf=65536  ctrl=0    k=1    | ns/dg    910.0 ->    690.0  (1.32x)  | ns/call     910.0 ->     690.0
v1024_64k_1dg    v=1024 buf=65536  ctrl=0    k=1    | ns/dg   3290.0 ->   1430.0  (2.30x)  | ns/call    3290.0 ->    1430.0
v64_full         v=64   buf=2048   ctrl=0    k=64   | ns/dg    377.3 ->    291.6  (1.29x)  | ns/call   24150.0 ->   18660.0
v256_full        v=256  buf=2048   ctrl=0    k=200  | ns/dg    503.2 ->    354.0  (1.42x)  | ns/call  100641.0 ->   70800.0
v8_ctrl          v=8    buf=2048   ctrl=512  k=1    | ns/dg    860.0 ->    660.0  (1.30x)  | ns/call     860.0 ->     660.0

where v is the requested vLen and k the actual number of datagrams.

essio_recvmmsg allocated (zeroed, and freed) the entire
vlen*(bufSz+ctrlSz) + metadata scratch block, plus a second block for the
result-term array, on every call. At vlen=64, bufSz=2048, ctrlSz=1024
that is ~197 KB of malloc+memzero+free to receive even one datagram.

Keep one grow-to-fit block per scheduler thread via thread-specific data.
A recvmmsg NIF call runs start-to-finish on a single scheduler thread and
uses the block only within that call (received data is copied out into
fresh binaries before returning), so no locking is needed and no two
calls ever touch the same block concurrently. Retained memory is bounded
by the number of scheduler threads that have run recvmmsg (normal +
dirty-IO), independent of the number of sockets. The result-term array is
carved out of the same block.

Add recvmmsg_pool_reuse_udp4 to socket_SUITE, exercising pool reuse and
growth across many calls with varying VLen/BufSz.
Now that the scratch block persists with a stable base pointer, the
"stable" mmsghdr/iovec fields (buffer pointers, iov, msg_name) only
change when the block moves or the per-slot dimensions (vlen, bufSz,
ctrlSz) change. Do the full O(vlen) setup loop only on such a (re)layout;
on a matching reuse, restore just the fields the kernel overwrites
(msg_namelen, msg_controllen, msg_flags, msg_len) and the one
post-processing overwrites (msg_control), and only for the leading slots
the previous call actually used. A grow of the block only happens when
total_sz (hence the dimensions) increased, so the layout check already
covers it and no separate signal is needed.

Also drop the per-call sys_memzero of the bufs/ctrls arrays: by keeping
the post-processing index at 0 until the allocation loop, the empty/error
paths free nothing and the success path only touches freshly allocated
slots, so the arrays never need pre-zeroing.

Together this makes the per-call cost O(messages received) rather than
O(vlen).
The result loop allocated a full bufSz data binary and a full ctrlSz
control binary for every datagram, then recv_create_bin realloced the
data binary down to the payload length. For a data-only receiver (e.g.
DNS) with the default 1024-byte control size and small payloads, that is
a ~2 KB alloc + realloc-down plus a wasted ~1 KB control alloc per
datagram -- the allocator was the largest remaining recv-side cost once
the scratch pool removed the per-call churn.

Since the payload and any cmsgs are copied out of the (reused) scratch
block, allocate the output binaries at exactly the received sizes:
msgLen for data and the actual control length for the control binary.
The data binary is then handed off by recv_create_bin without a realloc,
and a datagram with no ancillary data costs no control allocation at
all. The result terms are identical; msg_control still points at the
(now right-sized) control binary for cmsg decoding.
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

CT Test Results

    4 files    199 suites   1h 57m 3s ⏱️
3 372 tests 2 898 ✅ 472 💤 2 ❌
4 273 runs  3 722 ✅ 549 💤 2 ❌

For more details on these failures, see this check.

Results for commit 06eb668.

♻️ This comment has been updated with latest results.

To speed up review, make sure that you have read Contributing to Erlang/OTP and that all checks pass.

See the TESTING and DEVELOPMENT HowTo guides for details about how to run test locally.

Artifacts

// Erlang/OTP Github Action Bot

@rickard-green rickard-green added the team:PS Assigned to OTP team PS label Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

team:PS Assigned to OTP team PS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants