HPC/並列プログラミングポータルでは、HPC(High Performance Computing)プログラミングや並列プログラミングに関する情報を集積・発信しています。

新着トピックス

強力な最適化機能を持つインテル C++ Composer XE 2011

 インテル C++ Composer XE 2011には、表3のようなさまざまな最適化機能が搭載されている。

表3 インテル C++ Composer XE 2011が備える最適化機能
最適化機能説明
自動ベクトル化インテルCPUが備えるストリーミングSIMD拡張命令(SSE)を使用したベクトル演算により処理を高速化する
自動並列化安全に並列実行できるループを検出し、マルチスレッドで並列実行するコードを自動生成する
ハイパフォーマンス最適化機構(HPO)ループを解析し、キャッシュアクセスやメモリアクセスの最適化、SSEの使用、並列化、高速化のためのループ構造変更といった総合的な最適化を行う
プロシージャ間の最適化(IPO)高頻度で呼び出される関数についてリンク時にインライン化を行うことで呼び出しオーバーヘッドの削減による高速化を計る
プロファイルに基づく最適化プログラムを実際に実行した際の挙動を記録したデータ(プロファイル)を用いてコードを最適化する
データ・プリフェッチデータを先読みしてキャッシュに格納しておくことで計算処理のパフォーマンスを向上させる

 このなかでも、特に注目したい機能が自動ベクトル化と自動並列化機能、ハイパフォーマンス最適化機能である。

連続した同一の演算処理をSSEで実行する「自動ベクトル化」

 インテルCPUには、「ストリーミングSIMD拡張命令(SSE)」と呼ばれる演算命令が備えられている。SSEはXMMレジスタと呼ばれる専用レジスタを使用して各種演算を実行する機能で、FPU(浮動小数点処理ユニット)の代わりとして利用できるだけでなく、複数データに対する演算を同時に実行することが可能だ。XMMレジスタのサイズは128ビットなので、たとえば8バイトの倍精度浮動小数点データなら2個、4バイトの単精度浮動小数点データなら4個、1バイトのデータであれば16個を同時に処理できる。このように複数のデータを一括して処理する演算は「ベクトル処理」などとも呼ばれ、このような処理を行う命令は「パックド命令」などと呼ばれている。多くのデータを処理する場合、パックド命令を効率良く利用することで演算時間の短縮が期待できる。

 SSEはPentium III以降のインテルCPUに搭載されており、Pentium 4以降のCPUではSSE2/3、SSSE3、SSE4と拡張が続けられている。また、2011年初頭に登場すると見られているインテルの次世代CPUアーキテクチャ「Sandy Bridge」では、256ビットレジスタを利用できる「Intel AVX」と呼ばれる拡張命令セットが搭載される予定だ。

 さて、このSSE命令を利用する場合、効率的なコードを記述するためにはアセンブラでコードを記述する、もしくは専用のヘッダファイルをインクルードして「intrinsic命令」などと呼ばれるSSE命令を呼び出す関数を使用するなど、どのようにSSEを使用するかを明示的に指定する必要があった。いっぽうインテル C++ Composer XEでは明示的にSSEの利用を指定しなくとも、デフォルトでSSE/SSE2命令を利用したコードが出力されるようになっている。さらに、インテル C++ Composer XE 2011では複数のデータを連続して演算するような処理の場合、自動的にパックド命令を利用するコードを出力する。これが自動ベクトル化と呼ばれる機能である。

 自動ベクトル化は、主にループなどを使用して配列に対し連続して同一の演算を行うような処理において適用される。たとえばリスト1は、そのような処理の典型的なコードだ。

リスト1 典型的なループ処理コード
int i;
float a[128];
float b[128];
float d;
  :
  :
d = 0.0;
for (i = 0; i  128; i++) {
  d += a[i] * b[i];
}
printf("%f\n", d);
  :
  :

 これは「積和計算」などと呼ばれる処理で、科学・工業計算などの分野でよく利用されるものだ。このコードをインテル C++ Composer XE 2011でコンパイルすると、次のリスト2のようなアセンブラコードが出力される。

リスト2 リスト1のコードをインテル C++ Composer XE 2011でコンパイルした際に出力されるコード
;;; d = 0.0;
    pxor    xmm0, xmm0 ; xmm0を0に初期化
    mov     esi, eax
