Vaibhav Shende Vaibhav Shende

ROS 2 Composable Nodes: In-Process Communication & The Performance Trick

Master ROS 2 composable nodes: component containers, inter-process vs in-process communication, and why you're wasting CPU cycles with separate processes.

ROS 2

ROS 2 Composable Nodes: In-Process Communication & The Performance Trick

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 img

That’s it! The only change is the __init__ signature.

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:

  1. All 8 components run in a single process
  2. Data flows through in-process message passing
  3. Zero serialization overhead
  4. Zero network latency
  5. 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 list

Gotcha 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 needed

Part 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

  1. Composable nodes run in same process
  2. No serialization = zero-copy communication
  3. Better for high-rate sensor pipelines
  4. Use only when needed - complexity increases
  5. C++ is better than Python for composition
  6. Profile first - don’t optimize prematurely

Remember: Premature composition is the root of evil. Profile first.