C#中的乱序执行

Out-of-order execution in C#

我有以下片段:

static long F(long a, long b, long c, long d) 
{
    return a + b + c + d;
}

生成:

<Program>$.<<Main>$>g__F|0_0(Int64, Int64, Int64, Int64)
    L0000: add rdx, rcx
    L0003: lea rax, [rdx+r8]
    L0007: add rax, r9
    L000a: ret

如果我从 this§ 乱序执行)手册中理解正确:上面的代码转换为 ((a + b) + c) + d。为了计算这个 CPU 必须等待第一个括号和第二个等等。在这里我们看到 LEA 在中间,这意味着它们不能并行执行(如果我理解正确的话)。那么笔者的建议是:

在“独立”对上写括号:

static long G(long a, long b, long c, long d) 
{
    return (a + b) + (c + d);
}

但这会生成相同的程序集:

<Program>$.<<Main>$>g__G|0_1(Int64, Int64, Int64, Int64)
    L0000: add rdx, rcx
    L0003: lea rax, [rdx+r8]
    L0007: add rax, r9
    L000a: ret

相比之下,这是 GCC (O2)C 生成的代码:

int64_t
f(int64_t a, int64_t b, int64_t c, int64_t d) {
        return a + b + c + d;
}

int64_t
g(int64_t a, int64_t b, int64_t c, int64_t d) {
        return (a + b) + (c + d);
}

这是输出:

f: 
        add     rcx, rdx        ; I guess -O2 did the job for me.
        add     rcx, r8         ; I guess -O2 did the job for me.
        lea     rax, [rcx+r9]
        ret
g:
        add     rcx, rdx
        add     r8, r9
        lea     rax, [rcx+r8]
        ret

问题

备注

整数加法是关联的。编译器可以利用这一点(“假设规则”),无论来源如何-级操作顺序。

(不幸的是,似乎大多数编译器在这方面做得不好,即使您巧妙地编写了源代码,情况也会变得更糟。)

asm 中没有整数溢出的副作用;即使在 MIPS 这样的目标上,add 会陷入有符号溢出,编译器也会使用 addu 而不会,因此他们可以进行优化。 (在 C 语言中,编译器 可以 假定源级操作顺序永远不会溢出,因为那将是未定义的行为。因此他们 可以 使用陷阱 add 在具有它的 ISA 上,用于在 C 抽象机中使用相同输入发生的计算。但即使 gcc -fwrapv 给出有符号整数溢出定义明确的 2 的补码环绕行为也是 not 默认情况下,编译器确实使用可能允许静默包装的指令,而不是陷阱。主要是因为他们不必关心任何给定操作是否针对出现在 C 抽象机中的值。 UB 并不意味着需要故障;-fsanitize=undefined 需要额外的代码才能实现。)

例如INT_MAX + INT_MIN + 1 可以计算为 INT_MAX + 1(溢出到 INT_MIN),然后 . + INT_MIN 在 2 的补码机上溢出回 0,或者按源代码顺序没有溢出。相同的最终结果,这就是从操作中逻辑上可见的所有结果。


具有乱序执行的 CPU 不会尝试重新关联指令,但是,它们遵循来自 asm / 机器代码的依赖关系图。

(一方面,这对于硬件来说太多了,无法即时考虑,另一方面,每个操作的 FLAGS 输出 确实 取决于您创建的临时文件,并且中断可能随时到达。因此,当所有旧指令完成时,需要在指令边界恢复正确的体系结构状态。这意味着编译器的工作是在 asm[=] 中公开 instruction-level parallelism 94=],不是为了让硬件使用数学来创建它。另请参阅 现代微处理器 90 分钟指南!this answer)


那么编译器是怎么做的呢?

最糟糕,搬起石头砸自己的脚/悲观地尝试进行这种源级优化,至少在这种情况下是这样。

  • C#:删除 ILP,即使它存在于源代码中;将 (a+b) + (c+d) 序列化为一个线性操作链; 3 个周期延迟。

  • clang12.0: 相同,序列化两个版本。

  • MSVC:相同,序列化两个版本。

  • GCC11.1 for signed int64_t:保留源操作顺序。这是一个长期存在的 GCC 未优化错误,它的优化器甚至出于某种原因避免引入带符号的溢出,就像它倒退到抽象机器中的 UB 在进行具体实现时创建的承诺/保证/优化机会在抽象机上运行 就好像 一样。虽然 GCC 确实 知道它可以自动矢量化 int 添加;它只是在标量表达式中重新排序,其中一些过于保守的检查将带符号的整数与浮点作为非关联。

  • GCC11.1 for uint64_t or with -fwrapv:视为关联并以相同方式编译 fg。使用大多数调整选项(包括其他 ISA,如 MIPS 或 PowerPC)进行序列化,但 -march=znver1 恰好创建了 ILP。 (这 意味着只有 AMD Zen 是超标量的,这意味着 GCC 有优化错误!)

  • ICC 2021.1.2:甚至在线性源版本 (f) 中创建 ILP,但使用 add/mov 而不是 LEA 作为最后一步。 :/

Godbolt 对于 clang/MSVC/ICC.

Godbolt 用于 GCC 签名/未签名或 -fwrapv.


理想情况是从两个独立的加法开始,然后组合成对。这三个加法之一应该使用 lea 来完成,以便将结果放入 RAX,但它可以是三个中的任何一个。在独立函数中,您可以销毁任何传入的参数传递寄存器,并且没有真正的理由避免覆盖其中两个而不是一个。

您只需要一个 LEA,因为 2 寄存器寻址模式使其指令比 ADD 更长。