Performance Optimization

HORUS is already fast by default. This guide helps you squeeze out extra performance when needed.

Cross-Platform Philosophy

HORUS is designed for development on any OS with production deployment on Linux:

PhaseSupported PlatformsPerformance
DevelopmentWindows, macOS, LinuxGood (standard IPC)
TestingWindows, macOS, LinuxGood (standard IPC)
ProductionLinux (recommended)Best (sub-100ns with RT)

All performance features use graceful degradation - your code runs everywhere, with maximum performance on Linux. Advanced features like RtConfig (SCHED_FIFO, mlockall) and SIMD acceleration automatically fall back to safe defaults on unsupported platforms.

Why HORUS is Fast

Shared Memory Architecture

Zero network overhead: Data written to shared memory, read directly by subscribers

PlatformShared Memory Backend
Linux/dev/shm (tmpfs)
macOSPOSIX shm (shm_open)
WindowsNamed Shared Memory (CreateFileMapping)

Zero serialization: Fixed-size structs copied directly to shared memory

Zero-copy for pool-backed payloads: Image, PointCloud, DepthImage and alloc_tensor() keep the payload in the pool and put only a descriptor through the ring, so the bytes are never copied. Ordinary send() copies the message into the ring slot

Optimized Data Structures

HORUS uses carefully optimized memory layouts to minimize latency. The communication paths are designed for maximum throughput with predictable timing — cross-process single-producer paths measure ~198ns p50, contended multi-producer paths ~304ns p50.

Benchmark Results

Measured Latency

Measurement Note: These are the HORUS repository's own published results (Intel Core i7-10750H @ 2.60 GHz, powersave governor, 100K iterations per scenario, RDTSC timing with Tukey IQR outlier removal). All latencies are one-way (publish → receive). For round-trip, approximately double these values.

Every topic is shared-memory backed, so every backend below is cross-process. There is no intra-process or same-thread path — the heap ring-buffer backends (SpscIntra, SpmcIntra, MpmcIntra, DirectChannel) were removed, along with the single-digit-nanosecond figures that came with them.

Cross-process (shared memory):

ScenarioBackendp50p99
1 pub, 2 subSpmcShm198ns298ns
1 pub, 8 subSpmcShm276ns510ns
4 pub, 4 subPodShm304ns1.5µs
Raw SHM atomic (hardware floor)167ns319ns

The Backend column names the route the benchmark exercised, which it selects explicitly. It is not what auto-detection would pick for that topology: a real 1-publisher / many-subscriber POD topic gets PodShm, because SpmcShm's consumers compete for messages rather than each receiving the stream.

Real message types (measured with robotics_messages_benchmark, 50K iterations after 5K warmup):

MessageSizeMedianp99
CmdVel16B75ns135ns

Key insight: cross-process shared memory adds only ~31ns of framework overhead over the raw shared-memory hardware floor.

Reproduce these numbers yourself with cargo run --release -p horus_benchmarks --bin all_paths_latency (backend scenarios) and --bin robotics_messages_benchmark (real message types); the full result set and methodology live in benchmarks/README.md in the HORUS repository.

Throughput

No published throughput table. benchmarks/README.md publishes latency percentiles only. The HORUS repository has no throughput result for a 1 KB or 100 KB payload and none for a many-publisher / many-subscriber topology — no benchmark in it even sends a 100 KB message. The largest payloads anywhere in benchmarks/ are 4 KB (research_latency), 1 KB (ThroughputPayload1K in benches/topic_throughput.rs) and the 1,480-byte LaserScan in robotics_messages_benchmark.

The only per-message throughput figures HORUS publishes come from the same robotics_messages_benchmark run as the latency row above. They are send-only — producer-side send(), one publisher, derived from the median send time — not a sustained pub/sub rate:

MessageSizeSend-only throughput
CmdVel16 B12.14 M msg/s
Imu304 B7.46 M msg/s
JointCommand928 B6.89 M msg/s
LaserScan1,480 B4.42 M msg/s

Full result set on the Benchmarks page.

For anything larger, or for a topology with more than one publisher, measure it yourself rather than extrapolating:

# 16 B / 256 B / 1 KB sustained send, Criterion
cargo bench -p horus_benchmarks --bench topic_throughput

# 1:1 through 8:8 producers:consumers, with scaling efficiency
cargo run --release -p horus_benchmarks --bin scalability_benchmark

# sustained msg/s over 60s+, per-second granularity
cargo run --release -p horus_benchmarks --bin research_throughput

Build Optimization

Always Use Release Mode

Debug builds are 10–50x slower:

# SLOW: Debug build
horus run

# FAST: Release build
horus run --release

