为什么GCC不能优化C++中空指针的删除?

Dan*_*ica 46 c++ null gcc compiler-optimization delete-operator

考虑一个简单的程序:

int main() {
  int* ptr = nullptr;
  delete ptr;
}
Run Code Online (Sandbox Code Playgroud)

对于GCC(7.2),在结果程序中有call关于的指令operator delete.对于Clang和Intel编译器,没有这样的指令,空指针删除被完全优化(-O2在所有情况下).你可以在这里测试:https://godbolt.org/g/JmdoJi.

我想知道这样的优化能否以某种方式与GCC一起开启?(我更广泛的动机源于自定义swapvs std::swap可移动类型的问题,其中删除空指针可能代表第二种情况下的性能损失; 有关详细信息,请参阅/sf/answers/3198249771/.)

UPDATE

为了澄清我对这个问题的动机:如果我在移动赋值运算符和某个类的析构函数中使用delete ptr;if (ptr)保护,那么该类的对象将产生3个GCC指令.这可能是相当大的性能损失,例如,在对这些对象的数组进行排序时.std::swapcall

此外,我可以if (ptr) delete ptr;在任何地方写作,但是想知道,这是否也不能成为性能损失,因为delete表达式也需要检查ptr.但是,在这里,我想,编译器只会生成一个检查.

此外,我真的很喜欢delete没有后卫的可能性,这对我来说是一个惊喜,它可以产生不同的(表现)结果.

UPDATE

我只是做了一个简单的基准测试,即排序对象,它们delete在移动赋值运算符和析构函数中调用.来源是:https://godbolt.org/g/7zGUvo

std::sort使用GCC 7.1测量的运行时间和-O2Xeon E2680v3上的标志:

链接代码中存在一个错误,它会比较指针,而不是指向值.更正结果如下:

  1. 没有if后卫:17.6 [s] 40.8 [s],
  2. if后卫:10.6 [秒] 31.5 [S] ,
  3. if警卫和习俗swap:10.4 [s] 31.3 [s].

这些结果在许多运行中绝对一致,偏差最小.前两种情况之间的性能差异是显着的,我不会说这是一些像代码一样的"非常罕见的极端情况".

M.M*_*M.M 28

根据C++ 14 [expr.delete]/7:

如果delete-expression的操作数的值不是空指针值,则:

  • [...省略...]

否则,未指定是否将调用释放功能.

因此,两个编译器都符合标准,因为未指定是否operator delete要求删除空指针.

请注意,godbolt在线编译器只是编译源文件而不进行链接.因此,该阶段的编译器必须允许operator delete被另一个源文件替换的可能性.

正如在另一个答案中已经推测的那样 - 在替换的情况下,gcc可能正在寻求一致的行为operator delete; 这个实现意味着有人可以为调试目的重载该函数并中断delete表达式的所有调用,即使它恰好是删除空指针.

更新:删除了这可能不是一个实际问题的猜测,因为OP提供的基准显示它实际上是.

  • 我更新了问题以包括基准测试结果.性能影响相当大IMO. (2认同)

Swi*_*Pie 7

标准实际上说明何时应调用分配和释放函数以及何时不调用.本条款(@ n4296)

该库提供全局分配和释放功能的默认定义.一些全局分配和释放功能是可替换的(18.6.1).C++程序最多只能提供一个可替换分配或释放功能的定义.任何此类函数定义都将替换库中提供的默认版本(17.6.4.6).以下分配和释放函数(18.6)在程序的每个转换单元中在全局范围中隐式声明.

可能是这些函数调用不被任意省略的主要原因.如果是这样的话,替换它们的库实现会导致编译程序的功能不连贯.

在第一种方式(删除对象)的删除操作数的值可以是一个空指针值,指针到由先前新表达式创建的非阵列对象,或一个指向子对象(1.8)表示这种对象的基类(第10条).如果不是,则行为未定义.

如果传递给标准库中的释放函数的参数是一个指针,是不是空指针值(4.10),重新分配功能将取消分配指针所引用的存储,使得无效指解除了分配存储的任何部分,所有指针.通过无效指针值间接并将无效指针值传递给释放函数具有未定义的行为.无效指针值的任何其他使用都具有实现定义的行为.

...

如果delete-expression的操作数的值不是空指针值,那么

  • 如果没有省略对要删除的对象的new-expression的分配调用并且未扩展分配(5.3.4),则delete-expression应调用释放函数(3.7.4.2).从new-expression的分配调用返回的值应作为第一个参数传递给deallocation函数.

  • 否则,如果通过扩展另一个newexpression的分配来扩展或提供分配,并且已经评估了由具有由扩展的new-expression提供的存储的new-expression产生的每个其他指针值的delete-expression,则删除-expression将调用释放函数.从扩展new-expression的分配调用返回的值应作为第一个参数传递给deallocation函数.

    • 否则,delete-expression将不会调用释放函数

否则,未指定是否将调用释放功能.

标准说明如果指针为非null,应该做什么.暗示在这种情况下删除是noop,但是没有指定.

  • 如果指针为非null,则说明应该做什么并不意味着*指针何时为*null. (3认同)

Ric*_*ges 7

这是一个QOI问题.铿锵确实是在考验:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

  • @Swift它有.`xor eax,eax`是这样的`main()`返回0,正如标准所要求的那样. (7认同)
  • 啊,是main(),我想念那一点,对不起。 (2认同)

Pet*_*des 6

让程序operator delete使用nullptr 调用总是安全的(正确性).

对于性能而言,让编译器生成的asm实际执行额外测试并且跳过调用的条件分支operator delete将是一个胜利是非常罕见的.(但是,您可以帮助gcc优化编译时nullptr删除而不添加运行时检查;请参阅下文).

首先,在真正的热点之外的更大的代码大小增加了L1I缓存的压力,并且在具有一个(Intel SnB系列,AMD Ryzen)的x86 CPU上的甚至更小的解码uop缓存.

其次,额外的条件分支使用分支预测高速缓存中的条目(BTB =分支目标缓冲区等).根据CPU的不同,即使是从未采用的分支也可能会使其他分支的预测恶化,如果它在BTB中将它们混淆的话.(在其他情况下,这样的分支永远不会在BTB中获得条目,以保存分支的条目,其中默认静态预测掉落是准确的.)请参阅https://xania.org/201602/bpu-part-one.

如果nullptr在给定的代码路径中很少见,那么平均检查和分支以避免call最终使您的程序在检查上花费的时间比检查保存的时间长.

如果分析显示你有一个包含a的热点delete,并且instrumentation/logging显示它经常实际调用deletenullptr,那么它值得尝试
if (ptr) delete ptr;而不仅仅是delete ptr;

分支预测可能在一个呼叫站点比在内部分支更好运operator delete,特别是如果与其他附近分支有任何关联.(显然,现代BPU不仅仅是孤立地查看每个分支.)这是将无条件保存call到库函数中(加上另一个jmp来自PLT存根,来自Unix/Linux上的动态链接开销).


如果由于任何其他原因检查null,那么将delete内部放入代码的非null分支是有意义的.

delete在gcc可以证明(在内联之后)指针为空的情况下,你可以避免调用,但如果没有,则不进行运行时检查:

static inline bool 
is_compiletime_null(const void *ptr) {
#ifdef   __GNUC__
    // __builtin_constant_p(ptr) is false even for nullptr,
    // but the checking the result of booleanizing works.
    return __builtin_constant_p(!ptr) && !ptr;
#else
    return false;
#endif
}
Run Code Online (Sandbox Code Playgroud)

它总是会与clang返回false,因为它__builtin_constant_p在内联之前进行了求值.但是因为delete当它可以证明指针为空时,clang已经跳过调用,所以你不需要它.

这实际上可能对std::move案例有帮助,您可以安全地在任何地方使用它(理论上)没有性能下降.我总是编译if(true)或者if(false),因此它非常不同if(ptr),这很可能导致运行时分支,因为在大多数情况下编译器可能无法证明指针是非空的.(但是,取消引用可能因为null deref将是UB,并且现代编译器基于代码不包含任何UB的假设进行优化).

你可以把它变成一个宏来避免膨胀非优化的构建(因此它将"工作"而不必先内联).您可以使用GNU C语句表达,以避免双重评估宏观ARG(见GNU C的例子min()max()).对于没有GNU扩展的编译器的后备,你可以((ptr), false)在生成false结果时编写或者用于评估arg一次的副作用.

演示: 来自Godbolt编译器浏览器的gcc6.3 -O3的asm

void foo(int *ptr) {
    if (!is_compiletime_null(ptr))
        delete ptr;
}

    # compiles to a tailcall of operator delete
    jmp     operator delete(void*)


void bar() {
    foo(nullptr);
}

    # optimizes out the delete
    rep ret
Run Code Online (Sandbox Code Playgroud)

它使用MSVC(也在编译器资源管理器链接上)正确编译,但是测试总是返回false,bar()是:

    # MSVC doesn't support GNU C extensions, and doesn't skip nullptr deletes itself
    mov      edx, 4
    xor      ecx, ecx
    jmp      ??3@YAXPEAX_K@Z      ; operator delete
Run Code Online (Sandbox Code Playgroud)

有趣的是,MSVC operator delete将对象大小作为函数arg(mov edx, 4),但gcc/Linux/libstdc ++代码只传递指针.


相关:我发现这篇博文,使用C11(而不是C++ 11)_Generic尝试__builtin_constant_p在静态初始化器内部进行类似空指针检查的操作.


ste*_*ert 2

我认为,编译器不了解“删除”,特别是“删除空”是一个NOOP。

您可以显式地编写它,因此编译器不需要暗示有关删除的知识。

警告:我不建议将此作为一般实施。下面的示例应该显示,如何“说服”有限的编译器在该非常特殊和有限的程序中删除代码

int main() {
 int* ptr = nullptr;

 if (ptr != nullptr) {
    delete ptr;
 }
}
Run Code Online (Sandbox Code Playgroud)

在我没记错的地方,有一种方法可以用自己的函数替换“删除”。在这种情况下,编译器的优化就会出错。


@RichardHodges:当给编译器提示删除调用时,为什么它应该是一种反优化?

delete null 通常是一个 NOOP(无操作)。但是,由于可以替换或覆盖删除,因此不能保证所有情况都适用。

因此,由编译器来了解并决定是否使用删除 null 总是可以删除的知识。这两种选择都有充分的理由

但是,编译器始终允许删除死代码,即“if (false) {...}”或“if (nullptr != nullptr) {...}”

因此编译器将删除死代码,然后当使用显式检查时,它看起来像

int main() {
 int* ptr = nullptr;

 // dead code    if (ptr != nullptr) {
 //        delete ptr;
 //     }
}
Run Code Online (Sandbox Code Playgroud)

请问哪里有去优化的地方?

我将我的建议称为防御性编码风格,而不是去优化

如果有人可能会争论,现在非 nullptr 会导致对 nullptr 进行两次检查,我必须回复

  1. 抱歉,这不是原来的问题
  2. 如果编译器知道删除,特别是删除 null 是一个 noop,那么编译器可以删除外部。但是,我不希望编译器如此具体

@Peter Cordes:我同意用 if 来保护不是一般的优化规则。然而,一般优化并不是开场白的问题。问题是为什么某些编译器不消除非常短、无意义的程序中的删除。我展示了一种让编译器消除它的方法。

如果出现像那个短节目那样的情况,可能是其他地方出了问题。一般来说,我会尽量避免新建/删除(malloc/free),因为调用相当昂贵。如果可能的话我更喜欢使用堆栈(自动)。

当我查看同时记录的真实案例时,我会说,X 类设计错误,导致性能不佳和内存过多。(https://godbolt.org/g/7zGUvo

代替

class X {
  int* i_;
  public:
  ...
Run Code Online (Sandbox Code Playgroud)

在设计中

class X {
  int i;
  bool valid;
  public:
  ...
Run Code Online (Sandbox Code Playgroud)

或者更早的时候,我会询问对空/无效项目进行排序的意义。最后我也想摆脱“有效”。