.B1.7:
    xor     edx, edx ; edxを0に初期化
    pxor    xmm1, xmm1 ; xmm1を0に初期化
    pxor    xmm0, xmm0 ; xmm0を0に初期化
.B1.8:
;;; for (i = 0; i  128; i++) {
;;;   d += a[i] * b[i];
    movaps  xmm2, XMMWORD PTR [128+esp+edx*4] ; xmm2にa[0+edx]をコピー
    movaps  xmm3, XMMWORD PTR [144+esp+edx*4] ; xmm3にa[4+edx]をコピー
    mulps   xmm2, XMMWORD PTR [640+esp+edx*4] ; xmm2にb[0+edx]を乗算
    mulps   xmm3, XMMWORD PTR [656+esp+edx*4] ; xmm3にb[4+edx]を乗算
    addps   xmm1, xmm2 ; xmm1にxmm2を加算
    addps   xmm0, xmm3 ; xmm0にxmm3を加算
    movaps  xmm4, XMMWORD PTR [160+esp+edx*4] ; xmm4にa[8+edx]をコピー
    movaps  xmm5, XMMWORD PTR [176+esp+edx*4] ; xmm5にa[12+edx]をコピー
    mulps   xmm4, XMMWORD PTR [672+esp+edx*4] ; xmm4にb[8+edx]を乗算
    mulps   xmm5, XMMWORD PTR [688+esp+edx*4] ; xmm5にb[12+edx]を乗算
    addps   xmm1, xmm4 ; xmm1にxmm4を加算
    addps   xmm0, xmm5 ; xmm0にxmm5を加算
    movaps  xmm6, XMMWORD PTR [192+esp+edx*4] ; xmm6にa[16+edx]をコピー
    movaps  xmm7, XMMWORD PTR [208+esp+edx*4] ; xmm7にa[20+edx]をコピー
    mulps   xmm6, XMMWORD PTR [704+esp+edx*4] ; xmm6にb[16+edx]を乗算
    mulps   xmm7, XMMWORD PTR [720+esp+edx*4] ; xmm7にb[20+edx]を乗算
    addps   xmm1, xmm6 ; xmm1にxmm6を加算
    addps   xmm0, xmm7 ; xmm0にxmm7を加算
    movaps  xmm2, XMMWORD PTR [224+esp+edx*4] ; xmm2にa[24+edx]をコピー
    movaps  xmm3, XMMWORD PTR [240+esp+edx*4] ; xmm3にa[28+edx]をコピー
    mulps   xmm2, XMMWORD PTR [736+esp+edx*4] ; xmm2にb[24+edx]を乗算
    mulps   xmm3, XMMWORD PTR [752+esp+edx*4] ; xmm3にb[28+edx]を乗算
    addps   xmm1, xmm2 ; xmm1にxmm2を加算
    addps   xmm0, xmm3 ; xmm0にxmm3を加算
    add     edx, 32 ; edxに32を加算
    cmp     edx, 128 ; edx == 128か?
    jb    .B1.8 ; edx == 128でなければ.B1.8へ戻る
.B1.9:
;;; }
;;;
;;; printf("%f\n", d);
    mov     DWORD PTR [esp], OFFSET FLAT: ??_C@_03A@?$CFf?6?$AA@
    addps   xmm1, xmm0 ; xmm1にxmm0を加算
; xmm1の上位64ビットと下位64ビットを加算
    movaps  xmm0, xmm1 ; xmm1をxmm0にコピー
    movhlps   xmm0, xmm1 ; xmm1の上位64ビットをxmm0の下位64ビットにコピー
    addps   xmm1, xmm0 ; xmm1にxmm0を加算
    movaps  xmm2, xmm1; xmm1をxmm2にコピー
 ; xmm2の32~63ビットをxmm2の0~63ビットにコピー、xmm1の96~127ビットをxmm2の64~127ビットにコピー
    shufps  xmm2, xmm1, 245
    addss   xmm1, xmm2 ; xmm2の下位32ビットをxmm1の下位32ビットに加算
    cvtss2sd  xmm1, xmm1; xmm1の下位32ビットをdouble型に変換してxmm1の下位64ビットに格納
    movsd   QWORD PTR [4+esp], xmm1 ; xmm1の下位64ビットをスタックに投入
    call    _printf

 ここで、太字になっている命令がSSE命令だ。また、「xmm0」~「xmm7」というのは、SSEで使用される128ビット長レジスタ(XMMレジスタ)を表している。このコードではSSE命令を使用し、同時に4個のfloate型データの乗算を実行している(float型のサイズは32ビットなので、XMMレジスタに同時に4つを格納できる)。さらに8つのXMMレジスタを活用することで、データの不必要なコピーを最低限に抑え、最小の命令数で必要となる演算を行えるようなコードとなっている。

