ARM SVE

(まだ断片的な情報しか出てないので、多くの推測を含んでいます)


https://community.arm.com/groups/processors/blog/2016/08/22/technology-update-the-scalable-vector-extension-sve-for-the-armv8-a-architecture

(上から参照されてる https://community.arm.com/servlet/JiveServlet/download/38-25150/ARMv8-A%20SVE%20technology%20Hot%20Chips%20v12.pdf が一番情報あるのでこっちを開きながら以下読むと良いです)

ようやく資料が断片的に出てきたので書いておこうと思う。


一番の特徴は、命令セットはベクタ長を固定してないという点だろう。これのメリット、デメリットについて。


まず、メリットについて。

基本的に、現代の半導体は、横に広げる(スループットを向上させる)のは、縦を縮める(レイテンシを短くする)のに比べると、相対的に簡単だという点が重要だ。

CPUをぽやーんと考えて…

CPUの中のトランジスタは時間が立てば立つほど小さくなっていく…そうすると、同じサイズのチップでもそこに詰め込めるロジックの数は増える。これがスループットの向上。

それに対して、マザーボード上のCPUとDRAMの距離は全然変わらない…半導体がいくら進化しても、DRAMを外付けしてる限り、どうしても数cmぐらいの距離が空いてしまう。これがレイテンシが縮まない理由(…ではない…実際には今のDRAMだと距離の問題よりはDRAM自体の問題なんだけど…)。

まあそんな感じで、半導体が進歩すれば横には広くなるけど縦には短かくならない様子を想像して。新幹線が大きくなれば、運べる人間の数は増えるけど、東京-大阪間にかかる時間は変わらない様子を想像して。炎上プロジェクトに新規に人間が投入されても開発期間は短くならない様子を…

そう考えると、半導体が進歩するにつれて、ベクタ長が長くなっていく理由がなんとなくわかると思う。


実際に、x86MMX(64bit)、SSE(128bit)、AVX(256bit)、AVX512(512bit) と、時代が進むにつれて、ベクタ長を長くしてきた。


これのやりかたの問題は、ベクタ長が長くなるのにあわせて、プログラムを書きかえないといけないという点だ。MMX用に書いたプログラムは、64bitでしか動かない。256bit SIMDを持ってる今のCore i7で動かしても、その性能は25%しか発揮されないのだった。

/* SSE, AVX, AVX512 の各バージョン毎にプログラム作らないといけない…つら…くないんだよなぁ…というか単純作業でグングン速くなるからむしろ楽しい作業なんだよなぁ… */

#ifdef SSE2
    __m128d *in0, *in1, *out;
    for (int i=0; i<nelem/2; i++) {
        out[i] = _mm_add_pd(in0[i], in1[i]);
    }
#elif defined AVX
    __m256d *in0, *in1, *out;
    for (int i=0; i<nelem/4; i++) {
        out[i] = _mm256_add_pd(in0[i], in1[i]);
    }
#elif define AVX512
    __m512d *in0, *in1, *out;
    for (int i=0; i<nelem/8; i++) {
        out[i] = _mm512_add_pd(in0[i], in1[i]);
    }
#endif


これをなんとかしよう、というのが、SVE=Scalable Vector Extension だ。

SVE は、レジスタの長さ、命令が実行する演算の幅を、128bit〜2048bit の間のどれか、としか決めてない。これの間のどれになるかは実装依存だ。

    double *in0, *in1, *out;
    for (int i=0; i<nelem; i++) {
        out[i] = in0[i] + in1[i];
    }

これをSVE SIMD化すると、

    int VL = <マシン依存のベクタ長>; // あ、以下の説明ではVL=要素数で書いてるけど、ARMの資料だと、VL=bit数で書いてるね。まあ心の目で読んでください

    vector *in0, *in1, *out;
    for (int i=0; i<nelem; i+=VL) {
        out[i] = in0[i] + in1[i];
    }

こんな感じになる。

これは、簡単そうに見えるけど、実際にはもうちょっと色々問題がある。これに真面目に対応したぞ、というのが、SVEの重要なところなんだろう。

SVE

  • incd 命令

簡単なところから、 i += VL が、一命令でできる。これ必要か?RISCっぽくなくない?まあ VL はどうやっても必要なのでレジスタ一個空くというメリットがある。

out[i]は、マシン語レベルだと、 *(vector*) ( (char* )out + i * sizeof(vector) ) という形になるはずで、[i] と書いた場合、 i * VL というのが必要になる。これに対応するために、メモリオペランドのインデクスは、VL をかけられるようになっている。

  • 最後の余りの部分のマスクが簡単につくれる

nelem が、VL の倍数でなかった場合、データを壊してしまったり、メモリ例外を起こしたりしてしまう。これ普通のSIMDだと結構めんどくさくて、経験的には、ループの中身書くより難しいんだよね。

SVEでは、ループの残りからマスクを一発で作れるようになっている。

上の場合で、VL = 8, nelem が 45 とかだった場合、5回ループを回したあと、最後に TTTTTFFF というマスクが欲しい(あれ、マスクの説明してなくね?まあ現代のSIMDではマスクは常識だから知らないほうが悪いよ)。

これを一発でやるのが、whilelt 命令。


上のPDF の例だと、

daxpy_:
    ldrsw   x3, [x3]         // x3=*n
    mov     x4, #0           // x4=i=0
    whilelt p0.d, x4, x3     // p0=while(i++<n)
    ld1rd   z0.d, p0/z, [x2] // p0:z0=bcast(*a)
.loop:
    ld1d    z1.d, p0/z, [x0,x4,lsl 3] // p0:z1=x[i]
    ld1d    z2.d, p0/z, [x1,x4,lsl 3] // p0:z2=y[i]
    fmla    z2.d, p0/m, z1.d, z0.d    // p0?z2+=x[i]*a
    st1d    z2.d, p0, [x1,x4,lsl 3]   // p0?y[i]=z2
    incd    x4                        // i+=(VL/64)
.latch:
    whilelt p0.d, x4, x3              // p0=while(i++<n)
    b.first .loop                     // more to do?
    ret

とか書いてあるが、脳内シミュレーションすると

VL=8, nelm=45 として、

    // x4<32 の間
    ld1d    z1.d, p0/z [x0, x4, lsl 3] //  p0 でマスクしてz1にロード d は多分double、p0/z は マスクしたレーンはzero の意味だと思われる
    ld1d    z1.d, p0/z [x1, x4, lsl 3] //  p0 でマスクしてz2にロード
    fmla    z2d, p0/m, z1.d, z0.d      //  z2.d = z2.d + z1.d * z0.d
    st1d    z2.d, p0, [x1, x4, lsl 3]  //  
    incd    x4                         // x4 に 8 足す
    whilelt p0.d, x4, x3               // x4 と x3 の差分から、p0.d にマスク作る
    b.first .loop                      // 解説どこにも無いけど、おそらく p0 の先頭で条件分岐

でx4 が、48になったときに、

    ld1d    z1.d, p0/z [x0, x4, lsl 3] //  p0 でマスクしてz1にロード d は多分double、p0/z は マスクしたレーンはzero の意味だと思われる
    // .. (略)
    incd    x4                         // x4 = 48
    whilelt p0.d, x4, x3               // x4 = 48 と x3 = 45 の差分から、p0.d に TTTTTFFF のマスクを作る
    b.first .loop                      // p0 の 先頭は、まだ T なので 続ける

んで、次のループのときに、

    ld1d    z1.d, p0/z [x0, x4, lsl 3] //  TTTTTFFF でマスクするので、余計な領域をロードすることはない!
    ld1d    z1.d, p0/z [x1, x4, lsl 3] //
    fmla    z2d, p0/m, z1.d, z0.d      //
    st1d    z2.d, p0, [x1, x4, lsl 3]  //
    incd    x4                         // x4 = 64
    whilelt p0.d, x4, x3               // x4 = 64 と x3 = 45 の差分から、p0.d に FFFFFFFF のマスクを作る
    b.first .loop                      // p0 の 先頭が F なのでループを抜ける

となる!


よくできてる!最小命令数で、端の処理書かないでSIMDが書けるなんて!素晴らしい!AVXにもくれ!


ループ回数依存処理

さて、上は単純な処理だが、もうちょっと色々がんばってるというのが書いてあって、次の例がstrlen

int strlen(const char *s) {
    const char *e = s;
    while (*e) e++;
    return e - s;
}

これはSIMDにするのが地味にめんどくさくて、'\0' が、ページ境界ギリギリにいた場合に、*(e+1) にアクセスしてしまうと、上のC言語プログラムでは発生するはずがなかったSEGVが発生するおそれがある。しかし、ループ回数が、*e の値に依存しているので、「*e までは必ず読まないといけない、*(e+1) には絶対にアクセスしてはいけない」というプログラムになっている。


SSE、AVXだとどうするか、というと、まあ32byte境界にそろえてればページ境界またがないので、事前に32byteに揃うまでポインタをすすめたりする。めんどくさい。


これに対して、SVEでは、「ページ例外起こるポインタまで読む」という命令がある。そしてこの命令は、FFRというレジスタに、どこで例外が発生するか、というのが記録されるようになっている。(というように読める。詳細書いてないのでまちがってるかもしれない)

    ldff1b    z0.b, p0/z, [x1]

とすると、x1 から読めるだけ読んで、z0.b にそれを入れる。どこでフォルトしたかが、FFR というレジスタに保存される。

これに加えて、ldff1b は、マスクが立ってる要素の中で、先頭の要素がフォルトすると、例外が発生するので、えーと説明しづらいので書かないけど(書かないのか)これをうまく使うことで、上のstrlenも、

  • e が正しく領域を指していて、かつ正しく'\0'終端されてる場合は長さを返す
  • e が0終端されておらずページフォールトが発生する場合に、もとのC言語と同じようにページフォルトが出る
  • e が例外ページを指していて、かつ e+1 が割り当てられてるページを指していて、 *(e+1) が '\0' の場合にもC言語と同じようにページフォルトが出る

というのが、実現できるようになっている。例外処理まで含めてC言語と意味が変わらないのは、自動ベクトル化において非常に重要なんだけど、その理由は書かないから各自で調べて。

リストをたどる処理

飽きてきたので適当に書くと、リストを辿る処理はどうやっても最初スカラでポインタをたどらないといけないけど、ベクトル中の各要素に対してイテレーションするのがうまく書けるので、リストを辿ったのを簡単にベクタに詰められる。

(ベクタ長が固定なら、アンロールして、要素位置即値でpinsrdみたいなのをやる処理だが、SVEではベクタ長が可変なので、要素数分、事前にアンロールするとかが書けないので必要なのだと思う)

デメリット (?)

と、いうように、一見素晴らしいように見えるけど、疑問点もある。

http://d.hatena.ne.jp/w_o/20150423#1429775436 の、「現代の SIMD は正確には SIMD ではない」で書いたように、今のSIMD命令セットって、ベクタ長に依存したレジスタ内演算みたいなのが結構あるんだよね。単純なのだと水平加算、シャッフル、難しいのだと、SSE4.2 の文字列処理みたいなの。


あとサイズが変わる型変換とかも地味にSIMD的には美しくなくて、ISAごとにブレがある。


こういうのって、ベクタ長が決まってないと書けないと思うんだよね。


例えば、AVXで、 8要素のfloat から最大値1個求める、とかだと、

     // 8要素 → 4要素 reduction
     v1 = _mm256_shuffle_ps(v0, v0, foobar) // シャッフルパターン考える面倒だからなんかうまくならべてると思って
     v0 = _mm256_max_ps(v0, v1);

     // 4要素 → 2要素 reduction
     v1 = _mm256_shuffle_ps(v0, v0, foobar);
     v0 = _mm256_max_ps(v0, v1);

     // 2要素 → 1要素 reduction
     v1 = _mm256_shuffle_ps(v0, v0, foobar);
     v0 = _mm256_max_ps(v0, v1);

     return _mm_cvtss_f32(_mm256_extractf128_ps(v0,0));

とかなるよね。こで、「3回max命令並べれば8要素から1要素とれる」というのは、「8」というベクタ長がわかってるから取れるけれども、可変長だといくつ命令ならべたらいいかわかんなくね?まあ log2(VL) 取れればいいのかな?


あとシャッフルとか定義が難しくなると思う。bgr2gray は SIMD にするときは、 3とベクタ長の最小公倍数分の要素をとってきて、それをシャッフルするのが最速なのだけど、ベクタ長わかってないとやりづらくない?


そういうのとかどうするかという解説は特に見当たらない。(まあそもそもベクトル計算機向けみたいな演算しかしないと決めてて捨ててるかもしれない)