見出し画像

地道に解決していくためのパフォーマンスチューニング講座 Vol.4 ~非同期・並列化による改善~

CPUよ、休みなどないぞ

システムツー・ワンのテクニカルシステムエンジニアの「じょん(日本人)」です。前回に引き続きパフォーマンスチューニングのお話です。今回は、ある意味では一番効率化出来る手法ですが、一番頭がこんがらがる手法です。

そんな本稿、パフォーマンスチューニング講座第4回のテーマは「非同期・並列化による改善」。


そもそもプログラムってどうやって動くの


プログラムは基本的に上から順に一つずつ実行される。飛ばされたり順番が入れ替わることはない。これは、アプリケーションが立ち上がった際に、演算処理するための仕組みが1つしか用意されないためである。もう少し専門的な用語を使うと、アプリケーションが立ち上がった際にただ1つのプロセスと、プロセス内でただ1つのスレッドが立ち上がる。

プロセスはメモリ領域を確保する単位であり、通常1つのアプリケーションは1つのプロセスで動く。
※子プロセスとして新たなプロセスを立ち上げることも出来るが、メモリ領域は共有されないため、データの共有には工夫が必要となる。
※今回の内容でプロセス周りに触れることはないので、詳しくは調べてみてください。

プロセスの中にさらにスレッドという演算処理を行う単位がある。プログラムはこのスレッドによって処理が行われる。

子スレッドを立ち上げることで、演算処理を行う単位を増やすことができる。複数のスレッドがあると、プログラムは必ずしも上から順に一つずつ動くとは限らなくなってくる。

非同期処理とは


読んで字のごとく、同期していない処理である。「上の処理が終わったら下に流れて」という通常の処理が同期処理であるのに対し、「上の処理が終わるのを待たずに下の処理へ進む」のが非同期処理である。
※言語によって内部的な動きが異なりますが、今回は触れません。「JS 非同期 並列」などで検索すると色々な記事がヒットすると思います。

非同期処理の考え方


CPUは他の装置に処理を依頼すると暇する時間が発生することがある。CPUが暇するタイミングは他の処理を入れて、休ませずに働かせると良い。

具体的には、I/O・通信の処理中がねらい目である。

例えば、演算処理A:10秒、通信処理B:20秒、演算処理C:15秒とかかる処理があるとする。これを同期処理で行うと10+20+15=45秒かかる。それに対し、非同期処理では通信処理Bを実行中に演算処理Cを行うことを考える。そうすると、通信処理Bの時間内で演算処理Cが終わっているので、10+20=30秒だけで処理を終えることができる。

非同期処理を書いてみる


何かの合間にファイル出力するような処理があるとする。

// なんか処理
Thread.sleep(1000);

File file = new File("test.txt");
try (FileWriter fw = new FileWriter(file, false);
	BufferedWriter bw = new BufferedWriter(fw)) {
	for (int idx = 0; idx < 100000000; idx++) {
		bw.append(String.valueOf(idx));
		bw.newLine();
	}
} catch (Exception e) {
	e.printStackTrace();
}

// なんか処理
Thread.sleep(2000);

処理時間 9231msec.

このファイル出力は前の処理の影響は受けるが、後の処理には関係がないものとする。であればこのファイル出力処理は非同期にしてしまっても問題がない。非同期処理にしたコードが下記となる。

// なんか処理
Thread.sleep(1000);

Thread thread = new Thread(() -> {
	File file = new File("test.txt");
	try (FileWriter fw = new FileWriter(file, false);
		BufferedWriter bw = new BufferedWriter(fw)) {
		for (int idx = 0; idx < 100000000; idx++) {
			bw.append(String.valueOf(idx));
			bw.newLine();
		}
	} catch (Exception e) {
		e.printStackTrace();
	}
});
thread.start();

// なんか処理
Thread.sleep(2000);

thread.join();

処理時間 7101msec.

後続の2秒待つ処理がファイル出力中に行われるため、約2秒の短縮となる。

並列化とは


CPUに演算用のコアが複数あることを利用し、同時に複数の演算処理を行うようにすることである。非同期処理の一種。
※非同期処理という言葉は必ずしも同時演算処理が行われることを指していません。
※JSなどはI/O・通信中に演算を行うような非同期処理は出来ますが、並列処理は出来ません。

並列処理の考え方


互いに影響しあわない処理があれば並列処理にすることができる。互いに影響しあわない処理は大雑把に2通りの考え方がある。

  1. ロジックが同じ

  2. ロジックが違う

ロジックが同じ処理で考えられるのは、繰り返し処理である。繰り返し中の1回1回の処理が互いに影響しあっていなければ、1回目の繰り返しからからくそ真面目に一つずつ処理する必要はない。1回目と2回目と3回目の処理を同時に行う、つまり並列化することができる。なお、繰り返しの1回目の結果を2回目が使う場合は不可能である。
この考え方は対応手順が一般化しやすいため、コンパイラの最適化が勝手にやってくれていることもある。

