GCCがほぼ同じCコードに対して、このように根本的に異なるアセンブリを生成するのはなぜですか?


184

最適化されたftol関数を書いているとき、私はいくつかの非常に奇妙な動作を見つけましたGCC 4.6.1。最初にコードを示します(明確にするために、違いを示しました)。

fast_trunc_one、C:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;                       /* diff */
    } else {
        r = mantissa >> exponent;                        /* diff */
    }

    return (r ^ -sign) + sign;                           /* diff */
}

fast_trunc_two、C:

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent) ^ -sign;             /* diff */
    } else {
        r = (mantissa >> exponent) ^ -sign;              /* diff */
    }

    return r + sign;                                     /* diff */
}

同じでしょ?よくGCCは同意しません。でコンパイルした後gcc -O3 -S -Wall -o test.s test.cこれでは、アセンブリ出力です:

fast_trunc_one、生成:

_fast_trunc_one:
LFB0:
    .cfi_startproc
    movl    4(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %edx
    andl    $8388607, %edx
    sarl    $23, %eax
    orl $8388608, %edx
    andl    $255, %eax
    subl    %eax, %ecx
    movl    %edx, %eax
    sarl    %cl, %eax
    testl   %ecx, %ecx
    js  L5
    rep
    ret
    .p2align 4,,7
L5:
    negl    %ecx
    movl    %edx, %eax
    sall    %cl, %eax
    ret
    .cfi_endproc

fast_trunc_two、生成された:

_fast_trunc_two:
LFB1:
    .cfi_startproc
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_offset 3, -8
    movl    8(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %ebx
    movl    %eax, %edx
    sarl    $23, %ebx
    andl    $8388607, %edx
    andl    $255, %ebx
    orl $8388608, %edx
    andl    $-2147483648, %eax
    subl    %ebx, %ecx
    js  L9
    sarl    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_remember_state
    .cfi_def_cfa_offset 4
    .cfi_restore 3
    ret
    .p2align 4,,7
L9:
    .cfi_restore_state
    negl    %ecx
    sall    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_restore 3
    .cfi_def_cfa_offset 4
    ret
    .cfi_endproc

それは極端な違いです。これは実際にプロファイルにも表示され、にfast_trunc_one比べて約30%高速ですfast_trunc_two。今私の質問:これを引き起こしているものは何ですか?


1
テストの目的で、ソースを簡単にコピー/貼り付けして、GCCの他のシステム/バージョンでバグを再現できるかどうかを確認できる要点をここに作成しました。
orlp 2012

12
テストケースを独自のディレクトリに配置します。それらをでコンパイルします-S -O3 -da -fdump-tree-all。これにより、中間表現の多くのスナップショットが作成されます。それらに番号を付けて並べていくと、最初のケースで欠けている最適化を見つけることができるはずです。
zwol 2012

1
提案2:すべてintをに変更してunsigned int、違いがなくなるかどうかを確認します。
zwol 2012

5
2つの関数は、少し異なる計算を行っているようです。結果は同じかもしれませんが、式(r + shifted) ^ signはと同じではありませんr + (shifted ^ sign)。オプティマイザーを混乱させると思いますか?FWIW、MSVC 2010(16.00.40219.01)は、互いにほぼ同じリストを生成します:gist.github.com/2430454
DCoder

1
@DCoder:ああ、くそー!私はそれを見つけませんでした。ただし、違いの説明ではありません。これが除外されている新しいバージョンで質問を更新させてください。
orlp 2012

回答:


256

OPの編集と同期するように更新されました

コードをいじることで、GCCが最初のケースをどのように最適化するかを確認できました。

これらがなぜ異なるのかを理解する前に、まずGCCがどのように最適化されるのかを理解する必要がありますfast_trunc_one()

信じられないかもしれませんが、fast_trunc_one()これに最適化されています:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

これにより、オリジナルとまったく同じアセンブリが作成fast_trunc_one()されます-レジスタ名とすべて。

ないことに注意してください xorのアセンブリにはがfast_trunc_one()。それが私にそれを与えたものです。


どうして?


ステップ1: sign = -sign

まず、sign変数を見てみましょう。なのでsign = i & 0x80000000;sign取り得る値は2つだけです。

  • sign = 0
  • sign = 0x80000000

さて、どちらの場合も、それを認識sign == -sign。したがって、元のコードを次のように変更すると、

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;
    } else {
        r = mantissa >> exponent;
    }

    return (r ^ sign) + sign;
}

