web-dev-qa-db-ja.com

Cでシフト演算子を使用した乗算と除算は実際には高速ですか?

乗算と除算は、たとえばビット演算子を使用して実現できます。

i*2 = i<<1
i*3 = (i<<1) + i;
i*10 = (i<<3) + (i<<1)

等々。

実際に(i<<3)+(i<<1)を直接使用するよりも、say i*10を使用して10で乗算する方が速いですか?この方法で乗算または分割できない入力の種類はありますか?

271
eku

簡単な答え:ありそうもない。

長い答え:コンパイラには、ターゲットプロセッサアーキテクチャが可能な限り速く乗算する方法を知っているオプティマイザがあります。最善の策は、コンパイラに意図を明確に伝え(i << 1ではなくi * 2)、最も速いAssembly/machineコードシーケンスを決定させることです。プロセッサ自体が乗算命令を一連のシフトとマイクロコードの加算として実装している可能性さえあります。

要するに、これについて心配するのに多くの時間を費やさないでください。シフトするつもりなら、シフトします。掛けるつもりなら、掛けます。意味的に明確なことをしてください。同僚は後で感謝します。または、他の方法で行った場合は、後で呪われます。

454
Drew Hall

具体的な測定点:何年も前、私はハッシュアルゴリズムの2つのバージョンのベンチマークを行いました。

unsigned
hash( char const* s )
{
    unsigned h = 0;
    while ( *s != '\0' ) {
        h = 127 * h + (unsigned char)*s;
        ++ s;
    }
    return h;
}

そして

unsigned
hash( char const* s )
{
    unsigned h = 0;
    while ( *s != '\0' ) {
        h = (h << 7) - h + (unsigned char)*s;
        ++ s;
    }
    return h;
}

ベンチマークを行ったすべてのマシンで、最初のマシンは少なくとも2番目のマシンと同じくらい高速でした。少し驚くべきことに、それは時々より高速でした(例:Sun Sparc)。ハードウェアが高速乗算をサポートしていなかった場合(そしてほとんどの場合、当時はサポートしていませんでした)、コンパイラーは乗算をシフトとadd/subの適切な組み合わせに変換しました。そして、最終目標を知っていたので、シフトとadd/subsを明示的に書いたときよりも少ない命令でそうすることができました。

