English · 简体中文 · Português (Brasil) · 日本語 · Español · Deutsch
Real-time distributed middleware for Rust, Python, and C++. Sub-200ns IPC.
Docs · Quick Start · Benchmarks · Coming from ROS2? · Discord
curl -fsSL https://github.com/softmata/horus/raw/main/install.sh | bash
horus new my_robot && cd my_robot && horus runThe installer resolves the latest release tag and takes both halves from it: the
prebuilt CLI, verified against that release's SHA256SUMS, and the matching
source tree, cached at ~/.horus/cache/horus@<version>. Both come from the same
tag on purpose — horus run compiles your project against that cached tree as a
path dependency, so a CLI built from one revision and libraries from another
gives you nodes that cannot read each other's shared memory.
Pin the tag when you want the same install twice. The variable goes on the right
of the pipe — that is the environment bash reads; on the left it lands in
curl's, where nothing reads it and you silently get the latest release anyway:
curl -fsSL https://github.com/softmata/horus/raw/main/install.sh | HORUS_VERSION=v0.4.1 bashOr install manually:
git clone https://github.com/softmata/horus.git && cd horus && ./install.shRequires Rust 1.90 or newer (rustup update stable). Python 3.9+ for the
Python API; CMake 3.20+ and a C++17 compiler for the C++ API.
C++: link against libhorus_cpp and #include <horus/horus.hpp>.
Python: pip install "horus-robotics>=0.4.1" — keep the floor. The newest
release on PyPI is 0.1.9, from March 2026, and it speaks an older shared-memory
format than a 0.4.x CLI: bare pip install horus-robotics gets you a package
that imports, runs, and sees none of your topics. The floor turns that into a
resolver error instead. Until 0.4.x reaches PyPI, build the bindings from the
tree the installer already cached: pip install ~/.horus/cache/horus@0.4.1/horus_py.
Rust: there is no crates.io channel. cargo add horus and cargo install horus fetch an unrelated crate that happens to own the name; HORUS's own crates
carry git and path dependencies that cargo package rejects, so none of them are
published. horus new and horus run point Cargo at the cached source instead.
Docker, upgrading, uninstalling and the platform matrix are in Install, Upgrade, Uninstall.
HORUS is a real-time distributed middleware that replaces DDS with shared-memory ring buffers and lock-free synchronization. Built for any system where latency, determinism, and safety matter — robotics, industrial automation, autonomous vehicles, trading systems, game engines, and more.
| HORUS | ROS2 | |
|---|---|---|
| IPC latency | 171 ns one-way, cross-process (cross_process_benchmark) |
~5 µs (REP 2014) |
| Scheduling | Deterministic, 5 execution classes | Best-effort callbacks |
| RT support | Built-in (budget, deadline, watchdog) | Manual DDS QoS |
| Safety | Graduated watchdog, safe-state hook, BlackBox | Application-level |
| AI + RT | Same process — AsyncIo for GPU, RT for motors | Separate processes |
| Tensors | Pool-backed, zero-copy to NumPy (host memory) | Serialize → deserialize |
| Languages | Rust + Python + C++ (same shared memory) | C++ + Python (DDS serialization) |
| Config | Single horus.toml |
package.xml + CMakeLists.txt + launch files |
| Setup | horus new && horus run |
colcon build + source install + launch |
Rust — 1kHz motor controller in one file:
use horus::prelude::*;
message! {
SensorReading { position: f64, velocity: f64 }
MotorCommand { voltage: f64 }
}
struct Sensor {
reading: Topic<SensorReading>,
pos: f64,
}
impl Sensor {
fn new() -> Result<Self> {
Ok(Self { reading: Topic::new("sensor.data")?, pos: 0.0 })
}
}
impl Node for Sensor {
fn name(&self) -> &str { "sensor" }
fn tick(&mut self) {
self.pos += 0.01;
self.reading.send(SensorReading { position: self.pos, velocity: 0.5 });
}
}
struct Controller {
sensor: Topic<SensorReading>,
cmd: Topic<MotorCommand>,
target: f64,
}
impl Controller {
fn new() -> Result<Self> {
Ok(Self {
sensor: Topic::new("sensor.data")?,
cmd: Topic::new("motor.cmd")?,
target: 1.0,
})
}
}
impl Node for Controller {
fn name(&self) -> &str { "controller" }
fn tick(&mut self) {
if let Some(s) = self.sensor.recv() {
self.cmd.send(MotorCommand { voltage: (self.target - s.position) * 0.5 });
}
}
}
fn main() -> Result<()> {
let mut sched = Scheduler::new().tick_rate(1000_u64.hz());
sched.add(Sensor::new()?).order(0).build()?;
sched.add(Controller::new()?).order(1).rate(1000_u64.hz()).on_miss(Miss::SafeMode).build()?;
sched.run()
}You need three concepts to read that: a node (a struct with a tick()),
a topic (Topic::new("sensor.data")), and the scheduler that runs them.
Everything else in main() is timing policy and can wait: tick_rate() sets the
scheduler's clock (default 100 Hz), .order() sequences nodes within a tick
(default 0), .rate() moves a node onto its own real-time thread, and
.on_miss() says what to do when a node overruns its deadline. Delete all four
and the program still runs — every node ticks best-effort at 100 Hz. Add them
back when timing matters.
Execution classes →
Prefer less boilerplate? The node! macro writes the struct and impl Node
for you. horus new --macro scaffolds a starter in that style — a single
Controller publishing Twist on motors.cmd_vel, not the two-node example
above.
Python — same robot, 8 lines:
import horus
def sensor_tick(node):
node.send("sensor.data", {"position": sensor_tick.pos, "velocity": 0.5})
sensor_tick.pos += 0.01
sensor_tick.pos = 0.0
def controller_tick(node):
s = node.recv("sensor.data")
if s is not None:
node.send("motor.cmd", {"voltage": (1.0 - s["position"]) * 0.5})
horus.run(
horus.Node(name="sensor", pubs=["sensor.data"], tick=sensor_tick, rate=1000),
horus.Node(name="ctrl", subs=["sensor.data"], pubs=["motor.cmd"], tick=controller_tick, rate=1000),
)node.recv(topic) returns None when nothing is waiting. node.has_msg(topic)
asks the same question without consuming the message — the reading is held and
handed to the next recv(). horus new --python scaffolds a one-node starter
that uses both.
pubs= and subs= accept either a list of topics, as above, or a single bare
topic string: pubs="motor.cmd" and pubs=["motor.cmd"] build the same node.
Both spellings are in circulation across the docs, so a bare string in an
example is not a typo.
C++ — same robot, idiomatic API:
#include <horus/horus.hpp>
using namespace horus::literals;
// Struct-based node with built-in pub/sub (like Rust's impl Node)
class Controller : public horus::Node {
public:
Controller() : Node("controller") {
sensor_ = subscribe<horus::msg::CmdVel>("sensor.data");
motor_ = advertise<horus::msg::CmdVel>("motor.cmd");
}
void tick() override {
auto s = sensor_->recv();
if (!s) return;
horus::msg::CmdVel cmd{};
cmd.linear = (1.0f - s->get()->linear) * 0.5f;
motor_->send(cmd);
}
void enter_safe_state() override { /* stop motors */ }
private:
horus::Subscriber<horus::msg::CmdVel>* sensor_;
horus::Publisher<horus::msg::CmdVel>* motor_;
};
int main() {
horus::Scheduler sched;
sched.tick_rate(1000_hz);
horus::Publisher<horus::msg::CmdVel> sensor_pub("sensor.data");
sched.add("sensor").order(0)
.tick([&] {
auto out = sensor_pub.loan();
out->linear = 0.5f;
sensor_pub.publish(std::move(out));
}).build();
Controller ctrl;
sched.add(ctrl).order(1).on_miss(horus::Miss::SafeMode).build();
sched.spin();
}horus::log::info(node_name, message) writes to the HORUS log stream rather
than stdout, so horus log sees it. horus new --cpp scaffolds a one-node
starter that uses it.
All three languages share the same topics over shared memory — zero overhead between Rust, Python, and C++.
Five execution classes — the scheduler auto-selects based on your configuration:
sched.add(motor).order(0).rate(1000.hz()).on_miss(Miss::SafeMode).build()?; // RT
sched.add(planner).compute().build()?; // Thread pool
sched.add(estop).on("emergency.stop").build()?; // Event-driven
sched.add(detector).async_io().build()?; // GPU / network I/O
sched.add(logger).build()?; // Best-effortSet .rate(), .budget(), or .deadline() and RT is automatic — no manual thread management. Learn more →
The scheduler monitors every node at runtime:
- Graduated watchdog — warn → halve the rate → isolate → kill, at 3, 5, 10 and 20 consecutive misses by default. Recovery walks back down: 100 clean ticks de-isolate, 100 more restore the original rate. A killed node stays stopped.
- Deadline enforcement —
.budget()and.deadline()with miss policies (Warn, Skip, SafeMode, Stop) enter_safe_state()— you define what "safe" means per node (stop motors, close valves)- BlackBox flight recorder — ring-buffer event log for post-mortem crash analysis
- Fault tolerance — per-node failure policies (restart with backoff, skip, fatal)
Miss::SafeMode is a hook, not a state machine. The scheduler calls
enter_safe_state() once, on the transition into safe mode, and the node keeps
ticking afterwards — it is not isolated, and is_safe_state() is never polled.
So tick() has to go on publishing the safe outputs itself; a zeroed velocity
command still has to be sent every cycle. The latch clears the first time the
node meets its deadline again, which is what lets a later degradation be caught,
so a flapping node is safed once per episode rather than once per miss.
Isolating or stopping a node is the graduated watchdog's job, above.
Safety Monitor → · BlackBox → · Fault Tolerance →
Run camera → YOLO → tracking → motor control in one process. 4K frames stay in shared memory and reach NumPy without a copy. Tensor memory is host-side: the pool allocator is mmap, so moving a frame to a GPU is still an explicit host→device copy that you make.
def detector_tick(node):
frame = node.recv("camera")
if frame is not None:
array = frame.to_numpy() # zero-copy view of shared memory
for det in model(array):
node.send("detections", horus.Detection(
x=det.x, y=det.y, width=det.w, height=det.h,
confidence=det.conf, class_name=det.label
))8 built-in perception types: Detection, Detection3D, TrackedObject, SegmentationMask, Landmark, Image, PointCloud, CameraInfo. Learn more →
Everything you need for robotics, built-in:
// 40+ message types — all zero-copy Pod structs
let imu: Topic<Imu> = Topic::new("imu")?;
let cmd: Topic<CmdVel> = Topic::new("cmd_vel")?;
// Lock-free coordinate transforms (10-33x faster than ROS TF2)
let tf = TransformFrame::new();
tf.add_frame("laser").parent("base_link")
.static_transform(&Transform::from_translation([0.2, 0.0, 0.1]))
.build()?;
// Services (request/response) and Actions (long-running with feedback)
service! { AddTwoInts { request { a: i64, b: i64 } response { sum: i64 } } }
action! { Navigate { goal { x: f64, y: f64 } feedback { dist: f64 } result { ok: bool } } }Declare hardware in horus.toml, access typed handles in code. 30+ Terra HAL drivers — Dynamixel, RPLiDAR, RealSense, CAN, EtherCAT, and more.
[drivers.arm]
terra = "dynamixel"
port = "/dev/ttyUSB0"
baudrate = 1000000horus new my_robot # scaffold project (Rust, Python, or C++)
horus new my_bot --cpp # scaffold C++ project
horus run # build and run
horus topic list # inspect live topics
horus topic echo camera.rgb # watch messages (works across all languages)
horus monitor # TUI system dashboard
horus deploy pi@192.168.1.50 # deploy to robot
horus doctor # ecosystem health check
horus self update # upgrade the CLI and the cached source together40+ commands. Full CLI reference →
Measured with RDTSC cycle counting, Tukey IQR outlier filtering, bootstrap 95% CIs on Intel i9-14900K. Full methodology →
| Topology | HORUS | Measurement |
|---|---|---|
| Same-process pub/sub | 91 ns | producer-side send() |
| Cross-process | 171 ns | end-to-end, one-way |
| 1 pub → 3 subs | 80 ns | producer-side send() |
The link above is to the methodology — RDTSC timing with calibrated overhead
subtraction, Tukey IQR fences, bootstrap 95% CIs — which is shared. The numbers
are not: this table is an i9-14900K run whose raw percentiles are not published
here, while the tables in benchmarks/README.md were
taken on an Intel Core i7-10750H @ 2.60 GHz under the powersave governor. The
two sets are not comparable row to row, and the i7 figures are the ones this
repository can show its work for.
Reproduce with cargo run --release -p horus_benchmarks --bin all_paths_latency, which prints the
full percentile distribution, the backend selected for each topology, and the
measured hardware floor it subtracts.
Against ROS 2. The nearest published figure is ROS 2's REP 2014 reference for default DDS, ~5 µs median for a 64-byte same-process message. Compared to HORUS's end-to-end cross-process 171 ns — the harder case for HORUS, and therefore the conservative comparison — that is roughly 30x. HORUS does not measure ROS 2 itself, and no longer ships a benchmark that quotes it: the binary that did built ROS 2, CycloneDDS and FastDDS distributions arithmetically from two constants and wrote them beside real measurements, so it was deleted rather than relabelled. The REP 2014 figure above is cited, not measured here. Any number that matters to your decision is worth measuring on your own hardware and message sizes.
| vs iceoryx2 | HORUS med | iox2 med | median | p99 |
|---|---|---|---|---|
| Same-thread (8 B) | 55 ns | 416 ns | 7.6x | 5.8x |
| Cross-thread RTT (8 B) | 371 ns | 1716 ns | 4.6x | 3.9x |
| Cross-process RTT (8 B) | 413 ns | 1622 ns | 3.9x | 3.4x |
| Same-thread (1 KB) | 169 ns | 448 ns | 2.7x | 2.7x |
| Same-thread (4 KB) | 574 ns | 550 ns | 0.96x | 0.93x |
| MPMC 4P/4S recv cost | 35 ns | 216 ns | 6.2x | 3.0x |
| Throughput (same loop) | 37.8 M/s | 3.0 M/s | 12.8x | — |
Ratios above 1 favour HORUS. Both columns come from the same run, which is what makes the ratio trustworthy; the absolute nanoseconds drift with machine load and thermal state, so compare ratios across runs, not absolutes.
Unlike the ROS 2 row above, this one is measured on both sides — see the reproduce command below.
4 KB is now roughly a tie (0.96x median). It used to be 0.46x — see the zero-copy note below for why a 4 KB POD sent by value is the wrong shape for bulk data in the first place.
Bulk payloads do not go through Topic<[u8; N]>. Image, PointCloud,
DepthImage and Tensor keep their buffers in a shared-memory pool and put
only a 224-byte descriptor on the topic, so publishing is zero-copy and the
latency is flat in frame size:
| frame (Mono8) | pixels | one-way p50 |
|---|---|---|
| 64x64 | 4 KB | 306 ns |
| 640x480 | 307 KB | 297 ns |
| 1920x1080 | 2 MB | 284 ns |
That is the row to compare against a loan/publish API. The 4 KB line in the table above sends a POD by value, which copies 4 KB into the ring and 4 KB back out; it is the wrong API for bulk data and is kept in the table because removing a row where HORUS loses would be the more dishonest choice.
Reproduce with cargo run --release -p horus_benchmarks --bin topic_probe -- --image 1920x1080.
One caveat worth knowing before you build a camera pipeline: Image::new
zero-initialises the buffer, which for a 1920x1080 frame costs ~82 us. That is
a deliberate scrub — a pool slot can be reused by another process, so it is
cleared before it is handed out — but it means allocating a frame per capture
puts that cost on your critical path. Allocate once and clone; a clone is a
refcount bump and a descriptor copy, which is what the numbers above measure.
HORUS's advantage is largest at small payloads, which is where robotics control traffic lives: a CmdVel is 16 bytes. If your messages are camera frames or point clouds, use the tensor-backed types below rather than sending them by value, and measure on your own hardware either way.
The round-trip rows are full round trips, not halved. Halving reports a one-sided stall as half a stall on each leg, which understates exactly the tail that matters. The same-thread rows are one thread writing and then reading its own ring: a lower bound on IPC cost with no cross-core transfer in it, for either library. Prefer the cross-process row when quoting a number.
This table replaces one claiming 6.3x / 2.1x / 4.3x. Those came from a harness that gave the two libraries different topologies: the HORUS arm sent and received on a single handle, which selects an inlined same-instance fast path with no dispatch and no ring publication, while the iceoryx2 arm used a real publisher and subscriber. Both arms now use separate handles.
Reproduce with cargo run --release -p horus_benchmarks --bin iceoryx2_comparison --features iceoryx2, which links iceoryx2 and times it in the same harness.
Measured on an i7-10750H under the powersave governor; absolute values will
differ on your hardware, ratios less so.
Scales near-linearly to 100 nodes (14% degradation) and O(1) to 1,000 topics.
cargo run --release -p horus_benchmarks --bin all_paths_latency # run it yourself12 working projects in examples/ — from differential drive to quadruped gait generation. Every directory there is a horus.toml project: horus build and horus run drive all of them, in Rust, Python or C++.
The single-file API demos are examples of the horus crate. The workspace root is a virtual manifest, so select the package with -p:
cargo run -p horus --example 01_hello_node # your first node
cargo run -p horus --example 02_pub_sub # topics and messages
cargo run -p horus --example 03_multi_rate # multi-rate scheduling
cargo run -p horus --example 04_services # request/response
cargo run -p horus --example 05_realtime # RT with deadline enforcementFull examples → · Tutorials → · ROS 2 bridge recipe →
horus self update # move the CLI and the cached source to the latest release
horus self update --check # report current vs latest, change nothinghorus self update resolves the latest release, verifies the download against
that release's SHA256SUMS, replaces the binary, and refreshes
~/.horus/cache/horus@<version> at the same tag. Both halves move together, for
the reason in Get Started. Re-running the installer does the
same job, and is how you move to a particular release — including back to an
older one:
curl -fsSL https://github.com/softmata/horus/raw/main/install.sh | HORUS_VERSION=v0.4.1 bashTo see what you are running: horus --version reports the CLI, and
~/.horus/install_manifest.toml records what the last install put on the machine
— version, tag, commit and method. The CLI compares the two itself and warns when
they have drifted apart, naming the command that reconciles them; set
HORUS_STRICT_VERSION=1 to make that a hard failure instead, which is what you
want in CI.
curl -fsSL https://github.com/softmata/horus/raw/main/uninstall.sh | bashOr ./uninstall.sh from a clone. It prints an inventory first — binaries,
~/.horus (cache, install manifest, credentials, config), shell completions, the
man page, the shell-profile lines and /dev/shm/horus_* — and asks before
deleting any of it, on your terminal even when the script itself arrived through
the pipe. --dry-run prints that inventory and removes nothing. --yes runs
unattended and keeps ~/.horus/config.toml, your credentials and the Cargo
registry cache, so an automated run never removes more than a supervised one
would by default:
curl -fsSL https://github.com/softmata/horus/raw/main/uninstall.sh | bash -s -- --yesWith no terminal and no --yes — a CI job, a provisioning script — it stops and
says so rather than guessing at a default.
docker build --target dev -t horus:dev .
docker run --rm -it --shm-size=1g -v "$PWD:/work" -w /work horus:dev run--target dev is the one you want. A bare docker build . builds the default
stage, the slim CLI image the Dockerfile tags horus:cli — 284 MB,
no language toolchains. It can inspect a system, validate a manifest and read
logs, but it cannot build or run a project: horus run there stops at H060 Rust toolchain not installed. The dev image carries Rust, Python and CMake and
costs 3.98 GB for them.
Nodes talk through shared memory, so a container that runs them needs a real
/dev/shm — the 64 MB default is small for image or point-cloud topics, hence
--shm-size. Add --ipc=host for IPC between containers, and --cap-add=SYS_NICE
for real-time nodes: without it every RT thread drops to normal priority, and a
benchmark in that container measures the container. Each release tag publishes
both images to GitHub Container Registry — ghcr.io/softmata/horus:dev and
:latest, plus the immutable :<version> and :<version>-dev to pin a robot
to — so docker build is only needed for a fork or an unreleased commit. The
Dockerfile header covers the rest, including how to keep file ownership on bind
mounts.
| Platform | Prebuilt binary | Notes |
|---|---|---|
| Linux x86_64 | horus-linux-amd64.tar.gz |
glibc 2.28 or newer |
| Linux aarch64 | horus-linux-arm64.tar.gz |
glibc 2.28 or newer — Pi OS 64-bit, Jetson |
| Linux armv7 (32-bit) | horus-linux-armv7.tar.gz |
glibc 2.28 or newer — Pi OS 32-bit |
| macOS Apple Silicon | horus-macos-arm64.tar.gz |
|
| macOS Intel | horus-macos-amd64.tar.gz |
|
| Windows x86_64 | horus-windows-amd64.zip |
install.ps1, or install.sh under Git Bash / WSL |
On Windows, PowerShell takes the same install with the same knobs — set them as
their own statement, since PowerShell has no VAR=value command prefix:
irm https://github.com/softmata/horus/raw/main/install.ps1 | iexThe Linux binaries are linked against glibc rather than static, and are
cross-compiled against a glibc 2.28 floor — Debian 10, Ubuntu 18.04, RHEL 8 —
which the release workflow reads back out of each ELF and enforces. Releases cut
before that floor existed, v0.4.0 and earlier, were built natively on the CI
runner and need glibc 2.39, so they will not start on Pi OS, JetPack, Ubuntu
22.04, Debian 12 or RHEL 9. Below the floor, or on a musl distro such as Alpine,
install with HORUS_BUILD_FROM_SOURCE=1 and compile; budget hours, not minutes,
on a single-board computer.
| Variable | Effect |
|---|---|
HORUS_VERSION=v0.4.1 |
install exactly that release — binary and source. Leading v optional |
HORUS_BUILD_FROM_SOURCE=1 |
skip the prebuilt binary, compile the source at the resolved tag |
HORUS_LOCAL_SOURCE=/path |
use a source tree already on disk — no clone, nothing fetched for it (air-gapped) |
HORUS_PREFIX=/opt/horus |
install root override — <prefix>/bin for the binary, <prefix> in place of ~/.horus. A root install with no SUDO_USER refuses without it, rather than installing for root alone |
HORUS_INSTALL_BRANCH=dev |
developer escape hatch: build from a branch. Forces a source build — a branch is never paired with a release binary |
HORUS_NO_SHELL_INTEGRATION=1 |
leave shell rc files alone (skips horus env --init, which shadows cargo/pip/cmake inside HORUS projects) |
- Embedded HORUS —
no_stdruntime for STM32, ESP32, and other microcontrollers - HORUS–Zenoh Bridge — Distributed multi-machine deployments over Zenoh for seamless cloud-edge-robot communication
- ROS2 Bridge — Bidirectional topic bridging between HORUS and ROS2
horus/ Umbrella crate — prelude, universal types
horus_core/ Runtime — scheduler, nodes, topics, services, actions, safety monitor
horus_types/ Universal IPC types — math, diagnostics, time, generic
horus_cpp/ C++ bindings — extern "C" FFI, idiomatic C++17 headers (pool, params, TF, services, actions)
horus_py/ Python bindings (PyO3)
horus_manager/ CLI — build, run, test, deploy, monitor (40+ commands)
horus_sys/ Platform HAL — Linux, macOS
horus_net/ LAN replication — transparent cross-machine topics
# Separate packages (install via `horus install`):
# horus-tf Coordinate frame transforms (lock-free, 10-33x faster than ROS2 TF2)
# horus-robotics Standard robotics message types (CmdVel, Imu, LaserScan, 45+ types)
benchmarks/ Performance suite — latency, throughput, jitter, comparisons
- Found a bug? Open an issue — what you ran, what happened, what you expected. A reproducer is worth more than a description.
- Want a feature? Open an issue and say what you are trying to build; the use case shapes the API more than the request does.
- Fixed something? Open a pull request. See CONTRIBUTING.md.