"Observable behaviour"和编译器自由消除/转换片段 C++ 代码

"Observable behaviour" and compiler freedom to eliminate/transform pieces c++ code

本文关键字:转换 片段 代码 C++ Observable behaviour 编译器 自由      更新时间:2023-10-16

看完这个讨论后,我意识到我几乎完全误解了这件事。

由于c++抽象机的描述不够严格(例如,与JVM规范相比),如果不可能得到一个精确的答案,我宁愿得到关于合理的"好"(非恶意)实现应该遵循的规则的非正式澄清。

解决实现自由的标准第1.9部分的关键概念是所谓的假设规则:

实现可以自由地忽略这方面的任何要求只要达到标准的结果就好像达到了要求一样服从,只要能从可观察到的行为中确定程序。

根据标准(我引用n3092),术语"可观察行为"是指以下内容:

-对易失性对象的访问严格按照抽象机器的规则。

-在程序终止时,所有写入文件的数据都应该是与程序执行的可能结果之一相同根据抽象语义会产生。

-交互设备的输入和输出动态应采取以实际交付提示输出的方式放置在程序等待输入之前。什么构成了互动设备是实现定义的。

因此,粗略地说,应该保留易失性访问操作和io操作的顺序和操作数;实现可以对程序进行任意更改,以保持这些不变性(与抽象c++机器的某些允许行为相比)

  1. 期望非恶意实现将io操作处理得足够宽(例如,来自用户代码的任何系统调用都被视为此类操作)是否合理?(例如,如果RAII包装器不包含易失性,则编译器不会丢弃RAII互斥锁/解锁)

  2. "行为观察"应该从用户定义的c++程序级别深入到库/系统调用中?当然,这个问题只是关于从用户的角度来看,不打算具有io/volatile访问的库调用(例如,作为new/delete操作),但可能(并且通常)访问库/system 实现中的volatile或io。编译器应该从用户的角度(并考虑不可观察的副作用)还是从"库"的角度(并考虑可观察的副作用)来处理这些调用?

  3. 如果我需要防止一些代码被编译器消除,在任何看起来可疑的情况下,不询问上述所有问题并简单地添加(可能是假的)易失性访问操作(包装易失性方法所需的操作并在我自己类的易失性实例上调用它们)是一个好的做法吗?

  4. 或者我完全错了,编译器不允许删除任何c++代码,除了标准明确提到的情况(作为副本消除)

重要的一点是,编译器必须能够证明代码没有副作用,然后才能删除它(或确定它有哪些副作用,并用一些等效的代码片段替换它)。通常,由于单独的编译模型,这意味着编译器在某种程度上限制了哪些库调用具有可观察的行为,并且可以消除

至于深度,取决于库的实现。在gcc中,C标准库使用编译器属性通知编译器潜在的副作用(或没有副作用)。例如,strlen被标记为属性,该属性允许编译器转换以下代码:

char p[] = "Hi theren";
for ( int i = 0; i < strlen(p); ++i ) std::cout << p[i];

char * p = get_string();
int __length = strlen(p);
for ( int i = 0; i < __length; ++i ) std::cout << p[i];

但是如果没有纯粹的属性,编译器就无法知道函数是否有副作用(除非它内联了它,并且在函数中看到),并且无法执行上述优化。

也就是说,一般来说,编译器不会删除代码,除非能够证明没有副作用,即不会影响程序的结果。请注意,这不仅与volatile和io相关,因为任何变量更改都可能在以后的时间具有可观察行为

至于问题3,编译器只会删除你的代码,如果程序的行为完全像代码一样存在(复制省略是一个异常),所以你甚至不应该关心编译器是否删除它。关于问题4,as-if规则是成立的:如果编译器所做的隐式重构的结果产生相同的结果,那么它可以自由地执行更改。考虑:

unsigned int fact = 1;
for ( unsigned int i = 1; i < 5; ++i ) fact *= i;

编译器可以自由地将该代码替换为:

unsigned int fact = 120; // I think the math is correct... imagine it is

循环结束了,但是行为是一样的:每个循环交互不影响程序的结果,并且变量在循环结束时具有正确的值,也就是说,如果稍后在一些可观察的操作中使用它,结果将是,就好像循环已经执行。

