![](/img/trans.png)
[英]Assign std::vector<std::unique_ptr<T>> to another std::vector<std::unique_ptr<T>>
[英]Performance of resizing std::vector<std::unique_ptr<T>>
一般的概念似乎是std::unique_ptr
与正确使用的拥有原始指针相比没有时间开销 , 给定足够的优化 。
但是在复合数据结构中使用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上使用gcc 7.1.0,clang 3.8.0和17.0.4在Linux上编译代码-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编译器似乎能够优化掉无意义的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
安全地复制,对象必须是可以轻易复制的 。 现在std::unique_ptr
不是简单的可复制的,因为它实际上失败了几个要求,例如只有简单或删除的副本和移动构造函数。 确切的机制取决于所涉及的标准库,但一般来说,质量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<true>::__uinit_copy(...)
最终调用memmove
而<false>
版本没有 -或者您可以自己跟踪代码(但您已经在基准测试中看到了结果)。
最后,您最终会得到几个循环,这些循环为非平凡对象执行所需的复制步骤,例如调用目标对象的移动构造函数,然后调用所有源对象的析构函数。 这些是单独的循环,甚至现代编译器也几乎无法推断出类似“OK,在第一个循环中我移动了所有目标对象,因此它们的ptr
成员将为null,因此第二个循环是无操作” 。 最后,为了等于原始指针的速度,不仅编译器需要在这两个循环中进行优化,它们还需要进行转换,以识别整个事物可以被memcpy
或memmove
2替换。
所以对你的问题的一个答案是,编译器只是不够聪明才能进行这种优化,但主要是因为“原始”版本有很多编译时的帮助,完全不需要这种优化。
如前所述,现有的vector
实现在两个独立的循环中实现了resize类型的操作(除了非循环工作,例如分配新存储和释放旧存储):
从概念上讲,你可以想象另一种方法:在一个循环中完成所有这一切,复制每个元素并立即销毁它们。 编译器甚至可能注意到两个循环遍历同一组值并将两个循环融合为一个。 [显然],howevever,( https://gcc.gnu.org/ml/gcc/2015-04/msg00291.html ) gcc
今天没有做任何循环融合 ,如果你相信这个测试也不做clang
或icc
。
那么我们就会尝试在源级别明确地将循环放在一起。
现在,双循环实现通过不破坏任何源对象来帮助保留操作的异常安全合同,直到我们知道副本的构造部分已经完成为止,但是当我们具有可轻松复制的内容时,它还有助于优化复制和销毁。分别是可破坏的对象。 特别是,通过基于简单特征的选择,我们可以用memmove
替换副本,并且可以完全省略破坏循环3 。
因此,当这些优化适用时,双循环方法会有所帮助,但实际上它在一般情况下会受到伤害,这些对象既不是可复制的也不是可破坏的。 这意味着您需要对对象进行两次传递,否则您将失去优化和消除对象副本之间的代码以及随后的破坏的机会。 在unique_ptr
情况下,你失去了编译器传播源unique_ptr
将具有NULL
内部ptr
成员的知识的能力,因此完全跳过if (ptr) delete ptr
check 4 。
现在有人可能会问我们是否可以将相同的type-traits编译时优化应用于unique_ptr
情况。 例如,人们可能会看到平凡的可复制要求,并且看到它们对于std::vector
的常见移动操作可能过于严格。 当然, unique_ptr
显然不是简单的可复制的,因为逐位复制会使源和目标对象都由于相同的指针(并导致双删除),但似乎它应该是按位移动的 :如果你将unique_ptr
从一个内存区域移动到另一个区域,这样你就不再将源视为一个活动对象(因此不会调用它的析构函数)它应该“正常工作”,对于典型的 unique_ptr
实现。
不幸的是,虽然你可以试着自己动手,但不存在这种“琐碎的举动”概念。 对于可以按字节复制并且不依赖于移动场景中的构造函数或析构函数行为的对象,似乎存在关于这是否是UB的公开辩论 。
你总是可以实现你自己的简单可移动的概念,这类似于(a)对象有一个简单的移动构造函数和(b)当用作移动构造函数的源参数时,对象处于它的析构函数具有的状态没有效果 。 请注意,这样的定义目前大多无用,因为“普通的移动构造函数”(基本上是元素副本而不是其他)与源对象的任何修改都不一致。 因此,例如,一个普通的移动构造函数不能将源unique_ptr
的ptr
成员设置为零。 因此,您需要跳过一些更多的箍,例如引入破坏性移动操作的概念,这会使源对象被破坏,而不是处于有效但未指定的状态。
您可以在ISO C ++ usenet讨论组的此主题上找到关于这个“平凡可移动”的更详细讨论。 特别是在链接的回复中,解决了unique_ptr
向量的确切问题:
事实证明,许多智能指针(包括unique_ptr和shared_ptr)都属于这三个类别,通过应用它们,您可以拥有智能指针的向量,即使在非优化的调试版本中,对原始指针的开销也基本为零。
另请参阅重定位器提议。
0虽然问题末尾的非矢量示例表明情况并非总是如此。 这是因为zneak在他的回答中解释了可能的混叠。 原始指针将避免许多这些别名问题,因为它们缺少unique_ptr
具有的间接性(例如,您通过值传递原始指针,而不是通过引用传递指针的结构)并且通常可以省略if (ptr) delete ptr
检查完全。
2这实际上比你想象的要难,因为例如,当源和目标重叠时, memmove
具有与对象复制循环略有不同的语义。 当然,适用于原始点的高级类型特征代码知道(通过契约)没有重叠,或者即使存在重叠, memmove
的行为也是一致的,但在稍后的任意优化传递中证明相同的事情可能是更难。
3值得注意的是,这些优化或多或少是独立的。 例如,许多物体是可以轻易破坏的,并非易于复制。
4虽然在我的测试中, gcc
和clang
都没有能够抑制检查,即使应用了__restrict__
,显然是由于强大的别名分析,或者可能是因为std::move
以某种方式剥离了“限制”限定符。
我没有准确的答案,因为背后用矢量咬你的是什么; 看起来像BeeOnRope可能已经有一个给你。
幸运的是,我可以告诉你在后面为你的微型例子咬你的是什么,包括不同的重置指针的方法:别名分析。 具体来说,编译器无法证明(或不愿推断)两个unique_ptr
引用不重叠。 它们强制自己重新加载unique_ptr
值,以防第一个写入修改第二个值。 baz
没有受到影响,因为编译器可以证明在格式良好的程序中,这两个参数都不可能与具有函数本地自动存储的tmp
。
您可以通过将__restrict__
关键字 (其中双下划线暗示不是标准C ++)添加到unique_ptr
引用参数来验证这一点。 该关键字通知编译器引用是唯一可以通过其访问该内存的引用,因此不存在其他任何内容可能与其混淆的风险。 执行此操作时,函数的所有三个版本都会编译为相同的机器代码,并且无需检查是否需要删除unique_ptr
。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.