Real-Time Configuration (RtConfig)

HORUS provides RtConfig, a builder API for configuring system-level real-time settings. It gives hard real-time robotics applications deterministic execution on top of HORUS IPC, which measures in the hundreds of nanoseconds over shared memory (see Performance Impact).

Cross-Platform Support

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

PlatformRtConfig BehaviorUse Case
LinuxFull RT features enabledProduction deployment
macOSGraceful degradation (no RT)Development & testing
WindowsGraceful degradation (no RT)Development & testing

On non-Linux platforms, RtConfig::apply() returns a degraded result and your code continues running normally - just without kernel-level RT guarantees. This allows you to develop and test on any OS.

use horus::prelude::*;

let config = RtConfig::builder()
    .memory_locked(true)
    .scheduler(RtScheduler::Fifo)
    .priority(80)
    .cpu_affinity(&[2, 3])
    .build();

match config.apply() {
    Ok(RtApplyResult::FullSuccess) => println!("Full RT enabled (Linux)"),
    Ok(RtApplyResult::Degraded(reasons)) => {
        // macOS/Windows, and also Linux without PREEMPT_RT or without
        // the required capabilities: keeps running, minus the RT guarantees
        println!("Running in degraded mode: {:?}", reasons);
    }
    Err(e) => eprintln!("Error: {}", e),
}

Overview

RtConfig controls kernel-level features that eliminate latency sources (Linux only):

FeaturePurposeDefault
memory_lockedPrevent page faults via mlockall()false
schedulerRT scheduling class (Normal/Fifo)Normal
priorityRT scheduling priority (1-99)None (unset)
cpu_affinityPin to specific CPU coresNone

Quick Start

use horus::prelude::*;

// Apply hard real-time configuration
let config = RtConfig::builder()
    .memory_locked(true)
    .scheduler(RtScheduler::Fifo)
    .priority(80)
    .cpu_affinity(&[2, 3])  // Pin to cores 2, 3
    .build();
config.apply()?;

// Now all HORUS operations run with RT guarantees
let link: Topic<CmdVel> = Topic::new("cmd_vel")?;  // see Performance Impact below

Common Configurations

Every configuration is built with RtConfig::builder() - HORUS ships no preset constructors, so the chains below are the recipes to copy.

Hard Real-Time

For safety-critical applications with strict timing requirements:

// High priority, locked memory, pinned to isolated cores
let config = RtConfig::builder()
    .memory_locked(true)
    .scheduler(RtScheduler::Fifo)
    .priority(80)
    .cpu_affinity(&[2, 3])
    .build();
config.apply()?;

This enables:

  • mlockall(MCL_CURRENT | MCL_FUTURE) - No page faults
  • SCHED_FIFO at the requested priority, clamped to the kernel's RT range - Preempts all normal processes
  • CPU affinity - No migration jitter. The core list is a preference order, not a set: the thread is pinned to the first usable core in it and the rest never enter the affinity mask, even though the confirmation line echoes the whole list. To spread work across cores, pin each node's thread separately rather than passing several cores to one

Lower-Priority Real-Time

For applications that want RT scheduling without locking memory (runs without CAP_IPC_LOCK, but still needs CAP_SYS_NICE for SCHED_FIFO):

let config = RtConfig::builder()
    .scheduler(RtScheduler::Fifo)
    .priority(50)
    .build();
config.apply()?;

Normal Operation

Default configuration with no RT features:

let config = RtConfig::default();  // or RtConfig::builder().build()
config.apply()?;  // No RT modifications

Builder API

Every setter in one chain:

use horus::prelude::*;

let config = RtConfig::builder()
    .memory_locked(true)           // mlockall()
    .scheduler(RtScheduler::Fifo)  // SCHED_FIFO
    .priority(90)                  // High priority (1-99)
    .cpu_affinity(&[4, 5, 6, 7])   // Pin to cores 4-7
    .build();

config.apply()?;

Builder Methods

MethodDescription
memory_locked(bool)Enable/disable mlockall()
scheduler(RtScheduler)Scheduling class
priority(i32)RT priority (1-99 for Fifo)
cpu_affinity(&[usize])CPU cores to pin to
warn_on_degradation(bool)Log warnings when features degrade

You must call .priority(n) for .scheduler(RtScheduler::Fifo) to take effect - apply() skips scheduler setup entirely when no priority is set.

Scheduling Classes

RtScheduler comes from the prelude (use horus::prelude::*;):

VariantLinux SchedulerPreemptionUse Case
NormalSCHED_OTHERTime-slicedGeneral applications
FifoSCHED_FIFOHighest-priority-firstHard RT control loops

