我正在读Agner Fog的"Optimizing software in C++"(针对英特尔、AMD和VIA的x86处理器),它在第34页上写道

布尔变量存储为8位整数,值0表示FALSE,值1表示TRUE. 布尔变量在某种意义上是超定的,即所有具有布尔值的运算符 变量作为输入判断输入是否具有除0或1以外的任何其他值,但运算符 将布尔值作为输出只能生成0或1以外的值.这使得操作 以布尔变量作为输入,效率比必要的要低.

这在今天仍然是正确的吗?在哪些编译器上呢?你能举个例子吗?作者说,

如果它可以提高布尔运算的效率,则可以使布尔运算变得更有效率 可以肯定地知道,操作数除了0和1之外没有其他值. 编译器为什么不做这样的假设是因为变量可能有其他 值(如果它们未初始化或来自未知来源).

这是否意味着,如果我以函数指针bool(*)()为例调用它,那么对它的操作会产生低效的代码?或者,当我通过解引用指针或读取引用来访问布尔值,然后对其进行操作时,会是这种情况吗?

推荐答案

TL:DR: current compilers still have bool missed-optimizations when doing stuff like
(a&&b) ? x : y. But the reason why is not that they don't assume 0/1, they just suck at this.

bool的很多用法都是针对局部变量或内联函数的,所以布尔化到0/1可以在原始条件下进行优化并进行分支(或cmov或其他任何操作).当bool个输入/输出确实必须传递/返回不是内联的或真正存储在内存中的东西时,只需担心优化bool个输入/输出.

Possible optimization guideline:将来自外部源(函数参数/内存)的bool与位运算符(如a&b)组合在一起.MSVC和ICC在这方面做得更好.如果对当地的bool人来说情况更糟的话,我就不知道了.请注意,对于bool,a&b只相当于a&&b,而不是整数类型.2 && 1是真的,但是2 & 1是0,这是假的.按位OR没有这个问题.

IDK此准则是否会对通过函数内(或内联的内容)比较设置的本地变量造成伤害.例如,它可能会导致编译器实际生成整数布尔值,而不是在可能的情况下直接使用比较结果.还要注意的是,这似乎对目前的GCC和叮当没有帮助.


是的,x86存储bool中的C++实现在一个字节中始终是0或1(至少在函数调用边界上,编译器必须尊重要求这一点的ABI/调用约定).

编译器有时确实会利用这一点,例如,对于boolGT;int转换,即使是GCC 4.4也只是简单地将零扩展到32位(movzx eax, dil).Cang和MSVC也是这样做的.C和C++规则要求此转换生成0或1,因此只有在假定bool函数参数或全局变量的值为0或1是always安全的情况下,此行为才是安全的.

即使是老的编译器通常也会在bool-gt;int中利用它,但在其他情况下不会.因此,阿格纳说的原因是错误的:

编译器之所以不做这样的假设,是因为如果变量未初始化或来自未知来源,则它们可能具有其他值.


MSVC CL19确实编写了假定bool个函数参数为0或1的代码,因此Windows x86-64ABI必须保证这一点.

x86-64 System V ABI中(Windows除外),0.98版的ChangeLog写着"指定_Bool(也就是bool)在调用方被布尔化".我想,甚至在这一变化之前,编译器就已经假设了,但这只记录了编译器已经依赖的东西.x86-64 SysV ABI中的当前语言为:

3.1.2 Data Representation

布尔值存储在内存对象中时,存储为单字节对象,其值始终为0(false)或1(true).当存储在整数寄存器中时(作为参数传递除外),寄存器的所有8个字节都是有效的;任何非零值都被认为是真的.

第二句话是无稽之谈:ABI无权告诉编译器如何将内容存储在函数内部的寄存器中,而只是存储在不同编译单元(内存/函数参数和返回值)之间的边界.我在on the github page where it's maintained年前报告了这个ABI缺陷.

3.2.3 Parameter passing:

当在寄存器或堆栈中返回或传递类型为_Bool的值时,位0包含真值,位1至7应为零16.

(脚注16):其他位未指定,因此为the consumer side of those values can rely on it being 0 or 1 when truncated to 8 bit.

i386 System V ABI中的语言是相同的,即IIRC.


任何编译器如果假设一件事(例如转换为int)为0/1,但在其他情况下无法利用它,则其值为missed optimization.不幸的是,这种遗漏的优化仍然存在,尽管它们比Agner在那段关于编译器always重新布尔化的文章中写的要少得多.

(对于gcc4.6/4.7和clang/msvc,100上的Source+ASM.另请参阅Matt Godbolt的CppCon2017 Talk What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid)

