2013年5月6日月曜日

リアルタイムプログラミングの最適化

というわけでリアルタイムシステムの前提が確認できたところで技術的な問題ーー実際の最適化手法の話に移りましょう。

遅い命令を速い命令に変える


アナクロな話題ですが、たとえば除算命令は大抵のプロセッサで遅いことが解っています。
だからわざとらしい例では、

x = y / 8;

は共に整数型である場合は x = y >> 3 ; に置き換えられることが解っています。
ですが、さすがにこれくらいの最適化はコンパイラが自動で行ってくれるというのもまた事実です。
しかし x, y が浮動小数点型であった場合は話が別です。

では x = y / 2; より x = y * 0.5; がよいというのは本当でしょうか?
これは昔から言われていることですので事実はどうあれ、手が勝手に x = y * 0.5 と書いてしまう気持ちはわかります。
浮動小数点なら x86 では fdiv/fmul 命令(最近は divss/mulss) が使われるはずで、 fdiv は fmul より 30 cycle ほどの多いレイテンシがあります。
ですから大抵の場合は本当です。
しかし定数の事前演算はコンパイラにとって得意な最適化の部類であります。これくらいであれば最適化コンパイラが使える場合は自動で最適なコードが出力されると考えられます。


整数の除算、整数/浮動小数点のキャストに気をつける


ほぼ ARM 固有の話です。
ARM が整数の除算命令やキャスト命令を持っていない(いや、コプロにはあるよ)せいで、コンパイラの開発者は泣いています。
コンパイラの開発者が泣く時、利用者も泣くのです。
なので整数除算とキャストをしれっと C のプログラマが書いてしまうと、コンパイラは代理のコードを一生懸命出力します。
で、これが大抵遅いです。 gcc をお使いなら、ライセンスを一通り読んでください。 GNU Runtime Exception という特殊条項でしょう?これの解釈だって残念ながら一つじゃありません。……おっと、これは最適化よりも厄介な問題ですね。

浮動小数点コプロにしましょう。NEON 命令セットだったらキャストなんか一発なのです。
逆に言えば、ベクトル化しなくても SIMD 命令を使って最適化できる余地は案外あるよということなのです。
この場合はコンパイラに NEON 使用許可を出せば、コンパイラがいい感じにやってくれるでしょう。
NEON がないプロセッサもターゲットにしているなら……残念。他のところで速度を稼ぎましょう。

一つの命令で複数データを処理する


y0 = x0 * a;
y1 = x1 * a;
y2 = x2 * a;
y3 = x3 * a;

という例を考えましょう。全て float (単精度浮動小数点)とします。
これは乗算の右辺が変数ですのでコンパイラによる最適化はそもそも期待できません。
4回の乗算ですが、例えば、 [x0, x1, x2, x3] * [a, a, a, a] というベクトル演算と捉えれば一回の乗算と同じです。
こういう時は SIMD 命令の利用を検討しましょう。
x86 なら SSE2/AVX, ARM なら NEON, PPC なら Altivec です。

SIMD は Single Instruction Multi Data の略で一つの命令で複数データを処理できる命令体型です。
(お望みであれば一つのデータだけを処理することもできます)
具体的にコレはベクトルデータのことを意味します。
例えば、ピクセルデータの処理には 8bit 整数の 16 要素ベクトルから 16bit 8要素ベクトル、 32bit 4要素ベクトルまで幅広く使います。
普通の幾何学ベクトル演算なら 32bit 浮動小数点の 4 ベクトルでしょう。
128bit ベクトルレジスタを持つプロセッサなら 64bit 倍精度浮動小数点 2 要素ベクトルまでですが、 256bit ベクトルレジスタを持つ AVX 対応プロセッサなら 64bit 倍精度浮動小数点 4 要素ベクトルもいけます。

清水さんの4次補間関数での例を見てみましょう。

