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

时间:2017-08-15 09:01:51

标签: c++ gcc null compiler-optimization delete-operator

考虑一个简单的程序:

int main() {
  int* ptr = nullptr;
  delete ptr;
}

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

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

更新

澄清我对这个问题的动机:如果我在{em>移动分配操作符和析构函数中仅使用delete ptr;而没有if (ptr)防护某个类,然后std::swap与该类的对象产生3个call指令与GCC。这可能是相当大的性能损失,例如,在对这些对象的数组进行排序时。

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

另外,我真的很喜欢在没有后卫的情况下拨打delete的可能性,这对我来说是一个惊喜,它可以产生不同的(表现)结果。

更新

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

在Xeon E2680v3上使用GCC 7.1和std::sort标记测量的-O2的运行时间:

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

  1. 没有if后卫: 17.6 [s] 40.8 [s]
  2. if后卫: 10.6 [s] 31.5 [s]
  3. if后卫和自定义swap 10.4 [s] 31.3 [s]。
  4. 这些结果在许多运行中绝对一致,偏差最小。前两种情况之间的性能差异很大,我不会说这是一些“非常罕见的角落案例”,如代码。

6 个答案:

答案 0 :(得分:29)

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

  

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

     
      
  • [...省略...]
  •   
     

否则,未指定是否将调用释放函数。

因此两个编译器都符合标准,因为未指定是否调用<img src="uploads/images/banner.jpg" alt="" draggable="false"> 来删除空指针。

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

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

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

答案 1 :(得分:7)

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

  

该库提供全局分配的默认定义   释放功能。一些全局分配和解除分配   功能是可替换的(18.6.1)。 C ++程序应提供at   可替换分配或释放的大多数定义   功能。任何此类函数定义都将替换默认版本   图书馆提供(17.6.4.6)。以下分配和   释放函数(18.6)在全局范围内隐式声明   在程序的每个翻译单元中。

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

  

在第一个替代(删除对象)中,操作数的值   delete可以是空指针值,指向非数组对象的指针   由前一个new-expression创建,或者是一个指向子对象的指针   (1.8)代表这种对象的基类(第10条)。如果不,   行为未定义。

     

如果参数在标准中给出了释放函数   library是一个不是空指针值的指针(4.10),.   deallocation函数应解除分配引用的存储   指针,渲染无效指向任何部分的所有指针   解除分配存储。间接通过无效指针值和   将无效指针值传递给释放函数有   未定义的行为。任何其他使用无效指针值的方法都有   实现定义的行为。

...

  

如果delete-expression的操作数值不为null   指针值,然后

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

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

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

否则,未指定是否将调用释放函数。

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

答案 2 :(得分:7)

这是一个QOI问题。铿锵确实在考验之前:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret

答案 3 :(得分:5)

让程序使用nullptr调用operator delete始终是安全的(为了正确)。

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

首先,真正热点之外的较大代码大小会增加L1I缓存的压力,而x86 CPU上的解码uop缓存会更小(Intel SnB-family,AMD Ryzen)。 / p>

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

如果nullptr在给定的代码路径中很少见,那么平均检查&amp;分支以避免call结束,您的计划在支票上花费的时间比支票节省的时间长。

如果分析显示您有一个包含delete的热点,并且检测/日志记录显示它通常实际上使用nullptr调用delete,那么它值得尝试
 if (ptr) delete ptr;而非delete ptr;

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

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

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

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
}

它总是会与clang一起返回false,因为它在内联之前评估__builtin_constant_p。但是因为当它可以证明指针为空时,clang已经跳过delete次调用,所以你不需要它。

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

你可以把它变成一个宏来避免膨胀非优化版本(因此它会工作&#34;无需先内联)。您可以使用GNU C语句表达式来避免双重评估宏arg(see examples for GNU C min() and max())。对于没有GNU扩展的编译器的后备,你可以编写((ptr), false)或者其他东西来评估arg一次产生副作用,同时产生false结果。

示范:asm from gcc6.3 -O3 on the Godbolt compiler explorer

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

它使用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

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

相关:我发现this blog post,使用C11(而不是C ++ 11)_Generic尝试在静态初始化器内部执行类似__builtin_constant_p空指针检查的操作。

答案 4 :(得分:2)

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

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

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

int main() {
 int* ptr = nullptr;

 if (ptr != nullptr) {
    delete ptr;
 }
}

我记得没错,有办法用自己的功能取代“删除”。如果编译器的优化会出错。

@RichardHodges:当为编译器提供删除呼叫的提示时,为什么要进行去优化?

删除null通常是NOOP(无操作)。但是,由于可以替换或覆盖删除,因此不存在所有情况的保证。

因此,编译器需要知道并决定是否使用删除null总是可以删除的知识。两个选择都有很好的论据

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

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

int main() {
 int* ptr = nullptr;

 // dead code    if (ptr != nullptr) {
 //        delete ptr;
 //     }
}

请告诉我,哪里有去优化?

我将我的提案称为防御式编码,但不是去优化

如果有人可能会争辩,那么现在非nullptr会导致两次检查nullptr,我必须回复

  1. 对不起,这不是原来的问题
  2. 如果编译器知道删除,特别是delete null是noop,那么编译器可以删除外部if。但是,我不希望编译器如此具体
  3. @Peter Cordes:我同意使用if不是一般优化规则。但是,一般优化不是开启者的问题。问题是为什么有些编译器不会在非常短的非感知程序中删除删除。无论如何,我展示了让编译器消除它的方法。

    如果情况发生在那个短程序中,可能还有其他错误。一般来说,我会尽量避免使用new / delete(malloc / free),因为调用相当昂贵。如果可能,我更喜欢使用堆栈(自动)。

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

    而不是

    class X {
      int* i_;
      public:
      ...
    

    in will design

    class X {
      int i;
      bool valid;
      public:
      ...
    

    或更早,我会问排序空/无效项目的感觉。最后,我也希望摆脱“有效”。

答案 5 :(得分:2)

首先,我只是同意一些以前的回答者,因为它不是一个错误,GCC可能会在这里随心所欲。也就是说,我想知道这是否意味着一些普通和简单的RAII代码在GCC上可能比Clang慢,因为没有进行简单的优化。

所以我为RAII写了一个小测试用例:

struct A
{
    explicit A() : ptr(nullptr) {}
    A(A &&from)
        : ptr(from.ptr)
    {
        from.ptr = nullptr;
    }

    A &operator =(A &&from)
    {
        if ( &from != this )
        {
            delete ptr;
            ptr = from.ptr;
            from.ptr = nullptr;
        }
        return *this;
    }

    int *ptr;
};

A a1;

A getA2();

void setA1()
{
    a1 = getA2();
}

正如您可能会看到here,GCC 确实delete中第二次调用setA1(对于在getA2中创建的临时移动致电a1)。第一次调用对于程序正确性是必要的,因为之前可能已将a1.ptrif ( ptr != nullptr )分配给。{/ p>

显然我更喜欢更多&#34;押韵和理由&#34; - 有时为什么要进行优化但并非总是如此 - 但我还是不愿意在我的RAII代码中进行多余的asp.net-mvc-5检查。