为什么使用三元运算符返回字符串与返回等效的if / else块会产生截然不同的代码?


71

我在使用Compiler Explorer时,偶然发现使用三元运算符时会发生以下有趣的行为:

std::string get_string(bool b)
{
    return b ? "Hello" : "Stack-overflow";
}

编译器为此生成的代码(带有-O3的Clang干线)是这样的:

get_string[abi:cxx11](bool):                 # @get_string[abi:cxx11](bool)
        push    r15
        push    r14
        push    rbx
        mov     rbx, rdi
        mov     ecx, offset .L.str
        mov     eax, offset .L.str.1
        test    esi, esi
        cmovne  rax, rcx
        add     rdi, 16 #< Why is the compiler storing the length of the string
        mov     qword ptr [rbx], rdi
        xor     sil, 1
        movzx   ecx, sil
        lea     r15, [rcx + 8*rcx]
        lea     r14, [rcx + 8*rcx]
        add     r14, 5 #< I also think this is the length of "Hello" (but not sure)
        mov     rsi, rax
        mov     rdx, r14
        call    memcpy #< Why is there a call to memcpy
        mov     qword ptr [rbx + 8], r14
        mov     byte ptr [rbx + r15 + 21], 0
        mov     rax, rbx
        pop     rbx
        pop     r14
        pop     r15
        ret
.L.str:
        .asciz  "Hello"

.L.str.1:
        .asciz  "Stack-Overflow"

但是,编译器为以下代码段生成的代码要小得多,并且无需调用memcpy,也不必关心同时知道两个字符串的长度。跳转到2个不同的标签

std::string better_string(bool b)
{
    if (b)
    {
        return "Hello";
    }
    else
    {
        return "Stack-Overflow";
    }
}

上面的代码片段(带-O3的Clang干线)的编译器生成的代码是这样的:

better_string[abi:cxx11](bool):              # @better_string[abi:cxx11](bool)
        mov     rax, rdi
        lea     rcx, [rdi + 16]
        mov     qword ptr [rdi], rcx
        test    sil, sil
        je      .LBB0_2
        mov     dword ptr [rcx], 1819043144
        mov     word ptr [rcx + 4], 111
        mov     ecx, 5
        mov     qword ptr [rax + 8], rcx
        ret
.LBB0_2:
        movabs  rdx, 8606216600190023247
        mov     qword ptr [rcx + 6], rdx
        movabs  rdx, 8525082558887720019
        mov     qword ptr [rcx], rdx
        mov     byte ptr [rax + 30], 0
        mov     ecx, 14
        mov     qword ptr [rax + 8], rcx
        ret

同样的结果是当我将三元运算符用于:

std::string get_string(bool b)
{
    return b ? std::string("Hello") : std::string("Stack-Overflow");
}

我想知道为什么第一个示例中的三元运算符会生成该编译器代码。我相信罪魁祸首位于监狱内const char[]

PS:strlen在第一个示例中,GCC会调用,但Clang不会。

链接到Compiler Explorer示例:https : //godbolt.org/z/Exqs6G

感谢您的时间!

对不起代码墙


18
三元的结果类型是const char*字符串分别为const char[N]s时,大概编译器可以对后者进行更多优化
kmdreko

2
@kmdreko:编译器仍然知道它const char*指向两个可能的已知常数字符串文字之一。这就是clang能够避免strlen在无分支版本中使用的原因。(GCC错过了该优化)。甚至clang的无分支版本也没有得到很好的优化。可能会有更好的效果,例如2x cmov在常量之间进行选择,也许还cmov可以选择一个偏移量来存储。(因此,这两个版本都可以执行2个部分重叠的8字节存储,写入8或14字节的数据,包括尾随零。)这比调用memcpy更好。
彼得·科德斯

1
还是因为它无论如何都从内存中加载常量,所以请使用SSE2movdqa加载并将布尔值转换为向量掩码以在它们之间进行选择。(此优化依赖于编译器,尽管C ++源可能会保留一些未写的尾随字节,但始终将16个字节存储到retval对象中是安全的。出于线程安全的考虑,写写入对于编译器来说通常是大忌,)
Peter Cordes

Answers:


61

这里最重要的区别是第一个版本是无分支的

16不是这里的任何字符串的长度(较长的字符串,使用NUL,只有15个字节长);它是返回对象的偏移量(其地址在RDI中传递以支持RVO),用于指示正​​在使用小字符串优化(请注意缺少分配)。长度为5或5 + 1 + 8,存储在R14中,并存储在中std::string,并传递给memcpy(以及CMOVNE选择的指针)以加载实际的字符串字节。

另一个版本有一个明显的分支(尽管结构的一部分std::string已悬挂在其上面),实际上确实有5和14,但是由于字符串字节已作为立即数的值(表示为整数)而被混淆各种大小。

至于为什么这三个等效函数会生成所生成代码的两个不同版本,我只能提供的优化器是迭代算法和启发式算法。他们无法可靠地找到与其起点无关的“最佳”组件。


4
值得注意的是,在这种情况下,应该注意的是,内存写入在优化方面要困难得多-即使内存memcpy是内部固有的,优化器仍需要推理迟早发生写入的潜在副作用。在第一个代码段中,对三元表达式进行求值,然后进行写操作;在第二个代码片段中,写过程作为对三元表达式求值的一部分进行。
Matthieu M.

2
我同意,不应该这样,但是正如您提到的那样,由于优化程序是迭代式的和启发式的,所以……这样做并不奇怪:)
Matthieu M.

2
无分支是这里的红鲱鱼。Juergen的答案是正确的。区别在于执行选择的类型(std::stringvs. char*),以及是否需要使用选择结果调用构造函数。
cmaster-恢复莫妮卡

4
@ cmaster-reinstatemonica:无分支只是在一种情况下对生成的程序集的描述(这有助于理解其他差异)。在所有情况下,构造函数都是完全内联的(“在编译时求值”);返回语句操作数的类型绝不会对生成的代码产生约束(因为任何字符串文字的地址都不会转义)。
戴维斯·鲱鱼

2
如果编译器能够执行恒定值传播分析直至结束,则在所有三种情况下都应生成完全相同的输出。但事实并非如此。因此,显然,它并没有完成分析。很明显,它被以下事实打乱了:在第一种情况下,它必须使用两个可能的参数之一来构造单个对象,而在其他情况下,它需要选择两个不同的对象来构造。第一个和最后一个代码示例的行为之间的比较具有指导意义。
cmaster-恢复莫妮卡

13

第一个版本返回一个字符串对象,该对象用一个不恒定的表达式初始化,产生一个字符串文字,因此构造函数的运行与其他任何可变字符串对象一样,因此由memcpy进行初始化。

其他变体返回一个用字符串文字初始化的字符串对象或另一个用另一个字符串文字初始化的字符串对象,这两种字符串对象都可以优化为由不需要memcpy的常量表达式构造的字符串对象。

因此,真正的答案是:第一个版本在初始化对象之前先对char []表达式执行?:运算符,而其他版本则在已初始化的字符串对象上进行操作。

版本之一是否为无分支无关紧要。


4
没有memcpy被真正需要的网点ASM要么; 与cmov直接操作数或SSE2比较上使用更多指令相比,这是一个错过的优化。您的回答确实说明了为什么源代码将编译器引向了前进的方向。编译器远非完美。
彼得·科德斯

3
请注意,在OP的Godbolt链接中,所有未注释的三个版本,godbolt.org/z/597Kzd,都会return b ? std::string("Hello") : std::string("Stack-Overflow");编译为带有GCC和clang的分支(与if版本相同),尽管有可能不断传播以生成conststring对象。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.