元のコードは C++ で書くとこんな感じであります。
(stroke オブジェクトへの add() 操作はシリアルバッファへの書き出しという風にぼかしていますが、当たらずとも遠からずってところでしょうよ)

    for(int i = 0; i < num; i++, deltaT += invertNum){
        x =  ((xfact1n + xfact2) * deltaT + xv0) * deltaT + xp1;
        y =  ((yfact1n + yfact2) * deltaT + yv0) * deltaT + yp1;
        p =  ((pfact1n + pfact2) * deltaT + pv0) * deltaT + pp1;
       
        xfact1n += xFact1step;
        yfact1n += xFact1step;
        pfact1n += xFact1step;
        //stroke.add(x,y,p);
        *(buf ++) = x;
        *(buf ++) = y;
        *(buf ++) = p;
        *(buf ++) = 0.f;
    }

SSE2 命令を使ってこれを最適化します。
使いたい命令が解っている場合、イントリンジックス (intrinsics) というものを使って C/C++ のコードで命令を指定することができます。

gcc の場合、 emm_intrin.h をインクルードしてください。

    __m128 fact2 = {xfact2, yfact2, pfact2, 0.f};
    __m128 v = {xv0, yv0, pv0, 0.f};
    __m128 p1 = {xp1, yp1, pp1, 0.f};
    __m128 fact1 = {xfact1n, yfact1n, pfact1n, 0.f};
    __m128 step = {xFact1step, yFact1step, pFact1step, 0.f};
    __m128 delta = _mm_set1_ps(0.f);
    __m128 inv = _mm_set1_ps(invertNum);

    for(int i = 0; i < num; i++){
        __m128 r = _mm_add_ps(_mm_mul_ps(_mm_add_ps(_mm_mul_ps(_mm_add_ps(fact1, fact2), delta), v), delta), p1);
        delta = _mm_add_ps(delta, inv);
        fact1 = _mm_add_ps(fact1, step);
        *(((__m128*)buf) ++) = r;
   }

_mm_add_ps() は二つの float4 ベクトルの加算、 _mm_mul_ps() は float4 ベクトルの積算です。
積和命令が大抵あるんですけど……SSE には見当たりませんね。
_mm_set1_ps() は所謂 splat 命令で、スカラー値をベクトルスロットへコピーします。
でも、意外と簡単でしょう?

ベクトル化前のアセンブリコードは、ループ内だけで以下のようなものでした。

LBB1_2:
    movaps    %xmm4, %xmm9
    addss    %xmm10, %xmm9
    mulss    %xmm5, %xmm9
    addss    %xmm0, %xmm9
    mulss    %xmm5, %xmm9
    addss    -24(%rbp), %xmm9
    movss    %xmm9, -12(%rbx)
    movaps    %xmm2, %xmm9
    addss    %xmm8, %xmm9
    mulss    %xmm5, %xmm9
    addss    -12(%rbp), %xmm9
    mulss    %xmm5, %xmm9
    addss    -20(%rbp), %xmm9
    movss    %xmm9, -8(%rbx)
    movaps    %xmm1, %xmm9
    addss    %xmm11, %xmm9
    mulss    %xmm5, %xmm9
    addss    %xmm6, %xmm9
    mulss    %xmm5, %xmm9
    addss    %xmm3, %xmm9
    movss    %xmm9, -4(%rbx)
    movl    $0, (%rbx)
    movss    -16(%rbp), %xmm9
    addss    %xmm9, %xmm4
    addss    %xmm9, %xmm2
    addss    %xmm9, %xmm1
    addss    %xmm7, %xmm5
    addq    $16, %rbx
    decq    %rax
    jne    LBB1_2

ベクトル化の後は以下のようにシンプルになります。

LBB1_2:
    movaps    %xmm2, %xmm3
    addps    %xmm6, %xmm3
    mulps    %xmm1, %xmm3
    addps    %xmm8, %xmm3
    mulps    %xmm1, %xmm3
    addps    -32(%rbp), %xmm3
    movaps    %xmm3, (%rbx)
    addps    -48(%rbp), %xmm2
    addps    %xmm0, %xmm1
    addq    $16, %rbx
    decl    %r14d
    jne    LBB1_2

