<快于<=?

Vin*_*rta 1508 c++ performance assembly relational-operators

我正在读一本书,作者说这if( a < 901 )比书更快if( a <= 900 ).

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

Jon*_*art 1658

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

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

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

    if (a < b) {
        // Do something 1
    }
Run Code Online (Sandbox Code Playgroud)

编译为:

    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:
Run Code Online (Sandbox Code Playgroud)

    if (a <= b) {
        // Do something 2
    }
Run Code Online (Sandbox Code Playgroud)

编译为:

    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:
Run Code Online (Sandbox Code Playgroud)

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


我想解决的问题是,没有任何迹象表明不同的跳转指令需要相同的时间.这个问题有点难以回答,但这是我能给出的:在英特尔指令集参考中,它们都在一条通用指令下组合在一起Jcc(如果满足条件则跳转).在优化参考手册的附录C中,相同的分组一起进行.延迟和吞吐量.

延迟 - 执行内核完成执行构成指令的所有μop所需的时钟周期数.

吞吐量 - 在发出端口可以再次接受相同指令之前等待所需的时钟周期数.对于许多指令,指令的吞吐量可能远远小于其延迟

值为Jcc:

      Latency   Throughput
Jcc     N/A        0.5
Run Code Online (Sandbox Code Playgroud)

以下脚注Jcc:

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

因此,英特尔文档中没有任何内容可以将一条Jcc指令与其他指令区别对待.

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


编辑:浮点

对于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
Run Code Online (Sandbox Code Playgroud)

  • @Dyppl实际上`jg`和`jnle`是相同的指令,`7F` :-) (234认同)
  • @jontejj我非常清楚这一点.你甚至*读*我的答案了吗?我没有说明有关*number*指令的任何内容,我说它们被编译成基本上完全相同的*instrutcions*,除了一个跳转指令正在查看一个标志,另一个跳转指令正在查看两个标志.我相信我已经给出了足够的证据来证明它们在语义上是相同的. (19认同)
  • 更不用说优化器可以修改代码,如果一个选项确实比另一个更快. (15认同)
  • 仅仅因为某种结果导致相同数量的指令并不一定意味着执行所有这些指令的总时间将是相同的。实际上,更多指令可以更快地执行。每个周期的指令数不是固定的,它取决于指令。 (2认同)
  • 是的,现在看到了。我仍然认为你的第一句话会导致某人出于错误的原因得出这个结论。“您没有指定,但在 x86 上,所有积分比较通常都会在两个机器指令中实现”这实际上不是您应该提出的要点,但它是您提出的第一个要点。人们必须从最底层阅读您编辑的部分才能理解原因。否则你的答案就是一流的! (2认同)
  • @jontejj 你说得很好。为了尽可能提高这个答案的知名度,我可能应该对其进行一些清理。感谢您的反馈。 (2认同)

Luc*_*cas 585

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

Comparison     Subtraction
----------     -----------
A < B      --> A - B < 0
A = B      --> A - B = 0
A > B      --> A - B > 0
Run Code Online (Sandbox Code Playgroud)

现在,当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
Run Code Online (Sandbox Code Playgroud)

因此,实现分支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
Run Code Online (Sandbox Code Playgroud)

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

;; 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
Run Code Online (Sandbox Code Playgroud)

因此,在某些机器上,使用"小于"比较可能会保存一台机器指令.这与亚兆赫处理器速度和1:1 CPU到内存速度比的时代相关,但它现在几乎完全无关紧要.

  • 即使在8080上,`= =`测试也可以在_one_指令中实现,交换操作数并测试`not <`(相当于`> =`)这是所需的`<=`与交换操作数:`cmp B ,一个; bcs addr`.这就是英特尔省略了这项测试的原因,他们认为这是多余的,你在那些时候无法负担多余的指令:-) (28认同)
  • 此外,像x86这样的架构实现了像`jge`这样的指令,它可以测试零和符号/进位标志. (10认同)
  • 这在8080上是正确的.它有指令跳零和跳减号,但没有可以同时测试两者. (7认同)
  • 即使对于给定的架构也是如此.没有编译器编写者注意到的几率是多少,并添加了一个优化来更快地替换慢速? (3认同)
  • 6502和65816处理器系列也是如此,它也扩展到了Motorola 68HC11/12. (3认同)
  • @JonHanna:这个*是优化版本.对于循环,仅在循环的最后一次迭代中遇到branch-if-equal指令,因此其影响将分摊到循环的某个部分.反转测试需要在内部循环中放置一条额外的指令,这将影响*每个循环迭代.此外,可能无法颠倒比较的顺序,因为这些通常是累加器架构,并且将累加器溢出到存储器将比仅添加额外的条件分支指令昂贵得多. (2认同)
  • 卢卡斯:如果B是常数,@ Jon可能意味着`A &lt;(B + 1)`优化。 (2认同)
  • 我很确定这些架构中的一些仍处于嵌入式使用状态,因此即使它们出生于80年代,也不一定会死在那里. (2认同)

