アセンブラコードで見るC++ Composer XEの強力な最適化機能 4ページ
自動的にマルチスレッド化されたコードを出力する「自動並列化」
近年ではマルチコアCPUの普及が進んでいる。そのような状況で、処理を複数のスレッドに分割して実行する並列処理はパフォーマンス向上に効果的である。一般的にはループで複数繰り返される処理を並列化することが多いが、インテル C++ Composer XE 2011には、このような処理を自動的に並列化する自動並列化機能が搭載されている。
たとえば、次のリスト6は自動並列化が適用されるコードの例だ。
リスト6 自動並列化が適用されるようなコードの例
#define MSIZE 1000
for (i = 0; i < MSIZE; i++) {
for (j = 0; j < MSIZE; j++) {
double d = 0.0;
int k;
for (k = 0; k < MSIZE; k++) { ……(1)
d += a[i*MSIZE+k] * b[k*MSIZE+j];
}
c[i*MSIZE+j] = d;
}
}
printf("%f\n", c[0]);
こちらも科学・工業計算分野で良く用いられる行列演算処理で、行列の積を求める、というものだ。このコードは先に示したリスト1のコードと似ているように見えるが、(1)のループ内で配列aに対しては1要素ずつ順にアクセスが行われるのに対し、配列bについては定数MSIZE(ここでは1000)おきの要素にアクセスが行われる。そのため、リスト1の場合のようなベクトル化は困難である。さらに、MSIZEの値が大きいとキャッシュミスも頻出するため、速度が低下しやすい処理となっている。このような処理の高速化に有効なのが、インテル C++ Composer XE 2011の自動並列化機能である。
リスト6のコードは、(1)の部分をたとえば次のように分割したり、また分割後(2)と(3)の実行順序を入れ替えたりしても、結果は不変である。
リスト7 リスト6のループは分割可能
for (k = 0; k < MSIZE/2; k++) { ……(2)
d += a[i*MSIZE+k] * b[k*MSIZE+j];
}
for (k = MSIZE/2; k < MSIZE; k++) { ……(3)
d += a[i*MSIZE+k] * b[k*MSIZE+j];
}
そこで、ループをリスト7のように分割し、(2)と(3)の部分を複数のCPUコアを用いて同時に実行することで、最大で2倍近くの性能向上が期待できる。インテル C++ Composer XE 2011の自動並列化機能を用いれば、特に追加のコードを記述することなしにこのような並列化を行うことが可能だ。
自動並列化はデフォルトでは有効になっていないが、コンパイラオプション「/Qparallel」(Windows版の場合)もしくは「-parallel」(Linux版の場合)を付加するだけで利用できる。これらのオプション付きでコンパイルを実行するとソースコード中で自動並列化可能な個所が検出され、自動的に並列実行されるコートが出力される。たとえばリスト6のコードを自動並列化を有効にしてコンパイルした場合、生成されるアセンブラコードは次のリスト8~10のようになる。
まずリスト8は、並列化を行う準備を行っている部分のコードとなる。ここでは、「__kmpc_ok_to_fork」という関数を呼び出し、その戻り値によって実行する処理を分岐させている。この関数はドキュメント等には記載されていないが、「libiomp5md.dll」というライブラリに含まれているもので、インテル C++ Composer XE 2011でOpenMPを使用する際に用いられるものだ。この関数はその名前から、スレッドの生成を行うか否かを判断しているものと思われる。もし0が帰ってきた場合はラベル「.B1.19:」へとジャンプする。いっぽう0以外が帰ってきた場合は、引数を準備して「__kmpc_fork_call」を実行し、実行完了後ラベル「.B1.35」にジャンプするようになっている。
ちなみに、ラベル「.B1.19」以下のコードと、「__kmpc_fork_call」の引数として渡されるアドレス(ラベル「L__main_102__hpo_threaded_loop0_2.34:」)以下のコードは、どちらもほぼ同様の処理を行っている。アセンブラコードに含まれるコメントから、並列実行が可能な場合と、そうでない場合でと実行するコードを分けているようだ(リスト9、10)。
リスト8 自動並列化が行われたアセンブラコード:スレッドの作成部
;;; for (i = 0; i < MSIZE; i++) {
; .2.7_2_hpo_loc_struct_pack.36以下に格納されているデータを引数に
; __kmpc_ok_to_fork関数を呼び出す。
; 戻り値はeaxレジスタに格納される
push OFFSET FLAT: .2.7_2_hpo_loc_struct_pack.36
call ___kmpc_ok_to_fork
.B1.78:
add esp, 4
.B1.17:
test eax, eax ; eax == 0かどうかチェック
je .B1.19 ; eax == 0なら.B1.19へジャンプ
.B1.18:
; 引数を準備して___kmpc_fork_callを呼び出し
mov eax, DWORD PTR [144+esp]
lea ecx, DWORD PTR [140+esp]
mov DWORD PTR [128+esp], eax
lea edx, DWORD PTR [128+esp]
mov DWORD PTR [132+esp], ebx
lea eax, DWORD PTR [132+esp]
mov DWORD PTR [136+esp], esi
lea edi, DWORD PTR [136+esp]
mov DWORD PTR [140+esp], 0
push ecx
push edi
push eax
push edx
push OFFSET FLAT: L__main_102__hpo_threaded_loop0_2.34
push 4
push OFFSET FLAT: .2.7_2_hpo_loc_struct_pack.36
call ___kmpc_fork_call
.B1.79:
add esp, 28
jmp .B1.35
リスト9 「__kmpc_ok_to_fork」の戻り値が0の際に実行されるコード(抜粋)
.B1.19:
xor eax, eax
mov DWORD PTR [136+esp], eax
xor edi, edi
mov DWORD PTR [128+esp], esi
mov DWORD PTR [132+esp], ebx
.B1.20:
mov eax, DWORD PTR [132+esp]
;;; for (j = 0; j < MSIZE; j++) {
xor edx, edx
mov ecx, DWORD PTR [128+esp]
;;; double d = 0.0;
;;; int k;
;;; for (k = 0; k < MSIZE; k++) {
mov DWORD PTR [140+esp], edi
add eax, edi
mov ebx, eax
add ecx, edi
and ebx, 15
mov esi, ebx
and esi, 7
mov DWORD PTR [156+esp], ebx
mov DWORD PTR [148+esp], esi
mov DWORD PTR [152+esp], ecx
:
:
:
リスト10 「__kmpc_fork_call」の引数として渡されるアドレス以下のコード(抜粋)
L__main_102__hpo_threaded_loop0_2.34::
.B1.41:
push ebp
mov ebp, esp
:
:
:
.B1.45:
mov esi, DWORD PTR [156+esp]
;;; for (j = 0; j < MSIZE; j++) { に相当すると思われるコード
mov edx, ebx
mov eax, DWORD PTR [140+esp]
;;; double d = 0.0;
;;; int k;
;;; for (k = 0; k < MSIZE; k++) {
;;; に相当すると思われるコード
mov ecx, DWORD PTR [136+esp]
add eax, esi
add ecx, esi
mov esi, eax
and esi, 15
mov edi, esi
and edi, 7
mov DWORD PTR [172+esp], esi
mov DWORD PTR [168+esp], edi
mov DWORD PTR [164+esp], ecx
:
:
:
自動並列化の効果であるが、今回の例のように単純に処理を分割できる場合大きな高速化が期待できる。実際、プログラムを実行したところほぼ半分の時間で処理を完了できている(表5)。
| 自動並列化の有無 | 実行時間(3回の平均) | バイナリサイズ |
|---|---|---|
| 自動並列化有効 | 3.701秒 | 73288バイト |
| 自動並列化無効 | 7.238秒 | 12288バイト |
なお、自動並列化により生成されたバイナリはスレッド生成など並列化のためのコードも含むため、バイナリサイズが大きくなる傾向があるようだ。