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

新着トピックス

アセンブラコードで見るC++ Composer XEの強力な最適化機能

 インテル C++ Composer XEには、強力な最適化機能を備えるコンパイラが含まれている。インテルCPUが備えるSSEやAVXといった機能を効率的に利用するコードや、マルチコアによる並列処理を行うコードを自動的に生成できるのが特徴だ。本記事ではインテル C++ Composer XEが持つさまざまな最適化機能を紹介するとともに、コンパイラが出力するアセンブラコードをチェックしてその効果を探っていく。

無視できないコンパイラの最適化機能、アプリケーションによっては数十パーセントものパフォーマンス向上も

 近年のCPUの進化に伴い、コンパイラの最適化機能が注目されるようになってきた。パイプライン化やキャッシュの大容量化、分岐予測や投機的な実行といった機能の採用など、CPUの内部アーキテクチャは進化を続けている。そのため、コンパイラにはCPUのリソースを効率良く利用できるバイナリコードを生成することが求められるようになっている。

 現在、WindowsやLinuxで利用できるコンパイラは商用のものからフリーのものまでさまざまなものがあるが、その中でもインテルCPU向けに高パフォーマンスのコードを生成することで定評があるのがインテルの「インテル C++ Composer XE 2011」(旧製品名:「インテル C++ コンパイラー」)だ(製品ラインアップについては『高度な最適化機能や並列プログラミングサポートを提供する新開発ツール「インテル Parallel Studio XE 2011」』で紹介している)。Oracleのデータベース製品のビルドなどにも採用されるなど、パフォーマンスが求められるアプリケーションでの採用実績も多い。

 しかし、コンパイラによるパフォーマンスの違いについては、一般にはあまり気にされていないのが現状だろう。一般的なデスクトップアプリケーションの分野ではCPUによる処理時間よりも各種I/O処理時間やユーザーの入力待ち時間が多いため、コンパイラを変更することによるパフォーマンス向上はあまり感じられない。しかし、たとえば大規模な数値演算を行うような科学・技術計算やシミュレーション、大容量の動画や音声、画像のエンコード/デコードなどを行うマルチメディアアプリケーションなどの分野では、コンパイラを変えるだけで数パーセントから十数パーセントものパフォーマンス向上が見られることもある。

 たとえば次の図1は、行列計算や画像処理、ファイルの圧縮/展開(解凍)を行うプログラムを、Visual Studio 2010とインテル C++ Composer XE 2011でそれぞれコンパイルし、その実行時間をVisual Studio 2010を100%として比較したものだ。測定条件の詳細については表1、2にまとめているが、インテル C++ Composer XE 2011の利用により、以下の条件ではおおむね5~10%程度実行時間が短縮されるという結果となった。