RtScheduler has exactly these two variants. SCHED_DEADLINE is reachable, but only per-node via NodeBuilder::deadline_scheduler() - not through RtScheduler.

System Requirements

Capabilities Required

FeatureCapability Needed
memory_lockedCAP_IPC_LOCK (or unlimited memlock)
scheduler(Fifo)CAP_SYS_NICE
priority > 0CAP_SYS_NICE
cpu_affinityNone (always available)
# For development - grant capabilities to your binary
sudo setcap 'cap_sys_nice=ep cap_ipc_lock=ep' ./target/release/my_robot

# For production - run as RT user
sudo usermod -a -G realtime $USER

Alternative: let HORUS do it

horus setup-rt --check   # see what is missing
horus setup-rt           # write the limits file, offer the RT kernel

horus setup-rt performs the configuration described below — the limits file and, on a standard kernel, the distribution's real-time kernel package. It asks before each of those two changes and takes silence for no, so a run without a terminal (CI, a provisioning script) changes nothing unless HORUS_ASSUME_YES=1 is set. horus setup-rt --undo removes the limits file only; a kernel package or an isolcpus boot parameter has to be undone by hand, and --undo prints the removal command for the distribution it detects. The manual steps that follow are the same thing done by hand.

Alternative: Increase memlock limit

# In /etc/security/limits.conf
your_user  soft  memlock  unlimited
your_user  hard  memlock  unlimited

PREEMPT_RT Kernel

For a bounded worst case under load, use a PREEMPT_RT kernel:

Ubuntu/Debian

The package name differs between the two — Ubuntu has never shipped the arch-suffixed name:

# Ubuntu
sudo apt install linux-image-realtime

# Debian
sudo apt install linux-image-rt-amd64      # linux-image-rt-arm64 on 64-bit ARM

# Reboot and select RT kernel in GRUB
sudo reboot

horus setup-rt picks the right one for the distribution it finds and asks before installing anything. It also knows Fedora (kernel-rt kernel-rt-core) and Arch (linux-rt linux-rt-headers); on any other distribution it prints those commands and installs nothing. See horus setup-rt for the full table.

Verify RT Kernel

uname -a | grep -i rt
# Should show: PREEMPT_RT

Kernel Tuning

# Isolate CPU cores for RT (add to kernel cmdline)
isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3

# Disable CPU frequency scaling
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

Integration with the Scheduler

RtConfig provides system-level configuration. Combine it with per-node timing constraints on the scheduler for node-level budgets and deadlines:

use horus::prelude::*;
use std::time::Duration;

// 1. Configure system for RT
RtConfig::builder()
    .memory_locked(true)
    .scheduler(RtScheduler::Fifo)
    .priority(80)
    .cpu_affinity(&[2, 3])
    .build()
    .apply()?;

// 2. Create scheduler with safety-critical settings
let mut scheduler = Scheduler::new()
    .tick_rate(1000_u64.hz())
    .prefer_rt();

// 3. Add nodes with timing constraints
struct MotorControl { /* ... */ }

impl Node for MotorControl {
    fn tick(&mut self) {
        // Control loop body
    }
}

scheduler.add(MotorControl { /* ... */ })
    .order(0)                              // Execution order 0 (highest)
    .budget(Duration::from_micros(50))     // 50µs WCET budget
    .deadline(Duration::from_micros(500))  // 500µs deadline (2kHz)
    .done()?;

scheduler.run()?;

RT is enabled scheduler-wide rather than per node: .prefer_rt() attempts RT and degrades with a warning, .require_rt() panics if the system lacks RT capabilities.

Performance Impact

Every HORUS topic is shared-memory backed, so there is no intra-process fast path - publisher and subscriber go through the same SHM ring even inside a single process. Measured latency on an Intel Core i7-10750H @ 2.60 GHz (6C/12T), powersave governor, 100K iterations per scenario - without any RT configuration applied (benchmarks/README.md):

PathBackendp50p99
1 pub / 2 subSpmcShm198 ns298 ns
1 pub / 4 subSpmcShm236 ns417 ns
4 pub / 4 subPodShm304 ns1.5 µs

The backend is chosen automatically from the topic's topology and message type; the names above label what each benchmark exercised, not something you select.

There is no published before/after RT-config comparison. What RtConfig changes is the tail, not the median: memory_locked keeps page faults out of the hot path, SCHED_FIFO stops normal-priority tasks preempting the control loop, and cpu_affinity stops the kernel migrating the thread between cores. Measure your own worst case on your own kernel and hardware.

Graceful Degradation

