简单的 c++20 协程的性能看起来很糟糕。这是不可避免的吗?这是“帧切换”的代价吗?

Pio*_*ycz 8 c++ performance c++20 c++-coroutine

我正在使用 C++20 协程,特别是简单的生成器,但我观察到协程替换基于 boost::msm 的状态机的类似结果。

\n

实际上,我的目标是向我的团队提供有关从 c++17 升级到 C++20 后何时使用 c++20 协程的建议。因此,这些示例只是为了帮助提出此建议,它们并不是需要修复的真正代码。

\n

我的fast-bench.com表明,在简单生成器的情况下,此类协程的性能可能比基于迭代器的功能相同的代码差 4 倍(gcc13,-O3)或 3.5 倍(clang17,-O3)。

\n

完整代码可在此链接下找到;以下只是创建两个范围的笛卡尔积的示例代码:

\n
    \n
  • 参考代码

    \n
    void cartesian(auto const& xx, auto const& yy, auto body)\n{\n    for (auto x : xx) \n       for (auto y : yy)\n           body(std::make_pair(x, y));\n} \n// usage: cartesian(container1, container2, [&](auto const& x_y) { .... });\n
    Run Code Online (Sandbox Code Playgroud)\n
  • \n
  • 它的“协程”等价物

    \n
    template <typename T> struct generator { /*something similar to C++23 std::generator */ };\n\ntemplate <typename XX, typename YY>\ngenerator<std::pair<typename XX::value_type, typename YY::value_type>>\ncartesian(auto const& xx, auto const& yy)\n{\n    for (auto x : xx) \n       for (auto y : yy)\n           co_yield std::make_pair(x, y);\n\n}\n\n// usage: \n// for (const auto& x_y : cartesian(container1, container2)) \n// { .... }\n
    Run Code Online (Sandbox Code Playgroud)\n
  • \n
  • 基于迭代器的功能相同的代码

    \n
    template <typename R1, typename R2>\nclass cartesian_combine\n{\npublic:\n    explicit cartesian_combine(const R1& r1, const R2& r2) \n        : xx(r1), yy(r2)\n    {}\n\n    struct iterator_sentinel {};\n\n    template <typename It1, typename S1,\n              typename It2, typename S2>\n    struct const_iterator\n    {\n        const_iterator(It1 xx_begin, S1 xx_end,\n                       It2 yy_begin, S2 yy_end)\n            : xx_begin(xx_begin), xx_it(xx_begin), xx_end(xx_end),\n              yy_begin(yy_begin), yy_it(yy_begin), yy_end(yy_end)\n        {\n            if (yy_it == yy_end) { xx_it = xx_end; }\n        }\n        const It1 xx_begin;\n        It1 xx_it;\n        const S1 xx_end;\n        const It2 yy_begin;\n        It2 yy_it;\n        const S2 yy_end;\n\n        bool operator==(iterator_sentinel) const {\n            return xx_it == xx_end;\n        }\n        auto operator*() const {\n            return std::make_pair(*xx_it, *yy_it);\n        }\n        const_iterator& operator++() {\n            if (++yy_it == yy_end) \n            { \n                ++xx_it; \n                yy_it = yy_begin; \n            }\n            return *this;\n        }\n    };\n\n    auto begin() const { return const_iterator<\n        decltype(xx.begin()), decltype(xx.end()),\n        decltype(yy.begin()), decltype(yy.end())\n      >{xx.begin(), xx.end(), \n        yy.begin(), yy.end()}; \n    }\n    iterator_sentinel end() const { return {}; }\n\nprivate:\n    const R1& xx;\n    const R2& yy;\n};\n\nauto \ncartesian(const auto& xx, const auto& yy)\n{\n    return cartesian_combine(xx, yy);\n}\n// usage - identical as in "coroutine" case\n
    Run Code Online (Sandbox Code Playgroud)\n
  • \n
\n

很明显,“协程”代码比这个操作一堆迭代器的容易出错的代码简单得多。然而,性能成本是我真正担心的事情。所以,我的问题是,我不确定\xe2\x80\x94,可能,这就是主函数和这个协程的帧之间切换的成本。但为什么这个成本这么高呢?

\n

快速台结果

\n

在我的示例中,我组合了两个数组,每个数组有 10 个元素,导致这些帧之间进行 100 次切换(或上下文 \xe2\x80\x94 我不确定这里的正确术语是什么)。也许,这不是协程帧动态内存分配的成本,因为它只发生一次,我什至尝试从生成器的 Promise_type 运算符 new() 返回预分配的缓冲区,但这没有帮助。

