[ Main Page ]

畳み込みと最適化

畳み込み(convolution)

畳み込みは多倍長演算や画像/音声処理などによく使われる有名な演算で、特に離散値での演算は最近のマルチメディアといわれる分野では目に見えない形で利用されている。 例えば、デジカメを使う時のオートフォーカス、ボケ補正などといった画像処理や、ホール/ルーム/ゲームエフェクトでの音声処理ではしばしば使われている。 SONYの「AV/Hifiオーディオ コンポーネント 総合カタログ」(抜粋[PDF])にあるDCフェーズリニアライザーやCinema Studio EXはすべて畳み込みである FIRで実装するのが一番高速な方法である。ちなみにCinema Studio EXは10秒以上の畳み込みをしているようだ。

畳み込みは時間領域では通常O(n^2)のアルゴリズムなので、周波数領域にDFTで変換して積和演算単体ではO(n)で済ますことが多い。DFTでの変換自体は複素数のおかげで 演算量が減るのでO(nlogn)で済み、トータルでも時間領域の演算より速くなる。DFTなどのアルゴリズムはFFTW3などで実装されているので 詳しくはそちらを参照するとわかりやすいと思われる。

DFT後の周波数領域での畳み込みは、複素数の乗算となり、(a+bi)*(c+di)のループとなる。ここではまず(a+bi)*(c+di)のループの最適化を考えてみる。 普通に複素数の乗算だから、(ac-bd)+(ad+bc)iを求めればよい。

畳み込みの高速化

ここで利用するFFTW3は、実数数列のDFTが複素共役対称であり高速化できることからHalfcomplex-format DFTという演算方法を持っており、利用方法としては順方向は

float *in, *out;
in = fftwf_malloc(sizeof(float)*size);
out = fftwf_malloc(sizeof(float)*size);
fftwf_plan p;
p = fftwf_plan_r2r_1d(size, in, out, FFTW_R2HC, FFTW_ESTIMATE);
fftwf_execute(p);
fftwf_destroy_plan(p);
fftwf_free(in);
fftwf_free(out);
// 以下malloc/freeは省略
    

逆方向は

fftwf_plan p;
p = fftwf_plan_r2r_1d(size, in, out, FFTW_HC2R, FFTW_ESTIMATE);
fftwf_execute(p);
fftwf_destroy_plan(p);
    

のようになり、結果の配列には

r0, r1, r2, ..., rn/2, i(n+1)/2-1, ..., i2, i1

のように格納されており、これを複素数演算するとすると、以下のようなコードを実行することになる。

out[0] = x[0] * y[0]; // r0
out[n/2] = x[n/2] * y[n/2]; // r(n/2)
for (j = 1; j < n/2; j++)
{
  float a = x[j];
  float b = x[n - j];
  float c = y[j];
  float d = y[n - j];
  out[j] += a*c-b*d; // Re
  out[n - j] += a*d+b*c; // Im
}
    

まず、このコードの問題点として、順方向と逆方向との二つの方向の読み込みが存在するため、最適化できないことがあげられる。そこで、まず、データの配列を変換することから考える。以下のように配列を変換する。

out[0] = orig[0];
out[1] = orig[n/2];
for(int t = 1;t < n/2;t ++)
{
  out[2*t+0] = orig[t];
  out[2*t+1] = orig[n-t];
}
    

すると、データ構造は以下のようになる。

r0, rn/2, r1, i1, r2, i2, ..., r(n+1)/2-1, i(n+1)/2-1

このデータ構造を利用するコードは以下のようになる。

out[0] = x[0] * y[0]; // r0
out[1] = x[1] * y[1]; // r(n/2)
for (j = 1; j < n/2; j++)
{
  float a = x[j*2];
  float b = x[j*2+1];
  float c = y[j*2];
  float d = y[j*2+1];
  out[j*2] += a*c-b*d; // Re
  out[j*2+1] += a*d+b*c; // Im
}
    

これでいくらかは速くなる。しかし、これはスカラ計算の場合であって、もしベクトル化しようとなるとさらに話は発展する。どのようにデータ構造をとれば一番高速な計算ができるかということだ。

