最適化された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_one
はfast_trunc_two
よりも約30%高速です。今私の質問:これは何が原因ですか?
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()
のアセンブリにはxor
sがないことに注意してください。それは私にそれを与えたものです。
ステップ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 */
}
これがコンパイラの性質です。それらが最速または最良のパスをとると仮定すると、それはまったく間違っています。 「最新のコンパイラー」が空白を埋め、最高の仕事をし、最速のコードを作成するなど、最適化のためにコードに何もする必要がないことを暗示している人。少なくとも腕に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などとなる可能性があります。
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
の類似するが同一ではないコード。
とにかく、最適化に関して言えば、それは宝くじです—それが何であるか...あなたのコードが特定の方法でコンパイルされる理由を知ることは必ずしも容易ではありません。