ロジックが違う処理で考えられるのは、計算対象が色々あるが、互いに影響してないためどの順序で計算しても良いような処理である。その部分だけは影響しあわないといえど、最終的にはどこかで互いの結果を使うことになるとは思うが、それまでの部分を並列化することができる。
この考え方はコンパイラが最適化するのはほぼ不可能であり、プログラマが並列化処理を明示する必要がある。

並列処理を書いてみる


ロジックが違う処理は非同期処理でやったような感じでThreadを立てれば良く、複数の場合は複数Threadを立てれば良いだけなので割愛する。

ロジックが同じ場合を考えるため、モンテカルロ法による円周率の計算を考える。
※モンテカルロ法は繰り返しの1回1回がほぼ完全に独立しており、並列化の効率がとんでもなく良い。

final long MAX = 1000000000;
int count = 0;
Random random = new Random();
for (long idx = 0; idx < MAX; idx++) {
	double x = random.nextDouble();
	double y = random.nextDouble();
	double dist = Math.sqrt(x * x + y * y);
	if (dist <= 1) {
		count++;
	}
}
System.out.println(count * 4.0 / MAX);

処理時間 36438msec.

高々10数行のコードだが、これを並列化するとこうなる。

final long MAX = 1000000000;
final int THREAD_NUM = 11;

// 各スレッドが処理する件数を計算する
long countPerThread = (int)Math.ceil((double)MAX / THREAD_NUM);
long countPerThreadForLast = MAX - countPerThread * (THREAD_NUM - 1);

// 並列に処理する
CompletableFuture<Integer>[] arrFuture = new CompletableFuture[THREAD_NUM];
for (int idxFuture = 0; idxFuture < THREAD_NUM; idxFuture++) {
	final int _idxFuture = idxFuture;
	arrFuture[idxFuture] = CompletableFuture.supplyAsync(() -> {
		Random random = new Random(_idxFuture);
		long maxCount = _idxFuture != THREAD_NUM - 1 ? countPerThread : countPerThreadForLast;
		
		int count = 0;
		for (long idx = 0; idx < maxCount; idx++) {
			double x = random.nextDouble();
			double y = random.nextDouble();
			double dist = Math.sqrt(x * x + y * y);
			if (dist <= 1) {
				count++;
			}
		}
		return count;
	});
}

// 各スレッドで計算した結果を集約する
long allCount = 0;
for (int idxFuture = 0; idxFuture < THREAD_NUM; idxFuture++) {
	try {
		allCount += arrFuture[idxFuture].get();
	} catch (InterruptedException e) {
		e.printStackTrace();
	} catch (ExecutionException e) {
		e.printStackTrace();
	}
}
System.out.println(allCount * 4.0 / MAX);


約36秒を約4秒にまで短縮することができる。ロジックそのものに変化はなく、やり方を知っているとぱっと対応出来て効果的であることが分かる。

スレッド数を増やせば良いというものでもない


グラフを見てわかる通り、段々減少スピードは落ちていく。8スレッドで1つずつ並列処理をやるよりも、4スレッドでの並列処理を2つやった方が総合的な時間が短くなる場合もある。

また、12スレッドにするとむしろ遅くなっている。これは私のPCのCPUの論理プロセッサ数が12であるためである。

論理プロセッサ数が12だと11並列しかできないのか、と言われればそうではない。今回のロジックは簡易化のためメインスレッド(プログラム立ち上げ時にできる1つ目のスレッド)を計算に使っていない。そのため、メインスレッド1つ+11並列で計12スレッド使っている計算になる。12スレッド指定の場合は13スレッド使うことになっていたため、1スレッドオーバー。他のスレッドが終わるのを待ってオーバーしたスレッドが処理されるため、かえって遅くなってしまっている。

最後に


本稿では非同期と並列化による改善を行った。非同期ではCPUが暇するタイミングを作らせないという発想を、並列化では独立した処理は同時に実行するという発想をする必要がある。これは同期処理を書いているだけでは出てこない発想であり、考えることが増えるためこんがらがる考え方である。ぶっちゃけ、デバッグもしづらい。それでもやる価値はあるので、是非自分でも試してみてほしい。

全体を通して


今回は全4回でパフォーマンスチューニングの話を書いていきましたが如何でしたでしょうか。こういう記事は書き慣れていないため、読みづらいところも多々あったかと思います。それでもここまで読んでくれた皆様ありがとうございます。
正直まだまだ改善方法はあります。今回はSQLの話には全く触れていませんし。……SQL、というかDBのチューニングは奥が深いので、勉強してから改めてやりたいなと思っています。
それではまたお会いしましょう。では。