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_slots is 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_bytes is 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 as max_slots x largest payload to make every slot reusable for every payload. Allocation returns null once the region is exhausted; check pool.stats() for used_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_benchmark on the reference i7-10750H — the time for send() 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.
OperationLatencyNotes
FFI call (horus_get_abi_version)11 nsC++ bench — baseline overhead
CmdVel send (16 bytes)75 nsSend-side median (p99 135 ns)
LaserScan send (1480 bytes)210 nsSend-side median (p99 283 ns)
Scheduler tick (empty)37 nsC++ bench — no nodes
Scheduler tick (1 node)248 nsC++ bench — median
Scheduler tick (10 nodes)2.2 usC++ bench — median
Scheduler tick (50 nodes)10.9 usC++ bench — median
Throughput2.89M ticks/secC++ 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:

BuildSize
horus build (debug)82.3 MB
horus build --release2.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_;
};