表1 パフォーマンス測定に使用したプログラム
プログラム説明
bzip2(展開)bzip2(http://bzip.org/)により、Firefox 3.6.12のソースコード(firefox-3.6.12.source.tar.bz2)を展開する際の処理時間を測定
bzip2(圧縮)bzip2により、Firefox 3.6.12のソースコード(firefox-3.6.12.source.tar.bz2)を圧縮する際の処理時間を測定
画像処理1024×768サイズの画像に対し、15×15の平均化フィルタを適用する時間を測定
行列演算double型の500×500行列の10乗を計算する時間を測定
表2 パフォーマンス測定に使用した環境
項目条件
CPUCore 2 Duo E6550(2.33GHz)
メモリ2GB
インテル C++ Composer XE 2011のコンパイルオプション/O3 /Oi /Og /GA /Quse-intel-optimized-headers
Visual Studio 2010のコンパイルオプション/Ox /Oi /GL

※特に表記のない限り、以下のテストやパフォーマンス測定でもこの環境を使用している このようにパフォーマンスの高いプログラムを生み出せるインテル C++ Composer XEであるが、実際にどのように最適化を行っているのかは、そのスペックだけからはあまり知ることはできない。そこで本記事では、インテル C++ Composer XEが出力するアセンブラコードをチェックしながら、その最適化機能について紹介していく。

強力な最適化機能を持つインテル 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を利用することによるパフォーマンス向上が確認できる。

 さて、ここではSSEを使用した場合と使用しない場合でのパフォーマンス差をチェックしたが、実はVisual Studioでもオプション設定を行うことでSSEを利用するコードを出力できる。Visual Studio 2005/2008ではMMX/SSE/SSE2命令を使用した最適化が、Visual Studio 2010ではAVXを使用した最適化がサポートされている。たとえばSSE2を利用する場合、「/arch:sse2」オプションを使用すればよい。実際にVisual Studio 2010でSSE2を利用するように設定して生成したアセンブラコードが次のリスト4だ。

リスト4 Visual Studio 2008でSSE2を利用するよう設定し生成したアセンブラコード
; 142  : d = 0.0;
    xorps    xmm0, xmm0
; 143  : for (i = 0; i  128; i++) {
    xor    eax, eax
    npad    3
$LL3@main:
; 144  :   d += a[i] * b[i];
    movss    xmm1, DWORD PTR _b$[esp+eax+1088]
    movss    xmm2, DWORD PTR _a$[esp+eax+1088]
    cvtps2pd xmm1, xmm1
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    addsd    xmm1, xmm0
    movss    xmm2, DWORD PTR _b$[esp+eax+1092]
    xorps    xmm0, xmm0
    cvtpd2ps xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1092]
    cvtps2pd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1096]
    movss    xmm2, DWORD PTR _b$[esp+eax+1096]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    movss    xmm2, DWORD PTR _b$[esp+eax+1100]
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1100]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    movss    xmm2, DWORD PTR _b$[esp+eax+1104]
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1104]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    movss    xmm2, DWORD PTR _b$[esp+eax+1108]
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1108]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    movss    xmm2, DWORD PTR _b$[esp+eax+1112]
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1112]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    mulsd    xmm1, xmm2
    movss    xmm2, DWORD PTR _b$[esp+eax+1116]
    addsd    xmm0, xmm1
    movss    xmm1, DWORD PTR _a$[esp+eax+1116]
    cvtpd2ps xmm0, xmm0
    cvtss2sd xmm0, xmm0
    cvtps2pd xmm1, xmm1
    cvtps2pd xmm2, xmm2
    add    eax, 32
    mulsd    xmm1, xmm2
    addsd    xmm0, xmm1
    cvtpd2ps xmm0, xmm0
    cmp    eax, 512
    jl    $LL3@main
; 145  : }
; 146  :
; 147  : printf("%f\n", d);
    sub    esp, 8
    cvtss2sd xmm0, xmm0
    movsd    QWORD PTR [esp], xmm0
    push    OFFSET $SG5712
    call    _printf

 こちらの場合、「xmm0」~「xmm2」があることからも分かるとおり、確かにSSEを利用したコードが出力されている(太字がSSE命令)。ただし、インテル C++ Composer XE 2011で生成されたコードとは異なり、乗算/加算にはパックド命令は使用されておらず、同時に1つのデータしか処理していない。実行時間も9.800秒と、SSE2を利用しない場合よりもわずかではあるが劣る結果となった。

 なお、Visual StudioではSSE/SSE2、およびインテル AVXのみがサポートされているが、インテル C++ Composer XE 2011ではSSE2/SSE3/SSSE3/SSE4.1/SSE4.2およびインテル AVXがサポートされている。インテル AVXが実装されたCPUはまだリリースされていないものの、コードの生成自体は可能だ。実際にリスト1のコードをインテル AVX対応CPU向けの設定でコンパイルして生成されたアセンブラコードが次のリスト5である。

