コンパイラが予測可能な加算ループを乗算に最適化できない(またはできない)のはなぜですか?


133

これは、質問に対するMysticialの素晴らしい答えを読んでいるときに思い浮かんだ質問です。なぜ、ソートされていない配列よりもソートされた配列を処理する方が速いのですか?

関連するタイプのコンテキスト:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

彼の答えで彼はIntel Compiler(ICC)がこれを最適化すると説明しています:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

...これと同等のものに:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

オプティマイザはこれらが同等であることを認識しているため、ループを交換し、内部ループの外に分岐を移動しています。非常に賢い!

しかし、なぜこれを行わないのですか?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

うまくいけば、ミスティック(または他の誰でも)も同様に素晴らしい答えを出すことができます。これまでに他の質問で説明した最適化について学んだことがないので、本当にありがたいです。


14
それはおそらくインテルだけが知っていることです。最適化パスを実行する順序がわかりません。そして、どうやら、それはループ交換後にループ崩壊パスを実行しません。
Mysticial 2012年

7
この最適化は、データ配列に含まれる値が不変である場合にのみ有効です。たとえば、データを読み取るたびにメモリが入出力デバイスにマップされている場合、[0]は異なる値を生成します...
Thomas CG de Vilhena '30

2
これは整数または浮動小数点のどのデータ型ですか?浮動小数点で繰り返し加算すると、乗算からの結果が大きく異なります。
Ben Voigt 2012年

6
@Thomas:データがの場合、volatileループ交換も無効な最適化になります。
Ben Voigt

3
GNAT(GCC 4.6のAdaコンパイラー)はO3でループを切り替えませんが、ループが切り替えられた場合、ループを乗算に変換します。
プロファイラ2013年

回答:


105

コンパイラは一般に変換できません

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

後者は前者がしないのに符号付き整数のオーバーフローを引き起こす可能性があるためです。符号付き2の補数整数のオーバーフローに対するラップアラウンド動作が保証されていても、結果が変化します(data[c]30000の場合、製品はラップアラウンド付きの-1294967296一般的な32ビットintsにsumなります。オーバーフローしない、sum3000000000 増加)。同じことが符号なしの数量にも当てはまり、数値が異なる場合、オーバーフローにより、100000 * data[c]通常2^32、最終結果に現れてはならない剰余法が導入されることに注意してください。

それはそれを

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

ただし、通常どおり、long longが十分に大きい場合int

なぜそれができないのか、私にはわかりません、おそらくMysticialが言ったのだと思います。

ループ交換自体は一般に有効ではないことに注意してください(符号付き整数の場合)。

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

オーバーフローを引き起こす可能性があります

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

しません。data[c]追加されたすべての要素が同じ符号を持つことを条件が保証するため、ここではコーシャです。一方がオーバーフローすると、両方がそうなります。

