【问题标题】:Is returning a 2-tuple less efficient than std::pair?返回 2 元组的效率是否低于 std::pair?
【发布时间】:2018-04-04 17:31:03
【问题描述】:

考虑这段代码:

#include <utility>
#include <tuple>

std::pair<int, int> f1()
{
    return std::make_pair(0x111, 0x222);
}

std::tuple<int, int> f2()
{
    return std::make_tuple(0x111, 0x222);
}

Clang 3 和 4 在 x86-64 上为两者生成相似的代码:

f1():
 movabs rax,0x22200000111
 ret    
f2():
 movabs rax,0x11100000222 ; opposite packing order, not important
 ret    

但 Clang 5 为 f2() 生成不同的代码:

f2():
 movabs rax,0x11100000222
 mov    QWORD PTR [rdi],rax
 mov    rax,rdi
 ret    

GCC 4 到 GCC 7 也是如此:

f2():
 movabs rdx,0x11100000222
 mov    rax,rdi
 mov    QWORD PTR [rdi],rdx ; GCC 4-6 use 2 DWORD stores
 ret

为什么在返回适合单个寄存器的std::tuple 时生成的代码比std::pair 更差?这似乎特别奇怪,因为 Clang 3 和 4 似乎是最佳的,但 5 不是。

在这里试试:https://godbolt.org/g/T2Yqrj

【问题讨论】:

  • @JohnZwinck - 您是否有任何指向 Godbolt 的链接或任何其他证据表明 gcc 实际上为从 4 到(但不包括)7 的任何版本生成了您为 f2() 显示的代码?您的链接有 clang 并且我尝试的所有 gcc 版本都显示了不同的代码,其中有两个 DWORD 存储到内存中。
  • @BeeOnRope:如您所说,GCC 4 到 6 使用两个 DWORD 存储。如我的问题所示,GCC 7 使用单个 QWORD 存储。这两种情况的区别对我来说并不重要,所以我没有说出来。
  • 嗯,重要的是,在两个商店中的性能几乎是一个商店的两倍,因此由于重点是优化或缺乏优化,因此最好指出大多数版本的 gcc边缘之外的情况更糟(尽管与底层 ABI 问题不同,这个问题可以通过更多优化来解决)。

标签: c++ gcc clang calling-convention stdtuple


【解决方案1】:

简短的回答是因为gccclang 在Linux 上使用的libstc++ 标准库实现使用非平凡 移动构造函数实现std::tuple(特别是@ 987654336@ 基类有一个重要的移动构造函数)。另一方面,std::pair 的复制和移动构造函数都是默认的。

这反过来又会导致从函数返回这些对象以及按值传递它们的调用约定中与 C++-ABI 相关的差异。

血腥细节

您在遵循 SysV x86-64 ABI 的 Linux 上运行了测试。此 ABI 具有将类或结构传递或返回给函数的特定规则,您可以阅读有关 here 的更多信息。我们感兴趣的具体案例是这些结构中的两个int 字段是否将获得INTEGER 类或MEMORY 类。

ABI 规范的recent 版本有这样的说法:

聚合(结构体和数组)和联合的分类 类型的工作方式如下:

  1. 如果对象的大小大于 8 个八字节,或者它包含未对齐的字段,则它的类 MEMORY 12。
  2. 如果 C++ 对象具有非平凡的复制构造函数或非平凡的析构函数 13 ,则它通过不可见的引用( 参数列表中的对象被具有类的指针替换 整数)14 .
  3. 如果聚合的大小超过单个八字节,则将每个单独分类。每个八字节被初始化为类 NO_CLASS。
  4. 对象的每个字段都被递归分类,以便始终考虑两个字段。结果类是根据 到八字节中字段的类

这里适用的是条件(2)。请注意,它只提到了复制构造函数,而不是 move 构造函数 - 但很明显,这可能只是规范中的一个缺陷,因为引入了通常需要包含在任何分类中的移动构造函数之前包含复制构造函数的算法。特别是 IA-64 cxx-abi,gcc 被记录为遵循does include move constructors

如果参数类型对于调用而言是非平凡的,则 调用者必须为临时分配空间并通过该临时传递 参考。具体来说:

  • 调用者以通常的方式为临时分配空间,通常在堆栈上。

然后是不平凡的definition

如果满足以下条件,则类型被认为是非平凡的:

  • 它有一个重要的复制构造函数、移动构造函数或析构函数,或者
  • 它的所有复制和移动构造函数都被删除。