IntelAMDでは、最適化に関する情報をかなりの量提供しており、参考になるが、その中の「インターネット・ストリーミング SIMD拡張命令を考慮したアプリケーション・チューニング」というarticleでは、AoS(Arrays-of-Structure)やSoA(Structure-of-Arrays)という言葉で説明している。要は配列の構造が構造単位か要素単位かということである。 もちろん、Aosを利用して、計算前に転置すればよいともいえる。 (参考:AMD64 アーキテクチャ技術資料Intel 日本語技術資料のダウンロード

struct
{
  float A[1000], B[1000], C[1000];
} SoA_data;

struct
{
  float A, B, C;
} AoS_data[1000];
    

結論からいうと、3DNow!、SSEの場合は以下のようなデータ構造が好ましい。

struct
{
  float Re[2], Im[2]; // 16 byte
} Complex_SoA_3DNOW[N/2];
struct
{
  float Re[4]; // 16byte alignment for movaps
  float Im[4]; // 16byte alignment for movaps
} Complex_SoA_SSE[N/4];
    

これらのデータ構造を作るには、以下のようなコードを利用すればよい。ただし最初は例外である。

// 3DNow!
// sizeof(out[] = orig[])
out[0] = orig[0]; // r0
out[1] = orig[1]; // r1
out[2] = orig[n/2]; // r(n/2)
out[3] = orig[n-1]; // i1
for(int t = 1;t < n/4;t ++)
{
  out[4*t+0] = orig[2*t];
  out[4*t+1] = orig[2*t+1];
  out[4*t+2] = orig[n-2*t];
  out[4*t+3] = orig[n-2*t-1];
}
// SSE
// sizeof(out[] = orig[])
out[0] = orig[0]; // r0
out[1] = orig[1]; // r1
out[2] = orig[2]; // r2
out[3] = orig[3]; // r3
out[4+0] = orig[n/2]; // r(n/2)
out[4+1] = orig[n-1]; // i1
out[4+2] = orig[n-2]; // i2
out[4+3] = orig[n-3]; // i3
for(int t = 1;t < n/8;t ++)
{
  out[8*t+0] = orig[4*t];
  out[8*t+1] = orig[4*t+1];
  out[8*t+2] = orig[4*t+2];
  out[8*t+3] = orig[4*t+3];
  out[8*t+4+0] = orig[n-4*t];
  out[8*t+4+1] = orig[n-4*t-1];
  out[8*t+4+2] = orig[n-4*t-2];
  out[8*t+4+3] = orig[n-4*t-3];
}
    

これで準備は整った。3DNow!の場合、2並列、SSEの場合、4並列で乗算、加算、減算を行うことができる。イメージは以下のようになる。ちなみに、movapsは16byte alignmentを要求するので、FFTW3のmallocを利用するとよい。

# source,dest
# 3DNow!
femms
for(...){
movq  x.Re, mm0 # mm0 = a
movq  mm0,  mm2 # mm2 = a
movq  y.Re, mm1 # mm1 = c
pfmul mm1,  mm0 # mm0 = a*c
movq  x.Im, mm4 # mm4 = b
movq  mm4,  mm3 # mm3 = b
pfmul mm1,  mm3 # mm3 = b*c
movq  y.Im, mm5 # mm5 = d
pfmul mm5,  mm2 # mm2 = a*d
pfmul mm5,  mm4 # mm4 = b*d
pfsub mm4,  mm0 # mm0 = a*c - b*d
pfadd out.Re, mm0 # mm0 = Re + a*c - b*d
movq  mm0, out.Re # Re = Re + a*c - b*d
pfadd mm3,  mm2 # mm2 = a*d - b*c
pfadd out.Im, mm2 # mm2 = Im + a*d - b*c
movq  mm2, out.Im # Im = Im + a*d - b*c
}
femms
# SSE
for(...){
movaps x.Re, xmm0 # xmm0 = a
movaps xmm0, xmm2 # xmm2 = a
movaps y.Re, xmm1 # xmm1 = c
mulps  xmm1, xmm0 # xmm0 = a*c
movaps x.Im, xmm4 # xmm4 = b
movaps xmm4, xmm3 # xmm3 = b
mulps  xmm1, xmm3 # xmm3 = b*c
movaps y.Im, xmm5 # xmm5 = d
mulps  xmm5, xmm2 # xmm2 = a*d
mulps  xmm5, xmm4 # xmm4 = b*d
subps  xmm4, xmm0 # xmm0 = a*c - b*d
addps  out.Re, xmm0 # xmm0 = Re + a*c - b*d
movaps xmm0, out.Re # Re = Re + a*c - b*d
addps  xmm3, xmm2 # xmm2 = a*d - b*c
addps  out.Im, xmm2 # xmm2 = Im + a*d - b*c
movaps xmm2, out.Im # Im = Im + a*d - b*c
}
    

SIMDによる高速化に向けて...インラインアセンブラなど

ところで、3DNow!/SSE化するメリットは、勿論高速化であるが、本当にその意味があるのかといった点でプロファイラを利用する価値がある。いくつかのページで、SSE化 したのにあまり速くならなかったという記述を見かけるが、これは、SSEのPentium自体での実装がしょぼいという他に、同時実行/依存関係といった様々な問題をかかえているためで、 本当にどこを最適化すればよいか絞るためにもプロファイラの利用をおすすめする。 GNUには、gprofが存在し、gccでは-pgを付加することでプロファイル情報をgmon.outに出力するプログラムを生成するようになる。gmon.outのあるディレクトリ でgprof (バイナリ名)とするとだいたいの場合情報を表示してくれる(出力テキスト)。以下はその抜粋である。ちなみに使用プログラムは、 Freeverb3のsamples/impulse3である。(最適化前)

  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
 69.36      3.60     3.60   165698     0.02     0.02  frag::processMULT(float*, float*, float*, float*)
  5.39      3.88     0.28    20272     0.01     0.02  frag::processR2HC(float*, float*, float*, float*)
  4.24      4.10     0.22    10579     0.02     0.02  frag::processHC2R(float*, float*, float*, float*)
  3.47      4.28     0.18    62934     0.00     0.00  irmodel3::mute(float*, int)
  3.28      4.45     0.17     9973     0.02     0.47  irmodel3::processZL(float*, float*, float*, float*, long, unsigned int)
  2.89      4.60     0.15    20602     0.01     0.01  blockDelay::push(float*)
  2.89      4.75     0.15     9696     0.02     0.51  irmodel3::processreplace(float*, float*, float*, float*, long, unsigned int)
  2.12      4.86     0.11 19854354     0.00     0.00  delay::process(float)
  1.73      4.95     0.09    40742     0.00     0.00  frag::mute(float*, int)
  1.73      5.04     0.09     9419     0.01     0.01  splitLR(float*, float*, float*, int)
  1.35      5.11     0.07     9697     0.01     0.01  dump(void*, int)
  0.58      5.14     0.03                             main
    
fragmentSize = 1024
wetDB = 0.000000
factor = 16
loadImpulse(sample = 282953,FFTW_ESTIMATE)...done.
*** irmodel3 config ***
impulseSize = 282953
short Fragment Size = 1024
large Fragment Size = 16384
short Fragment vector Size = 16
large Fragment vector Size = 17
00002b17/00002b18
Wait for IR...
00000114/00000115

real    0m16.655s
user    0m16.293s
sys     0m0.208s
    

ここからもわかるように複素数乗算にかなりの時間がかかっていることがわかる。もしベクトル化によって2倍の速さになればかなりの改善を期待できることになるが、それはアセンブラの書き方/プリフェッチの仕方にかかっている。

次にアセンブラを使用する準備をする。もちろん、Cのみで開発しているのであればアセンブラ単体で使用することもできるが、C++を使っているとなると名前規則などで 困難を伴うので、インラインアセンブラを使うのがよいことになる。VC++などに関してはドキュメントが豊富であるし、Portabilityという面からここではGCCでの インラインアセンブラを利用することにする。基本的には以下のように使用する。

#include <stdio.h>

int main ()
{
  float a[2] = {1.0, 2.0};
  float b = 4.0;
  float c[2];
  /* a/b の低精度推定値を計算する。(正確な結果はc={0.25,0.5})*/
  
  /*
    一つ目の入力値には配列 a のアドレス &a を使い、そのアドレスにある
    メモリ内容を movq で %mm0 に移動する。二つ目の入力値は変数 b を使
    い、movd で %mm1 に移動する。
    まず %mm1 の逆数をとり、それを %mm0 に掛ける。
    結果は出力用の配列 c に割り当てたレジスタ %0 に movq で移動する。
  */
  __asm__ __volatile__ ("movq  (%1),%%mm0 \n\t"
                "movd  %2,%%mm1 \n\t"
                "pfrcp %%mm1,%%mm1 \n\t"
                "pfmul %%mm1,%%mm0 \n\t"
                "movq  %%mm0,%0 \n\t"
                "femms \n\t"
                : "=g" (c)
		: "r" (&a), "r" (b)
		:);
  printf ("c={%g,%g}\n", c[0], c[1]);
  return 0;
}
    

上の例は3DNow!の使用の例である。まず、アセンブラのセクションは基本的に__asm__ __volatile__ ("アセンブラのコード":出力:入力:破壊レジスタ);である。 入力や出力で%0などを利用する場合はレジスタを%でなく%%で修飾するようにする。asmやvolatileのようにアンダーバーを入れなくてもよいが、予約されていることがあるので、なるべくアンダーバーで 囲むようにする。これだけではわかりにくいので、もう少し詳しく説明すると、アセンブラのコードはその直前に入力のコードが、その直後に出力のコードが追加される。 入出力の時に=gやrといったようにレジスタを指定するが、その時に破壊レジスタは避けられて自動的に割り当てられる。上のCのアセンブラのコードは以下のようになっている。 objdump -S (バイナリ名)でソースコード混合でアセンブラのソースを参照できる。

  float a[2] = {1.0, 2.0};
 8048445:       b8 00 00 80 3f          mov    $0x3f800000,%eax
 804844a:       89 45 f0                mov    %eax,0xfffffff0(%ebp)
 804844d:       b8 00 00 00 40          mov    $0x40000000,%eax
 8048452:       89 45 f4                mov    %eax,0xfffffff4(%ebp)
  float b = 4.0;
 8048455:       b8 00 00 80 40          mov    $0x40800000,%eax
 804845a:       89 45 f8                mov    %eax,0xfffffff8(%ebp)
  float c[2];
  asm volatile (...)
 804845d:       8d 55 f0                lea    0xfffffff0(%ebp),%edx
 8048460:       8b 45 f8                mov    0xfffffff8(%ebp),%eax
 8048463:       0f 6f 02                movq   (%edx),%mm0
 8048466:       0f 6e c8                movd   %eax,%mm1
 8048469:       0f 0f c9 96             pfrcp  %mm1,%mm1
 804846d:       0f 0f c1 b4             pfmul  %mm1,%mm0
 8048471:       0f 7f 45 e8             movq   %mm0,0xffffffe8(%ebp)
 8048475:       0f 0e                   femms  
  printf ("c={%g,%g}\n", c[0], c[1]);
 8048477:       d9 45 ec                flds   0xffffffec(%ebp)
 804847a:       d9 45 e8                flds   0xffffffe8(%ebp)
 804847d:       d9 c9                   fxch   %st(1)
 804847f:       83 ec 0c                sub    $0xc,%esp
 8048482:       8d 64 24 f8             lea    0xfffffff8(%esp),%esp
 8048486:       dd 1c 24                fstpl  (%esp)
 8048489:       8d 64 24 f8             lea    0xfffffff8(%esp),%esp
 804848d:       dd 1c 24                fstpl  (%esp)
 8048490:       68 88 85 04 08          push   $0x8048588
 8048495:       e8 d6 fe ff ff          call   8048370 <printf@plt>
 804849a:       83 c4 20                add    $0x20,%esp
    

まず、

 804845d:       8d 55 f0                lea    0xfffffff0(%ebp),%edx
 8048460:       8b 45 f8                mov    0xfffffff8(%ebp),%eax
    

に注目する。これは、asmブロック内の直前のコードであり、

		: "r" (&a), "r" (b)
    

から生成されている。&aはポインタであり、そのアドレスを示すleaがrつまりeax,ebx,ecx,edx,esi,ediの中から自動割り当てされてedxに格納されている。 次に、bはそのままeaxに格納されている。これでアセンブラコードブロック内で変数を利用することができるようになった。ブロック内では%1や%2という形で 書いているが、それらがそれぞれedxやeaxとなっていることがわかるだろう。ちなみに、"r"などの文字についてはConstraints for 'asm' Operandsを参照するとよい (Constraints - Using the GNU Compiler Collection (GCC))。 高速化したいなどの希望があれば、0x??(%ebp)といったスタック上でなくレジスタ上にマップされるようにqやrを使用すればよい。ただ、x86はレジスタの数が少ないので、 入力部分であまりqを使い過ぎたり、破壊レジスタに多くのレジスタを指定すると、以下のようなエラーを吐くことになる。

			: "q"(oL), "q"(iL), "q"(fL), "q"(size)
			: "eax", "ebx");
