了解 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++ 8g++ -Wall -Wextra -g -std=c++11 -O3 loop.cpp -o loop 下的编译大致给出了以下时间安排:

我的问题是:这是为什么?默认版本是正常的,因为编译器优化了循环。但是我很难理解为什么 -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 不会出现在任何地方。