インテル® コンパイラーによる AVX 最適化入門: 第2回 AVX への最適化について(その1)

インテル® DPC++/C++ コンパイラーインテル® Fortran コンパイラー
このミニ連載では、インテル® AVX 拡張命令セットを利用した最適化について、4 回に分けて説明します。コンパイラー最適化入門第1回で紹介されているとおり、AVX は SSE 系命令の流れを汲む SIMD 命令であり、第 2 世代インテル® Core™ i7 プロセッサー・ファミリー (2011 年発売)で最初に実装されました。インストール・ベースが着実に伸びている AVX に対して、どのような最適化が有効であるのか、要点をかいつまんで解説していきます。 第1回 第2回 第3回 最終回

第2回 AVX への最適化について(その1)

前回、「SIMD 並列度を向上させることが高性能化への鍵になる」と書きましたが、今回はその逆を衝いて、「倍になりにくい部分」を見ていくことになります。

ターゲットオプション (/QxAVX, -xAVX) を用いて再コンパイルすることで、インテル® コンパイラーは AVX への自動ベクトル化を試み、浮動小数点演算に対して並列度が倍の YMM レジスターを使用するよう性能のモデル化をしてコード生成の指標とするわけですが、その際に大きな問題点になりうることが二つあります。一つ目は 256 ビット(32 バイト)幅でのメモリーの読み書きに関連すること、二つ目はループの反復回数がベクトル長で割り切れないときの余りの部分の処理速度です。今回は 32 バイト幅のメモリーの読み書きの最適化について説明します。

配列データは 32 バイト境界に整列

下の図を見るまでもありませんが、256 ビット AVX のループではメモリーへの要求も(単純化して考えると)128 ビット の XMM のときと比べて倍になります。XMM のターゲットでは、メモリーアクセスがキャッシュラインの境界をまたがないように配列データを 16 バイト境界に整列するように最適化するようにしてきたわけですが、YMM のターゲットではメモリーアクセスがキャッシュラインの境界をまたぐ頻度が倍になるので、データ配置の最適化の重要度がさらに高まります。

32 バイト幅のメモリーアクセスが 64 バイトのキャッシュラインの境界をまたがないためには、アドレスの先頭が 32 バイト境界に沿っている必要があるという認識が大事になります。

配列データ配置の最適化

自動ベクトル化の場合、コンパイラーが可能な限り自動で最適化しますが、自動化が可能なケースは限られていますので、プログラマー自身による最適化を強く推奨します。32 バイト境界への最適化は、自動ベクトル化の場合のみならず、組み込みベクトル関数を用いる場合、アセンブリーを利用する場合まで幅広く有効です。

1000 要素の単精度浮動小数点配列 A を 32 バイト境界で定義する例

  • Windows* の C/C++ では __declspec(align(32)) float A[1000];
  • Linux*/Mac OS* の C/C++ では float A[1000] __attribute__((aligned(32)));
  • FORTRAN では REAL*4 A(1000) !DIR$ATTRIBUTES ALIGN: 32:: A
アドレス境界指定子付きの malloc()
  • _aligned_malloc()
  • _mm_malloc()

データ配置が最適化されたことをコンパイラーに伝える

通常、データの定義と使用は別個に(例えば別々のソースファイルで)行われることが多いため、データ配置が最適化されていたとしても、データ参照部をコンパイルする際、配置の最適化情報が伝わらない場合が多くあります。そのような場合、以下のような方法でコンパイラーに最適化情報を伝えることが可能です(注:誤った情報が与えられた場合には、実行時エラーが発生する場合がありますのでご注意ください)。詳細については、コンパイラーのマニュアルの該当部分をご参照ください。

  • C/C++ において、プログラムのこの時点でポインターの値(この例では p )が 32 バイト境界にあることをコンパイラーに伝える __assume_aligned(p, 32) for (i=0; i<n; i++){ p[i]++; }
  • C/C++ において、ループ内のメモリー参照がすでに最適化(AVX をターゲットにコンパイルした場合は 32 バイト境界)されていることをコンパイラーに伝える #pragma vector aligned for (i=0; i<n; i++){ A[i] = B[i] * C[i] +D[i]; }
  • FORTRAN において、プログラムのこの時点で A(1) のアドレスが 32 バイト境界にあることをコンパイラーに伝える !DIR$ ASSUME_ALIGNED A(1): 32 DO I=1, N A(I) = A(I) + 1 ENDDO
  • FORTRAN において、ループ内のメモリー参照がすでに最適化(AVX をターゲットにコンパイルした場合は 32 バイト境界)されていることをコンパイラーに伝える !DIR$ VECTOR ALIGNED DO I=1, N A(I) = B(I) * C(I) + D(I) ENDDO

32バイト 境界に最適化できないとき

プログラムの構成上、すべての配列データを 32 バイト境界に整列させることが不可能な場合もありえます。その場合には「ページ境界をまたぐ書き込みのペナルティーが大きい」ことを指標にして、書き込み側の配列へのアクセスを 32 バイト境界に合わせる最適化が有効である場合が多く見られます。32 バイト境界に整列させられなかったアクセスに関しては、上位 16 バイトと下位 16バイトの二回に分けてアクセスすることで、キャッシュラインをまたいだ場合のペナルティーを軽減させるという手法が有効であると知られています。

