見出し画像

転置行列コピー関数の改良|行列積高速化#27

行列コピー関数は、行列積の計算量O(N^3)よりも計算コストが圧倒的に小さいことを利用して、以下の目的で導入しました。

(1)あらかじめ、行列データをキャッシュメモリに書き込んでおく
(2)行列データを直列化し、行列積計算カーネルのキャッシュ利用効率を最大化
(3)DGEMMの転置オプションを行列コピー関数の差し替えで実現する

これらに関しては、以下の記事をご参照ください。ただ、これらの記事は、実装や高速化にとって重要なポイントなので、有料記事にしています。

転置行列コピーの問題点

行列コピー関数は、転置せずにコピーする関数と転置しながらコピーする関数の2種類を用意しています。転置を行わない行列コピー関数は特に問題はないのですが、転置操作を伴いながら行う行列コピー関数には、高速化を阻む問題点が1つあります。

それは、転置行列コピー関数が、ストライド・アクセスになっていることです。

アンロールブロッキングを行う前のソースコードを見てみましょう。

size_t m = M1;
while( m-- ){
  size_t k = K1;
  while( k-- ){
    (*A2) = (*A);
    A += lda ;
    A2++;
  }
  A  = A  - lda *K1 + 1;
}

ここで、M1,K1はL1キャッシュブロッキングサイズ、ldaは行列Aの行サイズ(リーディングディメンジョン)です。

このコードでは、ループ最深部でデータ読み込みに使用するポインタAは、ldaずつポインタシフトしています。これは、lda=1であればシーケンシャル・アクセスしていることになりますが、lda>1の場合は全てストライド・アクセスになります。

もし、ldaがキャッシュラインサイズ64Bを超えると、次のデータがハードウェア・プリフェッチされておらず、キャッシュヒットミスをし続けることになります。

そこで、下記の記事では、これを防ぐためにソフトウェア・プリフェッチを使って、キャッシュヒットミスを削減していました。

しかしながら、これをシーケンシャル・アクセスに変更した方が、より性能が高いであろうことは予想できるでしょう。

シーケンシャル・アクセス化

上記のコードでポインタAをシーケンシャル・アクセスに変更するには、mループとkループの入れ替えること(=ループ交換)で実現できます。

実際に入れ換えると次のようなコードになります。

size_t k = K1;
while( k-- ){
  size_t m = M1;
  while( m-- ){
    (*A2) = (*A);
    A++;
    A2+=K;
  }
  A2 = A2 - K1*M1 + 1;
  A  = A  - M1  + lda;
}

ポインタAがシーケンシャル・アクセスになった代わりに、書き込み先のポインタA2がストライド・アクセスになります。

この段階だと、とてもシンプルな変更ですみます。

しかしながら、このコードを最適化するために、アンロールブロッキングを行うと別の問題が現れます。

その問題とは、ブロッキングに伴う端数の取り扱いです。

端数処理の方法

なぜ端数の取り扱いが問題になるかというと、ポインタA2がストライド・アクセスになっているためです。

行列コピー関数は、コピーと同時にデータの直列化(シリアライズ)を行っていますが、これがストライド・アクセスと非常に相性が悪いのです。

具体的には、ポインタA2のポインタシフトが端数を含めると、シフトサイズをうまく計算することができません。散々悩みましたが、シフトサイズの計算を諦めました。

ポインタシフトを諦めると、解決策は意外と簡単でした。

具体的には、端数専用のポインタを用意すると解決できます。

下記のコードをご覧ください。

double* A2_2 = A2   + K*( M & ~3 );
double* A2_1 = A2_2 + K*( M &  2 );;


if( K >> 3 ){       
 size_t k8 = ( K >> 3 );
 while( k8-- ){  
   if( M >> 2 ){
     size_t m4 = ( M >> 2 );
     while( m4-- ){ 

       for( size_t l=0; l<8; l++ ){
         for( size_t i=0; i<4; i++ ){
           (*A2) = *(A+i+l*lda);
           A2++; 
         }
       }       
       A  = A  + 4;
       A2 = A2 - 4*8 + 4*K;

     }
     A2 = A2 - (M&~3)*K + 4*8; // move to next row block
   }
   if( M & 2 ){

       for( size_t l=0; l<8; l++ ){
         for( size_t i=0; i<2; i++ ){
           (*A2_2) = *(A+i+l*lda);
           A2_2++;
         }         
       }           
       A  = A  + 2;

   }
   if( M & 1 ){

       for( size_t l=0; l<8; l++ ){
         for( size_t i=0; i<1; i++ ){
           (*A2_1) = *(A+i+l*lda);
           A2_1++;
         }
       }
       A  = A  + 1;

   }
   A  = A - M + 8*lda;
 }
}
...以下省略...

このコードは、K(=K1)を8でブロッキングした部分を示しています。Kの端数であるK=4,2,1の場合は省略しています。M(=M1)は、4でブロッキングし、端数であるM=2,1の場合も示してあります。

ポイントは、端数専用のポインタA2_2、A2_1を用意していることです。

ポインタA2, A2_2, A2_1が、それぞれ担当している範囲は次のようになります。

転置コピーのポインタ

上図は、Kを4でブロッキングした例ですが、Kを8でブロッキングした場合でも同じです。行列内部の矢印は、メモリの連続方向を示しています。

ポインタA2の直列化の方向は、上図では横方向になります。行列積カーネル関数を高速化するためには、まずA2の範囲内で直列化し、次にA2_2の範囲で直列化、最後にA2_1の範囲で直列化する、という処理が必要になります。つまり、A2_2とA2_1は、A2とは独立に直列化していく必要があります。

コードの中の下記2行は、ポインタA2_2とA2_1の先頭アドレスを計算しています。

double* A2_2 = A2   + K*( M & ~3 );
double* A2_1 = A2_2 + K*( M &  2 );;

A2_2とA2_1は、A2とは独立に直列化するため、一度先頭アドレスが計算できれば、あとは単純にインクリメント(A2_2++,A2_1++など)するだけで良くなります。上記のコードでもそうなっていることが確認できると思います。

性能比較

あとは、上記のブロッキングしたコードを最適化の手順に従って、アンロールし、アセンブラでベクトル化し、プリフェッチを挿入したのち、ループウィンドウをずらしてアセンブラ命令順序を最適化しました。

ストライド・アクセスとシーケンシャル・アクセスの性能比較すると、下記のようになりました。

改良した転置コピー関数の性能比較

測定の結果、行列サイズが小さいとほとんど差がありませんが、行列サイズが1600x1600を超えたあたりからシーケンシャル・アクセス(オレンジ線)の方が明らかに高速になっていることが分かります。

行列積計算全体に対しては、1~2%の差でしかありませんが、シーケンシャル・アクセスの方が高性能になるようです。

まとめ

転置を伴う行列コピー関数では、データの読み込みがストライド・アクセスになっている点が問題でした。

データの読み込みをシーケンシャル・アクセスにすると、データの書き込みがストライド・アクセスになり、これが直列化との相性が悪く、ブロッキングを行った際の端数の取り扱いを難しくしていました。

端数部分の直列化には、端数専用のポインタを別途用意することで、実装が容易になりました。

ストライド・アクセスの場合とシーケンシャル・アクセスの場合で性能比較を行ったところ、行列サイズが大きい場合にはシーケンシャル・アクセスの方が早いことが分かりました。


次の記事

ソースコード


この記事が気に入ったらサポートをしてみませんか?