リスト5 インテル AVXを使用するアセンブラコード
;;; d = 0.0;
;;; for (i = 0; i  128; i++) {
;;;   d += a[i] * b[i];
    lea     eax, DWORD PTR [1184+esp]
    and     eax, 31
    mov     DWORD PTR [128+esp], edi
    mov     esi, eax
    mov     edi, edx
.B1.7:
    mov     ecx, esi
.B1.12:
    vxorps  ymm0, ymm0, ymm0
    vxorpd  ymm1, ymm1, ymm1
.B1.13:
    vmovupd   xmm2, XMMWORD PTR [136+esp+ecx*8]
    vmovupd   xmm5, XMMWORD PTR [168+esp+ecx*8]
    vinsertf128 ymm3, ymm2, XMMWORD PTR [152+esp+ecx*8], 1
    vinsertf128 ymm6, ymm5, XMMWORD PTR [184+esp+ecx*8], 1
    vmulpd  ymm4, ymm3, YMMWORD PTR [1184+esp+ecx*8]
    vmulpd  ymm7, ymm6, YMMWORD PTR [1216+esp+ecx*8]
    vmovupd   xmm2, XMMWORD PTR [200+esp+ecx*8]
    vmovupd   xmm5, XMMWORD PTR [232+esp+ecx*8]
    vaddpd  ymm0, ymm0, ymm4
    vaddpd  ymm1, ymm1, ymm7
    vinsertf128 ymm3, ymm2, XMMWORD PTR [216+esp+ecx*8], 1
    vinsertf128 ymm6, ymm5, XMMWORD PTR [248+esp+ecx*8], 1
    vmulpd  ymm4, ymm3, YMMWORD PTR [1248+esp+ecx*8]
    vmulpd  ymm7, ymm6, YMMWORD PTR [1280+esp+ecx*8]
    vaddpd  ymm0, ymm0, ymm4
    vaddpd  ymm1, ymm1, ymm7
    add     ecx, 16
    cmp     ecx, 128
    jb    .B1.13
.B1.14:
    vaddpd  ymm0, ymm0, ymm1
    vextractf128 xmm1, ymm0, 1
    vaddpd  xmm2, xmm0, xmm1
    vhaddpd   xmm3, xmm2, xmm2
.B1.18:
;;; }
;;; 
;;; printf("%f\n", d);
    mov     DWORD PTR [esp], OFFSET FLAT: ??_C@_03A@?$CFf?6?$AA@
    vmovsd  QWORD PTR [4+esp], xmm3
    vzeroupper
    call    _printf

 アセンブラ中で「v」で始まる命令(リスト中太字の部分)がインテル AVX命令、「ymm0」~「ymm7」はインテル AVXで新たに追加された256ビット長のYMMレジスタである。現在ではAVX対応CPUがないためパフォーマンスは測定できないが、AVX命令を駆使したコードが出力されていることは確認できる。

GCCで出力されたアセンブラコード

 Linux/Windows環境で広く使われているコンパイラの1つに、GCCがある。最新版のGCC(GCC 4.5.0)ではSSEサポートが行われており、たとえば「-march=core2 -msse4 -mfpmath=sse」といったオプションを指定することで、SSEを使用するコードを出力できる。たとえば次のリストAは、リスト1のコードを上記のオプション付きでGCCでコンパイルして生成したアセンブラコードである。

 このコードではxmm0、xmm1レジスタや「movss」「「mulss」「addss」といったSSE命令が使用されていることが分かる。ただし、こちらもVisual Studioの例と同じくパックド命令は使用されておらず、インテル C++ Composer XE 2011のようなベクトル化は行われていない。

リストA GCCでSSEを使用するよう設定して出力したアセンブラコード
L26:
  movl  $0x00000000, %eax
  movl  %eax, 1560(%esp)
  movl  $0, 1564(%esp)
  jmp  L24
L25:
  movl  1564(%esp), %eax
  movss  1044(%esp,%eax,4), %xmm1
  movl  1564(%esp), %eax
  movss  532(%esp,%eax,4), %xmm0
  mulss  %xmm1, %xmm0
  movss  1560(%esp), %xmm1
  addss  %xmm1, %xmm0
  movss  %xmm0, 1560(%esp)
  incl  1564(%esp)
L24:
  cmpl  $127, 1564(%esp)
  setle  %al
  testb  %al, %al
  jne  L25
  cvtss2sd  1560(%esp), %xmm0
  movsd  %xmm0, 4(%esp)
  movl  $LC1, (%esp)
  call  _printf
  incl  1556(%esp)
L23:
  cmpl  $999999, 1556(%esp)
  setle  %al
  testb  %al, %al
  jne  L26
  movl  $0, %eax
  leave

自動的にマルチスレッド化されたコードを出力する「自動並列化」

 近年ではマルチコア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)。

表5 自動並列化による実行時間の変化
自動並列化の有無実行時間(3回の平均)バイナリサイズ
自動並列化有効3.701秒73288バイト
自動並列化無効7.238秒12288バイト

 なお、自動並列化により生成されたバイナリはスレッド生成など並列化のためのコードも含むため、バイナリサイズが大きくなる傾向があるようだ。

