C++20 Coroutine 性能测试 (附带和libcopp/libco/libgo/goroutine/linux ucontext对比)
前言
之前写了 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 和 《C++20 Coroutine》 ,但是一直没写 C++20 Coroutine 的测试报告。
现在的草案版本比我当时写 《C++20 Coroutine》 的时候有了一点点更新,cppreference 上有文档了(https://en.cppreference.com/w/cpp/language/coroutines) 。里面列举的标准文档是P0912R5,这个文档目前还没完工,详情可以看他的来源N4775。不过内容上暂时还没有太大的变化,今天我就照着之前的方式来benchmark一波 C++20 Coroutine 吧。
压力测试机环境
为了方便比较,我更新了一下之前在 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 里的测试项目的版本。Windows环境仅仅是为了测试MSVC下的性能,因为GCC还不支持所以Linux下是使用Clang编译的。
环境名称 | 值 |
系统 | Linux kernel 3.10.107(Docker) |
CPU | Intel(R) Xeon(R) Gold 61xx CPU @ 2.50GHz * 48 |
L1 Cache | 64Bytes*64sets*8ways=32KB |
系统负载 | 0.19 0.25 0.27 |
内存占用 | 3.5GB(used)/125GB(sum) |
CMake | 3.15.2 |
GCC版本 | 9.2.0 |
Clang版本 | 9.0.0 |
Golang版本 | 1.13.1 (20190903) |
1.71.1 (20190819) | |
libco版本 | 03ba1a453c266b76e1c01aa519621ef7db861500 (20190902) |
libcopp | 1.2.1 (20191004) |
cbdf26bbf568a72e81fdd7ec390ddbcae5d5dd92 (20190822) |
环境名称 | 值 |
系统 | Windows 10 Pro 1903 (2019 Sept) |
CPU | Intel(R) Core(TM) i7-8700 @ 3.20GHz * 12 |
L1 Cache | 64Bytes*64sets*8ways=32KB |
系统负载 | 低于 10% |
内存占用 | 8.2GB(used)/16.7GB(cached)/38.7GB(free) |
MSVC版本 | MSVC v142 - VS 2019 C++ x86/x64 (14.23) |
测试代码
C++20 Coroutine 上手比较麻烦,所以测试代码那是真滴长。 co_await
的原理和 co_yield
是一样的,只是 co_await
多了一点点对封装类似 libcotask 的支持,单纯的上下文切换仅使用 co_yield
就可以了。这样也更能公平地拿来和其他几个协程库对比。
Clang编译命令: $LLVM_CLANG_PREFIX/bin/clang++ -std=c++2a -O2 -g -ggdb -stdlib=libc++ -fcoroutines-ts -lc++ -lc++abi -Wl,-rpath=$LLVM_CLANG_PREFIX/lib/ test.cpp
MSVC编译命令: cl /nologo /O2 /std:c++latest /Zi /MDd /Zc:__cplusplus /EHsc /await test.cpp
其他库的测试代码在 https://gist.github.com/owt5008137/1842b56ac1edd5a7db54590d41af1c44
测试结果及对比
组件(Avg) | 协程数:1 切换开销 | 协程数:1000 创建开销 | 协程数:1000 切换开销 | 协程数:30000 创建开销 | 协程数:30000 切换开销 |
C++20 Coroutine - Clang | 5 ns | 130 ns | 6 ns | 136 ns | 9 ns |
C++20 Coroutine - MSVC | 10 ns | 407 ns | 14 ns | 369 ns | 28 ns |
C++20 裸测试的性能真是非常夸张地高,基本上性能已经赶上 call_in_stack 这种对分支预测做优化的版本,并且还有不错的灵活性。这里性能测试的结果很好看一方面是 coroutine_handle<T>
的成员是个指针,再里面的管理上下文的部分我没法控制它的实现,所以没法模拟cache miss。另一方面也是由于它是使用operator new并且分析调用的函数需要多少栈来分配栈空间的,这样不会有内存缺页的问题(因为和其他的逻辑共享内存块),而且地址空间使用量也很小并且是按需分配的,也减少了系统调用的次数。还有一点影响比较大的是这次测试的C++20 Coroutine的代码全部是非线程安全的。而 libcopp 在实际应用中是搭配上了线程安全检查和一些防止误用的状态检查的,全是atomic操作,甚至 libgo 那种加锁的线程安全的检查,性能会会受到一定影响。如果在实际应用C++20 Coroutine的时候也加上这些检查,估计性能会下降几倍,但是应该还是会比现在的成熟方案要快一些。
不过现阶段 《C++20 Coroutine》 使用上还有些限制,所有使用 co_await
或者 co_yield
的函数返回值必须有 promise_type 。 也就是说如果嵌套使用或者递归调用的话不能直接用上层的协程对象,一旦出现嵌套使用只能 co_await
然后新创建一个协程对象。比如调用链 func1()->func2()->func3()->func4()
, 如果 func1 和 func4 是需要使用协程调用,要么得 func2 和 func3 也实现成协程,然后 func1 里 co_await func2()
, 要么 func2 和 func3 把 func4 产生的 awaitable
对象透传回来,然后由 func1 来 co_await func2().awaitable
。 也就是说 func2 和 func3 对 func4 不能完全透明。这是 《C++20 Coroutine》 比不上 libcopp 的地方。 这也是我前段时间思考给 libcopp 接入 《C++20 Coroutine》 做Context管理的最大困难。
我们拿之前 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 对比过的其他库放一起来看:
组件(Avg) | 协程数:1 切换开销 | 协程数:1000 创建开销 | 协程数:1000 切换开销 | 协程数:30000 创建开销 | 协程数:30000 切换开销 |
栈大小(如果可指定) | 16 KB | 2 MB | 2 MB | 64 KB | 64 KB |
C++20 Coroutine - Clang | 5 ns | 130 ns | 6 ns | 136 ns | 9 ns |
C++20 Coroutine - MSVC | 10 ns | 407 ns | 14 ns | 369 ns | 28 ns |
34 ns | 4.1 us | 80 ns | 3.8 us | 223 ns | |
32 ns | 96 ns | 77 ns | 212 ns | 213 ns | |
50 ns | 4.1 us | 141 ns | 4.2 us | 389 ns | |
49 ns | 134 ns | 134 ns | 256 ns | 371 ns | |
libco+静态栈池 | 84 ns | 3.9 us | 168 ns | 4.2 us | 450 ns |
libco(共享栈4K占用) | 83 ns | 3.9 us | 529 ns | 3.9 us | 1073 ns |
libco(共享栈8K占用) | 86 ns | 4.0 us | 828 ns | 3.9 us | 1596 ns |
libco(共享栈32K占用) | - | 4.0 us | 9152 ns | 3.9 us | 11.5 us |
libgo 2019年9月master分支 | 53 ns | 8.3 us | 120 us | 5.5 us | 237 ns |
libgo 2018年版本 with boost | 197 ns | 5.3 us | 124 ns | 2.3 us | 441 ns |
libgo 2018年版本 with ucontext | 539 ns | 7.0 us | 482 ns | 2.7 us | 921 ns |
425 ns | 1.0 us | 710 ns | 1.0 us | 1047 ns | |
linux ucontext | 439 ns | 4.4 us | 505 ns | 4.8 us | 890 ns |
来个直观一点的图:
{ "type": "bar", "data": { labels: ['协程数:1,栈大小16KB', '协程数:1000,栈大小2MB', '协程数:30000,栈大小64KB'], "datasets": [ { "label": "C++20 Coroutine - Clang 切换耗时", "borderColor": "rgba(139, 0, 0, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [5, 6, 9], "type": 'line' }, { "label": "C++20 Coroutine - MSVC 切换耗时", "borderColor": "rgba(0, 0, 139, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [10, 14, 28], "type": 'line' }, { "label": "libcopp 切换耗时", "borderColor": "rgba(0, 139, 139, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [34, 80, 223], "type": 'line' }, { "label": "libcopp+动态栈池 切换耗时", "borderColor": "rgba(184, 134, 11, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [32, 77, 213], "type": 'line' }, { "label": "libcopp+libcotask 切换耗时", "borderColor": "rgba(169, 169, 169, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [50, 141, 389], "type": 'line' }, { "label": "libcopp+libcotask+动态栈池 切换耗时", "borderColor": "rgba(189, 183, 107, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [49, 134, 371], "type": 'line' }, { "label": "libco+静态栈池 切换耗时", "borderColor": "rgba(139, 0, 139, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [84, 168, 450], "type": 'line' }, { "label": "libco(共享栈4K占用) 切换耗时", "borderColor": "rgba(85, 107, 47, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [83, 529, 1073], "type": 'line' }, { "label": "libco(共享栈8K占用) 切换耗时", "borderColor": "rgba(255, 140, 0, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [82, 828, 1596], "type": 'line' }, { "label": "libgo 2019年9月master分支 切换耗时", "borderColor": "rgba(153, 50, 204, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [53, 120, 237], "type": 'line' }, { "label": "libgo 2018年版本 with boost 切换耗时", "borderColor": "rgba(233, 150, 122, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [197, 124, 441], "type": 'line' }, { "label": "libgo 2018年版本 with ucontext 切换耗时", "borderColor": "rgba(143, 188, 143, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [529, 482, 921], "type": 'line' }, { "label": "goroutine(golang) 切换耗时", "borderColor": "rgba(255, 20, 147, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [425, 710, 1047], "type": 'line' }, { "label": "linux ucontext 切换耗时", "borderColor": "rgba(72, 61, 139, 1)", "fill": false, "yAxisID": 'y-axis-2', "data": [435, 509, 890], "type": 'line' }, { "label": "C++20 Coroutine - Clang 创建耗时", "backgroundColor": "rgba(255, 0, 0, 1)", "yAxisID": 'y-axis-1', "data": [null, 130, 136] }, { "label": "C++20 Coroutine - MSVC 创建耗时", "backgroundColor": "rgba(0, 0, 255, 1)", "yAxisID": 'y-axis-1', "data": [null, 407, 369] }, { "label": "libcopp 创建耗时", "backgroundColor": "rgba(0, 255, 255, 1)", "yAxisID": 'y-axis-1', "data": [null, 4100, 3800] }, { "label": "libcopp+动态栈池 创建耗时", "backgroundColor": "rgba(218, 165, 32, 1)", "yAxisID": 'y-axis-1', "data": [null, 96, 212] }, { "label": "libcopp+libcotask 创建耗时", "backgroundColor": "rgba(128, 128, 128, 1)", "yAxisID": 'y-axis-1', "data": [null, 4100, 4200] }, { "label": "libcopp+libcotask+动态栈池 创建耗时", "backgroundColor": "rgba(240, 230, 140, 1)", "yAxisID": 'y-axis-1', "data": [null, 134, 256] }, { "label": "libco+静态栈池 创建耗时", "backgroundColor": "rgba(255, 0, 255, 1)", "yAxisID": 'y-axis-1', "data": [null, 3900, 4200] }, { "label": "libco(共享栈4K占用) 创建耗时", "backgroundColor": "rgba(128, 128, 0, 1)", "yAxisID": 'y-axis-1', "data": [null, 3900, 3900] }, { "label": "libco(共享栈8K占用) 创建耗时", "backgroundColor": "rgba(255, 165, 0, 1)", "yAxisID": 'y-axis-1', "data": [null, 4000, 3900] }, { "label": "libgo 2019年9月master分支 创建耗时", "backgroundColor": "rgba(218, 112, 214, 1)", "yAxisID": 'y-axis-1', "data": [null, 8300, 5500] }, { "label": "libgo 2018年版本 with boost 创建耗时", "backgroundColor": "rgba(250, 128, 114, 1)", "yAxisID": 'y-axis-1', "data": [null, 5300, 2300] }, { "label": "libgo 2018年版本 with ucontext 创建耗时", "backgroundColor": "rgba(46, 139, 87, 1)", "yAxisID": 'y-axis-1', "data": [null, 7000, 2700] }, { "label": "goroutine(golang) 创建耗时", "backgroundColor": "rgba(106, 90, 205, 1)", "yAxisID": 'y-axis-1', "data": [null, 1000, 1000] }, { "label": "linux ucontext 创建耗时", "backgroundColor": "rgba(112, 128, 144, 1)", "yAxisID": 'y-axis-1', "data": [null, 4400, 4800] }] }, "options": { title: { display: true, text: '切换/创建耗时(越小越好)' }, scales: { yAxes: [{ type: 'linear', display: true, scaleLabel: { display: true, labelString: "切换耗时(单位:纳秒)" }, position: 'left', id: 'y-axis-2', gridLines: { drawOnChartArea: false }, ticks: { callback: function(value, index, values) { return value + ' ns'; } } }, { type: 'logarithmic', display: true, scaleLabel: { display: true, labelString: "创建耗时(单位:纳秒)" }, ticks: { autoSkip: true, callback: function(value, index, values) { for (var idx in values) { var tv = values[idx]; if (tv < value && Math.floor(Math.log(value)) == Math.floor(Math.log(tv))) { return null; } } return value + ' ns'; } }, position: 'right', id: 'y-axis-1', }], } } }
结论就不多说了,和 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 差不多,需要稍微提一下的是上面的 创建耗时 的时间不是线性而是对数的,因为几个库差距有点大,等差的图示太难区分了;另外测试条目里并不全在一个层面,有些是比较底层的接口,有的是已经接近工程化了。还有上面的测试结果受代码缓存命中率和数据缓存命中率影响比较大,除了 C++20 Coroutine 的测试以外,其他的都使用了一定的手段来让cache miss(更接近实际应用)。所以实际使用 C++20 Coroutine 的话切换性能应该是会比这个结果看起来差一些。不过参考 boost.context 的裸调用fcontext的上下文切换,cache不miss的时候大约是30ns左右,相比起来 C++20 Coroutine 还是很有优势的,而且 C++20 Coroutine 更大的优势在于创建性能和内存占用。
最后
欢迎有兴趣的小伙伴们一起交流哈。
Last updated