因此,因为从 ABI 的角度来看,tuple 不被认为是普通可复制,所以它得到了MEMORY 处理,这意味着您的函数必须填充被调用者传入的堆栈分配对象在rdistd::pair 函数可以将rax 中的整个结构传回,因为它适合一个EIGHTBYTE 并具有INTEGER 类。

这有关系吗?是的,严格来说,像您编译的那样的独立函数对于tuple 的效率会降低,因为这个 ABI 不同是“内置”的。

但是,即使没有内联,编译器通常也能看到函数体并内联它或执行过程间分析。在这两种情况下,ABI 都不再重要,而且很可能这两种方法都同样有效,至少在使用合适的优化器时是这样。例如let's call your f1() and f2() functions and do some math on the result:

int add_pair() {
  auto p = f1();
  return p.first + p.second;
}

int add_tuple() {
  auto t = f2();
  return std::get<0>(t) + std::get<1>(t);
}

原则上add_tuple方法从一个缺点开始,因为它必须调用f2(),效率较低,而且它还必须在堆栈上创建一个临时元组对象,以便它可以将它传递给f2隐藏参数。好吧,不管怎样,这两个函数都经过全面优化,可以直接返回正确的值:

add_pair():
  mov eax, 819
  ret
add_tuple():
  mov eax, 819
  ret

因此,总的来说,您可以说 tuple 的这个 ABI 问题的影响相对较小:它为必须符合 ABI 的函数增加了一小部分固定开销,但这仅在相对意义上真正重要非常小的函数 - 但这些函数很可能在可以内联的地方声明(或者如果没有,你将把性能留在桌面上)。

libcs​​tc++ 与 libc++++

如上所述,这本身是一个 ABI 问题,而不是优化问题。 clang 和 gcc 都已经在 ABI 的约束下尽可能地优化库代码 - 如果他们为 std::tuple 情况生成像 f1() 这样的代码,他们将破坏符合 ABI 的调用者。

如果您切换到使用 libc++ 而不是 Linux 默认的 libstdc++,您可以清楚地看到这一点 - 此实现没有显式移动构造函数(正如 Marc Glisse 在 cmets 中提到的那样,他们坚持使用这个实现向后兼容)。现在clang(可能是gcc,虽然我没有尝试过),在这两种情况下都会生成same optimal code

f1():                                 # @f1()
        movabs  rax, 2345052143889
        ret
f2():                                 # @f2()
        movabs  rax, 2345052143889
        ret

Clang 的早期版本

为什么clang 的版本编译不同?这只是a bug in clang 或规范中的错误,具体取决于您如何看待它。在需要传递指向临时对象的隐藏指针的情况下,规范没有明确包括移动构造。不符合 IA-64 C++ ABI。例如,按照 clang 的方式编译它与 gcc 或更新版本的 clang 不兼容。规范是eventually updated 和clang behavior changed in version 5.0

更新: Marc Glisse mentions 在 cmets 中最初对非平凡移动构造函数和 C++ ABI 的交互感到困惑,clang 在某些时候改变了它们的行为,这可能解释了开关:

一些涉及移动的参数传递案例的 ABI 规范 构造函数不清楚,当他们澄清时,clang改变了 遵循 ABI。这可能是其中一种情况。

【讨论】:

  • 为什么std::pair&lt;int,int&gt; 可以简单地复制而std::tuple&lt;int,int&gt; 不是?我希望他们俩都是。无论哪种方式,这不只需要一个简单的移动构造函数和析构函数,而不是一个简单的复制构造函数吗?
  • @DanielH 它不是(在 libstdc++ 中),因为原始实现不是,然后不破坏 ABI 就不可能改变:-(
  • @DanielH - 我更新了我的答案:这是因为std::tuple 有一个重要的移动构造函数,但std::pair 没有。是的,这违反了specifies tuple 应该有一个 default 移动构造函数的标准。
  • @JohnZwinck - 正确,实施不符合该领域的标准,如果这就是您所说的有缺陷的话。显式移动构造函数是否有助于或损害整体性能,我不确定。请注意,它与“优化”本身无关,这完全与 ABI 有关。 clanggcc 都已经在 ABI 的约束下最大限度地优化库代码。如果他们为标准元组生成像f1() 这样的代码,它会在运行时失败,因为调用者不会得到预期的结果。
  • 一些涉及移动构造函数的参数传递案例的 ABI 规范不清楚,当它们被澄清时,clang 更改为遵循 ABI。这可能是其中一种情况。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-08-27
  • 2020-04-22
  • 2013-09-04
  • 1970-01-01
  • 2011-04-06
  • 1970-01-01
相关资源
最近更新 更多