Skip to content

Add experimental Linux shared-memory IPC transport - #4918

Draft
timeuser4 wants to merge 6 commits into
zeromq:masterfrom
timeuser4:feature/shm-ipc-backend
Draft

Add experimental Linux shared-memory IPC transport#4918
timeuser4 wants to merge 6 commits into
zeromq:masterfrom
timeuser4:feature/shm-ipc-backend

Conversation

@timeuser4

Copy link
Copy Markdown

Summary

This is a draft implementation of a Linux-only shared-memory IPC transport for local processes.

The PR adds:

  • a new experimental shm:// transport path for ZMQ_PAIR
  • a Unix-domain-socket handshake that transfers shared resources with SCM_RIGHTS
  • shared payload storage backed by memfd_create and mmap(MAP_SHARED)
  • bidirectional SPSC slot rings for process-to-process message movement
  • eventfd-based wakeups and peer liveness handling
  • direct shared-slot message APIs, zmq_shm_msg_init and zmq_shm_msg_send
  • ZMQ_SHM message inspection so applications can tell when received payload storage is shared
  • tests for the ring/channel primitives, fd transfer, shm pair roundtrips, direct shared-slot messages, slot release, and messages outliving socket/context teardown

Motivation

The existing ipc:// transport uses Unix domain sockets, so payload bytes still pass through kernel socket buffers. For large same-host messages this can dominate cost. This draft explores using UDS only for connection setup, fd transfer, wakeups, and liveness, while moving payloads through shared memory.

The direct-message path lets an application reserve a shared slot, construct payload data directly in that slot, send the message, and release the slot when the last zmq_msg_t reference is closed. That is the application-to-application zero-copy case this draft is trying to evaluate.

Design Notes

  • The implementation is Linux-only and guarded with ZMQ_HAVE_LINUX.
  • shm:// reuses the IPC address path for bind/connect but installs a shared-memory engine after the UDS handshake.
  • The listener creates the shared mapping and transfers the memfd plus eventfds to the peer.
  • Each connection owns two fixed-size SPSC rings, one per direction.
  • Regular zmq_send copies application data into a shared slot.
  • zmq_shm_msg_init reserves a shared slot before construction, initializes a msg_t over that storage, and binds slot release to the message finalizer.
  • The shared mapping remains referenced while received messages still point into it, even if the socket/context has already been closed.

Related Work

Draft Status

This is intentionally opened as a draft to get early feedback on the transport shape and public API before hardening it further.

Open areas before this should be considered ready:

  • decide whether the public API should remain as zmq_shm_msg_init/send, be draft-gated, or be shaped differently
  • decide whether shm:// should be a separate transport or an IPC backend option
  • expand resource-exhaustion and multi-peer behavior beyond the current MVP tests
  • add documentation once the API direction is agreed
  • publish the full benchmark matrix in the PR discussion

Validation

  • git diff --check upstream/master..HEAD
  • targeted Linux tests were added for test_shm_ring, test_shm_channel, and test_pair_shm

This has not been marked ready for review yet because the API and production-hardening questions above need maintainer feedback first.

@timeuser4

Copy link
Copy Markdown
Author

Preliminary Linux throughput results

I reran the IPC throughput matrix for the draft shm:// transport.

Environment:

Item Value
CPU Intel(R) Xeon(R) Platinum 8352V CPU @ 2.10GHz
CPU topology 2 sockets, 36 cores/socket, 2 threads/core, 144 logical CPUs
NUMA node0 CPUs 0-35,72-107; node1 CPUs 36-71,108-143
Kernel Linux 5.15.0-115-generic x86_64
Compiler GCC/G++ 11.4.0
Build CMake Release build

Benchmark setup:

  • socket type: ZMQ_PAIR
  • compared paths:
    • ipc://
    • shm:// with regular zmq_send
    • shm:// with direct shared-memory messages
  • sender writes the full payload for each message
  • 3 repeats per case; tables report median throughput
  • same-NUMA placement: receiver/sender pinned to CPUs 0/1
  • cross-NUMA placement: receiver/sender pinned to CPUs 0/36
  • two receiver modes were measured:
    • sampled validation: receiver samples the payload
    • full scan: receiver reads every byte of the payload