コンパイラーの出力を読み解く

第1回の最後に以下の簡単な関数を二通りにコンパイルしてみましたが、コンパイラーの出力したアセンブリー言語を読み解いてみましょう。

void foo(float *restrict a, float *b, int n){
   int i;
   for (i=0; i<n; i++){
      a[i] += b[i];
   }
}

まずはじめに、SSE2 のコード (icc  –restrict  –O2  –S により生成) から。必要に応じて始めの数回分ループを逐次実行して、代入の左辺 A[i] のアドレスが 16 バイト境界になるように調整してから以下のループに入ります (B[i] のアドレスが 16 バイト境界でない場合)。

..B1.12: ループ冒頭のラベル
movups    (%rsi,%rax,4), %xmm0 movups    16(%rsi,%rax,4), %xmm1 B[i+0], …, B[i+3] の 4 要素をロード B[i+4], …, B[i+7] の 4 要素をロード
addps     (%rdi,%rax,4), %xmm0 addps     16(%rdi,%rax,4), %xmm1 A[i+0], …, A[i+3] の 4 要素をロードして加算 A[i+4], …, A[i+7] の 4 要素をロードして加算
movaps    %xmm0, (%rdi,%rax,4) movaps    %xmm1, 16(%rdi,%rax,4) A[i+0], …, A[i+3] の 4 要素へストア A[i+4], …, A[i+7] の 4 要素へストア
addq      $8, %rax cmpq      %rdx, %rax jb        ..B1.12 ループ制御のコード

この調整により、 addps にメモリーオペランドを使用することが可能となり、さらにストアにも movaps 命令を利用できることになります。また、A[] への 16 バイト・メモリー・アクセスが複数のキャッシュラインまたはページにまたがることがなくなりますので、それに起因するペナルティーを避けることが可能となります。B[i] のアドレスが 16 バイト境界にある場合にはバージョン 2 のループを使うことで、movups 命令の使用をなるべく避けるという従来のプロセッサー向けの最適化が行われています。

次に AVX のコード (icc  –restrict  –O2  –xAVX  –S により生成)です。必要に応じて始めの数回分ループを逐次実行して、代入の左辺 A[i] のアドレスが 32 バイト境界になるように調整してから以下のループに入ります。AVX 命令の vaddps に関してはメモリーオペランドが 32 バイト境界にある必要はないものの、先に述べたとおり、書き込み側配列を 32 バイト境界に合わせるためにこの調整を行っています。

..B1.11: ループ冒頭のラベル
vmovups   (%rsi,%r8,4), %xmm0 vmovups   32(%rsi,%r8,4), %xmm3 B[i+0], …, B[i+3] の 4 要素をロード B[i+8], …, B[i+11] の 4 要素をロード
vinsertf128 $1, 16(%rsi,%r8,4), %ymm0, %ymm1  vinsertf128 $1, 48(%rsi,%r8,4), %ymm3, %ymm4 B[i+4], …, B[i+7] の 4 要素をロードして先にロードした下位 128 ビットと合わせて 256 ビットに B[i+12], …, B[i+15] の 4 要素をロードして先にロードした下位 128 ビットと合わせて 256 ビットに
vaddps    (%rdi,%r8,4), %ymm1, %ymm2  vaddps    32(%rdi,%r8,4), %ymm4, %ymm5 A[i+0], …, A[i+7] の 8 要素をロードして加算 A[i+8], …, A[i+15] の 8 要素をロードして加算
vmovups   %ymm2, (%rdi,%r8,4) vmovups   %ymm5, 32(%rdi,%r8,4) A[i+0], …, A[i+7] の 8 要素へストア A[i+8], …, A[i+15] の 8 要素へストア
addq      $16, %r8 cmpq      %rdx, %r8 jb        ..B1.11 ループ制御のコード

AVX のコードでは、B[i] のアドレスによるバージョン 2 の生成を行いませんでしたので、B[i] が 32 バイト境界に整列しているかどうか不明です。そのため、先に述べた上位 16 バイト、下位 16 バイトの 2 回に分けてメモリーにアクセスしています。

第2回のまとめ

今回は 32 バイトのメモリーアクセスを最適化する方法についてまとめてみました。データの配置はコンパイラーによる自動最適化が非常に困難なため、プログラマーの手による最適化が有効である場合が多く見られます。上の例で用いた関数 foo() に対して今回紹介した最適化の手法を適用しアセンブリー言語の出力の変化を見ることで理解がより深まるでしょう。

著者紹介

齋藤 秀樹氏。インテル コーポレーション ソフトウェア開発製品部門 シニア・スタッフ・エンジニア。2000 年の入社以来インテル® コンパイラーの性能評価および並列化の実装に従事するとともに、SPEC High Performance Group にて SPEC OMP、SPEC HPC2002、SPEC MPI2007 などのベンチマーク開発にも携わる。2007 年よりベクトル化の開発主任を担当し現在に至る。並列計算システムの分野で長年の経験と実績。多くの米国特許、論文および国際学会発表等の共著を持つ。

タイトルとURLをコピーしました