とやると明らかに配分するレジスタの数が足りないので
(oL,iL,fL,sizeはeax,ebxを除いたecx,edxのみでは割り当てられない。
qはeax,ebx,ecx,edxを示すConstraintsである。)
mul2.cpp:20: error: can't find a register in class 'Q_REGS' while reloading 'asm'
とかエラーになる。
    
/tmp/cc5TY99c.s:49: Error: suffix or operands invalid for `movq'
    

また、最適化などをするとしばしばinlineなどを行なったりするために様々な問題が起こる事があり、本当に理解するまでは-O0でインラインアセンブラを使った方が 良いかもしれない。そして、きちんとアセンブラをかいた時点で初めてコンパイラの方の最適化をかけるとよい。ただし、ジャンプラベルを使っている場合はinlineを -fno-inline-functionsで抑制しないと

/tmp/ccFb9zvd.s:230: Error: symbol `mul3_3dnow__' is already defined
    

といったエラーになる。これは勿論、inlineによって同一コード内に同一ラベルが生じることによる。まずは試すのが良い。以下は GCCでインラインアセンブリを使用する方法と留意点等 for x86からの抜粋である。

386系のレジスタや定数、メモリに対応した指定文字の抜粋

"a"eax
"b"ebx
"c"ecx
"d"edx
"s"esi
"D"edi
"I"0〜31までの定数 for 32 bit shifts
"J"0〜63までの定数 for 64 bit shifts
"K"定数0xff
"L"定数0xffff
"M"0,1,2,3のうちの定数 (shifts for `lea')
"N"0〜255までの定数 (`out'命令向け)
"G"80387標準浮動小数点定数
"q"自動割り当て(eax,ebx,ecx,edxの中から)
"r"自動割り当て(eax,ebx,ecx,edx,esi,ediの中から)
"g"eax,ebx,ecx,edx もしくは許される凡ゆるアドレスのメモリ
"m"許される凡ゆるアドレスのメモリ
"A"eaxとedxを合わせて64bitのlong long型で使う。
"f"数値浮動小数点レジスタの中から自動割り当て
"t"浮動小数点スタックの先頭の数値浮動小数点レジスタ
"u"浮動小数点スタックの2番目の数値浮動小数点レジスタ
"0","1","2",... 入出力オペランド部分で割り当てられたレジスタやメモリを割り当てられた順に指す。

修飾文字の抜粋

 =  書き込み専用を意味し、以前の値を消して、asm文の アセンブリ部分で書き込まれた値を持つようにする。
 +  読み書き両用を意味する。
 &  そのオペランドが、入力オペランドの使用が終わる前に 破損することに対して適切に対処する。 すなわち、そのオペランドをインプット用のレジスタや メモリから切り離すことで、入力オペランドが壊れたレ ジスタに影響されることを防ぐ。

86系で浮動小数点を扱う際は、入力用に"f"を1つでも使うならば、 出力オペランドに "=f"ではなく、"=&t" など、 "&"付のものを使う必要がある。 また、出力用のオペランドはスタックの先頭、つまり "=&t" から始めなければならない。そうしなければ、まともな動作は保証されない。

次に、出力についてであるが、アセンブラブロックでは%0にmovqで書き込んでいる。この

                "movq  %%mm0,%0 \n\t"
    

が実際のコードでは

 8048471:       0f 7f 45 e8             movq   %mm0,0xffffffe8(%ebp)
    

となっており、正しく書き込みされていることがわかる。これである程度準備できたので、今度はベクトル演算をしてみる。以下にスカラ演算と3DNow!を利用したベクトル演算の例を示す。

#include <stdio.h>

// oL[] = iL[] * fL[]
void mulScalar(float * iL, float * fL, float * oL, int size)
{
  for(int i = 0;i < size;i ++)
    oL[i] = iL[i] * fL[i];
  return;
}

void mulVector(float * iL, float * fL, float * oL, int size)
{
  size /= 2;
  __asm__ __volatile__ ("femms");
  __asm__ __volatile__ ("mul3_3dnow__: \n\t"
			"movq  (%1), %%mm0 \n\t"
			"movq  (%2), %%mm1 \n\t"
			"pfmul %%mm1, %%mm0 \n\t"
			"movq  %%mm0, (%0) \n\t"
			"add   $0x8, %0  \n\t"
			"add   $0x8, %1 \n\t"
			"add   $0x8, %2 \n\t"
			"dec   %3 \n\t"
			"jnz   mul3_3dnow__"
			:
			: "q"(oL), "q"(iL), "q"(fL), "q"(size)
			:);
  __asm__ __volatile__ ("femms");
  return;
}

int main(int argc, char **argv)
{
  static int N = 4096;
  float * fL = new float[N];
  float * iL = new float[N];
  float * oL = new float[N];
  fL[0] = 0.1f; fL[1] = 0.2f; iL[0] = 0.4f; iL[1] = 0.9f;
  fL[N-2] = 0.1f; fL[N-1] = 0.2f; iL[N-2] = 0.3f; iL[N-1] = 0.2f;
  mulScalar(fL, iL, oL, N);
  fprintf(stderr, "[0,1] %f %f\n", oL[0], oL[1], oL[2], oL[3]);
  fprintf(stderr, "[N-2,N-1] %f %f\n", oL[N-2], oL[N-1]);
  mulVector(fL, iL, oL, N);
  fprintf(stderr, "[0,1] %f %f\n", oL[0], oL[1], oL[2], oL[3]);
  fprintf(stderr, "[N-2,N-1] %f %f\n", oL[N-2], oL[N-1]);
  delete[] fL;
  delete[] iL;
  delete[] oL;
}
    

出力は以下の通りどちらも同じである。

[0,1] 0.040000 0.180000
[N-2,N-1] 0.030000 0.040000
[0,1] 0.040000 0.180000
[N-2,N-1] 0.030000 0.040000
    

RDTSCを使ってSMPだと不正確ながらクロックを計ってみる。以下のクロックはAMD Athlon(tm) 64 X2 Dual Core Processor 4600+を使用したものである。

#include <stdio.h>

inline long long rdtsc()
{
  long long tsc;
  __asm__ __volatile__ ("rdtsc":"=A" (tsc));
  return tsc;
}

void mulScalar(float * iL, float * fL, float * oL, int size)
{
  for(int i = 0;i < size;i ++)
    oL[i] = iL[i] * fL[i];
  return;
}

void mulVector(float * iL, float * fL, float * oL, int size)
{
  size /= 2;
  __asm__ __volatile__ ("femms");
  __asm__ __volatile__ ("mul3_3dnow__: \n\t"
			"movq  (%1), %%mm0 \n\t"
			"movq  (%2), %%mm1 \n\t"
			"pfmul %%mm1, %%mm0 \n\t"
			"movq  %%mm0, (%0) \n\t"
			"add   $0x8, %0  \n\t"
			"add   $0x8, %1 \n\t"
			"add   $0x8, %2 \n\t"
			"dec   %3 \n\t"
			"jnz   mul3_3dnow__"
			:
			: "q"(oL), "q"(iL), "q"(fL), "q"(size)
			:);
  __asm__ __volatile__ ("femms");
  return;
}

int main(int argc, char **argv)
{
  long long tsc_start, tsc_stop, tsc_min;
  static int N = 4*4096;
  static int C = 10;
  float * fL = new float[N];
  float * iL = new float[N];
  float * oL = new float[N];
  fprintf(stderr, "%d bytes allocated.\n", 3*sizeof(float)*N);
  fL[0] = 0.1f; fL[1] = 0.2f; iL[0] = 0.4f; iL[1] = 0.9f;
  fL[N-2] = 0.1f; fL[N-1] = 0.2f; iL[N-2] = 0.3f; iL[N-1] = 0.2f;
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      mulScalar(fL, iL, oL, N);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "Scalar Clock: %ld\n", tsc_min/N);
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      mulVector(fL, iL, oL, N);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "Vector Clock: %ld\n", tsc_min/(N/2));
  delete[] fL;
  delete[] iL;
  delete[] oL;
}
    

