如果没有副作用,compiler/JIT 能否优化短路评估?

Can the compiler/JIT optimize away short-circuit evaluation if there are no side-effects?

我有一个测试:

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

在这种情况下,在第二个测试的分支将比简单地执行它需要更多周期的平台上,是否允许优化器将 || 视为简单的 |

编译器允许将short-circuit比较优化为不是两个独立测试和分支的asm。但有时它无利可图(尤其是在 compare-into-register 需要多条指令的 x86 上),有时编译器会错过优化。

或者,如果编译器选择使用 conditional-move 生成无分支代码,则始终评估这两个条件。 (当然这只是没有side-effects时的一个选项)。

一个特例是 range-checks:编译器可以将 x > min && x < max(尤其是当 minmax 是 compile-time 常量时)转换为单个检查。这可以用 2 条指令完成,而不是分别对每个条件进行分支。如果输入较低,减去范围的低端将换行到一个大的无符号数,因此减法 + unsigned-compare 给你一个范围检查。

range-check 优化很容易/well-known(编译器开发人员),所以我假设 C# JIT 和 ahead-of-time 编译器也能做到。

以C为例(与C#有相同的short-circuit求值规则):

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

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

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(条件移动)。 ,所以如果你用 C# 编写它,也许你只会得到它 来源为 retval = (10 < x && x < 100) ? a : b;.


或者举一个分支示例,我们对范围检查进行了相同的优化 sub 然后是无符号 compare/branch 而不是 compare/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++)。


其他情况

除了 range-checks 之外的某些情况,在多个条件下优化为单个分​​支或 cmov 可能是有利可图的。 x86 无法非常有效地比较寄存器 (xor-zero / cmp / setcc),但在某些情况下你只需要 0 / non-zero 而不是 0 / 1 布尔值稍后合并。 x86 的 OR 指令设置标志,因此如果 either 寄存器是 non-zero,您可以 or / jnz 跳转。 (但请注意,在 jcc 之前保存 test reg,reg 只会保存 code-size;macro-fusion 适用于 test/jcc 但不适用于 or/jcc,因此或/test/jcc 与 or/jcc 的 uops 数量相同。不过它用 cmovcc 或 setcc 保存了一个 uop。)

如果分支预测完美,两个 cmp / jcc 可能仍然是最便宜的(因为 macro-fusion: cmp / jne 是单个 uop在最近的 CPU 上),但如果不是,那么两个条件一起可能会更好地预测,或者使用 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

其他编译器需要一些 hand-holding 如果您希望它们发出这样的代码。 (并且 clang 可以使用相同的帮助来实现它可以使用 lea 到 copy-and-subtract 而不是在 xor 之前需要 mov 以避免破坏稍后需要的输入)。

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 的代码更聪明:在 cmov 创建 instruction-level 并行性之前将 mov 放入 eax。如果 mov 具有 non-zero 延迟,则该延迟可能与为 cmov.

创建标志输入的延迟同时发生

如果你想要这种优化,你通常必须hand-hold编译器

是的,编译器可以进行优化。事实上,每一种感兴趣的语言通常都有一个显式或隐式的 "as if" 类型子句,使得这种 not-observable 优化被允许而不需要特定的规则。这允许以 non-shortcut 方式实施检查,此外还有一系列更极端的优化,例如将多个条件合并为一个,完全消除检查,使用谓词指令在没有任何分支的情况下实施检查等

然而,另一方面,您提到的无条件执行第二次检查的特定优化在大多数常见平台上并不经常执行,因为在许多指令集上,分支方法是最快的,如果您假设它不会改变分支的可预测性。例如,在 x86 上,您可以使用 cmp 将变量与已知值进行比较(如您的示例所示),但 "result" 最终出现在 EFLAGs 寄存器中(其中有在架构上只有一个)。在这种情况下,您如何在两个比较结果之间实现 || ?第二次比较将覆盖第一次设置的标志,因此您将无法在某处保存标志,然后进行第二次比较,然后以某种方式尝试 "combine" 标志,以便您可以进行单一测试1.

事实是,忽略预测,条件分支通常几乎是空闲的,尤其是当编译器将其组织为 "not taken" 时。例如,在 x86 上,您的条件可能看起来像两个 cmp 操作,每个操作后紧接着跳转 if() 块中的代码。因此,只有两条分支指令与您必须跳过的箍才能将其减少到一个。更进一步 - 这些 cmp 和后续分支通常 macro-fuse 成为一个单独的操作,其成本与单独的比较大致相同(并且需要一个周期)。有各种注意事项,但 "branching over the second test" 将花费很多时间的总体假设可能没有充分根据。

main 警告是分支预测。如果每个单独的条款都不可预测,但整个条件是可预测的,那么将所有内容组合到一个分支中可能会非常有利可图。例如,想象一下,在您的 (variable==SOME_CONSTANT || variable==OTHER_CONSTANT) 中,variable 50% 的时间等于 SOME_CONSTANT,而 OTHER_CONSTANT 49% 的时间。因此,if 将在 99% 的时间内被执行,但第一次检查 variable==SOME_CONSTANT 将完全不可预测:恰好有一半时间分支!在这种情况下,将检查结合起来是个好主意,即使要付出一定的代价,因为错误预测的代价很高。

现在在某些情况下,编译器可以 检查合并在一起,仅仅是因为检查的形式。彼得显示 ,还有其他人。

这是我偶然发现的一个有趣的例子,你的 SOME_CONSTANT 是 2 而 OTHER_CONSTANT 是 4:

void test(int a) {
    if (a == 2 || a == 4) {
        call();
    }
}

clangicc 都将此实现为一系列的两次检查和两个分支,但最近 gcc 使用 another trick:

test(int, int):
  sub edi, 2
  and edi, -3
  je .L4
  rep ret
.L4:
  jmp call()

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

然而,这实际上并不是无条件地进行两项检查的情况:这只是一个巧妙的情况,可以将多项检查组合成更少的检查,可能需要一些数学知识。所以我不知道它是否符合您 "yes, they actually do in practice" 答案的标准。也许编译器 do 进行了这种优化,但我在 x86 上没有看到它。如果它在那里存在,它可能只会被 profile-guided 优化触发,编译器知道各种子句的概率。


1 在具有快速 cmov 两个 cmovs 的平台上实现 || 可能不是一个糟糕的选择,并且 && 可以实现同样。

In this circumstances, on a platform where branching over the second test would take more cycles than simply doing it, would the optimizer be allowed to treat the || as a simple |?

是的,这是允许的,事实上 C# 编译器在某些情况下会在 &&|| 上执行此优化,将它们减少到 &|.正如您所注意到的,评估右侧一定没有副作用。

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

当逻辑运算涉及 lifted-to-nullable 个操作数时,编译器也会执行该优化。考虑例如

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/