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
问题
- 我对手册的理解是否正确? 2个
ADD
应该一起来吗(中间没有 LEA
)?如果是,我如何提示 C#
编译器不要忽略我的括号?
备注
整数加法是关联的。编译器可以利用这一点(“假设规则”),无论来源如何-级操作顺序。
(不幸的是,似乎大多数编译器在这方面做得不好,即使您巧妙地编写了源代码,情况也会变得更糟。)
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
:视为关联并以相同方式编译 f
和 g
。使用大多数调整选项(包括其他 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 更长。
我有以下片段:
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
问题
- 我对手册的理解是否正确? 2个
ADD
应该一起来吗(中间没有LEA
)?如果是,我如何提示C#
编译器不要忽略我的括号?
备注
整数加法是关联的。编译器可以利用这一点(“假设规则”),无论来源如何-级操作顺序。
(不幸的是,似乎大多数编译器在这方面做得不好,即使您巧妙地编写了源代码,情况也会变得更糟。)
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
:视为关联并以相同方式编译f
和g
。使用大多数调整选项(包括其他 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 更长。