Предоставляет ли компилятор возможность оптимизировать распределение памяти кучи?

Рассмотрим следующий простой код, который использует new (я знаю, что delete[], но он не относится к этому вопросу):

int main()
{
    int* mem = new int[100];

    return 0;
}

Разрешено ли компилятору оптимизировать new вызов?

В моем исследовании g++ (5.2.0) и Visual Studio 2015 не оптимизируют new вызов, в то время как clang (3. 0+) это оптимизирует. Все тесты были выполнены с включенной полной оптимизацией (-O3 для g++ и clang, режим выпуска для Visual Studio).

Разве new не делает системный вызов под капотом, не делает невозможным (и незаконным) компилятор оптимизировать это?

РЕДАКТИРОВАТЬ: я исключил неопределенное поведение из программы:

#include <new>  

int main()
{
    int* mem = new (std::nothrow) int[100];
    return 0;
}

Clang 3.0 больше не оптимизирует это, но более поздние версии делают.

EDIT2:

#include <new>  

int main()
{
    int* mem = new (std::nothrow) int[1000];

    if (mem != 0)
      return 1;

    return 0;
}

Clang всегда возвращает 1.

Ответ 1

История, кажется, такова, что clang следует правилам, изложенным в N3664: "Уточнение распределения памяти", которое позволяет компилятору оптимизировать распределение памяти, но, как отмечает Ник Левики:

Шафик указал, что это, кажется, нарушает причинно-следственную связь, но N3664 начал жизнь как N3433, и я почти уверен, что сначала мы написали оптимизацию, а потом написали статью позже.

Таким образом, clang реализовал оптимизацию, которая позже стала предложением, которое было реализовано как часть C++ 14.

Основной вопрос заключается в том, является ли это действительной оптимизацией до N3664, это сложный вопрос. Мы должны были бы перейти к правилу " как будто", описанному в проекте стандарта C++, раздел 1.9 Выполнение программы", которое гласит (выделено мое):

Семантические описания в этом международном стандарте определяют параметризованную недетерминированную абстрактную машину. Настоящий международный стандарт не предъявляет требований к структуре соответствующих реализаций. В частности, им не нужно копировать или эмулировать структуру абстрактной машины. Скорее, соответствующие реализации требуются для эмуляции (только) наблюдаемого поведения абстрактной машины, как объяснено ниже. 5

где примечание 5 говорит:

Это положение иногда называют правилом "как если бы", потому что реализация может свободно игнорировать любое требование настоящего Международного Стандарта при условии, что в результате будет выполнено требование, насколько это можно определить из наблюдаемого поведения. программы. Например, фактическая реализация не должна оценивать часть выражения, если она может сделать вывод, что ее значение не используется и что не возникает никаких побочных эффектов, влияющих на наблюдаемое поведение программы.

Так как new может выдать исключение, которое будет иметь наблюдаемое поведение, так как оно изменит возвращаемое значение программы, это может привести к тому, что оно будет разрешено правилом "как будто".

Хотя можно утверждать, что это деталь реализации, когда вызывать исключение, и поэтому clang может решить, что даже в этом сценарии это не вызовет исключения, и, следовательно, исключение new вызова не будет нарушать правило "как будто".

Кроме того, согласно правилу "как-если-то" и в самом деле можно оптимизировать вызов не-бросающей версии.

Но мы могли бы иметь заменяющий глобальный оператор new в другом модуле перевода, который мог бы повлиять на наблюдаемое поведение, поэтому компилятору нужно было бы каким-то образом доказать, что это не так, иначе он не сможет выполнить эту оптимизацию. без нарушения правила "как будто". Предыдущие версии clang действительно оптимизировали в этом случае, как показывает этот пример Godbolt, который был предоставлен через Кейси, взяв этот код:

#include <cstddef>

extern void* operator new(std::size_t n);

template<typename T>
T* create() { return new T(); }

int main() {
    auto result = 0;
    for (auto i = 0; i < 1000000; ++i) {
        result += (create<int>() != nullptr);
    }

    return result;
}

и оптимизируя это к этому:

main:                                   # @main
    movl    $1000000, %eax          # imm = 0xF4240
    ret

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

Ответ 2

Это разрешено N3664.

Реализация разрешается опускать вызов сменной глобальной функции распределения (18.6.1.1, 18.6.1.2). Когда это происходит, хранилище вместо этого обеспечивается реализацией или обеспечивается расширением выделения другого нового выражения.

Это предложение является частью стандарта С++ 14, поэтому в С++ 14 компилятору разрешено оптимизировать выражение new (даже если оно может быть выбрано).

Если вы посмотрите на статус реализации Clang, он четко заявляет, что они реализуют N3664.

Если вы наблюдаете это поведение при компиляции в С++ 11 или С++ 03, вы должны заполнить ошибку.

Обратите внимание, что до того, как С++ 14 динамические распределения памяти являются частью наблюдаемого статуса программы (хотя я не могу найти ссылку для этого на данный момент), поэтому для реализации на совместимости не было разрешено применять as-if в этом случае.

Ответ 3

Помните, что стандарт С++ говорит, что должна делать правильная программа, а не как это делать. Он не может сказать позже, поскольку новые архитектуры могут возникать и возникать после того, как стандарт написан, и стандарт должен быть им полезен.

new не должен быть системным вызовом под капотом. Есть компьютеры, которые можно использовать без операционных систем и без концепции системного вызова.

Следовательно, до тех пор, пока конечное поведение не изменится, компилятор может оптимизировать все и все. В том числе new

Существует одна оговорка.
Новый глобальный оператор нового оператора мог бы быть определен в другой системе перевода В этом случае побочные эффекты нового могут быть такими, что их нельзя оптимизировать. Но если компилятор может гарантировать, что новый оператор не имеет побочных эффектов, как это было бы, если бы опубликованный код был всего кода, тогда оптимизация действительна. Этот новый может вызывать std:: bad_alloc не является обязательным требованием. В этом случае, когда новый оптимизирован, компилятор может гарантировать, что исключение не будет выбрано, и никакого побочного эффекта не произойдет.

Ответ 4

Вполне допустимо (но не обязательно) для компилятора оптимизировать распределения в вашем исходном примере, и даже более того в примере EDIT1 согласно §1.9 стандарта, который обычно упоминается как правило "как если":

Соответствующие реализации необходимы для эмуляции (только) наблюдаемого поведения абстрактной машины, как объяснено ниже:
[3 страницы условий]

Более читабельное представление доступно на cppreference.com.

Соответствующие пункты:

  • У вас нет летучих веществ, поэтому 1) и 2) не применяются.
  • Вы не выводите/не записываете какие-либо данные и не подсказываете пользователю, поэтому 3) и 4) не применяются. Но даже если бы вы это сделали, они явно были бы удовлетворены в EDIT1 (возможно, также и в оригинальном примере, хотя с чисто теоретической точки зрения это незаконно, поскольку поток и вывод программы - теоретически - различаются, но см. Два абзаца). ниже).

Исключением, даже необученным, является четко определенное (не неопределенное!) Поведение. Однако, строго говоря, в случае new бросков (не произойдет, см. Также следующий параграф) наблюдаемое поведение будет отличаться как кодом завершения программы, так и любыми выходными данными, которые могут последовать позже в программе.

Теперь, в частном случае единственного небольшого выделения, вы можете дать компилятору "преимущество сомнения" в том, что он может гарантировать, что распределение не потерпит неудачу.
Даже в системе, находящейся под очень сильным давлением памяти, невозможно даже запустить процесс, когда у вас меньше минимальной доступной степени детализации, и куча будет настроена и до вызова main. Таким образом, если это распределение не будет выполнено, программа никогда не запустится или уже встретит неблагодарное завершение, прежде чем будет вызван main.
Поскольку, предполагая, что компилятор знает это, даже если распределение теоретически может привести к ошибкам, законно даже оптимизировать исходный пример, поскольку компилятор может практически гарантировать, что этого не произойдет.

<немного нерешенный>
С другой стороны, это не допустимо (и, как вы можете заметить, ошибка компилятора), чтобы оптимизировать из распределения в вашем примере edit2. Значение используется для получения наблюдаемого извне эффекта (код возврата).
Обратите внимание, что если вы замените new (std::nothrow) int[1000] на new (std::nothrow) int[1024*1024*1024*1024ll] (это выделение 4TiB!), Что - на современных компьютерах - гарантированно не удастся, он все равно оптимизирует вызов. Другими словами, он возвращает 1, хотя вы написали код, который должен выводить 0.

@Yakk выдвинул хороший аргумент против этого: до тех пор, пока память никогда не трогается, указатель может быть возвращен, а фактическая память не требуется. Поскольку было бы даже законно оптимизировать распределение в EDIT2. Я не уверен, кто прав, а кто здесь не прав.

Распределение 4TiB гарантированно приведет к сбою на машине, на которой нет хотя бы чего-то вроде двухзначного гигабайта ОЗУ, просто потому, что ОС нужно создавать таблицы страниц. Конечно, стандарт C++ не заботится о таблицах страниц или о том, что делает ОС для предоставления памяти, это правда.

Но с другой стороны, предположение "это будет работать, если память не затрагивается", опирается именно на такую деталь и на то, что обеспечивает ОС. Предположение, что если ОЗУ не затронуто, оно фактически не нужно, верно только потому, что ОС предоставляет виртуальную память. И это подразумевает, что ОС должна создавать таблицы страниц (я могу притворяться, что не знаю об этом, но это не меняет того факта, что я все равно полагаюсь на это).

Поэтому я думаю, что не совсем правильно сначала предполагать одно, а потом говорить "но нас не волнует другое".

Итак, да, компилятор может предположить, что выделение 4TiB в общем случае вполне возможно, если не затрагивать память, и он может предположить, что в целом это возможно. Можно даже предположить, что это может быть успешным (даже если это не так). Но я думаю, что в любом случае вам никогда не позволят предположить, что что-то должно работать, когда есть вероятность отказа. И не только существует вероятность отказа, в этом примере отказ еще более вероятна.
</немного не определились>

Ответ 5

Самое худшее, что может произойти в вашем фрагменте, - это new throws std::bad_alloc, который необработан. Затем происходит определение реализации.

В лучшем случае, когда нет-op и худший случай не определяется, компилятору разрешено подвергать их отсутствию. Теперь, если вы действительно попытаетесь поймать возможное исключение:

int main() try {
    int* mem = new int[100];
    return 0;
} catch(...) {
  return 1;
}

... then сохраняется вызов operator new.