调整std :: vector <std :: unique_ptr <t>&gt;

时间:2017-07-13 19:01:18

标签: c++ c++11 vector compiler-optimization unique-ptr

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

但是在复合数据结构中使用std::unique_ptr呢,尤其是std::vector<std::unique_ptr<T>>?例如,调整向量的基础数据的大小,这可能发生在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 ;-)
}

在英特尔至强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上(英特尔似乎比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<std::unique_ptr<int>>内优化重新分配?标准中是否有任何内容阻止生成与原始指针一样有效的代码?这是标准库实现的问题吗?或者编译器还不够聪明(还)?

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

注意:假设T具有多态性且移动成本高,因此std::vector<T>不是一种选择。

2 个答案:

答案 0 :(得分:39)

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&#34;运动&#34;方法最终在这里,__uninitialized_copy<true>::__uinit_copy(...)最终调用memmove<false>版本没有 - 或者您可以自己查看代码(但是您已经看到了结果基准)。

最后,您最终得到了几个循环,这些循环为非平凡对象执行所需的复制步骤,例如调用目标对象的移动构造函数,然后调用所有源对象的析构函数。这些是单独的循环,甚至现代编译器也几乎无法推理像#34; OK,在第一个循环中我移动了所有目标对象,因此它们的ptr成员将为null,所以第二个循环是一个无操作的&#34;。最后,为了与原始指针的速度相等,不仅编译器需要在这两个循环中进行优化,他们还需要进行转换,以识别整个事物可以被memcpymemmove替换。 2

所以对你的问题的一个答案是,编译器不够聪明,无法进行优化,但主要是因为&#34; raw&#34;版本有很多编译时帮助,完全不需要这种优化。

循环融合

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

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

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

因此,我们将继续尝试在源级别明确地将循环放在一起。

现在双循环实现通过不破坏任何源对象来帮助保留操作的异常安全合同,直到我们知道副本的构造部分已经完成,但它也有助于在我们简单地优化复制和破坏时 - 可复制和易破坏的对象。特别是,使用基于简单特征的选择,我们可以用memmove替换副本,并且可以完全取消销毁循环 3

因此,当这些优化适用时,双循环方法会有所帮助,但它实际上会受到对象的一般情况的伤害,这些对象既不是可复制的也不是可破坏的。这意味着您需要对对象进行两次传递,否则您将失去优化和消除对象副本与其后续破坏之间的代码的机会。在unique_ptr情况下,您失去了编译器传播源unique_ptr将拥有NULL内部ptr成员的知识的能力,因此跳过if (ptr) delete ptr完全检查 4

Trivially Movable

现在有人可能会问我们是否可以将相同的类型特征编译时优化应用于unique_ptr情况。例如,可以查看平易可复制的要求,看看它们对于std::vector中的常见移动操作可能过于严格。当然,unique_ptr显然不是轻易可复制的,因为逐位复制会使源和目标对象都由于相同的指针(并导致双删除),但似乎它应该是按位的可移动:如果将unique_ptr从一个内存区域移动到另一个区域,这样您就不再将源视为活动对象(因此不会调用其析构函数)对于典型的 unique_ptr实施,它应该只是工作&#34;

不幸的是,没有这样的&#34;微不足道的举动&#34;概念存在,虽然你可以尝试自己推出。似乎有open debate关于对于可以逐字节复制的对象是否为UB,并且在移动场景中不依赖于它们的构造函数或析构函数行为。

你总是可以实现你自己的简单可移动的概念,就像(a)对象有一个简单的移动构造函数和(b)当用作移动构造函数的源参数时,对象留在它的析构函数无效的状态。请注意,这样的定义目前大多无用,因为&#34;平凡的移动构造函数&#34; (基本上是元素副本,没有别的)与源对象的任何修改都不一致。因此,例如,一个简单的移动构造函数无法将源ptr的{​​{1}}成员设置为零。因此,您需要跳过更多的箍,例如引入破坏性移动操作的概念,这会使源对象被破坏,而不是处于有效但未指定的状态。

你可以找到更详细的讨论这个&#34;平凡可移动&#34;在ISO C ++ usenet讨论组的this thread上。特别是在链接的回复中,unique_ptr的向量的确切问题得到了解决:

  

事实证明,许多智能指针(包括unique_ptr和shared_ptr)   落入所有这三个类别,并通过应用它们,你可以   拥有智能指针的向量,其原始数据基本上为零   甚至在非优化的调试版本中也有指针。

另请参阅relocator提案。

0 虽然问题末尾的非矢量示例表明情况并非如此。这是因为zneak在his answer中解释了可能的别名。原始指针将避免许多这些别名问题,因为它们缺少unique_ptr具有的间接性(例如,您通过值传递原始指针,而不是通过引用传递指针的结构)并且通常可以省略{{1完全检查。

2 这实际上比你想象的要难,因为当源和目标重叠时,unique_ptr的语义与对象复制循环略有不同。当然,适用于原始点的高级类型特征代码知道(通过契约)没有重叠,或者if (ptr) delete ptr的行为即使存在重叠也是一致的,但是在稍后任意一些时候证明相同的东西优化传递可能要困难得多。

3 值得注意的是,这些优化或多或少是独立的。例如,许多物体都是可以轻易破坏的,并非易于复制。

4 尽管在my test中,memmovememmove都没有能够取消检查,即使应用了gcc,显然也是因为不够强大的别名分析,或者可能是因为clang剥离了&#34;限制&#34;不知何故,限定词。

答案 1 :(得分:8)

我没有准确的答案,因为背后用矢量咬你的是什么;看起来像BeeOnRope可能已经有了一个。

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

您可以通过adding the __restrict__ keyword(其中,双下划线暗示,不是标准C ++)对unique_ptr引用参数进行验证。该关键字通知编译器引用是唯一可以通过其访问该内存的引用,因此不存在其他任何内容可能与其混淆的风险。执行此操作时,函数的所有三个版本都会编译为相同的机器代码,并且无需检查是否需要删除unique_ptr