web-dev-qa-db-ja.com

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

最適化された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

それはextremeの違いです。これは実際にプロファイルにも表示されます。fast_trunc_onefast_trunc_twoよりも約30%高速です。今私の質問:これは何が原因ですか?

182
orlp

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()とまったく同じアセンブリが作成されます-レジスタ名とすべて。

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


どうして?


ステップ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()とまったく同じアセンブリを生成します。私はあなたにアセンブリをspareしまないでしょうが、それは同一です-登録名とすべて。


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

signは、0または0x80000000の2つの値のいずれかのみを取ることができます。

  • x = 0の場合、x + (y ^ x) = yの場合、自明です。
  • 0x80000000による追加とxoringは同じです。符号ビットを反転します。したがって、x = 0x80000000の場合、x + (y ^ x) = yも保持されます。

したがって、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 */
}
254
Mysticial

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

ここでのバグは、実際に生成されたものではなく、生成されるものに対する期待です。コンパイラーに同じ出力を生成させたい場合は、同じ入力をフィードします。数学的には同じではなく、まったく同じではありませんが、実際には同じであり、異なるパスはなく、あるバージョンから別のバージョンへの操作を共有または配布しません。これは、コードの記述方法を理解し、コンパイラがそれを使用して何をするかを理解する上で良い練習です。 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が指摘したように、記述されたコードの符号変数がすべて一緒に消えるのを開始できます。コンパイラーが符号変数がなくなることを期待しないので、あなたはそれを自分でやるべきであり、コンパイラーにそれを理解させようとはしませんでした。

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

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

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

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

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

64
old_timer

Mysticialはすでに素晴らしい説明をしてくれましたが、FWIW、コンパイラーが一方ではなく他方で最適化を行う理由については基本的なことは何もないと付け加えたいと思いました。

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

_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番目のgccバージョンほど長くはありません。

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の類似するが同一ではないコード。

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

22
Charphacy