これは15年前のようなものでした。願わくば、それ以来コンパイラは良くなっているだけなので、コンパイラが正しいことをやってくれると期待できます。 (また、コードがC'ishに見える理由は、15年以上前だったからです。明らかに、今日std::stringとイテレーターを使用します。)

90
James Kanze

ここでの他のすべての良い答えに加えて、除算または乗算を意味するときにシフトを使用しない別の理由を指摘させてください。乗算と加算の相対的な優先順位を忘れて誰かがバグを導入するのを見たことは一度もありません。メンテナンスプログラマがシフトによる「乗算」が論理的に乗算であることを忘れたときに導入されたバグを見ましたが、乗算と同じ優先順位の構文的にではありません。 x * 2 + zx << 1 + zは非常に異なります!

numbersで作業している場合は、+ - * / %などの算術演算子を使用します。ビットの配列で作業している場合は、& ^ | >>のようなビット調整演算子を使用します。それらを混ぜないでください。ちょっとした調整と演算の両方を含む式は、発生を待っているバグです。

62
Eric Lippert

これは、プロセッサとコンパイラに依存します。一部のコンパイラはすでにこの方法でコードを最適化していますが、他のコンパイラはそうではありません。したがって、この方法でコードを最適化する必要があるたびに確認する必要があります。

どうしても最適化する必要がない限り、アセンブリ命令またはプロセッササイクルを保存するためだけにソースコードをスクランブルすることはしません。

48
Jens

実際にsay *(i << 3)+(i << 1)を使用して10で乗算する方が、i * 10を直接使用するよりも速いですか?

それはあなたのマシンにあるかもしれないし、そうでないかもしれない-あなたが気にするなら、あなたの実世界の使用を測定する.

ケーススタディ-486からコアi7まで

ベンチマークを有意義に行うことは非常に困難ですが、いくつかの事実を見ることができます。 http://www.penguin.cz/~literakl/intel/s.html#SAL および http://www.penguin.cz/~literakl/intel/i。 html#IMUL 算術シフトと乗算に必要なx86クロックサイクルのアイデアが得られます。 「486」(リストにある最新のもの)、32ビットレジスタおよびイミディエイトに固執するとします。IMULには13-42サイクル、IDIV 44が必要です。各SALには2が追加され、1が追加されます。勝者のように。

最近、コアi7で:

http://software.intel.com/en-us/forums/showthread.php?t=61481 から)

レイテンシーは、整数加算の場合は 1サイクル、整数乗算の場合は3サイクルです。 http://www.intel.com/products/processor/manuals/にある「Intel®64およびIA-32アーキテクチャ最適化リファレンスマニュアル」の付録Cでレイテンシとスループットを確認できます。

(一部のインテルの宣伝文句より)

SSEを使用すると、Core i7は加算と乗算の同時命令を発行できるため、クロックサイクルごとに8つの浮動小数点演算(FLOP)のピークレートが得られます。

それはあなたに物事がどこまで来たかのアイデアを与えます。最適化のトリビア-ビットシフトと*のように-90年代に入っても真剣に考えられていたものが、今では時代遅れになっています。ビットシフトは依然として高速ですが、すべてのシフトを実行して結果を追加するまでに2のべき乗以外のmul/divの場合、再び遅くなります。その後、命令が増えるとキャッシュフォールトが多くなり、パイプライン処理の潜在的な問題が増え、一時レジスタの使用が増えると、スタックからのレジスタコンテンツの保存と復元が増える可能性があります...すべての影響を明確に定量化するにはすぐに複雑になりますが、主に負。

ソースコードの機能と実装

より一般的には、質問にはCおよびC++というタグが付けられます。第3世代の言語であるため、基礎となるCPU命令セットの詳細を隠すように特別に設計されています。言語標準を満たすためには、乗算およびシフト演算(および他の多くの演算)基礎となるハードウェアがサポートしていない場合でもをサポートする必要があります。そのような場合、他の多くの命令を使用して必要な結果を合成する必要があります。同様に、CPUにFPUがなく、浮動小数点演算にソフトウェアサポートを提供する必要があります。最近のCPUはすべて*<<をサポートしているため、これはばかげた理論的で歴史的なように思えるかもしれませんが、重要なことは、実装を選択する自由が両方の方向に進むことです:CPUが、一般的な場合、コンパイラは、コンパイラが直面しているspecificの場合に適しているため、コンパイラは好みのものを自由に選択できます。

例(架空のアセンブリ言語を使用)

source           literal approach         optimised approach
#define N 0
int x;           .Word x                xor registerA, registerA
x *= N;          move x -> registerA
                 move x -> registerB
                 A = B * immediate(0)
                 store registerA -> x
  ...............do something more with x...............

排他的または(xor)などの命令はソースコードとは関係がありませんが、それ自体で何かをxor-ingするとすべてのビットがクリアされるため、何かを0に設定するために使用できます。 。

この種のハッキングは、コンピューターが使用されている限り使用されています。 3GLの初期の頃、開発者の取り込みを確保するために、コンパイラの出力は、既存のハードコアな手作業で最適化されたAssembly-language開発者を満足させる必要がありました。作成されたコードが遅くなったり、冗長になったり、さもなければ悪化したりすることはありませんでした。コンパイラーはすぐに多くの優れた最適化を採用しました-個々のアセンブリ言語プログラマーよりも優れた一元化されたストアになりましたが、特定のケースで重要になる特定の最適化を見逃す可能性が常にあります-人間は時々誰かがその経験をフィードバックするまで、コンパイラーは言われたとおりに行うのに対して、それよりも良いものを探しましょう。

そのため、特定のハードウェアでシフトと追加が依然として高速であっても、コンパイラ作成者は安全かつ有益なときに正確に解決した可能性があります。

保守性

ハードウェアが変更された場合、再コンパイルでき、ターゲットCPUを調べて別の最適な選択を行いますが、「最適化」を再検討したり、乗算を使用するコンパイル環境とシフトする必要があるものをリストしたりすることはほとんどありません。 10年以上前に書かれた2のべき乗以外のビットシフトされた「最適化」が、現在のプロセッサーで実行されるときに現在のコードを減速させていると考えてください...!

ありがたいことに、GCCなどの優れたコンパイラーは通常、最適化が有効になっている場合(つまり...main(...) { return (argc << 4) + (argc << 2) + argc; }-> imull $21, 8(%ebp), %eax)に一連のビットシフトと算術を直接乗算に置き換えることができるため、コードを修正しなくても再コンパイルが役立つ場合がありますが、それは保証されていません。

乗算または除算を実装する奇妙なビットシフトコードは、概念的に達成しようとしていたものの表現がはるかに少ないため、他の開発者はそれによって混乱し、混乱したプログラマーはバグを導入したり、一見正気を取り戻すために不可欠な何かを削除する可能性が高くなります。明白に有益な場合にのみ非自明なことを行い、それを適切に文書化する(ただし、いずれにせよ直観的な他のものは文書化しないでください)と、誰もが幸せになります。

一般的なソリューションと部分的なソリューション

intが実際に値xy、およびzのみを格納するなど、追加の知識がある場合は、これらの値に対して機能するいくつかの命令を実行して、コンパイラーの場合よりも迅速に結果を得ることができますその洞察がなく、すべてのint値に対して機能する実装が必要です。たとえば、あなたの質問を考えてみましょう:

乗算と除算はビット演算子を使用して実現できます...

乗算を説明しますが、除算はどうですか?

int x;
x >> 1;   // divide by 2?

C++ Standard 5.8によると:

-3- E1 >> E2の値は、E1を右シフトしたE2ビット位置です。 E1に符号なし型がある場合、またはE1に符号付き型と非負の値がある場合、結果の値は、E1の商を2のべき乗で割った商の整数部です。 E1に符号付きタイプと負の値がある場合、結果の値は実装定義です。

そのため、xが負の場合、ビットシフトは実装定義の結果になります。異なるマシンでは同じように動作しない場合があります。しかし、/ははるかに予測可能に動作します。完全に一貫性がない場合があります。異なるマシンは異なる数の負の表現を持っているため、同じ数であっても範囲が異なるためです。表現を構成するビットの)。

