Enhancing ROS 2 Systems: The Power of Node Composition

Enhancing ROS 2 Systems: The Power of Node Composition

ROS 2, the next generation of the Robot Operating System, is engineered to excel in industrial and production settings, prioritizing reliability and safety in robotic systems. With a focus on performance, determinism, and security, ROS 2 introduces innovative strategies for developers. One such strategy is node composition, a technique that empowers users to finely manage node execution locations. By consolidating nodes into single processes, this approach minimizes overhead and optimizes communication efficiency, redefining ROS development.

While ROS 1 introduced similar capabilities through Nodelets, facilitating zero-copy communication, their adoption remained limited primarily to data-intensive sensor drivers like Lidar and cameras. In contrast, ROS 2's embrace of node composition as the default paradigm highlights a shift in approach. Notably, major projects like Nav2 and Moveit 2 exemplify this transition by adopting node composition, while their ROS 1 counterparts did not leverage Nodelets.

Details and Types

Node composition empowers system designers to make deliberate choices about the processes in which their programs reside. This capability proves invaluable for optimizing latency and computational resource utilization within robotics systems. Notably, in contrast to ROS 1's nodelets, ROS 2 components do not impose specific method implementations, making the conversion of a standard node into a component a straightforward task.

ROS 2 provides flexibility in composing nodes without requiring modifications to the core software. There are two main ways to group components: manual (compile-time) composition and dynamic (run-time) composition. These methods serve different purposes and adapt to various scenarios, allowing ROS 2 systems to be tailored to specific needs.

Manual Composition

Manual composition takes place during the compilation phase, where a user-developed executable is created explicitly to instantiate components. Typically, this process occurs within the main function of the program. In this phase, you construct an executor and add the nodes that you wish to run together. While manual composition provides maximal control over resource allocation between components, it does so at the expense of flexibility, as decisions are made at compilation time.

Here's an example that demonstrates manual composition with one publisher and one subscriber node:

rclcpp::executors::SingleThreadedExecutor exec; 
// or MultiThreadedExecutor, StaticSingleThreadedExecutor
rclcpp::NodeOptions options;
options.use_intra_process_comms(true); // Enable IPC
auto publisher_node = std::make_shared<PublisherNode>(options);
auto subscriber_node = std::make_shared<SubscriberNode>(options);
exec.add_node(publisher_node);
exec.add_node(subscriber_node);
exec.spin(); // Spin both nodes

In this example, the nodes run concurrently within a single-threaded executor. You can easily parallelize the same example by switching to a MultiThreadedExecutor. The setting use_intra_process_comms must be enabled to utilize intra-process communication, which allows for zero-copy data transfer.

It's worth noting that in embedded setups with limited processing power, the entire system can be run using manual composition as a single executable. However, this approach comes with the caveat that a failure in any part of the system can lead to the failure of the entire system. Therefore, it is advisable to employ manual composition only when the development phase is complete, and the system is deemed stable.

Dynamic Composition

Dynamic composition, a runtime process facilitated by component containers, offers significantly more flexibility than manual composition. During runtime, components can be effortlessly loaded into these containers using services. However, for added convenience and enhanced flexibility, it's often preferable to initiate a group of components simultaneously using launch files.

To enable dynamic composition, it's necessary to design your nodes as components and compile them into shared libraries. Typically, a component node is derived from rclcpp::Node and has a constructor that accepts const rclcpp::NodeOptions& as an argument. Since it's exclusively built as a shared library, there's no need to define a main function for it. Instead, within your C++ file, you should register the component similar to plugins:

#include "rclcpp_components/register_node_macro.hpp"
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::MyComponent)

In your CMakeLists.txt file, in addition to creating the shared library, you should invoke the rclcpp_components_register_nodes macro:

rclcpp_components_register_nodes(my_component "my_pkg::MyComponent")

Once your component is developed and the library is built, you're all set to utilize it for composition. Below is an example of a launch file for dynamic composition:

"""Launch a talker and a listener in a component container."""

import launch
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

def generate_launch_description():
    """Generate launch description with multiple components."""
    container = ComposableNodeContainer( 
            name='my_container',
            namespace='',
            package='rclcpp_components',
            executable='component_container', 
            // or component_container_mt, component_container_isolated 
            composable_node_descriptions=[
                ComposableNode(
                    package='composition',
                    plugin='composition::Talker',
                    name='talker',
                    extra_arguments=[{'use_intra_process_comms': True}]), // Enable IPC
                ComposableNode(
                    package='composition',
                    plugin='composition::Listener',
                    name='listener',
                    extra_arguments=[{'use_intra_process_comms': True}]) // Enable IPC
            ],
            output='screen',
    )

    return launch.LaunchDescription([container])

In this example, a talker and a listener component run together in a single-threaded manner within a component container. Additionally, there are other container types available that allow you to run nodes in a multi-threaded configuration or with a dedicated single thread for each node:

  • component_container → SingleThreadedExecutor

  • component_container_mt → MultiThreaded

  • component_container_isolated → SingleThreadedExecutor for each component

These container options provide flexibility in tailoring the execution of your nodes according to your specific requirements.

Performance Impact

You might wonder, "How much does node composition truly impact performance?" The answer is clear: it has a significant impact. An insightful article titled "Impact of ROS 2 Node Composition in Robotics Systems" authored by Macenski et al. provides a comprehensive analysis of the performance implications of node composition. The findings from this research shed light on the substantial benefits of utilizing composition in terms of memory usage, latency, and CPU efficiency. Let's delve into some of the key results presented in the paper.

The first graph illustrates that RAM usage remains nearly constant for both manual and dynamic composition, in stark contrast to the exponential increase observed in normal multi-process operation.

In the second graph, we can observe the results for CPU usage and latency across various message sizes. Notably, composition with intra-process communication consistently maintains remarkably low latency and stands out with the lowest CPU usage. Moreover, the graph highlights a significant reduction of more than half in both CPU usage and latency for every message size when employing either form of composition, as opposed to the traditional multi-process approach.

The third and final graph offers a comparison of maximum goodput, which quantifies the volume of published data delivered to the callback per second. Notably, the standard multi-process system exhibits notably lower goodput when contrasted with composition-based systems. Once more, composition with IPC emerges as the frontrunner, delivering the most favorable outcomes. This analysis emphasizes that composition with IPC is indeed a requirement for applications that must produce data at very high frequencies, such as above 1 KHz.

Conclusion

In summary, incorporating node composition into ROS 2 is crucial for optimizing robotics systems. It consistently improves memory usage, reduces latency, and lightens CPU workloads. It is highly recommended to make composition, especially with IPC, your default strategy for robotics development. Whether your application requires real-time performance or high data throughput, composition is essential for achieving the best efficiency and responsiveness in ROS 2 systems.

References