不要过于担心可观察行为as-if规则的意思,它们基本上意味着编译器必须产生你在代码中编程的输出,即使它可以通过不同的路径自由地得到那个结果。

编辑

@Konrad提出了一个非常好的观点,关于我与strlen的初始示例:编译器如何知道 strlen调用可以被省略?答案是,在最初的例子中它不能,因此它不能忽略调用。没有任何东西告诉编译器,从get_string()函数返回的指针不指向正在其他地方修改的内存。我已经更正了使用本地数组的示例。

在修改后的示例中,数组是本地的,编译器可以验证没有其他指针指向相同的内存。strlen接受一个const指针,所以它承诺不修改包含的内存,函数是纯的,所以它承诺不修改任何其他状态。数组不会在循环构造中被修改,编译器收集了所有这些信息,可以确定对strlen的一次调用就足够了。如果没有说明符,编译器就无法知道strlen在不同调用中的结果是否不同,因此必须调用它。

由标准定义的抽象机器将给定一个特定的输入,产生一组特定的输出。总的来说,就是这样保证的是,对于特定的输入,编译后的代码将产生一个可能的特定输出。魔鬼在里面然而,细节,有一些要点要记住。

其中最重要的可能是,如果程序具有未定义的行为,编译器可以做任何事情。所有的赌注就消失不见了。编译器可以并且确实使用潜在的未定义行为优化:例如,如果代码包含类似*p = (*q) ++的内容,编译器可以推断出pq不是相同的别名变量。

未指定的行为可以产生类似的效果:实际的行为可以取决于优化的水平。它所需要的只是实际输出对应于摘要的一种可能输出机器。