なんか無駄にスタックからロードしているのは気に入らないですが。
この場合はスタック以外には buf のアドレスにしかアクセスしてませんし、これを修正した程度ではベンチマークに影響なかったのでよしとしましょう。

本来はレジスタを使い切るまでループアンロールすべきですが、今回はループ回数が補間する点の距離に応じてダイナミックに変わるためそこまではしないものとします。
[x1, x2] - [y1, y2] として[1,1]-[1000000, 1000000] のような極端なデータを与え、 282843 回のループになるようにしています。これを10回ループさせて簡単なベンチを取得しました。

すると、ベクトル化前に 11ms だったコストがベクトル化後には 5ms になっていました。2.2倍も速くなったわけです!
Sandybridge の i7 ではそうだったというわけで、例えば NEON を実装した ARM ではもっと大きな伸びが予想されます。
経験的には 2.5 倍はカタいです。(もっとも NEON の実装がしょぼく、殆ど速くならないバカ実装のバカプロセッサもあるにはありますが……。その辺で売ってるようなやつは大丈夫でしょう)

でも実際に使うには飽和演算や、ベクトルレジスタへのロード/ストアについてはアドレスアラインメント,  permute (ベクトル要素の並べ替え) や splat など結構事前に要求される知識があるもんです。
そのへんには注意してくださいね。

ところで蛇足になりますが、ベクトル化する前のコードをよく見ると、


        *(buf ++) = 0.f;

という謎の行が追加されてます。  stroke.add() が未定義なのをいいことに、単純な比較が可能なように追加させてもらいました。
ベクトル命令は 128bit レジスタ前提なので 16byte ごとにしか読み書きできないからです。
メモリの読み書きを 16 の倍数にする(メモリのアドレスも)というのはベクトル最適化ではごくごく当たり前のことです。
ミスアラインアクセスも不可能なわけじゃないですが、アラインがマッチする場合についてベクトルレジスタの読み書きでは二倍程度遅いと思っていてください。

ループをアンロールする


ループを展開して条件分岐を削減します。
ですがループのアンロールが最適化にとって有意かどうか、実は常に議論の対象となります。
なぜなら条件分岐の予測はプロセッサにとって重要なテーマですし、昨今のプロセッサにとっては多くの場合ブランチミスプレディクションよりロードストアのほうがずっとパフォーマンスにインパクトがあるから無意味なことも多いのです。

for (int i = 0; i < 32; i ++)
   foobar(x[i]);

という極めて単純なパターンでは、インライン展開しないとした場合、何回ほどループを展開すべきでしょうか。
仮に4とすると

for (int i = 0; i < 32; i += 4) {
   foobar(x[i]);
   foobar(x[i+1]);
   foobar(x[i+2]);
   foobar(x[i+3]);
}

となります。この場合条件分岐の回数は1/4になります。
しかしながら関数の呼び出し毎に x の配列から値をロードしているのはそのままであることに注意してください。

コンパイラはループ内のコードが充分小さいなら結構積極的にループを展開します。ループ内のコードの量は、分岐予測までの最低サイクル数、命令キャッシュの量に依存します。
そしてループ回数が可変の場合も、予測が可能である場合かなり頑張って予測します。

つまりループのアンロールは考慮すべき条件が多過ぎて、コンパイラに任せるのが無難です。
しかしながら、ある条件下ではプログラマが自分で展開したほうがよい場合もあるのです。
どういう場合でしょうか?
それは以下のような場合です。
  1. ロード/ストア回数を削減できる場合
  2. レジスタが余っている場合
1の場合、要するにメモリアクセスが実は極めて遅くなり得るという事実に注目した最適化です。

たとえば上記のループでは