とりあえずクロックのおおよその最小値は以下のようになった。

196608 bytes allocated.
Scalar Clock: 8
Vector Clock: 15
    

ここから考えられる遅い原因は、浮動小数点演算といっても実行ユニットは同じであること、加算と乗算を同時実行できるわけでもないこと、 おそらくベクトル演算では克服できないメモリバンド/レイテンシなどの壁があることなどである。

ここでレイテンシを調べてみる。 Software Optimization Guide for AMD64 Processorsの Appendix C Instruction Latenciesを参照すると、以下の抜粋のようになっている。

ADD mreg16/32/64, imm16/32 81h 11-000-xxx DirectPath 1
ADD mem16/32/64, imm16/32 81h mm-000-xxx DirectPath 4
ADD mreg16/32/64, imm8 (sign extended) 83h 11-000-xxx DirectPath 1
ADD mem16/32/64, imm8 (sign extended) 83h mm-000-xxx DirectPath 4

DEC mem16/32/64 FFh mm-001-xxx DirectPath 4
DEC mreg16/32/64 FFh 11-001-xxx DirectPath 1

JNE/JNZ disp8 75h DirectPath 1 *1
JNE/JNZ disp16/32 0Fh 85h DirectPath 1 *1

MOVQ mmreg1, mmreg2 0Fh 6Fh 11-xxx-xxx DirectPath FADD/FMUL 2
MOVQ mmreg, mem64 0Fh 6Fh mm-xxx-xxx DirectPath FADD/FMUL/FSTORE 4 *2
MOVQ mmreg2, mmreg1 0Fh 7Fh 11-xxx-xxx DirectPath FADD/FMUL 2
MOVQ mem64, mmreg 0Fh 7Fh mm-xxx-xxx DirectPath FSTORE 2