Dav*_*rtz 89

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

如果有一些平台,<是速度比<=为简单的整数类型,编译器应该总是转换<=<为常数.任何编译器都不会只是一个糟糕的编译器(对于该平台).

  • +1我同意.在编译器决定它们将具有哪种速度之前,```和`<=`都没有速度.对于编译器来说,这是一个非常简单的优化,当你考虑到他们通常已经执行死代码优化,尾部调用优化,循环提升(和展开,偶尔),各种循环的自动并行化等等.*为什么浪费时间思考过早优化?*获取原型运行,对其进行分析以确定最重要的优化位置,按重要性顺序执行这些优化,并在测量进度的过程中再次进行配置... (6认同)

Adr*_*ish 67

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

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

if(a <=901)
cmpl  $901, -4(%rbp)
jg .L3
Run Code Online (Sandbox Code Playgroud)

我的例子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
Run Code Online (Sandbox Code Playgroud)

  • 我认为你应该使用`if(a <= 900)`来证明它生成完全相同的asm :) (10认同)
  • 请注意,这是特定于x86的. (9认同)
  • 没有人指出这种优化*仅适用于常数比较*.我可以保证在比较两个变量时不会这样做. (5认同)
  • @Boann可能会减少到if(true)并完全消除. (3认同)
  • @AdrianCornish对不起..我编辑了..它或多或少相同..但如果你改变第二个如果<= 900那么asm代码将完全相同:)它现在几乎相同..但你知道..为OCD :) (2认同)

rid*_*ish 50

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

int compare_strict(double a, double b) { return a < b; }
Run Code Online (Sandbox Code Playgroud)

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

现在考虑这个功能:

int compare_loose(double a, double b) { return a <= b; }
Run Code Online (Sandbox Code Playgroud)

这需要与compare_strict上面相同的工作,但现在有两点感兴趣:"小于"和"等于".这需要额外的指令(cror- 条件寄存器按位OR)将这两个位组合成一个.所以compare_loose需要五条指令,同时compare_strict需要四条指令.

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

int compare_loose(double a, double b) { return ! (a > b); }
Run Code Online (Sandbox Code Playgroud)

但是这会错误地处理NaN.NaN1 <= NaN2并且NaN1 > NaN2需要评估为假.

  • @JonathonReinhart:我认为你误解了PowerPC正在做什么 - 条件寄存器`cr`*是*等同于x86上的`ZF`和`CF`等标志.(虽然CR更灵活.)海报正在谈论的是将结果移动到GPR:它在PowerPC上有两条指令,但是x86有条件移动指令. (3认同)

glg*_*lgl 34

也许这本未命名的书的作者阅读的a > 0速度要快于a >= 1并认为这是普遍存在的.

但这是因为a 0涉及(因为CMP可以,取决于架构,例如替换OR)而不是因为<.

  • @BeeOnRope有时,我会感到惊讶,优化器可以优化哪些复杂的功能,而优化器却无法优化这些功能。 (2认同)

Eli*_*all 32

至少,如果这是真的,编译器可以轻而易举地优化<= b到!(a> b),所以即使比较本身实际上更慢,除了最天真的编译器之外,你不会注意到差别.

  • @AbhishekSingh`NOT`只是由其他指令(`je` vs.`jne`)制作的 (6认同)

Mas*_*oud 15

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


小智 14

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

这可能是非常不寻常的,因为编译器可以解决它,使它无关紧要.

  • 如果周期有差异。1) 无法检测到。2)任何称职的编译器都已经在不改变代码含义的情况下从慢速形式转换为更快形式。因此植入的结果指令将是相同的。 (2认同)

Mar*_*oth 11

TL; DR回答

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

完整答案

其他的答案都集中在x86的架构,我不知道ARM架构(其中的例子汇编好像是)不够好,特别是对生成的代码进行评论,但是这是一个示例微优化非常架构特定的,并且可能是一种反优化,因为它是一种优化.

因此,我建议这种微优化货物崇拜编程的一个例子,而不是最好的软件工程实践.

可能有一些架构,这是一个优化,但我知道至少有一个架构,反之亦然.令人尊敬的Transputer架构只有等于大于或等于的机器代码指令,因此必须从这些原语构建所有比较.

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

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


shi*_*kou 6

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

  • 他的意思是,如果你正在比较两种变量类型.当然,如果你为一个循环或其他东西设置值,这是微不足道的.但是如果你有x <= y,并且y是未知的,那么将它"优化"到x <y + 1会慢一些 (5认同)

Pet*_*des 5

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

对于 ARM 来说,能够编码为立即数取决于能够将狭窄字段旋转到字中的任何位置。所以cmp r0, #0x00f000可以编码,而cmp r0, #0x00efff不能编码。因此,与编译时常量进行比较的“缩小规则”并不总是适用于 ARM。cmp对于和之类的指令,AArch64 要么移位 12,要么不移位,而不是任意旋转cmn,这与 32 位 ARM 和 Thumb 模式不同。


一般情况下 < 与 <=,包括运行时变量条件

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

但这个问题是关于 C++ 运算符,即优化器的输入。 通常情况下,它们的效率是相同的。书中的建议听起来完全是假的,因为编译器总是可以转换他们在 asm 中实现的比较。但至少有一个例外,使用<=可能会意外地创建编译器无法优化的东西。

作为循环条件,在某些情况下 与<=本质上的不同<,当它阻止编译器证明循环不是无限时。 这会产生很大的影响,禁用自动矢量化。

与有符号溢出 (UB) 不同,无符号溢出被明确定义为以 2 为基数环绕。带符号循环计数器通常不会受到这种情况的影响,因为编译器基于带符号溢出 UB 进行优化,不会发生:++i <= size最终总是会变为 false。(每个 C 程序员都应该了解未定义行为

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++)
        ...
Run Code Online (Sandbox Code Playgroud)

编译器只能以保留所有可能输入值(导致未定义行为的值除外)的 C++ 源代码的(定义的和合法可观察的)行为的方式进行优化。

(一个简单的i <= size也会产生问题,但我认为计算上限是一个更现实的例子,意外地为您不关心但编译器必须考虑的输入引入无限循环的可能性。)

在这种情况下,size=0导致upper_bound=UINT_MAX, 并且i <= UINT_MAX始终为真。所以这个循环对于 来说是无限的size=0,编译器必须尊重这一点,即使你作为程序员可能从来不打算传递 size=0 。如果编译器可以将此函数内联到调用者中,并证明 size=0 是不可能的,那就太好了,它可以像i < size.

如果循环内不需要 的实际值,则 Asmif(!size) skip the loop; do{...}while(--size);是一种通常有效的优化循环的方法(为什么循环总是编译成“do...while”样式(尾部跳转)?)。for( i<size )i

但 do{}while 不能是无限的:如果输入size==0,我们会得到 2^n 次迭代。(在 for 循环 C 中迭代所有无符号整数 使得可以表达对包括零在内的所有无符号整数的循环,但是如果没有进位标志,就像在 asm 中那样,这并不容易。)

由于循环计数器的环绕是可能的,现代编译器通常只是“放弃”,并且不那么积极地优化。

示例:1 到 n 的整数之和

使用 unsignedi <= 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;
}
Run Code Online (Sandbox Code Playgroud)

Godbolt 编译器浏览器上来自 clang7.0 和 gcc8.2 的 x86-64 asm

 # 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
Run Code Online (Sandbox Code Playgroud)

但对于天真的版本,我们只是从 clang 中得到一个愚蠢的循环。

unsigned sum_1_to_n_naive(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i<=n ; ++i)
        total += i;
    return total;
}
Run Code Online (Sandbox Code Playgroud)
# 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
Run Code Online (Sandbox Code Playgroud)

GCC 不使用封闭形式,因此循环条件的选择并不会真正损害它;它通过 SIMD 整数加法自动矢量化,i在 XMM 寄存器的元素中并行运行 4 个值。

# "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.
     
Run Code Online (Sandbox Code Playgroud)

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

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


归档时间:

查看次数:

116171 次

最近记录:

6 年,1 月 前