for (int i = 0; i < 32; i += 4) {
   register int x0, x1, x2, x3;
   x0 = x[i];
   x1 = x[i+1];
   x2 = x[i+2];
   x3 = x[i+3];   foobar(x0); foobar(x1); foobar(x2); foobar(x3);
}

というようなコードが書けるかも知れません。命令のプリフェッチは別ですが、メモリからのロードを1/4にできたことを意味します。
もっともデータキャッシュは一般に16バイト以上ありますので、キャッシュアウトしてなければこんなことをしなくてもそこそこ速いのですが、キャッシュが汚染されるような場合にはこちらのほうが速いことがあります。
この例ではループを展開したことに大した意味はありません。
しかしながら別の方法を併用することでスループットを増大することができるのです。
次のループで使うデータを先にロードしておくといった工夫でスループットを上げる方法や、場合によってはキャッシュ制御命令を使ってストリームロードを行うなど様々な工夫を施すことができます。

当然、データの先読みというのは保存しておくためのレジスタが充分余っていることが前提です。
レジスタが沢山あるプロセッサの場合はぜひ検討しましょう。
例えば、SPU にはレジスタが128個ありました。そこまでではなくとも SSE2, およびSandybridge 以降搭載されている AVX 命令では XMM/YMM レジスタを 16 個利用できますし、 NEON では 16個のベクトルレジスタを ABI に配慮することなく自由に使えます。

ところで、上記のようなデータの先読みをコンパイラが自動的に行うことはできないのでしょうか?
残念ながら、基本的にはできません。先読みに関してはプログラマが明示的に最適化を行わねばならないことが殆どです。
なぜなら foobar() 関数の副作用で x の配列の中身が書き換えられるかも知れないからです。
x がグローバル変数で、 foobar() から見えるならコンパイラは先読みを行いません。
詳しくは後述します。

……とこのように細かい話が続くと「ああ、じゃあコンパイラ任せね」と思うかも知れません。
ところがあなたが使っているソフトウェアの殆どは、こういう最適化を行っています。
ブラウザが使っている cairo を始め、某ブラウザプラグインもそうです。基本的に画像を扱うソフトウェアでループのアンロールを用いて複数ピクセルを一度にロード/ストアする最適化をしていないソフトというのを、僕は見た事がありません。
画像を扱うソフトはほぼ100%、4pixel (時には 16pixel) 同時処理するためループをアンロールしています。
でもその動機はブランチミスプレディクションを減らすためではなく、データのロードストアを減らし、ストリーミング命令を駆使し、キャッシュのヒット率を上げてスループットを向上させるためです。
お間違えなきよう。

インライン展開する


ループアンロールに関する話は少々難しかったでしょうが、インライン展開は簡単です。
C++/C99 では関数をヘッダに書いて inline と関数の前に書くだけです。
マクロと同じではありません。マクロは必ず展開されますが、インライン関数は展開されないかも知れません。
マクロは関数ではありませんが、インライン関数は関数です。(この違いは実は大きいもので、マクロではリンクエラーになるようなコードもインライン関数では平気です)
インライン関数は、昔の VC のコンパイラがだらしなかったせいで未だに色々都市伝説がありますが煩わしいのでここでは無視します。

間抜けな会社のコーディングルールではヘッダに実装を書くことを禁止していることもあるでしょうが、そういうところでは仕事をしないほうがいいと思います。

ただやはりインライン展開されて良いコードとされて困るコードというのはあります。あまり大きなコードは避けるべきです。
小さい関数であれば、インライン展開することで飛躍的にコードサイズを減らす事もできるのです。
ABI のコーリングコンベンションという、関数を呼び出すために守らなければいけない決まりというのがプラットフォームによって決まっていまして、これが余りにも厳しいような場合では、積極的にインライン展開してかまいません。
インライン展開されたコードはもはやコーリングコンベンションを無視したものです。コードサイズの削減と同時にパフォーマンスも向上します。
個人的な経験では、PPC でインライン展開を完全に禁止すると 10MB くらいになってしまうコードが、インライン展開をすることで 9MB 程度まで押さえられたこともあります。