Visual Studioおよびインテル C++ Composer XE 2011でアセンブラコードを出力する設定

 Visual Studioおよびインテル C++ Composer XE 2011でアセンブラコードを出力させるには、「/FAs」コンパイルオプションを使用する。このオプション付きでコンパイルを行うと、バイナリファイルの出力先と同じディレクトリにアセンブラコードが出力される。このオプションで出力されるアセンブラファイルの拡張子は「.asm」で、アセンブラコードのコメントとして対応するC/C++ソースコードが記述された、解析しやすいアセンブラコードが生成される。

 なお、この設定はVisual Studio中ではプロジェクトのプロパティページ中「Output Files」(出力ファイル)の「Assembler Output」(アセンブリの出力)で行える(*図A)。

 いっぽう、同様のコードをVisual Studio 2010で「最大限の最適化」設定(コンパイルオプションは/Ox)でコンパイルして生成されたアセンブラコードは、リスト3のようになる。

リスト3 Visual Studio 2010で生成されたアセンブラコード
; 142  : d = 0.0;
    fldz
; 143  : for (i = 0; i  128; i++) {
    xor    eax, eax
    fstp    DWORD PTR _d$[esp+1088]
    npad    2
$LL3@main:
; 144  :   d += a[i] * b[i];
    fld    DWORD PTR _b$[esp+eax+1088]
    add    eax, 32
    fmul    DWORD PTR _a$[esp+eax+1056]
    fadd    DWORD PTR _d$[esp+1088]
    fstp    DWORD PTR tv1066[esp+1088]
    fld    DWORD PTR tv1066[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1060]
    fmul    DWORD PTR _b$[esp+eax+1060]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1063[esp+1088]
    fld    DWORD PTR tv1063[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1064]
    fmul    DWORD PTR _b$[esp+eax+1064]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1060[esp+1088]
    fld    DWORD PTR tv1060[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1068]
    fmul    DWORD PTR _b$[esp+eax+1068]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1057[esp+1088]
    fld    DWORD PTR tv1057[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1072]
    fmul    DWORD PTR _b$[esp+eax+1072]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1054[esp+1088]
    fld    DWORD PTR tv1054[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1076]
    fmul    DWORD PTR _b$[esp+eax+1076]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1051[esp+1088]
    fld    DWORD PTR tv1051[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1080]
    fmul    DWORD PTR _b$[esp+eax+1080]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR tv1048[esp+1088]
    fld    DWORD PTR tv1048[esp+1088]
    fld    DWORD PTR _a$[esp+eax+1084]
    fmul    DWORD PTR _b$[esp+eax+1084]
    faddp    ST(1), ST(0)
    fstp    DWORD PTR _d$[esp+1088]
    cmp    eax, 512
    jl    $LL3@main
; 145  : }
; 146  :
; 147  : printf("%f\n", d);
    fld    DWORD PTR _d$[esp+1088]
    sub    esp, 8
    fstp    QWORD PTR [esp]
    push    OFFSET $SG5712
    call    _printf
リスト3

中で用いられている「fld」や「fstp」、「fmul」、「fadd」などはCPUのFPU(浮動小数点処理ユニット)に対しデータをストア/ロードしたり、乗算、加算などを行う命令だ。このように、Visual Studio 2010のデフォルト設定で生成したコードは、FPUを使用して処理を実行していることがうかがえる。それぞれのコードの実際の処理時間であるが、上記のコードをループさせて100万回実行させるプログラムでその実行時間を測定した結果、次の表4のようになった。

表4 コンパイラによる実行時間の違い(3回の平均結果)
コンパイラ実行時間
インテル C++ Composer XE 20119.325秒
Visual Studio 20089.722秒

 ここで使用しているコードはシンプルであるため軽微な違いしか見られないものの、SSEを利用することによるパフォーマンス向上が確認できる。