RtConfig degrades gracefully when features aren't available:

use horus::prelude::*;

let config = RtConfig::builder()
    .memory_locked(true)
    .scheduler(RtScheduler::Fifo)
    .priority(80)
    .cpu_affinity(&[2, 3])
    .build();

match config.apply() {
    Ok(RtApplyResult::FullSuccess) => {
        println!("Full RT enabled");
    }
    Ok(RtApplyResult::Degraded(reasons)) => {
        // System continues with available features
        for reason in &reasons {
            match reason {
                RtDegradation::MemoryLockUnavailable(msg) => {
                    println!("Memory locking unavailable: {}", msg);
                }
                RtDegradation::SchedulerDegraded(msg) => {
                    println!("RT scheduler unavailable: {}", msg);
                }
                RtDegradation::PriorityClamped { requested, actual } => {
                    println!("Priority clamped: {} -> {}", requested, actual);
                }
                RtDegradation::AffinityUnavailable(msg) => {
                    println!("CPU affinity unavailable: {}", msg);
                }
                RtDegradation::NoPreemptRt => {
                    println!("PREEMPT_RT kernel not detected");
                }
            }
        }
    }
    Err(e) => eprintln!("IO error: {}", e),
}

Use warn_on_degradation(true) to automatically log warnings. apply() never fails on degradation - it always returns Ok(RtApplyResult::Degraded(..)), so inspect the result if you need to treat degradation as fatal.

Verification

Query Kernel RT Capabilities

use horus::prelude::*;

let info = RtKernelInfo::detect();
println!("PREEMPT_RT: {}", info.preempt_rt);
println!("Kernel: {}", info.kernel_version);
println!("RT priority range: {}-{}", info.min_rt_priority, info.max_rt_priority);
println!("mlockall permitted: {}", info.mlockall_permitted);

Check Current Scheduler and Affinity

RtConfig does not expose a getter for the current thread's scheduler or affinity - use the standard tools:

# Scheduling class (FF = SCHED_FIFO) and priority
ps -eo pid,cls,pri,comm | grep my_robot

# CPU affinity of a given PID
taskset -cp $$

Troubleshooting

"Operation not permitted" on mlockall

# Check memlock limit
ulimit -l
# If not unlimited, increase in /etc/security/limits.conf
# Or grant CAP_IPC_LOCK capability

"Operation not permitted" on SCHED_FIFO

# Grant CAP_SYS_NICE
sudo setcap 'cap_sys_nice=ep' ./target/release/my_robot

High latency despite RT config

  1. Verify PREEMPT_RT kernel: uname -a | grep RT
  2. Check CPU isolation: cat /sys/devices/system/cpu/isolated
  3. Disable CPU frequency scaling
  4. Check for other RT processes: ps -eo pid,cls,pri,comm | grep -E "FF|RR"

CPU affinity not taking effect

# Verify isolated cores
cat /sys/devices/system/cpu/isolated
# Should show your isolated cores: 2-3

Best Practices

  1. Isolate CPU cores - Use kernel isolcpus parameter
  2. Use PREEMPT_RT kernel - Essential for hard RT guarantees
  3. Pin to isolated cores - Avoid contention with system tasks
  4. Pre-fault everything - memory_locked(true) covers the heap; the stack is separate and pre-faulted via Scheduler::prefault_stack(bytes), not RtConfig
  5. Monitor worst-case latency - Not just average
  6. Test under load - RT violations often appear under stress

Complete Example

use horus::prelude::*;

fn main() -> Result<()> {
    // Configure system for hard real-time
    let rt_config = RtConfig::builder()
        .memory_locked(true)
        .scheduler(RtScheduler::Fifo)
        .priority(80)
        .cpu_affinity(&[2, 3])  // Isolated cores
        .warn_on_degradation(true)
        .build();

    match rt_config.apply()? {
        RtApplyResult::FullSuccess => println!("Full RT configuration applied"),
        RtApplyResult::Degraded(reasons) => {
            println!("RT applied with degradation: {:?}", reasons);
        }
    }

    // Create RT communication channel
    let producer: Topic<CmdVel> = Topic::new("motor_cmd")?;
    let consumer: Topic<CmdVel> = Topic::new("motor_cmd")?;

    // Every HORUS topic is shared-memory backed, so both endpoints talk
    // through the same SHM ring even in one process - RT config is what
    // keeps the tail flat

    loop {
        let cmd = CmdVel::new(1.0, 0.5);
        producer.send(cmd);

        if let Some(received) = consumer.recv() {
            // Process at 10kHz+ with deterministic timing
        }
    }
}

See Also