Skip to content

Real-Time Characterization

The Performance page publishes per-tick overhead on an x86 desktop. That is the wrong machine for a robot, and everyone reading it knows so. This page is the harness for measuring your own, and the settings that decide whether the result is worth quoting.

The two numbers, and why they are separate

Section titled “The two numbers, and why they are separate”

Conflating them is what makes most published real-time figures useless.

What it is Whose problem
Overhead Wall-clock inside tick_begin + halt_poll + tick_end. OmniLoop’s. The only thing this project can be held responsible for.
Wake jitter How late the OS started the iteration against its intended period. Your kernel, scheduler and neighbours. OmniLoop cannot improve it.

A run with 2 µs of overhead and 400 µs of wake jitter has an OS problem, not an instrumentation problem. The reverse is our bug. The harness reports both, side by side, and never charges one to the other — the jitter sample is taken before any OmniLoop call.

Terminal window
cargo run --release --example bench_rt -- --rate 1000 --seconds 60
microseconds mean p50 p90 p99 p99.9 max
------------------------------------------------------------------------
overhead 13.25 8 23 87 228 465
wake jitter 13.07 0 0 371 2787 6791

That sample is a Windows desktop with no real-time configuration at all, and it shows exactly what this page is about: the overhead p50 is 8 µs, but the tail is 228 µs at p99.9 — because a general-purpose scheduler preempts the process mid-tick. The wake-jitter tail is worse still at 2.8 ms. Neither number is a reason to distrust the loop; both are a reason not to run a 1 kHz controller on an unconfigured desktop.

Flags: --rate, --seconds, --channels, --publish-every, --journal, and --json for machine-readable output.

None of this requires a code change.

Pin the loop and give it a real-time priority

Section titled “Pin the loop and give it a real-time priority”
Terminal window
sudo chrt -f 80 taskset -c 3 \
./target/release/examples/bench_rt --rate 1000 --seconds 60 --json

chrt -f 80 runs it SCHED_FIFO; taskset -c 3 pins it to one core. Do the same to your real controller — OmniLoop deliberately does neither on your behalf, because a library that quietly changes your scheduling class is a library that will surprise you at the worst moment.

Isolate that core from the general scheduler

Section titled “Isolate that core from the general scheduler”

In the kernel command line:

isolcpus=3 nohz_full=3 rcu_nocbs=3

Then keep everything else off it. This is usually the single largest improvement to the jitter tail, and it is invisible to any code.

Lock memory so a page fault cannot land mid-tick

Section titled “Lock memory so a page fault cannot land mid-tick”

The harness calls mlockall(MCL_CURRENT | MCL_FUTURE) itself on Linux and reports whether it succeeded — it needs CAP_IPC_LOCK or root. Do the same in your controller:

#include <sys/mman.h>
mlockall(MCL_CURRENT | MCL_FUTURE);

A page fault inside a tick is a multi-hundred-microsecond event that shows up only in the tail, which is exactly where it is hardest to explain.

On stock Linux the tail is bounded by whatever the longest non-preemptible section happens to be. PREEMPT_RT is what makes the p99.9 a design parameter rather than a discovery.

Terminal window
cargo run --release ... # Rust
cmake -B build -DCMAKE_BUILD_TYPE=Release # C++

A debug build of the engine is roughly 100x slower. Loop::release_build(), ol_is_release_build() and the harness’s own header all report the profile, so a controller can refuse to arm against a debug core.

Both SDKs render frames into a buffer reserved at setup. It settles during the first few hundred iterations and then never grows again — the harness discards a warm-up window for this reason, and your controller should spin before it switches to SCHED_FIFO.

If you measure on hardware this project does not have — a Jetson, a Pi 5, an x86 industrial box with PREEMPT_RT — the numbers are welcome. What makes them comparable:

Terminal window
sudo chrt -f 80 taskset -c 3 ./bench_rt --rate 1000 --seconds 300 --json

Include alongside the JSON:

  • CPU and board
  • uname -a, and whether the kernel is PREEMPT_RT
  • kernel command line (isolcpus, nohz_full)
  • whether memory_locked came back true
  • what else was running

Five minutes minimum. A 10-second run cannot see a p99.9 worth quoting: at 1 kHz that is ten samples in the tail bucket.

  • Your controller. The loop body is a token computation. The figures are OmniLoop’s share of an iteration, not the iteration.
  • Dashboard or relay cost. Those run in another process; publish_every decimates what the loop hands them, and the relay’s own poll rate is its business.
  • GPU or device memory. Out of scope entirely.
  • Determinism. Different question, different tool — see replay.
  • Performance — the published x86 figures and the per-tick accounting behind them.
  • C++ SDK — what is and is not allocated on the tick path.
  • Tethered Deployment — the checklist before any of this goes near hardware.