为什么GCC不能优化删除C ++中的空指针呢?
考虑一个简单的程序:
int main() { int* ptr = nullptr; delete ptr; }
使用GCC(7.2),在结果程序中有一个关于operator delete
的call
指令。 使用Clang和Intel编译器,不存在这样的指令,空指针删除被完全优化(在所有情况下都是-O2
)。 你可以在这里testing: https : //godbolt.org/g/JmdoJi 。
我不知道这样的优化是否可以用GCC打开? (我更广泛的动机源于自定义swap
与可移动types的std::swap
的问题,在第二种情况下删除空指针可能代表性能损失;请参阅https://stackoverflow.com/a/45689282/580083细节。)
UPDATE
为了澄清我的问题的动机:如果我使用只是delete ptr;
if (ptr)
在一个移动赋值操作符和某个类的析构函数中没有if (ptr)
guard,那么std::swap
与该类的对象产生3个与GCC的call
指令。 这可能是一个相当大的性能损失,例如,sorting这样的对象数组时。
而且,我可以写if (ptr) delete ptr;
无处不在,但是,不知道这是否也不是一个性能损失,因为delete
expression式也需要检查ptr
。 但是,在这里,我想,编译器只会生成一个单一的检查。
另外,我真的很喜欢没有防范的情况下调用delete
的可能性,这对我来说是一个惊喜,它可能会产生不同的(性能)结果。
UPDATE
我只做了一个简单的基准testing,即sorting对象,在移动赋值运算符和析构函数中调用delete
。 来源在这里: https : //godbolt.org/g/7zGUvo
在至强E2680v3上使用GCC 7.1和-O2
标志测量std::sort
运行时间:
链接的代码中有一个错误,它比较指针,而不是指向值。 更正的结果如下:
- 没有防守:
17.6 [s]40.8 [s] , -
if
守卫:10.6 [s]31.5 [s] , -
if
警卫和海关swap
:10.4 [s]31.3 [s]。
这些结果在许多运行中是绝对一致的,而且偏差最小。 前两种情况之间的performance差异是显着的,我不会说这是一些像代码一样的“非常罕见的情况”。
根据C ++ 14 [expr.delete] / 7:
如果delete-expression的操作数的值不是空指针值,则:
- […略去…]
否则,未指定是否调用释放函数。
所以这两个编译器都符合这个标准,因为没有指定operator delete
空指针是否需要operator delete
。
请注意,godbolt在线编译器只是编译源文件而没有链接。 所以编译器在这个阶段必须考虑到operator delete
将被另一个源文件replace的可能性。
正如在另一个答案中已经推测的那样 – 在replaceoperator delete
的情况下,gcc可能会争取一致的行为; 这个实现意味着有人可以为了debugging的目的而重载这个函数,并且中断所有的delete
expression式的调用,甚至当它碰巧正在删除一个空指针的时候。
更新:删除了猜测,这可能不是一个实际的问题,因为OP提供的基准,表明它实际上是。
标准实际上指出何时调用分配和释放函数,哪些不是。 这个条款(@ n4296)
该库提供了全局分配和释放函数的默认定义。 一些全球分配和释放function是可以replace的(18.6.1)。 C ++程序至多应该提供一个可replace的分配或释放函数的定义。 任何这样的函数定义都会replace库中提供的默认版本(17.6.4.6)。 在程序的每个翻译单元中,下面的分配和释放函数(18.6)在全局范围内被隐式地声明。
可能会是这些函数调用不被任意省略的主要原因。 如果是这样,replace它们的实现会导致编译程序的不一致的function。
在第一种replace(删除对象)中,删除操作数的值可以是空指针值,指向由前一个新expression式创build的非数组对象的指针或指向代表这样一个对象的基类(第10章)。 如果不是,则行为是不确定的。
如果在标准库中赋予释放函数的参数是一个不是空指针值的指针(4.10),则释放函数将释放由该指针引用的存储器,使所有指向无效分配存储器的部分的指针无效。 通过一个无效的指针值的间接方式,并将一个无效的指针值传递给一个释放函数具有未定义的行为。 任何其他使用无效的指针值都有实现定义的行为。
…
如果delete-expression的操作数的值不是空指针值,那么
如果没有忽略要删除的对象的新expression式的分配调用,并且分配没有被扩展(5.3.4),那么delete-expression将调用一个释放函数(3.7.4.2)。 从new-expression的分配调用返回的值将作为第一个parameter passing给释放函数。
否则,如果扩展分配或通过扩展另一个新expression式的分配来提供分配,并且已经评估了由具有由扩展的新expression式提供的存储的新expression式产生的每个其他指针值的delete-expression,expression式应该调用一个释放函数。 从扩展的新expression式的分配调用返回的值将作为第一个parameter passing给释放函数。
- 否则,delete-expression将不会调用释放函数
否则,未指定是否调用释放函数。
标准状态如果指针不为null应该做什么。 暗示删除在这种情况下是noop,但是到底是什么,没有指定。
它总是安全的(为了正确)让你的程序调用一个nullptr的operator delete
。
对于性能来说,使用编译器生成的asm实际上做一个额外的testing是非常罕见的,条件分支跳过对operator delete
的调用将是一个胜利。 (你可以帮助gcc优化编译时nullptr
删除而不添加运行时检查,但是;见下文)。
首先,实际热点以外的更大的代码尺寸增加了对L1I高速caching的压力,以及x86 CPU上具有一个(Intel SnB系列,AMD Ryzen)的更高解码优先级高速caching。
其次,额外的条件分支在分支预测高速caching中使用条目(BTB =分支目标缓冲区等等)。 根据CPU,即使是从未采取的分支,如果在BTB中将其别名,也可能会恶化对其他分支的预测。 (在其他情况下,这样的分支永远不会在BTB中获得条目,以保存默认的默认静态预测准确的分支条目。)请参阅https://xania.org/201602/bpu-part-one 。
如果nullptr
在给定的代码path中是罕见的,那么在平均检查&分支以避免call
结束与您的程序在检查上花费更多的时间比检查保存。
如果性能分析显示你有一个包含delete
的热点,并且instrumentation / logging显示它通常实际上使用nullptr调用delete
,那么值得尝试
if (ptr) delete ptr;
而不是只是delete ptr;
在一个调用网站中,分支预测可能比operator delete
的分支有更好的运气,特别是如果与其他附近分支有任何关联。 (显然,现代的BPUs不只是单独看每个分支)。这是将无条件call
保存到库函数(加上另一个来自PLT存根的jmp
,来自Unix / Linux上的dynamic链接开销)之上的。
如果您为其他原因检查null,那么将delete
放入代码的非null分支内可能有意义。
在gcc可以certificate(在内联之后)一个指针为空的情况下,你可以避免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 }
它会一直返回false,因为它__builtin_constant_p
联之前评估__builtin_constant_p
。 但是,因为铿锵已经可以delete
调用,当它可以certificate一个指针为空,你不需要它。
这实际上可能有助于std::move
案件,并且可以在任何地方安全地使用它(理论上)没有性能下降。 我总是编译为if(true)
或if(false)
,所以它与if(ptr)
非常不同,因为编译器在大多数情况下可能无法certificate指针是非空的无论是。 (一个解引用可能会,因为一个空deref将是UB,并且现代编译器根据代码不包含任何UB的假设进行优化)。
你可以把它作为一个macros来避免膨胀非优化版本(所以它将“工作”,而不必先内联)。 您可以使用GNU C语句expression式来避免对macrosarg进行双重评估( 请参阅GNU C min()
和max()
示例 )。 对于没有GNU扩展的编译器的回退,你可以编写((ptr), false)
或者某个东西来评估一次副作用的arg,同时产生一个false
结果。
演示: 在Godbolt编译器资源pipe理器中的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
它编译与MSVC正确(也在编译器资源pipe理器链接),但与testing总是返回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 ++代码只是传递指针。
相关:我发现这个博客文章 ,使用C11(而不是C ++ 11) _Generic
试图可移植做类似__builtin_constant_p
空指针检查里面的静态初始化。
首先,我会同意一些以前的回答者,因为这不是一个错误,而GCC可以按照它的意愿去做。 也就是说,我想知道这是否意味着一些常见和简单的RAII代码在GCC上可能比Clang慢,因为没有直接的优化。
于是我写了一个RAII的小testing用例:
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
(对于在调用中创build的移动临时getA2
)。 第一次调用是程序正确性所必需的,因为a1
或a1.ptr
可能已经被赋值。
很明显,我更喜欢更多的“韵律和理性” – 为什么有时候做优化,但并不总是 – 但是if ( ptr != nullptr )
检查了我所有的RAII代码,我不愿意多余。
我觉得,编译器对“删除”没有任何的了解,特别是“删除null”是一个NOOP。
你可以直接写出来,所以编译器不需要暗示关于删除的知识。
警告:我不build议这作为一般的实现。 下面的例子应该显示,如何在一个非常特殊和有限的程序中“说服”有限的编译器来删除代码
int main() { int* ptr = nullptr; if (ptr != nullptr) { delete ptr; } }
在我记得的地方,有一种方法可以用自己的函数replace“删除”。 在这种情况下,编译器的优化会出错。
@RichardHodges:当给编译器提示删除一个调用时,为什么它应该是一个去优化?
删除null通常是一个NOOP(无操作)。 但是,由于可以replace或覆盖删除,因此不存在所有情况的担保。
因此,编译器知道并决定是否使用删除null的知识总是可以删除。 这两种select都有很好的理由
然而,编译器总是允许删除死的代码,这个“if(false){…}”或“if(nullptr!= nullptr){…}”
所以编译器会删除死代码,然后使用明确的检查,看起来像
int main() { int* ptr = nullptr; // dead code if (ptr != nullptr) { // delete ptr; // } }
请告诉我,哪里有去优化?
我把我的build议称为防御性的编码风格,但不是去优化
如果有人可能会争论,现在non-nullptr会导致nullptr两次检查,我不得不回复
- 对不起,这不是原来的问题
- 如果编译器知道删除,特别是删除null是一个noop,比编译器可以删除外部,如果任一。 不过,我不希望编译器如此具体
@彼得·科德斯:我同意守着一个如果不是一般的优化规则。 不过,一般的优化并不是开门红的问题。 问题是为什么一些编译器不能在非常短的无意义程序中删除删除操作。 我展示了一种让编译器消除它的方法。
如果在这个简短的程序中出现一个情况,可能是另外一个错误。 一般来说,我会尽量避免新/删除(malloc / free),因为这些调用相当昂贵。 如果可能,我更喜欢使用堆栈(自动)。
当我看一下同时logging的真实案例时,我会说Xclass的devise是错误的,造成了糟糕的performance和太多的记忆。 ( https://godbolt.org/g/7zGUvo )
代替
class X { int* i_; public: ...
在devise中
class X { int i; bool valid; public: ...
或更早,我会问sorting空/无效项目的意义。 最后我也想摆脱“有效”。