为什么在C++中,std::tuple会破坏小尺寸结构体的调用约定优化?

31
C++有一个小型结构体调用约定优化,编译器以与传递原始类型(例如,通过寄存器)相同的效率传递函数参数中的小型结构体。例如:
class MyInt { int n; public: MyInt(int x) : n(x){} };
void foo(int);
void foo(MyInt);
void bar1() { foo(1); }
void bar2() { foo(MyInt(1)); }

bar1()bar2()生成的汇编代码几乎完全相同,只是分别调用了foo(int)foo(MyInt)。具体来说,在x86_64上,它看起来像这样:

        mov     edi, 1
        jmp     foo(MyInt) ;tail-call optimization jmp instead of call ret

但是如果我们测试std::tuple,结果会不同:
void foo(std::tuple<int>);
void bar3() { foo(std::tuple<int>(1)); }

struct MyIntTuple : std::tuple<int> { using std::tuple<int>::tuple; };
void foo(MyIntTuple);
void bar4() { foo(MyIntTuple(1)); }

生成的汇编代码看起来完全不同,小型结构体(std::tuple<int>)通过指针传递:
        sub     rsp, 24
        lea     rdi, [rsp+12]
        mov     DWORD PTR [rsp+12], 1
        call    foo(std::tuple<int>)
        add     rsp, 24
        ret

我深入挖掘了一些,试图让我的整数变得更加"脏"一些(这应该接近于一个不完整的天真元组实现):
class Empty {};
class MyDirtyInt : protected Empty, MyInt {public: using MyInt::MyInt; };
void foo(MyDirtyInt);
void bar5() { foo(MyDirtyInt(1)); }

但是调用约定优化被应用:
        mov     edi, 1
        jmp     foo(MyDirtyInt)

我尝试过GCC/Clang/MSVC,它们都显示了相同的行为。(这里是Godbolt链接)所以我猜这一定是C++标准中的某个问题?(虽然我相信C++标准没有规定任何ABI约束,对吗?)
我知道编译器应该能够优化掉这些,只要foo(std::tuple<int>)的定义是可见的,并且没有标记为noinline。但是我想知道标准或实现的哪个部分导致了这种优化的失效。 顺便说一下,如果你对我使用std::tuple的目的感兴趣,我想创建一个包装类(即强类型定义),并且不想自己声明比较运算符(在C++20之前的operator<==>),也不想麻烦使用Boost,所以我觉得std::tuple是一个很好的基类,因为里面包含了所有需要的东西。
OP的编辑: Daniel Langr在下面的答案中指出了根本原因。请还要查看该答案下的评论。 而且,自gcc 12.1.0发布以来,已经有一年后提交的修复方案并合并到gcc中,这已经过去了将近2年的时间。

通常情况下,导致这种行为的是一个非平凡析构函数,但是 std::tuple<int> 的析构函数应该是平凡的。 - Konrad Rudolph
1
@KonradRudolph 无论如何,将用户定义的析构函数添加到 MyInt 中具有相同的效果:https://godbolt.org/z/s4zzcx。 - Daniel Langr
1
@YehezkelB。我不确定这是否是同一个情况。使用libc++,您可以通过寄存器传递参数。对我来说更像是实现质量问题。 - Daniel Langr
1
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=71301 - Marc Glisse
显示剩余5条评论
2个回答

13
这似乎是ABI的问题。例如,Itanium C++ ABI reads

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

此外,:

如果一个类型具有非平凡的复制构造函数、移动构造函数或析构函数,或其所有复制和移动构造函数都已删除,则认为该类型对于调用而言是非平凡的。

相同的要求在AMD64 ABI Draft 1.0中。

例如,在libstdc ++中, std :: tuple 具有非平凡的移动构造函数:https://godbolt.org/z/4j8vds。标准规定复制和移动构造函数都为默认值,在这里得到满足。但是,同时,tuple继承自 _Tuple_impl ,并且 _Tuple_impl 具有用户定义的移动构造函数。因此, tuple 本身的移动构造函数不能是平凡的。

相反,在libc ++中, std :: tuple <int> 的复制和移动构造函数都是平凡的。因此,在那里将参数传递到寄存器中:https://godbolt.org/z/WcTjM9

关于Microsoft STLstd::tuple<int>既不是可复制的也不是可移动的。它甚至似乎违反了C++标准规则。 std::tuple被递归定义,在递归结束时,std::tuple<>特化定义了非默认复制构造函数。有关此问题的注释如下:// TRANSITION, ABI: should be defaulted。由于tuple<>没有移动构造函数,因此tuple<class...>的复制和移动构造函数都是非平凡的。

1
那么 libstdc++ 在这里不符合标准吗?[惊讶] - underscore_d
1
@DanielLangr 哦,你是说 std::tuple 类中的那些构造函数确实是默认的,但不是在它的基类中。我的错误。 - Maxim Egorushkin
1
@MaximEgorushkin,我重新表述了答案中的解释,希望在这种情况下更清晰 :)。 - Daniel Langr
3
似乎已经有相关的补丁被提出,但由于破坏了关于调用规约的向后兼容性而未被接受。 - Daniel Langr
4
ABI稳定性原教旨主义者再次破坏了C++。 :-( - Konrad Rudolph
显示剩余5条评论

4
根据 @StoryTeller 的建议,这可能与在 std::tuple 内部定义的用户自定义移动构造函数有关,从而导致了这种行为。
例如:请参见:https://godbolt.org/z/3M9KWo 定义用户自定义移动构造函数会导致未优化的汇编代码。
bar_my_tuple():
        sub     rsp, 24
        lea     rdi, [rsp+12]
        mov     DWORD PTR [rsp+12], 1
        call    foo(MyTuple<int>)
        add     rsp, 24
        ret

例如,在libcxx中,对于tuple_leaftuple,复制和移动构造函数都被声明为默认值,对于std::tuple<int>,您可以获得小型结构体调用约定优化,但对于持有非平凡可移动成员的std::tuple<std::string>自身就变得非平凡地可移动了,并且不能进行优化。请参见这里这里的代码以及在这里这里的示例。

1
你需要告诉编译器使用libc++:https://godbolt.org/z/WcTjM9。默认情况下,Compiler Explorer上的clang使用libstdc++。 - Daniel Langr
@DanielLangr 嘿,太棒了!实际上,对于libc++,std::tuple<int>是可移动构造的,因此它具有小型结构体调用约定优化! - Amir Kirsh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接