是否允许编译器优化堆内存分配

Is the compiler allowed to optimize out heap memory allocations?

本文关键字:内存 分配 优化 编译器 是否      更新时间:2023-10-16

考虑以下使用new的简单代码(我知道没有delete[],但它不属于这个问题):

int main()
{
    int* mem = new int[100];
    return 0;
}

是编译器允许优化出new调用?

在我的研究中,g++(5.2.0)和Visual Studio 2015没有优化出new调用,而clang(3.0+)可以。所有测试都是在启用了完全优化的情况下进行的(g++和clang的-O3, Visual Studio的发布模式)。

new不是在引擎盖下进行系统调用,使得编译器不可能(和非法)优化它?

编辑:我现在已经从程序中排除了未定义的行为:

#include <new>  
int main()
{
    int* mem = new (std::nothrow) int[100];
    return 0;
}

clang 3.0不再优化这个,但是以后的版本会。

EDIT2 :

#include <new>  
int main()
{
    int* mem = new (std::nothrow) int[1000];
    if (mem != 0)
      return 1;
    return 0;
}

clang总是返回1。

历史似乎是clang遵循N3664中列出的规则:澄清内存分配,允许编译器围绕内存分配进行优化,但正如Nick Lewycky指出的:

Shafik指出,这似乎违反了因果关系,但N3664最初是N3433,我很确定我们先写了优化,然后写了论文。

所以clang实现了这个优化,这个优化后来成为了c++ 14的一部分。

基本问题是,在N3664之前,这是否是一个有效的优化,这是一个棘手的问题。我们必须参考c++标准草案1.9 程序执行中的as-if规则,该规则说(强调我的):

本国际标准中的语义描述定义了a参数化不确定性抽象机。这个国际标准对结构的一致性没有要求实现。特别是,他们不需要复制或模仿抽象机的结构。相反,符合的实现是否需要模拟(仅)抽象的可观察行为5

注释5说:

这个规定有时被称为"as-if"规则,因为实现可以自由地忽略这方面的任何要求只要是国际标准的结果就好像是要求的从可观察到的情况来看,是服从了吗程序的行为。例如,实际的实现需求不计算表达式的一部分,如果它可以推断出它的值为不使用且无副作用影响可观察的行为程序生成

由于new可能抛出一个异常,该异常将具有可观察的行为,因为它将改变程序的返回值,这似乎反对as-if规则允许它。

虽然,可以认为何时抛出异常是实现细节,因此clang可以决定即使在这种情况下它也不会引起异常,因此省略new调用不会违反as-if规则

as-if规则下,优化掉对非抛出版本的调用似乎也是有效的。

但是我们可以在不同的翻译单元中使用替换全局操作符new,这可能会导致影响可观察的行为,因此编译器必须以某种方式证明这不是这种情况,否则它将无法在不违反as-if规则的情况下执行此优化。clang的早期版本确实在这种情况下进行了优化,正如这个godbolt示例所示,它是由Casey提供的,代码如下:

#include <cstddef>
extern void* operator new(std::size_t n);
template<typename T>
T* create() { return new T(); }
int main() {
    auto result = 0;
    for (auto i = 0; i < 1000000; ++i) {
        result += (create<int>() != nullptr);
    }
    return result;
}

并将其优化为:

main:                                   # @main
    movl    $1000000, %eax          # imm = 0xF4240
    ret

这确实看起来太激进了,但后来的版本似乎没有这样做。

这是N3664允许的。

允许实现省略对可替换的全局分配函数的调用(18.6.1.1,18.6.1.2)。当它这样做时,存储由实现提供,或者通过扩展另一个new-expression的分配来提供。

这个建议是c++ 14标准的一部分,所以在c++ 14中,编译器被允许优化出new表达式(即使它可能抛出)。

如果你看一下Clang的实现状态,它清楚地表明他们确实实现了N3664。

如果您在c++ 11或c++ 03中编译时观察到此行为,您应该填充错误。

请注意,在c++ 14之前,动态内存分配是程序的可观察状态的一部分(尽管我目前找不到关于它的引用),所以在这种情况下,不允许一个一致的实现应用as-if规则。

记住c++标准告诉我们一个正确的程序应该做什么,而不是如何做。它根本无法判断以后的情况,因为新的体系结构可以而且确实在标准编写之后出现,并且标准必须对它们有用。