PFMUL mmreg1, mmreg2 0Fh, 0Fh B4h 11-xxx-xxx DirectPath FMUL 4
PFMUL mmreg, mem64 0Fh, 0Fh B4h mm-xxx-xxx DirectPath FMUL 6

*1. Static timing assumes a predicted branch.
*2. These instructions have an effective latency as shown.
However, these instructions generate an internal NOP
with a latency of two cycles but no related dependencies.
These internal NOPs can be executed at a rate of
three per cycle and can use any of the three execution resources.
    

ここから考えると、かなりレイテンシがかさんでいることがわかる。ちなみに以下のループのみでは3クロックである。inc,cmp,jneすべてが依存しているのですべて実行して3クロックとなるのだろう。

  __asm__ __volatile__ ("mul3_3dnow__: \n\t"
                        "inc   %%eax \n\t"
                        "cmp   %%eax, %3 \n\t"
                        "jne   mul3_3dnow__"
    

そこでプリフェッチの登場である。以下のようにprefetchを挿入してみる。

  __asm__ __volatile__ ("mul3_3dnow__: \n\t"
			"prefetch 0xプリフェッチ先のアドレス(%1) \n\t"
			"movq  (%1), %%mm0 \n\t"
    

プリフェッチするアドレスを試すとそれぞれ以下のようになる。

prefetchなし
Scalar Clock: 8
Vector Clock: 15
+0x0
Scalar Clock: 8
Vector Clock: 16
+0x10
Scalar Clock: 8
Vector Clock: 15
+0x20
Scalar Clock: 8
Vector Clock: 11
+0x40
Scalar Clock: 8
Vector Clock: 11
+0x80
Scalar Clock: 8
Vector Clock: 10
+0x100
Scalar Clock: 8
Vector Clock: 10
+0x120
Scalar Clock: 8
Vector Clock: 9
+0x140
Scalar Clock: 8
Vector Clock: 9
+0x200
Scalar Clock: 8
Vector Clock: 10
+0x1000
Scalar Clock: 8
Vector Clock: 20
    

prefetch 0x120(%1)近辺が一番効果が高く、ループあたり9クロックですんでいることがわかる。命令数が9なので、だいぶ良くなった。ちなみに以下のように レジスタ数を減らすことで見やすくなるし、もしかしたら速くなるかもしれない。

  __asm__ __volatile__ ("xor   %%eax, %%eax \n\t"
                        "mul3_3dnow__: \n\t"
                        "prefetch 0x120(%1,%%eax,8) \n\t"
                        "movq  (%1,%%eax,8), %%mm0 \n\t"
                        "movq  (%2,%%eax,8), %%mm1 \n\t"
                        "pfmul %%mm1, %%mm0 \n\t"
                        "movq  %%mm0, (%0,%%eax,8) \n\t"
                        "inc   %%eax \n\t"
                        "cmp   %%eax, %3 \n\t"
                        "jne   mul3_3dnow__"
    

ただ少なくとも雑な言い方をすれば、x87 FPUに比べて3DNow!は文字どおり倍速になったわけだ。しかしこれにはかなり誤りが含まれている。まず、このプログラムで allocateされているメモリのサイズに注意する。196608 bytes allocated.と表示されているとおり、現在使用しているCPUの二次キャッシュにおさまるサイズのデータを 計算している。そこでデータサイズを変化させてみると以下のようになる。

size    Scalar  Vector  Vector w/o PF
12288   2       4       4
24576   2       4       4
49152   3       4       4
98304   14      16      16
196608  14      16      16
393216  10      17      16
786432  13      19      22
1572864 13      19      22
3145728 14      19      22
6291456 14      20      22
    

Athlon64X2の一次データキャッシュ(L1)が64KiBであることから、49152byteまでは非常に速い演算ができているとがわかり、prefetchがL1に読み込む命令であること を考えると、w/o PreFecthとの差があまりない理由がわかる。AMD Athlon X2 Dual-Core Processor Product Data Sheetによれば、

64-Kbyte 2-Way Associative ECC-Protected L1 Data Caches
Two 64-bit operations per cycle, 3-cycle latency
    

なので、L1は1クロックあたり最高2x64bitのデータを読み書きでき、今回の場合、ループ/L1読み書きが同時に実行されて高速に動いていることになる。

また、二次キャッシュが512KiBなのでそれを超えるとクロックは上がる。サイズが大きくなってくるとプリフェッチの効果が見えてくる。ただし、最近のCPUはハードウエアプリフェッチを行うので その効果があまり見えないという問題はある。

ここでメモリバスの幅を考える。今回使用しているのはDDR 800 (PC2-6400) dual channelなので6.4x2=12.8GB/秒である。 CPUは2.4Gなので、CPUクロックあたり最高16/3byteの転送ができる。但しこれはシーケンシャルな読み込みの場合である。また、Hammer-Infoによれば

130nmでも90nmでもL2 Latencyは12 cyclesであったが、Brisbaneでは20 cyclesである。
AMDの回答によると、将来的にキャッシュサイズを増やすことも可能なように
意図的にLatencyを増やしたのだという。
    

ということで、現在使用中なのは90nm製品なのでL2からの読み込みには最低12クロックかかるとみてよいだろう。すると、先程述べたようにループ3クロック、 ベクトル演算1クロック、キャッシュからの読み込みで12クロックとなり、サイズがL1を越えたあたりで16クロックとなると思われる。以上から、すでにキャッシュやメモリの 読み書きの速度の壁のせいでベクトル演算が思ったように速くは動いていないということが推測できる。実際は計算はもっと複雑なので、もし計算内容がメモリ読み込みよりも 時間がかかるならばSIMD化する価値があるといえる。計算内容よりもメモリ読み込みの方が時間がかかるようになると、ストールが発生する。 最終的には以下のような最適化コードを考えた。二次キャッシュの範囲ではいくらか速くなったが、それでもメインメモリの領域になると最適化は難しい。 データがくるまでクロックを消費して待っているのだろう。

  __asm__ __volatile__ ("xor   %%eax, %%eax \n\t"
                        "mul3_3dnow__: \n\t"
                        "prefetch 0x120(%2,%%eax,8) \n\t"
                        "movq  (%1,%%eax,8), %%mm0 \n\t"
                        "pfmul (%2,%%eax,8), %%mm0 \n\t"
                        "movq  %%mm0, (%0,%%eax,8) \n\t"
                        "inc   %%eax \n\t"
                        "cmp   %%eax, %3 \n\t"
                        "jne   mul3_3dnow__"
                        :
                        : "q"(oL), "q"(iL), "q"(fL), "r"(size)
                        : "eax");
    
size    Scalar  Vector  Vector w/o PF
12288   2       4       4
24576   2       4       4
49152   3       4       4
98304   14      14      13
196608  14      14      14
393216  10      14      18
786432  13      17      22
1572864 13      19      22
3145728 14      19      23
6291456 14      19      23
    

SIMDによる複素数乗算の高速化

さて、複素数乗算の本題に戻る。以下のように、スカラ、スカラ2回、3DNow!ベクトル演算を利用した場合のクロックをはかる。

#include <stdio.h>

inline long long rdtsc()
{
  long long tsc;
  __asm__ __volatile__ ("rdtsc":"=A" (tsc));
  return tsc;
}

void complexScalar(float * x, float * y, float * out, int n)
{
  for (int j = 0; j < n/2; j++)
    {
      float a = x[j*2];
      float b = x[j*2+1];
      float c = y[j*2];
      float d = y[j*2+1];
      out[j*2] += a*c-b*d; // Re
      out[j*2+1] += a*d+b*c; // Im
    }
}

void complexScalar2(float * iL, float * fL, float * oL, int size)
{
  for(int i = 0;i < (size/4);i ++)
    {
      // Re
      oL[4*i+0] += iL[4*i+0]*fL[4*i+0]-iL[4*i+2]*fL[4*i+2];
      oL[4*i+1] += iL[4*i+1]*fL[4*i+1]-iL[4*i+3]*fL[4*i+3];
      // Im
      oL[4*i+2] += iL[4*i+0]*fL[4*i+2]+iL[4*i+2]*fL[4*i+0];
      oL[4*i+3] += iL[4*i+1]*fL[4*i+3]+iL[4*i+3]*fL[4*i+1];
    }
}

void complex3DNOW(float * iL, float * fL, float * oL, int size)
{
  size /= 4;
  __asm__ __volatile__ ("femms");
  __asm__ __volatile__ ("mul_3dnow__: \n\t"
			"movq  (%1), %%mm0 \n\t"
			"movq  %%mm0, %%mm2 \n\t"
			"movq  (%2), %%mm1 \n\t"
			"pfmul %%mm1, %%mm0 \n\t"
			"movq  0x8(%1), %%mm4 \n\t"
			"movq  %%mm4, %%mm3 \n\t"
			"pfmul %%mm1, %%mm3 \n\t"
			"movq  0x8(%2), %%mm5 \n\t"
			"pfmul %%mm5, %%mm2 \n\t"
			"pfmul %%mm5, %%mm4 \n\t"
			"pfsub %%mm4, %%mm0 \n\t"
			"pfadd (%0), %%mm0 \n\t"
			"movq  %%mm0, (%0) \n\t"
			"pfadd %%mm3, %%mm2 \n\t"
			"pfadd 0x8(%0), %%mm2 \n\t"
			"movq  %%mm2, 0x8(%0) \n\t"
			"add   $0x10, %0 \n\t"
			"add   $0x10, %1 \n\t"
			"add   $0x10, %2 \n\t"
                        "dec   %3 \n\t"
			"jne   mul_3dnow__"
			:
			: "q"(oL), "q"(iL), "q"(fL), "q"(size)
			:);
  __asm__ __volatile__ ("femms");
}

void work(int N, int C)
{
  long long tsc_start, tsc_stop, tsc_min;
  float * fL = new float[N];
  float * iL = new float[N];
  float * oL = new float[N];
  fprintf(stderr, "%d\t", 3*sizeof(float)*N);
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      complexScalar(fL, iL, oL, N);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "%ld\t", tsc_min/(N/2));
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      complex3DNOW(fL, iL, oL, N);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "%ld\t", tsc_min/(N/4));
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      complexScalar2(fL, iL, oL, N);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "%ld\n", tsc_min/(N/4));
  delete[] fL;
  delete[] iL;
  delete[] oL;
}


