相关问题:如何阻止编译器将一个微小的重复计算提升出循环
我在任何地方都找不到这个 - 所以在提出问题 11 年后添加我自己的答案;)。
对变量使用 volatile 不是您想要的。这将导致编译器每次都从 RAM 中加载和存储这些变量(假设必须保留该变量的副作用:aka - 对 I/O 寄存器有益)。当您进行基准标记时,您对测量从记忆中获取某些内容或将其写在那里需要多长时间不感兴趣。通常你只希望你的变量在 CPU 寄存器中。
volatile
如果您在没有得到优化的循环之外分配给它一次(例如对数组求和),则可以使用它作为打印结果的替代方法。(就像问题中的长期运行函数一样)。但不在一个小循环内;这将引入存储/重新加载指令和存储转发延迟。
我认为提交编译器不将基准代码优化到地狱的唯一方法是使用asm
. 这使您可以欺骗编译器,使其认为它对您的变量内容或用法一无所知,因此它必须每次都做所有事情,就像您的循环要求它做的那样频繁。
例如,如果我想对m & -m
m 的位置进行基准测试uint64_t
,我可以尝试:
uint64_t const m = 0x0000080e70100000UL;
for (int i = 0; i < loopsize; ++i)
{
uint64_t result = m & -m;
}
编译器显然会说:我什至不打算计算它;因为你没有使用结果。又名,它实际上会这样做:
for (int i = 0; i < loopsize; ++i)
{
}
然后你可以尝试:
uint64_t const m = 0x0000080e70100000UL;
static uint64_t volatile result;
for (int i = 0; i < loopsize; ++i)
{
result = m & -m;
}
编译器说,好的 - 所以你希望我每次都写结果并做
uint64_t const m = 0x0000080e70100000UL;
uint64_t tmp = m & -m;
static uint64_t volatile result;
for (int i = 0; i < loopsize; ++i)
{
result = tmp;
}
result
loopsize
正如您所要求的,花费大量时间写入时间的内存地址。
最后,您也可以使m
volatile,但结果在汇编中看起来像这样:
507b: ba e8 03 00 00 mov $0x3e8,%edx
# top of loop
5080: 48 8b 05 89 ef 20 00 mov 0x20ef89(%rip),%rax # 214010 <m_test>
5087: 48 8b 0d 82 ef 20 00 mov 0x20ef82(%rip),%rcx # 214010 <m_test>
508e: 48 f7 d8 neg %rax
5091: 48 21 c8 and %rcx,%rax
5094: 48 89 44 24 28 mov %rax,0x28(%rsp)
5099: 83 ea 01 sub $0x1,%edx
509c: 75 e2 jne 5080 <main+0x120>
除了请求的寄存器计算之外,从内存读取两次并写入一次。
因此,正确的方法是:
for (int i = 0; i < loopsize; ++i)
{
uint64_t result = m & -m;
asm volatile ("" : "+r" (m) : "r" (result));
}
这会产生汇编代码(来自 Godbolt 编译器资源管理器上的 gcc8.2):
# gcc8.2 -O3 -fverbose-asm
movabsq $8858102661120, %rax #, m
movl $1000, %ecx #, ivtmp_9 # induction variable tmp_9
.L2:
mov %rax, %rdx # m, tmp91
neg %rdx # tmp91
and %rax, %rdx # m, result
# asm statement here, m=%rax result=%rdx
subl $1, %ecx #, ivtmp_9
jne .L2
ret
在循环内完全执行三个请求的汇编指令,加上循环开销的 sub 和 jne。
这里的诀窍是通过使用asm volatile
1并告诉编译器
"r"
输入操作数:它使用 的值result
作为输入,因此编译器必须在寄存器中实现它。
"+r"
输入/输出操作数:m
保留在同一个寄存器中,但(可能)被修改。
volatile
:它有一些神秘的副作用和/或不是输入的纯函数;编译器必须与源代码一样多次执行它。这迫使编译器将您的测试片段单独留在循环中。请参阅gcc 手册的 Extended Asm#Volatile部分。
脚注 1:volatile
这里需要 ,否则编译器会将其变成一个空循环。非易失性 asm(具有任何输出操作数)被认为是其输入的纯函数,如果结果未使用,则可以优化掉。或 CSEd 如果多次使用相同的输入,则仅运行一次。
下面的一切都不是我的——我不一定同意。——卡罗伍德
如果您使用过asm volatile ("" : "=r" (m) : "r" (result));
(带有"=r"
只写输出),编译器可能会为m
and选择相同的寄存器result
,从而创建一个循环携带的依赖链来测试计算的延迟,而不是吞吐量。
从那里,你会得到这个asm:
5077: ba e8 03 00 00 mov $0x3e8,%edx
507c: 0f 1f 40 00 nopl 0x0(%rax) # alignment padding
# top of loop
5080: 48 89 e8 mov %rbp,%rax # copy m
5083: 48 f7 d8 neg %rax # -m
5086: 48 21 c5 and %rax,%rbp # m &= -m instead of using the tmp as the destination.
5089: 83 ea 01 sub $0x1,%edx
508c: 75 f2 jne 5080 <main+0x120>
这将每 2 或 3 个周期运行 1 次迭代(取决于您的 CPU 是否具有 mov-elimination)。没有循环携带依赖项的版本可以在 Haswell 及更高版本和 Ryzen 上以每个时钟周期运行 1 次。这些 CPU 的 ALU 吞吐量可以在每个时钟周期运行至少 4 微秒。
这个 asm 对应于 C++,如下所示:
for (int i = 0; i < loopsize; ++i)
{
m = m & -m;
}
通过用只写输出约束误导编译器,我们创建了看起来不像源代码的 asm(看起来它每次迭代都从一个常量计算一个新结果,而不是使用结果作为下一次迭代的输入迭代..)
您可能想要对延迟进行微基准测试,以便您可以更轻松地检测编译的好处-mbmi
或-march=haswell
让编译器在一条指令中使用blsi %rax, %rax
和计算。m &= -m;
但是,如果 C++ 源代码与 asm 具有相同的依赖项,则更容易跟踪您正在执行的操作,而不是欺骗编译器引入新的依赖项。