Почему GCC не оптимизирует удаление нулевых указателей в С++?

Рассмотрим простую программу:

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 для подвижных типов, где удаление нулевых указателей может представлять собой штраф за производительность во втором случае, см. qaru.site/info/19211/... для получения более подробной информации.)

UPDATE

Чтобы прояснить мою мотивацию для вопроса: если я использую только delete ptr; без if (ptr) guard в операторе присваивания перемещения и деструкторе некоторого класса, то std::swap с объектами этого класса выдает команды 3 call с GCC. Это может быть значительным снижением производительности, например, при сортировке массива таких объектов.

Кроме того, я могу писать if (ptr) delete ptr; всюду, но задаюсь вопросом, не может ли это быть и штрафом за производительность, поскольку выражение delete должно также проверять ptr. Но, здесь, я думаю, компиляторы будут генерировать только одну проверку.

Кроме того, мне очень нравится возможность вызова delete без охраны, и для меня это было неожиданностью, что он может дать разные результаты (производительности).

UPDATE

Я просто сделал простой тест, а именно сортировку объектов, которые вызывают delete в их операторе присваивания и деструкторе. Источник находится здесь: https://godbolt.org/g/7zGUvo

Время работы std::sort, измеренное с помощью знака GCC 7.1 и -O2 на Xeon E2680v3:

В связанном коде есть ошибка, она сравнивает указатели, а не точные значения. Исправлены следующие результаты:

  • без if guard: 17.6 [s] 40.8 [s],
  • с if guard: 10.6 [s] 31.5 [s],
  • с if защитой и пользовательским swap: 10.4 [s] 31.3 [s].

Эти результаты были абсолютно согласованы во многих пробегах с минимальным отклонением. Разница в производительности между первыми двумя случаями значительна, и я бы не сказал, что это какой-то "чрезвычайно редкий угловой случай", такой как код.

Ответ 1

В соответствии с С++ 14 [expr.delete]/7:

Если значение операнда выражения-удаления не является значением нулевого указателя, то:

  • [... опущено...]

В противном случае не указано, будет ли вызвана функция освобождения.

Таким образом, оба компилятора соответствуют стандарту, потому что не указано, вызывается ли operator delete для удаления нулевого указателя.

Обратите внимание, что онлайн-компилятор godbolt просто компилирует исходный файл без ссылки. Поэтому компилятор на этом этапе должен учитывать возможность того, что operator delete будет заменен другим исходным файлом.

Как уже было сказано в другом ответе - gcc может быть ориентирован на согласованное поведение в случае замены operator delete; эта реализация будет означать, что кто-то может перегрузить эту функцию для целей отладки и разбить все вызовы выражения delete, даже когда это произошло, чтобы удалить нулевой указатель.

ОБНОВЛЕНО: Устранены предположения, что это может не быть практической проблемой, поскольку OP предоставила контрольные показатели, показывающие, что это на самом деле.

Ответ 2

Стандарт фактически указывает, когда должны быть вызваны функции распределения и освобождения, а где нет. Этот раздел (@n4296)

Библиотека предоставляет определения по умолчанию для глобального распределения и функции освобождения. Некоторые глобальные распределения и освобождение функции сменяемы (18.6.1). Программа на С++ должна предоставлять на самое одно определение сменного распределения или освобождения функция. Любое такое определение функции заменяет версию по умолчанию представленный в библиотеке (17.6.4.6). Следующие распределения и функции освобождения (18.6) объявляются неявно в глобальной области в каждой единицы перевода программы.

вероятно, будет основной причиной, почему эти вызовы функций не пропущены произвольно. Если бы они были, замена их реализации библиотеки вызовет некогерентную функцию скомпилированной программы.

В первом альтернативе (объект удаления) значение операнда delete может быть значением нулевого указателя, указателем на объект без массива созданный предыдущим новым выражением, или указатель на подобъект (1.8), представляющий базовый класс такого объекта (п. 10). Если не, поведение undefined.

Если аргумент, присвоенный функции дезадаптации в стандартном библиотека - это указатель, который не является значением нулевого указателя (4.10), функция освобождения освобождает место хранения, на которое ссылается указатель, отображающий недействительные все указатели, ссылающиеся на любую часть освобожденное хранилище. Направление с помощью недопустимого значения указателя и передача недопустимого значения указателя функции освобождения undefined. Любое другое использование недопустимого значения указателя имеет поведение, определяемое реализацией.

...

Если значение операнда выражения-удаления не является нулевым значение указателя, затем

  • Если вызов выделения для нового выражения для объекта, который должен быть удален, не был опущен, а распределение не было расширено (5.3.4), выражение-выражение должно вызывать функцию освобождения (3.7.4.2). Значение, возвращаемое из вызова выделения нового выражения должен быть передан как первый аргумент функции освобождения.

  • В противном случае, если распределение было расширено или было предоставлено путем расширения выделения другого нового выражения и выражения-удаления для любого другого значения указателя, созданного новым выражением, имеющим хранилище, предоставленное расширенным новым выражением была оценена, выражение-выражение должно вызвать функция освобождения. Значение, возвращаемое из вызова распределения расширенное новое выражение должно быть передано в качестве первого аргумента функция освобождения.

    • В противном случае выражение delete не будет вызывать функцию освобождения

В противном случае не указано, будет ли вызвана функция освобождения.

Стандартные состояния, что должно быть сделано, если указатель НЕ равен нулю. Предположим, что удаление в этом случае noop, но с какой целью не указано.

Ответ 3

Это вопрос QOI. clang действительно подтверждает тест:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret

Ответ 4

Всегда безопасно (для правильности), чтобы ваша программа вызывала operator delete с помощью nullptr.

Для производительности очень редко бывает, что наличие компилятора asm на самом деле делает дополнительную тестовую и условную ветку, чтобы пропустить вызов operator delete будет победой. (Вы можете помочь gcc оптимизировать удаление времени компиляции nullptr без добавления проверки времени выполнения, см. Ниже).

Прежде всего, больший размер кода за пределами реального "горячего" места увеличивает давление на кеш L1I и еще меньше кэша декодированного-uop на процессорах 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).


Если вы проверяете значение null по любой другой причине, тогда имеет смысл разместить delete внутри ненулевой ветки вашего кода.

Вы можете избежать вызовов delete в случаях, когда gcc может доказать (после вложения), что указатель имеет значение NULL, но без проверки времени выполнения, если не:

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 перед встраиванием. Но поскольку clang уже пропускает вызовы delete, когда он может доказать, что указатель имеет значение null, он вам не нужен.

Это может реально помочь в случаях std::move, и вы можете безопасно использовать его в любом месте (теоретически) без снижения производительности. Я всегда компилируется в if(true) или if(false), поэтому он сильно отличается от if(ptr), что может привести к ветвлению во время выполнения, потому что компилятор, вероятно, не может доказать, что указатель в большинстве случаев также не равен null. (Разница может быть, однако, потому что null deref будет 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      [email protected][email protected]      ; operator delete

Интересно отметить, что MSVC operator delete принимает размер объекта как функция arg (mov edx, 4), но код gcc/Linux/libstdС++ просто передает указатель.


Related: Я нашел этот пост в блоге, используя C11 (не С++ 11) _Generic, чтобы попытаться сделать что-то вроде портативного __builtin_constant_p null-pointer проверяет внутри статических инициализаторов.

Ответ 5

Я думаю, что компилятор не знает об "удалении", особенно о том, что "удалить ноль" является 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, я должен ответить

  1. Извините, это был не оригинальный вопрос
  2. если компилятор знает об удалении, особенно о том, что delete 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:
  ...

или более ранее, я бы спросил о смысле сортировки пустых/недействительных элементов. В конце я бы тоже хотел избавиться от "действительного".

Ответ 6

Прежде всего, я соглашусь с некоторыми предыдущими респондентами в том, что это не ошибка, и 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.