返回比std :: pair更低效的2元组?

Joh*_*nck 21 c++ gcc clang calling-convention stdtuple

考虑以下代码:

#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);
}
Run Code Online (Sandbox Code Playgroud)

Clang 3和4在x86-64上生成类似的代码:

f1():
 movabs rax,0x22200000111
 ret    
f2():
 movabs rax,0x11100000222 ; opposite packing order, not important
 ret    
Run Code Online (Sandbox Code Playgroud)

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

f2():
 movabs rax,0x11100000222
 mov    QWORD PTR [rdi],rax
 mov    rax,rdi
 ret    
Run Code Online (Sandbox Code Playgroud)

正如GCC 4至GCC 7一样:

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

返回std::tuple适合单个寄存器的生成代码为什么会更糟std::pair?看起来特别奇怪,因为Clang 3和4似乎是最优的,而5则不是.

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

Bee*_*ope 23

简短的回答是,因为libstc++通过使用标准库的实现gcc,并clang在Linux上实现std::tuple不平凡的举动构造(尤其是_Tuple_impl基类有一个不平凡的移动构造函数).另一方面,复制和移动构造函数std::pair都是默认的.

血腥细节

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

一个最近的ABI规范的版本,有这样一段话:

聚合(结构和数组)和联合类型的分类如下:

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

条件(2)适用于此.请注意,它仅提及复制构造函数,而不是移动构造函数 - 但很明显,由于移动构造函数的引入通常需要包含在之前包含复制构造函数的任何分类算法中,因此很可能仅仅是规范中的缺陷.特别是IA-64 cxx-abi,其gcc记录如下,确实包括移动构造函数:

如果参数类型对于调用而言是非平凡的,则调用者必须为临时值分配空间并通过引用传递该临时值.特别:

  • 呼叫者以通常的方式为临时(通常在堆栈上)分配空间.

然后是非平凡的定义:

在以下情况下,对于呼叫,类型被认为是非平凡的:

  • 它有一个非平凡的复制构造函数,移动构造函数或析构函数,或
  • 它的所有复制和移动构造函数都将被删除.

因此,从ABI角度来看,它tuple不被认为是可以轻易复制的,它会得到MEMORY处理,这意味着你的函数必须填充被调用的传入的堆栈分配对象rdi.该std::pair函数可以只传回整个结构,rax因为它适合于一个EIGHTBYTE并具有类INTEGER.

有关系吗?是的,严格地说,一个独立的功能,如你编译的功能将效率低,tuple因为这个ABI不同的是"烘焙".

但是,通常,即使没有内联,编译器也能够看到函数的主体并内联它或执行过程间分析.在这两种情况下,ABI不再重要,两种方法可能同样有效,至少对于一个不错的优化器.例如,让我们打电话给你f1()f2()功能,并做结果一些数学:

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);
}
Run Code Online (Sandbox Code Playgroud)

原则上,该add_tuple方法从缺点开始,因为它必须调用f2()效率较低的并且还必须在堆栈上创建临时元组对象,以便它可以将其f2作为隐藏参数传递.好吧,无论如何,两个函数都经过全面优化,只需直接返回正确的值:

add_pair():
  mov eax, 819
  ret
add_tuple():
  mov eax, 819
  ret
Run Code Online (Sandbox Code Playgroud)

总的来说,你可以说这个ABI问题的影响tuple相对较小:它为必须符合ABI的函数增加了一个小的固定开销,但这对于非常小的函数只能在相对意义上真正重要 - 但是这样函数可能会在可以内联的地方声明(或者如果没有,则会在表格中保留性能).

libcs​​tc ++ vs libc +++

如上所述,这是ABI问题,而不是优化问题本身.无论铛和gcc已经优化的库代码以尽最大可能ABI的约束下-如果他们产生类似的代码f1()std::tuple情况下,他们将打破ABI兼容的来电.

如果你切换到使用libc++而不是Linux默认值,你可以清楚地看到这一点libstdc++- 这个实现没有明确的移动构造函数(正如Marc Glisse在评论中提到的,他们坚持使用这个实现以实现向后兼容).现在clang(并且可能是gcc虽然我没有尝试过),但在两种情况下都会生成相同的最佳代码:

f1():                                 # @f1()
        movabs  rax, 2345052143889
        ret
f2():                                 # @f2()
        movabs  rax, 2345052143889
        ret
Run Code Online (Sandbox Code Playgroud)

早期版本的Clang

为什么版本的clang编译方式不同?这只是铿锵声中的一个错误或规范中的一个错误,具体取决于你如何看待它.在需要传递临时指针的隐藏指针的情况下,规范没有明确包含移动构造.不符合IA-64 C++ ABI.例如编译clang用来做它的方式不兼容gcc或更新版本clang.规范最终更新,版本5.0中的clang 行为发生了变化.

更新: Marc Glisse 在评论中提到,最初对非平凡移动构造函数和C++ ABI的交互存在混淆,并且clang在某些时候改变了它们的行为,这可能解释了转换:

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

  • 为什么`std :: pair <int,int>`可以复制,而`std :: tuple <int,int>`不是?我希望他们都是.无论哪种方式,这不仅仅需要一个简单的移动构造函数和析构函数,而不是一个简单的复制构造函数? (2认同)
  • @DanielH它不是(在libstdc ++中),因为原来的实现不是,然后没有打破ABI让它无法改变:-( (2认同)
  • @DanielH-我更新了我的答案:这是因为`std :: tuple`具有非平凡的move构造函数,而`std :: pair`没有。是的,这违反了[tuple]应该具有“ default”移动构造函数的标准,该标准指定了“ tuple”应具有“ default”移动构造函数。 (2认同)
  • @JohnZwinck - 正确,该实现不符合该领域的标准,如果这就是您所说的有缺陷的话。显式移动构造函数是否有助于或损害整体性能,我不确定。请注意,它本身与“优化”无关,这完全与 ABI 有关。`clang` 和 `gcc` 都已经在 ABI 的约束下最大限度地优化了库代码。如果他们为标准元组生成像 `f1()` 这样的代码,它会在运行时_fail_,因为调用者不会得到预期的结果。 (2认同)
  • 涉及移动构造函数的一些参数传递案例的ABI规范尚不清楚,当它们被澄清时,clang改为遵循ABI.这可能是其中一种情况. (2认同)