PPC 以外でそこまで ABI が大きい例というのは知りませんが、 ARM や x86 であっても、ただの薄いラッパ関数などなら迷わずインライン展開すべきです。
リーフ関数の場合は呼び出し側の削減にしかならないので(コードサイズという点では)逆に大きな効果がないかも知れません。

小さいコードは効率がよいと思われるかも知れませんが、逆ということがあります。
例えば他の関数を呼び出すだけの関数といった薄いラッパーでは、コンパイラはそれ以上最適化する余地も殆ど残されていないのです。
レイテンシーを隠蔽するために命令同士を数サイクル離すのは(アセンブリレベルの最適化において)基本中の基本ですが、そもそも命令が絶対的に少なければコンパイラは諦めてしまいます。
かといってあまりに巨大なループは命令キャッシュから外れ、インライン展開により巨大になった関数ではプロファイラの運用やデバッグを困難にしてしまいます。

適度な大きさを保つことが重要です。
ラッパーやリーフから積極的にインライン展開し、マクロよりもコンパイルオプションで展開を制御できるインライン関数を使いましょう。


エイリアッシングの問題を避ける


void copy(void* dst, void* src, size_t len)
{
     void* end = (char*)dst + len;
     while (dst < end)
          *(((char*)dst) ++) = *(((char*)src) ++);
}

なーんにも考えずに書いたコピーのコードです。とりあえず前述のループアンロールの指針に基づいてループを展開しましょう。


void copy4w(void* dst, void* src, size_t len)
{
    void* end = (char*)dst + len;
    while (dst < ((char*)end + 4)) {
        *(((int*)dst) ++) = *(((int*)src) ++);
    }
    while (dst < end) {
        *(((char*)dst) ++) = *(((char*)src) ++);
    }
}

