移动分配比复制分配慢——错误、功能或未指定?

Posted

技术标签:

【中文标题】移动分配比复制分配慢——错误、功能或未指定?【英文标题】:Move-assignment slower than copy-assignment -- bug, feature, or unspecified? 【发布时间】:2014-09-26 01:28:35 【问题描述】:

我最近意识到,在 C++11(或者至少是我的实现,Visual C++)中添加移动语义已经积极(并且相当戏剧性地)破坏了我的优化之一。

考虑以下代码:

#include <vector>
int main()

    typedef std::vector<std::vector<int> > LookupTable;
    LookupTable values(100);  // make a new table
    values[0].push_back(1);   // populate some entries

    // Now clear the table but keep its buffers allocated for later use
    values = LookupTable(values.size());

    return values[0].capacity();

我遵循这种模式来执行容器回收:我会重复使用同一个容器而不是销毁和重新创建它,以避免不必要的堆释放和(立即)重新分配。

在 C++03 上,这工作得很好——这意味着这段代码曾经返回 1,因为向量是按元素复制,而它们的底层缓冲区保持原样。因此,我可以修改每个内部向量,知道它可以使用与以前相同的缓冲区。

然而,在 C++11 上,我注意到这会导致右侧 move 到左侧,这会对每个元素执行逐元素移动分配左侧的向量。这反过来又导致向量丢弃其旧缓冲区,突然将其容量减少到零。因此,由于过多的堆分配/释放,我的应用程序现在速度大大降低。

我的问题是:这种行为是错误还是故意的?它甚至是由标准指定的吗?

更新:

我刚刚意识到这种特定行为的正确性可能取决于a = A() 是否可以使指向a 元素的迭代器无效。但是,我不知道移动分配的迭代器失效规则是什么,所以如果您知道它们,可能值得在您的答案中提及这些规则。

【问题讨论】:

在复制或移动中capacity 会发生什么尚未明确。 你为什么不做for (auto&amp; v : values) v.clear(); ?无论如何,这似乎是意图。 @Mehrdad:我没有看到缓冲区是如何被重用的。在这两种情况下,values 中的元素都被完全重构了。我看到的唯一区别是默认向量容量的选择(C++11 要求为 0,而 C++03 没有要求)。我很惊讶 C++03 中的代码更快。 移动分配可以移动分配+移动构造单个元素或整个容器(取决于分配器)。因此,它可以使所有迭代器无效。不过,我在标准中找不到合适的报价。 也许我应该限定我的陈述:就操作而言,移动分配必须是 O(N),因为必须销毁 LHS 的现有元素。但尚不清楚是否保证仅在可能的情况下移动指针(即元素分配的 O(x))。 【参考方案1】:

C++11

C++03 和 C++11 在 OP 中的行为差异是由于移动赋值的实现方式。 有两个主要选项:

    摧毁 LHS 的所有元素。解除分配 LHS 的底层存储。将底层缓冲区(指针)从 RHS 移动到 LHS。

    从 RHS 的元素移动分配到 LHS 的元素。如果 RHS 有更多元素,则销毁 LHS 的任何多余元素或在 LHS 中移动构建新元素。

如果移动不是例外,我认为可以将选项 2 与副本一起使用。

选项 1 使 LHS 的所有引用/指针/迭代器无效,并保留 RHS 的所有迭代器等。它需要 O(LHS.size()) 破坏,但缓冲区移动本身是 O(1)。

选项 2 仅使 LHS 中被破坏的多余元素的迭代器失效,或者如果 LHS 发生重新分配,则所有迭代器失效。它是O(LHS.size() + RHS.size()),因为双方的所有元素都需要照顾(复制或销毁)。

据我所知,不能保证在 C++11 中会发生哪一个(请参阅下一节)。

理论上,只要您可以使用操作后存储在 LHS 中的分配器释放底层缓冲区,您就可以使用选项 1。这可以通过两种方式实现:

如果两个分配器比较相等,则一个可用于释放通过另一个分配的存储。因此,如果 LHS 和 RHS 的分配器在移动之前比较相等,则可以使用选项 1。这是一个运行时决定。

如果分配器可以从 RHS传播(移动或复制)到 LHS,则 LHS 中的这个新分配器可用于解除分配 RHS 的存储。分配器是否传播由allocator_traits&lt;your_allocator :: propagate_on_container_move_assignment 决定。这是由类型属性决定的,即编译时决定。


C++11 减去缺陷/C++1y

在LWG 2321(仍然开放)之后,我们保证:

没有移动构造函数(或移动赋值运算符时 allocator_traits&lt;allocator_type&gt; :: propagate_on_container_move_assignment :: valuetrue) 的容器(数组除外)使任何引用无效, 指向源元素的指针或迭代器 容器。 [ 注意: end() 迭代器不引用任何元素,所以 它可能会失效。 — 尾注 ]

要求那些在移动赋值时传播的分配器的移动赋值必须移动vector对象的指针,但不能移动向量的元素。 (选项 1)

LWG defect 2103 之后的默认分配器在容器的移动分配期间传播,因此 OP 中的技巧是禁止移动单个元素。


我的问题是:这种行为是错误还是故意的?它甚至是由标准指定的吗?

不,是,不(可以说)。

【讨论】:

我不确定 C++03 中的行为是否得到保证。 LWG 2321 的分辨率似乎也不会成为 C++1y 的一部分;所以它甚至可能是 C++1z。【参考方案2】:

请参阅this answer 了解vector 移动分配必须如何工作的详细说明。当您使用std::allocator 时,C++11 会将您置于案例 2 中,委员会中的许多人认为这是一个缺陷,并且已更正为 C++14 的案例 1。

案例 1 和案例 2 具有相同的运行时行为,但案例 2 对 vector::value_type 有额外的编译时要求。情况 1 和情况 2 都会导致在移动分配期间将内存所有权从 rhs 转移到 lhs,从而为您提供观察到的结果。

这不是错误。这是故意的。它由 C++11 和转发指定。是的,正如 dyp 在他的回答中指出的那样,存在一些小缺陷。但这些缺陷都不会改变您所看到的行为。

正如 cmets 中所指出的,最简单的解决方法是创建一个 as_lvalue 助手并使用它:

template <class T>
constexpr
inline
T const&
as_lvalue(T&& t)

    return t;

// ...

// Now clear the table but keep its buffers allocated for later use
values = as_lvalue(LookupTable(values.size()));

这是零成本,让您回到 C++03 的行为。但它可能无法通过代码审查。你会更清楚地遍历和clear 外部向量中的每个元素:

// Now clear the table but keep its buffers allocated for later use
for (auto& v : values)
    v.clear();

后者是我推荐的。前者(恕我直言)被混淆了。

【讨论】:

希望我能接受两个答案。感谢您的精彩回答!

以上是关于移动分配比复制分配慢——错误、功能或未指定?的主要内容,如果未能解决你的问题,请参考以下文章

打开CAD出现致命错误:安全系统(保密锁或网络许可)不起作用或未正确安装

编译错误子或未定义函数

Linux文件的复制、删除和移动命令是.?

使用 malloc 分配比现有内存更多的内存

在 NULL 值(或未定义)指针上重新分配

错误:(-2:未指定的错误)该功能未实现。使用 Windows、GTK+ 2.x 或 Cocoa 支持重建库