<< >>乗算と除算の速度


9

を使用<<して>>、Pythonで数値を乗算およ​​び除算するために使用できます。バイナリシフトを使用すると、通常の除算または乗算よりも10倍高速です。

なぜ使用している<<>>多くのよりも速く*/

背後で進行しているプロセス*/それによって非常に遅いプロセスは何ですか?


2
ビットシフトは、Pythonだけでなく、すべての言語で高速です。多くのプロセッサには、1または2クロックサイクルでそれを実行するネイティブビットシフト命令があります。
ロバートハーベイ、

4
ただし、通常の除算および乗算演算子を使用する代わりにビットシフトを行うことは、一般的に不適切であり、読みやすさを妨げる可能性があることに注意してください。
Azar

6
@crizlyせいぜいそれはマイクロ最適化であり、コンパイラがそれをとにかくバイトコードのシフトに変更する可能性が高いためです(可能な場合)。これには例外があります。たとえば、コードのパフォーマンスが非常に重要な場合などですが、ほとんどの場合、行っていることはコードを難読化することだけです。
Azar 2014

7
@Crizly:まともなオプティマイザを備えたコンパイラは、ビットシフトで実行できる乗算と除算を認識し、それらを使用するコードを生成します。コンパイラを上回ろうとするコードを醜くしないでください。
Blrfl 2014

2
StackOverflowの上でこの質問マイクロベンチマークは少し見つけ良く十分に小さい数字のために、同等の左シフトのためよりも、2乗算のためのPython 3でのパフォーマンスを。その理由は、ビットシフトとは異なる方法で最適化されている(現在)小さな乗算にまで遡ると思います。理論に基づいてより速く実行されるものを当然のことと見なすことができないことを示すために行きます。
Dan Getz

回答:


15

ビットシフトと除算を行う2つの小さなCプログラムを見てみましょう。

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

これらはそれぞれgcc -S実際のアセンブリがどうなるかを確認するためにコンパイルされます。

ビットシフトバージョンでは、呼び出しatoiから戻りまで:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

分割バージョンの間:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

これを見るだけで、ビットシフトと比較して、除算バージョンにはさらにいくつかの命令があります。

鍵は彼らが何をするかです。

ビットシフトバージョンでは、重要な命令はshll $2, %eax論理左シフトです-除算があり、その他はすべて値を移動しています。

分割バージョンでは、idivl %r8d-が表示されますが、そのすぐ上にcltd(longをdoubleに変換)と、スピルとリロードに関するいくつかの追加ロジックがあります。この追加の作業は、ビットではなく数学を扱っていることを知っているため、ビット計算だけを行うことで発生する可能性のあるさまざまなエラーを回避するために必要になることがよくあります。

いくつかの簡単な乗算を実行してみましょう:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

このすべてを通過するのではなく、1つの行が異なります。

$ diff mult.s bit.s
24c24
> shll $ 2、%eax
---
<sarl $ 2、%eax

ここで、コンパイラーはシフトを使用して数学を実行できることを識別できましたが、論理シフトの代わりに算術シフトを実行します。これらを実行した場合、これらの違いは明らかです- sarl標識を保持します。そのため、そうで-2 * 4 = -8はありshllません。

これを簡単なperlスクリプトで見てみましょう:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

出力:

16
16
18446744073709551600
-16

ええと... これは、乗算と除算を処理するときに期待するものとは正確に-4 << 2は異なります18446744073709551600。その通りですが、整数の乗算ではありません。

したがって、時期尚早の最適化に注意してください。コンパイラーが最適化するようにします。これは、ユーザーが実際に何をしようとしているのかを認識しており、バグが少ないため、より適切に機能します。


12
ペアに明確であるかもしれない<< 2* 4して>> 2/ 4各例の中に、同じシフト方向を維持します。
グレッグ・ヒューギル2014

5

既存の回答は実際にはハードウェアの側面に対応していなかったので、ここでその角度について少し説明します。従来の知識では、乗算と除算はシフトよりもはるかに遅くなりますが、今日の実際のストーリーはより微妙です。

たとえば、乗算はハードウェアに実装するためのより複雑な演算であることは確かに当てはまりますが、必ずしも低速になるとは限りません。結局のところ、addより実装するのにもかなり複雑であるxor(または一般に任意のビット演算)が、add(とsub)通常は、十分なトランジスタは、それらの動作そのために捧げますちょうどビット演算子の速さとアップ。したがって、ハードウェア実装の複雑さを速度の目安として見ることはできません。

では、シフトと、乗算やシフトなどの「完全な」演算子を詳しく見てみましょう。

シフティング

