了解 volatile asm 与 volatile 变量
Understanding volatile asm vs volatile variable
我们考虑以下程序,它只是对循环进行计时:
#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 为 -DVOLATILEASM
提供以下 count
函数:
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
相同的循环?
当您设置 i
volatile
时,您告诉编译器它不知道的某些内容可以更改其值。这意味着每次使用它时它都被迫加载它的值,并且每次写入它时都必须存储它。当 i
不是 volatile
时,编译器可以优化该同步。
-DVOLATILEVAR
强制编译器将循环计数器保存在内存中,因此 store/reload(存储转发)延迟的循环瓶颈,~5 个周期 + [=15 的延迟=] 1 个周期。
每个对 volatile int i
的赋值和读取都被认为是程序的一个可观察到的副作用,优化器必须在 内存 中实现,而不仅仅是一个寄存器.这就是 volatile
的意思。
还有一个用于比较的重新加载,但这只是吞吐量问题,而不是延迟问题。 ~6 循环循环携带数据依赖性意味着您的 CPU 不会在任何吞吐量限制上出现瓶颈。
这类似于您从 -O0
编译器输出中得到的结果,因此请查看我在 上的回答,了解更多关于此类循环和 x86 存储转发的信息。
只有 VOLATILEASM
,空的 asm
模板 (""
) 必须 运行 正确的次数。由于是空的,它不会向循环中添加任何指令,因此您只剩下一个 2-uop add / cmp+jne 循环,它可以 运行 在现代 x86 CPU 上每个时钟迭代 1 次s.
重要的是,循环计数器可以保留在寄存器中,尽管存在编译器内存屏障。 "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 语句会将循环变成单个比较跳转存储,就在函数 returns 之前。我认为简单地 return 而不存储到那个本地是合法的,因为没有正确的程序可以知道它在 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 #,
所以我们看到相同的store/reload循环计数器,与volatile i
的唯一区别是cmp
不需要重新加载它。
我使用了 nop
而不仅仅是评论,因为 Godbolt 默认隐藏评论行,我想看看它。对于 gcc,它纯粹是文本替换:我们正在查看编译器的 asm 输出,其中操作数在发送到 assembler 之前被替换到模板中。对于 clang,可能会有一些影响,因为 asm 必须有效(即实际上 assemble 正确)。
如果我们注释掉 scanf
并删除虚拟输出操作数,我们将得到一个包含 nop
的仅寄存器循环。但是保留虚拟输出操作数并且 nop
不会出现在任何地方。
我们考虑以下程序,它只是对循环进行计时:
#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 为 -DVOLATILEASM
提供以下 count
函数:
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
相同的循环?
当您设置 i
volatile
时,您告诉编译器它不知道的某些内容可以更改其值。这意味着每次使用它时它都被迫加载它的值,并且每次写入它时都必须存储它。当 i
不是 volatile
时,编译器可以优化该同步。
-DVOLATILEVAR
强制编译器将循环计数器保存在内存中,因此 store/reload(存储转发)延迟的循环瓶颈,~5 个周期 + [=15 的延迟=] 1 个周期。
每个对 volatile int i
的赋值和读取都被认为是程序的一个可观察到的副作用,优化器必须在 内存 中实现,而不仅仅是一个寄存器.这就是 volatile
的意思。
还有一个用于比较的重新加载,但这只是吞吐量问题,而不是延迟问题。 ~6 循环循环携带数据依赖性意味着您的 CPU 不会在任何吞吐量限制上出现瓶颈。
这类似于您从 -O0
编译器输出中得到的结果,因此请查看我在
只有 VOLATILEASM
,空的 asm
模板 (""
) 必须 运行 正确的次数。由于是空的,它不会向循环中添加任何指令,因此您只剩下一个 2-uop add / cmp+jne 循环,它可以 运行 在现代 x86 CPU 上每个时钟迭代 1 次s.
重要的是,循环计数器可以保留在寄存器中,尽管存在编译器内存屏障。 "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 语句会将循环变成单个比较跳转存储,就在函数 returns 之前。我认为简单地 return 而不存储到那个本地是合法的,因为没有正确的程序可以知道它在 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 #,
所以我们看到相同的store/reload循环计数器,与volatile i
的唯一区别是cmp
不需要重新加载它。
我使用了 nop
而不仅仅是评论,因为 Godbolt 默认隐藏评论行,我想看看它。对于 gcc,它纯粹是文本替换:我们正在查看编译器的 asm 输出,其中操作数在发送到 assembler 之前被替换到模板中。对于 clang,可能会有一些影响,因为 asm 必须有效(即实际上 assemble 正确)。
如果我们注释掉 scanf
并删除虚拟输出操作数,我们将得到一个包含 nop
的仅寄存器循环。但是保留虚拟输出操作数并且 nop
不会出现在任何地方。