web-dev-qa-db-ja.com

キャリーフラグを使用した効率的な128ビット加算

C++コードの非常に内側のループで128ビット整数カウンターを使用しています。 (不適切な背景:実際のアプリケーションは、通常のグリッドで有限差分方程式を評価します。これには、大きな整数を繰り返しインクリメントすることが含まれます。小さな丸めは回答に影響を与えるほど蓄積されるため、64ビットでも十分な精度ではありません。)

整数を2つの64ビットunsignedlongとして表しました。これらの値を128ビット定数でインクリメントする必要があります。これは難しいことではありませんが、ローワードからハイワードへのキャリーを手動でキャッチする必要があります。

私は次のような作業コードを持っています:

inline void increment128(unsigned long &hiWord, unsigned long &loWord)
  {
    const unsigned long hiAdd=0x0000062DE49B5241;
    const unsigned long loAdd=0x85DC198BCDD714BA;

    loWord += loAdd;
    if (loWord < loAdd) ++hiWord; // test_and_add_carry
    hiWord += hiAdd;
  }

これはタイトでシンプルなコードです。できます。

残念ながら、これは私の実行時間の約20%です。キラーラインはそのloWordテストです。それを削除すると、明らかに間違った答えが返されますが、実行時のオーバーヘッドは20%から4%に低下します。そのため、キャリーテストは特に高価です!

私の質問:C++は、GCCの拡張機能としても、ハードウェアキャリーフラグを公開しますか?実際にコンパイルされた命令がhiWordの加算に最後のキャリー命令を使用した加算を使用した場合、上記のtest-and-add-carry行なしで加算を実行できるようです。 test-and-add-carry行を書き直して、コンパイラーに組み込みオペコードを使用させる方法はありますか?

40
Randall Meyers

実際、コードを注意深く書くと、gccは自動的にキャリーを使用します...

現在のGCCは、hiWord += (loWord < loAdd);add/ adc (x86のadd-with-carry)に最適化できます。 この最適化はGCC5.3で導入されました。

(編集者注:もちろん難しい部分は、キャリーインとキャリーアウトを備えた正しい全加算器を書くことです;それはCでは難しいことであり、GCCはそれを最適化する方法を知りません私は見た。)

関連項目: https://gcc.gnu.org/onlinedocs/gcc/Integer-Overflow-Builtins.html は、符号なしまたは符号付きオーバーフローの検出からの実行を提供できます。


GCC4.5のような古いGCCは、setcを使用する代わりに、追加からのキャリーアウトで分岐またはadcし、adc(add-with-キャリー)フラグを付けます-__int128を使用した場合はaddの結果。 (または、32ビットターゲットではuint64_t)。 gccに128ビット整数はありますか? -GCC4.1以降でサポートされている64ビットターゲットでのみ参照してください。

私はこのコードをgcc -O2 -Wall -Werror -Sでコンパイルしました:

void increment128_1(unsigned long &hiWord, unsigned long &loWord)
{
    const unsigned long hiAdd=0x0000062DE49B5241;
    const unsigned long loAdd=0x85DC198BCDD714BA;

    loWord += loAdd;
    if (loWord < loAdd) ++hiWord; // test_and_add_carry                                                                                                             
    hiWord += hiAdd;
}

void increment128_2(unsigned long &hiWord, unsigned long &loWord)
{
    const unsigned long hiAdd=0x0000062DE49B5241;
    const unsigned long loAdd=0x85DC198BCDD714BA;

    loWord += loAdd;
    hiWord += hiAdd;
    hiWord += (loWord < loAdd); // test_and_add_carry                                                                                                               
}

これはincrement128_1のアセンブリです。

.cfi_startproc
        movabsq     $-8801131483544218438, %rax
        addq        (%rsi), %rax
        movabsq     $-8801131483544218439, %rdx
        cmpq        %rdx, %rax
        movq        %rax, (%rsi)
        ja  .L5
        movq        (%rdi), %rax
        addq        $1, %rax
.L3:
        movabsq     $6794178679361, %rdx
        addq        %rdx, %rax
        movq        %rax, (%rdi)
        ret

