GCCが整数除算を実装する際に奇妙な数による乗算を使用するのはなぜですか?


228

私は約読んでいるdivmul組立オペレーション、と私はC言語で簡単なプログラムを作成することにより、アクションでそれらを見ることにしました。

ファイルDivision.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をまったく使用しないのはなぜですか?このマジックナンバーはどのように生成され、なぜすべてが機能するのですか?


29
gccは定数による除算を最適化し、2、3、4、5、6、7、8による除算を試みます。ほとんどの場合、ケースごとに非常に異なるコードが表示されます。
Jabberwocky 2016

28
注:マジックナンバー-3689348814741910323CCCCCCCCCCCCCCCD、a uint64_tまたは(約(2 ^ 64)* 4/5)に変換されます。
chux-モニカを

32
@qiubit:最適化が無効になっているからといって、コンパイラーは非効率的に非効率的なコードを生成しません。たとえば、コードの並べ替えや変数の削除を伴わない簡単な「最適化」が実行されます。基本的に、単一のソースステートメントは、その操作の最も効率的なコードに分離して変換されます。コンパイラの最適化では、単一のステートメントだけではなく、周囲のコードが考慮されます。
クリフォード

20
この素晴らしい記事を読んでください:労働部
ジェスター

9
一部のコンパイラは、最適化が無効になっているため、実際に非効率的に非効率なコードを生成します。特に、コードの個々の行にブレークポイントを設定する機能など、デバッグを容易にするためにそれを行います。実際のところ、GCCの多くは最適化がオンになっているため、真の「最適化なし」モードがないため、GCCはかなり珍しいものです。これは、GCCでそれを確認できる場所の例です。一方打ち鳴らすと、MSVCは、あろう放出divに命令-O0。(cc @ clifford)
コーディグレイ

回答:


169

整数除算は、最新のプロセッサで実行できる最も遅い算術演算の1つであり、最大数十サイクルのレイテンシと悪いスループットを伴います。(x86については、Agner Fogの指示表とmicroarchガイドを参照してください)。

除数が事前にわかっている場合は、同等の効果を持つ他の演算(乗算、加算、およびシフト)のセットで除算を除算することにより、除算を回避できます。複数の演算が必要な場合でも、整数除算自体よりもはるかに高速であることがよくあります。

/このようにC 演算子を実装することは、複数の命令を含むシーケンスの代わりに、div定数による除算を行うGCCのデフォルトの方法にすぎません。操作全体で最適化する必要はなく、デバッグのために何も変更しません。(ただし、-Osコードサイズが小さい場合div、GCCはを使用します。)除算の代わりに乗法的逆数を使用するleaことはmul、代わりにおよびadd

結果として、コンパイル時に除数が分からない場合にのみ、divまたはidiv出力に表示される傾向があります。

コンパイラーがこれらのシーケンスを生成する方法、および自分でシーケンスを生成できるようにするコード(braindeadコンパイラーを使用している場合を除き、ほとんどの場合不要)については、libdivideを参照してください。


5
速度比較でFP演算と整数演算を一緒にまとめることが公平かどうかはわかりません、@ fuz。おそらくSneftelは、除算は最新のプロセッサで実行できる最も遅い整数演算であると言っているのでしょうか?また、この「魔法」の詳細な説明へのリンクがコメントで提供されています。あなたはそれらをあなたの答えに集めて可視性のために適切だと思いますか?123
コーディグレー

1
操作のシーケンスは機能的に同一であるため...でも、これは常に要件です-O3。コンパイラは、すべての可能な入力値に対して正しい結果を与えるコードを作成する必要があります。これは、を使用した浮動小数点に対してのみ変更され-ffast-math、AFAIKには「危険な」整数の最適化はありません。(最適化が有効になっていると、コンパイラーは、たとえば負でない符号付き整数に対してのみ機能するものを使用できるようにする値の可能な範囲について何かを証明できる場合があります。)
Peter Cordes

