关于C++:<比<=更快吗?

Is < faster than <=?

我在读一本书,作者说if( a < 901 )if( a <= 900 )快。

与这个简单的例子不完全一样,但是循环复杂代码的性能会有轻微的变化。我想这与生成的机器代码有关,以防它是真的。


不,在大多数体系结构上它不会更快。您没有指定,但是在x86上,所有的积分比较通常在两个机器指令中实现:

  • 设置EFLAGStestcmp指令。
  • 以及一条Jcc指令(跳转),具体取决于比较类型(和代码布局):
    • jne—不等于时跳转——>ZF = 0
    • jz—如果为零(等于)->ZF = 1,则跳转。
    • jg—如果大于该值,则跳转--->ZF = 0 and SF = OF
    • (等)

示例(为简洁而编辑)用$ gcc -m32 -S -masm=intel test.c编译

1
2
3
    if (a < b) {
        // Do something 1
    }

编译到:

1
2
3
4
5
    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jge     .L2                          ; jump if a is >= b
    ; Do something 1
.L2:

1
2
3
    if (a <= b) {
        // Do something 2
    }

编译到:

1
2
3
4
5
    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jg      .L5                          ; jump if a is > b
    ; Do something 2
.L5:

因此,两者之间的唯一区别是jg指令与jge指令。这两个都需要同样的时间。

我想说明的是,没有任何东西表明不同的跳转指令需要相同的时间。这是一个有点难回答的问题,但我可以给出以下结论:在Intel指令集引用中,它们都在一个公共指令Jcc下分组(如果满足条件,则跳转)。同样的分组是在优化参考手册的附录C.延迟和吞吐量下进行的。

Latency — The number of clock cycles that are required for the
execution core to complete the execution of all of the μops that form
an instruction.

Throughput — The number of clock cycles required to
wait before the issue ports are free to accept the same instruction
again. For many instructions, the throughput of an instruction can be
significantly less than its latency

Jcc的值为:

1
2
      Latency   Throughput
Jcc     N/A        0.5

Jcc上有以下脚注:

7) Selection of conditional jump instructions should be based on the recommendation of section Section 3.4.1,"Branch Prediction Optimization," to improve the predictability of branches. When branches are predicted successfully, the latency of jcc is effectively zero.

因此,英特尔文档中的任何内容都不会将一条Jcc指令与其他指令区别对待。

如果考虑到用于执行指令的实际电路,可以假设EFLAGS中不同位上有简单的和/或门,以确定是否满足条件。因此,测试两个位的指令所花费的时间不应超过或少于一个测试所花费的时间(忽略比时钟周期短得多的门传播延迟)。

编辑:浮点

这同样适用于x87浮点:(与上面的代码基本相同,但使用double而不是int)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; Compare ST(0) and ST(1), and set CF, PF, ZF in EFLAGS
        fstp    st(0)
        seta    al                     ; Set al if above (CF=0 and ZF=0).
        test    al, al
        je      .L2
        ; Do something 1
.L2:

        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; (same thing as above)
        fstp    st(0)
        setae   al                     ; Set al if above or equal (CF=0).
        test    al, al
        je      .L5
        ; Do something 2
.L5:
        leave
        ret


从历史上看(我们讲的是20世纪80年代和90年代初),有些架构是这样的。根本问题是整数比较是通过整数减法固有地实现的。这会导致以下情况。

1
2
3
4
5
Comparison     Subtraction
----------     -----------
A < B      --> A - B < 0
A = B      --> A - B = 0
A > B      --> A - B > 0

现在,当A < B时,减法必须借用一个高位才能使减法正确,就像用手加减时进行进位和借位一样。这个"借用"位通常被称为进位,可以通过分支指令进行测试。如果减法等于零,则将设置第二个名为零的位,这意味着相等。

通常至少有两个条件分支指令,一个在进位上分支,一个在零位上分支。

现在,为了弄清问题的核心,我们将上一个表扩展到包含进位和零位结果。

1
2
3
4
5
Comparison     Subtraction  Carry Bit  Zero Bit
----------     -----------  ---------  --------
A < B      --> A - B < 0    0          0
A = B      --> A - B = 0    1          1
A > B      --> A - B > 0    1          0

