========================
== Seeking Complexity ==
========================

鱼和熊掌我要兼得:ROS2适合量产吗

PUBLIC

之前在学校的时候用ROS,在DJI的时候用一整套DJI自研的中间件。

大家都认可ROS的易用性,但同时觉得它几乎成了“低效”和“实验室原型”的代名词。

所以在产品上,DJI毫不犹豫地选择了高效路线,搭建了一整套自研中间件。

我长期关注C++的zero-overhead principle信条,所以始终觉得很不服气:为什么中间件就不能既好用,又高效?

很有趣,最近用了一些时间做了一些实验,感觉整个过程是一个很值得讨论的技巧分享。

所以记录下来。

先说结论:在一个接近真实场景的case里面,从第一个版本的144% CPU占用,优化到了3.8%,提高了接近40倍的效率。

前情提要

小电脑和大电脑中记录一些优化分析的技巧。总结下来可能就是一句话,了解你的硬件的极限。

对于中间件来说,无非这些功能:进程内和进程间的消息传递;记录文本log;记录二进制log;可视化工具。

这些功能希望能实现等效效果的前提下,用最少的CPU,毕竟我们希望CPU都用在有用的计算上。

依次分析:

  1. 通信,基础的实现是复制,大家的追求是zero-copy。所以应该几乎不占用CPU。
  2. 记录,基础的实现是fwrite,大家追求的是async。所以也应该几乎不占用CPU。

讲到这里我们就知道,我们的目标就是中间件几乎不占用CPU,可能就是单核的个位数占用率。

接下来我们看看ROS2是否可以到达这个目标。

先测量,再优化,基线版本 144%

在这里假设一个场景。

一个完全mock的tinynav。传感器是20hz的720p灰度图双目相机和200hz的IMU。

计算设备是地平线x5

sunrise@ubuntu:~/workspace/data$ lscpu

Architecture:             aarch64
  CPU op-mode(s):         32-bit, 64-bit
  Byte Order:             Little Endian
CPU(s):                   8
  On-line CPU(s) list:    0-7
Vendor ID:                ARM
  Model name:             Cortex-A55
    Model:                0
    Thread(s) per core:   1
    Core(s) per cluster:  8
    Socket(s):            -
    Cluster(s):           1
    Stepping:             r2p0
    Frequency boost:      disabled
    CPU max MHz:          1500.0000
    CPU min MHz:          300.0000
    BogoMIPS:             48.00
    Flags:                fp asimd evtstrm crc32 atomics fphp asimdhp cpuid asimdrdm lrcpc dcpop asimddp
Caches (sum of all):
  L1d:                    256 KiB (8 instances)
  L1i:                    256 KiB (8 instances)
  L2:                     512 KiB (8 instances)
  L3:                     1 MiB (1 instance)
Vulnerabilities:
  Gather data sampling:   Not affected
  Itlb multihit:          Not affected
  L1tf:                   Not affected
  Mds:                    Not affected
  Meltdown:               Not affected
  Mmio stale data:        Not affected
  Reg file data sampling: Not affected
  Retbleed:               Not affected
  Spec rstack overflow:   Not affected
  Spec store bypass:      Not affected
  Spectre v1:             Mitigation; __user pointer sanitization
  Spectre v2:             Not affected
  Srbds:                  Not affected
  Tsx async abort:        Not affected

先来一个baseline:

.
|-- camera_node.cpp
|-- control_node.cpp
|-- imu_node.cpp
|-- perception_node.cpp
`-- planning_node.cpp

每个node一个文件。拿最简单的 imu_node.cpp 看下:

#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/imu.hpp>

using namespace std::chrono_literals;

class ImuNode : public rclcpp::Node {
public:
  ImuNode() : Node("imu_node") {
    publisher_ = this->create_publisher<sensor_msgs::msg::Imu>("imu/data", 10);
    timer_ =
        this->create_wall_timer(5ms, std::bind(&ImuNode::timer_callback, this));
  }

private:
  void timer_callback() {
    auto msg = sensor_msgs::msg::Imu();
    msg.header.stamp = this->now();
    msg.header.frame_id = "imu_frame";

    msg.angular_velocity.x = 0.0;
    msg.angular_velocity.y = 0.0;
    msg.angular_velocity.z = 0.1;

    msg.linear_acceleration.x = 0.0;
    msg.linear_acceleration.y = 0.0;
    msg.linear_acceleration.z = 9.81;

    publisher_->publish(msg);
  }

  rclcpp::Publisher<sensor_msgs::msg::Imu>::SharedPtr publisher_;
  rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char *argv[]) {
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<ImuNode>());
  rclcpp::shutdown();
  return 0;
}

好了,我们开始测量。

perf stat -p `pidof perception_node` -- sleep 10

 Performance counter stats for process id '4273':

          5,235.91 msec task-clock:u                     #    0.523 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
     1,427,684,811      cycles:u                         #    0.273 GHz
       647,318,281      instructions:u                   #    0.45  insn per cycle
        80,985,594      branches:u                       #   15.467 M/sec
         8,084,009      branch-misses:u                  #    9.98% of all branches

      10.008814607 seconds time elapsed

好的,果然很慢,其他node都不想看了。

perf stat -p `pidof camera_node` -- sleep 10

 Performance counter stats for process id '4269':

          3,853.95 msec task-clock:u                     #    0.385 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
            83,600      page-faults:u                    #   21.692 K/sec
       744,176,154      cycles:u                         #    0.193 GHz
       334,364,721      instructions:u                   #    0.45  insn per cycle
        37,471,342      branches:u                       #    9.723 M/sec
         1,250,800      branch-misses:u                  #    3.34% of all branches

      10.005158820 seconds time elapsed

perf stat -p `pidof imu_node` -- sleep 10

 Performance counter stats for process id '4271':

          1,489.53 msec task-clock:u                     #    0.149 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       367,451,810      cycles:u                         #    0.247 GHz
       157,764,377      instructions:u                   #    0.43  insn per cycle
        21,899,768      branches:u                       #   14.703 M/sec
         3,772,083      branch-misses:u                  #   17.22% of all branches

      10.005567466 seconds time elapsed

perf stat -p `pidof planning_node` -- sleep 10

 Performance counter stats for process id '4275':

          1,085.09 msec task-clock:u                     #    0.108 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       287,269,466      cycles:u                         #    0.265 GHz
       133,133,318      instructions:u                   #    0.46  insn per cycle
        16,560,378      branches:u                       #   15.262 M/sec
         1,443,901      branch-misses:u                  #    8.72% of all branches

      10.007625344 seconds time elapsed

perf stat -p `pidof control_node` -- sleep 10

 Performance counter stats for process id '4277':

          2,838.80 msec task-clock:u                     #    0.284 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       725,161,289      cycles:u                         #    0.255 GHz
       370,651,301      instructions:u                   #    0.51  insn per cycle
        52,844,557      branches:u                       #   18.615 M/sec
         7,309,073      branch-misses:u                  #   13.83% of all branches

      10.005027786 seconds time elapsed

加合起来是1.44个核。

V0 锁住CPU频率 37%

理解你的硬件,是否错过了关键的控制变量。

for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
    echo performance | sudo tee $cpu/cpufreq/scaling_governor
done

我们喜欢静态的系统,体现在方方面面,静态的内存。静态的CPU频率。以及作为结果的静态的CPU负载。

sunrise@ubunperf stat -p `pidof perception_node` -- sleep 10

 Performance counter stats for process id '4273':

          1,553.52 msec task-clock:u                     #    0.155 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
     1,868,415,448      cycles:u                         #    1.203 GHz
       660,517,840      instructions:u                   #    0.35  insn per cycle
        82,970,194      branches:u                       #   53.408 M/sec
         8,116,746      branch-misses:u                  #    9.78% of all branches

      10.004774491 seconds time elapsed

sunrise@ubuntu:~/workspace/data$ perf stat -p `pidof camera_node` -- sleep 10

 Performance counter stats for process id '4269':

            720.49 msec task-clock:u                     #    0.072 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       951,908,198      cycles:u                         #    1.321 GHz
       334,262,236      instructions:u                   #    0.35  insn per cycle
        37,364,399      branches:u                       #   51.860 M/sec
         1,122,648      branch-misses:u                  #    3.00% of all branches

      10.004771016 seconds time elapsed

sunrise@ubuntu:~/workspace/data$ perf stat -p `pidof imu_node` -- sleep 10

 Performance counter stats for process id '4271':

            442.39 msec task-clock:u                     #    0.044 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       481,199,305      cycles:u                         #    1.088 GHz
       158,023,331      instructions:u                   #    0.33  insn per cycle
        21,924,993      branches:u                       #   49.560 M/sec
         3,771,693      branch-misses:u                  #   17.20% of all branches

      10.004882884 seconds time elapsed

sunrise@ubuntu:~/workspace/data$ perf stat -p `pidof planning_node` -- sleep 10

 Performance counter stats for process id '4275':

            365.89 msec task-clock:u                     #    0.037 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       419,252,329      cycles:u                         #    1.146 GHz
       137,643,995      instructions:u                   #    0.33  insn per cycle
        17,233,971      branches:u                       #   47.102 M/sec
         1,496,232      branch-misses:u                  #    8.68% of all branches

      10.004889550 seconds time elapsed

sunrise@ubuntu:~/workspace/data$ perf stat -p `pidof control_node` -- sleep 10

 Performance counter stats for process id '4277':

            644.55 msec task-clock:u                     #    0.064 CPUs utilized
                 0      context-switches:u               #    0.000 /sec
                 0      cpu-migrations:u                 #    0.000 /sec
                 0      page-faults:u                    #    0.000 /sec
       755,655,328      cycles:u                         #    1.172 GHz
       287,431,393      instructions:u                   #    0.38  insn per cycle
        40,744,616      branches:u                       #   63.214 M/sec
         6,137,673      branch-misses:u                  #   15.06% of all branches

      10.005378124 seconds time elapsed

加起来0.372个CPU核。很大的提高,但是其实是之前的测量不准确。

好像可以了吗,我们继续往后。

V1 single process, multiple thread 26.7%

我们不用5个node/process了,换成一个,但是用executor去安排:

int main(int argc, char *argv[]) {
  rclcpp::init(argc, argv);

  // Create node options without intra-process communication
  rclcpp::NodeOptions options;
  options.use_intra_process_comms(false);

  // Create all nodes without intra-process communication
  auto camera_node = std::make_shared<CameraNode>(options);
  auto imu_node = std::make_shared<ImuNode>(options);
  auto perception_node = std::make_shared<PerceptionNode>(options);
  auto planning_node = std::make_shared<PlanningNode>(options);
  auto control_node = std::make_shared<ControlNode>(options);

  // Create multi-threaded executor
  rclcpp::executors::MultiThreadedExecutor executor(
      rclcpp::ExecutorOptions(), 8); // 8 threads for all nodes

  // Add all nodes to executor
  executor.add_node(camera_node);
  executor.add_node(imu_node);
  executor.add_node(perception_node);
  executor.add_node(planning_node);
  executor.add_node(control_node);

  RCLCPP_INFO(rclcpp::get_logger("main"),
              "All nodes started with multi-threaded executor and "
              "no intra-process communication");
  RCLCPP_INFO(rclcpp::get_logger("main"),
              "Nodes: Camera (20Hz), IMU (200Hz), Perception (20Hz), Planning "
              "(20Hz), Control (200Hz)");

  executor.spin();

  rclcpp::shutdown();
  return 0;
}

测量一下。

sudo perf record -g -p `pidof giant_nodes_system_v1` -- sleep 10

Performance counter stats for process id '7396':

       2,667.23 msec task-clock:u                     #    0.267 CPUs utilized
              0      context-switches:u               #    0.000 /sec
              0      cpu-migrations:u                 #    0.000 /sec
         90,099      page-faults:u                    #   33.780 K/sec
  2,989,886,366      cycles:u                         #    1.121 GHz
  1,768,020,433      instructions:u                   #    0.59  insn per cycle
    243,045,912      branches:u                       #   91.123 M/sec
     12,328,677      branch-misses:u                  #    5.07% of all branches

   10.004910597 seconds time elapsed

有一定提升。把 multi-process 换成 multi-thread 确实减少了开销,至于具体原因,有时间再细分析。

不过值得一提的是,如果想优化调度之类的问题,例如优先级,实时性,核亲和性,thread和process具备同样的功能,在Linux的处理里面,是不做区分的。

可能主要的区别就是内存空间的问题,process内部,可以不需要做地址的转换,共享同一个memory page,process之间就需要用shared memory相关的系统调用了。

然后继续分析,我们先跑一个火焰图

interactive

从打印里面可以看出来,一个Image msg,从 camera_nodeperception_node 已经经历了复制:

[INFO] [1756955429.751859783] [camera_node]: Publishing left image at address: 0xffff60001cf0
[INFO] [1756955429.752962456] [perception_node]: Received left image at address: 0xffff68000b80
[INFO] [1756955429.754733717] [perception_node]: Received right image at address: 0xffff6400a200
[INFO] [1756955429.754863010] [perception_node]: Processing stereo images at addresses: left 0xffff68000b80, right 0xffff6400a200

V2 intra-process zero-copy 16.0%

如上面所说,如果我们把任务都保留在一个进程内,我们可以减少复制。

在ROS2中开启这个feature也很简单: options.use_intra_process_comms(true)

测一下:

sudo perf record -g -p `pidof giant_nodes_system_v2` -- sleep 10

Performance counter stats for process id '15691':

       1,597.49 msec task-clock:u                     #    0.160 CPUs utilized
              0      context-switches:u               #    0.000 /sec
              0      cpu-migrations:u                 #    0.000 /sec
         25,076      page-faults:u                    #   15.697 K/sec
  1,837,091,696      cycles:u                         #    1.150 GHz
  1,010,977,273      instructions:u                   #    0.55  insn per cycle
    140,877,405      branches:u                       #   88.187 M/sec
      8,593,332      branch-misses:u                  #    6.10% of all branches

   10.004934483 seconds time elapsed

明显的提高啊。火焰图如下:

interactive

通过打印检查一下是否是同一个对象:

[INFO] [1756956492.805418607] [camera_node]: Publishing left image at address: 0xaaaab18bbb10
[INFO] [1756956492.805616065] [perception_node]: Received left image at address: 0xaaaab18bbb10
[INFO] [1756956492.805697899] [perception_node]: Received right image at address: 0xaaaab1793150
[INFO] [1756956492.805758066] [perception_node]: Processing stereo images at addresses: left 0xaaaab18bbb10, right 0xaaaab1793150

符合预期。

V3 Static Executor 6.9%

火焰图中已经有很多是和消息的等待有关了,线程间同步。

此时想到,我们也许也不需要多线程,只需要把消息发送都用在single thread,各个node除了接受消息之外,自己起thread去做计算。

如果这样的话, StaticSingleThreadedExecutor 就很适合我们,得到第三版:

sudo perf record -g -p `pidof giant_nodes_system_v3` -- sleep 10

Performance counter stats for process id '16150':

         694.89 msec task-clock:u                     #    0.069 CPUs utilized
              0      context-switches:u               #    0.000 /sec
              0      cpu-migrations:u                 #    0.000 /sec
              0      page-faults:u                    #    0.000 /sec
    874,690,659      cycles:u                         #    1.259 GHz
    466,580,545      instructions:u                   #    0.53  insn per cycle
     66,610,771      branches:u                       #   95.858 M/sec
      3,786,256      branch-misses:u                  #    5.68% of all branches

   10.004895885 seconds time elapsed

又是很明显的提高。

interactive

这个结果已经很好了。

V4 TCMalloc 6.3%

从上面的思路来看,我们也不追求消除所有的内存操作,毕竟大部分的msg都是很小的,我们主要优化图像。 而 sensor_msgs::msg::Image 是一个动态对象,除了对象头部信息,还有一个 data 是动态的 vector

就算是我们传递消息的时候做到了zero-copy,每次新的图片,在目前的实现中,也是一个新的对象,需要申请这个vector的内存。

从火焰图中还能看到不少与 page fault 相关的部分( el0_da )。

能不能优化这一点呢。可以,但是大部分思路都比较难搞。

  1. 可以不用 sensor_msgs::msg::Image ,而是换成一个静态的msg。但是代价就是现有的可视化工具都用不了了。
  2. sensor_msgs::msg::Image_<VoidAlloc> ,但是给一个memory pool的简单实现。但是。。和1一样, sensor_msgs::msg::Image_<VoidAlloc> 其实也不被现有的工具支持, 毕竟已经完全不是一个type了。
  3. 重载unique_ptr的delete,每次“释放”的时候,实际去做一个回收。实验下来,发现ROS有一个自己的delete,不能改变,具体原因也可以再看看。

最后选了一个更粗暴的方法,既然我只关心图片的内存,而且图片的内存尺寸总是固定的。那么我可以要求malloc库针对重复分配的尺寸进行缓存。

这也就是(我理解的)tcmalloc的思路。

测试一下:

LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libtcmalloc_minimal.so.4 ros2 run ros2_efficiency_test giant_nodes_system_v3

Performance counter stats for process id '17388':

         631.34 msec task-clock:u                     #    0.063 CPUs utilized
              0      context-switches:u               #    0.000 /sec
              0      cpu-migrations:u                 #    0.000 /sec
            320      page-faults:u                    #  506.858 /sec
    821,420,073      cycles:u                         #    1.301 GHz
    454,039,167      instructions:u                   #    0.55  insn per cycle
     64,927,835      branches:u                       #  102.841 M/sec
      3,489,579      branch-misses:u                  #    5.37% of all branches

   10.004872700 seconds time elapsed

效果看起来不算特别明显。

V5 CycloneDDS 3.8%

再往后就是感觉DDS本身的操作比较多了。

我们换一换试试:

RMW_IMPLEMENTATION=rmw_cyclonedds_cpp LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libtcmalloc_minimal.so.4 ros2 run ros2_efficiency_test giant_nodes_system_v3

Performance counter stats for process id '17646':

          379.74 msec task-clock:u                     #    0.038 CPUs utilized
               0      context-switches:u               #    0.000 /sec
               0      cpu-migrations:u                 #    0.000 /sec
               3      page-faults:u                    #    7.900 /sec
     445,157,443      cycles:u                         #    1.172 GHz
     179,113,939      instructions:u                   #    0.40  insn per cycle
      28,939,796      branches:u                       #   76.209 M/sec
       2,528,278      branch-misses:u                  #    8.74% of all branches

    10.004862817 seconds time elapsed

挺好的提高。

总结

代码放在:https://github.com/dvorak0/ros2_efficiency_test

todos

  1. 同时考虑rosbag record
  2. 测试一下Loaned Messages

吐槽

先测量,再提高。说起来一句很简单的话,但是有多少时候能做到。

更多时候,在一个组织里面,用一个带有fancy词汇的PPT,项目就上马了。什么zero-copy,direct IO,shared memory,lightweight,一片欢快的气氛。

可能真的阅读别人的代码,真的比自己写一遍要难很多。

我记得本科的时候有个朋友说:“我的XFCE非常轻量级,虽然功能少,但是启动快啊”,在同一个笔记本上做了实验对比KDE,没想到是“不仅启动慢,而且功能少”。

真的是往事了,说起来现在已经很久没用过功能这么丰富的桌面了,大家的电脑也被橙黄色覆盖了。

我对ROS充满感激,虽然确实有很多缺点,但是无数次在搭建原型的时候给我一种愉快的使用体验。

想新写中间件的工程师,如果真的想击败ROS,为什么不考虑加入ROS的committee直接把ROS变成自己心中的样子呢。

再一次想到开源经济学的一些东西,不管怎么说ROS的git历史都会永远的保留下去,但是dji_sys就未必了。

同时也非常理解吧。

哈哈,老朋友们看到这里不要骂我。let’s_have_a_talk