繁体   English   中英

了解易失性asm与易失性变量

[英]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++ -Wall -Wextra -g -std=c++11 -O3 loop.cpp -o loopg++ 8下进行编译的时间大致如下:

  • default: 0m0.001s
  • -DVOLATILEASM: 0m1.171s
  • -DVOLATILEVAR: 0m5.954s
  • -DVOLATILEVAR -DVOLATILEASM: 0m5.965s

我的问题是:为什么呢? 默认版本是正常的,因为编译器已对循环进行了优化。 但是我很难理解为什么-DVOLATILEVAR-DVOLATILEASM更长,因为两者都应强制循环运行。

编译器资源管理器-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强制编译器将循环计数器保留在内存中,因此循环瓶颈会导致存储/重新加载(存储转发)的延迟, -DVOLATILEVAR个周期+ add 1个周期的延迟。

每次对volatile int i赋值和从volatile int i读取的赋值都被认为是优化程序必须在内存中发生的可观察到的副作用,而不仅仅是寄存器。 这就是volatile意思。

还需要重新加载以进行比较,但这只是吞吐量问题,而不是延迟问题。 〜6个循环循环带有数据依赖性,这意味着您的CPU不受任何吞吐量限制的瓶颈。

这与您从-O0编译器输出中获得的结果相似,因此请看一下我的回答: 添加编译时的冗余分配可加快代码的速度,而无需对诸如此类的更多循环以及x86存储转发进行优化。


仅使用VOLATILEASM ,空的asm模板( "" )必须运行正确的次数。 为空时,它不会向循环添加任何指令,因此您剩下一个2 uop add / cmp + jne循环,该循环可以在现代x86 CPU上以每个时钟1次迭代的速度运行。

至关重要的是,尽管存在编译器内存障碍,循环计数器仍可以保留在寄存器中。 "memory"破坏器被视为对非内联函数的调用 :它可以读取或修改它可能引用的任何对象,但不包括从未使用其地址转义过该函数的局部变量。 (即我们从未调用过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。 带有gcc和clang的Godbolt编译器资源管理器 ):

 # 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区别( volatile icmp不需要重载)。

我使用nop而不是仅添加注释,因为Godbolt默认情况下隐藏仅注释行,我希望看到它。 对于gcc,它纯粹是文本替换:我们正在查看编译器的asm输出,其中将操作数替换为模板,然后将其发送到汇编器。 对于clang来说,可能会有一些效果,因为asm必须有效(即实际上正确地组装了)。

如果我们注释掉scanf并删除伪输出操作数,则会得到其中只有nop的仅寄存器循环。 但是请保留伪输出操作数,并且nop不会出现在任何地方。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM