为什么一个易失局部变量与一个易失参数在优化上有所不同,为什么优化器从后者生成一个无操作循环?

16 浏览
0 Comments

为什么一个易失局部变量与一个易失参数在优化上有所不同,为什么优化器从后者生成一个无操作循环?

背景

本文灵感来源于这个问题/答案以及后续的评论讨论:Is the definition of “volatile” this volatile, or is GCC having some standard compliancy problems?。根据其他人和我对应该发生的事情的理解,正如评论中讨论的那样,我已经将其提交给了GCC Bugzilla:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=71793,其他相关的回答仍然受欢迎。

此外,该线程后来引发了这个问题:Does accessing a declared non-volatile object through a volatile reference/pointer confer volatile rules upon said accesses?

简介

我知道volatile并不是大多数人认为的那样,而是一个实现定义的复杂问题。我当然不想在任何真实的代码中使用下面的构造。尽管如此,我对这些示例中发生的事情感到非常困惑,所以我真的很希望能够得到解释。

我的猜测是这可能是由于对标准高度微妙的解释,或者(更有可能)只是优化器使用的边际情况。无论哪种情况,尽管更加学术而非实际,我希望这被认为是有价值的分析,特别是考虑到volatile通常被误解的情况下。更多的数据点 - 或者更有可能的是,对其的否定观点 - 一定是有益的。

输入

给定以下代码:

#include 
void f(void *const p, std::size_t n)
{
    unsigned char *y = static_cast(p);
    volatile unsigned char const x = 42;
    // N.B. Yeah, const is weird, but it doesn't change anything
    while (n--) {
        *y++ = x;
    }
}
void g(void *const p, std::size_t n, volatile unsigned char const x)
{
    unsigned char *y = static_cast(p);
    while (n--) {
        *y++ = x;
    }
}
void h(void *const p, std::size_t n, volatile unsigned char const &x)
{
    unsigned char *y = static_cast(p);
    while (n--) {
        *y++ = x;
    }
}
int main(int, char **)
{
    int y[1000];
    f(&y, sizeof y);
    volatile unsigned char const x{99};
    g(&y, sizeof y, x);
    h(&y, sizeof y, x);
}

输出

gcc (Debian 4.9.2-10) 4.9.2 (Debian stable a.k.a. Jessie)使用命令行g++ -std=c++14 -O3 -S test.cpp编译main()时,会生成以下汇编代码。版本Debian 5.4.0-6(当前的unstable版本)生成了等效的代码,但我刚好先运行了旧版本,所以这里是旧版本的代码:

main:
.LFB3:
    .cfi_startproc
# f()
    movb    $42, -1(%rsp)
    movl    $4000, %eax
    .p2align 4,,10
    .p2align 3
.L21:
    subq    $1, %rax
    movzbl  -1(%rsp), %edx
    jne .L21
# x = 99
    movb    $99, -2(%rsp)
    movzbl  -2(%rsp), %eax
# g()
    movl    $4000, %eax
    .p2align 4,,10
    .p2align 3
.L22:
    subq    $1, %rax
    jne .L22
# h()
    movl    $4000, %eax
    .p2align 4,,10
    .p2align 3
.L23:
    subq    $1, %rax
    movzbl  -2(%rsp), %edx
    jne .L23
# return 0;
    xorl    %eax, %eax
    ret
    .cfi_endproc

分析

所有3个函数均被内联,并且分配volatile局部变量的两个函数都在堆栈上进行分配,这是相当明显的原因。但这是它们唯一共享的东西...

  • f()确保在每次迭代中从x读取,可能是由于它是volatile - 但是只是将结果转储到edx,可能是因为目标y没有声明为volatile并且从未被读取,这意味着对它的更改可以根据as-if规则取消。好吧,有道理。

    • 嗯,我的意思是...有点吧。就像,不是真的,因为volatile实际上是用于硬件寄存器的,而显然一个局部值不能是硬件寄存器 - 除非传递了它的地址...但是没有传递。看,对于volatile的局部值来说,并没有太多的意义。但是C++允许我们声明它们并尝试对它们做一些处理。因此,像往常一样感到困惑,我们继续前进。
  • g():什么鬼。通过将volatile源移入按值传递的参数中,它仍然只是另一个局部变量,GCC以某种方式决定它不是或更少地volatile,因此它不需要在每次迭代中读取它...但是它仍然执行循环,尽管它的主体现在什么也不做。
  • h():通过以引用形式接收传递的volatile,恢复了与f()相同的有效行为,因此循环执行volatile读取。

    • 仅仅这种情况对我来说实际上是有意义的,原因如上所述反对f()。具体来说:想象一下x是一个硬件寄存器,每次读取都会产生副作用。你不想跳过其中任何一个。

