使用 C++ 的移动语义优化运行时性能

减少复制操作是提高对性能至关重要的应用程序的运行时执行速度的好方法。
57 位读者喜欢这篇文章。
woman on laptop sitting at the window

CC BY 3.0 US Mapbox Uncharted ERG

如果您可以选择用于应用程序的编程语言,您通常会选择您熟悉的并且能够以最短路径达到目标的语言。如果您需要高运行时速度,那么直接编译为机器代码的编程语言(如 C++)是您的最佳选择。

在现代应用程序中,内存地址的来回跳转、循环以及(有时是不必要的)数据区域复制会消耗大量的机器代码。在本文中,我将重点介绍 C++ 的 move 语义,它可以让您避免不必要的复制过程。即使您不是程序员,您仍然可以使用 valgrind 堆分析器 massif 分析内存分配。

代码:rvalues 和 lvalues

在 C++ 编程中,您必须处理 lvaluesrvalues。在下面的示例中,a 在赋值运算符 = 的左侧,因此它是 lvalue。赋值 5 在右侧,因此它是 rvalue

a = 5;


当编译此行代码时,lvalue 被解释为之后可以更改的符号地址。rvalue 是一个纯粹的硬编码值。后续代码无法访问它,因为 rvalues 没有地址。如果您可以确定表达式的地址,或者编译器允许,则它是 lvalue。也就是说,如果一个表达式在行尾的分号后仍然存在,那么它是 lvalue;否则,它是 rvalue

lvalue 可以出现在左侧和右侧。rvalue 只能出现在右侧。

a = 5;
b = a;

自 C++11 以来,移动语义和处理右值引用的可能性已融入标准。

右值引用用双 & 符号 && 标记。它们允许将 lvalue 解释为 rvalue。尤其是在创建对象时,使用 rvalue 引用可以提高性能,我们将在示例代码中看到这一点。

网上有很多文章介绍了语法和正确用法。Chromium 项目文档 对此主题做了很好的介绍。在本文中,我们将使用一个示例来揭示对性能的实际影响。

git clone https://github.com/hANSIc99/optimizing_cpp_sample.git

默认行为

假设您有一个名为 MyObject 的类,它在其构造函数中接受另一个类 MyType 作为参数。为了创建 MyObject 的实例,必须预先创建 MyType 的实例。

在代码中,它看起来像这样(main.cpp 第 16 行)

MyType<double> type_1(container);
MyObject<MyType<double> > object_1(type_1);

MyType 是一个模板类,在其构造函数中需要一个双精度向量(您无需关注此属性)。MyObject 也是一个模板类,在其构造函数中接受 MyType (type_1) 的实例。

使用 MyObject

  1. 调用 MyType 的构造函数来创建实例 (type_1)
  2. 调用 MyType 的复制构造函数(创建 type_1 的副本)
  3. 调用 MyObject 的构造函数(将 type_1 的副本作为参数)
  4. (… 使用 object_1 做一些工作 … )
  5. 调用 type_1 的析构函数
  6. 调用 object_1 的析构函数,这将导致它调用 type_1 副本的析构函数

如果 MyType 的实例 (type_1) 仅用于创建 MyObject 的实例 (object_1),则会执行许多不必要的代码。

您可以自己尝试:更改目录并调用 make

cd optimizing_cpp_sample
make CFLAGS=-DOPT1

编译完成后,调用示例程序

 ./memory_sample

您现在应该看到不同构造函数类型的跟踪消息

MyType::MyType() contructor called
MyType::MyType(const MyType&) copy constructor called
MyObject::MyObject(const T& mytype) constructor called
MyType::m_data contains 32767 elements
MyType::~MyType() destructor called
MyType::~MyType() destructor called

优化示例

清除二进制文件,使用不同的参数调用 make,然后再次运行程序

make clean
make CFLAGS=-DOPT2
./memory_sample

这次,构造函数的跟踪消息看起来有点不同

MyType::MyType() contructor called
MyType::MyType(MyType&& other) move constructor called
MyObject::MyObject(T&& mytype) constructor with move called
MyType::~MyType() destructor called
MyType::m_data contains 32767 elements
MyType::~MyType() destructor called