int main(int argc, char **argv)
{
  // static int N = 4*4096;
  static int N = 4*256;
  static int C = 30;
  fprintf(stderr, "size\tScalar\t3DNow!\tScalar2\n");
  for(int i = 0;i < 10;i ++)
    {
      work(N, C);
      N *= 2;
    }
}
    
size    Scalar  3DNow!  Scalar2
12288   11      10      23
24576   10      10      24
49152   10      10      26
98304   33      38      100
196608  37      43      103
393216  34      39      70
786432  48      50      110
1572864 49      51      111
3145728 49      52      112
6291456 50      52      112
    

ループあたりのクロックだから、ほぼ3DNow!は倍速であることになる。先程の単純な乗算の場合は計算内容が単純だったためにメモリバンドの 壁があったが、この場合は計算内容がある程度複雑なので、メモリバンドにあまり左右されずに高速化できたと考えられる。 ちなみに、Athlon thunderbird 800MHz (PC133)の場合、以下のようになり、3DNow!を利用しても性能が悪化していることから、 メインメモリのレイテンシがかなり影響していることがわかる。Opteron以降のAMDのメモリコントローラ内蔵CPUはメインメモリのレイテンシを削減したことで かなり性能を伸ばしたようだ。

size    Scalar  3DNow!  Scalar2
12288   11      11      23
24576   12      11      24
49152   10      10      25
98304   36      38      94
196608  38      46      96
393216  27      34      64
786432  43      55      108
1572864 52      81      123
3145728 51      81      124
6291456 51      82      124
    

次にSSEを利用してみる。movapsは16byte alignmentを要求するので少々複雑になる。

#include <stdio.h>
#include <fftw3.h>

inline long long rdtsc()
{
  long long tsc;
  __asm__ __volatile__ ("rdtsc":"=A" (tsc));
  return tsc;
}

void complexSSE(float * iL, float * fL, float * oL, int count)
{
  __asm__ __volatile__ ("mul_sse__: \n\t"
			"movaps (%1), %%xmm0 \n\t"
			"movaps %%xmm0, %%xmm2 \n\t"
			"movaps (%2), %%xmm1 \n\t"
			"mulps  %%xmm1, %%xmm0 \n\t"
			"movaps 0x10(%1), %%xmm4 \n\t"
			"movaps %%xmm4, %%xmm3 \n\t"
			"mulps  %%xmm1, %%xmm3 \n\t"
			"movaps 0x10(%2), %%xmm5 \n\t"
			"mulps  %%xmm5, %%xmm2 \n\t"
			"mulps  %%xmm5, %%xmm4 \n\t"
			"subps  %%xmm4, %%xmm0 \n\t"
			"addps  (%0), %%xmm0 \n\t"
			"movaps %%xmm0, (%0) \n\t"
			"addps  %%xmm3, %%xmm2 \n\t"
			"addps  0x10(%0), %%xmm2 \n\t"
			"movaps %%xmm2, 0x10(%0) \n\t"
			"add   $0x20, %0 \n\t"
			"add   $0x20, %1 \n\t"
			"add   $0x20, %2 \n\t"
                        "dec   %3 \n\t"
			"jne   mul_sse__"
			:
			: "q"(oL), "q"(iL), "q"(fL), "q"(count)
			:);
}