6
本当の答えは、Cをマシンコードに変換する一環として、gcc -O0は依然として内部表現を通じてコードを変換することです。でさえ-O0(ただし、ではなく-Os)モジュラー乗法逆数がデフォルトで有効になっています。他のコンパイラー(clangなど)は、で2の累乗でない定数にDIVを使用し-O0ます。関連:私はこれについてのパラグラフをCollat​​z予想の手書きのasm回答
Peter Cordes

6
@PeterCordesそして、そうです、GCC(および他の多くのコンパイラ)は、「最適化が無効になっているときにどのような最適化が適用されるか」についての適切な根拠を考え出すのを忘れていると思います。1日の大半をあいまいなcodegenバグの追跡に費やしてきたので、私は今のところ、少しイライラしています。
Sneftel

9
@Sneftel:予想よりも速く実行されているコードについてコンパイラ開発者に積極的に不満言うアプリケーション開発者の数が比較的少ないためです。
dan04

121

5で除算することは、1/5を乗算することと同じです。これも、4/5で乗算して右に2ビットシフトすることと同じです。関連する値はCCCCCCCCCCCCCCCD16進数です。これは、16進ポイントの後に置くと4/5のバイナリ表現です(つまり、5分の4のバイナリが0.110011001100繰り返されます-理由については以下を参照してください)。こちらからどうぞ!固定小数点演算を確認することもできます(ただし、最後に整数に丸められることに注意してください)。

理由としては、乗算は除算よりも高速であり、除数が固定されている場合、これはより高速なルートです。

それがどのように機能するかについての詳細な説明については、固定小数点に関して説明するチュートリアル、逆数乗算を参照してください。これは、逆数を見つけるためのアルゴリズムがどのように機能するか、および符号付き除算とモジュロを処理する方法を示しています。

0.CCCCCCCC...(hex)または0.110011001100...binaryが4/5である理由を少し考えてみましょう。バイナリ表現を4で割ります(2桁右にシフト)。0.001100110011...簡単な検査で、オリジナルにgetを追加できるものを取得します0.111111111111...。これは明らかに1に等しく0.9999999...、10進数での同じ方法は1に等しくなります。したがって、我々はそれを知っているx + x/4 = 1ので、5x/4 = 1x=4/5。次に、これはCCCCCCCCCCCCD丸めのために16進数で表されます(最後に存在するものを超える2進数はaになるため1)。


2
@ user2357112自分の回答を自由に投稿してください。しかし、同意しません。乗算は64.0ビットx 0.64ビットの乗算と見なすことができ、128ビットの固定小数点の回答が得られます。このうち、最下位の64ビットは破棄され、4で除算されます(最初の段落で指摘したように)。あなたはビットの動きを同等にうまく説明する別のモジュラー算術の答えを思いつくことができるかもしれませんが、私はこれが説明として機能すると確信しています。
2016

6
値は実際には「CCCCCCCCCCCCCCCCCD」です。最後のDは重要です。結果が切り捨てられたときに、正確な除算が正しい答えで出てくることを確認します。
プラグウォッシュ

