我正在使用 C++20 协程,特别是简单的生成器,但我观察到协程替换基于 boost::msm 的状态机有类似的结果。
实际上我的目标是向我的团队提供有关何时进行的建议 从 c++17 升级到 C++20 后使用 c++20 协程。所以这些 示例只是为了帮助做出此建议,而不是 需要修复的真实代码。
我有 quick-bench.com 显示,在简单生成器的情况下,此类协程的性能可能比基于功能的相同代码差 4 倍(gcc13,-O3)或 3.5 倍(clang17,-O3)迭代器。
完整代码可在此链接下获取;以下只是创建两个范围的笛卡尔积的示例代码:
void cartesian(auto const& xx, auto const& yy, auto body)
{
for (auto x : xx)
for (auto y : yy)
body(std::make_pair(x, y));
}
// usage: cartesian(container1, container2, [&](auto const& x_y) { .... });
template <typename T> struct generator { /*something similar to C++23 std::generator */ };
template <typename XX, typename YY>
generator<std::pair<typename XX::value_type, typename YY::value_type>>
cartesian(auto const& xx, auto const& yy)
{
for (auto x : xx)
for (auto y : yy)
co_yield std::make_pair(x, y);
}
// usage:
// for (const auto& x_y : cartesian(container1, container2))
// { .... }
template <typename R1, typename R2>
class cartesian_combine
{
public:
explicit cartesian_combine(const R1& r1, const R2& r2)
: xx(r1), yy(r2)
{}
struct iterator_sentinel {};
template <typename It1, typename S1,
typename It2, typename S2>
struct const_iterator
{
const_iterator(It1 xx_begin, S1 xx_end,
It2 yy_begin, S2 yy_end)
: xx_begin(xx_begin), xx_it(xx_begin), xx_end(xx_end),
yy_begin(yy_begin), yy_it(yy_begin), yy_end(yy_end)
{
if (yy_it == yy_end) { xx_it = xx_end; }
}
const It1 xx_begin;
It1 xx_it;
const S1 xx_end;
const It2 yy_begin;
It2 yy_it;
const S2 yy_end;
bool operator==(iterator_sentinel) const {
return xx_it == xx_end;
}
auto operator*() const {
return std::make_pair(*xx_it, *yy_it);
}
const_iterator& operator++() {
if (++yy_it == yy_end)
{
++xx_it;
yy_it = yy_begin;
}
return *this;
}
};
auto begin() const { return const_iterator<
decltype(xx.begin()), decltype(xx.end()),
decltype(yy.begin()), decltype(yy.end())
>{xx.begin(), xx.end(),
yy.begin(), yy.end()};
}
iterator_sentinel end() const { return {}; }
private:
const R1& xx;
const R2& yy;
};
auto
cartesian(const auto& xx, const auto& yy)
{
return cartesian_combine(xx, yy);
}
// usage - identical as in "coroutine" case
很明显,“协程”代码比这个容易出错的操作一堆迭代器的代码简单得多。然而,性能成本是我真正担心的事情。 所以,我的问题是,我不确定——可能,这就是在主函数和这个协程的框架之间切换的成本。但为什么成本这么高?
在我的示例中,我将两个数组(每个数组包含 10 个元素)组合在一起,从而在这些框架(或上下文 - 我不确定这里的正确术语是什么)之间进行 100 倍的切换。也许,这不是协程帧动态内存分配的成本,因为它只发生一次,我什至尝试从生成器的 Promise_type 运算符 new() 返回预分配的缓冲区,但这没有帮助。
虽然期望生成器式协程与非生成器一样高效通常是不正确的,但性能差异的规模表明您的
generator
对象(或其使用方式)正在抑制优化,或者您的编译器的目前生成器优化还不成熟。
您的特定用例应该有利于触发生成器优化,但它们似乎没有发生。
范围叉积和迭代器代码除了操作索引和单个内存加载之外什么也不做。如果不是硬编码的“不优化”,它会优化到什么都不做。
协程代码确实没有优化到什么都不做,因为它设置了执行任意操作的能力,并且它确实进行了类型擦除。
如果你的操作是空的,协程不是一个好主意。
但是,如果您的操作与单个内存分配一样便宜,那么差异几乎完全消失。
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);
在这里,我为每个访问的元素添加内存分配(4 到 400 字节),然后在每次迭代结束时释放它们。这表示正在完成一些不平凡的计算。
当我这样做时,迭代器和范围版本得到 13,000,协程版本得到 15,000。
用 C++ 实现的协程不适合取代逐像素操作,其中迭代量远远主导迭代中完成的工作量。您的基准测试是展示这一点的好方法。
如果您使用任何类型的类型擦除迭代器进行类似的测试,您会遇到类似的开销问题;这不是特定于协程的,而是特定于类型擦除的。
我通常处理这个问题的一种方法是确保我正在迭代的数据块在类型擦除代码中是不平凡的。例如,如果我迭代像素,我的运行时类型擦除是在扫描线或扫描线块的级别。
任何更高分辨率的类型擦除我都会在编译时进行,这样我就可以编写每个像素的操作,让我的编译时模板将其重写为块或扫描线操作,然后将这些块或扫描线操作输入到擦除的类型中(迭代)或(协程)或任何系统。
让协程需要类型擦除的决定导致了这个结果。编译器编写者在不进行类型擦除的情况下遇到了问题,因为协程的大小很难尽早确定。类型擦除协程可以在其主体被完全理解之前确定其大小。