(見易さのためにアラインメントの評価をさぼっているのでこれはダメなコードですがそれはさておき……)
さて、このコードは期待通りのパフォーマンスが出るでしょうか?
ロードストアがレジスタ長に一致するのでたぶんパフォーマンスは出ます(というか下がらなくなります。ですが最適化前の copy() とは意味が違っています
なぜかというと、 dst, src が示す領域に重複した部分があると動きが違ってしまうからです。
このことは memcpy と memmove では memcpy は最適化によって高速化できるが memmove はそうではないという事実に結びついています。
(最適化できる可能性がある、というだけで全ての実装で memcpy のほうが速いとは限りません。同じかも知れません)


同様の問題を更に見てみます。

void copy4(void* __restrict__ dst, void* __restrict__ src, size_t len)
{
    void* end = (char*)dst + len;

    while (dst < ((char*)end + 4)) {
        *(((char*)dst) ++) = *(((char*)src) ++);
        *(((char*)dst) ++) = *(((char*)src) ++);
        *(((char*)dst) ++) = *(((char*)src) ++);
        *(((char*)dst) ++) = *(((char*)src) ++);
    }
    while (dst < end) {
        *(((char*)dst) ++) = *(((char*)src) ++);
    }
}

これは愚直にインライン展開した場合です。
展開した部分のアセンブリを見てみましょう。

LBB2_3:
        movb    (%rsi,%r8), %r9b
        movb    %r9b, (%rdi,%r8)
        movb    1(%rsi,%r8), %r9b
        movb    %r9b, 1(%rdi,%r8)
        movb    2(%rsi,%r8), %r9b
        movb    %r9b, 2(%rdi,%r8)
        movb    3(%rsi,%r8), %r9b
        movb    %r9b, 3(%rdi,%r8)
        addq    $4, %r8
        cmpq    %r8, %rcx
        jg      LBB2_3

レジスタへのロードからメモリへのストアが近過ぎます。
ロードしたレジスタを実際に使うまでに命令を稼いでレイテンシを隠蔽するというアセンブリの最適化の基本方針が生かされていません。
レジスタはまだ6つ余ってますので、どうにかならないのでしょうか。
せめてこんな感じ
        movb    (%rsi,%r8), %r9b
        movb    1(%rsi,%r8), %r10b
        movb    2(%rsi,%r8), %r11b
        movb    3(%rsi,%r8), %r12b
        movb    %r9b, (%rdi,%r8)
        movb    %r10b, 1(%rdi,%r8)
        movb    %r11b, 2(%rdi,%r8)
        movb    %r12b, 3(%rdi,%r8)
の最適化をコンパイラが勝手にやってくれてもいいのになぁ〜〜と思うかも知れません。
ですがこれも、前述した src/dst が互いに重複したアドレスを指しているかも、という可能性のために、コンパイラは決してこのような最適化を行いません。
この例では、 mov の順番を多少入れ替えたところでレイテンシを隠蔽する効果は殆ど期待できませんけど、もし仮に効果があるとしても、コンパイラは最適化しません

同じメモリ領域を指す複数の異なるポインタをエイリアスと呼び、最適化においては重要なテーマです。
エイリアッシングが発生すると、コンパイラは(仮に可能だったとしても)最適化を行えなくなるのです。

こういうときには、 __restrict__ キーワードを使って src と dst が互いにエイリアスとはならないことをコンパイラに示します。
(もちろん、本当にエイリアスにならない場合だけです。仕様上エイリアスを許容する memmove ではだめです)
void copy4(void* __restrict__ src, void* __restrict__ dst, size_t len)
{


エイリアッシングによる不要なロードを避ける


再びエイリアッシングの話題です。
話を簡単にするために、まず簡単な例から見てみます。
これも少々わざとらしい例ですが……。

void copyi(int* dst, int* src, size_t* len)
{
    size_t l = 0;
    for (size_t i = 0; i < *len; i ++) {
        *(((int*)dst) ++) = *(((int*)src) ++);
        l ++;
    }
    *len = l;
}

長さをアドレス渡ししています。これくらいは戻り値で返せよ!と思うでしょうが、まぁお付き合い下さい。
アセンブリを見てみましょう。

LBB4_2:
        movl    (%rsi,%rax,4), %ecx
        movl    %ecx, (%rdi,%rax,4)
        incq    %rax
        cmpq    %rax, (%rdx)
        ja      LBB4_2
LBB4_3:
        movq    %rax, (%rdx)

len に渡したアドレスは rdx に入ってます。rsi が  src, rdi が dst です。
大体予想通りかと思いますが、もしかすると一点奇妙なロードがあるとお思いかもしれませんね。
        cmpq    %rax, (%rdx)
copyi() の len は一回渡ったら関数の実行中に変更されないはず。
ならなんで比較の度にわざわざメモリから値をロードしているのでしょうか?
まぁ、 C のコードが自分で len から値を引っ張ってループの比較に使えばよいのですが……コンパイラの最適化とはこの程度のこともできないのでしょうか?
実はコンパイラは目ざとくも、ここでもエイリアスの問題が発生する可能性に気付いているのです。
len が dst のエイリアスーーつまり、 len のインスタンスが、 copyi() が書き換える dst の領域に配置されている可能性があることを気にして毎回ロードしているのです。
つまり呼び出し元が
copyi(dst, src, (size_t)dst[0]);
とやらかしているんじゃないか、という配慮ですね。
この場合も __restrict__ キーワードが使えます。
void copyi(int* dst, int* src, size_t* __restrict__ len)
{
としてコンパイルした結果をご覧ください。
        movq    (%rdx), %rax
        testq   %rax, %rax
        je      LBB4_4
        xorl    %ecx, %ecx
        .align  4, 0x90
LBB4_2:
        movl    (%rsi,%rcx,4), %r8d
        movl    %r8d, (%rdi,%rcx,4)
        incq    %rcx
        cmpq    %rcx, %rax
        ja      LBB4_2
LBB4_3:
        movq    %rcx, (%rdx)
        popq    %rbp
        ret

len はループに入る前に rax にロードされ、 cmpq で比較する際にいちいちロードされたりはしません。

まぁでも、こんな変なコードは書かないから俺関係ないよ、という方がほとんどでしょう。

では以下の例はどうですか?

struct wstr {
    short len;
    short* ptr;
};

void copywstr(wstr& dst, wstr& src)
{
    for (short i = 0; i < src.len; i ++)
        *(dst.ptr ++) = *(src.ptr ++);
    dst.len = src.len;
}

ずっとありそうですね。
事前に short l = src.len; としておくより1行少なく、よっぽどスマートなコードに見えます。
……が、これもエイリアスの問題を踏むのでコンパイルした結果は残念ながらループのたびにロードをしています。
小さいコードが効率よいとは限らないのです。
(無駄かどうかは本当にエイリアスを意図したかどうかによります)

エイリアスの場合の振る舞いを正しくするため、 STL の実装では意図的に上記のようなものが多いように思います。
STL を使うなという話じゃありません。真似するときは本当にエイリアスの場合があるかどうか考えてやりましょうということです。

エイリアスの問題に慌てて取り組んだところで、プログラムの全体が何パーセントも向上することは期待できませんし、ホットスポットに対応するにはもっとずっと効果的な方法があるはずと考えるのが普通です。
それどころか、本来エイリアスを受け入れるべきところを潰してしまい、バグを生む可能性のほうがずっと高いです。

ですが——ARMのようなプロセッサで何百KBにもなるデータを処理する場合を考えてみてください。
ARM の実装により、 LRU キャッシュアルゴリズムを持たない実装というのが結構あります。そういうバカキャッシュのプロセッサでキャッシュの汚染が発生するというのはパフォーマンスを大きく下げる原因になります。
(4way キャッシュくらいあるのは普通なのでコピーくらいは救われるかも知れませんが、他に src/dst 以外に係数テーブルやらスタックやらで消費されるでしょう)
L1/L2キャッシュミスしたら数十usから百数十usのオーダーで時間が無駄になることを覚悟せねばなりません。それがループ回数だけ積み重なるとあっという間に 1ms なくなってしまう場合もあるのです。

遅いコードを速くするのは最適化の基本ですが、リアルタイムプログラミングにおいては遅くなる可能性を徹底的に排除しなければいけないコンテキストが存在します。
そのようなコードにおいては気をつけましょう。

-fno-strict-aliasing を回避する


最近の最適化コンパイラは、非互換型のキャストに際して発生する偽のエイリアッシングを無視して最適化することがあります。
これをこのまま使うと予期せぬバグが顕在化することも少なくありません。
というか、すごい発生します。

そこで、最適化を抑制するためのコンパイルオプションとして、 gcc では -fno-strict-aliasing というのがあります。
これは最適化を保守的にする代わりに、非互換キャストを行った場合でもエイリアッシングを無視した先読みをさせなくなり、ルーズなキャストまみれのコードが問題を起こすのを避けることができます。

経験上、オープンソースの大半のプロジェクトで -fno-strict-aliasing がついてます。
パフォーマンスの観点からは避けるべきです。
ホットスポットの翻訳単位から順番に外していけるようにしましょう。

エイリアッシングの問題はそもそもわかりにくく、更に非互換キャストでのみ発生する偽のエイリアスなんて人間が自分で見つけるのは困難です。

そこで -Wstrict-aliasing オプションでコンパイルすると、最適化を妨げるエイリアッシングを警告してくれるようになります。


インラインアセンブリの運用


インラインアセンブリを書いていると、解ってない人からは
「今時アセンブリなんて……バカじゃないの?」
と言われます。

一方、よく解っている人からは
「インラインアセンブリなんか使うな。フルアセンブリで書け」
と言われます。

アセンブリが時代遅れなんてのはそりゃー昔から言われてます。
最近の複雑化したプロセッサにおいて人間の書いたアセンブリがコンパイラの最適化に勝てるなんて、そういう理由から書くわけじゃないのです。
ピンポイントで、どうしてもここだけコンパイラの最適化じゃ足りないとか、どうしても使うべき命令が他にあるというような止むに止まれぬ事情から使うのです。

で、そういう状況はみなさんが思うよりずっとあります。
cairo, dlmalloc, avm+, webkit, fiber……名前は伏せるけど商用アプリケーションの数々、これらは全部使ってます。人気のあるソフトでインラインアセンブリを全く使っていない例というほうが少ないです。 zlib とか Freetype は使ってないかな。 STLport とか DB の実装とかでも使ってなさそう。
必ずといっていいほどどこかに登場します。

僕はデバッグ目的で書く事も多いのですけど、その目的は皆パフォーマンスのためです。
時代遅れと誹られつつも100%全員がインラインアセンブリのお世話になっている。

または、時代遅れだよなと鼻で笑った当人が裏ではこっそりインラインアセンブリを書いている。アイツも、……アイツもだ!

まぁ、そうはいってもコンパイラのイントリンジックスが充実しているお陰で、本当にインラインアセンブリを書かねばいけないケースというのはだいぶ減ってきたと思います。

ところで、本当に解っている人がインラインアセンブリを嫌う理由は何でしょうか?
それはインラインアセンブリのオペランド制約が gcc info を見ても載っておらず、 gcc のソースを見てその通りにやっても上手くいかずイライラすることがとても多いからです。
コンパイラとプロセッサ、そしてそれぞれのバージョンへの依存がとても大きいのですね。
逆説的ですが、保守のことを考えるとインラインより関数全部をアセンブリで書いたほうがマシ、ということすらあり得ます。

じゃあ実際、どういうときに使うの?ということをここでは書いておきます。

ビットを数える

値の何ビット目が立っているか?値の中に立っている立っているビットはいくつあるか?
そういうことを調べたいのはよくあります。
hacker's delight の実装を使っている例もありますが dlmalloc などではプロセッサの命令を使うようなコンフィギュレーションも存在します。
popcount 命令や bcl/bcr 命令なんかですね。
x86 ではイントリンジックスにありますので不要でしょう。

SIMD命令

計算そのものはイントリンジックスで行えることが殆どですが、特定レジスタへの値のロードなどではイントリンジックスが使えないもあります。
そもそもイントリンジックスがなければしょうがありません。
例えば AVX 命令を使いたい!と思っても手元の gcc4.2 では対応してません。
gcc を気軽にアップデートできない事情は皆さんよくご存知でしょう。
そういうときには活躍します。

スピンロック

マルチスレッドプログラミングにおいて、マシン固有のスピンロックは人気があります。
もはやマルチスレッドプログラミングでは必須といってよいでしょう。
mutex/semaphore などのシステムコールと異なり特権モードにスイッチする必要がないので非常に軽いです。
(その代わり使うときにはデッドロックに注意せねばなりませんが)

大抵の場合、イントリンジックスにはなくてそれ以外の組み込み関数にはあるかも知れません。
処理系が持っているならそれを使いましょう。だってテストするの面倒じゃないですか。狙った通りにバスロック横取りしたりキャッシュラインロックロストしないとならないんですから。
でも、そうでない場合や処理系依存の実装を使いたくない(スピンロックを使う以上、可及的速やかにクリティカルセクションを通過する場合なんですからそういうこともあるでしょう)場合自分で作るほかありません。

ABIを無視、またはABIに強烈に依存するコード

余程レアケースとは思いますが VM の実装なんかでは結構あります。
レジスタのアサインメントは ABI (Application Binary Interface) により固定なので、コーリングコンベンションを利用したデバッグルーチンや特殊なサンク関数などはアセンブリで書くよりありません。



0 件のコメント:

コメントを投稿