Sampled receiver validation, median throughput, GiB/s:

Placement Payload ipc:// shm:// regular send shm:// direct direct / ipc
same NUMA 1 KiB 1.052 0.163 0.076 0.07x
same NUMA 64 KiB 2.217 4.203 7.269 3.28x
same NUMA 1 MiB 2.032 4.135 16.283 8.01x
same NUMA 8 MiB 1.507 4.881 15.007 9.96x
cross NUMA 1 KiB 0.858 0.132 0.067 0.08x
cross NUMA 64 KiB 2.122 3.817 6.372 3.00x
cross NUMA 1 MiB 1.800 4.007 15.159 8.42x
cross NUMA 8 MiB 1.341 4.044 12.020 8.96x

Full receiver payload scan, median throughput, GiB/s:

Placement Payload ipc:// shm:// regular send shm:// direct direct / ipc
same NUMA 1 KiB 0.627 0.200 0.125 0.20x
same NUMA 64 KiB 1.457 2.334 2.488 1.71x
same NUMA 1 MiB 2.504 2.999 3.429 1.37x
same NUMA 8 MiB 1.959 3.137 3.341 1.71x
cross NUMA 1 KiB 0.744 0.169 0.063 0.09x
cross NUMA 64 KiB 1.787 2.240 2.882 1.61x
cross NUMA 1 MiB 2.019 2.408 3.253 1.61x
cross NUMA 8 MiB 1.857 3.226 3.296 1.78x

Interpretation:

  • This path is not a small-message optimization. At 1 KiB, both shared-memory paths are slower than ipc://.
  • Starting at 64 KiB, the shared-memory transport starts to show a clear benefit.
  • With sampled receiver validation, the direct shared-memory path is significantly faster for large payloads because it avoids payload copying in the transport path.
  • With full receiver payload scans, the headline speedup is smaller because receiver-side memory bandwidth becomes part of the measured workload, but the shared-memory paths still remain faster than ipc:// for 64 KiB and larger payloads.
  • The regular shm:// send path also helps for larger payloads, but the direct path is consistently better or comparable, especially for larger messages.

@timeuser4

timeuser4 commented Jul 17, 2026

Copy link
Copy Markdown
Author

Update: I have also been testing an alternative prototype direction for shared-memory IPC acceleration before deciding how to harden this draft further.

The current draft PR started as a standalone shm:// transport plus an explicit direct shared-memory message API. That path is still the closest to true application-to-application zero-copy and gives the highest peak throughput in the transport-isolated benchmarks.

However, I do not expect the automatic ipc:// backend path to outperform the explicit shm:// direct-message path. The reason to consider it is different: compatibility and adoption.

A standalone shm:// transport or a direct shared-memory API requires applications to opt into a new endpoint scheme and/or new buffer-lifetime semantics. An opt-in ipc:// backend could preserve the existing ipc:// endpoint and normal zmq_send() API, while still accelerating large messages for existing IPC users.

The alternative ipc:// backend direction is:

ipc:// endpoint remains unchanged

small messages:
  existing ZMTP over UDS path

large messages:
  payload stored in shared memory
  ZMTP/UDS sends a small descriptor
  receiver msg_t borrows the shared-memory slot
  slot is released when the received message is destroyed

For normal zmq_send(ptr, size), this is not full application-to-application zero-copy: the sender still copies the application buffer into the shared-memory slot. The saving is avoiding the large payload transfer through the UDS/kernel path. A true zero-copy path would likely need a separate explicit API where the application reserves a shared-memory message buffer and writes/constructs the payload directly there.

So I currently see these as two separate design options:

  • shm:// transport / direct shared-memory API: best peak performance, but requires a new endpoint/API and stricter lifetime semantics.
  • opt-in ipc:// backend: lower peak performance, but preserves existing ipc:// usage and can transparently accelerate large messages while keeping UDS/ZMTP as the control and fallback path.

I reran the benchmark with four modes:

  • ipc: existing UDS/ZMTP path
  • ipc-force-uds: shared-memory setup enabled, but payloads forced through UDS
  • ipc-force-shm: payloads forced through shared memory
  • ipc-auto: payloads >= 64 KiB use shared memory, smaller payloads use UDS