intが従業員の年齢を格納していることを気にしません。決して負の値になることはありません」と言うかもしれません。そのような特別な洞察がある場合、はい-コードで明示的に行わない限り、>>安全な最適化がコンパイラによって渡される可能性があります。しかし、それはリスクが高いであり、この種の洞察が得られない場合はほとんど有用ではなく、同じコードで作業している他のプログラマーはあなたが賭けたことを知らないあなたが扱うデータに対するいくつかの異常な期待に基づいて家に...それらへの完全に安全な変更は、あなたの「最適化」のために裏目に出るかもしれません。

この方法で乗算または除算できない入力の種類はありますか?

はい...前述のように、負数はビットシフトによって「分割」されたときの実装定義の動作を持ちます。

36
Tony Delroy

私のマシンでこれをコンパイルしてみました:

int a = ...;
int b = a * 10;

分解すると出力が生成されます:

MOV EAX,DWORD PTR SS:[ESP+1C] ; Move a into EAX
LEA EAX,DWORD PTR DS:[EAX+EAX*4] ; Multiply by 5 without shift !
SHL EAX, 1 ; Multiply by 2 using shift

このバージョンは、純粋にシフトと追加を行う、手で最適化されたコードよりも高速です。

コンパイラーが何を思い付くのか、あなたは本当に知らないので、単純にnormal乗算を書いて、彼が望む方法を最適化する方が良い、コンパイラが最適化できないknowという非常に正確な場合を除きます。

32
user703016

一般に、シフトは命令レベルで乗算するよりもはるかに高速ですが、時期尚早な最適化を行うのに時間を浪費している可能性があります。コンパイラは、コンパイル時にこれらの最適化を実行できます。自分で行うと読みやすさに影響し、パフォーマンスに影響を与えない可能性があります。プロファイルを作成し、これがボトルネックであるとわかった場合にのみ、このようなことをする価値があるでしょう。