Why it matters: at a 1 kHz control loop a debug build typically misses its deadlines outright, and its timing tells you nothing about release performance. The scheduler says so itself at startup when it sees RT nodes in a debug build:

Note: 1 real-time node in a debug build. Debug is typically 10-50x slower,
so deadline misses here are expected and timing is not representative.
Use `horus run --release` to measure.

Measurement Note: 10–50x is the figure HORUS reports at runtime (horus_core/src/scheduling/scheduler/mod.rs), not a benchmarked ratio. The repository publishes no debug-vs-release per-tick measurement — the numbers in benchmarks/README.md are all release builds. Measure your own workload with horus run --release before and after rather than assuming a multiplier.

Enable LTO in your Cargo.toml for additional 10-20% speedup:

# Cargo.toml
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1

Warning: Slower compilation, but faster execution.

Target CPU Features

Release builds target a baseline CPU so the binary runs anywhere. For maximum performance on a machine you control, compile targeting its specific CPU:

RUSTFLAGS="-C target-cpu=native" cargo build --release

Gains: 5-15% from CPU-specific instructions. The resulting binary only runs on CPUs with the same feature set, so build this way on (or for) the deployment machine.

Hardware Acceleration

HORUS automatically uses hardware-accelerated memory operations when available (e.g., AVX2 on x86_64 for large payload copies). Support is detected at runtime, so no build flags are needed — your code runs on any platform, with extra performance on supported hardware.

Message Optimization

Use Fixed-Size Types

// FAST: Fixed-size array
pub struct LaserScan {
    pub ranges: [f32; 360],  // Stack-allocated
}

// SLOW: Dynamic vector
pub struct BadLaserScan {
    pub ranges: Vec<f32>,  // Heap-allocated
}

Impact: Fixed-size avoids heap allocations in hot path.

Prefer Small Message Types

// FAST: Small, fixed-size struct
let topic: Topic<Pose2D> = Topic::new("pose")?;
topic.send(Pose2D::new(1.0, 2.0, 0.5));
// Cross-process IPC latency: ~198-304ns p50 depending on topology

// SLOWER: Larger struct with more data
let topic: Topic<SensorBundle> = Topic::new("sensors")?;
// Latency scales linearly with message size

Rule: Use the smallest struct that represents your data. Avoid padding and unused fields.

Choose Appropriate Precision

// f32 (single precision) - sufficient for most robotics
pub struct FastPose {
    pub x: f32,  // 4 bytes
    pub y: f32,  // 4 bytes
}

// f64 (double precision) - scientific applications
pub struct PrecisePose {
    pub x: f64,  // 8 bytes
    pub y: f64,  // 8 bytes
}

Rule: Use f32 unless you need scientific precision.

Minimize Message Size

// GOOD: 8 bytes
struct CompactCmd {
    linear: f32,   // 4 bytes
    angular: f32,  // 4 bytes
}

// BAD: 1KB+ bytes
struct BloatedCmd {
    linear: f32,
    angular: f32,
    metadata: [u8; 256],    // Unused
    debug_info: [u8; 768],  // Unused
}

Every byte matters: Latency scales with message size.

Batch Small Messages

Instead of sending 100 separate f32 values:

// SLOW: 100 separate messages
for value in values {
    topic.send(value);  // 100 IPC operations
}

// FAST: One batched message
pub struct BatchedData {
    values: [f32; 100],
}
topic.send(batched);  // 1 IPC operation

Speedup: 50-100x for batched operations.

Node Optimization

Keep tick() Fast

Target: <1ms per tick for real-time control.

// GOOD: Fast tick
fn tick(&mut self) {
    let data = self.read_sensor();     // Quick read
    self.process_pub.send(data);  // ~200ns cross-process
}

// BAD: Slow tick
fn tick(&mut self) {
    let data = std::fs::read_to_string("config.yaml").unwrap();  // 1-10ms!
    // ...
}

File I/O, network calls, sleeps = slow. Do these in init() or separate threads.

Pre-Allocate in init()

fn init(&mut self) -> Result<()> {
    // Pre-allocate buffers
    self.buffer = vec![0.0; 10000];

    // Open connections
    self.device = Device::open()?;

    // Load configuration
    self.config = Config::from_file("config.yaml")?;

    Ok(())
}

fn tick(&mut self) {
    // Use pre-allocated resources - no allocations here!
    self.buffer[0] = self.device.read();
}

Allocations in tick() = slow. Move to init().

Avoid Unnecessary Cloning

// BAD: Unnecessary clone
fn tick(&mut self) {
    if let Some(data) = self.sub.recv() {
        let copy = data.clone();  // Unnecessary!
        self.process(copy);
    }
}

// GOOD: Direct use
fn tick(&mut self) {
    if let Some(data) = self.sub.recv() {
        self.process(data);  // Already cloned by recv()
    }
}

