是<快于< =?

时间:2012-08-27 02:10:13

标签: c++ performance assembly relational-operators

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

与此简单示例不完全相同,但循环复杂代码略有性能变化。我想这必须对生成的机器代码做一些事情,如果它甚至是真的。

14 个答案:

答案 0 :(得分:1630)

不,它在大多数架构上都不会更快。您没有指定,但在x86上,所有的整数比较通常都会在两个机器指令中实现:

  • testcmp指令,设置EFLAGS
  • Jcc (jump) instruction,取决于比较类型(和代码布局):
    • jne - 如果不相等则跳转 - &gt; ZF = 0
    • jz - 如果为零(等于)则跳转 - > ZF = 1
    • jg - 如果更大则跳转 - &gt; ZF = 0 and SF = OF
    • (等...)

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

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

编译为:

    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:

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

编译为:

    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:

因此,两者之间的唯一区别是jgjge指令。两者将花费相同的时间。


我想解决的问题是,没有任何内容表明不同的跳转指令需要相同的时间。回答这个问题有点棘手,但这是我能给出的:在Intel Instruction Set Reference中,它们都在一个共同指令Jcc下组合在一起(如果条件满足则跳转)。相同的分组在Optimization Reference Manual下的附录C.延迟和吞吐量下进行。

  

延迟 - 所需的时钟周期数   执行核心,以完成所有形成的μops的执行   指示。

     

吞吐量 - 所需的时钟周期数   在问题端口可以自由接受相同的指令之前等待   再次。对于许多指令,指令的吞吐量可以是   显着低于其延迟

Jcc的值为:

      Latency   Throughput
Jcc     N/A        0.5

Jcc上添加了以下脚注:

  

7)条件跳转指令的选择应基于第3.4.1节“分支预测优化”一节的建议,以提高分支的可预测性。当成功预测分支时,jcc的延迟实际上为零。

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

如果考虑用于实现指令的实际电路,可以假设在EFLAGS中的不同位上将存在简单的AND / OR门,以确定是否满足条件。那么,没有理由说测试两位的指令应该比仅测试一位的指令花费更多或更少的时间(忽略门传播延迟,这远远小于时钟周期。)


编辑:浮点

这也适用于x87浮点数:(与上面的代码完全相同,但使用double代替int。)

        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

答案 1 :(得分:584)

历史上(我们谈论的是20世纪80年代和90年代初),有一些架构,其中这是真的。根本问题是整数比较通过整数减法固有地实现。这引起了以下情况。

Comparison     Subtraction
----------     -----------
A < B      --> A - B < 0
A = B      --> A - B = 0
A > B      --> A - B > 0

现在,当A < B减法必须借用高位以使减法正确时,就像你在手动添加和减去时携带和借用一样。这个“借用”位通常被称为进位,并且可以通过分支指令进行测试。如果减法相同为零,则表示相等的第二位称为零位

通常至少有两个条件分支指令,一个用于分支进位,另一个用于零位。

现在,为了解决问题的核心,让我们扩展上一个表,以包含进位和零位结果。

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实现一个分支可以在一条指令中完成,因为在这种情况下进位位只是 ,即

;; 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

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

;; 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与内存速度比的时代相关,但今天几乎完全不相关。

答案 2 :(得分:88)

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

对于简单整数类型,如果某个平台<<=快,则编译器应始终<=转换为<对于常数。任何编译器都不是一个糟糕的编译器(对于那个平台)。

答案 3 :(得分:66)

我发现两者都不快。编译器在每个条件中使用不同的值生成相同的机器代码。

if(a < 901)
cmpl  $900, -4(%rbp)
jg .L2

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

我的示例if来自Linux上x86_64平台上的GCC。

编译人员非常聪明,他们会想到这些事情以及我们大多数人认为理所当然的事情。

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

int b;
if(a < b)
cmpl  -4(%rbp), %eax
jge   .L2

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

答案 4 :(得分:50)

对于浮点代码,即使在现代架构上,&lt; =比较可能确实更慢(通过一条指令)。这是第一个功能:

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

在PowerPC上,首先执行浮点比较(更新cr,条件寄存器),然后将条件寄存器移动到GPR,将“比较小于”位移位到位,然后返回。它需要四条指令。

现在考虑这个功能:

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

这需要与上面compare_strict相同的工作,但现在有两个感兴趣的部分:“小于”和“等于”。这需要额外的指令(cror - 条件寄存器按位OR)将这两个位组合成一个。因此compare_loose需要五条指令,而compare_strict需要四条指令。

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

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

然而,这将错误地处理NaN。 NaN1 <= NaN2NaN1 > NaN2需要评估为false。

答案 5 :(得分:34)

也许这本未命名的书的作者已经读到a > 0a >= 1跑得更快,并认为这是普遍的。

但这是因为涉及0(因为CMP可以,取决于架构,可以替换为OR),而不是<。< / p>

答案 6 :(得分:31)

至少,如果这是真的,编译器可以轻而易举地优化&lt; = b到!(a&gt; b),所以即使比较本身实际上比较慢,除了最天真的编译器你不会注意到差异。

答案 7 :(得分:15)

