を使用<<
して>>
、Pythonで数値を乗算および除算するために使用できます。バイナリシフトを使用すると、通常の除算または乗算よりも10倍高速です。
なぜ使用している<<
と>>
多くのよりも速く*
と/
?
背後で進行しているプロセス*
と/
それによって非常に遅いプロセスは何ですか?
を使用<<
して>>
、Pythonで数値を乗算および除算するために使用できます。バイナリシフトを使用すると、通常の除算または乗算よりも10倍高速です。
なぜ使用している<<
と>>
多くのよりも速く*
と/
?
背後で進行しているプロセス*
と/
それによって非常に遅いプロセスは何ですか?
回答:
ビットシフトと除算を行う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
。その通りですが、整数の乗算ではありません。
したがって、時期尚早の最適化に注意してください。コンパイラーが最適化するようにします。これは、ユーザーが実際に何をしようとしているのかを認識しており、バグが少ないため、より適切に機能します。
<< 2
と* 4
して>> 2
と/ 4
各例の中に、同じシフト方向を維持します。
既存の回答は実際にはハードウェアの側面に対応していなかったので、ここでその角度について少し説明します。従来の知識では、乗算と除算はシフトよりもはるかに遅くなりますが、今日の実際のストーリーはより微妙です。
たとえば、乗算はハードウェアに実装するためのより複雑な演算であることは確かに当てはまりますが、必ずしも低速になるとは限りません。結局のところ、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ビットの除算よりもさらに低速です。他のほとんどの演算とは異なり、除算は引数に応じて可変数のサイクルを取る場合があります。
可能な場合は、分割してシフトで置換しないでください(またはコンパイラに実行を許可しますが、アセンブリの確認が必要になる場合があります)。
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]