Setup:

  • Linux
  • CPU: Intel Xeon Platinum 8352V
  • single PAIR connection
  • median of 3 runs
  • same-NUMA: CPU 0 / CPU 1
  • cross-NUMA: CPU 0 / CPU 36
  • throughput unit: GiB/s

Same-NUMA, sampled receiver:

Payload ipc force-uds force-shm ipc-auto auto/ipc
1 KiB 0.965 1.013 0.122 0.912 0.94x
16 KiB 1.587 1.503 1.642 1.506 0.95x
64 KiB 2.204 2.167 2.610 3.348 1.52x
256 KiB 2.484 2.494 4.030 3.953 1.59x
1 MiB 2.752 2.759 4.953 3.554 1.29x
4 MiB 2.339 2.581 4.952 4.861 2.08x
8 MiB 2.248 2.474 5.051 4.955 2.20x
16 MiB 2.411 2.460 N/A 2.368 0.98x

Same-NUMA, full-scan receiver:

Payload ipc force-uds force-shm ipc-auto auto/ipc
1 KiB 0.921 0.937 0.129 0.927 1.01x
16 KiB 1.610 1.538 2.030 1.529 0.95x
64 KiB 1.989 2.005 2.225 2.355 1.18x
256 KiB 2.249 2.221 2.191 2.155 0.96x
1 MiB 2.600 2.512 2.930 2.707 1.04x
4 MiB 2.343 2.575 3.120 3.080 1.31x
8 MiB 2.214 2.544 3.125 3.203 1.45x
16 MiB 2.344 2.430 N/A 2.466 1.05x

Cross-NUMA, sampled receiver:

Payload ipc force-uds force-shm ipc-auto auto/ipc
1 KiB 0.831 0.800 0.100 0.760 0.92x
16 KiB 1.178 1.132 1.498 1.128 0.96x
64 KiB 1.572 1.579 2.941 3.337 2.12x
256 KiB 1.961 2.172 3.284 3.241 1.65x
1 MiB 1.979 1.982 4.596 3.312 1.67x
4 MiB 1.703 1.693 4.675 4.555 2.68x
8 MiB 1.665 1.596 4.740 4.584 2.75x
16 MiB 1.619 1.588 N/A 1.651 1.02x

Cross-NUMA, full-scan receiver:

Payload ipc force-uds force-shm ipc-auto auto/ipc
1 KiB 0.765 0.764 0.106 0.752 0.98x
16 KiB 1.172 1.102 1.873 1.111 0.95x
64 KiB 1.493 1.524 2.152 2.240 1.50x
256 KiB 1.966 1.983 2.265 2.311 1.18x
1 MiB 1.911 1.891 2.609 2.387 1.25x
4 MiB 1.867 1.863 3.126 3.087 1.65x
8 MiB 1.701 1.758 3.180 3.179 1.87x
16 MiB 1.638 1.705 N/A 1.722 1.05x

Observations:

  • Forcing shared memory for small messages is significantly worse, so a threshold is required.
  • ipc-auto keeps small messages close to the existing UDS path.
  • In sampled-receiver tests, where the benchmark mostly isolates transport/copy overhead, ipc-auto shows clear benefit from 64 KiB upward.
  • In full-scan tests, where the receiver reads every byte, the benefit is smaller and workload-dependent. Some medium sizes are close to UDS, while larger sizes still show benefit.
  • The current prototype has an 8 MiB shared-memory slot capacity. Therefore 16 MiB force-shm is not supported in this run; ipc-auto falls back to UDS for that case.

Before iterating further, I would like feedback on the intended architecture:

Should shared-memory acceleration, if pursued, be integrated as an opt-in ipc:// backend using UDS/ZMTP as the control and fallback path?

In particular:

  1. Is preserving ipc:// externally and selecting UDS vs SHM internally the right direction?
  2. Should normal zmq_send() acceleration and an explicit true zero-copy shared-memory API be treated as separate features?
  3. What would be the preferred protocol shape for negotiation and descriptors, so normal user payloads cannot be confused with shm descriptors?
  4. Would maintainers prefer this discussion to continue in this draft PR, or should I open a separate RFC issue before iterating further?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant