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

48
考虑一个简单的程序:

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

在GCC(7.2)中,生成的程序中存在一个与operator delete相关的call指令。而在Clang和Intel编译器中,没有这样的指令,空指针删除完全被优化掉了(-O2在所有情况下都是如此)。您可以在https://godbolt.org/g/JmdoJi进行测试。
我想知道是否可以通过某种方式在GCC上打开这样的优化?(我的更广泛的动机源于可移动类型的自定义swapstd::swap的问题,在第二种情况下,空指针删除可能代表性能损失;有关详细信息,请参见https://dev59.com/xmw15IYBdhLWcg3wv-ba#45689282。)
更新:
为了澄清我的问题动机:如果我在某个类的move assignment operatordestructor中只使用delete ptr;而没有使用if (ptr)守卫,则使用GCC对该类对象进行std::swap会产生3个call指令。这可能是一个相当大的性能损失,例如当对这些对象的数组进行排序时。
此外,我可以在任何地方写if (ptr) delete ptr;,但我想知道这是否也会导致性能损失,因为delete表达式也需要检查ptr。但是,在这里,我猜编译器只会生成一个检查。
另外,我真的很喜欢调用没有保护的delete的可能性,而且令我惊讶的是,它可能会产生不同的(性能)结果。
更新
我刚刚做了一个简单的基准测试,即对调用其移动赋值运算符和析构函数中的delete的对象进行排序。源代码在此处:https://godbolt.org/g/7zGUvo 使用GCC 7.1和Xeon E2680v3上的-O2标志测量的std::sort运行时间:
有一个链接代码中的错误,它比较指针,而不是指向的值。更正后的结果如下:
  1. 没有if保护:17.6 [s] 40.8 [s],
  2. if保护:10.6 [s] 31.5 [s],
  3. if保护和自定义swap10.4 [s] 31.3 [s]。
这些结果在多次运行中完全一致,且偏差极小。前两种情况的性能差异显著,我不会认为这是一些像代码那样“极其罕见的边缘情况”。

3
我认为,编写依赖于优化器的代码是个非常糟糕的想法。无法保证这种行为在未来版本的Clang/Intel编译器中不会发生改变。 - Dmitriy Kalugin-Balashov
16
这段话的意思是:这不是关于依赖于优化器的代码,而是关于依赖于优化器的程序性能。有许多优化是“标准”的,编码人员每天都会依靠这些优化,比如复制省略或内联。 - Daniel Langr
1
你有检查过在 delete 前添加显式的 if 会生成什么样的代码吗? - hyde
1
@EJP,我认为这种情况尤其会发生在有意义的代码中。当然,不是上面的示例代码。您看过关于“swap”与“std::swap”的讨论吗? - Daniel Langr
2
@cmaster 听起来有点像钓鱼贴。这甚至与模板无关,而是与RAII有关,它被认为是现代C++中的最佳实践。你所说的是必须在抽象和性能之间做出选择,这在很大程度上是虚假的二元论,尽管有一些真理,即如果要同时拥有两者,则不能将任何一个极端发挥到极致。然而,在我看来,OP的问题并不是极端的,而是合理且实用的。这是一种简单的优化,可以帮助某些具有直接实现的RAII类的性能。 - Arne Vogel
显示剩余5条评论
6个回答

29
根据C++14 [expr.delete]/7规定:
如果delete表达式的操作数值不是空指针,则:
[ ...省略...]
否则,未指定是否调用解除分配函数。
因此,两个编译器都符合标准,因为对于删除空指针的情况,未指定是否调用operator delete。
请注意,godbolt在线编译器仅编译源文件而不进行链接。因此,在该阶段编译器必须允许operator delete被另一个源文件替换的可能性。
正如在另一个答案中已经推测的那样,gcc可能正在寻求在替换operator delete的情况下实现一致的行为;这个实现意味着有人可以为了调试目的重载该函数并在所有delete表达式的调用上中断,即使它删除的是一个空指针。
更新:删除了猜测这可能不是一个实际问题的说法,因为OP提供的基准测试显示它实际上是一个问题。

我的观点是,即使对于可移动的类,提供自定义的swap函数也是合理的。此外,std::sort实现通常不仅调用自定义的swap,而且直接使用移动语义(如libstdc++、libc++、Microsoft)。 - Daniel Langr
2
我更新了问题,包括基准测试结果。在我看来,性能影响相当大。 - Daniel Langr
1
在实践中,测试用例的性能下降并不是一个问题,因为没有理由首先删除已知的空指针。实际上,delete 将被调用一些指针变量,其状态无法通过静态分析确定,在这种情况下,一个完全适当的实现(特别是对于非类类型的指针)将是调用释放函数并让它测试指针是否为空。但是,在代码内联之后,这可能不是真的。也就是说,可以使用 if (!(__builtin_constant_p(ptr) && ptr == NULL)) delete ptr 进行警告和修复。 - Maciej Piechotka
1
@M.M 我注意到了GCC开发者们:https://gcc.gnu.org/ml/gcc/2017-08/msg00170.html(希望这是正确的方式)。 - Daniel Langr
1
关于一致性的猜测是错误的,真正的原因是时间和人力不足。gcc删除了free(NULL),当有人教它那个函数是神奇的(而不是任意函数)时,它最终会删除delete。出于同样的原因,gcc删除了free(malloc(42))但不删除delete new int等。 - Marc Glisse
显示剩余5条评论

7

标准实际上规定了何时调用分配和释放函数以及它们的位置。此款(@ n4296)

库为全局分配和释放函数提供默认定义。某些全局分配和释放函数可替换(18.6.1)。C++程序应最多提供一个可替换分配或释放函数的定义。任何这样的函数定义都将替换库中提供的默认版本(17.6.4.6)。下面的分配和释放函数(18.6)在程序的每个翻译单位中隐式声明为全局范围。

这可能是那些函数调用不会被任意省略的主要原因。如果省略它们,替换库实现的效果将导致编译程序的不一致性。

在第一个选择(delete对象)中,delete的操作数的值可以是空指针值、先前由new-expression创建的非数组对象的指针,或者表示该对象基类的子对象(Clause 10)的子对象。否则,行为是未定义的。

如果标准库中的取消分配函数所给出的参数不是空指针值(4.10),取消分配函数应该取消分配由指针引用的存储,使所有指向取消分配存储任何部分的指针无效。通过无效指针值间接引用和通过无效指针值传递到取消分配函数都具有未定义的行为。对于无效指针值的任何其他使用具有实现定义的行为。

...

如果delete表达式的操作数的值不是空指针值,则

  • 如果new-expression用于创建要删除的对象的分配调用未被省略且分配未被扩展(5.3.4),则delete-expression应调用取消分配函数(3.7.4.2)。新表达式的分配调用返回的值应作为第一个参数传递给取消分配函数。

  • 否则,如果分配被扩展或通过扩展另一个new-expression的分配提供,并且已评估由扩展new-expression提供存储的每个指针值的其他new-expression的delete-expression,则delete-expression应调用取消分配函数。扩展new-expression的分配调用返回的值应作为第一个参数传递给取消分配函数。

    • 否则,delete-expression将不会调用取消分配函数

否则,未指定是否将调用取消分配函数。

标准规定了如果指针不为空应该执行什么操作。暗示在这种情况下delete是noop,但目的是什么未被指定。


4
说如果指针不为空应该做什么并不意味着当指针为空时会发生任何事情。 - rustyx
@RustyX 它指出 null 参数是已定义的行为,并说明如果它不是 null 应该怎么做。如果它为 null,则无需执行任何操作,但格式模糊,并且自 C++0x 以来就一直是模糊的。至少它没有像取消引用空指针的定义那样散布在文档中。 - Swift - Friday Pie

7

1
有人可能期望它可以将整个函数折叠为noop,甚至排除调用?XD - Swift - Friday Pie
7
@Swift 已经解释了, xor eax,eax 的作用是让 main() 返回0,这是符合标准要求的。 - Richard Hodges
2
啊,是main()函数,我漏看了那一部分,请原谅。 - Swift - Friday Pie
这怎么回答问题了?听起来你只是在重复问题中已经提到的一些背景信息。 - Don Hatch
@RichardHodges 你确定吗?我看不到你的回答解答了这十个修订版本中任何一个所问的问题。clang确实省略了测试的事实在第一个修订版本中就已经说明了,而且从未改变过。你回答的是什么问题? - Don Hatch
显示剩余3条评论

6

在保证正确性的情况下,让程序使用nullptr调用operator delete是安全的。

就性能而言,编译器生成的汇编代码实际上多做一个测试和条件分支来跳过对operator delete的调用的情况非常少见,这不会带来优势。(但是您可以帮助gcc优化掉编译时的nullptr删除而不添加运行时检查;请参见下文)

首先,大量的代码大小会增加L1I缓存以及一些x86 CPU(Intel SnB系列,AMD Ryzen)中更小的解码uop缓存的压力。

其次,额外的条件分支会占用分支预测缓存中的条目(BTB = Branch Target Buffer等等)。根据CPU的不同,即使从未执行,一个分支可能会恶化其他分支的预测结果,如果它们在BTB中别名。 (在其他CPU上,这样的分支永远不会在BTB中获得条目,以节省条目以用于默认静态预测为fall-through的分支。)请参见https://xania.org/201602/bpu-part-one

如果nullptr在给定的代码路径中很少出现,则平均而言,检查并分支避免调用会导致程序在检查上花费的时间比检查节省的时间还要多。

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

与operator delete内部的分支相比,分支预测在那个调用站点可能有更好的运气,特别是如果与其他附近的分支存在任何相关性。 (显然,现代BPUs不仅仅看每个分支的孤立情况。)这还可以节省调用库函数时的无条件call(加上PLT存根的另一个jmp,Unix / Linux上的动态链接开销)。


如果您出于任何其他原因检查null,则将delete放在代码的非空分支中可能是有意义的。

您可以避免在gcc证明(内联后)指针为null的情况下进行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时,由于它会在内联之前评估__builtin_constant_p,因此它将始终返回false。但是,由于clang已经在可以证明指针为空时跳过删除调用,所以您不需要它。这实际上可能有助于std :: move案例,并且您可以在任何地方安全地使用它(理论上)没有性能缺陷。我总是编译为if(true)或if(false),因此它与if(ptr)非常不同,后者很可能会导致运行时分支,因为编译器在大多数情况下可能无法证明指针是非空的(尽管解引用可能会,因为空解引用将是未定义的,并且现代编译器根据假设进行优化,即代码不包含任何UB)。您可以将其制作为宏,以避免膨胀非优化构建(因此它将“工作”而不必首先进行内联)。您可以使用GNU C语句表达式来避免双重评估宏参数(请参见GNU C min()和max()的示例)。对于没有GNU扩展的编译器的回退,您可以编写((ptr),false)或类似的内容,以产生一次副作用的评估结果,同时产生false结果。
演示:在Godbolt编译器资源管理器上,通过gcc6.3 -O3的汇编代码 上的汇编代码进行了翻译。
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编译器一起编译是正确的(链接到compiler explorer),但是由于测试始终返回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函数将对象大小作为函数参数(mov edx, 4),但gcc/Linux/libstdc++代码只传递指针。
相关:我发现
这篇博客文章,使用C11(而不是C++11)的_Generic来尝试在静态初始化器中进行类似于__builtin_constant_p的空指针检查。

2

我认为编译器对于 "delete" 操作并没有很好的理解,特别是 "delete null" 是无效操作。

你可以明确的指定,这样编译器就不需要暗示关于 delete 的知识。

警告:我不建议将此作为通用实现。以下示例仅展示如何在非常特殊和有限的程序中“说服”受限制的编译器删除代码。

int main() {
 int* ptr = nullptr;

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

我记得有一种方法可以用自己的函数替换“delete”。当编译器的优化出现问题时,这种方法会很有用。
@RichardHodges:如果给编译器提示去掉一个调用,为什么它要变成非优化呢?
通常情况下,delete null是无操作的。但是,由于可以替换或覆盖delete,因此并不保证所有情况都能如此。
所以,要看编译器是否知道并决定使用删除null的知识。对于这两种选择都有充分的理由。
然而,编译器总是允许删除死代码,例如“if (false) {...}”或“if (nullptr != nullptr) {...}”。
因此,编译器将删除死代码,然后在使用显式检查时,它看起来像是:
int main() {
 int* ptr = nullptr;

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

请告诉我,哪里有反优化的地方?

我称我的建议为一种防御性编码风格,而不是反优化。

如果有人认为现在的非空指针会导致对空指针进行两次检查,我必须回复:

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

@Peter Cordes:我同意用if保护不是一般的优化规则。然而,一般的优化不是提问者的问题。问题是为什么有些编译器不能消除一个非常简短、毫无意义的程序中的delete。我展示了一种方法来让编译器消除它。

如果发生像那个简短程序中的情况,可能还有其他问题。通常情况下,我会尽量避免new/delete(malloc/free),因为这些调用相当昂贵。如果可能的话,我更喜欢使用堆栈(auto)。

当我看到现在已经记录的真实情况时,我会说,类X的设计有问题,导致性能不佳,内存使用过多。(https://godbolt.org/g/7zGUvo

而不是

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

在设计中
class X {
  int i;
  bool valid;
  public:
  ...

更早之前,我会询问对于空/无效项目的排序意义。最终,我也想摆脱“有效”这个词。


1
它是'operator delete[]'和'operator delete'。 - Swift - Friday Pie
1
删除明确检查 nullptr。这段代码是去优化。 - Richard Hodges
@Peter Cordes:我在答案中会做更详细的解释。 - stefan bachert
1
@stefanbachert 当然,类X是无意义的,它只是一个基准代码。但是,对于任何具有拥有成员指针的类(例如持有字符指针的字符串类,持有其元素指针的向量类等),其行为都将完全相同。问题不在于类X的含义,而在于需要调用delete的移动赋值运算符和析构函数的通用情况,在这种情况下,在空指针上调用了3次std::swap - Daniel Langr
1
@PeterCordes 如果我有时间的话,我会这么做,你也可以试试 :). 但是我更喜欢 if (ptr) delete ptr; 的解决方案,因为它适用于GCC并且具有可移植性。 - Daniel Langr
显示剩余4条评论

2

首先,我同意之前回答者的观点,这不是一个 bug,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();
}

如您所见 在这里 ,GCC确实省略了对setA1中第二次调用delete 的操作(对于在调用getA2 时创建的已移动且未使用的临时对象)。由于a1a1.ptr 可能已被先前分配,因此第一次调用是必要的,以确保程序的正确性。

显然,我更喜欢更多的“逻辑和原理”——为什么有时会进行优化但有时不会——但是我现在不愿意在我的RAII代码上到处添加冗余的if ( ptr!= nullptr)检查。


你所看到的是RVO的效果。 - Richard Hodges
如果代码片段可能经常进行空值删除,且对象的性质使得可能存在大量集合,则我不愿意添加冗余检查。但是,如果考虑到检查与函数调用相比非常便宜,那么添加检查可能就不是过早优化了。在最坏的情况下造成的损失基本上是微不足道的,而从最新的问题编辑(基准测试)来看,受益可能很大。 - hyde

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