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

74

考虑下面这段使用new的简单代码(我知道没有delete[],但这与本问题无关):

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

    return 0;
}
编译器是否可以优化掉 new 调用?
在我的研究中,g++ (5.2.0) 和 Visual Studio 2015 都没有对 new 调用进行优化,而 clang (3.0+) 则优化了。所有测试都是使用全优化 (-O3 对于 g++ 和 clang,Release 模式对于 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

5个回答

64
历史上,Clang似乎遵循了N3664: 澄清内存分配中规定的规则,允许编译器在内存分配周围进行优化,但正如Nick Lewycky所指出的那样

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

因此,clang实现了这个优化,后来成为C++14的一部分。
基本问题是在N3664之前是否存在有效的优化,这是一个棘手的问题。我们必须参考草案C++标准第1.9程序执行中涵盖的as-if规则,该规则规定(重点是我的):
这个国际标准中的语义描述定义了一个参数化的非确定性抽象机器。该国际标准对符合要求的实现结构没有要求。特别地,它们不需要复制或模拟抽象机器的结构。相反,符合要求的实现需要模拟(仅)下面解释的抽象机器的可观察行为。5 其中注释 5 表示:
这个规定有时被称为“as-if”规则,因为只要程序的可观察行为表现得像已经遵守了要求一样,实现就可以自由地忽略这个国际标准的任何要求。例如,如果实现可以推断出一个表达式的值未被使用并且不会产生影响程序的可观察行为的副作用,那么实际实现不需要计算表达式的一部分。
由于 new 可能会抛出异常,而异常将具有可观察的行为,因为它将改变程序的返回值,所以似乎这与“as-if”规则不允许它。
虽然可以争论在何时抛出异常是实现细节,因此clang甚至可以在这种情况下决定不会引发异常,因此省略new调用不会违反as-if规则。根据as-if规则,优化掉非抛出版本的调用也是有效的。

但是我们可以在另一个翻译单元中拥有一个替代的全局operator 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

这确实看起来过于激进,但后续版本似乎没有这样做。


13
像这样的答案是使 StackOverflow 成为一个宝贵的宝藏的原因。太棒了! - Barrett Adair

21
这是N3664允许的。

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

这个提案是C++14标准的一部分,所以在C++14中编译器可以优化出new表达式(即使可能会抛出异常)。
如果您查看Clang实现状态,它清楚地说明他们实现了N3664。
如果您在C++11或C++03中编译时观察到此行为,应填写错误报告。
注意,在C++14之前,动态内存分配是程序的可观察状态的一部分(尽管我目前找不到相关的参考资料),因此符合规范的实现不允许在这种情况下应用“as-if”规则。

@Banex IMH 是的。实现基本上允许用自动存储替换动态存储。由于分配自动存储不会失败,mem != nullptr 总是为真。您应该提到您正在使用哪个标准版本。 - sbabbi
我明白了。你是对的,Clang 3.4+ 是符合标准的。然而,根据他们的状态页面,没有实现 N3664 的 Clang 3.3 也会优化这样的代码。所以至少那个版本存在一个错误。 - Banex
2
@Banex 那个提议是由clang的人提出的。我认为发生的事情是他们先实现了那个(不太简单)的优化处理,然后发现它不符合标准...并提出了一个修复建议。 - sbabbi
7
N3664提议被称为“澄清内存分配”。其目的不是改变标准,而是明确某些优化是允许的。例如,它将“A new-expression obtains storage for the object by calling an allocation function (3.7.4.1)”更改为“A new-expression may obtain storage for the object by calling an allocation function (3.7.4.1)”。我认为“may obtain”在“As-if”条款下已经是可能的了。N3664只是使其明确化。因此,我认为3.3符合标准。 - Anonymous Coward

9
请记住,C++标准规定了正确程序应该做什么,而不是如何做。它根本无法告诉后者,因为在标准编写之后可能会出现新的架构,而标准必须对它们有用。 new在底层不一定是系统调用。有些计算机可以在没有操作系统和系统调用概念的情况下使用。
因此,只要最终行为不变,编译器可以优化任何东西。包括那个 new
但有一个警告。
替代全局 operator new 可能已在不同的翻译单元中定义
在这种情况下,new 的副作用可能是无法优化的。但如果编译器可以保证 new 运算符没有副作用,就像发布的代码是整个代码一样,那么优化是有效的。
new 可以抛出 std::bad_alloc 不是必需的。在这种情况下,当 new 被优化时,编译器可以保证不会抛出异常,也不会发生任何副作用。

4
请记住,C++标准规定了正确程序应该做什么,而不是如何做到这一点。这种说法有些流于表面,对于这个问题来说细节很重要。请参见我上面提供的可能存在重复的链接。 - Shafik Yaghmour
1
我已经检查过了,这进一步证实了我的立场。编译器只需要生成“仿佛”执行的代码。唯一重要的部分是“替换全局 operator new 可以在不同的翻译单元中定义”。 - Anonymous Coward
1
@JoseAntonioDuraOlmos 这里的问题是“堆是否属于可观察状态?”如果答案是“是”,那么“as-if”规则就不适用。 - sbabbi
2
未分配的堆不是可观察状态的一部分。这是因为堆的大小可以随时间变化而变化。优化分配只对未分配的堆产生影响(如果未进行优化,则其大小将更大)。它对已分配的空间没有影响,而这些空间是可观察的。 - Anonymous Coward
1
我敢说这个程序根本没有任何可观察的影响(没有volatile访问或调用不透明函数)。堆本身也是不可观测的。 - Simon Richter

7
编译器可以在您最初的示例中优化分配,但这不是必需的。根据标准 §1.9,尤其是通常称为“as-if规则”的部分,编译器甚至可以在EDIT1示例中进行更多优化:

符合要求的实现只需要模拟抽象机器的可观察行为,具体如下所述:
[3页条件]

可在cppreference.com上找到更易读的表述。
相关要点包括:
  • 您没有使用任何volatile关键字,因此1)和2)不适用。
  • 您没有输出/写入任何数据或提示用户,因此 3)和4)不适用。但即使是这样,它们在EDIT1中也将明显得到满足(从纯理论角度看,原始示例也属于非法操作,因为程序流程和输出从理论上讲是不同的,但请参阅下面两段落)。