Topic::recv() already clones data. Don't clone again.

Minimize Logging

// BAD: Logging every tick
fn tick(&mut self) {
    hlog!(debug, "Tick #{}", self.counter);  // Slow!
    self.counter += 1;
}

// GOOD: Conditional logging
fn tick(&mut self) {
    if self.counter % 1000 == 0 {  // Log every 1000 ticks
        hlog!(info, "Reached tick #{}", self.counter);
    }
    self.counter += 1;
}

Logging is expensive. Log sparingly in hot paths.

Scheduler Optimization

Understanding Tick Rate

The default scheduler runs at 100 Hz (10ms per tick). Use .tick_rate() to change it:

// Default: 100 Hz
let scheduler = Scheduler::new();

// 10kHz for high-performance control loops
let scheduler = Scheduler::new().tick_rate(10_000_u64.hz());

Key Point: Keep individual node tick() methods fast (ideally <1ms) to maintain the target tick rate.

Use Priority Levels

// Critical tasks run first (order 0 = highest)
scheduler.add(safety).order(0).done();

// Logging runs last (order 100 = lowest)
scheduler.add(logger).order(100).done();

Predictable execution order = better performance. Use lower numbers for higher priority tasks.

Minimize Node Count

// BAD: 50 small nodes
for i in 0..50 {
    scheduler.add(TinyNode::new(i)).order(50).done();
}

// GOOD: One aggregated node
scheduler.add(AggregatedNode::new()).order(50).done();

Fewer nodes = less scheduling overhead.

Transports

Local communication goes through shared memory; cross-machine communication goes through UDP in horus_net.

Transport Options

TransportLatency (one-way, p50)Requirements
Shared Memory (Topic, 1 pub : N sub)~198nsLocal only
Shared Memory (Topic, N pub : N sub)~304nsLocal only
UDP (horus_net, cross-machine)Network-boundnet feature

Enable Cross-Machine Networking

LAN replication is opt-in — HORUS ships minimal by default (fast IPC via shared memory). Build with the net feature to enable transparent cross-machine topic sharing:

horus run --net

Or make it permanent in horus.toml:

enable = ["net"]

cargo build --features net does not work in a HORUS project: --features applies to your own crate, and the generated Cargo.toml declares no [features] section at all — net is a feature of the horus dependency. Cargo rejects the command before compiling anything.

When enabled, replication auto-starts on scheduler.run(). Disable it at runtime with .network(false) or HORUS_NET_ENABLED=false.

Network Topics

Replication is transparent: topics you create with Topic::new() keep using shared memory locally and are mirrored to LAN peers automatically — there is no separate network-topic API. Tune replication with the [network] section of horus.toml or the HORUS_NET_* environment variables. The UDP transport sends one datagram per syscall (send_to/recv_from) on every platform. See Network Backends for details.

Shared Memory Optimization

Check Available Space

df -h /dev/shm

Insufficient space = message drops.

Increase /dev/shm Size

# Increase to 4GB
sudo mount -o remount,size=4G /dev/shm

More space = larger buffer capacity.

Clean Up Stale Topics

Note: HORUS automatically cleans up sessions after each run. Manual cleanup is rarely needed.

# Clean HORUS shared memory (if needed after crashes)
horus clean --shm

Stale topics from crashes can waste space, but auto-cleanup prevents this in normal operation.

Topic Memory Usage

Topics carrying inline (Pod) messages use shared memory slots proportional to message size. Keep those messages small to reduce memory footprint:

// Inline (Pod) messages: the ring slot holds the whole struct
let cmd: Topic<CmdVel> = Topic::new("cmd_vel")?;       // 16B per slot

// Pool-backed types are zero-copy: the ring slot holds a fixed-size
// descriptor, the point data lives in the shared TensorPool
let cloud: Topic<PointCloud> = Topic::new("cloud")?;    // 272B descriptor per slot

Balance: For inline Pod messages, message size directly affects shared memory consumption. Pool-backed types (Image, PointCloud, DepthImage, OccupancyGrid, CostMap) keep only a fixed-size descriptor in the ring, so their slot cost does not grow with the payload.

Profiling and Measurement

Built-In Metrics

HORUS automatically tracks node performance metrics. Use horus monitor to view real-time performance data including tick duration, messages sent, and CPU usage.

Available metrics (on NodeMetrics):

  • total_ticks: Total number of ticks
  • avg_tick_duration_ms: Average tick time in milliseconds
  • max_tick_duration_ms: Worst-case tick time in milliseconds
  • messages_sent, messages_received: always 0 — nothing in the runtime writes these two NodeMetrics fields. Use horus topic list / horus topic hz for traffic counts.
  • errors_count: Total error count
  • uptime_seconds: Node uptime in seconds

