调整 std::vector<std::unique_ptr<T>> 大小的性能

Posted

技术标签:

【中文标题】调整 std::vector<std::unique_ptr<T>> 大小的性能【英文标题】:Performance of resizing std::vector<std::unique_ptr<T>> 【发布时间】:2017-12-18 17:35:58 【问题描述】:

一般的概念似乎是std::unique_ptr 具有no time overhead 与正确使用的拥有原始指针given sufficient optimization 相比。

但是在复合数据结构中使用std::unique_ptr 怎么样,尤其是std::vector&lt;std::unique_ptr&lt;T&gt;&gt;?例如,调整向量的基础数据的大小,这可能发生在push_back 期间。为了隔离性能,我循环了pop_backshrink_to_fitemplace_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(无涡轮)的 Linux 上使用 gcc 7.1.0、clang 3.8.0 和 17.0.4 编译代码:

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 编译器 seem to be able to optimize away 无意义的 if (ptr) delete ptr;。英特尔编译器甚至给了删除 28% 的机会。令人惊讶的是,删除检查始终被省略:

auto tmp = b.release();
a.release();
a.reset(tmp);

这些点不是这个问题的主要方面,但所有这些都让我觉得我错过了一些东西。

为什么各种编译器无法优化std::vector&lt;std::unique_ptr&lt;int&gt;&gt; 内的重新分配?标准中是否有任何内容阻止生成与原始指针一样高效的代码?这是标准库实现的问题吗?还是编译器不够聪明(还)?

与使用原始指针相比,可以做些什么来避免性能影响?

注意:假设T 是多态的并且移动成本很高,所以std::vector&lt;T&gt; 不是一个选项。

【问题讨论】:

默认构造的std::unique_ptr 可能会导致几乎NOP。 我很确定如果你将back 声明为volatile,编译器不会优化它,你也不需要那个异常技巧。 推理巨大的缓冲区和它们的 unform 状态很难吗?以某种方式教编译器 std::unique_ptr 可以用源零破坏性地不进行位块移动,并且向量可以使用破坏性的不位块移动,而不是教它指向的 100000 个元素的状态更容易前面的代码行可以证明以下几点。不久前,我确实在一个标准提案中看到了 nothrow move-and-destroy 特性,我不知道它的状态。 godbolt 对于探索这类问题很有用(如果您是汇编语言)。 unique_ptr 的情况可能必须通过调用移动构造函数来移动所有元素,而编译器能够通过简单地调用 memcpy 来移动 T*s 【参考方案1】:

unique_ptr 的性能与原始指针一样优化后的说法大多仅适用于对单个指针的基本操作,例如创建、取消引用、单个指针的赋值和删除.这些操作的定义足够简单,优化编译器通常可以进行所需的转换,以使生成的代码在性能上与原始版本等效(或几乎相同)0

其中一个崩溃的地方尤其是在基于数组的容器(例如 std::vector)上更高级别的基于语言的优化,正如您在测试中所指出的那样。这些容器通常使用 源代码级别 优化,这些优化依赖于类型特征在编译时确定是否可以使用诸如memcpy 的逐字节副本安全地复制类型,并委托给这样的方法,如果所以,或者以其他方式回退到逐元素复制循环。

要使用memcpy 安全地复制对象,对象必须是trivially copyable。现在std::unique_ptr 不是一般可复制的,因为它确实失败了几个requirements,例如只有琐碎或删除的复制和移动构造函数。确切的机制取决于所涉及的标准库,但一般来说,高质量的 std::vector 实现最终会调用像 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“运动”方法都在这里结束,__uninitialized_copy&lt;true&gt;::__uinit_copy(...) 最终调用memmove&lt;false&gt; 版本没有 - 或者你可以追踪自己编写代码(但您已经在基准测试中看到了结果)。

最终,您最终会得到几个循环,这些循环执行非平凡对象所需的复制步骤,例如调用目标对象的移动构造函数,然后调用所有源对象的析构函数。这些是单独的循环,即使是现代编译器也几乎无法推断出类似“好的,在第一个循环中我移动了所有目标对象,因此它们的 ptr 成员将为空,所以第二个循环是否-操作”。最后,为了与原始指针的速度相等,编译器不仅需要跨这两个循环进行优化,还需要进行转换,以识别整个事物可以被memcpymemmove2 替换.

因此,您的问题的一个答案是编译器不够聪明,无法进行这种优化,但这主要是因为“原始”版本有很多编译时帮助,可以完全跳过这种优化的需要。

循环融合

如前所述,现有的vector 实现在两个单独的循环中实现了调整大小类型的操作(除了分配新存储和释放旧存储等非循环工作):

将源对象复制到新分配的目标数组中(在概念上使用类似placement new 调用移动构造函数)。 销毁旧区域中的源对象。