実際、「マジックディビジョン」として知られるディビジョントリックは、実際に大きな見返りをもたらします。ここでも、最初にプロファイルを作成して、必要かどうかを確認する必要があります。しかし、それを使用する場合、同じ除算セマンティクスに必要な命令を見つけるのに役立つ便利なプログラムがあります。次に例を示します。 http://www.masm32.com/board/index.php?topic=12421.

MASM32のOPのスレッドから取り上げた例:

include ConstDiv.inc
...
mov eax,9999999
; divide eax by 100000
cdiv 100000
; edx = quotient

生成します:

mov eax,9999999
mov edx,0A7C5AC47h
add eax,1
.if !CARRY?
    mul edx
.endif
shr edx,16
21
Mike Kwan

シフトおよび整数乗算命令は、ほとんどの最新のCPUで同様のパフォーマンスを発揮します。整数乗算命令は1980年代には比較的遅くなりましたが、一般的にはこれは事実ではありません。整数乗算命令は、より長いlatencyを持つ可能性があるため、シフトが望ましい場合もあります。より多くの実行ユニットをビジー状態に保つことができる場合も同様です(ただし、両方の方法を削減できます)。

ただし、整数除算は依然として比較的遅いため、2の累乗による除算の代わりにシフトを使用することは依然として勝ちであり、ほとんどのコンパイラはこれを最適化として実装します。 ただし、この最適化を有効にするには、配当が符号なしであるか、正であることがわかっている必要があります。負の配当では、シフトと除算は等しくありません!

#include <stdio.h>

int main(void)
{
    int i;

    for (i = 5; i >= -5; --i)
    {
        printf("%d / 2 = %d, %d >> 1 = %d\n", i, i / 2, i, i >> 1);
    }
    return 0;
}

出力:

5 / 2 = 2, 5 >> 1 = 2
4 / 2 = 2, 4 >> 1 = 2
3 / 2 = 1, 3 >> 1 = 1
2 / 2 = 1, 2 >> 1 = 1
1 / 2 = 0, 1 >> 1 = 0
0 / 2 = 0, 0 >> 1 = 0
-1 / 2 = 0, -1 >> 1 = -1
-2 / 2 = -1, -2 >> 1 = -1
-3 / 2 = -1, -3 >> 1 = -2
-4 / 2 = -2, -4 >> 1 = -2
-5 / 2 = -2, -5 >> 1 = -3

したがって、コンパイラを支援する場合は、被除数の変数または式が明示的に符号なしであることを確認してください。

11
Paul R

ターゲットデバイス、言語、目的などに完全に依存します。

ビデオカードドライバーでのピクセルの処理?はい、そうです!

あなたの部署の.NETビジネスアプリケーション?調査する理由さえまったくありません。

モバイルデバイス向けの高性能ゲームの場合、検討する価値があるかもしれませんが、より簡単な最適化が実行された後でなければなりません。

3
Brady Moritz

絶対に必要で、コードの意図が乗算/除算ではなくシフトを必要とする場合を除き、実行しないでください。

通常の日-少数のマシンサイクルを節約できる可能性があります(または、コンパイラは最適化するものをよりよく知っているため、ゆるんでいます)。あなたの同僚はあなたをののしります。

高負荷の計算では、保存された各サイクルが実行時間の分を意味するため、これが必要になる場合があります。ただし、一度に1か所を最適化し、毎回パフォーマンステストを行って、実際に高速化したか、コンパイラロジックを壊したかを確認する必要があります。

2
Kromster

私の知る限り、一部のマシンでは乗算に最大16から32マシンサイクルが必要です。 はい、マシンのタイプによっては、ビットシフト演算子は乗算/除算よりも高速です。

ただし、特定のマシンには、乗算/除算のための特別な命令を含む数学プロセッサがあります。

1
iammilind

ドリュー・ホールのマークされた答えに同意します。ただし、答えには追加のメモを使用できます。

ソフトウェア開発者の大多数にとって、プロセッサーとコンパイラーはもはや問題に関連していません。私たちのほとんどは、8088やMS-DOSをはるかに超えています。それはおそらく、組み込みプロセッサ向けにまだ開発中の人にのみ関係します...

