Оптимизации RVO / NRVO
Всем привет! Настало время завершающего поста этой серии. Сегодня мы поговорим об одной из самых нетривиальных оптимизаций в С++.
Я очень удивлюсь, если встречу человека, который по мере изучения стандартных контейнеров никогда не задумывался, что эти ребята слишком «жирные», чтобы их просто так возвращать в качестве результата функции или метода:
...и приходили к мысли, что нужно заполнять уже существующий объект:
Эта мысль волновала всех с давних времен... Поэтому она нашла поддержку от разработчиков компиляторов.
Существует такие древние оптимизации, как RVO (Return Value Optimization) и NRVO (Named Return Value Optimization). Они призваны избавить нас от потенциально избыточных и лишних вызовов конструктора копирования для объектов на стеке. Например, в таких ситуациях:
Давайте взглянем на живой пример 1, в котором вызов конструктора копирования явно пропускается. Вообще говоря, эта информация немного выбивается в контексте постов, посвященных move семантике C++11, т.к. это работает даже на C++98. Вот поэтому я её называю древней 😉
Немного теории. При вызове функции резервируется место на стеке, куда должно быть записано возвращаемое значение функции. Если компилятор может гарантировать, что функция возвращает единственный локальный объект, тип которого совпадает с
Иными словами, компилятор пытается понять, можно ли "подсунуть" область памяти
В конце поста потом почитайте ассемблерный код в комментариях, а пока продолжим.
RVO отличается NRVO тем, что в первом случае выполняется оптимизация для объекта, который создается при выходе из функции в
А во втором для возвращаемого именованного объекта:
Но при этом замысел и суть остаются такими же! Тут важно отметить, что и вам, и компилятору, по объективным причинам, намного проще доказать корректность RVO, чем NRVO.
Давайте покажу, когда NRVO может не сработать и почему. Рассмотрим кусочек из живого примера 2:
Оптимизация NRVO не выполнится. В данном примере компилятору будет неясно, какой именно из объектов
Продолжение в комментариях!
#cppcore #memory #algorithm #hardcore
Всем привет! Настало время завершающего поста этой серии. Сегодня мы поговорим об одной из самых нетривиальных оптимизаций в С++.
Я очень удивлюсь, если встречу человека, который по мере изучения стандартных контейнеров никогда не задумывался, что эти ребята слишком «жирные», чтобы их просто так возвращать в качестве результата функции или метода:
std::string get_very_long_string();
...и приходили к мысли, что нужно заполнять уже существующий объект:
void fill_very_long_string(std::string &);
Эта мысль волновала всех с давних времен... Поэтому она нашла поддержку от разработчиков компиляторов.
Существует такие древние оптимизации, как RVO (Return Value Optimization) и NRVO (Named Return Value Optimization). Они призваны избавить нас от потенциально избыточных и лишних вызовов конструктора копирования для объектов на стеке. Например, в таких ситуациях:
// RVO example
Foo f()
{
return Foo();
}
// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}
// Foo no coping
Foo obj = f();
Давайте взглянем на живой пример 1, в котором вызов конструктора копирования явно пропускается. Вообще говоря, эта информация немного выбивается в контексте постов, посвященных move семантике C++11, т.к. это работает даже на C++98. Вот поэтому я её называю древней 😉
Немного теории. При вызове функции резервируется место на стеке, куда должно быть записано возвращаемое значение функции. Если компилятор может гарантировать, что функция возвращает единственный локальный объект, тип которого совпадает с
lvalue
, тогда он может сразу сконструировать этот объект напрямую в ожидаемом месте вызывающего кода. Допустимо отличаться на константность.Иными словами, компилятор пытается понять, можно ли "подсунуть" область памяти
lvalue
при вычислении rvalue
и гарантировать, что мы получим тот же результат, что и при обычном копировании. Можно считать, что компилятор преобразует код в следующий:void f(Foo *address)
{
// construct an object Foo
// in memory at address
new (address) Foo();
}
int main()
{
auto *address = reinterpret_cast<Foo *>(
// allocate memory directly on stack!
alloca(sizeof(Foo))
);
f(address);
}
В конце поста потом почитайте ассемблерный код в комментариях, а пока продолжим.
RVO отличается NRVO тем, что в первом случае выполняется оптимизация для объекта, который создается при выходе из функции в
return
:// RVO example
Foo f()
{
return Foo();
}
А во втором для возвращаемого именованного объекта:
// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}
Но при этом замысел и суть остаются такими же! Тут важно отметить, что и вам, и компилятору, по объективным причинам, намного проще доказать корректность RVO, чем NRVO.
Давайте покажу, когда NRVO может не сработать и почему. Рассмотрим кусочек из живого примера 2:
// NRVO failed!
Foo f(bool value)
{
Foo a, b;
if (value)
return a;
else
return b;
}
Оптимизация NRVO не выполнится. В данном примере компилятору будет неясно, какой именно из объектов
a
или b
будет возвращен. Несмотря на то, что объекты БУКВАЛЬНО одинаковые, нельзя гарантировать применимость NRVO. До if (value)
можно было по-разному поменять каждый из объектов и их память. Или вдруг у вас в конструкторе Foo
зашит генератор случайных чисел? 😉 Следовательно, компилятору может быть непонятно куда надо конструировать объект напрямую из этих двух. Тут будет применено копирование.Продолжение в комментариях!
#cppcore #memory #algorithm #hardcore