ほとんどすべてのハードウェアで、一定量(つまり、コンパイラーがコンパイル時に決定できる量)のシフトは高速です。特に、これは通常、1サイクルのレイテンシで発生し、1サイクルあたり1以上のスループットで発生します。一部のハードウェア(たとえば、一部のIntelおよびARMチップ)では、定数による特定のシフトは別の命令に組み込むことができるため(lea「Intel」では、ARMの最初のソースの特別なシフト機能)、「フリー」になる場合があります。

可変量だけシフトすると、灰色の領域が増えます。古いハードウェアでは、これは時々非常に遅く、速度は世代ごとに変化しました。たとえば、IntelのP4の最初のリリースでは、可変量のシフトは非常に遅く、シフト量に比例した時間が必要でした。そのプラットフォームでは、シフトを置き換えるために乗算を使用することは有益である可能性があります(つまり、世界は逆さまになっています)。以前のIntelチップとその後の世代では、可変量のシフトはそれほど苦痛ではありませんでした。

現在のIntelチップでは、可変量のシフトは特に高速ではありませんが、ひどくもありません。x86アーキテクチャは、変数シフトに関しては異常な方法で操作を定義しているため、厳しい状態にあります。シフト量0は条件フラグを変更しませんが、他のすべてのシフトは変更します。これは、後続の命令がシフトによって書き込まれた条件コードを読み取るか、または前の命令を読み取るかどうかをシフトが実行するまで決定できないため、フラグレジスタの効率的な名前変更を阻害します。さらに、シフトはフラグレジスタの一部にのみ書き込みを行うため、部分的なフラグストールが発生する可能性があります。

その結果、最近のIntelアーキテクチャでは、可変量のシフトには3つの「マイクロ演算」が必要ですが、他のほとんどの単純な演算(加算、ビットごとの演算、乗算も)は1つしかかかりません。 。

乗算

最近のデスクトップおよびラップトップハードウェアの傾向は、乗算を高速演算にすることです。実際、最近のIntelおよびAMDチップでは、サイクルごとに1つの乗算を実行できます(これを相互スループットと呼びます)。ただし、乗算のレイテンシは3サイクルです。つまり、開始から3サイクル後に任意の乗算の結果を取得できますが、サイクルごとに新しい乗算を開始できます。どちらの値(1サイクルまたは3サイクル)がより重要かは、アルゴリズムの構造によって異なります。乗算が重要な依存関係チェーンの一部である場合は、待ち時間が重要です。そうでない場合は、相互スループットまたはその他の要因がより重要になる場合があります。

彼らの重要な要点は、最新のラップトップチップ(またはそれ以上)では、乗算は高速な演算であり、強度が低下したシフトのためにコンパイラーが「丸め」を得るために発行する3または4命令シーケンスよりも高速である可能性が高いということです。変数シフトの場合、Intelでは上記の問題があるため、一般的に乗算も推奨されます。

小型のフォームファクタープラットフォームでは、完全で高速な32ビットまたは特に64ビットの乗算器を構築すると、多くのトランジスタと電力が必要になるため、乗算はさらに遅くなる可能性があります。最近のモバイルチップでの乗算のパフォーマンスの詳細を誰かが記入できれば、大歓迎です。

割る

除算は乗算よりもハードウェア的にはより複雑な演算であり、実際のコードではあまり一般的ではありません。つまり、割り振られるリソースが少なくなる可能性があります。最近のチップの傾向は、より高速な分周器に向かっていますが、現在の最上位チップでも、分周を実行するのに10〜40サイクルかかり、部分的にパイプライン化されています。一般に、64ビットの除算は32ビットの除算よりもさらに低速です。他のほとんどの演算とは異なり、除算は引数に応じて可変数のサイクルを取る場合があります。

可能な場合は、分割してシフトで置換しないでください(またはコンパイラに実行を許可しますが、アセンブリの確認が必要になる場合があります)。


2

BINARY_LSHIFTおよびBINARY_RSHIFTは、BINARY_MULTIPLYおよびBINARY_FLOOR_DIVIDEよりもアルゴリズム的に単純なプロセスであり、必要なクロックサイクルが少なくなる場合があります。つまり、2進数があり、Nだけビットシフトする必要がある場合、必要なのは、その桁数だけ桁をシフトしてゼロに置き換えることだけです。 バイナリ乗算は、一般的にはより複雑ですがDadda乗算器などの手法により、非常に高速になります。

確かに、最適化コンパイラは、2の累乗で乗算/除算し、適切な左/右シフトで置き換える場合を認識できます。逆アセンブルされたバイトコードを見ると、pythonは明らかにこれを行いません:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

しかし、私のプロセッサーでは、乗算と左/右シフトのタイミングが似ており、フロア除算(2の累乗による)は約25%遅くなります。

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.