我们考虑以下程序,这只是定时循环:
#include <cstdlib>
std::size_t count(std::size_t n)
{
#ifdef VOLATILEVAR
volatile std::size_t i = 0;
#else
std::size_t i = 0;
#endif
while (i < n) {
#ifdef VOLATILEASM
asm volatile("": : :"memory");
#endif
++i;
}
return i;
}
int main(int argc, char* argv[])
{
return count(argc > 1 ? std::atoll(argv[1]) : 1);
}
出于可读性考虑,同时具有volatile变量和volatile asm的版本如下:
#include <cstdlib>
std::size_t count(std::size_t n)
{
volatile std::size_t i = 0;
while (i < n) {
asm volatile("": : :"memory");
++i;
}
return i;
}
int main(int argc, char* argv[])
{
return count(argc > 1 ? std::atoll(argv[1]) : 1);
}
在g++ 8
下使用g++ -Wall -Wextra -g -std=c++11 -O3 loop.cpp -o loop
进行编译的时间大致如下:
default: 0m0.001s
-DVOLATILEASM: 0m1.171s
-DVOLATILEVAR: 0m5.954s
-DVOLATILEVAR -DVOLATILEASM: 0m5.965s
我的问题是:为什么?默认版本是正常的,因为编译器已对循环进行了优化。但是我很难理解为什么-DVOLATILEVAR
比-DVOLATILEASM
长得多,因为两者都应强制循环运行。
Compiler explorer为count
提供以下-DVOLATILEASM
函数:
count(unsigned long):
mov rax, rdi
test rdi, rdi
je .L2
xor edx, edx
.L3:
add rdx, 1
cmp rax, rdx
jne .L3
.L2:
ret
并针对-DVOLATILEVAR
(以及合并的-DVOLATILEASM -DVOLATILEVAR
):
count(unsigned long):
mov QWORD PTR [rsp-8], 0
mov rax, QWORD PTR [rsp-8]
cmp rdi, rax
jbe .L2
.L3:
mov rax, QWORD PTR [rsp-8]
add rax, 1
mov QWORD PTR [rsp-8], rax
mov rax, QWORD PTR [rsp-8]
cmp rax, rdi
jb .L3
.L2:
mov rax, QWORD PTR [rsp-8]
ret
为什么会这样呢?为什么变量的volatile
限定条件会阻止编译器执行与使用asm volatile
的循环相同的循环?
答案 0 :(得分:3)
当您创建i
volatile
时,您告诉编译器它不知道的内容可以更改其值。这意味着每次使用它时都必须加载它的值,并且每次写入它时都必须存储它。如果i
不是volatile
,则编译器可以优化该同步。
答案 1 :(得分:1)
-DVOLATILEVAR
强制编译器将循环计数器保留在内存中,因此循环瓶颈在存储/重新加载(存储转发)的延迟,〜5个周期加上add
1个周期的延迟
每次对volatile int i
进行分配和从中进行读取都被认为是优化程序必须在内存中发生的程序的可观察到的副作用,而不仅仅是寄存器。这就是volatile
的意思。
还需要重新加载以进行比较,但这只是吞吐量问题,而不是延迟问题。 〜6个循环循环带有数据依赖性,这意味着您的CPU不会在任何吞吐量限制上成为瓶颈。
这与您从-O0
编译器输出中获得的结果相似,因此请查看我在Adding a redundant assignment speeds up code when compiled without optimization上的答案,以获取更多类似这样的循环以及x86存储转发的信息。
仅使用VOLATILEASM
,空的asm
模板(""
)必须运行正确的次数。为空时,它不会向循环添加任何指令,因此,您剩下一个2 uop add / cmp + jne循环,该循环可以在现代x86 CPU上以每个时钟1次迭代的速度运行。
重要的是,尽管存在编译器内存障碍,循环计数器仍可以保留在寄存器中。 "memory"
Clobber被视为对非内联函数的调用:它可以读取或修改它可能引用的任何对象,但不包括从未使用过的局部变量他们的地址escape the function。 (即,我们从未调用过sscanf("0", "%d", &i)
或posix_memalign(&i, 64, 1234)
。但是,如果这样做了,"memory"
屏障将不得不溢出/重新加载它,因为外部函数可以保存指向该对象的指针
即"memory"
障碍物只是对象的完整编译器屏障,这些对象可能在当前函数之外可见。这实际上只是一个问题,当您四处乱逛并查看编译器输出以查看哪些障碍做什么时,因为障碍仅对其他线程可能指向的变量的多线程正确性很重要。
顺便说一句,您的asm
语句已经隐式volatile
,因为它没有输出操作数。 (请参阅gcc手册中的Extended-Asm#Volatile。
您可以添加虚拟输出以使编译器可以优化掉非易失性asm
语句,但是不幸的是,gcc
在从其中删除了非易失性asm语句后仍然保持空循环。如果i
的地址已转义了该函数,则删除asm语句会完全在函数返回之前将循环变成对存储的单个比较跳转。我认为直接返回而不存储到该本地是合法的,因为没有正确的程序可以知道它在i
超出范围之前已经设法从另一个线程读取了i
。>
但是无论如何,这是我使用的来源。正如我所说,请注意,这里总是有一个asm
语句,并且我正在控制它是否为volatile
。
#include <stdlib.h>
#include <stdio.h>
#ifndef VOLATILEVAR // compile with -DVOLATILEVAR=volatile to apply that
#define VOLATILEVAR
#endif
#ifndef VOLATILEASM // Different from your def; yours drops the whole asm statement
#define VOLATILEASM
#endif
// note I ported this to also be valid C, but I didn't try -xc to compile as C.
size_t count(size_t n)
{
int dummy; // asm with no outputs is implicitly volatile
VOLATILEVAR size_t i = 0;
sscanf("0", "%zd", &i);
while (i < n) {
asm VOLATILEASM ("nop # operand = %0": "=r"(dummy) : :"memory");
++i;
}
return i;
}
编译(使用gcc4.9和更高版本的-O3,均未启用VOLATILE)到该奇怪的asm。 ( Godbolt compiler explorer with gcc and clang ):
# gcc8.1 -O3 with sscanf(.., &i) but non-volatile asm
# the asm nop doesn't appear anywhere, but gcc is making clunky code.
.L8:
mov rdx, rax # i, <retval>
.L3: # first iter entry point
lea rax, [rdx+1] # <retval>,
cmp rax, rbx # <retval>, n
jb .L8 #,
好工作,gcc。...gcc4.8 -O3
避免在循环内拉出额外的mov
:
# gcc4.8 -O3 with sscanf(.., &i) but non-volatile asm
.L3:
add rdx, 1 # i,
cmp rbx, rdx # n, i
ja .L3 #,
mov rax, rdx # i.0, i # outside the loop
无论如何,如果没有伪输出操作数或使用volatile
,gcc8.1会给我们:
# gcc8.1 with sscanf(&i) and asm volatile("nop" ::: "memory")
.L3:
nop # operand = eax # dummy
mov rax, QWORD PTR [rsp+8] # tmp96, i
add rax, 1 # <retval>,
mov QWORD PTR [rsp+8], rax # i, <retval>
cmp rax, rbx # <retval>, n
jb .L3 #,
因此,我们看到循环计数器的存储/重装相同,只是与volatile i
的区别在于cmp
不需要重装。
我使用nop
而不是注释,因为Godbolt默认情况下隐藏仅注释行,我希望看到它。对于gcc,它纯粹是文本替换:我们正在查看编译器的asm输出,其中将操作数替换为模板,然后将其发送到汇编器。对于clang来说,可能会有一些效果,因为asm必须有效(即实际上正确地组装了)。
如果我们注释掉scanf
并删除虚拟输出操作数,则会得到其中包含nop
的仅寄存器循环。但是请保留伪输出操作数,并且nop
不会出现在任何地方。