因此,为A < B实现分支可以在一条指令中完成,因为进位只有在这种情况下才是清晰的,也就是说,

1
2
3
;; Implementation of"if (A < B) goto address;"
cmp  A, B          ;; compare A to B
bcz  address       ;; Branch if Carry is Zero to the new address

但是,如果我们想做一个小于或等于的比较,我们需要对零标志做一个额外的检查来捕捉相等的情况。

1
2
3
4
;; Implementation of"if (A <= B) goto address;"
cmp A, B           ;; compare A to B
bcz address        ;; branch if A < B
bzs address        ;; also, Branch if the Zero bit is Set

因此,在某些机器上,使用"小于"比较可能会节省一条机器指令。这在亚兆赫处理器速度和1:1 CPU对内存的速度比的时代是相关的,但今天几乎完全不相关。


假设我们讨论的是内部整数类型,那么没有可能一种方法比另一种方法更快。它们显然在语义上是相同的。它们都要求编译器做完全相同的事情。只有极坏的编译器才能为其中一个生成劣质代码。

如果某个平台的<<=更快,那么对于简单的整数类型,编译器应该总是将<=转换为<作为常量。任何不是这样的编译器(对于那个平台)都会是一个糟糕的编译器。


我看也不快。编译器在每个条件下生成具有不同值的相同机器代码。

1
2
3
4
5
6
7
if(a < 901)
cmpl  $900, -4(%rbp)
jg .L2

if(a <=901)
cmpl  $901, -4(%rbp)
jg .L3

我的示例if来自Linux上x86_平台上的gcc。

编译器作者是相当聪明的人,他们想到这些事情,我们大多数人都认为是理所当然的。

我注意到,如果它不是一个常量,那么在这两种情况下都会生成相同的机器代码。

1
2
3
4
5
6
7
8
int b;
if(a < b)
cmpl  -4(%rbp), %eax
jge   .L2

if(a <=b)
cmpl  -4(%rbp), %eax
jg .L3


对于浮点代码,即使在现代体系结构上,<=比较(通过一条指令)也可能会更慢。以下是第一个函数:

1
int compare_strict(double a, double b) { return a < b; }

在PowerPC上,首先执行浮点比较(更新cr,条件寄存器),然后将条件寄存器移动到gpr,将"compared less than"位移动到位,然后返回。它需要四个指令。

现在考虑这个函数:

1
int compare_loose(double a, double b) { return a <= b; }

这需要与上面的compare_strict相同的工作,但现在有两个兴趣:"小于"和"等于"。这需要一个额外的指令(cror条件寄存器按位或)将这两个位组合成一个。因此,compare_loose需要5条指令,而compare_strict需要4条指令。

您可能认为编译器可以像这样优化第二个函数:

1
int compare_loose(double a, double b) { return ! (a > b); }

但是,这将错误地处理nan。NaN1 <= NaN2NaN1 > NaN2都需要评估为假。


也许这本无名书的作者读到了a > 0a >= 1运行得更快,并且认为这是普遍正确的。

但这是因为涉及到一个0(因为CMP可以,根据架构,替换为OR,而不是因为<


至少,如果这是真的,编译器可以将a<=b优化到!(A>B),所以即使比较本身比较慢,除了最幼稚的编译器之外,其他所有编译器都不会有什么不同。


它们有相同的速度。也许在某些特殊的建筑中,他/她所说的是对的,但在x86家族中,至少我知道他们是一样的。因为这样做,CPU将做一个减法(A-B),然后检查标志寄存器的标志。该寄存器的两个位称为ZF(零标志)和SF(符号标志),它在一个周期内完成,因为它将在一个掩码操作中完成。


这将高度依赖于C被编译到的底层体系结构。一些处理器和体系结构可能具有等于或小于等于的显式指令,这些指令以不同的周期数执行。

不过,这将是非常不寻常的,因为编译器可以绕过它,使之不相关。


回答问题

对于大多数体系结构、编译器和语言的组合,它不会更快。

完整答案

其他的答案都集中在x86架构上,我不太清楚ARM架构(您的示例汇编程序似乎是这样)是否足以专门对生成的代码进行评论,但这是一个非常特定于架构的微优化的例子,而且很可能是一个反优化,因为它是一个优化的例子。在。

因此,我建议这种微观优化是Cargo Cult编程的一个例子,而不是最佳软件工程实践。

可能有一些架构是这样优化的,但我知道至少有一个架构是相反的。古老的Transputer体系结构只有等于或大于或等于的机器代码指令,因此所有比较都必须用这些原语来构建。

即使这样,在几乎所有的情况下,编译器都可以按照这样的方式来排序评估指令:在实践中,没有任何比较比任何其他的都有优势。但在最坏的情况下,它可能需要添加一个反向指令(rev)来交换操作数堆栈的前两项。这是一个单字节的指令,只需运行一个周期,因此开销尽可能小。

不管这种微观优化是优化还是反优化,都取决于你所使用的具体架构,所以养成使用特定架构的微观优化的习惯通常是个坏主意,否则当不合适的时候,你可能会本能地使用一个,看起来这是个很好的选择。确切地说,你所读的那本书是在鼓吹什么。


即使有差异,你也不应该注意到。此外,在实践中,你需要做一个额外的a + 1a - 1,以使条件成立,除非你要使用一些魔法常数,这是一个非常糟糕的做法。


您可以说,在大多数脚本语言中,这一行是正确的,因为额外的字符会导致代码处理稍慢。然而,正如上面的答案所指出的,它在C++中不应该有任何效果,任何使用脚本语言的事情可能都不关心优化。


当我写这个答案的时候,我只关注标题问题,一般来说,a < 901vs.a <= 900的具体例子。许多编译器总是通过在<<=之间转换来缩小常量的大小,例如,因为x86立即操作数的1字节编码比-128..127短。好的。

对于ARM,尤其是AARCH64,能够立即编码取决于能够将一个狭窄的字段旋转到单词中的任何位置。因此,cmp w0, #0x00f000是可编码的,而cmp w0, #0x00effff可能不是。因此,与编译时常量相比,make it较小的比较规则并不总是适用于aarch64。好的。在大多数机器上的汇编语言中,对<=的比较与对<的比较具有相同的成本。无论您是在其上进行分支、将其布尔化以创建0/1整数,还是将其用作无分支选择操作(如x86 cmov)的谓词,这都适用。其他答案只解决了问题的这一部分。好的。

但这个问题是关于C++运算符,输入到优化器。通常,它们都同样有效;书中的建议听起来完全是假的,因为编译器总是可以转换它们在ASM中实现的比较。但至少有一个例外,即使用<=会意外地创建编译器无法优化的内容。好的。

作为循环条件,在某些情况下,当<=停止编译器证明循环不是无限循环时,<=<在质量上不同。这会有很大的不同,禁用自动矢量化。好的。

与有符号溢出(UB)不同,无符号溢出被定义为基2环绕。签名循环计数器通常是安全的,因为编译器根据签名溢出ub进行优化时不会发生这种情况:++i <= size最终将变为false。(每个C程序员都应该知道哪些未定义的行为)好的。

1
2
3
4
void foo(unsigned size) {
    unsigned upper_bound = size - 1;  // or any calculation that could produce UINT_MAX
    for(unsigned i=0 ; i <= upper_bound ; i++)
        ...

编译器只能以保留所有可能的输入值的C++源(定义的和法律上可观察的)行为的方式进行优化,除了那些导致未定义行为的行为。好的。

(一个简单的EDCOX1,12)也会产生问题,但我认为计算上限是一个更实际的例子,它意外地引入了无限循环的可能性,而不需要考虑编译器必须考虑的输入。好的。

在这种情况下,size=0导致upper_bound=UINT_MAXi <= UINT_MAX始终是正确的。因此,对于size=0,这个循环是无限的,编译器必须考虑到这一点,即使作为程序员,您可能从未打算传递size=0。如果编译器可以将这个函数内联到一个调用者中,在调用者中它可以证明size=0是不可能的,那么很好,它可以像对i < size那样进行优化。好的。

if(!size) skip the loop;do{...}while(--size);一样,如果循环中不需要i的实际值(为什么循环总是编译成"do…while"样式(尾跳),那么优化for( i循环通常是一种有效的方法。.好的。

但是这样做了虽然不能是无限的:如果用size==0输入,我们得到2^n次迭代。(迭代for循环c中的所有无符号整数可以表示包含零的所有无符号整数的循环,但如果没有进位标志(如asm中的进位标志),就不容易了。)好的。

由于循环计数器的包围是一种可能性,现代编译器通常只是"放弃",并没有那么积极地进行优化。好的。示例:从1到n的整数之和

使用无符号i <= n会破坏clang的惯用识别方法,该方法基于高斯的n * (n+1) / 2公式,以闭合形式优化sum(1 .. n)循环。好的。

1
2
3
4
5
6
unsigned sum_1_to_n_finite(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i < n+1 ; ++i)
        total += i;
    return total;
}

来自Godbolt编译器资源管理器clang7.0和gcc8.2的x86-64 ASM好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
 # clang7.0 -O3 closed-form
    cmp     edi, -1       # n passed in EDI: x86-64 System V calling convention
    je      .LBB1_1       # if (n == UINT_MAX) return 0;  // C++ loop runs 0 times
          # else fall through into the closed-form calc
    mov     ecx, edi         # zero-extend n into RCX
    lea     eax, [rdi - 1]   # n-1
    imul    rax, rcx         # n * (n-1)             # 64-bit
    shr     rax              # n * (n-1) / 2
    add     eax, edi         # n + (stuff / 2) = n * (n+1) / 2   # truncated to 32-bit
    ret          # computed without possible overflow of the product before right shifting
.LBB1_1:
    xor     eax, eax
    ret

但是对于幼稚的版本,我们只是从clang得到一个哑循环。好的。

1
2
3
4
5
6
unsigned sum_1_to_n_naive(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i<=n ; ++i)
        total += i;
    return total;
}
1
2
3
4
5
6
7
8
9
10
# clang7.0 -O3
sum_1_to_n(unsigned int):
    xor     ecx, ecx           # i = 0
    xor     eax, eax           # retval = 0
.LBB0_1:                       # do {
    add     eax, ecx             # retval += i
    add     ecx, 1               # ++1
    cmp     ecx, edi
    jbe     .LBB0_1            # } while( i<n );
    ret

GCC也不使用封闭形式,因此循环条件的选择并不会真正影响它;它使用simd整数加法自动向量化,在xmm寄存器的元素中并行运行4个i值。好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#"naive" inner loop
.L3:
    add     eax, 1       # do {
    paddd   xmm0, xmm1    # vect_total_4.6, vect_vec_iv_.5
    paddd   xmm1, xmm2    # vect_vec_iv_.5, tmp114
    cmp     edx, eax      # bnd.1, ivtmp.14     # bound and induction-variable tmp, I think.
    ja      .L3 #,       # }while( n > i )

"finite" inner loop
  # before the loop:
  # xmm0 = 0 = totals
  # xmm1 = {0,1,2,3} = i
  # xmm2 = set1_epi32(4)
 .L13:                # do {
    add     eax, 1       # i++
    paddd   xmm0, xmm1    # total[0..3] += i[0..3]
    paddd   xmm1, xmm2    # i[0..3] += 4
    cmp     eax, edx
    jne     .L13      # }while( i != upper_limit );

     then horizontal sum xmm0
     and peeled cleanup for the last n%3 iterations, or something.

它还有一个普通的标量循环,我认为它用于非常小的n,和/或用于无限循环情况。好的。

顺便说一句,这两个循环在循环开销上都浪费了一条指令(以及SandyBridge系列CPU上的UOP)。用sub eax,1/jnz代替add eax,1/cmp/jcc更有效。1 UOP而不是2(SUB/JCC或CMP/JCC宏观融合后)。两个循环之后的代码无条件地写入EAX,因此它不使用循环计数器的最终值。好的。好啊。