私のソフトウェア会社では、すべての数学にMath(add/sub/mul/div)を使用する必要があります。データ型間で変換するときはShiftを使用する必要があります。 n >> 8およびnot n/256としてのバイトへのushort。

1
deegee

あるケースでは、2の累乗で乗算または除算を行いたいと思いますが、ビットシフト演算子を使用しても、コンパイラーがMUL/DIVに変換しても、一部のプロセッサーマイクロコード(実際には、マクロ)とにかく、それらの場合、特にシフトが1より大きい場合、改善を達成します。より明確に、CPUにビットシフト演算子がない場合は、とにかくMUL/DIVになりますが、ビットシフト演算子を使用すると、マイクロコードの分岐を回避でき、これにより命令数が少なくなります。

私は現在、いくつかのコードを書いていますが、それは密なバイナリツリーで動作しているため、多くの倍増/半分の操作を必要とし、追加よりも最適であると思われるもう1つの操作があります-左(2の累乗)追加でシフトします。これは左シフトとxorに置き換えることができます。シフトが追加するビット数よりも広い場合、簡単な例は(i << 1)^ 1で、2倍の値に1を追加します。もちろん、これは右シフト(2除算の累乗)には適用されません。これは、左(リトルエンディアン)シフトのみがギャップをゼロで埋めるためです。

私のコードでは、これらの2の乗算/除算と2のべき乗の演算が非常に集中的に使用されます。式は既に非常に短いため、削除できる各命令はかなりのゲインになります。プロセッサがこれらのビットシフト演算子をサポートしていない場合、ゲインは発生しませんが、損失は発生しません。

また、私が書いているアルゴリズムでは、発生する動きを視覚的に表しているので、その意味で実際にはより明確です。バイナリツリーの左側は大きく、右側は小さくなります。それに加えて、私のコードでは、奇数と偶数は特別な意味を持ち、ツリー内のすべての左側の子は奇数であり、すべての右側の子とルートは偶数です。まだ出会っていない場合もありますが、実際にはこれを考えていなかったかもしれませんが、x&1はx%2に比べて最適な操作である可能性があります。偶数のx&1はゼロを生成しますが、奇数の場合は1を生成します。

奇数/偶数の識別よりも少し進んで、x&3で0が得られた場合、4は数値の要因であり、x%7でも8であることがわかります。これらのケースにはおそらく限られた有用性しかありませんが、モジュラス演算を回避し、代わりにビットごとの論理演算を使用できることを知ってうれしいです。ビットごとの演算はほとんど常に最速であり、コンパイラにとって曖昧になる可能性が最も低いためです。

私は密なバイナリツリーの分野をかなり発明しているので、このコメントの価値を人々が理解できない可能性があることを期待しています。

0
Louki Sumirniy

同じ乱数に対して同じ乗算を1億回実行するPythonテスト。

>>> from timeit import timeit
>>> setup_str = 'import scipy; from scipy import random; scipy.random.seed(0)'
>>> N = 10*1000*1000
>>> timeit('x=random.randint(65536);', setup=setup_str, number=N)
1.894096851348877 # Time from generating the random #s and no opperati

>>> timeit('x=random.randint(65536); x*2', setup=setup_str, number=N)
2.2799630165100098
>>> timeit('x=random.randint(65536); x << 1', setup=setup_str, number=N)
2.2616429328918457

>>> timeit('x=random.randint(65536); x*10', setup=setup_str, number=N)
2.2799630165100098
>>> timeit('x=random.randint(65536); (x << 3) + (x<<1)', setup=setup_str, number=N)
2.9485139846801758

>>> timeit('x=random.randint(65536); x // 2', setup=setup_str, number=N)
2.490908145904541
>>> timeit('x=random.randint(65536); x / 2', setup=setup_str, number=N)
2.4757170677185059
>>> timeit('x=random.randint(65536); x >> 1', setup=setup_str, number=N)
2.2316000461578369

したがって、Pythonで2の累乗による乗算/除算ではなくシフトを行うと、わずかな改善があります(除算では最大10%、乗算では最大1%)。 2のべき乗ではない場合、かなりの減速が見込まれます。

