在 C++ 中将函数转换为协程会带来什么性能损失?

Fed*_*dor 6 c++ visual-studio c++20 c++-coroutine

由于对于 C++20 协程,编译器必须通过将所有局部变量放入堆而不是堆栈中来创建与普通函数不同的代码,因此相对于进行相同计算的普通函数,协程函数的预期减慢程度如何?

我编写了一个简单的测试来测量大量值求和的时间:

#include <iostream>
#include <chrono>
#include <coroutine>

constexpr size_t N = 1024ull*1024ull*1024ull;

double compute()
{
    double res = 0;
    for ( size_t i = 0; i < N; ++i )
        res += i;
    return res;
}

template<typename F>
auto timer( F f )
{
    using namespace std::chrono;
    auto s = high_resolution_clock::now();
    auto res = f();
    nanoseconds dur = high_resolution_clock::now() - s;
    std::cout << "duration: " << dur.count() * 1e-9 << " sec\n";
    return res;
}

struct Future {
  struct promise_type {
    double value_;
    Future get_return_object() { return { std::coroutine_handle<promise_type>::from_promise(*this) }; }
    std::suspend_always initial_suspend() { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
    void return_value(double v) { value_ = v; }
  };
  bool done() { return h_.done(); }
  void resume() { return h_(); }
  double value() { return h_.promise().value_; }
  ~Future() { h_.destroy(); }
  std::coroutine_handle<promise_type> h_;
};

double computeInCoroutine()
{
    auto future = []() -> Future
    {
        double res = 0;
        for ( size_t i = 0; i < N; ++i )
            res += i;
        co_return res;
    }();
    while (!future.done())
        future.resume();
    return future.value();
}

int main()
{
    std::cout << "Ordinary function ";
    auto res = timer( compute );
    std::cout << "Coroutine ";
    res += timer( computeInCoroutine );
    return (int)res;
}
Run Code Online (Sandbox Code Playgroud)

在 Visual Studio 2019 16.10.3 中我得到结果:

Ordinary function duration: 0.793619 sec
Coroutine duration: 1.05897 sec
Run Code Online (Sandbox Code Playgroud)

请注意,时间是在本地计算机上获取的,并且非常可重复。我不建议在线测量时间(例如在 godbold.org 中),因为那里非常不稳定。

那么,仅仅通过在协程中转换一个在计算过程中不会暂停的普通函数,在 MSVC 中我们就会得到大约 30% 的性能损失,或者说比较不公平?

更新。当我将迭代次数增加到16倍后:

constexpr size_t N = 16ull*1024ull*1024ull*1024ull;
Run Code Online (Sandbox Code Playgroud)

两个函数的性能差异变得非常显着:

Ordinary function duration: 12.602 sec
Coroutine duration: 45.5615 sec
Run Code Online (Sandbox Code Playgroud)

Yak*_*ont 4

我拿了你的代码并让它在 godbolt 上的 clang/gcc 中工作。

铛:

Ordinary function duration: 2.27166 sec
5.76461e+17
Coroutine duration: 2.76769 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

海湾合作委员会:

Ordinary function duration: 2.21894 sec
5.76461e+17
Coroutine duration: 2.18039 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

Clang 慢了 23%,gcc 的速度大致相同(区别在于噪音)。

当我启用时-ffast-math,我得到:

Ordinary function duration: 0.465791 sec
5.76461e+17
Coroutine duration: 1.58706 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

在叮当声中,并且

Ordinary function duration: 2.19963 sec
5.76461e+17
Coroutine duration: 2.23827 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

在海湾合作委员会。

然后我将其future重写为:

auto future = []() -> Future
{
    auto helper = [](){
        double res = 0;
        
        for ( size_t i = 0; i < N; ++i )
            res += i;
        return res;
    };
    co_return helper();
}();
Run Code Online (Sandbox Code Playgroud)

我在辅助 lambda 中将协程的状态隐藏起来。这会将 clang 时间更改为:

Ordinary function duration: 1.07504 sec
5.76461e+17
Coroutine duration: 0.465179 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

大会没有任何明显的结果。所以我交换了他们的顺序(没有隐藏 lambda)并得到:

Coroutine duration: 0.467913 sec
5.76461e+17
Ordinary function duration: 0.959462 sec
5.76461e+17
Run Code Online (Sandbox Code Playgroud)

以相反的顺序运行它们会使第一个更快。

因此,我们看到了性能测试工具的工件。

您需要测试这个交叉编译器,因为协程支持是新的并且正在改进。您需要使用真正的测试工具,进行多次运行,并在两种情况之间完全对称(不是运行一个,然后运行另一个;只在程序的任何一次执行中运行一个)。您需要设置优化标志,并调整微优化的细节以查看是否存在不稳定。

然后,您需要查看生成的程序集,以了解速度减慢的原因是否合理,以及可能在哪里。

微观优化很难。

无论如何,当我以多种方式调整基准时,我设法获得了与非协程版本匹配或超过的速度。所以不,这种情况下 30% 的命中率是不可预期的。