void work(int N, int C)
{
  long long tsc_start, tsc_stop, tsc_min;
  float * fL = (float*)fftwf_malloc(sizeof(float)*N*2);
  float * iL = (float*)fftwf_malloc(sizeof(float)*N*2);
  float * oL = (float*)fftwf_malloc(sizeof(float)*N*2);
  fprintf(stderr, "%d\t", 3*sizeof(float)*N*2);
  for(int i = 0;i < C;i ++)
    {
      tsc_start = rdtsc();
      complexSSE(fL, iL, oL, N/4);
      tsc_stop = rdtsc();
      if(i == 0)
	tsc_min = tsc_stop - tsc_start;
      else
	if(tsc_min > tsc_stop - tsc_start)
	  tsc_min = tsc_stop - tsc_start;
    }
  fprintf(stderr, "%ld\n", tsc_min/(N/4));
  fftwf_free(fL);
  fftwf_free(iL);
  fftwf_free(oL);
}


int main(int argc, char **argv)
{
  // static int N = 4*4096;
  static int N = 4*128;
  static int C = 10;
  fprintf(stderr, "size\tSSE\n");
  for(int i = 0;i < 10;i ++)
    {
      work(N, C);
      N *= 2;
    }
}
    

ループあたりのクロックは以下の通りである。

size    SSE
12288   18
24576   19
49152   19
98304   52
196608  55
393216  61
786432  95
1572864 97
3145728 98
6291456 99
    

4倍速は無理だが二次キャッシュの範囲のみでは2倍程度は高速化できそうである。しかし、メインメモリの領域では大幅な最適化は難しいと考えられる。

以上のことを利用して、Fragment Multiplier (class frag)をSIMD化し、SSEを利用した場合のsamples/impulse3のプロファイリング結果は以下のようになった。(抜粋)

  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
 69.81      2.92     2.92                             multMONO_sse__
  6.92      3.21     0.29    10013     0.03     0.10  irmodel3_f::processreplace(float*, float*, float*, float*, long, unsigned int)
  5.73      3.46     0.24    10916     0.02     0.03  frag_f::processHC2R(float*, float*, float*, float*)
  4.53      3.65     0.19    41918     0.00     0.00  frag_f::convertR2SIMD(float*, float*, int)
  3.10      3.77     0.13    10014     0.01     0.01  dump(void*, int)
  2.63      3.88     0.11    21832     0.01     0.01  frag_f::convertSIMD2R(float*, float*, int)
  2.15      3.98     0.09    10290     0.01     0.06  irmodel3_f::processZL(float*, float*, float*, float*, long, unsigned int)
  1.91      4.05     0.08 20503506     0.00     0.00  delay_f::process(float)
  1.43      4.12     0.06    10013     0.01     0.01  mergeLR(float*, float*, float*, int)
  0.95      4.16     0.04     9736     0.00     0.00  splitLR(float*, float*, float*, int)
  0.24      4.17     0.01   171107     0.00     0.00  frag_f::processMULT(float*, float*, float*, float*)
  0.24      4.17     0.01    20926     0.00     0.01  frag_f::processR2HC(float*, float*, float*, float*)
  0.24      4.18     0.01        4     2.50     2.50  blockDelay_f::setBlock(int, int)
  0.12      4.19     0.01   342214     0.00     0.00  frag_f::multMONO(float*, float*, float*, int)
    
fragmentSize = 1024
wetDB = 0.000000
factor = 16
loadImpulse(sample = 282953,FFTW_ESTIMATE)...done.
*** irmodel3 config ***
impulseSize = 282953
short Fragment Size = 1024
large Fragment Size = 16384
short Fragment vector Size = 16
large Fragment vector Size = 17
00002b17/00002b18
Wait for IR...
00000114/00000115

real    0m14.774s
user    0m14.609s
sys     0m0.168s
    

時間的には8/7倍程度速くなったが、データの並べかえに少し時間がかかっていることがわかる。このため、SSEにはデータシャッフル用の命令が用意されており、フレキシブルにデータ構造を利用できる。 ただ、今回の場合、上からわかるように並べかえを高速化してもほとんど速くならないだろうことが予測できる。 Athlon64X2の場合、浮動小数点演算であってもかなり速いので、メモリ帯域などの壁があってなかなか高速化することができない。

gprofなどのプロファイラを利用した場合、関数単位での時間はわかるが、命令毎での時間を知りたいという場合は、パフォーマンスカウンタを利用することになる。 パフォーマンスカウンタとは、最近のCPUには備わっているレジスタの一種で、キャッシュミスなどのパフォーマンスに関する統計を取るために利用できるものである。 これらはユーザーランドからは一部しか利用できないので、一般的にはカーネルモード(カーネルモジュールあるいはドライバ)を読み込んで利用することになる。 intelのVTuneや、GPLのoprofileperfmon2などがそれにあたるが、 幸いなことに、Linux 2.6以降では標準でoprofileを利用することができる。そこで、ここではoprofileを利用してみたい。 カーネルのオプションでInstrumentation SupportまたはCONFIG_PROFILING,CONFIG_OPROFILE,CONFIG_KPROBESとして利用できる。まずはこれらを使用して カーネルを作りなおして起動する。opcontrol -lとすると以下のように利用できるカウンタが表示される。

oprofile: available events for CPU type "AMD64 processors"

CPU_CLK_UNHALTED: (counter: all)
        Cycles outside of halt state (min count: 3000)
RETIRED_INSTRUCTIONS: (counter: all)
        Retired instructions (includes exceptions, interrupts, re-syncs) (min count: 3000)
RETIRED_UOPS: (counter: all)
        Retired micro-ops (min count: 500)
INSTRUCTION_CACHE_FETCHES: (counter: all)
        Instruction cache fetches (RevE) (min count: 500)
INSTRUCTION_CACHE_MISSES: (counter: all)
        Instruction cache misses (min count: 500)
DATA_CACHE_ACCESSES: (counter: all)
        Data cache accesses (min count: 500)
DATA_CACHE_MISSES: (counter: all)
        Data cache misses (min count: 500)
...以下略
    

まずは実際に利用してみる。opcontrol -sで開始するので、その後調べたいプログラムを起動する。終ったらopcontrol -tで終了する。 /var/lib/oprofile/samples/current/にカウンタの内容が記録される。まずopreportとすると、概要が表示される。

CPU: AMD64 processors, speed 2399.98 MHz (estimated)
Counted CPU_CLK_UNHALTED events (Cycles outside of halt state) \
with a unit mask of 0x00 (No unit mask) count 100000
CPU_CLK_UNHALT...|
  samples|      %|
