如果没有副作用,编译器/ JIT可以优化短路评估吗?

时间:2018-02-22 14:04:52

标签: c# micro-optimization short-circuiting

我有一个测试:

if(variable==SOME_CONSTANT || variable==OTHER_CONSTANT)

在这种情况下,在平台上进行第二次测试的分支需要更多的周期而不是简单地进行,优化器是否可以将||视为一个简单的|

3 个答案:

答案 0 :(得分:5)

  

在这种情况下,在第二次测试的分支比简单操作需要更多周期的平台上,是否允许优化器处理||作为一个简单的|?

是的,这是允许的,实际上C#编译器会在某些情况下在&&||上执行此优化,将它们缩减为&|。如您所知,评估右侧必须没有副作用。

有关生成优化的详细信息,请参阅编译器源代码。

当逻辑操作涉及提升到可为空的操作数时,编译器也将执行该优化。考虑例如

int? z = x + y;

其中x和y也是可以为空的;这将生成为

int? z;
int? temp1 = x;
int? temp2 = y;
z = temp1.HasValue & temp2.HasValue ? 
  new int?(temp1.GetValueOrDefault() + temp2.GetValueOrDefault()) :
  new int?();

请注意,它是&而不是&&。我知道调用HasValue是如此之快,以至于不需要额外的分支逻辑来避免它。

如果您对我如何编写可空算术优化器感兴趣,我在这里写了详细的解释:https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

答案 1 :(得分:3)

编译器允许优化短路比较到asm,这不是两个单独的测试&科。但有时它不盈利(特别是在x86上,比较寄存器需要多个指令),有时编译器会错过优化。

或者,如果编译器选择使用条件移动来创建无分支代码,则始终会评估这两个条件。 (当没有副作用时,这当然只是一种选择)。

一个特殊情况是范围检查:编译器可以将x > min && x < max(特别是当minmax编译时常量)转换为单个检查。这可以通过2条指令完成,而不是分别在每种条件下进行分支。如果输入较低,则减去范围的低端将换算为大的无符号数,因此减去+ unsigned-compare会给出范围检查。

范围检查优化很容易/众所周知(由编译器开发人员),所以我假设C#JIT和提前编译器也会这样做。

