Performance
A control loop at 1 kHz has 1000 µs per iteration. Whether OmniLoop costs 5 µs or 500 µs of that decides whether you can leave it armed on real hardware, so this page publishes measurements rather than adjectives.
Everything below is reproducible; the commands are at the bottom.
Headline
Section titled “Headline”A live tick costs ~43 µs, measured through shared memory with the release core — a typical frame of 5 tunables and 8 metrics, published every iteration. That is about 4% of a 1 kHz budget, or 0.4% at 100 Hz.
Per-tick SDK overhead
Section titled “Per-tick SDK overhead”Wall-clock cost of wrapping an iteration in with loop.tick():, minus the same
loop body with no OmniLoop at all — so these are overhead, not total time.
Each case runs in its own interpreter.
| Case | Tunables | Metrics | Overhead | p95 |
|---|---|---|---|---|
| minimal | 1 | 0 | 3.6 µs | 3.8 µs |
| typical | 5 | 8 | 6.5 µs | 6.6 µs |
| typical + limits armed | 5 | 8 | 6.6 µs | 6.7 µs |
| wide frame | 5 | 64 | 18.6 µs | 18.8 µs |
| typical + journal | 5 | 8 | 11.5 µs | 12.0 µs |
| live: typical | 5 | 8 | 42.8 µs | 47.9 µs |
| live: + 1 watchpoint | 5 | 8 | 43.3 µs | 48.3 µs |
| live: + 10 watchpoints | 5 | 8 | 44.8 µs | 45.6 µs |
| live: + journal | 5 | 8 | 51.3 µs | 55.4 µs |
The live: rows go through shared memory and the Rust core. The rows above
them stop at the SDK boundary, which is what isolates Python-side cost from
IPC.
Three things worth reading off this table:
- Limits are free per tick. Enforcement runs when an edit arrives, not every iteration, so arming a safety envelope costs nothing while you are not moving a slider.
- Watchpoints are nearly free. Ten armed watchpoints add ~2 µs per tick. There is no reason to leave a tripwire disarmed to save time.
- Journaling costs ~5–8 µs on the loop thread — a channel send and a buffer copy. The actual file I/O happens on a background thread and never blocks the loop.
Core primitives
Section titled “Core primitives”Criterion benchmarks against the Rust core directly, for the paths that sit on the hot side of a mutation or a safety check.
| Benchmark | Time |
|---|---|
sync_to_pointers, no limit |
57 ns |
sync_to_pointers, limit armed and in range |
74 ns |
sync_to_pointers, limit clamping |
120 ns |
evaluate_watchpoints, none armed |
36 ns |
evaluate_watchpoints, 1 armed |
103 ns |
evaluate_watchpoints, 10 armed |
738 ns |
write_frame (caller side, journal attached) |
~306 ns |
state_hash, 16 keys |
1.13 µs |
A limit costs 17 ns per write — one HashMap lookup, which is what
limit enforcement claims.
Unarmed watchpoint evaluation is constant-time
Section titled “Unarmed watchpoint evaluation is constant-time”Not merely cheap — constant, independent of how many variables the frame carries. If the unarmed path scanned every variable, a loop publishing 500 keys would pay for a feature it never switched on.
| Variables registered | evaluate_watchpoints, none armed |
|---|---|
| 8 | 35.6 ns |
| 64 | 35.7 ns |
| 512 | 36.2 ns |
Reference machine
Section titled “Reference machine”| CPU | Intel Core Ultra 9 288V |
| OS | Windows 11 |
| Python | 3.11.0 |
| Core build | release |
Numbers from other machines will differ; the shape of the table should not.
Reproducing
Section titled “Reproducing”# Rust core primitivescargo bench -p omniloop-core
# Per-tick SDK overhead (release core required)cd omniloop-sdk-python && maturin develop --release && cd ..python omniloop-sdk-python/bench_tick.py
# Machine-readable, for tracking over timepython omniloop-sdk-python/bench_tick.py --jsonpytest omniloop-sdk-python/tests/test_perf_budget.py runs the same paths as
regression guards. Those assert against a deliberately loose multiple of the
budget: they run on shared CI hardware where wall-clock timing is noisy, and
they exist to catch a change in order of magnitude — an accidental O(n) scan,
a lock taken per key, I/O moved onto the loop thread — not a few percent of
drift. A flaky timing test gets muted, and a muted test guards nothing.
Long-run memory
Section titled “Long-run memory”Per-tick latency and long-run memory are different failure modes, and a loop can be excellent at one while failing the other. An unbounded history buffer or a telemetry cache that only ever grows costs nothing per tick and everything four hours in.
soak.py runs a loop against a live server and watches RSS on both processes:
pip install psutilpython omniloop-sdk-python/soak.py --duration 3600 --rate 100Over 5 minutes at 100 Hz (30,000 ticks) on the reference machine, both processes are flat:
| Process | RSS | Growth |
|---|---|---|
| target loop | ~22 MB | ~0.0 MB/hour |
| telemetry server | ~51 MB | ~0.2 MB/hour |
Those figures are from a 5-minute local run; the hour-long version is what CI runs nightly, and its artifacts are the authoritative record.
“Bounded” here does not mean RSS never rises — it does, during the first seconds, on any Python process, as allocator arenas fill. What matters is whether it is still climbing once steady state is reached, so an initial warm-up window is discarded and growth is measured as a least-squares slope across the remainder. A single GC pause cannot move that the way it moves a last-minus-first reading.
The budget is 25 MB/hour per process. For calibration: an injected 200 KB/tick leak measures ~13,700 MB/hour against it, and a clean run measures ~0.2 — the budget sits three orders of magnitude away from both.
The nightly workflow runs this for an hour on Linux and Windows, with and without journaling.
Install footprint
Section titled “Install footprint”Per-tick cost is what a running loop pays; install footprint is what a container pays, and for a training job shipped to a cluster that is often the number people actually notice.
omniloop wheel |
1.5 MB |
| Dependencies | 6.0 MB |
import omniloop |
38 ms |
from omniloop.server import create_app |
123 ms |
import omniloop in a training script pulls in no web-framework modules at
all — omniloop.server resolves its exports lazily, so a loop that never
opens a dashboard never pays for one.
The server is built on Starlette rather than FastAPI. FastAPI’s value is request/response validation derived from type hints, and this server declares no models to validate: the payloads that matter travel over the websocket, and the HTTP surface is four routes taking at most one uploaded file between them. Its pydantic dependency was 11 MB — two thirds of the old footprint — paid on every install:
| Dependencies | Server import | |
|---|---|---|
| FastAPI | 16.9 MB | 448 ms |
| Starlette | 6.0 MB | 123 ms |
Measuring your own machine
Section titled “Measuring your own machine”Every figure above is an x86 desktop, which is the wrong machine for a robot.
bench_rt measures per-tick overhead and wake jitter separately on your
hardware, at your rate, under your scheduler:
cargo run --release --example bench_rt -- --rate 1000 --seconds 60See Real-Time Characterization for the kernel settings that decide whether the result is worth quoting, and for how to report one.
What is not measured here
Section titled “What is not measured here”- Dashboard rendering. The browser’s frame budget is its own problem.
- Your training step. These figures are OmniLoop’s share of an iteration, not the iteration.
- GPU or device memory.
SnapshotReflectorreduces device tensors to host scalars; what the tensors themselves cost is your framework’s concern.