C++ Performance Guide
Publishing Patterns
The loan pattern reserves a sample, lets you fill it field by field, and consumes it on publish:
horus::Scheduler sched;
auto pub = sched.advertise<horus::msg::LaserScan>("lidar.scan");
auto sample = pub.loan(); // reserve a sample
sample->ranges[0] = 1.5f; // fill it in place
sample->ranges[1] = 2.0f;
pub.publish(std::move(sample)); // hand it to the transport
vs. send by copy:
horus::msg::LaserScan scan{};
scan.ranges[0] = 1.5f;
pub.send(scan); // copies 1480 bytes into SHM
Two things to know before you pick between them:
Where the subscriber runs does not change the cost. Every HORUS topic is shared-memory and cross-process — there is no same-thread or in-process shortcut backend — so a publish costs the same whether the subscriber is a thread away or a process away.
Today the two paths cost the same. In the current C++ binding LoanedSample<T>
holds the message by value and publish() forwards straight to send()
(horus_cpp/include/horus/impl/topic_impl.hpp), so loan + publish performs the
same single copy that send() does. loan() is the shape the zero-copy path will
take once the binding writes through to the SHM buffer; it is not a measured win
right now. Do not budget for one.
Rule: Use loan() + publish() for large messages such as LaserScan
(1480 bytes), so the code is already in the right shape. Use send() for small
messages (CmdVel = 16 bytes), where it will never matter.
TensorPool for Large Data
For camera images and point clouds, use pool-backed types:
horus::TensorPool pool(1, 64 * 1024 * 1024, 128); // pool id 1, 64MB, 128 slots
// Camera image — allocated from SHM pool, not heap
horus::Image img(pool, 1920, 1080, horus::Encoding::Rgb8);
// 1920 * 1080 * 3 = 6.2MB — zero-copy from pool
// Lidar point cloud — same pool, 3 fields per point (XYZ)
horus::PointCloud cloud(pool, 100000, 3); // 100k XYZ points
// Neural network tensor
uint64_t shape[] = {1, 3, 224, 224};
horus::Tensor tensor(pool, shape, 4, horus::Dtype::F32);
float* data = reinterpret_cast<float*>(tensor.data());
data[0] = 1.0f; // Direct SHM write
Sizing a pool
The two size arguments cap different things:
max_slotsis the number of tensors alive at the same time, not the number you allocate over the run. Releasing a tensor returns its slot to a free stack for reuse, so size this to your peak in-flight count.pool_size_bytesis the shared-memory data region. It is bump-allocated and each allocation is rounded up to a 64-byte cache line. Freed bytes are only reclaimed when a slot is reused for a payload that fits the largest allocation that slot has ever held — so a pool cycling between sizes will keep bumping. Size it asmax_slots x largest payloadto make every slot reusable for every payload. Allocation returns null once the region is exhausted; checkpool.stats()forused_bytes/free_bytes.
Bytes are usually the binding constraint. The 64MB pool above holds about ten 1080p RGB frames at once, well under its 128-slot cap; raise it to 128 x 6.2MB (around 800MB) only if you really need 128 frames in flight.
Measured Performance
These rows come from two different harnesses, so read the Notes column for the basis of each figure.
- FFI / scheduler rows are measured from the C++ side, across the FFI
boundary, by
horus_cpp/tests/cpp_benchmark.cpp. - Send rows are send-side medians from the Rust
robotics_messages_benchmarkon the reference i7-10750H — the time forsend()to return, not the time for a subscriber to see the message. There is no C++-side publisher benchmark, so treat these as the floor for the C++ path, which adds the FFI call on top.
| Operation | Latency | Notes |
|---|---|---|
| FFI call (horus_get_abi_version) | 11 ns | C++ bench — baseline overhead |
| CmdVel send (16 bytes) | 75 ns | Send-side median (p99 135 ns) |
| LaserScan send (1480 bytes) | 210 ns | Send-side median (p99 283 ns) |
| Scheduler tick (empty) | 37 ns | C++ bench — no nodes |
| Scheduler tick (1 node) | 248 ns | C++ bench — median |
| Scheduler tick (10 nodes) | 2.2 us | C++ bench — median |
| Scheduler tick (50 nodes) | 10.9 us | C++ bench — median |
| Throughput | 2.89M ticks/sec | C++ bench — 1 node |
Send cost scales with message size: 16 bytes costs 75 ns, 1480 bytes costs 210 ns.
Binary Size
The whole Rust runtime is statically linked into your executable, so a one-node hello-world is not small — and the debug and release numbers are 40x apart, which is a surprise worth having before deploy day rather than on it:
| Build | Size |
|---|---|
horus build (debug) | 82.3 MB |
horus build --release | 2.1 MB |
The generated CMakeLists.txt does the work: -ffunction-sections -fdata-sections plus
-Wl,--gc-sections in every configuration, and -s in Release only. The same binary is
17.6 MB with none of them and 6.7 MB with --gc-sections but no -s.
Debug keeps its symbols on purpose, which is why it stays at 82 MB. Flash the release build. See Binary Size and Flash Budget for the Rust comparison and what to do when 2.1 MB per executable is still too much.
Avoiding Common Pitfalls
Don't allocate in tick:
// BAD — allocates every tick
void tick() override {
auto str = std::string("hello"); // heap allocation
}
// GOOD — pre-allocate
class MyNode : public horus::Node {
char buf_[256]; // stack or member
void tick() override {
std::snprintf(buf_, 256, "hello");
}
};
Prefer the loan shape for large messages:
// OK for small messages — builds a full LaserScan on the stack, then copies it
horus::msg::LaserScan scan{};
pub.send(scan);
// PREFERRED for large messages — fill the sample in place, then publish
auto sample = pub.loan();
sample->ranges[0] = 1.5f;
pub.publish(std::move(sample));
Pre-create publishers/subscribers:
// BAD — creates Topic in tick (SHM open + mmap)
void tick() override {
horus::Publisher<msg::CmdVel> pub("cmd"); // DON'T
}
// GOOD — create once in constructor
class MyNode : public horus::Node {
public:
MyNode() : Node("x") { pub_ = advertise<msg::CmdVel>("cmd"); }
private:
horus::Publisher<msg::CmdVel>* pub_;
};