Benchmark results¶
Measured 2026-08-01 in stock ros:humble / ros:jazzy / ros:kilted containers on a 16-core
x86 host. Workload: 30 light publishers @100 Hz + 8 heavy (~100 KB) @50 Hz inter-process + 15
intra-process topics @100 Hz, ≈4,900 msg/s aggregate across 91 endpoints and 3 processes, all
on MultiThreadedExecutors. Metric: summed workload CPU seconds (getrusage(RUSAGE_SELF)).
Harnesses: run_overhead_repeated.sh (paired end-to-end overhead + microbench) and
run_bakeoff.sh (single-shot comparison vs eBPF/LTTng).
Headline¶
| Method | CPU overhead @4900 msg/s | Monitor's own cost | Disk | Intra-proc | Works on stock ROS binaries | Privileges / kernel |
|---|---|---|---|---|---|---|
| ours (LD_PRELOAD tracetools) | ≈ +2 % worst-case stress (pooled +1.9 % ± 0.7 % SEM)¹ | in-process, none | ~22 KB rolling file | ✅ | ✅ yes, as-is | none |
| eBPF uprobe (bpftrace) | ~+0.2 % (noise) at this rate² | bpftrace proc ~0.02 s | 0 (in-kernel map) | ✅ (same hooks) | ✅ | CAP_SYS_ADMIN + debugfs + BTF + uprobe kernel |
| LTTng / ros2_tracing | n/a, captured 0 events³ | sessiond + consumerd | CTF (large when working) | ✅ | ❌ needs ROS rebuilt with lttng-ust | sessiond |
¹ Paired, order-alternated trials (N=10 per distro, methodology below). This workload is a
a harsh upper bound, 91 endpoints churning at 4,900 msg/s across every core.
² uprobe cost is a per-event kernel trap (~1-2 µs). At 4900 msg/s that's ~0.1-0.2 % of a core:
noise here, but it grows with message rate, whereas our in-line count is ~0.3-1.2 ns/op.
³ On this stock ros:humble image libtracetools.so is not linked against lttng-ust, so
the ros2:* tracepoints are no-ops and lttng + babeltrace2 recorded 0 events.
ros2_tracing requires ROS rebuilt with instrumentation to capture anything.
End-to-end overhead (paired, order-alternated, N=10 per distro)¶
Single samples of this workload swing ±4 % run-to-run, enough to fake (or hide) a ~2 % effect. So each trial runs baseline and probe back-to-back with the arm order alternated every trial, and the statistic is the mean of the per-trial differences with its standard error. A delta is only reported as real if it clears ~2× SEM. (This protocol exists because a fixed arm order and N=6 means did mislead us once; see the KNOWN_ISSUES #15 note.)
Probe config: ROS_TOPIC_STATISTICS_PUBLISH_PERIOD=2.0 (normal flushing).
| Distro | baseline mean | probe mean | paired diff ± SEM | as % of baseline |
|---|---|---|---|---|
| humble | 2.449 s | 2.510 s | +0.061 s ± 0.036 | +2.5 % ± 1.5 % |
| jazzy | 2.515 s | 2.579 s | +0.064 s ± 0.041 | +2.5 % ± 1.6 % |
| kilted | 2.570 s | 2.609 s | +0.039 s ± 0.023 | +1.5 % ± 0.9 % |
| pooled (inverse-variance) | +0.049 s ± 0.018 | +1.9 % ± 0.7 % |
Per message that is ≈1 µs of added system cost against ≈51 µs the stack already spends delivering it. On realistic graphs (fewer endpoints, lower aggregate rate per process) the share is proportionally smaller.
Where the cost is (and is not): controlled attributions, ros:jazzy¶
| Experiment | Result | Conclusion |
|---|---|---|
| Null shim (same 8 exported symbols, empty bodies) vs baseline | −0.3 % | LD_PRELOAD interposition itself is free |
Probe with flushing disabled (PERIOD=60) vs with flushing (PERIOD=2.0), paired N=10 |
+0.027 s ± 0.033 | flushing is not the cost |
| Instrumented flush time (6 windows, 3 processes) | 0.7-7 ms wall total | flush work is µs to ms scale |
| TLS-cache miss counters, pre-fix → post-fix | 31,926 → 6,509 fallbacks (pubfarm) | KNOWN_ISSUES #15 stride-aliasing fixed |
| Paired bench pre-fix → post-fix | +2.6 % → +1.9 % (pooled) | cache fix reclaimed part of the delta |
The residual ≈2 % does not localize to the hot path (sub-ns/op, below), the flush path, or symbol interposition; it is the diffuse footprint of observing at all: the chained call into the real tracepoint, extra code/data resident in cache and TLB across all 16 executor threads, and one parked flush thread. We report it rather than subtract it.
Per-operation microbench (isolated hot path)¶
Two access patterns: fixed = each thread hammers one endpoint (best case); alt-4 = each
thread alternates across 4 endpoints (the realistic camera-pipeline pattern). 8 threads,
2026-08-01 run of hotpath_bench.cpp inside each distro container:
| Leg | humble | jazzy | kilted |
|---|---|---|---|
| OLD design (global mutex + per-msg string hash), fixed | 140.9 ns/op | 167.4 ns/op | 137.0 ns/op |
| single-entry TLS cache (pre-#13), fixed | 0.3 ns/op | 0.3 ns/op | 0.4 ns/op |
| single-entry TLS cache (pre-#13), alt-4 | 46.8 ns/op | 52.3 ns/op | 46.6 ns/op |
| current TLS cache (post-#15), fixed | 0.3 ns/op | 0.3 ns/op | 0.3 ns/op |
| current TLS cache (post-#15), alt-4 | 0.6 ns/op | 1.2 ns/op | 1.1 ns/op |
The alternating-pattern number is the one that matters in practice (KNOWN_ISSUES #13/#15): the single-entry cache thrashed to ~50 ns/op, and the 16-slot cache's stride-aliasing pushed realistic multi-topic farms onto a contended lock entirely. The current 256-slot stride-breaking cache holds alternation at ~1 ns/op, two orders of magnitude under one LTTng-UST tracepoint (~158 ns, Bédard et al. 2022). Numbers move with host/thread count, treat ratios, not absolutes, as the signal.
aarch64: Jetson AGX Orin (2026-08-23, production robot, clocks pinned)¶
Same hotpath_bench.cpp, 10 trials × 8 threads, mean ± SEM. Full write-up and raw trials in
test/orin/RESULTS.md.
| Leg | Orin (Cortex-A78AE @ 2.2 GHz) | x86-64 reference |
|---|---|---|
| OLD design (mutex + string hash), fixed | 351 ns/op | 141-167 ns/op |
| single-entry TLS cache, alt-4 | 225 ns/op | 47-52 ns/op |
| current TLS cache, fixed | 0.9 ns/op | 0.3 ns/op |
| current TLS cache, alt-4 | 2.1-2.2 ns/op | 0.6-1.2 ns/op |
raw steady_clock::now(), 1 thread |
38.7 ns ± 0.08 | ~21 ns |
R5 jitter clock read, fixed (JITTER=1) |
+51.0 ns/msg ± 0.6 | +24.4 ns/msg ± 0.02 |
| R5 jitter clock read, alt-4 | +55.6 ns/msg ± 0.9 | — |
The ratios carry over: the cache fix holds alternation two orders of magnitude under the old design on aarch64 too, and the R5 cost is the clock read (89 % of the delta); the vDSO path works on the 5.15 Tegra kernel, so the syscall-fallback risk flagged in the R5 design did not materialize.
Observer effect: what watching a topic with the stock CLI costs (2026-08-23)¶
bench/run_observer_effect.sh, ros:humble, CycloneDDS default, AMD Ryzen 7 7435HS (16 cores).
Workload: the stress farm above (8 × ~100 KB @ 50 Hz + 30 light @ 100 Hz inter-process, 15
intra-process @ 100 Hz, all with subscribers). The probe is the ruler in every arm: it counts
rcl_publish in-process, so its publish-side rate is the true rate whatever else subscribes.
Four arms rotated each trial, N = 10, 10 s runs with the watcher attached for 8 s; mean ± SEM.
Raw: out/observer_effect/ (CSV, summary, per-arm probe logs, watcher output).
| Arm | true /heavy_0 Hz |
rate the watcher printed | watcher's own CPU | publisher farm CPU | intra farm CPU | TOPIC /intra_0 windows |
|---|---|---|---|---|---|---|
| nothing watching | 50.000 | — | 0 | 1.152 s ± 0.016 | 0.303 s ± 0.010 | 0 / 5 |
ros2 topic hz /heavy_0 |
50.000 | 49.845 ± 0.053 | 0.566 s ± 0.005 = 7.1 % of a core | 1.171 ± 0.020 | 0.329 ± 0.009 | 0 |
ros2 topic echo /heavy_0 > /dev/null |
50.000 | — | 2.504 s ± 0.007 = 31.3 % of a core | 1.133 ± 0.015 | 0.318 ± 0.007 | 0 |
ros2 topic hz /intra_0 (intra-process topic) |
50.000 | 99.997 ± 0.001 | 0.654 s ± 0.004 = 8.2 % | 1.116 ± 0.014 | 0.460 s ± 0.004 (+52 %) | 4 / 5, in 10 / 10 trials |
What it says, in order of how sure we are:
- The stock tools are accurate here. The publisher held 50.000 Hz in every arm and
hzreported it within 0.3 %. We expected a slow reliable reader to back-pressure a 100 KB writer; on this box at this load it did not. Published as measured. - Watching is not free. One
ros2 topic hzon one 100 KB topic is 7 % of a core for as long as you look;ros2 topic echoof the same topic is 31 % of a core with its output thrown away. Per topic. The probe adds no subscriber and pulse-top reads a file. - On an intra-process topic the watcher is the perturbation. A pure intra-process
publisher never reaches
rcl_publish; the first out-of-process subscriber, thehzyou just started, makes it serialize and send every message. The watched process's CPU went up 52 % and the publish-side path lit up in 4 of 5 windows, 10 trials out of 10. The numberhzprints is right; the system it describes is no longer the one that was running before you looked. - Publisher and subscriber CPU did not move with an extra inter-process reader (differences are within ~2 SEM in both directions, reliable / KeepLast(10)).
Caveats: one box, one distro, one RMW, 100 KB messages at 50 Hz. A slower CPU (the Orin), bigger messages or several watchers scale the watcher's cost; they do not change finding 1.
Verdict¶
- CPU cost is ≈2 % on a worst-case synthetic stress and proportionally less on real graphs. The paired numbers are published with error bars rather than a "zero overhead" claim; at moderate rates ours ≈ eBPF ≈ small, and the differentiator is elsewhere.
- Ours wins on deployability, and it's measured:
- eBPF required a privileged container (CAP_SYS_ADMIN + debugfs + BTF) to attach at all, the Orin/Jetson kernel-portability risk is real, not hypothetical. Ours needs zero privileges.
- LTTng/ros2_tracing captured nothing on the stock binaries and needs a ROS rebuild with lttng-ust, then offline CTF analysis to derive Hz. Ours runs on the exact deployed binaries and emits ready-to-read Hz to a tiny file.
- No single off-the-shelf option meets all constraints (intra-process + zero-network + zero-priv + stock-binary + drop-in file). Ours does; that is the empirically supported "better."
Reproduce¶
# per distro: paired overhead trials + microbench
docker run --rm -e ROS_DISTRO=humble -v <pkg>:/pkg:ro -v /tmp/bench-humble:/work ros:humble \
bash /pkg/bench/run_overhead_repeated.sh
docker run --rm -e ROS_DISTRO=jazzy -v <pkg>:/pkg:ro -v /tmp/bench-jazzy:/work ros:jazzy \
bash /pkg/bench/run_overhead_repeated.sh
docker run --rm -e ROS_DISTRO=kilted -v <pkg>:/pkg:ro -v /tmp/bench-kilted:/work ros:kilted \
bash /pkg/bench/run_overhead_repeated.sh
# observer effect: what `ros2 topic hz` / `echo` cost, and what they do to intra-process topics
docker run --rm -v "$PWD":/pkg -v /tmp/oe:/work ros:humble-ros-base bash /pkg/bench/run_observer_effect.sh
# bake-off vs eBPF / LTTng (needs --privileged for the eBPF leg)
docker run --rm --privileged -v <pkg>:/pkg -v /tmp/bench:/work ros:humble bash /pkg/bench/run_bakeoff.sh