关于volatile,标准确实说访问易失性对象是可观察的行为,但是它留下了"访问"到实现为止。在实践中,你不能真正地数数这些天volatile的内容很多;对易失性对象的实际访问可以在外部观察者看来,它们出现的顺序与它们出现的顺序不同程序。(这可以说违反了至少是标准的。然而,实际情况是大多数现代编译器,运行在现代架构上。

大多数实现将所有系统调用视为"io"。与当然是关于互斥体的:就c++ 03而言,只要你启动了第二个线程,你得到了未定义的行为(来自c++)Posix或Windows确实定义了它),而在c++ 11中,同步原语是语言的一部分,并约束可能输出的集合。(当然,编译器可以消除(如果它能证明同步是不必要的,那么它就会同步)

newdelete操作符是特例。它们可以是由用户定义的版本取代,这些用户定义的版本可以显然有可观察的行为。编译器只能在以下情况下删除它们它有办法知道它们没有被取代,或者替代物没有可观察到的行为。在大多数系统中,替换是在链接时定义的,在编译器完成它的工作,因此不允许更改。

关于你的第三个问题:我想你是从角度不对。编译器不会消除代码,没有程序中的特定语句绑定到的特定块代码。你的程序(完整的程序)定义了一个特定的语义,编译器必须做一些产生具有这些语义的可执行程序。最明显的解决方案对于编译器,编写器是将每个语句分开处理,并且为它生成代码,但这是编译器编写者的观点,不是你的。输入源代码,输出可执行文件;但许多Of语句不会产生任何代码,即使是那些产生代码的语句,这不一定是一对一的关系。从这个意义上说,防止代码删除的思想不会让感觉:你的程序有一个语义,由标准指定,等等你可以要求的(你应该感兴趣的)是最终的可执行文件具有这些语义。(你的第四点也很相似:编译器不会移除”

我不能说编译器应该做什么,但这里是一些编译器实际上做的

#include <array>
int main()
{
    std::array<int, 5> a;
    for(size_t p = 0; p<5; ++p)
        a[p] = 2*p;
}

GCC 4.5.2的汇编输出:

main:
     xorl    %eax, %eax
     ret

用vector替换array表示new/delete不会被消除:

#include <vector>
int main()
{
    std::vector<int> a(5);
    for(size_t p = 0; p<5; ++p)
        a[p] = 2*p;
}

GCC 4.5.2的汇编输出:

main:
    subq    $8, %rsp
    movl    $20, %edi
    call    _Znwm          # operator new(unsigned long)
    movl    $0, (%rax)
    movl    $2, 4(%rax)
    movq    %rax, %rdi
    movl    $4, 8(%rax)
    movl    $6, 12(%rax)
    movl    $8, 16(%rax)
    call    _ZdlPv         # operator delete(void*)
    xorl    %eax, %eax
    addq    $8, %rsp
    ret

我最好的猜测是,如果一个函数调用的实现对编译器不可用,它必须将其视为可能具有可观察到的副作用。

期望非恶意实现处理io操作足够宽是否合理

是的。假设副作用是默认的。除了默认情况,编译器必须证明一些东西(除了消除复制)。

2。"行为观察"应该从用户定义的c++程序级别深入到库/系统调用中?

尽可能深。使用当前的标准c++,编译器不能看到库的含义static library,即调用目标函数在一些"。A -"或"。Lib文件"调用,因此假定副作用。

使用具有多个目标文件的传统编译模型,编译器甚至无法查看外部调用的背后。优化跨但是,编译单元可以在链接时完成。

顺便说一句,一些编译器有一个扩展来告诉它纯函数。来自gcc文档:

许多函数除了返回值之外没有任何作用,它们的返回值仅取决于形参和/或全局变量。这样的函数可以像算术运算符一样进行公共子表达式消除和循环优化。这些函数应该用pure属性声明。例如,

      int square (int) __attribute__ ((pure));

表示假设函数square的调用次数比程序规定的要少。纯函数的一些常见示例是strlen或memcmp。有趣的非纯函数是具有无限循环或依赖于易失性存储器或其他系统资源的函数,这可能在两个连续调用之间发生变化(例如多线程环境中的feof)。

Thinking about向我提出了一个有趣的问题:如果一些代码块改变了一个非局部变量,并调用了一个不可自省的函数,它会假设这个外部函数可能依赖于那个非局部变量吗?

编译单元:

int foo() {
    extern int x;
    return x;
}

编译单元B :

int x;
int bar() {
    for (x=0; x<10; ++x) {
        std::cout << foo() << 'n'; 
    }
}

当前的标准有序列点的概念。我想如果编译器没有看到足够的信息,它只能优化到不破坏相关序列点的排序。

3。如果我需要防止某些代码被编译器删除

除了查看对象转储之外,如何判断是否删除了某些内容?

如果你不能判断,那么这不等于不可能编写依赖于(非)删除的代码吗?

在这方面,编译器扩展(如OpenMP)帮助您能够判断。也存在一些内置机制,如volatile变量。

如果没有人能观察到树,它还存在吗?

4。或者我完全错了,编译器不允许删除任何c++代码,除了标准明确提到的情况(作为副本消除)

不,这是完全允许的。它还可以像转换黏液一样转换代码。(除了副本消除,你无法判断)。

一个不同之处在于,Java被设计为只在一个平台上运行,即JVM。这使得在规范中更容易做到"足够严格",因为只需要考虑平台,并且您可以准确地记录它是如何工作的。

c++被设计成能够在广泛的平台上运行,并且不需要中间的抽象层,而是直接使用底层硬件功能。因此,它选择允许在不同平台上实际存在的功能。例如,像int(1) << 33这样的移位操作的结果在不同的系统上是允许不同的,因为这是硬件工作的方式。

c++标准描述的是你期望从程序中得到的结果,而不是实现它的方式。在某些情况下,它说您必须检查您的特定实现,因为结果可能不同,但仍然是预期的

例如,在IBM大型机上,没有人期望浮点数与IEEE兼容,因为大型机系列比IEEE标准早得多。c++ 仍然允许使用底层硬件,而Java不允许。这对两种语言来说是优势还是劣势?视情况而定!


在语言的限制和允许范围内,一个合理的实现必须表现得像你在程序中编写的那样。如果您执行系统调用,如锁定互斥锁,编译器有以下选项:知道调用做什么,因此不能删除它们,或者确实知道确切地知道它们做什么,因此也知道它们是否可以删除。结果是一样的!

如果你调用标准库,编译器可以很好地知道确切地调用做了什么,正如标准中所描述的那样。然后,它可以选择真正调用函数,用其他代码替换它,或者如果没有效果则完全跳过它。例如,std::strlen("Hello world!")可以替换为12