Skip to content

Repository files navigation

coro_nrvo

Это небольшая техническая иллюстрация, как можно добиться RVO/NRVO при возврате объектов из корутин в co_return, co_yield.

Суть проблемы

Как вообще объекты возвращаются из функций?

// Пусть имеется такой тип объекта:
struct object {
    object(int _a, int _b, int _c) : a(_a), b(_b), c(_c) {}
    int a, b, c;
};

// Пусть имеется функция, возвращающая такой объект:
object foo(int k) {
    ....
    object obj(1, 2, k);
    ....
    modify(obj);
    ....
    return obj;
}

void bar() {
    object obj = foo(3);
    use(obj);
}

// Как на внутреннем уровне вообще объекты возвращаются из функций?
// В стековом фрейме вызывающей стороны отводится место под возвращаемый объект.
// В вызываемую функцию неявным дополнительным параметром передается указатель на это место.
// Вызываемая функция помещает свой ответ в это место.
// То есть на самом деле в общем случае реализация внутри выглядит примерно так:

object* foo(object* ret_value, int k) {
    ....
    object obj(1, 2, k);
    ....
    modify(obj);
    ....
    // Вместо return obj размещаем возвращаемый объект
    new (ret_value) object(std::move(obj));
    return ret_value;
}

// Вызывающая сторона
void bar() {
    alignas(object) char place_for_object[sizeof(object)];

    object& obj = *foo(reinterpret_cast<object*>(place_for_object), 3);
    // Ну тут компилятор конечно не заводит отдельную переменную, а просто начинает трактовать
    // place_for_object как obj.
    use(obj);
}

RVO и NRVO - это оптимизации, которые позволяют функциям, возвращающим объекты, не создавать эти объекты сначала в своём фрейме стека а после копировать их во фрейм вызывающей функции, а сразу оперировать с объектами на стеке вызывающей стороны. То есть внутри получается такой псевдо-код:

object* foo(object* ret_value, int k) {
    ....
    object& obj = *new (ret_value) object(1, 2, k);
    ....
    modify(obj);
    ....
    return ret_value;
}

Эти оптимизации позволяют избежать излишнего копирования или перемещения объектов. Возврат pr-values вообще обязывает компилятор применять RVO настолько, что возвращаемый тип может быть вообще не копируемым и не перемещаемым. И это всё отлично работает, пока мы пользуемся функциями.

Однако когда речь заходит о корутинах, об этих оптимизациях можно забыть. Если вы немного разбираетесь в корутинах, то знаете, что "co_return expression;" не является настоящим возвратом, а всего лишь синтаксическим сахаром над "promise.return_value(expression)". То есть корутина должна передать возвращаемый объект в эту функцию, та его должна куда-то скопировать/переместить, а затем скопировать/переместить сохранённое значение пользователю корутины. Возникает как минимум два копирования/перемещения промежуточных объектов.

// Пусть корутина представлена неким типом task, в котором есть метод exec, возобновляющий корутину
// до полного выполнения, сохраняющий результат корутины и возвращающий его из result()
template<typename T>
struct task {
    struct promise_type {
        ....
        std::optional<T> result_;
        ....
        template<typename V> requires std::is_constructible_v<T, V>
        void return_value(V&& v) noexcept(std::is_nothrow_constructible_v<T, V>) {
            result_.emplace(std::forward<V>(v));
        }
        ....
    };
    ....
    task&& exec()&& {
        while(!coro_.done()) {
            coro_.resume();
        }
        return std::move(*this);
    }
    T&& result() && {
        return std::move(coro_.promise().result_).value();
    }

    std::coroutine_handle<promise_type> coro_;
};

task<std::vector<int>> coro_foo() {
    std::vector<int> result;
    ....
    fill(result);
    ...
    co_return result;
}

void bar() {
    std::vector<int> v = coro_foo().exec().result();
}

Если task написан хорошо, в этом коде создастся один вектор в coro_foo, который затем будет перемещён в promise как результат корутины, и затем ещё раз перемещён в bar. Если же task написан не очень хорошо, вектор из coro_foo будет в bar два раза скопирован, а не перемещён. Но даже в лучшем случае, хотелось бы избежать и этих двух перемещений, мы же любим "zero cost".

Некоего подобия частичного RVO можно добиться, если мы сразу возвращаем конструируемый объект, и у возвращаемого типа один параметр конструктора. Тогда хранимый где-то результат можно сразу инициализировать нужным конструктором, и мы избежим одного перемещения/копирования:

struct object {
    object(int p);
    ....
};

task<object> coro_foo(int k) {
    ....
    // Здесь мы сразу конструируем хранимый возвращаемый объект "по месту", без излишнего копирования
    co_return k + 1;
}

