Jul 10, 2026 · benchmarks · performance · ros2
We pulled our headline ROS 2 speedup number. Here's the one we can defend.
The multiplier in our old headline was never measured. Here's the methodology, the ROS 2 comparison the repo can actually back, and how to run it yourself.
If you arrived here from the old headline — the one with the very large multiplier against ROS 2 — that number is withdrawn. This post used to lead with it, and it should not have.
Here is what was wrong with it, because the failure is more instructive than the number was.
The number we could not source
The old table put HORUS's nanoseconds next to ROS 2 latencies in the tens of microseconds and divided. The HORUS side was real. The ROS 2 side was not: those latencies appear in no benchmark, no report and no source anywhere in the repository. They were plausible-looking figures that nobody had measured and nobody could check.
Two smaller errors rode along with the first:
- The comparison was not like-for-like. Two of the three HORUS rows are producer-side
send()latency. A DDS comparison figure is end-to-end. Dividing one by the other flatters the numerator. - The harness quietly filled in the gap.
dds_comparison_benchmarkbuilds without theddsfeature by default, and on that path it emitted reference results for ROS 2, CycloneDDS, FastDDS and iceoryx with full percentile distributions, into the same JSON as real measurements, with nothing in the schema to tell them apart.
That last one is now fixed at the source: every BenchmarkResult carries a provenance field,
and a figure quoted out of a paper is tagged literature, not measured.
The rest of this post is the part that was always true.
How the numbers are measured
Latency this small is easy to measure wrong. The harness does the following, and you can read
every line of it in benchmarks/:
- RDTSC cycle counting with serializing
lfence/mfencefences, so the CPU can't reorder the measurement around the work. - Per-run overhead subtraction — the timer's own cost is measured on the machine at startup
(
measure_rdtsc_overhead()) and subtracted from every sample, rather than assumed. - Tukey IQR (1.5×) outlier filtering to drop scheduler hiccups and interrupts.
- Bootstrap 95% confidence intervals (10,000 resamples), plus a Jarque–Bera normality check — so a reported number comes with an error bar, not just a single lucky run.
- CPU pinning (
sched_setaffinity) and a 5,000-iteration warmup discard, to keep the measurement off cold caches and migrating cores.
None of that is exotic — it's just what you have to do to report a nanosecond honestly. It is also, obviously, not sufficient: a rigorous measurement of one side proves nothing about the side you didn't measure.
What HORUS actually measures
On an Intel i9-14900K, with the topology and the measurement spelled out, because those two rows are not comparable to each other:
| Topology | HORUS | What is being timed |
|---|---|---|
| Same-process pub/sub | 91 ns | producer-side send() |
| Cross-process | 171 ns | end-to-end, one-way |
| 1 publisher → 3 subscribers | 80 ns | producer-side send() |
The speed isn't a clever optimization of the serializer — it's the absence of one. HORUS moves messages through lock-free shared-memory ring buffers; the message is the type's memory layout, so there's nothing to serialize.
Against ROS 2, conservatively
The nearest published figure for ROS 2's default DDS is REP 2014's reference: roughly 5 µs median for a 64-byte same-process message. Compared against HORUS's end-to-end cross-process 171 ns — the harder case for HORUS, and therefore the conservative comparison — that is roughly 30x.
It is a quoted number, not one we ran, and it is doing a lot of work in that sentence: different
machine, different kernel, different DDS vendor, one payload size. If the ratio matters to your
decision, measure it on your hardware and your message sizes. Building the comparison with
-F dds against an installed DDS implementation is the only way to get a measured one out of
this repository.
Against iceoryx2, measured on both sides
This is the comparison worth arguing about — iceoryx2 is also shared-memory IPC, so it's a much fairer fight than DDS, and both sides run in the same harness:
| HORUS | iceoryx2 | Speedup | |
|---|---|---|---|
| Same-thread | 11 ns | 69 ns | 6.3× |
| Cross-process | 170 ns | 361 ns | 2.1× |
| Throughput | 95 M msg/s | 22 M msg/s | 4.3× |
cargo run --release --bin iceoryx2_comparison --features iceoryx2A smaller number that a reader can reproduce is worth more than a large one they cannot. That is the whole lesson of this post's first draft.
It holds up under load, too: near-linear scaling to 100 nodes (14% degradation) and O(1) to 1,000 topics.
Run it yourself
You don't have to trust any table above:
cargo run --release -p horus_benchmarks --bin all_paths_latencyThat prints the full percentile tables (p50 / p99 / p99.9 / max) for all ten IPC backends on your hardware, the backend selected for each topology, and the measured hardware floor it subtracts. If your numbers differ from ours, that's a bug report we want.
HORUS is open-source and Apache-2.0. If "reproducible or it didn't happen" is your bar too, the harness is all there in the repo — and so, now, is the provenance of every number next to it.