• このエントリーをはてなブックマークに追加

今なら、継続入会で月額会員費が1ヶ月分無料!

記事 1件
  • 【機械学習】Chainer vs C++ AVX最終決戦

    2016-09-01 07:55  
    102pt
     さて 前回の結果に気を良くした僕は、調子に乗って5層オートエンコーダの高速化を試みるのである。 といっても、前回作ったプログラムがほぼ流用できるので、予め学習させたpklファイルをヘッダファイルに書き出すプログラムを流用して5層オートエンコーダをC++の配列として出力する。
    def dumpArray(ar):
        for i in range(len(ar)):
            e = ar[i]
            if isinstance(e,np.ndarray):
                print "{",
                dumpArray(e)
                print "}",
            else:
                print ("{}".format(e)),
            if i < len(ar)-1:
                print ","
    def dumpLayer(name,l):
        print "double ",
        print "{}_W".format(name),
        for d in l.W.data.shape:
            print "[{}]".format(d),
        print "={"
        dumpArray(l.W.data)
        print "};"
        print "double ",
        print "{}_b".format(name),
        for d in l.b.data.shape:
            print "[{}]".format(d),
        print "={"
        dumpArray(l.b.data)
        print "};"
    print "double in[]={"
    dumpArray(x_train[0])
    print "};"
    dumpLayer("l1",model.l1)
    dumpLayer("l2",model.l2)
    dumpLayer("l3",model.l3)
    dumpLayer("l4",model.l4)
    dumpLayer("l5",model.l5)


     こいつで得られたlayers.hをC++プログラムから読み込んで前回と同じようなことをするわけだ。 今回も余裕だろうと思って試すと
    $ python bench_ae_chainer.py 
    start
    Chainer:0.223642sec
    $ ./hoge 
    0.129455 sec
    31.683862 


     どっどっどっ どういうことだってばよ!? 前回は500倍くらいのスピードだったのに今回は2倍程度のスピードになってしまっている。 いろいろ考えると、まあ結論はひとつしかない。 要するに、第一層がデカすぎるのである。 前回の第一層は1,407バイト、つまり1キロバイト強しかないのに対し、今回の第一層は3,513,339バイト、つまり3.5メガバイトもある。 コンピュータの世界ではキャッシュに乗るか乗らないかというのが非常に重要だ。 最新世代のCore i7であっても、L1キャッシュは32KB、L2キャッシュでも256KB、L3キャッシュになってようやく、4〜8MBになる。 1.4キロバイトのデータは全てL1キャッシュに乗るが3.5メガバイトはL3キャッシュにやっと乗るかどうか、というところだ。 つまり前回の戦いは単にネイティブコードとPythonの戦いではなく、キャッシュの戦いでもあったわけだ。 まあ普通に考えて同じコンピュータで同じアルゴリズムを走らせてそんな数百倍の違いになることのほうがどうかしてるのだから、こういうからくりでまあ間違いないだろう。 さて、問題はそんなことではない。 この、キャッシュが全然効かない状況でどうやって処理速度を稼ぐのか。 C++のネイティブコードがPythonの二倍程度の速度でしかないなんていうことは常識的にあり得ないのである。 もしかするとnumpyの中でSIMD命令を使っているのではないか? そう思って調べてみると案の定、numpyの内部ではSIMD命令を使って高速化されていた。 SIMDとは、Single Instruction Multiple Data streamの略で、要するに単一の命令で複数のデータを一括処理しちゃおうという思想である。 むかしはSIMD命令を使うとすると全てインラインアセンブリでやっていたが、今はC++からでもイントリンジックという愉快な仕組みを導入すると好き放題使えるらしい。 僕のMacBookAirはAVX2までをサポートしているらしいので、最大256ビットのSIMDレジスタを使うことができる。 256ビットのSIMDレジスタは、32ビット単精度を8つか、64ビット倍精度を4つ同時に処理することができる。 やってやろうじゃん。 ということで元はこんな感じになっていたコードを
    Vector *apply(Vector *v){
    int offset=0;
    for(int j=0;j<h;j++){
    double acc=0.0;
    offset+=w;
    for(int i=0;i<w;i++){
    acc += m[offset+i] * (v->v[i]);
    }
    r->v[j]=acc;
    }
    return r;
    }

     こんな感じに書き換えてみた。
    Vector *apply(Vector *v){
    int offset=0;
    for(int j=0;j<h;j++){
    double acc=0.0;
    offset+=w;
    __m256d M,V,Tmp,Acc; 
    double *pm = m+offset;
    double *pv = v->v;
    Acc = _mm256_setzero_pd();
    int end = (w/4)*4;
    int i;
    for(i=0;i<end;i+=4){
    M = _mm256_load_pd(pm);
    V = _mm256_load_pd(pv);
    Tmp=_mm256_mul_pd(M,V);
    Acc=_mm256_add_pd(Tmp,Acc);
    pm+=4;
    pv+=4;
    }
    acc = Acc[0]+Acc[1]+Acc[2]+Acc[3];
    for(;i<w;i++){
    acc += *pm * *pv;
    pm++;
    pv++;
    }
    r->v[j]=acc;
    }
    return r;
    }

     地味に配列参照ではなくポインタにするなど地道な努力もかいま見える。
     SIMD命令を使う場合は複数のデータを一括で渡さなければならないのでポインタ演算の方が渡すのに都合がいい。 __m256dという型を宣言するとこれはAVXレジスタに割り当てられる。 今のAVXはレジスタが8本あるらしいのでまあさらに病的な改造を加えればもうちょっと速くなるのかもしれないが汎用処理で回してるであろうnumpyごときと戦うのにはこの程度で充分だろう(いやどうかな行列とベクトルの乗算だからもっと特化した計算になっていても不思議はないが)。

    $ ./avx
    0.075887 sec
    31.951800 

      ふむ。これでまあなんとかC++の面目が保てたか。 おっと忘れてた。コンパイラ先生にも少し頑張ってもらおうか。-O2発動!



    $ ./avx 
    0.022472 sec
    31.683862 


     来ましたね。 さらに、単精度にして8つ同時に計算したらどうなるか。


    $ ./avx 
    0.017531 sec
    0.000000 


     でましたね。 5回繰り返して平均を取った数字でグラフ化するとこんな感じ まあなんですか。10倍程度高速っていう感じになりました。 いやー久しぶりにCPUの中身いじった清々しさがあるなあ。まったく。 ニューラル・ネットワークの世界にやってきても、最適化野郎の出番はあるぜ、という美しい結論で終わっておきましょうか。