オリジナルとまったく同じアセンブリを生成しfast_trunc_one()ます。アセンブリは割愛しますが、登録名などすべて同じです。


ステップ2:数学的簡約:x + (y ^ x) = y

sign2つの値のうちの1つしか取ることができない、0または0x80000000

  • いつ x = 0、それからx + (y ^ x) = y些細なことが成り立つ。
  • 追加とxoring 0x80000000は同じです。符号ビットを反転します。したがって、x + (y ^ x) = yも成立しx = 0x80000000ます。

したがって、にx + (y ^ x)減少しyます。そしてコードはこれを単純化します:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent);
    } else {
        r = (mantissa >> exponent);
    }

    return r;
}

繰り返しますが、これは完全に同じアセンブリにコンパイルされます-レジスタ名とすべて。


この上記のバージョンは最終的にこれに削減されます:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

これは、GCCがアセンブリで生成するものとほぼ同じです。


では、なぜコンパイラfast_trunc_two()は同じことに最適化しないのでしょうか?

の重要な部分fast_trunc_one()x + (y ^ x) = y最適化です。中にfast_trunc_two()x + (y ^ x)表現支店にまたがって分割されています。

私は、GCCを混乱させてこの最適化を行わないのに十分かもしれないと思います。(^ -signブランチから持ち上げて、r + sign、最後にます。)

たとえば、次のコードと同じアセンブリが生成されfast_trunc_one()ます。

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = ((mantissa << -exponent) ^ -sign) + sign;             /* diff */
    } else {
        r = ((mantissa >> exponent) ^ -sign) + sign;              /* diff */
    }

    return r;                                     /* diff */
}

4
編集、リビジョン2に回答したようです。現在のリビジョンでは2つの例が反転し、コードが少し変更されています...これは混乱を招きます。
ミスティシャル

2
@nightcracker心配ありません。現在のバージョンと同期するように回答を更新しました。
ミスティシャル

1
@Mysticial:あなたの最後の文は、(それが、最も重要な質問に答えていない解答空間を作り、新しいバージョンではもはや真実ではない「なぜGCCは、このような根本的に異なるアセンブリを生成ない」。)
orlp

11
回答が再度更新されました。十分満足できるかどうかはわかりません。しかし、関連するGCC最適化パスがどのように機能するかを正確に理解していなければ、もっとうまくやれるとは思いません。
Mysticial

4
@Mysticial:厳密に...コンパイラはここに行っているほとんどすべての変換の、限り署名タイプが誤って、このコードで使用されているように、動作は未定義である場合にされて話す
R .. GitHubのSTOPはICE手助け

63

これはコンパイラの性質です。彼らが最速または最良の道をたどると仮定するのはかなり間違っています。「最新のコンパイラ」が空白を埋め、最高の仕事をし、最速のコードを作成するなど、コードを最適化するために何もする必要がないことを暗示する人。実際、私はgccが3.xから3少なくとも腕に4.x。この時点で4.xは3.xに追いついた可能性がありますが、初期の段階では遅いコードが生成されていました。練習することで、コードの記述方法を習得できるため、コンパイラーが一生懸命働く必要がなくなり、その結果、より一貫した期待される結果が得られます。

ここでのバグは、実際に生成されたものではなく、生成されるものに対する期待です。コンパイラーが同じ出力を生成するようにしたい場合は、同じ入力をフィードします。数学的には同じではなく、同じではありませんが、実際には同じであり、パスが異なりません。バージョン間で操作を共有または分散しません。これは、コードの記述方法を理解し、コンパイラーがコードをどのように使用するかを理解するための良い練習です。1つのプロセッサターゲットの1つのバージョンのgccが1日の特定の結果を生成したため、それがすべてのコンパイラとすべてのコードに適用されるルールであると想定するのは間違いではありません。何が起こっているのかを理解するには、多くのコンパイラとターゲットを使用する必要があります。

