From 47fb32fdff3d920161e17a8bb742345c86d479fd Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 10 Sep 2026 03:40:36 +0800 Subject: [PATCH 1/5] perf(rmw-zenoh-rs): pin glibc trim threshold at init --- crates/rmw-zenoh-rs/src/context.rs | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/rmw-zenoh-rs/src/context.rs b/crates/rmw-zenoh-rs/src/context.rs index bf96457e6..09312a8c5 100644 --- a/crates/rmw-zenoh-rs/src/context.rs +++ b/crates/rmw-zenoh-rs/src/context.rs @@ -359,6 +359,44 @@ pub extern "C" fn rmw_init( } } + // Stop glibc returning the payload heap to the kernel between messages. + // + // This RMW sizes its deserialisation buffer to the payload. glibc adapts + // M_MMAP_THRESHOLD to the largest mmap'd block a process frees and sets + // M_TRIM_THRESHOLD to twice it, so payload-sized buffers leave the trim + // threshold at roughly 4 MiB. An rclpy node's heap swings 6-9 MB per + // message at 1 MiB payloads, which exceeds that: the heap is handed back + // with brk on every free and re-faulted on the next message. Measured at + // 1 MiB / 200 Hz: 12,028 brk calls and 2.3M page faults in 18 s, costing + // 33% of round-trip latency and making the arm bimodal between runs. + // + // An implementation that over-allocates avoids this by accident, because a + // larger freed block raises the threshold. Setting it explicitly is the + // same protection without the waste. See circle/hiroz issue 201. + // + // Deliberately skipped when the operator has set the glibc environment + // variables, so an explicit deployment choice is not silently overridden. + #[cfg(all(target_os = "linux", target_env = "gnu"))] + { + const M_TRIM_THRESHOLD: core::ffi::c_int = -1; + const M_MMAP_THRESHOLD: core::ffi::c_int = -3; + unsafe extern "C" { + fn mallopt(param: core::ffi::c_int, value: core::ffi::c_int) -> core::ffi::c_int; + } + let operator_set = std::env::var_os("MALLOC_TRIM_THRESHOLD_").is_some() + || std::env::var_os("MALLOC_MMAP_THRESHOLD_").is_some(); + if !operator_set { + // 64 MiB clears the measured 6-9 MB working-set swing with margin. + const THRESHOLD: core::ffi::c_int = 64 << 20; + let mmap_rc = unsafe { mallopt(M_MMAP_THRESHOLD, THRESHOLD) }; + let trim_rc = unsafe { mallopt(M_TRIM_THRESHOLD, THRESHOLD) }; + tracing::debug!( + "glibc thresholds pinned at {} MiB (mallopt rc: mmap={}, trim={})", + THRESHOLD >> 20, mmap_rc, trim_rc + ); + } + } + // Initialize Zenoh logging zenoh::init_log_from_env_or("error"); From e7694d531520e09fffc781e222a015b92d9cd3f3 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 11 Sep 2026 15:21:09 +0800 Subject: [PATCH 2/5] docs(rmw-zenoh-rs): explain both mallopt calls and drop internal reference Justify M_MMAP_THRESHOLD explicitly (pinning M_TRIM_THRESHOLD alone freezes it at 128 KiB, routing every payload through mmap -- measured worse than not touching either), state the 4 MiB figure as measured rather than a general glibc guarantee, and explain the 64 MiB bound against glibc's own threshold-adaptation ceiling. --- crates/rmw-zenoh-rs/src/context.rs | 41 ++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/rmw-zenoh-rs/src/context.rs b/crates/rmw-zenoh-rs/src/context.rs index 09312a8c5..2fd1b0d83 100644 --- a/crates/rmw-zenoh-rs/src/context.rs +++ b/crates/rmw-zenoh-rs/src/context.rs @@ -362,17 +362,38 @@ pub extern "C" fn rmw_init( // Stop glibc returning the payload heap to the kernel between messages. // // This RMW sizes its deserialisation buffer to the payload. glibc adapts - // M_MMAP_THRESHOLD to the largest mmap'd block a process frees and sets - // M_TRIM_THRESHOLD to twice it, so payload-sized buffers leave the trim - // threshold at roughly 4 MiB. An rclpy node's heap swings 6-9 MB per - // message at 1 MiB payloads, which exceeds that: the heap is handed back - // with brk on every free and re-faulted on the next message. Measured at - // 1 MiB / 200 Hz: 12,028 brk calls and 2.3M page faults in 18 s, costing - // 33% of round-trip latency and making the arm bimodal between runs. + // M_MMAP_THRESHOLD upward as it frees large blocks, and ties + // M_TRIM_THRESHOLD to twice that value. In our measurements, a process + // handling 1 MiB payloads settled with a trim threshold around 4 MiB -- + // that is a measured figure for this workload, not a general glibc + // guarantee, and it depends on prior allocation history. An rclpy node's + // heap swings 6-9 MB per message at 1 MiB payloads, which exceeds that + // threshold: the heap is handed back with brk() on every free and + // re-faulted on the next message. Measured at 1 MiB / 200 Hz: 12,028 brk + // calls and 2.3M page faults in 18 s, costing 33% of round-trip latency + // and making the arm bimodal between runs. // - // An implementation that over-allocates avoids this by accident, because a - // larger freed block raises the threshold. Setting it explicitly is the - // same protection without the waste. See circle/hiroz issue 201. + // Both calls below are required together, not as a stronger/weaker pair. + // mallopt() on EITHER parameter disables glibc's automatic threshold + // adaptation for BOTH of them (see mallopt(3), "dynamic adjustment ... + // is disabled if any of M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD or + // M_MMAP_MAX is set"). Pinning M_TRIM_THRESHOLD alone freezes + // M_MMAP_THRESHOLD at its 128 KiB default, so every payload-sized buffer + // would then be served by mmap() instead of the heap -- measured to be + // *worse* than doing nothing at all, not merely ineffective. Raising + // M_MMAP_THRESHOLD in lockstep is what keeps the buffer heap-served, and + // M_TRIM_THRESHOLD is then what decides whether that heap is trimmed + // between messages. + // + // An implementation that over-allocates its buffer avoids this by + // accident, because freeing a larger block raises the threshold anyway. + // Setting it explicitly gives the same protection without the waste. + // + // 64 MiB is a bound, not a tuned value: it must exceed both the largest + // message this pins for and the working-set swing of several live + // payload-sized buffers, and it is (measured on this host) twice glibc's + // own ceiling on how far it will ever raise these thresholds by itself -- + // so this pins inside the allocator's own envelope rather than past it. // // Deliberately skipped when the operator has set the glibc environment // variables, so an explicit deployment choice is not silently overridden. From 20b05d4dd0953261172baf078b2f9f8c7b69fdbf Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 11 Sep 2026 16:12:12 +0800 Subject: [PATCH 3/5] fix(rmw-zenoh-rs): warn when the allocator threshold pin is rejected mallopt() failure previously logged at debug, so a silently-rejected pin would look identical to the fix simply not applying -- exactly the symptom this call exists to prevent. Elevate to warn on failure only; the success path stays at debug. --- crates/rmw-zenoh-rs/src/context.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/rmw-zenoh-rs/src/context.rs b/crates/rmw-zenoh-rs/src/context.rs index 2fd1b0d83..011be4c40 100644 --- a/crates/rmw-zenoh-rs/src/context.rs +++ b/crates/rmw-zenoh-rs/src/context.rs @@ -411,10 +411,23 @@ pub extern "C" fn rmw_init( const THRESHOLD: core::ffi::c_int = 64 << 20; let mmap_rc = unsafe { mallopt(M_MMAP_THRESHOLD, THRESHOLD) }; let trim_rc = unsafe { mallopt(M_TRIM_THRESHOLD, THRESHOLD) }; - tracing::debug!( - "glibc thresholds pinned at {} MiB (mallopt rc: mmap={}, trim={})", - THRESHOLD >> 20, mmap_rc, trim_rc - ); + if mmap_rc == 0 || trim_rc == 0 { + // mallopt() returns 0 only for a handful of documented invalid + // (param, value) combinations; the cfg gate above and the + // fixed, valid THRESHOLD constant make this unlikely, but a + // silent failure here would look identical to the regression + // this call exists to prevent, so make it visible by default. + tracing::warn!( + "glibc rejected the allocator threshold pin (mallopt rc: mmap={}, trim={}); \ + large 1 MiB-class messages may show the heap-trim latency regression this call exists to avoid", + mmap_rc, trim_rc + ); + } else { + tracing::debug!( + "glibc thresholds pinned at {} MiB (mallopt rc: mmap={}, trim={})", + THRESHOLD >> 20, mmap_rc, trim_rc + ); + } } } From 50cdfa6b1bdc8d5599af92b1780e5c7fde109995 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 11 Sep 2026 17:07:02 +0800 Subject: [PATCH 4/5] fix(rmw-zenoh-rs): log the allocator pin after the subscriber exists The mallopt block ran before zenoh::init_log_from_env_or installed the tracing subscriber, so its warn!/debug! calls fired into a void and were silently dropped -- exactly the visibility the warn-on-failure change was meant to add. Move the block after logging init. Also correct the comment's claim that both mallopt values sit "inside glibc's own envelope": glibc's dynamic M_MMAP_THRESHOLD adjustment caps at ~32 MiB (DEFAULT_MMAP_THRESHOLD_MAX), so pinning it to 64 MiB is a deliberate override beyond what glibc would choose on its own for that parameter. M_TRIM_THRESHOLD is the one whose dynamic ceiling is 64 MiB (twice the mmap threshold), so the two do not share one envelope. --- crates/rmw-zenoh-rs/src/context.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/rmw-zenoh-rs/src/context.rs b/crates/rmw-zenoh-rs/src/context.rs index 011be4c40..57aa46ef6 100644 --- a/crates/rmw-zenoh-rs/src/context.rs +++ b/crates/rmw-zenoh-rs/src/context.rs @@ -359,6 +359,9 @@ pub extern "C" fn rmw_init( } } + // Initialize Zenoh logging + zenoh::init_log_from_env_or("error"); + // Stop glibc returning the payload heap to the kernel between messages. // // This RMW sizes its deserialisation buffer to the payload. glibc adapts @@ -391,9 +394,16 @@ pub extern "C" fn rmw_init( // // 64 MiB is a bound, not a tuned value: it must exceed both the largest // message this pins for and the working-set swing of several live - // payload-sized buffers, and it is (measured on this host) twice glibc's - // own ceiling on how far it will ever raise these thresholds by itself -- - // so this pins inside the allocator's own envelope rather than past it. + // payload-sized buffers. It is NOT "inside glibc's own envelope" for + // both parameters equally, and the two should not be read as matching + // the same ceiling: glibc's *dynamic* M_MMAP_THRESHOLD adjustment caps + // itself at DEFAULT_MMAP_THRESHOLD_MAX (typically 32 MiB on 64-bit), so + // pinning M_MMAP_THRESHOLD to 64 MiB is deliberately larger than glibc's + // own adjustment would ever choose for that parameter -- an explicit + // override, not a value glibc would arrive at on its own. M_TRIM_THRESHOLD + // is different: glibc ties its dynamic value to twice the mmap threshold, + // so 64 MiB is exactly the largest value its own adjustment could ever + // reach for THIS parameter. // // Deliberately skipped when the operator has set the glibc environment // variables, so an explicit deployment choice is not silently overridden. @@ -431,9 +441,6 @@ pub extern "C" fn rmw_init( } } - // Initialize Zenoh logging - zenoh::init_log_from_env_or("error"); - // Log RMW initialization tracing::info!("rmw_zenoh_rs v{} initialized", env!("CARGO_PKG_VERSION")); From 3d23c5f4e76095957903a8abb60ca0852a9fe884 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 17 Sep 2026 14:14:11 +0800 Subject: [PATCH 5/5] docs(rmw-zenoh-rs): compact the trim-threshold comment Cites #349 for the mechanism and measurements instead of restating them at the call site -- brk/fault counts and the 6-9 MB working-set figure were exact duplicates of the issue and would drift independently of it. Keeps the one number worth having locally (33% latency cost) and every fact a reader needs to avoid breaking the fix (both calls required together, why 64 MiB, the operator opt-out). 39 lines -> 20. --- crates/rmw-zenoh-rs/src/context.rs | 57 +++++++++--------------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/crates/rmw-zenoh-rs/src/context.rs b/crates/rmw-zenoh-rs/src/context.rs index 57aa46ef6..4c614974d 100644 --- a/crates/rmw-zenoh-rs/src/context.rs +++ b/crates/rmw-zenoh-rs/src/context.rs @@ -363,50 +363,25 @@ pub extern "C" fn rmw_init( zenoh::init_log_from_env_or("error"); // Stop glibc returning the payload heap to the kernel between messages. + // See #349 for the mechanism and the measurements. At 1 MiB and 200 Hz + // this costs 33% of round-trip latency. // - // This RMW sizes its deserialisation buffer to the payload. glibc adapts - // M_MMAP_THRESHOLD upward as it frees large blocks, and ties - // M_TRIM_THRESHOLD to twice that value. In our measurements, a process - // handling 1 MiB payloads settled with a trim threshold around 4 MiB -- - // that is a measured figure for this workload, not a general glibc - // guarantee, and it depends on prior allocation history. An rclpy node's - // heap swings 6-9 MB per message at 1 MiB payloads, which exceeds that - // threshold: the heap is handed back with brk() on every free and - // re-faulted on the next message. Measured at 1 MiB / 200 Hz: 12,028 brk - // calls and 2.3M page faults in 18 s, costing 33% of round-trip latency - // and making the arm bimodal between runs. + // This fix needs both calls together. `mallopt()` on either parameter + // disables glibc's automatic adjustment of both (see `mallopt(3)`). + // Pinning `M_TRIM_THRESHOLD` alone freezes `M_MMAP_THRESHOLD` at its 128 + // KiB default. Every payload-sized buffer then routes through `mmap()` + // instead of the heap. That measures worse than doing nothing. Do not + // simplify this to one call. // - // Both calls below are required together, not as a stronger/weaker pair. - // mallopt() on EITHER parameter disables glibc's automatic threshold - // adaptation for BOTH of them (see mallopt(3), "dynamic adjustment ... - // is disabled if any of M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD or - // M_MMAP_MAX is set"). Pinning M_TRIM_THRESHOLD alone freezes - // M_MMAP_THRESHOLD at its 128 KiB default, so every payload-sized buffer - // would then be served by mmap() instead of the heap -- measured to be - // *worse* than doing nothing at all, not merely ineffective. Raising - // M_MMAP_THRESHOLD in lockstep is what keeps the buffer heap-served, and - // M_TRIM_THRESHOLD is then what decides whether that heap is trimmed - // between messages. + // 64 MiB means something different for each parameter. glibc's own + // `M_MMAP_THRESHOLD` ceiling is about 32 MiB (`DEFAULT_MMAP_THRESHOLD_MAX`). + // Pinning it to 64 MiB is a deliberate override glibc would never reach + // on its own. `M_TRIM_THRESHOLD`'s dynamic ceiling is twice the mmap + // value, so 64 MiB is exactly its own maximum. // - // An implementation that over-allocates its buffer avoids this by - // accident, because freeing a larger block raises the threshold anyway. - // Setting it explicitly gives the same protection without the waste. - // - // 64 MiB is a bound, not a tuned value: it must exceed both the largest - // message this pins for and the working-set swing of several live - // payload-sized buffers. It is NOT "inside glibc's own envelope" for - // both parameters equally, and the two should not be read as matching - // the same ceiling: glibc's *dynamic* M_MMAP_THRESHOLD adjustment caps - // itself at DEFAULT_MMAP_THRESHOLD_MAX (typically 32 MiB on 64-bit), so - // pinning M_MMAP_THRESHOLD to 64 MiB is deliberately larger than glibc's - // own adjustment would ever choose for that parameter -- an explicit - // override, not a value glibc would arrive at on its own. M_TRIM_THRESHOLD - // is different: glibc ties its dynamic value to twice the mmap threshold, - // so 64 MiB is exactly the largest value its own adjustment could ever - // reach for THIS parameter. - // - // Deliberately skipped when the operator has set the glibc environment - // variables, so an explicit deployment choice is not silently overridden. + // This fix skips both calls when the operator has already set the glibc + // environment variables. An explicit deployment choice is never + // overridden. #[cfg(all(target_os = "linux", target_env = "gnu"))] { const M_TRIM_THRESHOLD: core::ffi::c_int = -1;