繰り返しますが、これらの#sは、プロセッサー、コンパイラー(またはインタープリター-簡単にするためにpythonで行いました)に応じて変わります。

他のすべての人と同様に、時期尚早に最適化しないでください。非常に読みやすいコードを書き、速度が十分でない場合はプロファイルを作成し、遅い部分を最適化してください。あなたのコンパイラはあなたよりも最適化がはるかに優れていることを忘れないでください。

0
dr jimbob

実際により高速であるかどうかは、ハードウェアとコンパイラに依存します実際に使用されます。

コンパイラが実行できない最適化があります。これは、入力のセットが少ない場合にのみ機能するためです。

以下に、64ビットの「逆数による乗算」を行う高速除算を実行できるc ++サンプルコードを示します。分子と分母の両方が特定のしきい値を下回っている必要があります。実際には通常の除算よりも高速になるように、64ビット命令を使用するようにコンパイルする必要があることに注意してください。

#include <stdio.h>
#include <chrono>

static const unsigned s_bc = 32;
static const unsigned long long s_p = 1ULL << s_bc;
static const unsigned long long s_hp = s_p / 2;

static unsigned long long s_f;
static unsigned long long s_fr;

static void fastDivInitialize(const unsigned d)
{
    s_f = s_p / d;
    s_fr = s_f * (s_p - (s_f * d));
}

static unsigned fastDiv(const unsigned n)
{
    return (s_f * n + ((s_fr * n + s_hp) >> s_bc)) >> s_bc;
}

static bool fastDivCheck(const unsigned n, const unsigned d)
{
    // 32 to 64 cycles latency on modern cpus
    const unsigned expected = n / d;

    // At least 10 cycles latency on modern cpus
    const unsigned result = fastDiv(n);

    if (result != expected)
    {
        printf("Failed for: %u/%u != %u\n", n, d, expected);
        return false;
    }

    return true;
}

int main()
{
    unsigned result = 0;

    // Make sure to verify it works for your expected set of inputs
    const unsigned MAX_N = 65535;
    const unsigned MAX_D = 40000;

    const double ONE_SECOND_COUNT = 1000000000.0;

    auto t0 = std::chrono::steady_clock::now();
    unsigned count = 0;
    printf("Verifying...\n");
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        fastDivInitialize(d);
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            count += !fastDivCheck(n, d);
        }
    }
    auto t1 = std::chrono::steady_clock::now();
    printf("Errors: %u / %u (%.4fs)\n", count, MAX_D * (MAX_N + 1), (t1 - t0).count() / ONE_SECOND_COUNT);

    t0 = t1;
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        fastDivInitialize(d);
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            result += fastDiv(n);
        }
    }
    t1 = std::chrono::steady_clock::now();
    printf("Fast division time: %.4fs\n", (t1 - t0).count() / ONE_SECOND_COUNT);

    t0 = t1;
    count = 0;
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            result += n / d;
        }
    }
    t1 = std::chrono::steady_clock::now();
    printf("Normal division time: %.4fs\n", (t1 - t0).count() / ONE_SECOND_COUNT);

    getchar();
    return result;
}
0
user2044859

Gccコンパイラでx + x、x * 2、およびx << 1構文の出力を比較すると、x86アセンブリで同じ結果が得られます。 https://godbolt.org/z/JLpp0j

        Push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        add     eax, eax
        pop     rbp
        ret

したがって、gccをsmartとして考えて、入力したものとは無関係に彼自身の最適なソリューションを決定できます。

0
Buridan

符号付き整数と右シフトvs除算の場合、違いが生じる可能性があります。負の数の場合、シフトは負の無限大に向かって丸めますが、除算はゼロに向かって丸めます。もちろん、コンパイラーは除算をより安価なものに変更しますが、変数が負ではないことを証明できないか、単にそうでないため、通常は除算と同じ丸め動作を持つものに変更しますケア。そのため、数値が負にならないことを証明できる場合、またはどのように丸めるのかを気にしない場合、違いを生む可能性が高い方法で最適化を行うことができます。

0
harold