bool logical_or(bool a, bool b) { return a||b; }

 # gcc4.6.4 -O3 for the x86-64 System V ABI
    test    dil, dil            # test a against itself (for non-zero)
    mov     eax, 1
    cmove   eax, esi            # return   a ? 1 : b;
    ret

即使是gcc4.6没有将b重新布尔化,但它确实错过了gcc4的优化.7 makes:(以及其他答案中所示的clang和更高版本的编译器):

    # gcc4.7 -O3 to present: looks ideal to me.
    mov     eax, esi
    or      eax, edi
    ret

(Clang的or dil, sil/mov eax, edi是愚蠢的:在写入dil之后读取edi时,它肯定会在Nehalem或更早版本的Intel上造成部分寄存器暂停,并且由于需要REX前缀来使用edi的低8部分,它的代码量更大.如果你想避免reading个32位寄存器,以防你的调用者留下一些arg通过,更好的 Select 可能是or dil,sil/movzx eax, dil带有"脏"部分寄存器的寄存器.)

MSVC emits this code that checks 101 then 102 separately, completely failing to take advantage of anything,甚至用xor al,al而不是xor eax,eax.因此,它对大多数CPU(including Haswell/Skylake, which don't rename low-8 partial regs separately from the whole register, only AH/BH/...)上的旧值eax有错误的依赖性.这太蠢了.使用xor al,al的唯一原因是当您明确希望保留较高的字节时.

logical_or PROC                     ; x86-64 MSVC CL19
    test     cl, cl                 ; Windows ABI passes args in ecx, edx
    jne      SHORT $LN3@logical_or
    test     dl, dl
    jne      SHORT $LN3@logical_or
    xor      al, al                 ; missed peephole: xor eax,eax is strictly better
    ret      0
$LN3@logical_or:
    mov      al, 1
    ret      0
logical_or ENDP

ICC18也没有利用输入的已知0/1特性,它只使用or指令来根据两个输入的逐位或来设置标志,而使用setcc指令来产生0/1.

logical_or(bool, bool):             # ICC18
    xor       eax, eax                                      #4.42
    movzx     edi, dil                                      #4.33
    movzx     esi, sil                                      #4.33
    or        edi, esi                                      #4.42
    setne     al                                            #4.42
    ret                                                     #4.42

即使对于bool bitwise_or(bool a, bool b) { return a|b; },ICC也会发出相同的代码.它提升到int(具有movzx),并使用or根据逐位OR设置标志.与or dil,sil/setne al相比,这是愚蠢的.

对于bitwise_or,MSVC只使用or指令(在每个输入movzx之后),但无论如何不会重新布尔化.


当前GCC/clang中错过的优化:

只有icc/msvc才会用上面的简单函数做哑巴代码,但是这个函数还是给GCC和clang带来了麻烦:

int select(bool a, bool b, int x, int y) {
    return (a&&b) ? x : y;
}

100(相同的源代码,与上次 Select 的编译器不同).

看起来很简单;您会希望一个聪明的编译器能用test/cmov来无分支地完成这项工作.x86的test指令根据按位AND设置标志.这是一条AND指令,实际上并不写入目的地.(就像cmp是不写目的地的sub一样).

# hand-written implementation that no compilers come close to making
select:
    mov     eax, edx      # retval = x
    test    edi, esi      # ZF =  ((a & b) == 0)
    cmovz   eax, ecx      # conditional move: return y if ZF is set
    ret

但即使是每天在Godbolt编译器浏览器上构建的gcc和clang,也会生成much个更复杂的代码,分别判断每个布尔值.如果返回ab,他们知道如何优化bool ab = a&&b;,但即使以这种方式编写(使用一个单独的布尔变量来保存结果),也无法让他们手工编写出不糟糕的代码.

请注意,test same,same is exactly equivalent to cmp reg, 0和更小,所以编译器使用它.

Clang's版本严格来说比我的手写版本差.(请注意,它要求调用方将bool个参数零扩展到32位,like it does for narrow integer types as an unofficial part of the ABI which it and gcc implement but only clang depends on).

select:  # clang 6.0 trunk 317877 nightly build on Godbolt
    test    esi, esi
    cmove   edx, ecx         # x = b ? y : x
    test    edi, edi
    cmove   edx, ecx         # x = a ? y : x
    mov     eax, edx         # return x
    ret

每晚为此编写分支代码,类似于较早的GCC版本所做的工作.

select(bool, bool, int, int):   # gcc 8.0.0-pre   20171110
    test    dil, dil
    mov     eax, edx          ; compiling with -mtune=intel or -mtune=haswell would keep test/jcc together for macro-fusion.
    je      .L8
    test    sil, sil
    je      .L8
    rep ret
.L8:
    mov     eax, ecx
    ret

MSVC x86-64 CL19生成非常相似的分支代码.它的目标是Windows调用约定,其中整数参数在RCX、RDX、R8、R9中.

select PROC
        test     cl, cl         ; a
        je       SHORT $LN3@select
        mov      eax, r8d       ; retval = x
        test     dl, dl         ; b
        jne      SHORT $LN4@select
$LN3@select:
        mov      eax, r9d       ; retval = y
$LN4@select:
        ret      0              ; 0 means rsp += 0 after popping the return address, not C return 0.
                                ; MSVC doesn't emit the `ret imm16` opcode here, so IDK why they put an explicit 0 as an operand.
select ENDP

ICC18也生成分支代码,但是在分支之后都有mov条指令.

select(bool, bool, int, int):
        test      dil, dil                                      #8.13
        je        ..B4.4        # Prob 50%                      #8.13
        test      sil, sil                                      #8.16
        jne       ..B4.5        # Prob 50%                      #8.16
..B4.4:                         # Preds ..B4.2 ..B4.1
        mov       edx, ecx                                      #8.13
..B4.5:                         # Preds ..B4.2 ..B4.4
        mov       eax, edx                                      #8.13
        ret                                                     #8.13

Trying to help the compiler by using

int select2(bool a, bool b, int x, int y) {
    bool ab = a&&b;
    return (ab) ? x : y;
}

leads MSVC into making hilariously bad code:

;; MSVC CL19  -Ox  = full optimization
select2 PROC
    test     cl, cl
    je       SHORT $LN3@select2
    test     dl, dl
    je       SHORT $LN3@select2
    mov      al, 1              ; ab = 1

    test     al, al             ;; and then test/cmov on an immediate constant!!!
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
$LN3@select2:
    xor      al, al            ;; ab = 0

    test     al, al            ;; and then test/cmov on another path with known-constant condition.
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
select2 ENDP

这仅适用于MSVC(ICC18在刚刚设置为常数的寄存器上具有相同的test/cmov遗漏优化).

GCC和Cang一如既往地编写的代码不会像msvc那样糟糕;他们编写的asm和msvc一样,这仍然不是很好,但至少试图帮助他们不会像msvc那样让情况变得更糟.


Combine bool with bitwise operators helps MSVC and ICC

在我非常有限的测试中,对于MSVC和ICC,|&似乎比||&&工作得更好.使用编译器+编译选项查看您自己的代码的编译器输出,看看会发生什么.

int select_bitand(bool a, bool b, int x, int y) {
    return (a&b) ? x : y;
}

Gcc still branches separately在两个输入的单独test个上,代码与select的其他版本相同.clang still does two separate 102,与其他源版本的ASM相同.

MSVC通过并正确优化,击败了所有其他编译器(至少在独立定义中):

select_bitand PROC            ;; MSVC
    test     cl, dl           ;; ZF =  !(a & b)
    cmovne   r9d, r8d
    mov      eax, r9d         ;; could have done the mov to eax in parallel with the test, off the critical path, but close enough.
    ret      0

ICC18浪费了两条movzx指令,将bools扩展为int,然后生成与MSVC相同的代码

select_bitand:          ## ICC18
    movzx     edi, dil                                      #16.49
    movzx     esi, sil                                      #16.49
    test      edi, esi                                      #17.15
    cmovne    ecx, edx                                      #17.15
    mov       eax, ecx                                      #17.15
    ret                                                     #17.15

C++相关问答推荐

如何在不修改字符串缓冲区早期使用的情况下覆盖字符串缓冲区

为什么已经设置的值在C中被重置为for循环条件中的新值?

你能用自己的地址声明一个C指针吗?

如何在IF语句中正确使用0.0

如何创建一个C程序来存储5种动物的名字,并在用户 Select 其中任何一种动物时打印内存地址?

在C语言中,在数学运算过程中,为什么浮点数在变量中的行为不同

解决S随机内存分配问题,实现跨进程高效数据共享

Kdb:仅升级指定的列

<;unistd.h>;和<;sys/unistd.h>;之间有什么区别?

如何在VS 2022中正确安装额外的C头文件

For循环不会迭代所有字符串字符吗?(初学者问题)

如何在VSCode中创建和使用我自己的C库?

C:如何将此代码转换为与数组一起使用?

从不兼容的指针类型返回&&警告,但我看不出原因

GetText不适用于包含国际字符的帐户名称

为什么会导致分段故障?(C语言中的一个程序,统计文件中某个单词的出现次数)

浮点正零何时不全为零?

WSASocket在哪里定义?

Struct 内的数组赋值

是什么阻止编译器优化手写的 memcmp()?