ループの構造を変更して最大限の最適化を行う「ハイパフォーマンス並列最適化」

 昨今のCPUでは、どのようにメモリアクセスを行うかによって大幅にパフォーマンスが変動する。CPUの動作速度は近年では2~3GHz程度が主流であるが、CPUと外部を接続するバスの速度はその2分の1から4分の1程度であり、さらにメモリの動作速度がそれよりも小さい場合もある。そのため、昨今のCPUでは大容量のキャッシュを搭載し、メモリアクセスを抑えるように設計されている。しかし、それでもキャッシュ容量は数MBというサイズであり、大容量のデータを扱うアプリケーションの場合、メモリへの頻繁なアクセスは避けられない。

 インテル C++ Composer XE 2011のハイパフォーマンス並列最適化機能(HPO)は、このようなキャッシュアクセスやメモリアクセスなども考慮し、また可能な限りSSEや並列化などを行えるよう、ループや命令の順序を変更して最適化する機能である。

 インテル C++ Composer XE 2011では、最適化レベルとして「Od」および「O1」、「O2」、「O3」が用意されており、「O3」を選択することでループ構造の変更も含めた最大限の最適化が行われるようになる(表6)。

表6 インテル C++ Composer XE 2011の最適化レベル
最適化レベル説明
Od最適化なし
O1サイズを最小化
O2速度を最大化
O3速度を最大化+最大限の最適化

 たとえば次の図2および図3は、冒頭で紹介した画像処理プログラム中で、実際にぼかし処理を行っている個所のコードと、そのアセンブラコードをインテル VTune Amplifier XE 2011で表示させたものだ。左側にCコード、右側に対応するアセンブラコードが表示されているが、最適化レベルO2の場合と、O3との場合ではアセンブラコードが大幅に異なり、O2の場合はCコードと対応するアセンブラコードがほぼ同じ順番で並んでいるのに対し、O3の場合はコードの順番が多くの個所で入れ替わっていることが分かる。

 実際、このコード変更により、もっとも内側のループ内の処理の実行時間も変わっており、最適化が有効なことが分かる。

 なお、Visual Studio 2010でコンパイルしたコードに対して解析を行った場合は図4のようになった。アセンブラコードが異なるばかりか、時間がかかっている個所自体が異なるものになっていることが分かる。

並列プログラミングに役立つ機能も搭載、すべての開発者に有用なインテル C++ Composer XE 2011

 以上ではインテル C++ Composer XE 2011の最適化機能に焦点を当ててその機能を紹介してきたが、インテル C++ Composer XE 2011では自動的な最適化機能だけでなく、手動によるパフォーマンスチューニングや、プログラムの実装に有用な機能も搭載されている。たとえば「ガイド付き自動並列化」機能は、自動並列化では並列化できないループについて、診断メッセージを表示する機能だ。具体的にどこをどのように修正すれば自動並列化が行えるかが表示される。従来も並列化できないループについてはその旨が表示されたが、ガイド付き自動並列化ではその原因や個所なども表示され、より修正やチューニングが容易になった。

 また、Cilk PlusやOpenMP 3.0といった並列化技術もサポートされている。これはそれぞれ別記事(「インテル Parallel Composerの新機能――並列プログラムを容易に実装できる「インテル Cilk Plus」入門」および「ソフトウェア高速化の鍵は「並列化」:いま注目される並列化技術を知る」)で紹介しているためそちらを確認して頂きたいが、これらを用いることでより容易に並列プログラムを実装できるようになる。そのほか、インテル Inspector XEと連携し、プログラムのセキュリティ問題を解析する「スタティック・セキュリティー解析機能」や、プログラム中に実行時の挙動を計測する命令を埋め込み、実行時の挙動を収集してその結果を最適化に反映させる「プロファイルに基づく最適化機能」(PGO)といった機能も搭載されている。

 高いパフォーマンスを求められるプログラムが要求される開発者や、並列プログラミングを行っている開発者にとって、インテル C++ Composer XE 2011のこれらの機能は非常に有用だろう。フル機能を30日間無償で利用できる体験版も用意されているので、まずはその機能、性能を試して見てほしい。