コンパイラがそれを考慮に入れたかどうかはあまりわかりません(@Mysticial、data[c] & 0x80正と負の値に当てはまるような条件で試すことができますか?)。コンパイラーに無効な最適化を行わせました(たとえば、2、3年前、ICC(11.0、iirc)で、signed-32-bit-int-to-double変換を使用1.0/nnましたunsigned int。出力ですが、間違っています。多くの値がより大きいです2^31


4
32Kより大きいスタックフレームを許可するオプションを追加したMPWコンパイラのバージョンを覚えています[以前のバージョンは、ローカル変数の@ A7 + int16アドレス指定を使用して制限されていました]。32K未満または64Kを超えるスタックフレームの場合はすべて正常ですが、40Kスタックフレームの場合はADD.W A6,$A000、アドレスレジスタしたワード演算でワードを32ビットに符号拡張してから加算することを忘れてしまいました。トラブルシューティングにしばらく時間がかかりました。それ以降、コードがADDスタックからA6をポップしたときにコードが実行したのは、呼び出し元のレジスタを復元することだけだったので、そのフレームに保存されました...
supercat

3
...そして、呼び出し元がたまたま気にした唯一のレジスタは、静的配列の[ロード時定数]アドレスでした。コンパイラーは、配列のアドレスがレジスターに保存されていることを知っているため、それに基づいて最適化できますが、デバッガーは定数のアドレスを知っていました。したがって、ステートメントの前に、MyArray[0] = 4;のアドレスを確認しMyArray、ステートメントの実行前後のその場所を確認できます。変更されません。コードは何かのようなものでmove.B @A3,#4あり、A3は常にMyArrayその命令が実行されたときはいつでも指すようになっていたが、そうではなかった。楽しい。
スーパーキャット2013

では、なぜclangはこの種の最適化を行うのでしょうか
Jason S

コンパイラーは、内部中間表現で未定義の動作を減らすことができるため、内部中間表現でその書き換えを実行できます。
user253751

48

この回答はリンクされた特定のケースには適用されませんが、質問のタイトルには適用され、将来の読者にとって興味深いかもしれません。

有限の精度のため、繰り返しの浮動小数点加算は乗算と同等ではありません。考慮してください:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

デモ


10
これは質問に対する答えではありません。興味深い情報(そしてC / C ++プログラマーには知っておくべきこと)にも関わらず、これはフォーラムではなく、ここには属していません。
orlp

30
@nightcracker:StackOverflowの目標は、将来のユーザーに役立つ回答の検索可能なライブラリを構築することです。そして、これは尋ねられた質問への答えです...たまたま、この答えを元のポスターに適用しないようにするいくつかの明記されていない情報があることがあります。同じ質問を持つ他の人にも適用される場合があります。
Ben Voigt

12
それはだ可能性があり、質問への答えに、タイトルなし、ではなく、質問です。
orlp

7
私が言ったように、それは興味深い情報です。しかし、今のところ、質問のトップアンサー現在の質問に答えていないというの、私にはまだ間違っているようです。これは、インテルコンパイラーが最適化しないことに決めた理由ではありません、バスタ。
orlp 2012年

4
@nightcracker:これが一番の答えだというのも私には間違っているようです。私はスコアでこれを超える整数のケースに対して誰かが本当に良い答えを投稿することを望んでいます。残念ながら、整数の場合は「できない」という答えはないと思います。変換は正当であるため、実際には「なぜそれができないのか」が残り、実際には「それは特定のコンパイラバージョンに固有であるため、ローカライズされすぎている」という理由が考えられます。私が回答した質問は、より重要な質問であるIMOです。
Ben Voigt

6

コンパイラには、最適化を行うさまざまなパスが含まれています。通常、各パスでは、ステートメントの最適化またはループの最適化が行われます。現時点では、ループヘッダーに基づいてループ本体の最適化を行うモデルはありません。これは検出が難しく一般的ではありません。

行われた最適化は、ループ不変のコードモーションでした。これは、一連の手法を使用して実行できます。


4

まあ、私が整数演算について話していると仮定すると、一部のコンパイラはこの種の最適化を行う可能性があると思います。

同時に、反復加算を乗算で置き換えるとコードのオーバーフロー動作が変わる可能性があるため、一部のコンパイラはそれを拒否する場合があります。符号なし整数型の場合、オーバーフロー動作は言語によって完全に指定されているため、違いはありません。しかし、署名されたものについては、それは可能性があります(おそらく2の補数プラットフォーム上ではありません)。符号付きオーバーフローが実際にCで未定義の動作を引き起こすことは事実です。つまり、オーバーフローのセマンティクスを完全に無視しても問題ありませんが、すべてのコンパイラーがそれを実行できるほど勇敢であるわけではありません。「Cは単なるより高いレベルのアセンブリ言語」の群衆から多くの批判を引き付けます。(GCCが厳密なエイリアシングセマンティクスに基づく最適化を導入したときに何が起こったか覚えていますか?)

歴史的に、GCCは、そのような抜本的なステップを踏むのに必要なものを備えたコンパイラーとしての地位を示してきましたが、他のコンパイラーは、言語で定義されていない場合でも、「ユーザーが意図した」動作を維持することを好むかもしれません。


私が誤って未定義の動作に依存しているかどうかを知りたいのですが、オーバーフローは実行時の問題になるため、コンパイラーは知る方法がないと思います:/
jhabbott

2
@jhabbott:場合に限っオーバーフローが発生し、その後、未定義の動作があります。動作が定義されているかどうかは、実行時まで不明です(実行時に数値が入力されると想定)。
orlp 2012年

3

今はそうです- 少なくとも、clangはします:

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

-O1でコンパイルして

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

整数オーバーフローはそれとは何の関係もありません。未定義の動作を引き起こす整数オーバーフローがある場合、どちらの場合でも発生する可能性があります。これはの代わりに使用する同じ種類の関数intですlong

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

-O1でコンパイルして

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

2

この種の最適化には概念的な障壁があります。コンパイラの作成者は、強度の削減に多くの労力を費やしています。たとえば、乗算を加算とシフトに置き換えます。彼らは乗算が悪いと考えることに慣れています。したがって、一方を他方の方向に進めなければならないケースは、驚くほど直観に反するものです。したがって、誰もそれを実装することを考えていません。


3
ループを閉形式の計算で置き換えると、強度が低下しますね。
Ben Voigt

正式にはそうだと思いますが、そのように誰かがそれについて話すのを聞いたことがありません。(しかし、私は文学では少し時代遅れです。)
zwol

1

コンパイラーの開発と保守を行う人は、作業に費やす時間とエネルギーに限りがあるため、一般に、ユーザーが最も気にかけていること、つまり、よく書かれたコードを高速コードに変換することに集中したいと考えています。彼らは、愚かなコードを高速なコードに変える方法を見つけることに時間を費やしたくないのです。それがコードレビューの目的です。高水準言語では、重要なアイデアを表現する「ばかげた」コードがあり、開発者がそれを速くするのに十分な時間をかけることができます。たとえば、ショートカットの森林破壊とストリームの融合により、Haskellプログラムは特定の種類の遅延で構築されますメモリを割り当てないタイトなループにコンパイルされるデータ構造を生成しました。しかし、そのようなインセンティブは、ループされた加算を乗算に変えることには適用されません。速くしたいなら

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.