Оптимизации RVO / NRVO



Всем привет! Настало время завершающего поста этой серии. Сегодня мы поговорим об одной из самых нетривиальных оптимизаций в С++.



Я очень удивлюсь, если встречу человека, который по мере изучения стандартных контейнеров никогда не задумывался, что эти ребята слишком «жирные», чтобы их просто так возвращать в качестве результата функции или метода:

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