Skip to content

Receive path allocates and frees a payload-sized buffer per message: glibc trims the heap and the next message page-faults it back in #84

Open

Description

@benaliabderrahmane

Generated by Generative AI

Claude Code (Claude Opus 5.5). The profiling, the experiments and this write-up were done with Claude Code; every number comes from the local runs described below.

Operating System:

Linux 6.8.0-142-generic #142-Ubuntu SMP PREEMPT_DYNAMIC x86_64 GNU/Linux

ROS 2 distro:

jazzy

rmw_unix_socket_cpp version or commit hash:

Measured on devel @ 3ca379b (includes #83). The code paths below are unchanged on devel @ ba97253.

Client library (if applicable):

rclcpp

Executor (if applicable):

SingleThreadedExecutor

Does the same reproducer work with another RMW?

Yes — it works with another RMW, so the bug looks specific to rmw_unix_socket_cpp

QoS profile (if applicable):

No response (irobot_benchmark defaults)

'ros2 doctor --report' output

No response

Steps to reproduce issue

  1. Get the iRobot benchmark tools (irobot_benchmark from irobot-ros/ros2-performance, as built in irobot-ros/ros2-benchmark-container) and the topology benchmark/topologies/pub-sub/topology_multi_5_topics_1000hz.json. It puts 5 publishers of 1 MB messages (44, 158, 540, 37 and 393 Hz, about 1.17 GB/s together) and one subscriber node on all 5 topics in one process.
  2. Run it with intra-process communication off, so every message goes through the RMW:
    RMW_IMPLEMENTATION=rmw_unix_socket_cpp irobot_benchmark topology_multi_5_topics_1000hz.json \
      --executor 1 --ipc off -t 20 -s 1000 --csv-out on --results-dir out
    
  3. Read out/topology_multi_5_topics_1000hz_log/latency_all.txt, then repeat with RMW_IMPLEMENTATION=rmw_fastrtps_cpp.

This is the 5_process case of the suite's multi_process_scalability_pub_sub matrix.

Expected behavior

Latency in the range of the other RMWs: Fast DDS 256–277 µs mean, 56–99 µs standard deviation per topic.

Actual behavior

724–727 µs mean, 677–1 075 µs standard deviation per topic, single messages delayed by up to 14 ms, and the process uses 55 % of a core against 35 % for Fast DDS. All ~23 400 messages arrive with both RMWs, so the difference is latency and CPU, not loss.

Additional information

Summary

Each received payload goes into a new std::vector<uint8_t> (ReceivedMessage::payload, filled by shm_fetch_payload in drain_socket), and rmw_take_with_info frees it when the message goes out of scope after deserializing. For a 1 MB payload, glibc trims the heap on that free (madvise(MADV_DONTNEED)), so the next message page-faults the whole buffer back in, and the kernel zeroes it again. In the 5-publisher fan-in this is the single biggest cost: it roughly doubles the mean latency and causes most of the multi-millisecond delays. Disabling trimming alone cut the mean from 724–727 µs to 393–398 µs and process CPU from 55 % to 44 %.

struct ReceivedMessage
{
WireHeader header;
std::vector<uint8_t> payload;
int64_t received_timestamp_ns;
};

bool shm_fetch_payload(
ShmReaderCache & cache,
size_t domain_id,
std::vector<uint8_t> & payload_io)
{
if (payload_io.size() != sizeof(ShmPayloadDescriptor)) {
RMW_UDS_LOG_WARN_THROTTLE(
5000, "shm descriptor datagram has wrong size (%zu) — dropped",
payload_io.size());
return false;
}
ShmPayloadDescriptor desc;
std::memcpy(&desc, payload_io.data(), sizeof(desc));
std::lock_guard<std::mutex> lock(cache.mutex);
const ShmReaderCache::Mapping * mapping = map_segment(cache, domain_id, desc);
if (!mapping) {
return false;
}
// Bounds-check the descriptor against the capacity snapshotted at map
// time (never the live header, which the owner keeps writable): a corrupt
// or hostile descriptor must not be able to read outside the mapping.
const uint64_t ring_bytes = mapping->ring_bytes;
if (desc.offset % SHM_RECORD_ALIGN != 0 ||
desc.offset > ring_bytes ||
sizeof(ShmRecordHeader) + static_cast<uint64_t>(desc.payload_size) >
ring_bytes - desc.offset)
{
RMW_UDS_LOG_WARN_THROTTLE(
5000, "shm descriptor out of bounds (offset %llu, size %u) — dropped",
static_cast<unsigned long long>(desc.offset), desc.payload_size);
return false;
}
const auto * record = reinterpret_cast<const ShmRecordHeader *>(
mapping->base + sizeof(ShmRingHeader) + desc.offset);
// Seqlock read (registry protocol): the record must carry the expected
// stable seq before the copy and still carry it after. Anything else means
// the publisher lapped the ring and reused the slot — a clean drop.
if (record->seq.load(std::memory_order_acquire) != desc.seq ||
record->payload_len != desc.payload_size)
{
return false;
}
payload_io.assign(
reinterpret_cast<const uint8_t *>(record + 1),
reinterpret_cast<const uint8_t *>(record + 1) + desc.payload_size);
// The fence keeps the payload loads above from sinking below the re-check
// on weakly-ordered CPUs; a bare acquire load only pins later accesses.
std::atomic_thread_fence(std::memory_order_acquire);
if (record->seq.load(std::memory_order_relaxed) != desc.seq) {
return false; // overwritten mid-copy; payload_io holds torn bytes
}
return true;

rmw_ret_t rmw_take_with_info(
const rmw_subscription_t * subscription,
void * ros_message,
bool * taken,
rmw_message_info_t * message_info,
rmw_subscription_allocation_t * allocation)
{
(void)allocation;
RMW_CHECK_ARGUMENT_FOR_NULL(subscription, RMW_RET_INVALID_ARGUMENT);
RMW_CHECK_ARGUMENT_FOR_NULL(ros_message, RMW_RET_INVALID_ARGUMENT);
RMW_CHECK_ARGUMENT_FOR_NULL(taken, RMW_RET_INVALID_ARGUMENT);
RMW_CHECK_ARGUMENT_FOR_NULL(message_info, RMW_RET_INVALID_ARGUMENT);
RMW_CHECK_TYPE_IDENTIFIERS_MATCH(
subscription, subscription->implementation_identifier,
rmw_uds::identifier, return RMW_RET_INCORRECT_RMW_IMPLEMENTATION);
*taken = false;
auto * sub_data = static_cast<rmw_uds::UdsSubscription *>(subscription->data);
drain_subscription(sub_data);
std::lock_guard<std::mutex> lock(sub_data->queue_mutex);
if (sub_data->message_queue.empty()) {
return RMW_RET_OK;
}
auto msg = std::move(sub_data->message_queue.front());
sub_data->message_queue.pop_front();
if (!rmw_uds::deserialize(msg.payload.data(), msg.payload.size(),
sub_data->callbacks, ros_message))
{
// Surface to the log too: rcl only prints the SET_ERROR_MSG string if
// the caller checks the return code, but a corrupted/truncated payload
// (e.g. silently truncated by the kernel on send) is something the
// operator needs to see regardless. Throttle to bound the rate.
RMW_UDS_LOG_ERROR_THROTTLE(
1000,
"rmw_take: CDR deserialization failed on topic '%s' (payload=%zu bytes) "
"— likely truncated or type mismatch with publisher",
sub_data->topic_name.c_str(), msg.payload.size());
char err_buf[512];
std::snprintf(err_buf, sizeof(err_buf),
"failed to deserialize message on topic '%s' (payload=%zu bytes)",
sub_data->topic_name.c_str(),
msg.payload.size());
RMW_SET_ERROR_MSG(err_buf);
return RMW_RET_ERROR;
}
message_info->source_timestamp = msg.header.source_timestamp_ns;
message_info->received_timestamp = msg.received_timestamp_ns;
message_info->publication_sequence_number =
static_cast<uint64_t>(msg.header.sequence_number);
message_info->reception_sequence_number =
sub_data->reception_seq.fetch_add(1, std::memory_order_relaxed);
std::memcpy(
message_info->publisher_gid.data, msg.header.gid, RMW_GID_STORAGE_SIZE);
message_info->publisher_gid.implementation_identifier = rmw_uds::identifier;
message_info->from_intra_process = false;
*taken = true;
TRACETOOLS_TRACEPOINT(
rmw_take, subscription, ros_message, msg.header.source_timestamp_ns, *taken);
return RMW_RET_OK;
}

Possible solutions

  1. Reuse the buffer instead of freeing it per message. Keep a small per-subscription free list of payload vectors, hand one to drain_socket and return it after rmw_take. assign() into a vector that already has the capacity doesn't reallocate, so the trims and page faults disappear. Smallest change, but it keeps the copy from Shared-memory receive path copies every large payload into a heap buffer before deserializing (shm_fetch_payload) #86.
  2. Deserialize straight from the shm ring in rmw_take. Queue only the descriptor, check the slot's sequence after deserializing, and drop the sample (counting it as lost) if the publisher lapped it. This removes both the buffer and the copy from Shared-memory receive path copies every large payload into a heap buffer before deserializing (shm_fetch_payload) #86. It's more work and has to keep KEEP_LAST semantics.
  3. Stopgap only: users can set the GLIBC_TUNABLES below (measured −46 % latency). The RMW itself shouldn't call mallopt, since that's process-wide.

The datagram path allocates a vector per message the same way. Above glibc's mmap threshold (128 KB by default) the same trim-and-fault cycle may also explain part of the 16–256 KB gap in perf_test. That part isn't measured.

How this was found

Everything ran in a container pinned to 12 logical CPUs (Ryzen 7 5700X), with devel built RelWithDebInfo -fno-omit-frame-pointer and libc6-dbg installed so libc symbols resolve. Profiling needed CAP_PERFMON because the host has perf_event_paranoid=4.

  1. Benchmark. The iRobot suite ranked unix_socket last on this case: devel 1 236–1 990 µs over 3 runs, 668–799 µs after fix(rmw_wait): keep the wait timeout's sub-millisecond resolution #83, against Fast DDS 278 µs, Fast DDS zero-copy 207 µs, Cyclone 494 µs and Zenoh 222 µs.
  2. Per-thread CPU (/proc/<pid>/task/*/stat deltas over 5 s). With either RMW all the work runs on the one executor thread: unix_socket 54.6 %, Fast DDS 35.4 %. Fast DDS's 14 other threads stay idle because it delivers in-process without a transport. On one thread, every extra microsecond per message also delays the messages queued behind it.
  3. perf record -F 1999 -g on the process for 6 s, then perf script, attributing each memmove/memset sample to its first caller outside libc.
  4. perf stat for task-clock and page faults over 3 s.
  5. strace -c -f -p <pid> over a 4 s window to count syscalls.
  6. strace -f -k -e trace=madvise over 6 s, to get call stacks for every heap trim.
  7. A/B experiments, two 20 s runs each: glibc trimming disabled with GLIBC_TUNABLES=glibc.malloc.trim_threshold=1073741824:glibc.malloc.mmap_threshold=1073741824, an experimental guard-condition patch (see rmw_wait reads every guard condition's eventfd twice per call: ~38 failing read() syscalls per wait #85), and both together.

Results

A/B experiments (2 runs each):

variant mean latency process CPU per-topic sd max delay
unix_socket devel 724–727 µs 55 % 677–1 075 µs 6.0–13.9 ms
+ glibc trimming disabled 393–398 µs 44 % 129–294 µs 3.2–10.9 ms
+ guard-condition pending flag only 815–828 µs 56 % 822–1 347 µs 5.8–13.8 ms
+ both 347–351 µs 38 % 114–257 µs 2.3–11.3 ms
Fast DDS 256–277 µs 35 % 56–99 µs 0.6–3.4 ms

Where the CPU goes (perf, % of the process's samples). CPU per message: unix_socket 1 370 ms task-clock in 3 s ≈ 390 µs; Fast DDS 1 084 ms ≈ 308 µs.

work unix_socket Fast DDS
deserialize the 1 MB array (Cdr::deserialize_array → memmove) 20.1 % 26.6 %
serialize the 1 MB array (Cdr::serialize_array → memmove) 12.8 % 12.1 %
benchmark fills the message (std::fill → memset) 15.9 % 22.4 %
shm_fetch_payload copy, shm ring → heap buffer 9.6 % –
kernel, total 22.8 % ~0 (futex only)
↳ read() from rmw_wait 12.5 % –
↳ sendmsg, recv, epoll_pwait2, page faults during the fill 2.5 %, 2.1 %, 1.9 %, 1.8 % –

Syscalls in 4 s (strace -c, about 4 700 messages):

syscall unix_socket Fast DDS
read 88 789 (88 781 EAGAIN) 8
madvise 638 (69 µs each) 0
recvfrom 17 916 (8 912 EAGAIN) 2
sendmsg 4 502 –
epoll_pwait2 2 336 –
futex – 7 660

Page faults in 3 s (perf stat): 15 247 for unix_socket (about 4.3 per message), 0 for Fast DDS.

madvise call stacks (strace -k, 6 s): all 351 calls are MADV_DONTNEED on the executor thread. 288 come from rmw_take_with_info freeing the received payload; the other 63 come from the benchmark freeing its own 1 MB message.

What's left after both experimental fixes: about 80 µs of mean latency, plus occasional multi-millisecond maxima that aren't explained yet. Removing the copy in #86 should account for part of it.

Related issues: #84 (buffer per message), #85 (guard-condition polling), #86 (extra copy).

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

Metadata

Metadata

Labels

P1NextperformanceThroughput/latency work

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions