Компиляторам разрешено опускать заменяемые глобальные аллокации new или объединять несколько таких аллокаций new в одну, даже если у них есть наблюдаемые побочные эффекты.
Explanation Ссылка на заголовок
В общем случае компиляторам разрешено выполнять любую оптимизацию, если наблюдаемое поведение программы остаётся неизменным (это известно как “as-if rule”). Тем не менее, есть хорошо известное исключение, о котором вы, вероятно, слышали: copy elision — правило языка, по которому определённые операции копирования или перемещения опускаются (в одних случаях это обязательно по стандарту, в других — лишь разрешено), даже если отсутствие вызовов конструкторов или деструкторов поменяет наблюдаемое поведение программы.
Однако существует ещё одна похожая менее известная оптимизация: allocation elision. Начиная с C++14, реализациям разрешено опускать вызовы заменяемых глобальных функций выделения памяти (replaceable global allocation functions), сделанные new-выражениями. Им также разрешено расширять одну аллокацию так, чтобы она предоставляла память для другого new-выражения.
Термин “заменяемые глобальные функции выделения памяти” относится к стандартным перегрузкам ::operator new и ::operator new[], объявленным в глобальном пространстве имён. Стандарт C++ явно разрешает пользовательским программам переопределять ::operator new(size_t), ::operator new[](size_t) и так далее. Данные функции используются обычными new T{...}-выражениями. Слово “заменяемые” здесь несёт реальную смысловую нагрузку: placement new тоже глобальный, однако его заменять нельзя, а operator new, являющийся методом класса, вообще не глобальный — следовательно, ни один из них не подпадает под allocation elision.
Цитата из cppreference:
new expressions are allowed to elide or combine allocations made through replaceable allocation functions. In case of elision, the storage may be provided by the compiler without making the call to an allocation function (this also permits optimizing out unused new expression).
Если компилятор решил применить allocation elision и объект, для которого выделяется память, не нужен вовсе — тогда аллокация может убраться полностью. Если же объект всё ещё нужен — тогда компилятор берёт память не из функции аллокации, а откуда-то ещё, например со стека. В обоих сценариях, если компилятор применяет allocation elision, вызов функции аллокации исчезает.
Пары malloc/free и так удаляются по as-if правилу. В отличие от них, заменяемые пользователем operator new/operator delete могут содержать дополнительную логику помимо самой аллокации (например, логирование, подсчёт). As-if правило само по себе запрещало бы убирать такие наблюдаемые побочные эффекты, поэтому [expr.new]/14 делает явное исключение — разрешает компилятору опускать такие вызовы, даже ценой изменения наблюдаемого поведения программы.
Первый пример Ссылка на заголовок
Сделаем operator new, который не может завершиться успешно:
#include <cstdio>
#include <new>
void* operator new(std::size_t) {
throw std::bad_alloc{};
}
int main() {
try {
int* const p = new int;
delete p;
std::puts("allocatable");
} catch (const std::bad_alloc&) {
std::puts("not allocatable");
}
}
При компиляции clang с -O3 программа выводит allocatable — даже несмотря на то, что operator new не может завершиться успешно (Godbolt). Поскольку данный operator new(std::size_t) не может завершиться, не бросив исключения, allocatable может означать только одно: вызов был пропущен — память была “предоставлена реализацией”, и выполнение не попадает в блок catch. А вот g++ с -O3 всё ещё выводит not allocatable.
As-if rule сам по себе не мог бы дать вывод allocatable, потому что такого валидного исполнения абстрактной машины, выполняющей данную программу, попросту нет. Только [expr.new]/14, которая явно разрешает опустить вызов вместе со всеми побочными эффектами в его теле, может породить тот вывод, который получается в clang.
Когда применяется Ссылка на заголовок
Давайте рассмотрим другие случаи, когда происходит allocation elision.
#include <cstdio>
#include <cstdlib>
#include <new>
int allocs = 0;
void* operator new(const std::size_t n) {
++allocs;
void* const p = std::malloc(n);
if (p == nullptr) {
throw std::bad_alloc{};
}
return p;
}
void operator delete(void* const p) noexcept {
std::free(p);
}
int main() {
for (int i = 0; i < 1000; ++i) {
int* p = new int{i};
delete p;
}
std::printf("operator new was called %d times\n", allocs);
}
С отключёнными оптимизациями GCC, Clang и MSVC выводят, что operator new был вызван 1000 раз. С включёнными оптимизациями уровня -O3 GCC и Clang оптимизируют вызовы аллокации и выводят, что operator new был вызван 0 раз. MSVC c -O2 по-прежнему выводит, что operator new был вызван 1000 раз.
Обратите внимание на асимметрию: если программа выводит 0, это однозначное доказательство того, что произошла allocation elision — только она позволяет реализации полностью пропустить вызов operator new, включая побочный эффект ++allocs (применение только правила as-if не может выбросить инкремент ++allocs, поскольку счётчик наблюдаем через printf). А вот вывод 1000 не доказывает того, что оптимизация не была применена. As-if в принципе разрешает достаточно агрессивному компилятору заинлайнить замену, убрать пару malloc/free в предположении, что аллокация всегда успешна, и оставить ++allocs исполняться — это дало бы 1000 без единого реального вызова аллокатора. Эмпирически GCC и Clang при -O3 так не делают на этом тесте (выбирают полный elision, когда он применим), но стандарт это не запрещает, так что 1000 совместимо как со сценарием “все вызовы случились”, так и со сценарием “ни одной аллокации, но счётчик отработал”. Для того, чтобы понять, что действительно произошло, необходимо смотреть на сгенерированный ассемблер.
MSVC, по всей видимости, не реализует эту оптимизацию — стандарт её разрешает, однако не требует. Лучшую “документацию” по этому поводу, которую я нашёл, — это комментарий, связанный с MSVC: “we don’t implement this particular temporary allocation optimization at the moment.”
Эта оптимизация довольно хрупкая: мелкие, не связанные между собой детали в окружающем коде или в заменяющем аллокаторе могут влиять на то, наблюдается ли она. Несколько вариаций примера выше (показания счётчика при -O3):
| Вариация | GCC | Clang | MSVC |
|---|---|---|---|
Базовая версия: читаем и пишем по указателю, operator new бросает на nullptr (Godbolt) | 0 | 0 | 1000 |
Указатель передаётся в функцию, operator new бросает на nullptr (Godbolt) | 0 | 1000 | 1000 |
Проверка на nullptr в operator new закомментирована (Godbolt) | 1000 | 0 | 1000 |
std::make_unique, передача указателя, throw в operator new (Godbolt) | 0 | 1000 | 1000 |
std::make_unique, без передачи указателя, без throw (Godbolt) | 1000 | 1000 | 1000 |
operator new возвращает результат malloc, без проверки на nullptr (Godbolt) | 1000 | 0 | 1000 |
nothrow operator new (Godbolt) | 1000 | 1000 | 1000 |
Прослеживаются две приблизительные закономерности: для allocation elision в GCC обычно требуется, чтобы operator new был слишком сложен для инлайнинга — throw плюс проверки на nullptr обычно достаточно, а -fno-inline возвращает оптимизацию даже для тех замен, которые поддаются инлайнингу. Заметим, что стандарт ([dcl.fct.def.replace]/1.1) явно запрещает объявлять replaceable operator new как inline, однако запрет распространяется на ключевое слово; оптимизатор остаётся свободен в автоматическом инлайнинге небольшого определения самостоятельно — именно это и сводит на нет allocation elision в GCC здесь. Для allocation elision в Clang, видимо, требуется, чтобы получаемый указатель не покидал локальную область видимости.
Помимо этих особенностей, прослеживается и общее правило: оба компилятора применяют allocation elision в простых случаях — когда компилятор видит всё время жизни аллокации и это время жизни по сути автоматическое, то есть укладывается в блок или функцию. Тогда аллокацию можно без проблем заменить памятью на стеке или не выделять память вовсе.
Стандарт также разрешает объединение аллокаций: одна аллокация может быть расширена так, чтобы предоставлять память для другого new-выражения. Однако мне не удалось написать код, который вызвал бы такое поведение. Возможно, ни GCC, ни Clang, ни MSVC эту оптимизацию не реализуют.