IPC Latency Logging

HORUS automatically tracks IPC timing for each topic operation. The horus monitor web interface displays per-log-entry metrics:

Tick: 12μs | IPC: 296ns

Each log entry includes tick_us (node tick time in microseconds) and ipc_ns (IPC write time in nanoseconds).

Manual Profiling

use std::time::Instant;

fn tick(&mut self) {
    let start = Instant::now();

    self.expensive_operation();

    let duration = start.elapsed();
    println!("Operation took: {:?}", duration);
}

CPU Profiling

Use perf on Linux:

# Profile your application
perf record --call-graph dwarf horus run --release

# View results
perf report

Hotspots show where CPU time is spent.

Common Performance Pitfalls

Pitfall: Using Debug Builds

# SLOW: debug build, 10–50x slower, timing not representative
horus run

# FAST: release build, the configuration all published numbers use
horus run --release

Fix: Always use --release for benchmarks and production.

Pitfall: Allocations in tick()

// BAD
fn tick(&mut self) {
    let buffer = vec![0.0; 1000];  // Heap allocation every tick!
}

// GOOD
struct Node {
    buffer: Vec<f32>,  // Pre-allocated
}

fn init(&mut self) -> Result<()> {
    self.buffer = vec![0.0; 1000];  // Allocate once
    Ok(())
}

Fix: Pre-allocate in init().

Pitfall: Excessive Logging

// BAD: 100 logs per second
fn tick(&mut self) {
    hlog!(debug, "Tick");  // Every 10ms!
}

// GOOD: 1 log per second
fn tick(&mut self) {
    self.tick_count += 1;
    if self.tick_count % 100 == 0 {
        hlog!(info, "100 ticks completed");
    }
}

Fix: Log sparingly.

Pitfall: Large Message Types

// BAD: 1MB per message
pub struct HugeMessage {
    image: [u8; 1_000_000],
}

// GOOD: Compressed or separate channel
pub struct CompressedImage {
    data: Vec<u8>,  // JPEG compressed, ~50KB
}

Fix: Compress or split large data.

Pitfall: Synchronous I/O in tick()

// BAD: Blocking I/O
fn tick(&mut self) {
    let data = std::fs::read("data.txt").unwrap();  // Blocks!
}

// GOOD: Async or pre-loaded
fn init(&mut self) -> Result<()> {
    self.data = std::fs::read("data.txt")?;  // Load once
    Ok(())
}

Fix: Move I/O to init() or use async.

Performance Checklist

Before deployment, verify:

  • Build in release mode (--release)
  • Profile with perf or similar
  • tick() completes in <1ms
  • No allocations in tick()
  • Messages use fixed-size types
  • Logging is rate-limited
  • Shared memory has sufficient space
  • IPC latency is <10µs
  • Priority levels set correctly

Measuring Your Performance

Latency Measurement

use std::time::Instant;

struct BenchmarkNode {
    pub_topic: Topic<f32>,
    sub_topic: Topic<f32>,
    start_time: Option<Instant>,
}

impl Node for BenchmarkNode {
    fn tick(&mut self) {
        // Publish
        self.start_time = Some(Instant::now());
        self.pub_topic.send(42.0);

        // Receive
        if let Some(data) = self.sub_topic.recv() {
            if let Some(start) = self.start_time {
                let latency = start.elapsed();
                println!("Round-trip latency: {:?}", latency);
            }
        }
    }
}

Throughput Measurement

struct ThroughputTest {
    pub_topic: Topic<f32>,
    message_count: u64,
    start_time: Instant,
}

impl Node for ThroughputTest {
    fn tick(&mut self) {
        for _ in 0..1000 {
            self.pub_topic.send(42.0);
            self.message_count += 1;
        }

        if self.message_count % 100_000 == 0 {
            let elapsed = self.start_time.elapsed().as_secs_f64();
            let throughput = self.message_count as f64 / elapsed;
            println!("Throughput: {:.0} msg/s", throughput);
        }
    }
}

Real-Time Configuration

For hard real-time applications requiring deterministic latency, HORUS provides system-level RT configuration:

use horus::prelude::*;

// Configure for hard real-time operation
let config = RtConfig::builder()
    .memory_locked(true)          // mlockall() - No page faults
    .scheduler(RtScheduler::Fifo) // SCHED_FIFO - Preempts normal processes
    .priority(80)                 // RT priority (1-99)
    .cpu_affinity(&[2, 3])        // Pin to isolated cores - No migration jitter
    .warn_on_degradation(true)
    .build();

// apply() returns RtApplyResult - inspect it to see what was degraded
config.apply()?;

For detailed configuration options, see the Real-Time Configuration Guide.

Next Steps