调整大小的性能 std::vector<std::unique_ptr<T>>
Performance of resizing std::vector<std::unique_ptr<T>>
一般的概念似乎是 std::unique_ptr
有 no time overhead compared to properly used owning raw pointers, given sufficient optimization。
但是在复合数据结构中使用 std::unique_ptr
,特别是 std::vector<std::unique_ptr<T>>
呢?例如,调整矢量基础数据的大小,这可能会在 push_back
期间发生。为了隔离性能,我循环 pop_back
、shrink_to_fit
、emplace_back
:
#include <chrono>
#include <vector>
#include <memory>
#include <iostream>
constexpr size_t size = 1000000;
constexpr size_t repeat = 1000;
using my_clock = std::chrono::high_resolution_clock;
template<class T>
auto test(std::vector<T>& v) {
v.reserve(size);
for (size_t i = 0; i < size; i++) {
v.emplace_back(new int());
}
auto t0 = my_clock::now();
for (int i = 0; i < repeat; i++) {
auto back = std::move(v.back());
v.pop_back();
v.shrink_to_fit();
if (back == nullptr) throw "don't optimize me away";
v.emplace_back(std::move(back));
}
return my_clock::now() - t0;
}
int main() {
std::vector<std::unique_ptr<int>> v_u;
std::vector<int*> v_p;
auto millis_p = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_p));
auto millis_u = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_u));
std::cout << "raw pointer: " << millis_p.count() << " ms, unique_ptr: " << millis_u.count() << " ms\n";
for (auto p : v_p) delete p; // I don't like memory leaks ;-)
}
在 Intel Xeon E5-2690 v3 @ 2.6 GHz(无 Turbo)上 Linux 上使用 gcc 7.1.0、clang 3.8.0 和 17.0.4 使用 -O3 -o -march=native -std=c++14 -g
编译代码:
raw pointer: 2746 ms, unique_ptr: 5140 ms (gcc)
raw pointer: 2667 ms, unique_ptr: 5529 ms (clang)
raw pointer: 1448 ms, unique_ptr: 5374 ms (intel)
原始指针版本将所有时间都花在了优化的 memmove
中(intel 似乎有一个比 clang 和 gcc 更好的版本)。 unique_ptr
代码似乎首先将矢量数据从一个内存块复制到另一个内存块,然后将原始内存块赋值为零——所有这些都在一个可怕的未优化循环中。然后它再次遍历原始数据块以查看是否有任何刚刚为零的数据是非零的并且需要删除。完整的细节可以在 godbolt 上看到。 问题不在于编译后的代码有何不同,这很清楚。问题是 为什么 编译器无法优化通常被认为是无额外开销的抽象。
为了了解编译器如何推理处理 std::unique_ptr
,我更多地关注了孤立的代码。例如:
void foo(std::unique_ptr<int>& a, std::unique_ptr<int>& b) {
a.release();
a = std::move(b);
}
或类似的
a.release();
a.reset(b.release());
x86 编译器的 none seem to be able to optimize away 毫无意义 if (ptr) delete ptr;
。英特尔编译器甚至给出了 28% 的删除机会。令人惊讶的是,删除检查始终被省略:
auto tmp = b.release();
a.release();
a.reset(tmp);
这些不是这个问题的主要方面,但是所有这些让我觉得我错过了什么。
为什么各种编译器无法优化 std::vector<std::unique_ptr<int>>
中的重新分配?标准中是否有任何内容阻止生成与原始指针一样高效的代码?这是标准库实现的问题吗?还是编译器还不够聪明?
与使用原始指针相比,如何避免性能影响?
注意:假设 T
是多态的并且移动起来很昂贵,所以 std::vector<T>
不是一个选项。
声称 unique_ptr
在优化后 与原始指针 表现一样好 主要仅适用于单个指针的基本操作,例如创建,取消引用,单个指针的分配和删除。这些操作的定义足够简单,优化编译器通常可以进行所需的转换,使得生成的代码在性能上等同于(或接近)原始版本0.
一个地方分崩离析,尤其是 更高级别的基于语言的优化 在基于数组的容器上,例如 std::vector
,正如您在测试中指出的那样。这些容器通常使用 源级别 优化,这取决于类型特征,以在编译时确定是否可以使用 memcpy
等字节复制安全地复制类型,并委托如果是这样,则使用这样的方法,否则将退回到按元素复制循环。
要使用 memcpy
安全地复制对象必须是 trivially copyable. Now std::unique_ptr
is not trivially copyable since indeed it fails several of the requirements such as having only trivial or deleted copy and move constructors. The exact mechanism depends on the standard library involved, but in general a quality std::vector
implementation will end up calling a specialized form of something like std::uninitialized_copy
对于仅委托给 memmove
.
的平凡可复制类型
典型的实现细节相当折磨,但是对于libstc++
(被gcc
使用)你可以看到std::uninitialized_copy
中的高层分歧:
template<typename _InputIterator, typename _ForwardIterator>
inline _ForwardIterator
uninitialized_copy(_InputIterator __first, _InputIterator __last,
_ForwardIterator __result)
{
...
return std::__uninitialized_copy<__is_trivial(_ValueType1)
&& __is_trivial(_ValueType2)
&& __assignable>::
__uninit_copy(__first, __last, __result);
}
从那里你可以相信我的话,许多 std::vector
"movement" 方法在这里结束,并且 __uninitialized_copy<true>::__uinit_copy(...)
最终调用 memmove
而 <false>
版本没有 - 或者您可以自己跟踪代码(但您已经在基准测试中看到了结果)。
最终,您会得到几个循环,这些循环为重要对象执行所需的复制步骤,例如调用目标对象的移动构造函数,然后调用所有源对象的析构函数。这些是独立的循环,即使是现代编译器也几乎无法推理出 "OK, in the first loop I moved all the destination objects so their ptr
member will be null, so the second loop is a no-op" 这样的东西。最后,为了达到原始指针的速度,编译器不仅需要优化这两个循环,还需要进行转换以识别整个事物可以被 memcpy
或 memmove
替换2.
所以你的问题的一个答案是编译器不够聪明,无法进行这种优化,但这主要是因为 "raw" 版本有很多编译时帮助来跳过这个需要完全优化。
循环融合
如前所述,现有的 vector
实现在两个单独的循环中实现调整大小类型的操作(除了非循环工作,例如分配新存储和释放旧存储):
- 将源对象复制到新分配的目标数组中(概念上使用类似于 placement new 调用移动构造函数的方法)。
- 正在销毁旧区域中的源对象。
从概念上讲,您可以想象另一种方法:在一个循环中执行所有操作,复制每个元素并立即销毁它。编译器甚至可能注意到这两个循环遍历同一组值并且 将这两个循环融合 为一个。 [显然],但是,(https://gcc.gnu.org/ml/gcc/2015-04/msg00291.html) gcc
doesn't do any loop fusion today, and nor do clang
or icc
if you believe this test.
因此,我们只能尝试在源代码级别明确地将循环放在一起。
现在,双循环实现通过在我们知道副本的构造部分已完成之前不销毁任何源对象来帮助维护操作的异常安全契约,但它也有助于优化复制和销毁,当我们分别具有平凡可复制和平凡可破坏的对象。特别是,通过基于简单特征的选择,我们可以用 memmove
替换副本,并且可以完全省略破坏循环 3。
因此,当应用这些优化时,双循环方法会有所帮助,但在对象既不可简单复制也不可破坏的一般情况下,它实际上会造成伤害。这意味着您需要对对象进行两次传递,并且您失去了优化和消除对象副本及其后续销毁之间代码的机会。在 unique_ptr
的情况下,您失去了编译器传播源 unique_ptr
将具有 NULL
内部 ptr
成员的知识的能力,因此跳过了 if (ptr) delete ptr
完全检查4。
平凡可动
现在有人可能会问我们是否可以将相同的类型特征编译时优化应用于 unique_ptr
案例。例如,人们可能会查看 trivially copyable 要求,发现它们对于 std::vector
中常见的 move 操作可能过于严格.当然,unique_ptr
显然不是可复制的,因为按位复制会使源对象和目标对象都归因于同一个指针(并导致双重删除),但它似乎应该是按位的movable:如果将 unique_ptr
从一个内存区域移动到另一个内存区域,这样您就不再将源视为活动对象(因此不会调用其析构函数) 它应该 "just work",对于 典型 unique_ptr
实现。
不幸的是,不存在这样的 "trivial move" 概念,尽管您可以尝试推出自己的概念。对于可以按字节复制并且不依赖于它们在移动场景中的构造函数或析构函数行为的对象,似乎有一个关于这是否是 UB 的 。
你总是可以实现你自己的平凡可移动的概念,就像 (a) 对象有一个平凡的移动构造函数和 (b) 当用作移动构造函数的源参数时对象处于其析构函数无效的状态。请注意,这样的定义目前几乎没有用,因为 "trivial move constructor"(基本上是按元素复制,仅此而已)与源对象的任何修改都不一致。因此,例如,一个普通的移动构造函数不能将源 unique_ptr
的 ptr
成员设置为零。因此,您需要跳过更多步骤,例如引入 破坏性移动 操作的概念,该操作使源对象被销毁,而不是处于有效但未指定的状态。
您可以在 ISO C++ usenet 讨论组的 this thread 上找到关于此 "trivially movable" 的一些更详细的讨论。特别是,在链接的回复中,解决了 unique_ptr
向量的确切问题:
It turns out many smart pointers (unique_ptr and shared_ptr included)
fall into all three of those categories and by applying them you can
have vectors of smart pointers with essentially zero overhead over raw
pointers even in non-optimized debug builds.
另请参阅 relocator 提案。
0 虽然问题末尾的非矢量示例表明情况并非总是如此。这是由于 zneak 在 中解释的可能的别名。原始指针将避免许多这些别名问题,因为它们缺少 unique_ptr
具有的间接性(例如,您按值传递原始指针,而不是按引用传递指针的结构)并且通常可以省略 if (ptr) delete ptr
完全检查。
2 这实际上比你想象的要难,因为 memmove
,例如,当源和目标重叠。当然,适用于原始点的高级类型特征代码(通过合同)知道没有重叠,或者 memmove
的行为即使存在重叠也是一致的,但在以后的任意时间证明相同的事情优化过程可能更难。
3 重要的是要注意这些优化或多或少是独立的。例如,许多对象是可平凡破坏的,但不可平凡复制。
4 尽管在 my test 中 gcc
和 clang
都无法禁止检查,即使应用了 __restrict__
,显然是由于别名分析不够强大,或者可能是因为 std::move
以某种方式去除了 "restrict" 限定符。
我没有一个准确的答案来说明是什么在背后用向量来攻击你;看起来 BeeOnRope 可能已经为您准备好了。
幸运的是,对于涉及重置指针的不同方法的微型示例,我可以告诉您是什么在背后困扰着您:别名分析。具体来说,编译器无法证明(或不愿推断)两个 unique_ptr
引用不重叠。他们强迫自己重新加载 unique_ptr
值,以防对第一个的写入修改了第二个。 baz
不会受到影响,因为编译器可以证明在格式良好的程序中,这两个参数都可能与 tmp
别名,它具有函数局部自动存储。
您可以通过 adding the __restrict__
keyword(正如双下划线暗示的那样,不是标准的 C++)对任一 unique_ptr
引用参数进行验证。该关键字通知编译器该引用是唯一可以访问该内存的引用,因此不存在任何其他内容可以使用它的别名的风险。当你这样做时,你的函数的所有三个版本都会编译成相同的机器代码,并且不会费心检查是否需要删除 unique_ptr
。
一般的概念似乎是 std::unique_ptr
有 no time overhead compared to properly used owning raw pointers, given sufficient optimization。
但是在复合数据结构中使用 std::unique_ptr
,特别是 std::vector<std::unique_ptr<T>>
呢?例如,调整矢量基础数据的大小,这可能会在 push_back
期间发生。为了隔离性能,我循环 pop_back
、shrink_to_fit
、emplace_back
:
#include <chrono>
#include <vector>
#include <memory>
#include <iostream>
constexpr size_t size = 1000000;
constexpr size_t repeat = 1000;
using my_clock = std::chrono::high_resolution_clock;
template<class T>
auto test(std::vector<T>& v) {
v.reserve(size);
for (size_t i = 0; i < size; i++) {
v.emplace_back(new int());
}
auto t0 = my_clock::now();
for (int i = 0; i < repeat; i++) {
auto back = std::move(v.back());
v.pop_back();
v.shrink_to_fit();
if (back == nullptr) throw "don't optimize me away";
v.emplace_back(std::move(back));
}
return my_clock::now() - t0;
}
int main() {
std::vector<std::unique_ptr<int>> v_u;
std::vector<int*> v_p;
auto millis_p = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_p));
auto millis_u = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_u));
std::cout << "raw pointer: " << millis_p.count() << " ms, unique_ptr: " << millis_u.count() << " ms\n";
for (auto p : v_p) delete p; // I don't like memory leaks ;-)
}
在 Intel Xeon E5-2690 v3 @ 2.6 GHz(无 Turbo)上 Linux 上使用 gcc 7.1.0、clang 3.8.0 和 17.0.4 使用 -O3 -o -march=native -std=c++14 -g
编译代码:
raw pointer: 2746 ms, unique_ptr: 5140 ms (gcc)
raw pointer: 2667 ms, unique_ptr: 5529 ms (clang)
raw pointer: 1448 ms, unique_ptr: 5374 ms (intel)
原始指针版本将所有时间都花在了优化的 memmove
中(intel 似乎有一个比 clang 和 gcc 更好的版本)。 unique_ptr
代码似乎首先将矢量数据从一个内存块复制到另一个内存块,然后将原始内存块赋值为零——所有这些都在一个可怕的未优化循环中。然后它再次遍历原始数据块以查看是否有任何刚刚为零的数据是非零的并且需要删除。完整的细节可以在 godbolt 上看到。 问题不在于编译后的代码有何不同,这很清楚。问题是 为什么 编译器无法优化通常被认为是无额外开销的抽象。
为了了解编译器如何推理处理 std::unique_ptr
,我更多地关注了孤立的代码。例如:
void foo(std::unique_ptr<int>& a, std::unique_ptr<int>& b) {
a.release();
a = std::move(b);
}
或类似的
a.release();
a.reset(b.release());
x86 编译器的 none seem to be able to optimize away 毫无意义 if (ptr) delete ptr;
。英特尔编译器甚至给出了 28% 的删除机会。令人惊讶的是,删除检查始终被省略:
auto tmp = b.release();
a.release();
a.reset(tmp);
这些不是这个问题的主要方面,但是所有这些让我觉得我错过了什么。
为什么各种编译器无法优化 std::vector<std::unique_ptr<int>>
中的重新分配?标准中是否有任何内容阻止生成与原始指针一样高效的代码?这是标准库实现的问题吗?还是编译器还不够聪明?
与使用原始指针相比,如何避免性能影响?
注意:假设 T
是多态的并且移动起来很昂贵,所以 std::vector<T>
不是一个选项。
声称 unique_ptr
在优化后 与原始指针 表现一样好 主要仅适用于单个指针的基本操作,例如创建,取消引用,单个指针的分配和删除。这些操作的定义足够简单,优化编译器通常可以进行所需的转换,使得生成的代码在性能上等同于(或接近)原始版本0.
一个地方分崩离析,尤其是 更高级别的基于语言的优化 在基于数组的容器上,例如 std::vector
,正如您在测试中指出的那样。这些容器通常使用 源级别 优化,这取决于类型特征,以在编译时确定是否可以使用 memcpy
等字节复制安全地复制类型,并委托如果是这样,则使用这样的方法,否则将退回到按元素复制循环。
要使用 memcpy
安全地复制对象必须是 trivially copyable. Now std::unique_ptr
is not trivially copyable since indeed it fails several of the requirements such as having only trivial or deleted copy and move constructors. The exact mechanism depends on the standard library involved, but in general a quality std::vector
implementation will end up calling a specialized form of something like std::uninitialized_copy
对于仅委托给 memmove
.
典型的实现细节相当折磨,但是对于libstc++
(被gcc
使用)你可以看到std::uninitialized_copy
中的高层分歧:
template<typename _InputIterator, typename _ForwardIterator>
inline _ForwardIterator
uninitialized_copy(_InputIterator __first, _InputIterator __last,
_ForwardIterator __result)
{
...
return std::__uninitialized_copy<__is_trivial(_ValueType1)
&& __is_trivial(_ValueType2)
&& __assignable>::
__uninit_copy(__first, __last, __result);
}
从那里你可以相信我的话,许多 std::vector
"movement" 方法在这里结束,并且 __uninitialized_copy<true>::__uinit_copy(...)
最终调用 memmove
而 <false>
版本没有 - 或者您可以自己跟踪代码(但您已经在基准测试中看到了结果)。
最终,您会得到几个循环,这些循环为重要对象执行所需的复制步骤,例如调用目标对象的移动构造函数,然后调用所有源对象的析构函数。这些是独立的循环,即使是现代编译器也几乎无法推理出 "OK, in the first loop I moved all the destination objects so their ptr
member will be null, so the second loop is a no-op" 这样的东西。最后,为了达到原始指针的速度,编译器不仅需要优化这两个循环,还需要进行转换以识别整个事物可以被 memcpy
或 memmove
替换2.
所以你的问题的一个答案是编译器不够聪明,无法进行这种优化,但这主要是因为 "raw" 版本有很多编译时帮助来跳过这个需要完全优化。
循环融合
如前所述,现有的 vector
实现在两个单独的循环中实现调整大小类型的操作(除了非循环工作,例如分配新存储和释放旧存储):
- 将源对象复制到新分配的目标数组中(概念上使用类似于 placement new 调用移动构造函数的方法)。
- 正在销毁旧区域中的源对象。
从概念上讲,您可以想象另一种方法:在一个循环中执行所有操作,复制每个元素并立即销毁它。编译器甚至可能注意到这两个循环遍历同一组值并且 将这两个循环融合 为一个。 [显然],但是,(https://gcc.gnu.org/ml/gcc/2015-04/msg00291.html) gcc
doesn't do any loop fusion today, and nor do clang
or icc
if you believe this test.
因此,我们只能尝试在源代码级别明确地将循环放在一起。
现在,双循环实现通过在我们知道副本的构造部分已完成之前不销毁任何源对象来帮助维护操作的异常安全契约,但它也有助于优化复制和销毁,当我们分别具有平凡可复制和平凡可破坏的对象。特别是,通过基于简单特征的选择,我们可以用 memmove
替换副本,并且可以完全省略破坏循环 3。
因此,当应用这些优化时,双循环方法会有所帮助,但在对象既不可简单复制也不可破坏的一般情况下,它实际上会造成伤害。这意味着您需要对对象进行两次传递,并且您失去了优化和消除对象副本及其后续销毁之间代码的机会。在 unique_ptr
的情况下,您失去了编译器传播源 unique_ptr
将具有 NULL
内部 ptr
成员的知识的能力,因此跳过了 if (ptr) delete ptr
完全检查4。
平凡可动
现在有人可能会问我们是否可以将相同的类型特征编译时优化应用于 unique_ptr
案例。例如,人们可能会查看 trivially copyable 要求,发现它们对于 std::vector
中常见的 move 操作可能过于严格.当然,unique_ptr
显然不是可复制的,因为按位复制会使源对象和目标对象都归因于同一个指针(并导致双重删除),但它似乎应该是按位的movable:如果将 unique_ptr
从一个内存区域移动到另一个内存区域,这样您就不再将源视为活动对象(因此不会调用其析构函数) 它应该 "just work",对于 典型 unique_ptr
实现。
不幸的是,不存在这样的 "trivial move" 概念,尽管您可以尝试推出自己的概念。对于可以按字节复制并且不依赖于它们在移动场景中的构造函数或析构函数行为的对象,似乎有一个关于这是否是 UB 的
你总是可以实现你自己的平凡可移动的概念,就像 (a) 对象有一个平凡的移动构造函数和 (b) 当用作移动构造函数的源参数时对象处于其析构函数无效的状态。请注意,这样的定义目前几乎没有用,因为 "trivial move constructor"(基本上是按元素复制,仅此而已)与源对象的任何修改都不一致。因此,例如,一个普通的移动构造函数不能将源 unique_ptr
的 ptr
成员设置为零。因此,您需要跳过更多步骤,例如引入 破坏性移动 操作的概念,该操作使源对象被销毁,而不是处于有效但未指定的状态。
您可以在 ISO C++ usenet 讨论组的 this thread 上找到关于此 "trivially movable" 的一些更详细的讨论。特别是,在链接的回复中,解决了 unique_ptr
向量的确切问题:
It turns out many smart pointers (unique_ptr and shared_ptr included) fall into all three of those categories and by applying them you can have vectors of smart pointers with essentially zero overhead over raw pointers even in non-optimized debug builds.
另请参阅 relocator 提案。
0 虽然问题末尾的非矢量示例表明情况并非总是如此。这是由于 zneak 在 unique_ptr
具有的间接性(例如,您按值传递原始指针,而不是按引用传递指针的结构)并且通常可以省略 if (ptr) delete ptr
完全检查。
2 这实际上比你想象的要难,因为 memmove
,例如,当源和目标重叠。当然,适用于原始点的高级类型特征代码(通过合同)知道没有重叠,或者 memmove
的行为即使存在重叠也是一致的,但在以后的任意时间证明相同的事情优化过程可能更难。
3 重要的是要注意这些优化或多或少是独立的。例如,许多对象是可平凡破坏的,但不可平凡复制。
4 尽管在 my test 中 gcc
和 clang
都无法禁止检查,即使应用了 __restrict__
,显然是由于别名分析不够强大,或者可能是因为 std::move
以某种方式去除了 "restrict" 限定符。
我没有一个准确的答案来说明是什么在背后用向量来攻击你;看起来 BeeOnRope 可能已经为您准备好了。
幸运的是,对于涉及重置指针的不同方法的微型示例,我可以告诉您是什么在背后困扰着您:别名分析。具体来说,编译器无法证明(或不愿推断)两个 unique_ptr
引用不重叠。他们强迫自己重新加载 unique_ptr
值,以防对第一个的写入修改了第二个。 baz
不会受到影响,因为编译器可以证明在格式良好的程序中,这两个参数都可能与 tmp
别名,它具有函数局部自动存储。
您可以通过 adding the __restrict__
keyword(正如双下划线暗示的那样,不是标准的 C++)对任一 unique_ptr
引用参数进行验证。该关键字通知编译器该引用是唯一可以访问该内存的引用,因此不存在任何其他内容可以使用它的别名的风险。当你这样做时,你的函数的所有三个版本都会编译成相同的机器代码,并且不会费心检查是否需要删除 unique_ptr
。