采用C示例(与C#具有相同的短路评估规则):

int foo(int x, int a, int b) {
    if (10 < x && x < 100) {
        return a;
    }
    return b;
}

编译(对于x86-64 Windows ABI使用gcc7.3 -O3on the Godbolt compiler explorer。您可以看到ICC,clang或MSVC的输出;或者ARM,MIPS等的gcc。 ):

foo(int, int, int):
    sub     ecx, 11        # x-11
    mov     eax, edx       # retval = a;
    cmp     ecx, 89
    cmovnb  eax, r8d       # retval = (x-11U) < 89U ? retval : b;
    ret

因此该函数是无分支的,使用cmov(条件mov)。 @HansPassant says .NET's compiler only tends to do this for assignment operations,所以如果你在C#中编写它,也许你只能得到asm 来源为retval = (10 < x && x < 100) ? a : b;

或者采用分支示例,我们将范围检查的相同优化得到sub,然后是无符号比较/分支而不是比较/ cmov。

int ext(void);
int bar(int x) {
    if (10 < x && x < 100) {
        return ext();
    }
    return 0;
}

  # gcc -O3
    sub     ecx, 11
    cmp     ecx, 88
    jbe     .L7             # jump if ((unsigned)x-11U) <= 88U
    xor     eax, eax        # return 0;
    ret
.L7:
    jmp     ext()           # tailcall ext()

IDK如果现有的C#实现以相同的方式进行此优化,但它对所有可能的输入都很容易且有效,所以它们应该。

Godbolt没有C#编译器;如果有一个方便的在线C#编译器向您显示asm,那么在那里尝试这些函数会很有趣。 (我认为它们是有效的C#语法以及有效的C和有效的C ++)。

其他情况

除了范围检查之外的某些情况可以有利于在多个条件下优化到单个分支或cmov。 x86无法非常有效地与寄存器进行比较(xor - 零/ cmp / setcc),但在某些情况下,您只需要0 /非零而不是0/1布尔值以后合并。 x86的OR指令设置标志,因此如果 寄存器非零,您可以or / jnz跳转。 (但请注意,在test reg,reg之前保存jcc只能保存代码大小;宏融合适用于test / jcc但不适用于/ jcc,所以或者/ test / jcc是相同的uops数as或/ jcc。但它保存了一个带有cmovcc或setcc的uop。)

如果分支完全预测,则两个cmp / jcc可能仍然最便宜(因为宏融合:cmp / jne是最近CPU上的单个uop) ,但如果没有,那么两个条件可以很好地预测,或者CMOV会更好。

int foo(int x, int a, int b) {
    if ((a-10) || (x!=5)) {
        return a;
    }
    return b;
}

On Godbolt with gcc7.3, clang5.0, ICC18, and MSVC CL19

gcc以明显的方式编译它,有2个分支和几个mov指令。 clang5.0发现改变它的机会:

    # compiled for the x86-64 System V ABI this time: args in edi=x, esi=a, edx=b
    mov     eax, esi
    xor     eax, 10
    xor     edi, 5
    or      edi, eax        # flags set from edi=(a^10) | (x^5)
    cmovne  edx, esi        # edx = (edi!=0) ? a : b
    mov     eax, edx        # return edx
    ret

如果你希望它们像这样发出代码,那么其他编译器需要一些手持。 (并且clang可以使用相同的帮助来实现它可以使用lea进行复制和减去,而不是在mov之前需要xor以避免破坏以后需要的输入。

int should_optimize_to(int x, int a, int b) {
    // x!=10 fools compilers into missing the optimization
    if ((a-10) | (x-5)) {
        return a;
    }
    return b;
}

gcc,clang,msvc和ICC都将此编译为基本相同的事情:

    # gcc7.3 -O3
    lea     eax, [rsi-10]      # eax = a-10
    sub     edi, 5             # x-=5
    or      eax, edi           # set flags
    mov     eax, edx
    cmovne  eax, esi
    ret

这比clang的代码更聪明:在mov创建指令级并行之前将cmov放到eax中。如果mov具有非零延迟,则该延迟可以与为cmov创建标志输入的延迟并行发生。

如果您需要这种优化,通常需要手动编译器

答案 2 :(得分:3)

是的,编译器可以进行优化。实际上,每种感兴趣的语言通常都有一个显式或隐式的“似乎”类型的子句,它允许在不需要特定规则的情况下允许这种不可观察的优化。这允许以非快捷方式实现检查,此外还有一系列更极端的优化,例如将多个条件合并为一个,完全取消检查,使用预测指令实现检查而不使用任何分支等

然而,另一方面,你提到的无条件执行第二次检查的具体优化在大多数常见平台上并不经常执行,因为在许多指令集上,分支方法是最快的,如果你认为它没有改变分支的可预测性。例如,在x86上,您可以使用cmp将变量与已知值进行比较(如示例所示),但“结果”最终会出现在EFLAGs寄存器中(其中只有一,建筑上)。在这两种比较结果之间,如何实现||?第二个比较将覆盖第一个设置的标志,因此你将被困在某个地方保存标志,然后进行第二次比较,然后以某种方式尝试“组合”标志,这样你就可以进行单一测试 1

事实是,忽略预测,条件分支通常几乎是免费的,特别是当编译器将其组织为“未被采用”时。例如,在x86上,您的条件可能看起来像两个cmp操作,每个操作后面紧跟if()块中的代码。因此,只需要两个分支指令与你必须跳过的箍,以减少它。更进一步 - 这些cmp和后续分支通常macro-fuse进入单个操作,其成本与单独的比较大致相同(并且需要一个周期)。有各种警告,但总体假设“分支超过第二次测试”将花费很多时间可能没有充分根据。

主要警告是分支预测。如果每个单独的子句都是不可预测的,但是整个条件是可预测的,那么将所有内容组合到一个分支中可能非常有利可图。例如,想象一下,在(variable==SOME_CONSTANT || variable==OTHER_CONSTANT) variable SOME_CONSTANT等于OTHER_CONSTANT 50%的时间,if 49%的时间。因此,variable==SOME_CONSTANT将占99%的时间,但第一次检查SOME_CONSTANT将是完全不可预测的:分支正好一半的时间!在这种情况下,即使以一定的代价组合支票也是一个好主意,因为错误预测是昂贵的。

现在在某些情况下,编译器可以通过检查的形式组合检查。彼得显示an example using a range-check like example in his answer,还有其他人。

这是一个有趣的一个我偶然发现你的OTHER_CONSTANT是2而void test(int a) { if (a == 2 || a == 4) { call(); } } 是4:

clang

iccgcc都将此实现为一系列两个检查和两个分支,但最近test(int, int): sub edi, 2 and edi, -3 je .L4 rep ret .L4: jmp call() 使用another trick

a

基本上它从cmov中减去2,然后检查是否设置了0b10以外的任何位。值2和4是该检查接受的唯一值。有趣的转变!对于可预测的输入,它并不比两个分支方法好多少,但对于不可预测的条款,但是可预测的最终结果情况,这将是一个巨大的胜利。

然而,这并不是无条件地执行这两项检查的情况:只是一个聪明的情况,能够将多个检查组合成更少,可能还有一些数学。所以我不知道它是否符合你的标准“是的,他们实际上在实践中做”的答案。也许编译器进行这种优化,但我还没有在x86上看到它。如果它存在,那么它可能只是由配置文件引导的优化触发,其中编译器知道各种子句的概率。

1 在快速||两个cmov实现&&的平台上可能不是一个糟糕的选择,{{1}}可以类似地实现。