The Problem: Process Overhead
You have 10 small sensor processing nodes. Each one:
- Subscribes to a topic
- Processes data
- Publishes results
All separate processes. All with their own memory space. All communicating over the network stack.
The cost:
- 10 context switches per data cycle
- 10x network serialization/deserialization
- 100+ MB of memory just for process overhead
- Inter-process communication latency
The solution: Run them all in one process with zero-copy communication.
This is what composable nodes do. But the documentation makes it sound harder than it is.
Part 1: The Fundamentals
1.1 Regular Nodes vs Composable Nodes
Regular Node (separate process):
Process 1: sensor_driver_node
└─ Publishes to /sensor/data (serialized)
↓ (over network stack)
Process 2: processor_node
└─ Subscribes from /sensor/data (deserialized)
└─ Publishes to /processed/data
Composable Nodes (same process):
Process 1: component_container
├─ Component: sensor_driver
│ └─ Publishes to /sensor/data (direct memory)
│ ↓ (zero-copy pointer passing)
└─ Component: processor
└─ Subscribes from /sensor/data (same memory)
└─ Publishes to /processed/data
The difference:
- Regular: Serialize → Send → Deserialize (expensive)
- Composable: Pointer passing (cheap)
1.2 When to Use Composable Nodes
Use when:
- Tight coupling (A → B → C in sequence)
- High data rate (100+ Hz with large messages)
- Want zero-copy communication
- Performance critical
- Development/testing speed important
Don’t use when:
- Nodes need independent lifetimes
- Different update rates important
- Sharing not feasible
- Adding complexity for small gain
Part 2: Creating a Composable Node Component
2.1 Converting a Regular Node to Component
Before (Regular Node):
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
class ImageProcessor(Node):
def __init__(self):
super().__init__('image_processor')
self.subscription = self.create_subscription(
Image,
'camera/image',
self.image_callback,
10
)
self.publisher = self.create_publisher(
Image,
'processed/image',
10
)
def image_callback(self, msg):
# Process image
processed = self.process_image(msg)
self.publisher.publish(processed)
def process_image(self, img):
# Simulate processing
return img
def main():
rclpy.init()
node = ImageProcessor()
rclpy.spin(node)
if __name__ == '__main__':
main()After (Composable Component):
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
class ImageProcessor(Node):
def __init__(self, context=None):
# Note: different initialization signature
super().__init__('image_processor', context=context)
self.subscription = self.create_subscription(
Image,
'camera/image',
self.image_callback,
10
)
self.publisher = self.create_publisher(
Image,
'processed/image',
10
)
self.get_logger().info('ImageProcessor component created')
def image_callback(self, msg):
# Process image
processed = self.process_image(msg)
self.publisher.publish(processed)
def process_image(self, img):
# Simulate processing
return imgThat’s it! The only change is the __init__ signature.
2.2 The Python Way (Recommended)
Just use regular nodes and compose them via launch file. Python composition is clunky.
2.3 The C++ Way (If You Need Performance)
In C++, you inherit from rclcpp_components::NodeComponent:
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_components/register_node_macro.hpp"
#include "sensor_msgs/msg/image.hpp"
class ImageProcessor : public rclcpp::Node
{
public:
explicit ImageProcessor(const rclcpp::NodeOptions & options = rclcpp::NodeOptions())
: rclcpp::Node("image_processor", options)
{
subscription_ = this->create_subscription<sensor_msgs::msg::Image>(
"camera/image",
10,
std::bind(&ImageProcessor::image_callback, this, std::placeholders::_1)
);
publisher_ = this->create_publisher<sensor_msgs::msg::Image>(
"processed/image",
10
);
}
private:
void image_callback(const sensor_msgs::msg::Image::SharedPtr msg)
{
auto processed = process_image(msg);
publisher_->publish(*processed);
}
sensor_msgs::msg::Image::SharedPtr process_image(const sensor_msgs::msg::Image::SharedPtr img)
{
return img; // Simulated processing
}
rclcpp::Subscription<sensor_msgs::msg::Image>::SharedPtr subscription_;
rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr publisher_;
};
RCLCPP_COMPONENTS_REGISTER_NODE(ImageProcessor)Part 3: Composing Nodes into a Container
3.1 Dynamic Loading (Runtime)
Load components at runtime via launch file:
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
# Container that holds all components
container = ComposableNodeContainer(
name='processing_pipeline',
namespace='',
package='rclcpp_components',
executable='component_container',
composable_node_descriptions=[
# Component 1: Camera driver
ComposableNode(
package='image_processing',
plugin='image_processing::CameraDriver',
name='camera_driver',
remappings=[('image', '/camera/image')],
parameters=[{
'frame_rate': 30,
'resolution': 'hd',
}]
),
# Component 2: Image processor
ComposableNode(
package='image_processing',
plugin='image_processing::ImageProcessor',
name='image_processor',
remappings=[
('camera/image', '/camera/image'),
('processed/image', '/processed/image'),
]
),
# Component 3: Feature detector
ComposableNode(
package='image_processing',
plugin='image_processing::FeatureDetector',
name='feature_detector',
remappings=[
('processed/image', '/processed/image'),
('features', '/features/detected'),
]
),
],
output='screen',
)
return LaunchDescription([container])In the same process:
component_container
├─ CameraDriver (publishes /camera/image)
├─ ImageProcessor (subscribes /camera/image, publishes /processed/image)
└─ FeatureDetector (subscribes /processed/image, publishes /features/detected)
3.2 Loading Additional Components at Runtime
Start with a container, then load more components:
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer, LoadComposableNodes
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
# Start with empty container
container = ComposableNodeContainer(
name='processing_pipeline',
namespace='',
package='rclcpp_components',
executable='component_container',
composable_node_descriptions=[
# Start with camera driver
ComposableNode(
package='image_processing',
plugin='image_processing::CameraDriver',
name='camera_driver',
),
],
output='screen',
)
# Load more components later
loader = LoadComposableNodes(
target_container='processing_pipeline',
composable_node_descriptions=[
ComposableNode(
package='image_processing',
plugin='image_processing::ImageProcessor',
name='image_processor',
),
],
)
return LaunchDescription([container, loader])Part 4: Real-World Pattern - Multi-Sensor Fusion Pipeline
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
"""
High-performance sensor fusion pipeline:
Camera → Image Processing → Feature Detection ↘
Fusion → Output
Lidar → Point Cloud Processing → Feature Detection ↗
"""
container = ComposableNodeContainer(
name='sensor_fusion_pipeline',
namespace='robot',
package='rclcpp_components',
executable='component_container',
composable_node_descriptions=[
# ===== CAMERA PIPELINE =====
ComposableNode(
package='camera_driver',
plugin='camera_driver::CameraDriver',
name='camera',
parameters=[{
'frame_rate': 30,
'resolution': 'hd',
'exposure': 'auto',
}],
remappings=[('image', '/camera/raw_image')],
),
ComposableNode(
package='image_processing',
plugin='image_processing::ImageRectifier',
name='image_rectifier',
remappings=[
('input/image', '/camera/raw_image'),
('output/image', '/camera/rectified'),
]
),
ComposableNode(
package='image_processing',
plugin='image_processing::FeatureDetector',
name='image_features',
parameters=[{
'detector_type': 'sift',
'n_features': 500,
}],
remappings=[
('image', '/camera/rectified'),
('features', '/features/image'),
]
),
# ===== LIDAR PIPELINE =====
ComposableNode(
package='lidar_driver',
plugin='lidar_driver::LidarDriver',
name='lidar',
parameters=[{
'frame_rate': 20,
'range_max': 50.0,
}],
remappings=[('scan', '/lidar/raw_scan')],
),
ComposableNode(
package='point_cloud_processing',
plugin='pcl::PointCloudProcessor',
name='cloud_processor',
remappings=[
('input/cloud', '/lidar/raw_scan'),
('output/cloud', '/lidar/processed'),
]
),
ComposableNode(
package='pcl_features',
plugin='pcl_features::FeatureEstimation',
name='lidar_features',
remappings=[
('input/cloud', '/lidar/processed'),
('output/features', '/features/lidar'),
]
),
# ===== FUSION =====
ComposableNode(
package='sensor_fusion',
plugin='sensor_fusion::Fusion',
name='fusion',
parameters=[{
'fusion_rate': 50,
'fusion_algorithm': 'iekf',
}],
remappings=[
('input/image_features', '/features/image'),
('input/lidar_features', '/features/lidar'),
('output/fused_state', '/robot_state'),
]
),
],
output='screen',
)
return LaunchDescription([container])What happens:
- All 8 components run in a single process
- Data flows through in-process message passing
- Zero serialization overhead
- Zero network latency
- Shared memory - efficient data transfer
Part 5: The Gotchas
Gotcha 1: Plugin Path Not Found
Error: Could not find class with the requested implementation 'image_processing::ImageProcessor'
Why: Plugin not properly registered or package not built.
Solution:
# Rebuild the package
colcon build --packages-select image_processing
# List available plugins
ros2 component listGotcha 2: Namespace Chaos
# ❌ WRONG: Inconsistent namespacing
ComposableNode(
plugin='...',
name='processor',
remappings=[
('input', '/camera/image'), # Absolute path
('output', 'processed'), # Relative path
]
)
# ✅ CORRECT: Be consistent
ComposableNode(
plugin='...',
name='processor',
remappings=[
('input', '/camera/image'),
('output', '/processed/image'),
]
)Gotcha 3: Components Can’t Share State Easily
# ❌ WRONG: Trying to share state between components
class ComponentA(Node):
def __init__(self):
super().__init__('comp_a')
self.shared_data = {} # How does ComponentB access this?
# ✅ CORRECT: Use services or messages
# If components need to share state, they're too tightly coupled
# Consider: Can one be a callback in the other instead?Gotcha 4: Debugging is Harder
# ❌ Can't easily attach debugger to one component
# ❌ All components crash if one has an exception
# ❌ Memory profile is opaque
# ✅ Solution: Start with separate processes, optimize later
# Use composition only when performance testing proves it's neededPart 6: Composition vs Performance
When Composition Helps
Scenario: Image processing pipeline at 100 Hz
Message size: 2.4 MB (1920x1080 image)
Separate processes:
├─ Serialize: 1 ms
├─ Network: 2 ms
├─ Deserialize: 1 ms
└─ Total overhead per frame: 4 ms
With composition:
└─ Direct pointer: 0.1 ms
Savings: 39.6 ms per second = 4 MB/s of wasted bandwidth
When Composition Doesn’t Help
Scenario: 10 Hz low-bandwidth sensors
Message size: 100 bytes
Separate processes:
└─ Total overhead: < 1 ms (negligible)
With composition:
└─ Overhead is same, but complexity increases
Better to keep separate for debugging
Quick Checklist
- Know your message rates and sizes
- Profiled to confirm need for composition
- Components properly registered as plugins
- Remappings clear and consistent
- Container starts before loading nodes
- Tested error handling in container
- Documentation clear on dependencies
Key Takeaways
- Composable nodes run in same process
- No serialization = zero-copy communication
- Better for high-rate sensor pipelines
- Use only when needed - complexity increases
- C++ is better than Python for composition
- Profile first - don’t optimize prematurely
Remember: Premature composition is the root of evil. Profile first.