添加#define volatile /**/会使main()成为一个空操作,正如你所期望的那样。因此,即使在局部变量上存在,volatile确实也会产生一些影响...只是在g()的情况下,我完全不知道发生了什么。这到底是怎么回事?

问题

  • 为什么在函数内部声明的局部值会产生与按值传递的参数不同的结果,前者可以优化掉读取?两者都声明为volatile。两者都没有传递地址 - 也没有静态地址,排除了任何内联汇编的POKE操作 - 因此它们永远不能在函数之外进行修改。编译器可以看到它们都是常量,不需要重新读取,而volatile并不真实 -

    • 所以(A)在这些限制下,是否允许消除它们?(就好像它们没有被声明为volatile一样) -
    • (B)为什么只有一个被消除?有些volatile局部变量比其他的更volatile吗?
  • 抛开这个不一致性片刻:在读取被优化掉后,为什么编译器仍然生成循环?它什么也不做!为什么优化器不消除它,就像没有编写循环一样?

这是一个由于优化分析顺序等原因而产生的奇怪边缘情况吗?由于代码是一个愚蠢的思想实验,我不会因此指责GCC,但是确切地知道这一点将是很好的。(或者g()是人们多年来梦寐以求的手动计时循环吗?)如果我们得出结论认为这些都与任何标准无关,我会将其移动到他们的Bugzilla中,只是供他们参考。

当然,从实际角度来看,更重要的问题是,尽管我不希望这个问题掩盖了对编译器的热爱...以下这些中的哪一个(如果有的话)根据标准是定义良好/正确的?

0
0 Comments

在这段对话中,讨论了对于一个带有volatile修饰符的局部变量和参数,编译器为什么会产生不同的优化行为,以及为什么后者会生成一个无操作的循环。根据对话的内容,我们可以得出以下结论:

1. 对于局部变量f,编译器会消除非volatile的存储操作,但是不会消除加载操作,因为加载操作可能会有副作用,特别是当源位置是内存映射硬件寄存器时。

2. 对于参数x,它是通过寄存器(例如rdx)分配的,而不是在内存中的位置。从通用寄存器读取没有任何可观察的副作用,所以这个无用的读取操作被消除了。

3. 编译器不能为参数x分配内存,因为ABI不会特殊处理volatile参数(因为它们没有太多意义),而是将它们像非volatile参数一样传递到寄存器中。在内存中分配x会破坏ABI的函数调用顺序。

根据以上讨论,我们可以得出编译器对于volatile局部变量和参数的优化行为的原因和解决方法:

原因:编译器会消除对于volatile局部变量的非volatile存储操作,因为对于局部变量的加载操作可能会有副作用,但是对于参数,由于参数是通过寄存器传递的,读取寄存器中的值没有可观察的副作用,所以无用的读取操作被消除了。

解决方法:由于ABI不会特殊处理volatile参数,编译器不能为参数分配内存。如果函数包含setjmp或者让局部volatile变量的指针逃逸出函数,编译器应该将值复制到内存中,并且不会消除对这个内存位置的访问。在其他情况下,编译器可以消除volatile读写操作,因为它可以证明没有人(甚至是内存映射硬件、信号处理程序或其他异步事件)可以真正观察到这个访问。

虽然volatile修饰符的使用是有争议的,但是根据对C/C++标准的解读,编译器的优化行为是符合规范的。编译器不能为参数分配内存,因为这会破坏ABI的函数调用顺序。对于局部变量,编译器可以消除非volatile的存储操作,但是不能消除加载操作,以防止副作用的发生。

0