它没有调用 MyType 复制构造函数,而是调用了 move 构造函数,并且 MyType 析构函数只被调用一次。正如您将在下面看到的,这对运行时性能有积极影响。

查看代码中的 main.cpp 第 23 行。它显示 object_2 是直接使用应该传递给 MyType 构造函数的参数创建的

MyObject<MyType<double> > object_2(container);

就是这样:只需一行代码即可使用 MyType 构造函数的参数调用 MyObject 的构造函数。编译器检测到无需在内存中保留 MyType 的实例。它没有创建另一个副本,而是将 object_2 内部指向 MyType 实例的内部指针设置为先前创建的实例。

谨慎移动

使用 move 语义需要一定的谨慎。再次运行程序

make clean 
make CFLAGS=-DOPT3
./memory_sample

程序立即崩溃

MyType::MyType() contructor called.
MyType::MyType(MyType&& other) move constructor called
MyType::m_data contains 3 elements
Segmentation fault (core dumped)

发生了什么?打开 main.cpp 并移动到第 27 行

void case_3(){
                               
    MyType<double> type_3({1.2, 3.4, 5.6});
    MyObject<MyType<double> >  object_3(std::move(type_3));
    object_3.m_mytype.print();
    /* Dangerous: std::move destroys the object */
    type_3.print();
} 

这强制使用 move 构造函数,通过在传递 type_3 作为参数时使用 std::move 来初始化 object_3(第 30 行)。

type_3 移动到 object_3 后,您不能再引用 type_3(因为您已经移动了它)。该对象已被销毁,无法再次使用。

衡量性能影响

您可能已经注意到输出中显示了执行时间。虽然您在单次调用中看不到任何效果(如上面的示例),但当您在无限循环中运行代码时,会有明显的改进。

不使用移动构造函数

make clean
make CFLAGS=-DOPT4
./memory_sample

在我的系统上,执行平均需要 170 毫秒

Average time: 170ms - Last execution took: 160um
Average time: 170ms - Last execution took: 179um
Average time: 170ms - Last execution took: 162um

使用移动构造函数

make clean
make CFLAGS=-DOPT5
./memory_sample

这次,执行平均需要 143 毫秒

Average time: 143ms - Last execution took: 142um
Average time: 143ms - Last execution took: 138um
Average time: 143ms - Last execution took: 142um

这个(构建的)示例实现了运行时减少 16%。请注意,编译器优化已关闭 (-O0)。使用更高程度的优化 (-O3),运行时减少将不那么显着 (11%)。

分析内存分配

在基于 Linux 的系统上分析内存分配的首选工具是 valgrind,或者更准确地说,是 堆分析器 massif。在本例中,执行复制构造函数会导致堆内存分配。

在第一个示例中运行 massif

$ make clean
$ make CFLAGS=-DOPT1
$ valgrind --tool=massif ./memory_sample

这将输出一个名为 massif.out 的文件,带有尾随进程 ID。可以使用 ms_print 读取此文件

ms_print massif.out.4781

它打印出内存分配随时间变化的图表

此图表显示内存分配随时间增加。这与实现相符。ms_print 的输出还包括有关哪些代码行导致内存分配的信息。我不会详细介绍如何读取输出,因为有 优秀的文档 可用。

结论

无论您使用哪种编程语言,减少复制操作(在本例中会导致堆内存分配)都是提高对性能至关重要的应用程序的运行时执行速度的好方法。当使用 C++ 时,您需要一些先决条件来应用这些优化

  • C++11,因为 move 语义仅自该版本起可用
  • move 构造函数/move 赋值运算符的可用性(请参阅 规则 5

在现代 x86-64 CPU 上,堆内存分配非常快,如果没有精确的测量,您不会注意到应用程序是否已优化。您的 CPU 性能越低,优化运行时代码就越有意义。例如,在移动设备上,它不仅可以改善响应行为,还可以延长电池寿命。

接下来阅读什么
标签
User profile image.
Stephan 是一位技术爱好者,他欣赏开源,因为它能深入了解事物的工作原理。Stephan 在工业自动化软件的专有领域担任全职支持工程师。如果可能,他会从事基于 Python 的开源项目、撰写文章或骑摩托车。

评论已关闭。

© . All rights reserved.