gccはかなり厄介です。カーテンの後ろを見て、gccの根性を見て、ターゲットを追加するか、自分で何かを変更してみてください。ダクトテープとベイルワイヤーでかろうじて固定されています。重要な場所で追加または削除された追加のコード行は、崩れ落ちます。それが他の期待に応えられなかった理由を心配するのではなく、使用可能なコードを生成したという事実は喜ばしいことです。

gccの異なるバージョンが何を生成するかを見ましたか?3.xおよび4.x、特に4.5対4.6対4.7など そして、異なるターゲットプロセッサ、x86、arm、mipsなど、またはそれが使用するネイティブコンパイラである場合は、x86のさまざまなフレーバー(32ビットと64ビットなど)の場合、そして、別のターゲットのllvm(clang)?

Mysticalは、コードの分析/最適化の問題を解決するために必要な思考プロセスで優れた仕事をしてきました。コンパイラーが「現代のコンパイラー」には期待されていないものを考え出すことを期待しています。

数学のプロパティに入らないで、この形式のコード

if (exponent < 0) {
  r = mantissa << -exponent;                       /* diff */
} else {
  r = mantissa >> exponent;                        /* diff */
}
return (r ^ -sign) + sign;                           /* diff */

コンパイラーをAに導きます。その形式で実装し、if-then-elseを実行してから、一般的なコードに収束して終了し、戻ります。またはB:これは関数の末尾であるため、ブランチを保存します。また、rの使用や保存についても気になりません。

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}

次に、Mysticalが指摘したように、記述されたコードのsign変数がすべて一緒に消えることを示します。コンパイラーがsign変数がなくなることを期待しないので、コンパイラーにそれを理解させようとせず、自分でそれを行うべきでした。

これは、gccソースコードを掘り下げるのに最適な機会です。オプティマイザが1つのケースを別のケースで別のケースを見たケースを見つけたようです。次に、次のステップに進み、gccでそのケースを表示できないかどうかを確認します。一部の個人またはグループが最適化を認識し、意図的にそこに配置したため、すべての最適化が存在します。この最適化がそこにあり、誰かがそこに置く必要があるたびに機能する(そしてそれをテストして、それを将来にわたって維持する)には。

コードが少ないほど速く、コードが多いほど遅くなるとは絶対に想定しないでください。そうでない例を作成して見つけるのは非常に簡単です。多くの場合、多くのコードよりも少ないコードの方が高速です。最初から説明したように、その場合の分岐やループなどを保存するためのコードをさらに作成して、最終的に高速なコードにすることができます。

一番下の行は、コンパイラーに別のソースを提供し、同じ結果が期待されることです。問題はコンパイラの出力ではなく、ユーザーの期待です。特定のコンパイラーとプロセッサー、関数全体を劇的に遅くする1行のコードの追加を示すのはかなり簡単です。たとえば、なぜa = b + 2を変更するのですか。a = b + c + 2; _fill_in_the_blank_compiler_name_により、根本的に異なる低速なコードが生成されますか?もちろん、答えはコンパイラが入力で異なるコードを与えられたので、コンパイラが異なる出力を生成することは完全に有効です。(さらに良いのは、無関係な2行のコードを入れ替えて出力を劇的に変化させる場合です)入力の複雑さとサイズと出力の複雑さとサイズの間に予期される関係はありません。

for(ra=0;ra<20;ra++) dummy(ra);

60-100行のアセンブラーのどこかで生成されました。ループを展開しました。行数は数えませんでした。考えてみれば、結果を追加し、結果を関数呼び出しへの入力にコピーし、関数呼び出しを行う必要があります。最低3つの操作が必要です。したがって、ターゲットによっては、少なくとも60命令である可能性があります。ループごとに4つの場合は80、ループごとに5つの場合は100などです。


なぜあなたはあなたの答えを破壊したのですか?Odedも編集に同意していないようです;-)。
ピーター-モニカの復活2016年

