Prototype in Python, run at C++ speed. rclcppyy is a drop-in accelerator for
rclpy: add one line at the top of an existing ROS 2 Python node and its
publishers, subscriptions, timers, and messages run on the rclcpp C++ backend
instead — no rewrite, no bindings to maintain, no Python⇄C++ message copies on the
hot path. It is powered by cppyy, which calls
C++ from Python directly via reflection and just-in-time compilation, and built on
the cppyy_kit suite (docs:
awesomebytes.github.io/cppyy_kit),
which packages the same "mix Python and C++ with ease" machinery for ROS 2 and a
family of C++ robotics libraries.
Take any ordinary rclpy node and add a single line at the top:
import rclcppyy; rclcppyy.enable_cpp_acceleration()
# Everything below is unchanged rclpy — but now runs on the rclcpp C++ backend.
import rclpy
from std_msgs.msg import String
rclpy.init()
node = rclpy.create_node('talker') # actually an rclcpp-backed node
pub = node.create_publisher(String, 'chatter', 10)
node.create_timer(0.5, lambda: pub.publish(String(data='hello')))
rclpy.spin(node)enable_cpp_acceleration() monkeypatches rclpy so create_node, spin,
publishers, subscriptions, and wall timers are served by rclcpp, and message
classes (e.g. std_msgs.msg.String) resolve to their C++ types — the payload lives
as a C++ object end to end, so there is no per-message Python conversion.
Publishing and subscribing on the C++ backend uses a fraction of the CPU of
plain rclpy at the same message rate, because the hot path (executor, DDS calls,
message handling) runs in C++ instead of the Python interpreter. In the run below —
a small std_msgs/String at 1 kHz on the reference machine — the monkeypatched
node used roughly 4–6× less CPU than rclpy (publisher and subscriber), at
the same throughput and about half the latency. Measure it on your own machine in
one command:
pixi run bench # rclpy vs rclcppyy CPU comparison table (1 kHz + 10 kHz)Example output (absolute numbers vary by machine — reproduce it yourself with the command above):
Benchmark @ 1000 Hz target
=======================================================================================
variant pub CPU% sub CPU% msgs recv eff Hz dropped avg lat us
---------------------------------------------------------------------------------------
rclpy 17.9 19.2 6000 968.9 0 174.8
rclcppyy (monkeypatched) 4.4 3.4 6000 969.4 0 89.0
For the full, consolidated and freshly-measured benchmark set — across the whole suite, including the freeze/AOT optimization ladder — see the cppyy_kit benchmarks page.
rclcppyy 0.2.0 is published as ros-jazzy-rclcppyy on the prefix.dev
awesomebytes channel. To use it (no clone, no colcon build), add the channel
and the package to your own pixi workspace:
# pixi.toml
[workspace]
channels = ["https://repo.prefix.dev/awesomebytes", "robostack-jazzy", "conda-forge"]
platforms = ["linux-64"]
[dependencies]
ros-jazzy-rclcppyy = "*"Or in one line:
pixi add -c https://repo.prefix.dev/awesomebytes -c robostack-jazzy -c conda-forge ros-jazzy-rclcppyyThen import rclcppyy; rclcppyy.enable_cpp_acceleration() works out of the box — no
LD_LIBRARY_PATH or activation setup required. Message packages you publish or
subscribe (e.g. ros-jazzy-std-msgs) are separate dependencies, as in any ROS 2
project. Installing rclcppyy pulls its runtime deps ros-jazzy-rclcpp-kit and
cppyy-kit (the suite) transitively.
rclcppyy accelerates the pub/sub hot path and keeps everything else on stock
rclpy, so unpatched code keeps working. Be aware of the boundaries:
Runs on the C++ backend after enable_cpp_acceleration():
- Nodes created via
rclpy.create_node(...)(returned as C++-backed nodes) - Publishers, subscriptions, and wall timers on those nodes
rclpy.spin(node)(delegates torclcpp::spin)- Message types — resolved to their
rclcppC++ equivalents on import, kept as C++ objects with no Python⇄C++ conversion on publish/receive
Stays on stock rclpy (not accelerated):
- Services and actions
- Parameters, parameter services, and parameter events
- Custom executors, callback groups, and multi-threaded spinning — acceleration targets the default single-threaded spin
- Publisher/subscription event callbacks and QoS overriding options
Known walls:
enable_cpp_acceleration()monkeypatchesrclpyprocess-globally and irreversibly — call it once, at the very top, before creating nodes.- The first
rclcppbringup JIT-compiles headers. The suite ships a zero-config Cling PCH cache (cppyy_kitauto-PCH) that makes subsequent process starts far cheaper (a warmrclcppbringup measured ~1.73 s → ~0.064 s); see the Freeze & Cache docs and the benchmarks page. - Acceleration is opt-in per process; without the one-line call, your code is
ordinary
rclpy.
As of 0.2.0, rclcppyy is the ROS 2 drop-in product in a larger family. The
reusable machinery it pioneered was extracted into the
cppyy_kit suite — a set of "kits",
where a kit is a thin layer that mirrors a C++ library's own API and hides only
the cppyy friction (bringup, object lifetime, crossing callbacks, teardown).
rclcppyy now depends on the suite at runtime and re-exports the moved pieces
through deprecation shims, so existing imports (from rclcppyy.bringup_rclcpp import ..., rclcppyy.tf, from rclcppyy.kits import bt_kit, …) keep working with a
DeprecationWarning pointing at the new home. New code should import the suite
packages directly.
| Kit | Import / conda package | What it gives you | Docs |
|---|---|---|---|
cppyy_kit |
cppyy_kit / cppyy-kit |
ROS-free base: load / keep-alive / callback / teardown primitives, the freeze PCH tooling, compile cache |
page |
rclcpp_kit |
rclcpp_kit / ros-jazzy-rclcpp-kit |
ROS 2 core: rclcpp bringup, C++ message resolution/conversion, serialization, rosbag2, tf | page |
bt_kit |
bt_kit / ros-jazzy-bt-kit |
BehaviorTree.CPP v4 from Python | page |
pcl_kit |
pcl_kit / ros-jazzy-pcl-kit |
PCL — clouds stay in C++ end to end | page |
ompl_kit |
ompl_kit / ros-jazzy-ompl-kit |
OMPL motion planning | page |
nav2_kit |
nav2_kit / ros-jazzy-nav2-kit |
Nav2 cores (Costmap2D + NavFn), no lifecycle servers | page |
moveit_kit |
moveit_kit / ros-jazzy-moveit-kit |
the full MoveIt 2 C++ API from Python | page |
control_kit |
control_kit / ros-jazzy-control-kit |
a Python ros2_control controller in the real controller_manager | page |
cv_kit |
cv_kit / ros-jazzy-cv-kit |
OpenCV C++ with a zero-copy sensor_msgs/Image → cv::Mat bridge |
page |
dbow_kit |
dbow_kit / ros-jazzy-dbow-kit |
DBoW2 place recognition / loop closure | page |
The cppyy_kit docs site also carries the
common-pattern playbook, the L0→L1→L2 freeze/AOT ladder, a vision loop-closure
tutorial, per-kit benchmarks, and a cppyy-accelerate skill for driving a coding
agent to speed up existing Python.
The repo is a self-contained pixi workspace — the manifest
(pixi.toml) and lockfile (pixi.lock) live here, so pixi install reproduces the
exact environment (ROS 2 Jazzy from robostack, cppyy from conda-forge, the suite
from the awesomebytes channel, compilers, colcon). No manual steps.
# If you haven't installed pixi:
curl -fsSL https://pixi.sh/install.sh | sh && source ~/.bashrc
git clone https://github.com/awesomebytes/rclcppyy
cd rclcppyy
pixi install # downloads the environment (a few GB the first time)
pixi run build # colcon build --packages-select rclcppyyInside pixi shell the workspace overlay is sourced automatically and the
recommended middleware defaults are already set
(RMW_IMPLEMENTATION=rmw_cyclonedds_cpp, ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST) —
the ROS default fastrtps has intermittent latency issues and drops big messages, so
cyclonedds on LOCALHOST is the default here.
Tasks:
| Task | What it does |
|---|---|
pixi run build |
colcon build --packages-select rclcppyy into install/ |
pixi run test |
pytest test/ (bringup, monkeypatch, pub/sub roundtrip, serialization parity, tf, clean-exit, kit shims) |
pixi run lint |
flake8 rclcppyy test |
pixi run clean |
remove build/ install/ log/ |
pixi run bench |
rclpy-vs-rclcppyy CPU comparison table (1 kHz + 10 kHz) |
pixi run demo-tutorial |
the rclpy pub/sub tutorial, on the rclcppyy C++ backend |
pixi run demo-pubsub |
a live pub/sub pair (rclcppyy backend), stats streamed |
pixi run bench spawns each publisher/subscriber pair as separate child processes,
warms up (the rclcppyy variants JIT-compile rclcpp on first bringup, excluded from
the measurement), samples each process's CPU with psutil while parsing the
subscriber's throughput/latency, kills the children itself, and prints a table per
rate. Flags pass straight through the pixi task:
pixi run bench --rate 5000 --duration 10 # custom rate / window
pixi run bench --variants rclpy,rclcppyy,rclcppyy-templated # add the pure-cppyy pair
pixi run bench --json # machine-readable outputAdvanced: run the individual bench scripts by hand
Enter the environment with pixi shell (middleware defaults and the workspace
overlay are applied automatically), then, one per shell:
# rclpy baseline
ros2 run rclcppyy bench_pub_rclpy.py 10000
ros2 run rclcppyy bench_sub_rclpy.py
# rclcppyy (monkeypatched rclpy → C++ backend)
ros2 run rclcppyy bench_pub_rclcppyy_monkeypatch.py 10000
ros2 run rclcppyy bench_sub_rclcppyy_monkeypatched.py
# Monitor with: top -c -p $(pgrep -d, -f bench_)Without entering a shell, any command can be run through pixi directly, e.g.
pixi run ros2 run rclcppyy bench_sub_rclcppyy_monkeypatched.py.
enable_cpp_acceleration() normally has to be called from inside your process. To
accelerate a process you cannot edit — notably the stock ros2 CLI — rclcppyy
ships an opt-in startup hook. Install it once, then set RCLCPPYY_ENABLE_HOOK=1 and
the stock ros2 topic hz (and other rclpy-based verbs) runs on the C++ backend with
zero code changes:
python -m rclcppyy.hook install # once per environment (uninstall/status too)
RCLCPPYY_ENABLE_HOOK=1 ros2 topic hz /some_topic # runs on the rclcpp backend
ros2 topic hz /some_topic # env var unset -> ordinary rclpy, untouchedRCLCPPYY_ENABLE_HOOK is the single control: RCLCPPYY_ENABLE_HOOK=1 turns the
hook on for a process; unset, 0, or any other value leaves it off. install
writes a .pth into the environment's site-packages that runs at every interpreter
start; when the hook is off it is a near-zero-cost no-op, and python -m rclcppyy.hook uninstall removes it.
At interpreter start the .pth registers a post-import hook on rclpy; the first
import rclpy triggers enable_cpp_acceleration(), after rclpy is importable but
before the tool builds its node. ros2 topic hz drives its loop with
rclpy.spin_once (in ros2cli's DirectNode discovery loop and in the hz loop), so
enable_cpp_acceleration() also patches rclpy.spin_once to run an rclcpp executor;
otherwise the CLI would stall at node bring-up.
Measured (stock ros2 topic hz vs the same binary under
RCLCPPYY_ENABLE_HOOK=1, against a C++ publisher of a sensor_msgs/Image,
BEST_EFFORT, --window 100, with net.core.rmem_max raised (see below);
pixi run -e heavydemo demo-topic-hz-cli; numbers vary by machine and run):
| payload | target Hz | rclpy Hz | rclcppyy Hz | rclpy CPU % | rclcppyy CPU % |
|---|---|---|---|---|---|
| 3.0 MB | 100 | 99.0 | 97.0 | 48.7 | 18.8 |
| 3.0 MB | 200 | 195.1 | 197.0 | 92.2 | 34.5 |
| 3.0 MB | 300 | 291.4 | 296.7 | 102.7 | 42.6 |
| 0.05 MB | 1000 | 998.2 | 999.7 | 19.3 | 7.3 |
| 0.05 MB | 3000 | 2967.6 | 2985.0 | 46.6 | 17.2 |
Both backends deliver essentially the publisher's rate wherever the publisher can
sustain it; the difference is CPU — the accelerated backend uses about a third to a
half as much. ros2 topic hz (without --filter) subscribes with raw=True, so
stock rclpy does not deserialise the payload; the saving is the C++ executor and
message handling replacing the Python executor, not avoiding a per-message copy.
Delivered rates stay close because the raw stock reader is cheap enough to keep up
where the publisher can feed it — but note where the CPU lands: on the 3 MB stream
stock rclpy crosses one full core near 200–250 Hz (92–107 %), while the accelerated
backend stays around a third of a core, so on a busier machine or at a higher rate
the stock reader saturates first.
Startup: a one-time cost. Time to first hz line
(pixi run -e heavydemo demo-topic-hz-startup):
ros2 topic hz first hz line |
time |
|---|---|
| stock (no acceleration) | ~1.7 s |
RCLCPPYY_ENABLE_HOOK=1, cold cache |
~8.3 s |
RCLCPPYY_ENABLE_HOOK=1, warm cache |
~4.1 s |
Two caches cut the warm start: the suite's Cling PCH removes the rclcpp header
parse, and a per-message-type subscription cache removes the ~2.8 s one-time JIT of
create_subscription<MsgT> (both built on the first run and loaded on later ones,
cold → warm above). The warm figure is still above stock because the rest of the
rclcpp bring-up — cppyy symbol discovery and the rclpy-compatibility adapters — is
not yet cached. Acceleration therefore remains a one-time startup cost that pays off
over a long-running hz session rather than a single short call. (Needs the newer
suite; see the dev-bridge note below.)
ros2 topic hz subscribes with qos_profile_sensor_data (BEST_EFFORT). A 3 MB
image fragments into roughly two thousand UDP datagrams, and if the OS socket
receive buffer is smaller than one message a single dropped fragment loses the whole
message. On a stock Linux install net.core.rmem_max defaults to about 200 KB, so a
BEST_EFFORT reader receives little or nothing on a 3 MB topic — for stock rclpy and
for the accelerated backend alike, since both use the same reader QoS. Raising the
kernel limit resolves it (this needs root, and applies to any DDS user, not only
rclcppyy):
sudo sysctl -w net.core.rmem_max=2147483647With that in place, CycloneDDS's default socket buffer is large enough that a 3 MB
BEST_EFFORT topic is received normally; no CYCLONEDDS_URI tuning is required. The
3 MB figures above were measured with this setting.
The startup hook is covered by test/test_hook.py (install / uninstall / status;
RCLCPPYY_ENABLE_HOOK=1 accelerates a fresh import rclpy; unset and =0 leave
stock rclpy untouched). For the acceleration on a node that reads message fields —
where stock rclpy pays a per-message Python deserialisation that ros2 topic hz
(raw subscription) does not — scripts/heavy_hz_demo/run_heavy_hz.py is a controlled
publisher/subscriber harness (pixi run -e heavydemo demo-heavy-hz).
Dev bridge. The startup measurement and the fastest bring-up use the zero-config auto-PCH, which postdates the published suite 0.1.0. Until the next suite release the
heavydemopixi env bridges the newerrclcpp_kit/cppyy_kitfrom a siblingcppyy_kitsource checkout (default../cppyy_kit, overrideCPPYY_KIT_SRC) and isolates its PCH cache under.heavy_demo_cache/. It shares the default solve (no extra conda deps) and leaves the default env's published-channel dependency untouched (workspace_activation.sh, gated on theheavydemoenv).
Heavier example scripts (OpenCV, PCL, GStreamer, typer, hypothesis) live in an
optional demos environment so the default pixi install stays lean. Enable it
once, then run any demo task with pixi run -e demos <task>:
pixi install -e demos # adds opencv, pcl, pcl_conversions, eigen,
# gstreamer, pygobject, typer, hypothesis| Task | What it does |
|---|---|
pixi run -e demos demo-images |
Publishes a real JPEG as a sensor_msgs/Image at 4 Hz through the C++ backend (builds the message straight into a C++ std::vector, no copy). Pair with pixi run -e demos python scripts/big_messages_demos/images_sub.py to receive it. |
pixi run -e demos demo-hypothesis |
Drives a JIT-compiled C++ function with Hypothesis property-based testing. It intentionally finds the failing input ("DDS") and exits non-zero — that's the demo. |
pixi run -e demos demo-pointcloud-voxelgrid |
JIT-compiles a PCL VoxelGrid filter (via pcl_conversions) and runs it as a ROS 2 node, waiting for PointCloud2 on /lexus3/os_center/points (needs an external bag to downsample anything). |
Additional example scripts under roscon_uk_2025/ need external data not in this
repo (a live PointCloud2 source, the cloudini
compressor workspace, or a V4L2 camera) and therefore have no pixi run task — see
the scripts' own comments for setup.
rclcppyy depends on the published cppyy_kit suite, which creates a publish order
(suite first, then the product). See RELEASING.md for the release
choreography and the rclcppyy.* / rclcppyy.kits.* deprecation timeline.
BSD 3-Clause — see LICENSE.
