perf(rmw_graph): throttle the stale-slot sweep off the graph query path - #58
Conversation
query_all ran registry_cleanup_stale before every graph query, and it has 13
call sites, so rmw_get_node_names, rmw_count_publishers, rmw_count_subscribers,
rmw_get_topic_names_and_types and the rest all paid for it. The sweep walks
every live slot, copies 1164 bytes out of each and stats /proc/<pid> to decide
whether the owner is still alive.
Measured on one registry:
live entries one sweep via rmw_get_topic_names_and_types
1335 1.4 ms 2.9 ms
9235 21.3 ms 42.6 ms
12671 27.2 ms 54.4 ms
rmw_get_topic_names_and_types calls query_all twice, so it paid twice. Anything
polling the graph paid it several times a second whether or not the graph had
changed.
Reclaiming slots owned by dead processes is garbage collection and does not
belong on a read path. Sweep at most once per second per context instead,
guarded by a CAS so concurrent callers skip rather than queue up behind each
other.
No guarantee is weakened. A graph query could already return an entity whose
owner died just after the sweep that vetted it, so the result was never a
liveness statement; this only widens a window that was always open. The
full-registry path in registry_add still sweeps unconditionally, since there it
is the last resort before entity creation fails.
test_rmw_graph now compiles registry.cpp so it can seed the registry directly.
Closes #52
There was a problem hiding this comment.
Pull request overview
This PR removes the expensive registry_cleanup_stale() sweep from the hot graph-query read path by throttling it (per context) to at most once per second, avoiding an O(live_slots) /proc/<pid> stat pass on every graph query call.
Changes:
- Add per-context stale-cleanup throttling (steady-clock timestamp + CAS) in
rmw_graph.cpp. - Track last cleanup timestamp in
UdsContext(last_cleanup_ns). - Extend graph tests to seed the registry directly (and update test target to compile
registry.cpp).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| rmw_unix_socket_cpp/src/rmw_graph.cpp | Introduces maybe_cleanup_stale() throttling logic and routes graph queries through it. |
| rmw_unix_socket_cpp/src/types.hpp | Adds last_cleanup_ns atomic timestamp to UdsContext to support throttling. |
| rmw_unix_socket_cpp/test/test_rmw_graph.cpp | Adds a test that validates cleanup is not run on every graph query by injecting “dead” registry entries. |
| rmw_unix_socket_cpp/CMakeLists.txt | Compiles src/registry.cpp into test_rmw_graph so the test can call internal registry functions. |
Suppressed comments (1)
rmw_unix_socket_cpp/test/test_rmw_graph.cpp:95
- This assertion assumes the second graph query happens within the 1s throttle window. On a slow/contended test runner that may not hold, making the test flaky. Consider explicitly refreshing last_cleanup_ns immediately before the second graph query so the test validates the throttle behavior without depending on scheduling/timing.
const int32_t second = add_ghost("ghost_second");
ASSERT_GE(second, 0);
EXPECT_TRUE(graph_lists("ghost_second")) <<
"cleanup_stale ran again right away; the throttle is not in effect";
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const int64_t now = steady_now_ns(); | ||
| int64_t last = ctx->last_cleanup_ns.load(std::memory_order_relaxed); | ||
| if (now - last < CLEANUP_MIN_INTERVAL_NS) { | ||
| return; | ||
| } |
| #include <cstring> | ||
| #include <string> | ||
|
|
||
| #include "../src/registry.hpp" | ||
| #include "../src/types.hpp" | ||
|
|
| auto graph_lists = [&](const char * name) { | ||
| rcutils_string_array_t names = rcutils_get_zero_initialized_string_array(); | ||
| rcutils_string_array_t namespaces = rcutils_get_zero_initialized_string_array(); | ||
| EXPECT_EQ(RMW_RET_OK, rmw_get_node_names(node, &names, &namespaces)); |
…sion - WaitDoesNotStealTriggerOfGuardConditionNotWaitedOn: a GC armed earlier but absent from this call's array must keep its trigger (fails before the armed-dispatch gate lands). - RecvFromSkipsZeroLengthDatagramToRealMessage: junk must not end a drain with a real message queued behind it (fails before the recv_from restructure). - RecycledFdNumberIsRearmedForNewEntity: pins the armed-cache uid check. - QueuedBacklogStillReportsSocketDataWithoutBlocking: pins the poll-only pass contract and its non-blocking property. - BoundedWaitUnderRegistryChurnTimesOutOnSchedule: pins the doorbell-only re-block loop under registry churn. - ResponseDeliveredThroughWaitOnClient: first coverage of the client arm of the wait dispatch. - GraphQueryThrottlesStaleCleanup: private domain 93 (the old domain-99 negative raced every parallel test binary's unthrottled rmw_init sweep), deterministic throttle stamps instead of wall-clock races, a sweep-resume assertion, and unconditional ghost cleanup.
|
Full adversarial review completed (same process as #60 and #57). Verdict: the core change is sound — the CAS throttle is correctly lock-free, changes only when sweeps run and never what they do, and the "whoever wins sweeps, losers skip" semantics is exactly the pile-up removal intended. No blockers. 11 findings — 3 should-fix, 8 nits — all addressed on this branch. Should-fix (fixed)
Nits (fixed or recorded)
Heads-up for merge ordering
VerificationFull 14-suite ctest green on this branch, and on |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
rmw_unix_socket_cpp/test/test_rmw_graph.cpp:22
- The test already adds
srcas an include directory (seetarget_include_directories(test_rmw_graph PRIVATE src)), so using../src/...includes is unnecessarily brittle and can break if the test file moves or the build is out-of-source. Prefer including the internal headers via the configured include path.
#include "../src/registry.hpp"
#include "../src/types.hpp"
rmw_unix_socket_cpp/CMakeLists.txt:205
test_rmw_graphlinks against thermw_unix_socket_cppshared library (which already includessrc/registry.cpp), and also compilessrc/registry.cppinto the test executable. This creates two global definitions ofrmw_uds::registry_*in the same process and can cause symbol interposition (the shared library may end up calling the test's copy), making tests brittle and behavior dependent on linker/runtime symbol resolution. Prefer callingrmw_uds::registry_add/removefrom the shared library directly (includeregistry.hpp), or factor registry into a reusable OBJECT/static target used by both the shared lib and tests (single definition).
# test can seed the shared registry directly; the rmw library does not export
# those internal symbols. Both copies act on the same shared memory; the only
# process-local state in registry.cpp is ring_doorbells' thread_local send-fd
# cache, one per copy, so the duplication is benign.
ament_add_gtest(test_rmw_graph test/test_rmw_graph.cpp src/registry.cpp)
Three review findings against GraphQueryThrottlesStaleCleanup: - Its negative assertion raced the 1 s throttle interval against the wall clock; under load or a sanitizer the gap between the two queries closes and the test flakes. Re-arm the throttle by storing stamps into last_cleanup_ns instead of racing it. - It asserted a machine-global negative on domain 99, which every fixture-based test binary in the package sweeps unthrottled at rmw_init — a live CI flake under parallel ctest. Give it a private domain (93) via a fixture domain override. - 'Once per second' was not actually pinned: 'once per process lifetime' satisfied both assertions. Add a third query with an aged stamp to assert the sweep resumes, and remove ghosts unconditionally so a failed assertion cannot strand DEAD_PID slots in the persistent segment.
rmw_init runs an unconditional registry_cleanup_stale but never dated it, so the process's first graph query — typically moments later, during node discovery, exactly when queries cluster — repeated the identical full stat-every-slot sweep the init just paid for. Conversely 0 is a legitimate steady_clock reading near boot, inverting the intended 'first query after a quiet period sweeps'. Stamping last_cleanup_ns right after the init sweep fixes both. Comment corrections from the review: the throttle is per context, not per process (multiple contexts per process are legal and sweep independently); the crash-detection latency the throttle introduces (<= one interval per polling context) is now stated; and the test_rmw_graph CMake note claimed registry.cpp keeps no per-process state when ring_doorbells holds a thread_local send-fd cache — the conclusion held, the premise did not. Behaviour note kept from the review: a CAS loser proceeds straight to registry_query and may observe dead-owner slots the in-flight sweep is about to reclaim; that is the pile-up removal working as intended.
query_all ran registry_cleanup_stale before every graph query, and it has 13 call sites, so rmw_get_node_names, rmw_count_publishers, rmw_count_subscribers, rmw_get_topic_names_and_types and the rest all paid for it. The sweep walks every live slot, copies 1164 bytes out of each and stats /proc/ to decide whether the owner is still alive.
Measured on one registry:
rmw_get_topic_names_and_types calls query_all twice, so it paid twice. Anything polling the graph paid it several times a second whether or not the graph had changed.
Reclaiming slots owned by dead processes is garbage collection and does not belong on a read path. Sweep at most once per second per context instead, guarded by a CAS so concurrent callers skip rather than queue up behind each other.
No guarantee is weakened. A graph query could already return an entity whose owner died just after the sweep that vetted it, so the result was never a liveness statement; this only widens a window that was always open. The full-registry path in registry_add still sweeps unconditionally, since there it is the last resort before entity creation fails.
test_rmw_graph now compiles registry.cpp so it can seed the registry directly.
Closes #52