@ PeterA.Schneiderのすべての回答は同じ日に破壊されたようです。彼の(盗まれた?)アカウントデータを持つ誰かがそれをしたと思います。
trinity420

23

Mysticialはすでにすばらしい説明をしていますが、FWIW氏は、コンパイラが最適化を行う理由は基本的なものではなく、他の理由ではないということを付け加えておきます。

clangたとえば、LLVMのコンパイラは、両方の関数(関数名を除く)に同じコードを提供します。

_fast_trunc_two:                        ## @fast_trunc_one
        movl    %edi, %edx
        andl    $-2147483648, %edx      ## imm = 0xFFFFFFFF80000000
        movl    %edi, %esi
        andl    $8388607, %esi          ## imm = 0x7FFFFF
        orl     $8388608, %esi          ## imm = 0x800000
        shrl    $23, %edi
        movzbl  %dil, %eax
        movl    $150, %ecx
        subl    %eax, %ecx
        js      LBB0_1
        shrl    %cl, %esi
        jmp     LBB0_3
LBB0_1:                                 ## %if.then
        negl    %ecx
        shll    %cl, %esi
LBB0_3:                                 ## %if.end
        movl    %edx, %eax
        negl    %eax
        xorl    %esi, %eax
        addl    %edx, %eax
        ret

このコードは、OPの最初のgccバージョンほど短くはありませんが、2番目のバージョンほど長くはありません。

x86_64用にコンパイルされている別のコンパイラー(私は名前を付けません)からのコードは、両方の関数に対してこれを生成します。

fast_trunc_one:
        movl      %edi, %ecx        
        shrl      $23, %ecx         
        movl      %edi, %eax        
        movzbl    %cl, %edx         
        andl      $8388607, %eax    
        negl      %edx              
        orl       $8388608, %eax    
        addl      $150, %edx        
        movl      %eax, %esi        
        movl      %edx, %ecx        
        andl      $-2147483648, %edi
        negl      %ecx              
        movl      %edi, %r8d        
        shll      %cl, %esi         
        negl      %r8d              
        movl      %edx, %ecx        
        shrl      %cl, %eax         
        testl     %edx, %edx        
        cmovl     %esi, %eax        
        xorl      %r8d, %eax        
        addl      %edi, %eax        
        ret                         

それはの両側を計算するという点で魅力的です ifし、最後に条件付き移動を使用して正しいものを選択です。

Open64コンパイラーは以下を生成します。

fast_trunc_one: 
    movl %edi,%r9d                  
    sarl $23,%r9d                   
    movzbl %r9b,%r9d                
    addl $-150,%r9d                 
    movl %edi,%eax                  
    movl %r9d,%r8d                  
    andl $8388607,%eax              
    negl %r8d                       
    orl $8388608,%eax               
    testl %r8d,%r8d                 
    jl .LBB2_fast_trunc_one         
    movl %r8d,%ecx                  
    movl %eax,%edx                  
    sarl %cl,%edx                   
.Lt_0_1538:
    andl $-2147483648,%edi          
    movl %edi,%eax                  
    negl %eax                       
    xorl %edx,%eax                  
    addl %edi,%eax                  
    ret                             
    .p2align 5,,31
.LBB2_fast_trunc_one:
    movl %r9d,%ecx                  
    movl %eax,%edx                  
    shll %cl,%edx                   
    jmp .Lt_0_1538                  

および類似しているが同一ではないコード fast_trunc_two

とにかく、最適化に関して言えば、それは宝くじです—それが何であるか...コードが特定の方法でコンパイルされる理由を知るのは必ずしも容易ではありません。


10
あなたが最高機密のスーパーコンパイラと名付けないコンパイラはありますか?
orlp 2012

4
Top SecretコンパイラはおそらくIntel iccです。私は32ビットのバリアントしか持っていませんが、これは非常によく似たコードを生成します。
Janus Troelsen、2012年

5
ICCでもあると思います。コンパイラーは、プロセッサーが命令レベルの並列処理が可能であることを知っているため、両方の分岐を同時に計算できます。条件付き移動のオーバーヘッドは、誤った分岐予測のオーバーヘッドよりもはるかに低くなります。
フィリップナバラ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.