异常,即使是未捕获的异常,也是定义良好的(而不是未定义的!)行为。但严格来说,如果new引发异常(不太可能发生,下一段还有说明),那么可观察的行为将是不同的,包括程序的退出代码和随后可能出现在程序中的任何输出。
现在,在特定情况下,即小额单个分配的情况下,您可以给编译器一个“怀疑的好处”,即它可以保证分配不会失败。
即使在内存压力非常大的系统下,当可用内存小于最小分配粒度时,甚至无法启动进程,并且堆也会在调用main之前设置。因此,如果这个分配失败了,程序将在main被调用之前永远不会启动或已经遇到了不优雅的结束。
就此而言,即使分配理论上可能引发异常,假设编译器知道这一点,甚至可以优化原始示例,因为编译器可以实际保证它不会发生。
另一方面,编译器不允许(并且您可以观察到这是编译器错误)在EDIT2示例中优化掉分配。该值被使用以产生外部可观察效果(返回代码)。
请注意,如果您将new(std::nothrow)int[1000]替换为new(std::nothrow) int[1024 * 1024 * 1024 * 1024ll](即4TiB分配!),它仍会优化掉此调用。换句话说,尽管您编写了必须输出0的代码,但它仍会返回1。
@Yakk提出了一个很好的反驳观点:只要不触及内存,就可以返回指针,而不需要实际的RAM。因此,在EDIT2中优化掉分配甚至是合法的。我不确定谁是对的,谁是错的。
在没有至少两位数千兆字节数量的RAM的机器上,进行4TiB的分配几乎肯定会失败,因为操作系统需要创建页表。当然,C++标准并不关心页表或者操作系统为提供内存所做的工作,这是真的。
但另一方面,“如果不触及内存,这将起作用”的假设确实依赖于这样一个细节和操作系统提供的东西。假设如果未触及内存,则实际上不需要RAM,这只有在操作系统提供虚拟内存的情况下才是正确的。这意味着操作系统需要创建页表(我可以假装我不知道,但这并不改变我仍然依赖它的事实)。
因此,我认为首先假设一件事,然后说“但我们不关心其他事情”并不完全正确。
因此,是的,编译器可以假设在未触及内存的情况下,4TiB的分配通常是完全可能的,并且可以假设成功通常是可能的。它甚至可以假设它很可能成功(即使它实际上不会)。但是,我认为在任何情况下,当存在失败的可能性时,您永远不允许假设某些内容必须工作。而且,在那个例子中,失败甚至是更有可能的情况。

2
我认为这个答案需要引用一下为什么在进行4 TiB的分配时需要要求new抛出异常。 - user1084944
3
我不同意:编译器可以自由返回1。对于未使用的内存,标准认为未分配的内存与已分配的内存一样。 new 可以返回指向空的非空指针,如果编译器能够证明没有定义的访问发生在指向的内容上,那么它就符合标准的要求。如果可以调用 delete ,情况会变得棘手,但只是略微棘手(类似的论点也可以跳过该调用)。 - Yakk - Adam Nevraumont
2
@damon C++标准没有描述页面描述符:它们的状态是实现细节,因此在as-if下无关紧要。 - Yakk - Adam Nevraumont
3
是的,这是合法的。你继续谈论不相关的实现细节:as-if 不关心它原本如何实现。不,编译器没有必要进行这种优化:编译器可以在每次调用 new 时始终抛出异常,不这样做是质量实现的问题。尝试分配4个attobyte可以“诚实地”抛出异常,也可以在不尝试的情况下变成一个throw,或者如果可以证明永远不会使用,则变成一个空操作。对于分配1字节也是如此(除了诚实分支更有可能起作用)。 - Yakk - Adam Nevraumont
2
@Damon:如果我写了 int foo(unsigned long long n) { unsigned long long a,b; a=0; for (b=0; b<n; b++) a++; return a; },那么标准中是否有任何规定禁止编译器将其替换为 { return n; }?如果编译器能够找出机器在拥有足够时间和内存的情况下会执行什么操作,那么它就没有必要实际使用这些时间或内存。 - supercat
显示剩余9条评论

2
在您的代码片段中,最糟糕的情况是new会抛出未处理的std::bad_alloc异常。此时发生的情况由实现定义。
如果最好的情况是无操作而最坏的情况没有定义,编译器可以将它们因素为不存在。现在,如果您实际上尝试并捕获可能的异常:
int main() try {
    int* mem = new int[100];
    return 0;
} catch(...) {
  return 1;
}

然后保留了对operator new的调用


如果您将 100 更改为某个巨大值,您将期望分配失败,而优化 new 将意味着更改程序的可观察行为。编译器也不能总是失败,因为将来在具有 3 艾字节内存的计算机上运行相同的程序并且期望成功。 - Quentin
@JoseAntonioDuraOlmos 现在我用 Clang 3.6 尝试了一下... 它实际上总是返回零。这是一个错误。 - Quentin
请提供一个关于为什么这是一个错误的参数。它如何未通过 as-if 测试:标准的哪一段违反了返回 0 的规定?请注意,何时抛出异常是未指定的 - Yakk - Adam Nevraumont
@Yakk 难道分配失败不一定会抛出异常吗? - Quentin
2
@quen 当分配失败时,其实现是定义的。由于成功的分配除了返回“0”之外没有任何副作用,因此返回“0”的程序表现为分配成功,并且因此是符合规范的程序具有成功的分配(即使它以attobytes为单位)。分配失败只是实现质量问题。(请注意,每次分配都失败的程序也是符合规范的) - Yakk - Adam Nevraumont
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接