div
とmul
のアセンブリ操作について読んでいたので、Cで簡単なプログラムを作成して、それらの動作を確認することにしました。
#include <stdlib.h>
#include <stdio.h>
int main()
{
size_t i = 9;
size_t j = i / 5;
printf("%zu\n",j);
return 0;
}
そして、以下を使用してアセンブリ言語コードを生成します。
gcc -S division.c -O0 -masm=intel
ただし、生成されたdivision.s
ファイルを見ると、div操作は含まれていません。代わりに、ビットシフトとマジックナンバーを使用して、ある種の黒魔術を行います。 i/5
を計算するコードスニペットを次に示します。
mov rax, QWORD PTR [rbp-16] ; Move i (=9) to RAX
movabs rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul rdx ; Multiply 9 by magic number
mov rax, rdx ; Take only the upper 64 bits of the result
shr rax, 2 ; Shift these bits 2 places to the right (?)
mov QWORD PTR [rbp-8], rax ; Magically, RAX contains 9/5=1 now,
; so we can assign it to j
何が起きてる? GCCがdivをまったく使用しないのはなぜですか?このマジックナンバーはどのように生成され、なぜすべてが機能するのですか?
整数除算は、最新のプロセッサで実行できる最も遅い算術演算の1つであり、最大で数十サイクルの遅延とスループットの低下を伴います。 (x86については、 Agner Fogの命令テーブルとマイクロアーチガイド を参照してください)。
前もって除数を知っている場合は、同等の効果を持つ他の操作(乗算、加算、およびシフト)のセットで除数を置き換えることで除算を回避できます。いくつかの操作が必要な場合でも、多くの場合、整数除算自体よりもはるかに高速です。
div
を含むマルチ命令シーケンスではなく、この方法でC /
演算子を実装することは、定数による除算を行うGCCのデフォルトの方法です。オペレーション全体で最適化する必要はなく、デバッグ用であっても何も変更しません。 (小さなコードサイズで-Os
を使用すると、GCCはdiv
を使用します。)除算の代わりに乗法逆数を使用することは、lea
およびmul
の代わりにadd
を使用するようなものです。
その結果、除数がコンパイル時に不明な場合にのみ、出力にdiv
またはidiv
が表示される傾向があります。
コンパイラーがこれらのシーケンスを生成する方法と、自分で生成できるようにするコード(ブレインデッドコンパイラーを使用している場合を除き、ほぼ確実に不要です)については、 libdivide を参照してください。
5で除算することは、1/5を乗算することと同じです。これは、4/5を乗算して右に2ビットシフトすることと同じです。関係する値はCCCCCCCCCCCCD
(16進数)です。これは、16進数の後に置かれた場合は4/5のバイナリ表現です(つまり、5分の4のバイナリは0.110011001100
繰り返しです-理由については以下を参照)。ここから持っていけると思います! 固定小数点演算 をチェックアウトすることもできます(ただし、最後は整数に丸められていることに注意してください)。
理由として、乗算は除算よりも高速であり、除数が固定されている場合、これはより高速なルートです。
逆数の乗算、チュートリアル を参照して、固定小数点の観点から説明する、その仕組みに関する詳細な説明をご覧ください。これは、逆数を見つけるためのアルゴリズムがどのように機能するか、符号付き除算とモジュロを処理する方法を示しています。
0.CCCCCCCC...
(16進数)または0.110011001100...
バイナリが4/5である理由を少し考えてみましょう。バイナリ表現を4で除算(右に2桁シフト)すると、0.001100110011...
が得られます。これは簡単な検査により、元の0.111111111111...
を追加できます。これは明らかに1に等しくなります。 0.9999999...
10進数は1に等しい。したがって、x + x/4 = 1
、つまり5x/4 = 1
、x=4/5
であることがわかります。これは、丸めのためにCCCCCCCCCCCCD
として16進数で表されます(最後の1桁を超える2進数は1
になるため)。
一般に、乗算は除算よりはるかに高速です。したがって、逆数による乗算を回避できる場合は、代わりに定数による除算を大幅に高速化できます
しわとは、逆数を正確に表すことができないことです(除算が2の累乗による場合を除き、通常は除算をビットシフトに変換するだけです)。したがって、正解を確実にするために、相互のエラーが最終結果にエラーを引き起こさないように注意する必要があります。
-3689348814741910323は0xCCCCCCCCCCCCCCCCCDで、0.64固定小数点で表された4/5を少し超える値です。
64ビット整数に0.64固定小数点数を掛けると、64.64の結果が得られます。値を64ビット整数に切り捨て(事実上ゼロに丸めます)、さらに4で割ってさらに切り捨てるシフトを実行します。ビットレベルを見ると、両方の切り捨てを1つの切り捨てとして処理できることが明らかです。
これにより、少なくとも5による除算の近似値が明らかになりますが、正確にゼロに丸められた正確な答えが得られますか?
正確な答えを得るには、誤差を丸め境界を越えてプッシュしないように十分に小さくする必要があります。
5による除算の正確な答えは、常に0、1/5、2/5、3/5または4/5の小数部分になります。したがって、乗算およびシフトされた結果で1/5未満の正のエラーが発生しても、結果が丸め境界を超えてプッシュされることはありません。
定数のエラーは(1/5)* 2です-64。 iの値は2未満です64 したがって、乗算後のエラーは1/5未満です。 4で除算すると、エラーは(1/5)* 2未満になります−2。
(1/5)* 2−2 1/5未満なので、答えは常に正確な除算とゼロへの丸めに等しくなります。
残念ながら、これはすべての除数で機能するわけではありません。
4/7を0からの丸めで0.64固定小数点数として表現しようとすると、(6/7)* 2のエラーが発生します-64。 2未満のi値を乗算した後64 最終的に6/7未満のエラーが発生し、4で割った後、1.5/7未満のエラーが発生し、これは1/7を超えます。
したがって、7による除算を正しく実装するには、0.65の固定小数点数を乗算する必要があります。固定小数点数の下位64ビットを乗算し、元の数を加算して(これがキャリービットにオーバーフローする可能性があります)、キャリーを介して回転を実行することで実装できます。
これは、Visual Studioで見られる値とコードを生成するアルゴリズムのドキュメントへのリンクです(ほとんどの場合)。また、定数整数による可変整数の除算のためにGCCでまだ使用されていると思います。
http://gmplib.org/~tege/divcnst-pldi94.pdf
この記事では、uwordにはNビット、udwordには2Nビット、n =分子=被除数、d =分母=除数、ℓは最初にceil(log2(d))に設定され、shpreは事前シフト(乗算前に使用) )= e = dの末尾のゼロビットの数、shpostはシフト後(乗算後に使用)、precは精度= N-e = N-shpreです。目標は、シフト前、乗算、およびシフト後を使用してn/dの計算を最適化することです。
下方向にスクロールして、6.2のワード乗算器(最大サイズはN + 1ビット)の生成方法を定義しますが、プロセスを明確に説明していません。これについては以下で説明します。
図4.2および図6.2は、ほとんどの除数で乗数をNビット以下の乗数に減らす方法を示しています。式4.5は、図4.1および4.2のN + 1ビット乗算器の処理に使用される式の導出方法を説明しています。
最新のX86およびその他のプロセッサーの場合、乗算時間は固定されているため、これらのプロセッサーではプリシフトは役立ちませんが、それでも乗数をN + 1ビットからNビットに減らすのに役立ちます。 GCCまたはVisual StudioがX86ターゲットのプレシフトを排除したかどうかはわかりません。
図6.2に戻ります。 mlowおよびmhighの分子(被除数)は、分母(除数)> 2 ^(N-1)(when == N => mlow = 2 ^(2N)の場合)のみ、この場合、 n/dの最適化された置換は比較(n> = dの場合、q = 1、それ以外の場合q = 0)であるため、乗数は生成されません。 mlowおよびmhighの初期値はN + 1ビットであり、2つのudword/uword除算を使用して各N + 1ビット値(mlowまたはmhigh)を生成できます。例としてX86を64ビットモードで使用します。
; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend dq 2 dup(?) ;16 byte dividend
divisor dq 1 dup(?) ; 8 byte divisor
; ...
mov rcx,divisor
mov rdx,0
mov rax,dividend+8 ;upper 8 bytes of dividend
div rcx ;after div, rax == 1
mov rax,dividend ;lower 8 bytes of dividend
div rcx
mov rdx,1 ;rdx:rax = N+1 bit value = 65 bit value
GCCでこれをテストできます。 j = i/5がどのように処理されるかはすでに見ました。 j = i/7がどのように処理されるかを見てみましょう(これはN + 1ビットの乗数の場合です)。
現在のほとんどのプロセッサでは、乗算のタイミングは固定されているため、事前シフトは必要ありません。 X86の場合、最終結果は、ほとんどの除数の場合は2命令シーケンス、7のような除数の場合は5命令シーケンスです(pdfファイルの式4.5および図4.2に示すように、N + 1ビット乗算器をエミュレートするため)。 X86-64コードの例:
; rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
; two instruction sequence for most divisors:
mul rbx ;rdx = upper 64 bits of product
shr rdx,cl ;rdx = quotient
;
; five instruction sequence for divisors like 7
; to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)
mul rbx ;rdx = upper 64 bits of product
sub rbx,rdx ;rbx -= rdx
shr rbx,1 ;rbx >>= 1
add rdx,rbx ;rdx = upper 64 bits of corrected product
shr rdx,cl ;rdx = quotient
; ...