------------------
   467853 36.0132 libfftw3f.so.3.2.0
   164017 12.6253 libfreeverb3.so.2.0.0
   160743 12.3733 impulse3
   158160 12.1744 libc-2.5.so
   136565 10.5122 libfreeverb3.so.2.0.0
    60555  4.6613 vmlinux
    47917  3.6884 libsamplerate.so.0.1.1
...以下略
    

opgprof (バイナリ名)とするとgprofで読めるgmon.outを作成してくれる。この内容はほぼ-pgで作成できるものと同じであるが、パフォーマンスカウンタ の内容を指定して入れることもできる。それについては後述する。内容は以下のようなものである。

Flat profile:

Each sample counts as 1 samples.
  %   cumulative   self              self     total
 time   samples   samples    calls  T1/call  T1/call  name
 34.88 107853.00 107853.00                             multMONO_sse__
 33.09 210189.00 102336.00                             frag_f::allocFFT(int, unsigned int)
  4.20 223177.00 12988.00                             _init
  3.39 233663.00 10486.00                             irmodel3_f::irmodel3_f()
  3.30 243875.00 10212.00                             frag_f::processHC2R(float*, float*, float*, float*)
  3.00 253146.00  9271.00                             readSoundFloatLR(SNDFILE_tag*, SF_INFO*, float*, float*, int)
  2.33 260359.00  7213.00                             irmodel3_f::processZL(float*, float*, float*, float*, long, unsigned int)
  2.26 267349.00  6990.00                             dump(void*, int)
  1.94 273359.00  6010.00                             irmodel3_f::processreplace(float*, float*, float*, float*, long, unsigned int)
  1.90 279245.00  5886.00                             frag_f::convertR2SIMD(float*, float*, int)
  1.52 283941.00  4696.00                             delay_f::delay_f()
  1.41 288316.00  4375.00                             frag_f::convertSIMD2R(float*, float*, int)
...
    

そして、命令毎の結果はopreport -d (バイナリ名)で表示できる。

CPU: AMD64 processors, speed 2399.98 MHz (estimated)
Counted CPU_CLK_UNHALTED events (Cycles outside of halt state) \
with a unit mask of 0x00 (No unit mask) count 100000
vma      samples  %        symbol name
0804cac0 107853   34.8776  multMONO_sse__
  0804cac0 2343      2.1724
  0804cac4 2566      2.3792
  0804cac7 3400      3.1524
  0804caca 3744      3.4714
  0804cace 2159      2.0018
  0804cad1 4745      4.3995
  0804cad4 3384      3.1376
  0804cad8 7474      6.9298
  0804cadb 6290      5.8320
  0804cade 3372      3.1265
  0804cae2 11709    10.8564
  0804cae5 15141    14.0386
  0804cae8 12098    11.2171
  0804caeb 2810      2.6054
  0804caef 1871      1.7348
  0804caf2 2472      2.2920
  0804caf5 2029      1.8813
  0804caf8 2501      2.3189
  0804cafc 2266      2.1010
  0804cb00 4580      4.2465
  0804cb03 3112      2.8854
  0804cb06 2247      2.0834
  0804cb09 2772      2.5702
  0804cb0a 2554      2.3680
  0804cb0c 1        9.3e-04
  0804cb0d 209       0.1938
  0804cb0e 4         0.0037
    

アセンブラのコードとマージすると以下のようになる。

Asm code

Pentium4のx87 FPUの遅さとは

以上だけ見ると、SIMDによる最適化が意味のないものに見えてくるので、やる気を出すために、非常に効果があるという例も示しておく。例えば、以下の環境では、以上のSSE最適化に非常に意味がある。

Pentium4 520 (2.8GHz,FSB 800MHz,1MB L2 Cache)
E7221 PC2-3200 DDR2-400 ECC 512M
Linux 2.6.24-gentoo-r8
    

Pentium3やPentium4はAthlon系に比べてx87 FPUが非常に遅いという話は有名だが、どれだけ違うかというと以下のとおり8倍くらい違う。(process t が畳み込みにかかった時間。)

Benchmark: freeverb3-2.3.0 fv3_benchmark

FPU
-=-=-=-=- Results -=-=-=-=-
irLength 480000 (10.00[s]@48kHz)
fragmentS 1024
factor 16
process t 1024000 (21.33[s]@48kHz)
load time 0.19[s] (x52.63@48kHz)
process t 5.92[s] (x3.60@48kHz)

SSE optimized
-=-=-=-=- Results -=-=-=-=-
irLength 480000 (10.00[s]@48kHz)
fragmentS 1024
factor 16
process t 1024000 (21.33[s]@48kHz)
load time 0.17[s] (x58.82@48kHz)
process t 0.85[s] (x25.10@48kHz)
    

ちなみにloadの時間はFFTW3に依存しているので、あまり差がついていない。プリフェッチ命令を多用したこともあるが、最適化が有効なこともあるのだ。 Core2Duo世代以降のIntelのCPUはこれほど差が出ることが少なくなったらしいが、それでもAthlon系のFPUの早さには特筆すべき物があると思う。ちなみに昔のAthlonでもそれなりの最適化の意味はあるようだ。

Athlon 800MHz
KT133A PC133 384M

FPU
-=-=-=-=- Results -=-=-=-=-
irLength 480000 (10.00[s]@48kHz)
fragmentS 1024
factor 16
process t 1024000 (21.33[s]@48kHz)
load time 0.67[s] (x14.93@48kHz)
process t 9.00[s] (x2.37@48kHz)

3DNow! optimized
-=-=-=-=- Results -=-=-=-=-
irLength 480000 (10.00[s]@48kHz)
fragmentS 1024
factor 16
process t 1024000 (21.33[s]@48kHz)
load time 0.66[s] (x15.15@48kHz)
process t 7.41[s] (x2.88@48kHz)
    

更に詳しいことは、SAXPYやZAXPYという言葉で探すことができる。

4. User Friendliness and Graphical User Interface:

Most people are not very user-friendly. Try talking to a person (especially of
the opposite sex) and trying to guess what you're supposed to do now, what the
other person wants from you, what would happen if you did this, and what would
happen if you did that, and how the heck to you get the other person to do
what you really want. No more of that! People should get a graphical user
interface. Why talk to the other person in that complex command line language
we call "Hebrew", when you can just look at the menu, see the options "Leave
me alone" and "Let's have sex" and just chose the one you want! Better yet,
why not have a toolbar, with nice little icons?

    -- Nadav Har'El
    -- Hackers-IL Message No. 1,408 ( http://tech.groups.yahoo.com/group/hackers-il/message/1408 )

Q:	What lies on the bottom of the ocean and twitches?
A:	A nervous wreck.


Powered by UNIX fortune(6)
[ Main Page ]