4
気にしないで。128ビットの乗算結果の上位64ビットを取得していることがわかりませんでした。ほとんどの言語でできることではないので、最初はそれが起こっていることに気づきませんでした。この答えは、128ビットの結果の上位64ビットを取得することは、固定小数点数を乗算して切り捨てることと同等であることを明示的に言及することで大幅に改善されます。(また、なぜそれが1/5ではなく4/5でなければならないのか、なぜ4/5を切り捨てずに
切り上げる

2
丸めの境界を超えて5で上に除算をスローするために必要なエラーの大きさを計算し、それを計算での最悪の場合のエラーと比較する必要があると思われます。gccの開発者はこれを行っており、常に正しい結果が得られると結論付けています。
プラグウォッシュ

3
実際には、他のすべても正しく丸める必要がある場合、おそらく5つの最も高い可能な入力値のみをチェックする必要があります。
プラグウォッシュ

60

一般に、乗算は除算よりもはるかに高速です。したがって、逆数による乗算を回避できる場合は、定数による除算を大幅に高速化できます。

しわは、逆数を正確に表すことができないことです(除算が2の累乗でなかった場合を除きますが、その場合、通常、除算をビットシフトに変換できます)。したがって、正しい答えを確実にするために、相互のエラーが最終結果にエラーを引き起こさないように注意する必要があります。

-3689348814741910323は0xCCCCCCCCCCCCCCCDCDで、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 64未満のi値を掛けると、6/7未満のエラーになり、4で割ると、1/7より大きい1.5 / 7未満のエラーになります。

したがって、7による除算を正しく実装するには、0.65の固定小数点数を乗算する必要があります。これを実装するには、固定小数点数の下位64ビットを乗算してから、元の数を加算し(これはキャリービットにオーバーフローする可能性があります)、ローテートスルーキャリーを実行します。


8
この答えは、モジュラー乗法逆数を「時間をかけたくないほど複雑に見える数学」から意味のあるものに変えました。わかりやすいバージョンの+1。コンパイラが生成した定数を使用する以外に何もする必要がなかったので、数学を説明する他の記事をざっと読みました。
Peter Cordes 2016

2
コードのモジュラー演算とは何の関係もありません。他のコメンターがそれを得ているDunno。
plugwash 2016

3
これは、レジスタ内のすべての整数演算と同様に、2 ^ nを法としています。 en.wikipedia.org/wiki/…–
Peter Cordes

4
@PeterCordesモジュラー乗法逆数は正確な除算に使用されますが、一般的な除算には役立ちません
ハロルド

4
固定小数点の逆数による@PeterCordes乗算?誰もがそれを何と呼んでいるかはわかりませんが、おそらくそれはそう呼んでいると思います。かなり説明的です
ハロルド

12

これは、(ほとんどの場合)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の計算を最適化することです。

udword乗数(最大サイズはN + 1ビット)の生成方法を定義している図6.2まで下にスクロールしますが、プロセスを明確に説明していません。これについては以下で説明します。

図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)の場合)の場合にのみ、udwordより大きくできます。 n / dの最適化された置換は比較(n> = dの場合、q = 1、それ以外の場合はq = 0)であるため、乗数は生成されません。mlowとmhighの初期値はN + 1ビットであり、2つのudword / uword除算を使用して各N + 1ビット値(mlowまたはmhigh)を生成できます。例として64ビットモードでX86を使用する場合:

; 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
;       ...

その論文はそれをgccで実装することについて説明しているので、同じアルゴがまだ使用されていると想定すると安全だと思います。
Peter Cordes 2016

1994年の日付の論文は、gccでの実装について説明しているため、gccがアルゴリズムを更新する時期が来ています。他の人がそのURLの94の意味を確認する時間がない場合に備えて。
Ed Grimm

0

少し違った角度からお答えさせていただきます。

CおよびC ++は、抽象的なマシンに対して定義されています。コンパイラは、as-ifルールに従って、抽象マシンの観点からこのプログラムを具象マシンに変換します。

  • コンパイラーは、抽象マシンで指定された監視可能な動作を変更しない限り、任意の変更を行うことができます。コンパイラが可能な限り最も簡単な方法でコードを変換することは合理的な期待はありません(多くのCプログラマが想定している場合でも)。通常、これは、コンパイラーが(他の回答で詳しく説明されているように)直接的なアプローチと比較してパフォーマンスを最適化したいためです。
  • 何らかの状況下で、コンパイラーが正しいプログラムを別の観察可能な動作を持つものに「最適化」する場合、それはコンパイラーのバグです。
  • コード内の未定義の動作(符号付き整数オーバーフローは古典的な例です)とこの規約は無効です。
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.