Однако, это довольно частный случай, мы не можем через co_return вызвать конструктор, в котором не 1 параметр. Кроме того, перемещение/копирование из промежуточного хранилища в вызывающую функцию всё-равно остаётся.

Всё вышеперечисленное относится и к co_yield, которое как и co_return на самом деле является вызовом функции, которая должна где-то сохранить переданное значение.

Путь к решению проблемы

Можно ли как-то улучшить это поведение? Да, для этого нужно как-то передать корутине указатель на неинициализированное место, где нужно сохранить результат её выполнения. А в самой корутине иметь возможность получить доступ к этой информации, чтобы сразу разместить возвращаемое значение в нужном месте, с любым из его конструкторов. Примерно так:

job<std::vector<int>> return_vector() {
    // Корутина вызывает нужный конструктор std::vector на месте, где лежит неинициализированное возвращаемое
    // значение и получает ссылку на него
    auto& result = co_emplace(3, 5);
    // Может даже модифицировать его
    result.emplace_back(8);
    // Это вместо co_return result, мы же ведь уже установили возвращаемое значение.
    co_done;
}

// Вот так бы хотелось чтобы работал вызов, но пока так не работает.
// Чтобы корутина конструировала сразу v во фрейме этой функции, без промежуточных объектов и перемещений.
void bar() {
    std::vector<int> v = return_vector().exec().result();
    std::cout << v.size();
    use(v);
}

Можно ли на текущем уровне C++ добиться такого поведения? Почти можно, но с небольшими плясками.

Существующее решение

Чтобы корутину можно было так вызывать, используем тот факт, что переменная становиться видна компилятору и позволяется использовать, как только он видит её имя, ещё даже до инициализации. Создан "хитрый" тип-обёртка, uninit<T>, через который можно заставить этот метод работать вот в таком виде:

template<typename T>
struct uninit {
    uninit() = default;
    uninit(uninit&& o) noexcept {
        // Так как этот тип предполагается использовать только для передачи неинициализированных
        // переменных в левую часть присваивания/инициализации самому себе же, сделаем конструктор
        // перемещения, в котором ничего не делаем.
        // Только проверим, что вызываемся сами для себя.
        assert(&o == this);
    }
    ~uninit() {
        reinterpret_cast<T*>(value_)->~T();
    }
    // Не копируемый, не присваиваемый.
    uninit(const uninit& o) = delete;
    uninit& operator=(const uninit&) = delete;

    // Конструирование значения
    template<typename...Args> requires std::is_constructible_v<T, Args...>
    T& emplace(Args&&...args) noexcept(std::is_nothrow_constructible_v<T, Args...>) {
        new (value_) T(std::forward<Args>(args)...);
        return *reinterpret_cast<T*>(value_);
    }

    T& operator*() {
        return *reinterpret_cast<T*>(value_);
    }
    const T& operator*() const {
        return *reinterpret_cast<const T*>(value_);
    }
    T* operator->() {
        return reinterpret_cast<T*>(value_);
    }
    const T* operator->() const {
        return reinterpret_cast<const T*>(value_);
    }
private:
    alignas(T) char value_[sizeof(T)];
};


job<std::vector<int>> return_vector() {
    auto& result = co_emplace(3, 5);
    result.emplace_back(8);
    co_done;
}

TEST(Job, ReturnVector) {
    uninit<std::vector<int>> vec = return_vector().exec_to(vec).result();
    std::cout << "Get return vector in " << &*vec << ", size " << v->size() << "\n";
    EXPECT_EQ(*vec, (std::vector<int>{5, 5, 5, 8}));
}

Как вы видите, мы объявляем переменную vec, и в её инициализацию передаём ссылку на неё саму же. Метод result возвращает r-value ссылку снова на неё же. После чего переменная vec инициализируется конструктором перемещения, который просто ничего не делает, так как объект уже был инициализирован в корутине return_vector посредством co_emplace. В данном примере результирующий объект конструируется сразу в месте использования, никаких промежуточных объектов с перемещением/копированием не создаётся.

Работающий код можно сразу посмотреть на godbolt.

Заключение

Да, сейчас код для оптимального возврата объектов из корутин выглядит не очень хорошо: переменная объявляется через обёртку вместо чистого типа, нельзя использовать auto, для доступа к объекту приходится использовать методы обёртки (хотя и ничего не стоящие, но пачкающие код * и ->). Но сам принцип, как корутины технически возможно улучшить и так же добиться в них RVO/NRVO, как и для обычных функций - вполне виден.

Имхо, при доработках компилятора это можно будет сделать и без использования этой uninit обёртки.

About

RVO/NRVO in coroutines

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages