Почему GCC не оптимизирует удаление нулевых указателей в C++?
Рассмотрим простую программу:
int main() {
int* ptr = nullptr;
delete ptr;
}
С GCC (7.2), есть call
инструкция относительно operator delete
в результирующей программе. С компиляторами Clang и Intel таких инструкций нет, удаление нулевого указателя полностью оптимизировано (-O2
во всех случаях). Вы можете проверить здесь: https://godbolt.org/g/JmdoJi.
Интересно, можно ли как-то включить такую оптимизацию с GCC? (Моя более широкая мотивация проистекает из проблемы обычая swap
против std::swap
для подвижных типов, где удаление нулевых указателей может представлять снижение производительности во втором случае; см. /questions/9196097/peremestit-semantiku-polzovatelskaya-funktsiya-podkachki-ustarela/9196123#9196123 для получения дополнительной информации.)
ОБНОВИТЬ
Чтобы уточнить мою мотивацию на вопрос: если я использую только delete ptr;
без if (ptr)
охранник в операторе присваивания ходов и деструктор некоторого класса, затем std::swap
с объектами этого класса дает 3 call
инструкции с GCC. Это может быть значительным снижением производительности, например, при сортировке массива таких объектов.
Более того, я могу написать if (ptr) delete ptr;
везде, но задаюсь вопросом, не может ли это быть также и ухудшением производительности, так как delete
выражение нужно проверить ptr
также. Но здесь, я думаю, компиляторы будут генерировать только одну проверку.
Также мне очень нравится возможность звонить delete
без охраны, и для меня было неожиданностью, что это может привести к другим (производительность) результатам.
ОБНОВИТЬ
Я просто сделал простой тест, а именно сортировку объектов, которые вызывают delete
в их движении присваивается оператор и деструктор. Источник находится здесь: https://godbolt.org/g/7zGUvo
Время работы std::sort
измерено с помощью GCC 7.1 и -O2
флаг на Xeon E2680v3:
В связанном коде есть ошибка, она сравнивает указатели, а не указанные значения. Исправлены следующие результаты:
- без
if
защита:17,6 [с]40,8 [с], - с
if
защита:10,6 [с]31,5 [с], - с
if
охрана и обычайswap
:10,4 [с]31,3 [с].
Эти результаты были абсолютно последовательными во многих прогонах с минимальным отклонением. Разница в производительности между первыми двумя случаями значительна, и я бы не сказал, что это какой-то "чрезвычайно редкий случай", подобный коду.
6 ответов
Согласно C++14 [expr.delete]/7:
Если значение операнда выражения удаления не является нулевым значением указателя, то:
- [... опущено... ]
В противном случае не определено, будет ли вызвана функция освобождения.
Таким образом, оба компилятора соответствуют стандарту, потому что не указано, operator delete
вызывается для удаления нулевого указателя.
Обратите внимание, что онлайн-компилятор godbolt просто компилирует исходный файл без ссылок. Таким образом, компилятор на этом этапе должен учитывать возможность того, что operator delete
будет заменен другим исходным файлом.
Как уже предполагалось в другом ответе, в случае замены gcc может работать согласованно. operator delete
; эта реализация будет означать, что кто-то может перегрузить эту функцию в целях отладки и прервать все вызовы delete
выражение, даже когда это произошло, удалив нулевой указатель.
ОБНОВЛЕНО: Удалено предположение, что это не может быть практической проблемой, поскольку OP предоставил тесты, показывающие, что это действительно так.
Это проблема QOI. Clang действительно проходит тест
main: # @main
xor eax, eax
ret
Стандарт фактически указывает, когда должны быть вызваны функции распределения и освобождения, а где нет. Этот пункт (@ n4296)
Библиотека предоставляет определения по умолчанию для глобальных функций выделения и освобождения. Некоторые глобальные функции распределения и освобождения являются заменяемыми (18.6.1). Программа на C++ должна содержать не более одного определения заменяемой функции выделения или освобождения. Любое такое определение функции заменяет версию по умолчанию, предоставленную в библиотеке (17.6.4.6). Следующие функции выделения и освобождения (18.6) неявно объявляются в глобальной области видимости в каждой единице перевода программы.
вероятно, это будет основной причиной, по которой эти вызовы функций не опускаются произвольно. Если бы они были, замена их реализации библиотеки привела бы к несогласованной функции скомпилированной программы.
В первом варианте (объект удаления) значением операнда удаления может быть значение нулевого указателя, указатель на объект, не являющийся массивом, созданный предыдущим новым выражением, или указатель на подобъект (1.8), представляющий базовый класс такого объекта (п. 10). Если нет, поведение не определено.
Если аргумент, данный функции освобождения в стандартной библиотеке, является указателем, который не является нулевым значением указателя (4.10), функция освобождения должна освободить хранилище, на которое ссылается указатель, делая недействительными все указатели, ссылающиеся на любую часть освобожденного хранилища., Направление через недопустимое значение указателя и передача недопустимого значения указателя в функцию освобождения имеют неопределенное поведение. Любое другое использование недопустимого значения указателя имеет поведение, определяемое реализацией.
...
Если значение операнда выражения удаления не является нулевым значением указателя, тогда
Если вызов выделения для нового выражения для удаляемого объекта не был опущен и распределение не было расширено (5.3.4), выражение удаления должно вызвать функцию освобождения (3.7.4.2). Значение, возвращаемое из вызова выделения нового выражения, должно быть передано в качестве первого аргумента функции освобождения.
В противном случае, если выделение было расширено или было предоставлено путем расширения выделения другого выражения new, и выражение delete для каждого другого значения указателя, созданного выражением new, у которого была память, предоставленная расширенным выражением new, было оценено, удаление -выражение должно вызывать функцию освобождения. Значение, возвращаемое из вызова выделения расширенного выражения new, должно быть передано в качестве первого аргумента функции освобождения.
- В противном случае выражение удаления не будет вызывать функцию освобождения
В противном случае не определено, будет ли вызвана функция освобождения.
Стандарт заявляет, что должно быть сделано, если указатель НЕ является нулевым. Подразумевается, что delete в этом случае является noop, но с какой целью не указывается.
Всегда безопасно (для правильности) позволить вашей программе вызывать operator delete
с nullptr.
Что касается производительности, очень редко, когда сгенерированный компилятором asm фактически выполняет дополнительный тест и условную ветвь, чтобы пропустить вызов operator delete
будет победа. (Вы можете помочь gcc оптимизировать время компиляции nullptr
удаление без добавления проверки времени выполнения; увидеть ниже).
Прежде всего, больший размер кода вне реальной горячей точки увеличивает нагрузку на кэш-память L1I и еще меньший кэш декодированного доступа на процессорах x86, которые имеют один (семейство Intel SnB, AMD Ryzen).
Во-вторых, дополнительные условные ветвления используют записи в кэшах предсказания ветвлений (BTB = целевой буфер ветвления и т. Д.). В зависимости от ЦП, даже ветвь, которая никогда не используется, может ухудшить предсказания для других ветвей, если она объединит их в BTB. (В других случаях такая ветвь никогда не получает запись в BTB, чтобы сохранить записи для ветвей, в которых статический прогноз сбоя по умолчанию точен.) См. https://xania.org/201602/bpu-part-one.
Если nullptr
редко встречается в данном пути кода, тогда в среднем выполняется проверка и переход, чтобы избежать call
В итоге ваша программа тратит на чек больше времени, чем сохраняет чек.
Если профилирование показывает, у вас есть горячая точка, которая включает в себя delete
и инструментарий / ведение журнала показывает, что это часто на самом деле вызывает delete
с nullptr, то стоит попробовать if (ptr) delete ptr;
вместо просто delete ptr;
Прогнозирование ветки может оказаться более удачным в этом одном сайте вызова, чем для ветки внутри operator delete
особенно если есть какая-то связь с другими соседними ветками. (Очевидно, что современные BPU не просто смотрят на каждую ветвь изолированно.) Это на вершине сохранения безусловного call
в функцию библиотеки (плюс еще один jmp
из заглушки PLT, из-за динамического связывания в Unix/Linux).
Если вы проверяете на ноль по любой другой причине, то может иметь смысл поставить delete
внутри ненулевой ветви вашего кода.
Вы можете избежать 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
}
Он всегда будет возвращать false с Clang, потому что он оценивает __builtin_constant_p
до встраивания. Но так как лязг уже пропускает delete
вызовы, когда он может доказать, что указатель нулевой, он вам не нужен.
Это может реально помочь в std::move
случаи, и вы можете безопасно использовать его в любом месте без (теоретически) без снижения производительности. Я всегда компилирую if(true)
или же if(false)
так что это сильно отличается от if(ptr)
, что, вероятно, приведет к ветке времени выполнения, потому что компилятор, вероятно, не может доказать, что указатель также не равен нулю в большинстве случаев. (Тем не менее, разыменование может быть связано с тем, что нулевым разыменованием будет UB, а современные компиляторы оптимизированы на основе предположения, что код не содержит UB).
Вы можете сделать это макросом, чтобы избежать раздувания неоптимизированных сборок (и поэтому он будет "работать" без необходимости встраивать сначала). Вы можете использовать выражение-выражение GNU C, чтобы избежать двойной оценки макроса arg ( см. Примеры для GNU C). min()
а также max()
). Для отступления для компиляторов без расширений GNU, вы можете написать ((ptr), false)
или что-то, чтобы оценить arg один раз для побочных эффектов, производя false
результат.
Демонстрация: asm из gcc6.3 -O3 в проводнике компилятора Godbolt
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++ просто передает указатель.
Связанный: я нашел это сообщение в блоге, используя C11 (не C++11) _Generic
пытаться переносить что-то вроде __builtin_constant_p
нулевой указатель проверяет внутри статических инициализаторов.
Прежде всего, я просто согласен с некоторыми предыдущими ответчиками в том, что это не ошибка, и 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 делает второй вызов delete
в setA1
(для временного перемещения, созданного в вызове getA2
). Первый вызов необходим для корректности программы, потому что a1
или же a1.ptr
возможно, был ранее назначен.
Очевидно, что я предпочел бы больше "рифмы и рассудка" - почему оптимизация проводится иногда, но не всегда, - но я не хочу высыпать лишнее if ( ptr != nullptr )
проверяет весь мой код RAII только сейчас.
Я думаю, что компилятор не знает о "удалении", особенно о том, что "удалить ноль" - это NOOP.
Вы можете написать это явно, поэтому компилятору не нужно подразумевать знание об удалении.
ВНИМАНИЕ: я не рекомендую это как общую реализацию. Следующий пример должен показать, как можно "убедить" ограниченный компилятор удалить код в любом случае в этой очень специальной и ограниченной программе
int main() {
int* ptr = nullptr;
if (ptr != nullptr) {
delete ptr;
}
}
Там, где я помню, есть способ заменить "удалить" собственной функцией. И в случае, если оптимизация компилятором не состоялась.
@RichardHodges: Почему это должно быть де-оптимизацией, когда кто-то дает компилятору подсказку удалить вызов?
delete null - это вообще NOOP (без операции). Однако, поскольку можно заменить или перезаписать удаление, гарантия не распространяется на все случаи.
Поэтому компилятор должен знать и решать, использовать ли знания, которые удаляют null, всегда могут быть удалены. есть веские аргументы в пользу обоих вариантов
Тем не менее, компилятору всегда разрешается удалять мертвый код, это "if (false) {...}" или "if (nullptr!= Nullptr) {...}"
Таким образом, компилятор удаляет мертвый код, а затем при использовании явной проверки выглядит
int main() {
int* ptr = nullptr;
// dead code if (ptr != nullptr) {
// delete ptr;
// }
}
Подскажите пожалуйста, где тут де-оптимизация?
Я называю свое предложение защитным стилем кодирования, но не де-оптимизацией.
Если кто-то может поспорить, что теперь non-nullptr будет вызывать двукратную проверку на nullptr, я должен ответить
- Извините, это был не оригинальный вопрос
- если компилятор знает об удалении, особенно если удалить null - это noop, то компилятор может удалить внешнее, если тоже. Однако я бы не ожидал, что компиляторы будут такими конкретными
@Peter Cordes: Я согласен с тем, что if не является общим правилом оптимизации. Тем не менее, общая оптимизация не была вопросом новичка. Вопрос заключался в том, почему некоторые компиляторы не облегчают удаление в очень короткой бессмысленной программе. Я показал способ заставить компилятор устранить его в любом случае.
Если ситуация происходит, как в этой короткой программе, возможно, что-то другое не так. В общем, я бы старался избегать нового / удаления (malloc/free), так как звонки довольно дорогие. По возможности я предпочитаю использовать стек (авто).
Когда я смотрю на реальный документированный случай, я бы сказал, что класс X спроектирован неправильно, что приводит к снижению производительности и увеличению объема памяти. ( https://godbolt.org/g/7zGUvo)
Вместо
class X {
int* i_;
public:
...
в дизайне
class X {
int i;
bool valid;
public:
...
или более ранее, я бы спросил о смысле сортировки пустых / недействительных элементов. В конце я бы тоже хотел избавиться от "действительного".