为什么构造 std::optional<int> 比 std::pair<int, bool> 更昂贵?

73

考虑一下以下两种方法,它们都可以表示一个“可选的 int”:

using std_optional_int = std::optional<int>;
using my_optional_int = std::pair<int, bool>;

考虑这两个函数...

auto get_std_optional_int() -> std_optional_int 
{
    return {42};
}

auto get_my_optional() -> my_optional_int 
{
    return {42, true};
}

...g++ trunkclang++ trunk(使用-std=c++17 -Ofast -fno-exceptions -fno-rtti都会生成以下汇编代码:

get_std_optional_int():
        mov     rax, rdi
        mov     DWORD PTR [rdi], 42
        mov     BYTE PTR [rdi+4], 1
        ret

get_my_optional():
        movabs  rax, 4294967338 // == 0x 0000 0001 0000 002a
        ret

在godbolt.org上的实时示例


为什么get_std_optional_int()需要三个mov指令,而get_my_optional()只需要一个movabs这是一个QoI问题,还是std::optional的规范中有阻止此优化的内容?

此外,请注意函数的用户可能会被完全优化掉:

volatile int a = 0;
volatile int b = 0;

int main()
{
    a = get_std_optional_int().value();
    b = get_my_optional().first;
}

...得到的结果是:

main:
        mov     DWORD PTR a[rip], 42
        xor     eax, eax
        mov     DWORD PTR b[rip], 42
        ret

7
“optional”通过一个隐藏指针返回,这意味着类型定义中包含某些内容,禁止通过寄存器返回它。 - Jester
3
boost::optional 存在相同的问题,在任何版本的 GCC 上都可以复现,不需要使用花哨的 C++17 来演示:https://godbolt.org/g/MV14mr - John Zwinck
3
聚合类型和非聚合类型、SYS V x64 ABI 以及 4294967338 是 0x10000002a 这个事实应该能够说明问题。 - Margaret Bloom
1
为什么非聚合类型必须在堆栈上返回? - Passer By
3
folly::Optional没有必要的魔法来使其特殊成员函数有条件地平凡化。它还通过在内联函数中使用内部链接的 "None" 违反了ODR,并且每个单独的 constexpr 或者 FOLLY_CPP14_CONSTEXPR 函数都是不合规范的 NDR: 你不能用 aligned_storage 实现 optionalconstexpr API。虽然可以使用 co_await,但最好从range-v3中窃取optional实现,然后添加其余的API。 - Casey
显示剩余6条评论
4个回答

46

据称libstdc++没有实现P0602 "variant and optional should propagate copy/move triviality"。您可以通过以下方式进行验证:

libstdc++ 显然没有实现 P0602 "variant and optional should propagate copy/move triviality"。您可以使用以下代码进行验证:

static_assert(std::is_trivially_copyable_v<std::optional<int>>);
此测试未通过libstdc++,但通过libc++和MSVC标准库(它真的需要一个适当的名称,以便我们不必称之为“C ++标准库的MSVC实现”或者“MSVC STL”)。

当然,由于MS ABI,MSVC仍然无法在寄存器中传递optional<int>

编辑:此问题已在GCC 8发布系列中得到修复。


6
只有当T拥有析构函数时,它才需要一个析构函数。 - ratchet freak
1
那篇论文被采纳了吗?在最新的草案中没有反映出来。 - Barry
1
根据Barry Per的说法,它目前正在Library Evolution中。 - NathanOliver
1
正确,P0602尚未被采纳。 作者曾试图与主要实现者沟通,以便在人们发货和锁定ABI之前“修复”optionalvector。 我相信libstdc ++ / libc ++ / MSFT variant都符合,libc ++ / MSFT optional也符合,但显然libstdc ++ optional维护者没有收到备忘录。 - Casey
P0602R4已于20181113在圣地亚哥的C++20工作草案中被纳入。 - Casey
显示剩余2条评论

19
为什么get_std_optional_int()需要三个mov指令,而get_my_optional()只需要一个movabs指令呢?直接原因是optional通过隐藏的指针返回,而pair则在寄存器中返回。不过为什么会这样呢?SysV ABI规范,第3.2.3节“参数传递”指出:

如果C++对象具有非平凡的复制构造函数或非平凡的析构函数,则将其通过隐式引用传递。

解决optional的C++混乱并不容易,但是我检查的实现中,optional_base类至少包含一个非平凡的复制构造函数


3
不一定非要这样,但是可以这样,对于编译器来说这点很重要。因此你实际上需要查看实现细节。 - Jester

15

x86-64 没有 mov mem, imm64,因此您无法合并存储。 - Jester
@Jester 可以将 rax 加载为合并值,并将其存储到 [rdi] 中。 - Maxim Egorushkin
当然可以,但我认为这并没有任何固有的速度优势,而且这是更短的机器代码。它当然不会减少指令数量。 - Jester
它不一定是rax,也不一定是“另一个”。你可以这样做:movabs rax,4294967338; mov [rdi],rax; mov rax,rdi; ret - Jester
1
@Maxim:gcc7 实现了存储合并(gcc3 或 gcc4 破坏了它,所以多年来 gcc 在相邻的窄赋值方面表现不佳),但在具有填充的对象中仍存在未优化的情况。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82142。(在这种情况下,clang 似乎也存在同样的未优化问题。)如果您编写一个通过 pair<int,int> 指针进行存储的函数,则 gcc 和 clang 将合并存储:https://godbolt.org/g/44zodQ。 - Peter Cordes
显示剩余5条评论

3
即使 std::is_trivially_copyable_v<std::optional<int>> 为 false,这种优化在技术上是被允许的。然而,编译器可能需要过高的“聪明程度”才能找到它。此外,对于将 std::optional 用作函数返回类型的特定情况,优化可能需要在链接时间而不是编译时间完成。
执行此优化不会对任何(定义良好的)程序的可观察行为产生影响*,因此根据 as-if rule 的规则隐式允许进行。然而,由于其他答案中所解释的原因,编译器尚未明确知道这一事实,并且需要从头开始推断。行为静态分析 本质上是困难的,因此编译器可能无法证明此优化在所有情况下都是安全的。
假设编译器能找到这个优化,那么它将需要更改该函数的调用约定(即更改函数如何返回给定值),通常需要在链接时完成,因为调用约定会影响所有调用站点。或者,编译器可以完全内联该函数,这可能无法在编译时完成。对于一个平凡可复制对象来说,这些步骤是不必要的,因此从这个意义上讲,标准抑制并使优化变得更加复杂。
std::is_trivially_copyable_v> 应该为 true。如果为 true,则编译器更容易发现并执行此优化。因此,回答你的问题:
“这是一个质量问题,还是 std::optional 的规范中有什么阻止了这种优化?”
两者都是。规范使优化变得更加困难,而实现在这些限制下没有足够的“智能”来找到它。

* 假设你没有做一些非常奇怪的事情,比如 #define int something_else


1
你将调用约定作为“实现”的一部分进行了包含。从技术上讲,这是正确的,但除非启用整个程序/链接时优化,否则在给定平台上的编译器甚至不会尝试更改它,除非完全内联或制作函数的常量传播克隆版本。 - Peter Cordes
我猜gcc也可以发出像.gcc_reg_return_get_std_optional_int.clone123这样的东西,以及一个正常的ABI兼容定义。来自其他翻译单元的调用者不能假设这个存在,因此他们必须调用常规版本(除非您使用LTO,在这种情况下,它将仅内联,因为它很小)。但是,如果函数实际上很大,那么克隆一个使用备用调用约定版本的函数肯定是有用的。可能最有用的是将部分返回到2个单独的寄存器中,而不是打包到RAX中。 - Peter Cordes
@PeterCordes:你确定吗?在这里,函数的实现似乎完全无关紧要,只需要std::optional的实现,而且由于它是一个模板,其实现始终可用。 - Matthieu M.
@PeterCordes:我添加了一个关于链接时间的注释,但是说实话,我认为这只是一个非常小的细节。链接时间优化已经普遍可用了相当长的一段时间,我认为大多数开发人员没有关闭它的理由。 - Kevin
原来clang对于int类型的返回值即使没有进行内联也有常量传播。但是对于像optional<int>这样的聚合类型则不行。已经提交了https://bugs.llvm.org/show_bug.cgi?id=34839和https://bugs.llvm.org/show_bug.cgi?id=34840。 - Peter Cordes
显示剩余6条评论

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