1. ホーム
  2. c

[解決済み] なぜGCCはほとんど同じCコードに対して根本的に異なるアセンブリを生成するのでしょうか?

2022-04-28 20:05:48

質問

最適化された 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 . さて、私の疑問は、何が原因なのか、ということです。

解決方法は?

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

まず、「Subject」内の sign という変数があります。このため sign = i & 0x80000000; が取り得る値は2つだけです。 sign を取ることができる。

  • 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

sign は2つの値のうちどちらか一方しか取ることができません。 0 または 0x80000000 .

  • いつ x = 0 であれば x + (y ^ x) = y が成立すれば、トリビアルが成立する。
  • による足し算・引き算 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 */
}