— boreal-kiss.com

Grand Central Dispatch: ストライドを用いてforループのイテレーション回数を最適化する

イントロダクション

Grand Central Dispatch (GCD)にはforループをマシンのコア数に応じて並列処理してくれる便利な関数 dispatch_apply(size_t, dispatch_queue_t, void) が存在する。

void dispatch_apply(
	size_t iterations,
	dispatch_queue_t queue,
	void (^block)(size_t));

引数は前から順番に、forループのイテレーション回数、タスク送信先のキュー、各イテレーションで行われるタスク、である。この関数はforループの各イテレーションごとにキューにタスクを送っている。したがって、もしタスクの処理時間がキューにタスクを送信するためにかかる時間(オーバーヘッド)より小さくなるような場合には注意が必要となる。普通にforループをまわす時よりも処理が遅くなる可能性があるからだ。具体例を見てみよう。

実行環境

Mac OS X 2.4 GHz Intel Core 2 Duo (CPU2個)

forループ vs. GCD その1

以下のような「何もしない」blockをforループでまわして処理にかかった時間を計測してみる。

void (^block)(int i) = ^(int i){
 
};

比較するのは普通のforループ処理を行う関数 loop_normal(int, void)

static void loop_normal(int count, void (^block)(int)){
	for (int i=0; i<count; i++){
		block(i);
	}
}

とGCDを用いた場合の関数 loop_normal_gcd(int, void) である。

static void loop_normal_gcd(int count, void (^block)(int)){
	void (^block_gcd)(size_t i) = ^(size_t i){
		block(i);
	};
 
	dispatch_queue_t queue 
		= dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
	dispatch_apply(count, queue, block_gcd);
}

比較した結果が下図になる(横軸はイテレーション回数、縦軸が関数の処理にかかった時間)。これを見るとGCDを用いた場合の方が(格段に)遅くなっていることがわかるだろう。この原因は「何もしない」タスクを完了するための時間に比べてキュー送信のオーバーヘッドが大きいことによる。

for loop vs GCD

それではこのオーバーヘッドが無視できるような構造にするにはどうすればよいだろうか。ひとつの方法はループにストライドを導入することである。

ストライドの導入

この方式はストライドで定義される整数値分の回数のイテレーションを一回のイテレーションに押し込んでしまう方法だ。これによりforループのイテレーション回数が減り、かわりに一回のイテレーションにおけるタスク量を増やすことができる。具体的に見てみよう。まず普通のforループを行う関数 loop_normal(int, void) がある。これはcount回のイテレーションを行う。

static void loop_normal(int count, void (^block)(int)){
	for (int i=0; i<count; i++){
		block(i);
	}
}

次に上記関数と全く同じ処理を行う関数が以下の loop_stride(int, int, void) である。ただし整数 stride (stride < count) を導入することで関数内部のループ回数が (count / stride) になっていることに注目してもらいたい。イテレーション回数が減った分イテレーション一回のタスク量が増えていることもわかるだろう。

static void loop_stride(int count, int stride, void (^block)(int)){
	int iMax = count / stride;
	int remainder = count % stride;
 
	void (^block_stride)(int i) = ^(int i){
		int j = i * stride;
		int jMax = j + stride;
 
		if (i == iMax - 1){
			jMax += remainder;
		}
 
		while (j < jMax) {
			block(j);
			j++;
		}
	};
 
	for (int i=0; i<iMax; i++){
		block_stride(i);
	}
}

ストライドを導入すればイテレーションの一回のタスク量を増やせることがわかった。それでは実際にGCDに放り込んで結果を見てみよう。

forループ vs. GCD その2

比較したのは普通のforループを行う関数 loop_normal(int, void)

static void loop_normal(int count, void (^block)(int)){
	for (int i=0; i<count; i++){
		block(i);
	}
}

とストライドを導入して一回のタスク量を増やした関数 loop_stride_gcd(int, int, void) である。

static void loop_stride_gcd(int count, int stride, void (^block)(int)){
	int iMax = count / stride;
	int remainder = count % stride;
 
	void (^block_gcd)(size_t i) = ^(size_t i){
		int j = i * stride;
		int jMax = j + stride;
 
		if (i == iMax - 1){
			jMax += remainder;
		}
 
		while (j < jMax) {
			block(j);
			j++;
		}
	};
 
	dispatch_queue_t queue 
		= dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
	dispatch_apply(iMax, queue, block_gcd);
}

そして両者の引数に「何もしない」blockを渡し実行時間を比較した。

void (^block)(int i) = ^(int i){
 
};

ここではイテレーションの回数は100,000回に固定し、ストライドの値を変化させて両者を比較した。結果は以下の図になる(横軸はストライドの値、縦軸は関数の処理にかかった時間)。ストライドの値が大きい場合はGCDを用いた場合の方が処理が早くなっていることがわかる。これらはつまりキューにタスクを送る際のオーバーヘッドが全体として無視できる程度になっていることを意味する。

for loop vs GCD (stride)

まとめ

GCDの恩恵を受けるためにはタスクは適度に重い(処理に時間がかかる)ものである必要があることがわかった。タスクが軽すぎる場合、GCDを利用することでかえって普通のforループよりも処理が遅くなる場合がある。これはGCDのオーバーヘッドによる。このような場合にはforループにストライドを導入することでイテレーション回数を減らし一回のタスクを重くすることで解決できる。

0 comments
Submit comment