...そしてこれはincrement128_2のアセンブリです:

        movabsq     $-8801131483544218438, %rax
        addq        %rax, (%rsi)
        movabsq     $6794178679361, %rax
        addq        (%rdi), %rax
        movabsq     $-8801131483544218439, %rdx
        movq        %rax, (%rdi)
        cmpq        %rdx, (%rsi)
        setbe       %dl
        movzbl      %dl, %edx
        leaq        (%rdx,%rax), %rax
        movq        %rax, (%rdi)
        ret

2番目のバージョンには条件分岐がないことに注意してください。

[編集]

また、GCCはエイリアシングについて心配する必要があるため、参照はパフォーマンスに悪影響を与えることがよくあります...値で渡す方がよい場合がよくあります。考えてみましょう:

struct my_uint128_t {
    unsigned long hi;
    unsigned long lo;
};

my_uint128_t increment128_3(my_uint128_t x)
{
    const unsigned long hiAdd=0x0000062DE49B5241;
    const unsigned long loAdd=0x85DC198BCDD714BA;

    x.lo += loAdd;
    x.hi += hiAdd + (x.lo < loAdd);
    return x;
}

アセンブリ:

        .cfi_startproc
        movabsq     $-8801131483544218438, %rdx
        movabsq     $-8801131483544218439, %rax
        movabsq     $6794178679362, %rcx
        addq        %rsi, %rdx
        cmpq        %rdx, %rax
        sbbq        %rax, %rax
        addq        %rcx, %rax
        addq        %rdi, %rax
        ret

これは実際には3つの中で最も厳しいコードです。

... OKなので、実際にキャリーを自動的に使用したものはありません:-)。しかし、条件分岐は避けています。これは遅い部分だと思います(分岐予測ロジックは半分の時間で間違ってしまうため)。

[編集2]

そしてもう1つ、ちょっとした検索をしているときに偶然見つけました。 GCCには128ビット整数のサポートが組み込まれていることをご存知ですか?

typedef unsigned long my_uint128_t __attribute__ ((mode(TI)));

my_uint128_t increment128_4(my_uint128_t x)
{
    const my_uint128_t hiAdd=0x0000062DE49B5241;
    const unsigned long loAdd=0x85DC198BCDD714BA;

    return x + (hiAdd << 64) + loAdd;
}

これのためのアセンブリはそれが得るのとほぼ同じくらい良いです:

        .cfi_startproc
        movabsq     $-8801131483544218438, %rax
        movabsq     $6794178679361, %rdx
        pushq       %rbx
        .cfi_def_cfa_offset 16
        addq        %rdi, %rax
        adcq        %rsi, %rdx
        popq        %rbx
        .cfi_offset 3, -16
        .cfi_def_cfa_offset 8
        ret

ebxのプッシュ/ポップがどこから来たのかはわかりませんが、それでも悪くはありません。)

ちなみに、これらはすべてGCC4.5.2を使用しています。

42
Nemo

もちろん、最善の答えは、組み込みの__int128_tサポートを使用することです。

または、インラインasmを使用します。名前付き引数形式を使用することを好みます。

__asm("add %[src_lo], %[dst_lo]\n"
      "adc %[src_hi], %[dst_hi]"
      : [dst_lo] "+&r" (loWord), [dst_hi] "+r" (hiWord)
      : [src_lo] "erm" (loAdd), [src_hi] "erm" (hiAdd)
      : );

loWordは、他のいくつかのオペランドが読み取られる前に書き込まれるため、 early clobber オペランドとしてフラグが付けられます。これにより、gccが同じレジスタを使用して両方を保持するのを防ぐため、hiAdd = loWordの間違ったコードを回避できます。ただし、安全な場合は、コンパイラがloAdd = loWordの場合に同じレジスタを使用するのを防ぎます。

その初期のクラバーの質問が指摘しているように、インラインasmは本当に間違ってしまいがちです(インライン化されたコードに何らかの変更を加えた後にのみ問題を引き起こすデバッグが難しい方法で)。

x86およびx86-64インラインasmはフラグを上書きすると想定されているため、明示的な「cc」クローバーは必要ありません。

18
Stephen Canon