new不一定是底层的系统调用。有些计算机没有操作系统,没有系统调用的概念也可以使用。

因此,只要结束行为没有改变,编译器可以优化任何东西。包括new

有一点需要注意。
替换的全局操作符new可以在不同的翻译单元中定义
在这种情况下,new的副作用可能是无法优化的。但是,如果编译器可以保证new操作符没有副作用,就像发布的代码是完整代码的情况一样,那么优化是有效的。
new可以抛出std::bad_alloc并不是必需的。在这种情况下,当new被优化时,编译器可以保证不会抛出异常,也不会产生副作用。

编译器完全允许(但不是必需的)在您的原始示例中优化分配,在EDIT1示例中更是如此,根据标准的§1.9,这通常被称为as-if规则:

一致性实现需要模拟(仅)抽象机器的可观察行为,如下所述:
[3页条件]

在cppreference.com上有一个更容易读懂的表示。

相关要点如下:

  • 您没有挥发物,所以1)和2)不适用。
  • 您不输出/写入任何数据或提示用户,因此3)和4)不适用。但是,即使您这样做了,他们显然也会对EDIT1感到满意(可以说在原始示例中也,尽管从纯理论的角度来看,这是非法的,因为程序流程和输出-理论上-不同,但请参阅下面的两段)。

异常,即使是未捕获的异常,也是定义良好的(不是未定义的!)行为。然而,严格地说,如果new抛出(不会发生,参见下一段),可观察到的行为将是不同的,无论是程序的退出代码还是程序后面可能跟随的任何输出。

现在,在单个小分配的特殊情况下,您可以给编译器"怀疑的好处",它可以保证分配不会失败。
即使在内存压力非常大的系统上,当可用的分配粒度小于最小时,也不可能启动进程,而且堆也会在调用main之前设置好。因此,如果这个分配失败,程序将永远不会启动,或者甚至在调用main之前就已经遇到了一个不体面的结束。
到目前为止,假设编译器知道这一点,即使分配理论上可能抛出,即使优化原始示例也是合法的,因为编译器可以实际上保证它不会发生。

& lt;略undecided>
另一方面,在EDIT2示例中,不允许优化分配(正如您可以观察到的,这是一个编译器错误)。该值被用来产生一个外部可观察的效果(返回代码)。
注意,如果将new (std::nothrow) int[1000]替换为new (std::nothrow) int[1024*1024*1024*1024ll](这是4TiB的分配!),这在当前的计算机上肯定会失败,但它仍然会优化调用。换句话说,虽然您编写的代码必须输出0,但它返回1。

@Yakk提出了一个很好的反对意见:只要内存不被触摸,就可以返回指针,而不需要实际的RAM。到目前为止,在EDIT2中优化分配是合理的。我不确定谁对谁错。

在一台至少没有两位数gb RAM的机器上执行4TiB分配几乎肯定会失败,因为操作系统需要创建页表。当然,c++标准并不关心页表或操作系统如何提供内存,这是真的。

但另一方面,假设"如果内存未被触及,这将工作"确实依赖于这样一个细节和操作系统提供的一些东西。假设如果RAM没有被触及,它实际上是不需要的,因为操作系统提供虚拟内存。这意味着操作系统需要创建页表(我可以假装我不知道,但这并不能改变我依赖它的事实)。

因此,我认为首先假设一个,然后说"但我们不关心另一个"是不100%正确的。

所以,是的,编译器可以假设只要内存不被触及,4TiB分配通常是完全可能的,并且它可以假设它通常是可能成功的。它甚至可能认为它很可能成功(即使事实并非如此)。但我认为,在任何情况下,当存在失败的可能性时,绝不允许假设某些必须工作。不仅有失败的可能性,在这个例子中,失败甚至是更有可能的可能性。

代码片段中最糟糕的情况是new抛出未处理的std::bad_alloc。接下来发生的事情是由实现定义的。

最好的情况是没有操作,最坏的情况没有定义,编译器允许将它们分解为不存在。现在,如果您尝试捕获可能的异常:

int main() try {
    int* mem = new int[100];
    return 0;
} catch(...) {
  return 1;
}

…则保留对operator new的调用