\n

以防万一我的生成器出现问题 - 代码:

\n
template <typename T>\nclass generator\n{\npublic:\n    struct promise_type;\n    using handle_type = std::coroutine_handle<promise_type>;\n    struct promise_type\n    {\n        const T* value{};\n        std::suspend_always yield_value(const T& v) {\n            value = &v;\n            return {};\n        }\n\n        std::suspend_never initial_suspend() { return {}; }\n        std::suspend_always final_suspend() noexcept { return {}; }\n        void return_void() {}\n        void unhandled_exception() { std::exit(-1); }\n\n        generator get_return_object() { \n            return generator(handle_type::from_promise(*this)); \n        }\n    };\n\n    explicit generator(handle_type h) : handle(h) {}\n    ~generator() \n    {\n        if (handle)\n            handle.destroy();\n    }\n    generator(generator&& other) \n        : handle(std::exchange(other.handle, {}))\n    {}\n\n    generator& operator=(generator&& other)\n    {\n        if (this != &other)\n        {\n            if (handle) handle.destroy();\n            handle = std::exchange(other.handle, {});\n        }\n        return *this;\n    } \n\n    struct iterator_sentinel {};\n    struct const_iterator\n    {\n        handle_type handle;\n        bool operator==(iterator_sentinel) const {\n            return handle.done();\n        }\n        const T& operator*() const {\n            return *handle.promise().value;\n        }\n        const_iterator& operator++() {\n            handle.resume();\n            return *this;\n        }\n    };\n\n    const_iterator begin() const { return {handle}; }\n    iterator_sentinel end() const { return {}; }\n\nprivate:\n    handle_type handle;\n};\n
Run Code Online (Sandbox Code Playgroud)\n

Yak*_*ont 7

范围叉积和迭代器代码除了操作索引和单个内存加载之外什么也不做。如果不是硬编码的“不优化”,它会优化到什么都不做。

协程代码不会优化到什么都不做,因为它设置了执行任意操作的能力,并且它确实进行类型擦除。

如果您的操作是空的,那么协程不是一个好主意。

但是,如果您的操作与单个内存分配一样便宜,那么差异几乎完全消失。

static void coro_combine_test(benchmark::State& state) {
    std::vector<std::unique_ptr<int[]>> buff;
    for (auto _ : state) {
      for (auto x : coro_combine(xx, yy)) {
        buff.emplace_back( new int[x.first*x.second] );
        benchmark::DoNotOptimize(x);
      }
      buff.clear();
    }
}
BENCHMARK(coro_combine_test);
Run Code Online (Sandbox Code Playgroud)

在这里,我为每个访问的元素添加内存分配(4 到 400 字节),然后在每次迭代结束时释放它们。这表示正在完成一些不平凡的计算。

当我这样做时,迭代器和范围版本的值是 13,000,协程版本的值是 15,000。

用 C++ 实现的协程不适合取代逐像素操作,其中迭代量远远主导迭代中完成的工作量。您的基准测试是展示这一点的好方法。

如果您使用任何类型的类型擦除迭代器进行类似的测试,您会遇到类似的开销问题;这不是特定于协程的,而是特定于类型擦除的。

我通常处理这个问题的方法是确保我正在迭代的数据块在类型擦除代码中是不平凡的。例如,如果我迭代像素,则我的运行时类型擦除位于扫描线或扫描线块的级别。

任何更高分辨率的类型擦除我都会将其编译时,这样我就可以编写每个像素的操作,让我的编译时模板将其重写为块或扫描线操作,然后将这些块或扫描线操作输入到擦除的类型中(迭代)或(协程)或任何系统。

void ForeachScanline( MyImage* pImage, std::function<void(std::size_t y, std::span<MyPixel>)>);
template<class F>
void ForeachPixel( MyImage* pImage, F f ) {
  ForeachScanline( pImage, [&f](std::size_t y, std::span<MyPixel> pixels) {
    for (std::size_t x = 0; x < pixels.size(); ++x) {
      f(MyLocation{x,y}, pixels[x]);
    }
  });
}
Run Code Online (Sandbox Code Playgroud)

运行时类型擦除扫描线实现由编译时类型擦除像素实现使用。这意味着运行时类型擦除开销发生的频率要低得多;扫描线循环可以内联并与每像素操作混合。

让协程需要类型擦除的决定导致了这个结果。编译器编写者在不进行类型擦除的情况下遇到了问题,因为协程的大小很难尽早确定。类型擦除协程可以在其主体被完全理解之前确定其大小。