他们的速度相同。也许在一些特殊的架构中他/她说的是对的,但至少在x86家族中我知道它们是相同的。因为为此,CPU将进行减法(a-b),然后检查标志寄存器的标志。该寄存器的两位称为ZF(零标志)和SF(符号标志),它在一个周期内完成,因为它将通过一个屏蔽操作完成。

答案 8 :(得分:14)

这将高度依赖于编译C的底层架构。某些处理器和体系结构可能具有等于或小于等于在不同循环数中执行的明确指令。

但这很不寻常,因为编译器可以解决它,使其变得无关紧要。

答案 9 :(得分:11)

TL;DR回答

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

完整答案

其他答案主要集中在x86架构上,我不知道ARM架构(你的示例汇编器似乎是)已经足够好,可以专门评论生成的代码,但这是一个micro-optimisation的例子,其中 非常特定于架构,并且可能是一个反优化,因为它是一个优化

因此,我建议这种micro-optimisationcargo cult编程的一个例子,而不是最好的软件工程实践。

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

即便如此,在几乎所有情况下,编译器都可以按照这样的方式对评估指令进行排序:在实践中,没有任何比较优于任何其他比较。最糟糕的情况是,可能需要添加反向指令(REV)来交换operand stack上的前两项。这是一个单字节指令,需要一个周期才能运行,所以可能的开销最小。

这样的微优化是优化还是反优化取决于您使用的特定架构,因此通常是一个坏主意养成使用特定于体系结构的微优化的习惯,否则你可能本能地使用一个不合适的方式,看起来这正是你正在阅读的书所倡导的。

答案 10 :(得分:6)

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

答案 11 :(得分:4)

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

答案 12 :(得分:3)

当我写这个答案时,我通常只看关于a < 901对a <= 900的具体示例。许多编译器总是通过在<<=之间进行转换来缩小常量的大小,例如因为x86立即操作数的-128..127编码具有较短的1字节编码。

对于ARM尤其是AArch64,能否将其编码为立即数取决于能否将狭窄的字段旋转到单词中的任何位置。因此cmp w0, #0x00f000是可编码的,而cmp w0, #0x00effff可能不是。因此,比较小和编译时常量的规则并不总是适用于AArch64。


在大多数机器上,汇编语言中,<=的比较与<的比较具有相同的成本。无论您是在其上进行分支,对其进行布尔化以创建一个0/1整数,还是将其用作无分支选择操作的谓词(例如x86 CMOV),这都适用。其他答案仅解决了问题的这一部分。

但是这个问题是关于C ++运算符,即优化程序的 input 书中的建议听起来完全是伪造的,因为编译器总是可以转换他们在asm中实现的比较。但是至少有一个例外,其中使用<=会意外地创建编译器无法优化的内容。

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

与有符号溢出(UB)不同,无符号溢出被定义为以2为基的环绕。使用不会根据有符号溢出的UB进行优化的编译器,签名循环计数器通常是安全的:++i <= size最终将始终为false。 (What Every C Programmer Should Know About Undefined Behavior

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 ++源的(定义的和法律上可观察的)行为的优化方式,除非那些导致未定义行为的输入值

(一个简单的i <= size也会产生此问题,但我认为计算上限是一个更现实的示例,即偶然为您不在乎的输入引入无限循环的可能性,编译器必须考虑。)

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

如果if(!size) skip the loop;的实际值在循环内是不需要的,则像do{...}while(--size); for( i<size )这样的asm是优化i循环的一种通常有效的方法({ {3}}。

但是这样做{}不可能是无限的:如果使用size==0输入,我们将得到2 ^ n次迭代。 (Why are loops always compiled into "do...while" style (tail jump)? C使得可以在所有无符号整数(包括零)上表达循环,但要像在asm中那样没有进位标志,这并不容易。)

由于可能存在循环计数器的环绕,现代编译器通常只是“放弃”,而没有那么积极地进行优化。

示例:从1到n的整数之和

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

unsigned sum_1_to_n_finite(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i < n+1 ; ++i)
        total += i;
    return total;
}

Iterating over all unsigned integers in a for loop

 # 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中获得了一个哑巴循环。

unsigned sum_1_to_n_naive(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i<=n ; ++i)
        total += i;
    return total;
}
# 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值。

# "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,因此它没有使用循环计数器的最终值。

答案 13 :(得分:0)

仅当创建计算机的人对布尔逻辑不好时。他们不应该的。

每次比较(>= <= > <的速度都可以相同。

每个比较是什么,只是一个减法(差值),并查看它是否为正/负。
(如果设置了msb,则该数字为负)

如何检查a >= b?子a-b >= 0检查a-b是否为正。
如何检查a <= b?子0 <= b-a检查b-a是否为正。
如何检查a < b?子a-b < 0检查a-b是否为负。
如何检查a > b?子0 > b-a检查b-a是否为负。

简而言之,计算机可以在给定操作的引擎盖下进行此操作:

a >= b == msb(a-b)==0
a <= b == msb(b-a)==0
a > b == msb(b-a)==1
a < b == msb(a-b)==1

当然,计算机实际上也不需要执行==0==1
对于==0,它可以将电路中的msb反转。

无论如何,他们肯定不会使a >= b的计算公式为a>b || a==b大声笑