从概念上讲,您可以想象另一种方法:在一个循环中完成所有操作,复制每个元素,然后立即销毁它。编译器甚至可能会注意到这两个循环迭代同一组值并将两个循环融合合为一个。 [显然],然而,(https://gcc.gnu.org/ml/gcc/2015-04/msg00291.html) gcc 今天不做任何循环融合,如果你相信this test,clangicc 也不做。

那么我们只能在源代码级别明确地将循环放在一起。

现在,双循环实现通过在我们知道副本的构造部分已经完成之前不销毁任何源对象来帮助保持操作的异常安全契约,但它也有助于优化复制和销毁,当我们有微不足道的- 可复制和可简单破坏的对象,分别。特别是,使用基于简单特征的选择,我们可以用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 从一个内存区域移动到另一个内存区域,这样您就不再将源视为活动对象(因此不会调用它的析构函数)它应该“正常工作” , 对于 典型 unique_ptr 实现。

不幸的是,不存在这样的“微不足道的举动”概念,尽管您可以尝试自己动手。似乎有一个 open debate 关于这是否是 UB 对于可以按字节复制并且不依赖于它们在移动场景中的构造函数或析构函数行为的对象。

您始终可以实现自己的可移动概念,类似于 (a) 对象具有普通移动构造函数,并且 (b) 当用作移动构造函数的源参数时,对象留在它的析构函数无效的状态。请注意,这样的定义目前大多是无用的,因为“微不足道的移动构造函数”(基本上是元素复制,仅此而已)与源对象的任何修改都不一致。例如,一个简单的移动构造函数不能将源unique_ptrptr 成员设置为零。因此,您需要跳过更多的障碍,例如引入 破坏性移动 操作的概念,该操作会使源对象被破坏,而不是处于有效但未指定的状态。

您可以在 ISO C++ usenet 讨论组的this thread 上找到有关此“简单可移动”的更多详细讨论。特别是,在链接的回复中,解决了unique_ptr 向量的确切问题:

原来有很多智能指针(包括unique_ptr和shared_ptr) 属于所有这三个类别,通过应用它们,您可以 具有智能指针向量,其原始开销基本为零 即使在未优化的调试版本中也有指针。

另请参阅relocator 提案。


0 尽管问题末尾的非向量示例表明情况并非总是如此。正如 zneak 在his answer 中解释的那样,这是由于可能的别名。原始指针将避免许多这些别名问题,因为它们缺少 unique_ptr 所具有的间接性(例如,您通过值传递原始指针,而不是通过引用传递具有指针的结构)并且通常可以完全省略 if (ptr) delete ptr 检查.

2 这实际上比您想象的要难,因为例如,memmove 在源和目标重叠时与对象复制循环的语义略有不同。当然,适用于原始点的高级类型特征代码(通过合同)知道没有重叠,或者即使有重叠,memmove 的行为也是一致的,但在以后的任意优化过程中证明了同样的事情可能会更难。

3 需要注意的是,这些优化或多或少是独立的。例如,许多对象都是可简单破坏的,但不能简单复制。

4 尽管在my test 中gccclang 都无法抑制检查,即使应用了__restrict__,显然是由于别名分析不够强大,或者可能是因为@ 987654376@ 以某种方式去除了“限制”限定符。

【讨论】:

很好的答案。谢谢。 你提出了一些非常好的观点。您是否认为可以通过针对 unique_ptr 的向量函数专门使用某些模板来改进这一点 - 通过利用 libstdc++ 中有关 unique_ptr 实现的知识来弥补它缺乏可复制性的不足? @Zulan - 可能。我刚刚在关于可移动的部分添加了更多内容,从“您始终可以实现自己的......”开始 - 但正如我在那里指出的那样,这需要更改语言和一些问题。您总是可以尝试在unique_ptr 上专门化vector&lt;&gt; 函数(或他们调用的各种std::... 辅助函数),然后强迫他们做“简单”的事情,我怀疑它会起作用,但这在技​​术上仍然是未定义的行为,因为标准不保证 memcpy 使源对象处于可用状态。 这是我的牛肉与 C++ 移动语义的要点;通过要求源处于可破坏/可分配状态,它将性能冲入下水道:( @MatthieuM。 - 是的,但是对于很多场景(例如,实现swap 等),移动语义通常不太有用。此外,一般来说,您仍然希望在许多类型的可移动对象上调用析构函数,因此至少您仍然需要该选项。您还希望避免编译器必须记住是否销毁具有自动持续时间的对象,具体取决于通过函数的流程。它会变得混乱......【参考方案2】:

我没有准确的答案来说明什么是矢量在背后咬你;看起来 BeeOnRope 可能已经为您准备了一个。

幸运的是,我可以告诉您在您的微型示例中背后有什么困扰您,这些示例涉及重置指针的不同方法:别名分析。具体来说,编译器无法证明(或不愿推断)两个 unique_ptr 引用不重叠。他们强迫自己重新加载unique_ptr 值,以防对第一个的写入修改了第二个。 baz 不会受到影响,因为编译器可以证明,在格式良好的程序中,这两个参数都不可能与具有函数本地自动存储功能的 tmp 别名。

您可以通过adding the __restrict__ keyword(正如双下划线在某种程度上暗示,这不是标准C++)和unique_ptr 参考参数来验证这一点。该关键字通知编译器该引用是唯一可以访问该内存的引用,因此没有其他任何东西可以与它别名的风险。当您这样做时,您的函数的所有三个版本都会编译为相同的机器代码,并且无需检查是否需要删除 unique_ptr

【讨论】:

很好的补充,我怀疑有混叠,但__restrict__ 确实显示了它。有趣的是,如果编译器确定它是一个别名 - optimization does work. 我对 GCC 不熟悉,但是 LLVM 的别名查询有三种可能的结果:“不别名”、“可能别名”和“总是别名”。你真的不能用“可能别名”做任何事情(不幸的是,这是大多数别名查询的结果,因为这个问题非常难以解决),但是“不别名”和“总是别名”都提供了不同的优化可能性.

以上是关于调整 std::vector<std::unique_ptr<T>> 大小的性能的主要内容,如果未能解决你的问题,请参考以下文章

调整 std::vector<std::unique_ptr<T>> 大小的性能

std::vector 向下调整大小

调整动态字符串数组的大小[关闭]

调整指针段错误向量的大小

std::vector 保留和调整 NUMA 局部性

矢量调整大小后下标超出范围