coro プールに関するベンチマーク

Coroでスレッドプールを使う - 北海道苫小牧市出身の初老PGが書くブログで Coro でスレッドプールを使う際の注意点が、様々なベンチマークと共に示されている。まとめとして

「async_poolを使う時には、同時にたくさんのスレッドを利用し過ぎないように気をつける」の一点に尽きます。

とあるのだが、これは順序が違うのではないかと思ったりする。ごく単純な話だが、「async_pool を使う」ことが先に来るわけではなく、「スレッドを起こしたり破棄したりを繰り返すケース」が存在し、そういう場合は async を使うよりも async_pool を使ったほうがいい、という順序になる。もちろん、そういうケースで async_pool を使うことを選択したときに、「同時にたくさんのスレッドを利用し過ぎないように気をつける」というのは適切な注意であるわけだが。同エントリでは、最初に次のようなベンチマークをとっている。

my $tasks = 100;
Benchmark::cmpthese 1000, {
	async => sub {
		my $sem = Coro::Semaphore->new( 1 - $tasks );
		for(1 .. $tasks){
			async { $sem->up };
		}
		$sem->down;
	},
	async_pool => sub {
		my $sem = Coro::Semaphore->new( 1 - $tasks );
		for(1 .. $tasks){
			async_pool { $sem->up };
		}
		$sem->down;
	},
};

このクロージャはそもそも async_pool が活きてくる「スレッドを起こしたり破棄したりを繰り返す」パターンではない。100 個の coro が作られるけれども、それらは実行されず ready キューに入れられたままになっている。そして main スレッドが $sem->down できずにブロックされたあと、次々に実行され、$sem->up して (async_pool ブロックを抜けて) terminate する。100 個の coro が terminateの処理が終了した後、セマフォのカウンタが 1 になって main スレッドが動いて一回のイテレーションが終了する。
1,000 回イテレーションが行われるので、そこでは「スレッドを起こしたり破棄したりを繰り返す」パターンになるのだが、破棄された coro のうち再利用に回されるのは $Coro::POOL_SIZE == 8 個だけなので 92 個の coro は毎回オブジェクトの生成、破棄が繰り返されることになり、coro プールのありがたみはほとんど得られない。それ以上にプール操作のオーバヘッドが利いてしまう。

coro プールを使うことで得られるアドバンテージ、プール管理にかかるオーバヘッドを測定するために次のようなベンチマークをしてみた。

use strict;
use warnings;
use Coro;
use Benchmark;

timethese 50_000, {
    async_7 => sub { async      {} for 1..7; cede },
    async_8 => sub { async      {} for 1..8; cede },
    async_9 => sub { async      {} for 1..9; cede },
    pool_7  => sub { async_pool {} for 1..7; cede },
    pool_8  => sub { async_pool {} for 1..8; cede },
    pool_9  => sub { async_pool {} for 1..9; cede },
};
__END__

それぞれ、何もしない coro を 7 (もしくは 8 もしくは 9) 個生成し、main スレッドで cede することですべての coro を走らせ、terminate 処理を終了させる。これを繰り返す。coro プールの利用、すなわち async_pool が有効と考えられるパターンである。async_7 と async_8、async_9 は 7:8:9 の比になることが予想される。pool_7 と pool_8 も 7:8 の比になることが予想される。pool_8 より pool_9 にかかる時間がひとつの coro を余分に生成、破棄するコストと考えられる。これは async_7 と async_8 の差 (async_8 と async_9 の差) に近いと予想される。

Benchmark: timing 50000 iterations of async_7, async_8, async_9, pool_7, pool_8, pool_9...
   async_7:  4 wallclock secs ( 3.78 usr +  0.00 sys =  3.78 CPU) @ 13227.51/s (n=50000)
   async_8:  4 wallclock secs ( 4.24 usr +  0.01 sys =  4.25 CPU) @ 11764.71/s (n=50000)
   async_9:  5 wallclock secs ( 4.81 usr +  0.00 sys =  4.81 CPU) @ 10395.01/s (n=50000)
    pool_7:  1 wallclock secs ( 0.79 usr +  0.00 sys =  0.79 CPU) @ 63291.14/s (n=50000)
    pool_8:  1 wallclock secs ( 0.86 usr +  0.00 sys =  0.86 CPU) @ 58139.53/s (n=50000)
    pool_9:  1 wallclock secs ( 1.63 usr +  0.00 sys =  1.63 CPU) @ 30674.85/s (n=50000)

async_7 : async_8 : async_9 = 3.78 : 4.25 : 4.81 = 7.12 : 8.00 : 9.06 となった。予想どおり 7:8:9 に近い。一方 pool_7 : pool_8 : pool_9 = 0.79 : 0.86 : 1.63 = 7.35 : 8.00 : 15.2 となった。これも予想どおり 7:8 に近く、pool_8 と pool_9 では 8:9 よりも大幅に時間がかかっている。async_8 - async_7 = 0.47 は pool_9 - pool_8 = 0.77 よりもだいぶ少ない。この差が coro プールを管理するオーバヘッドと考えられる。