Geschwindigkeiten der << >> Multiplikation und Division

9

Sie können verwenden <<, >>um Zahlen in Python zu multiplizieren und zu teilen, wenn ich sie zeitlich festlege. Ich finde, dass die binäre Verschiebung 10x schneller ist als das Teilen oder Multiplizieren der regulären Methode.

Warum ist <<und >>viel schneller als *und /?

Was sind die hinter den Kulissen Prozesse gehen zu machen *und /so langsam?

Crizly
quelle
2
Die Bitverschiebung ist in allen Sprachen schneller, nicht nur in Python. Viele Prozessoren verfügen über einen nativen Bitverschiebungsbefehl, der dies in ein oder zwei Taktzyklen ausführt.
Robert Harvey
4
Es sollte jedoch beachtet werden, dass Bitshifting anstelle der Verwendung der normalen Divisions- und Multiplikationsoperatoren im Allgemeinen eine schlechte Praxis ist und die Lesbarkeit beeinträchtigen kann.
Azar
6
@crizly Da es sich bestenfalls um eine Mikrooptimierung handelt und die Wahrscheinlichkeit groß ist, dass der Compiler sie ohnehin in eine Verschiebung des Bytecodes ändert (wenn möglich). Es gibt Ausnahmen, z. B. wenn Code äußerst leistungskritisch ist, Sie jedoch meistens nur Ihren Code verschleiern.
Azar
7
@Crizly: Jeder Compiler mit einem anständigen Optimierer erkennt Multiplikationen und Divisionen, die mit Bitverschiebungen durchgeführt werden können, und generiert Code, der sie verwendet. Machen Sie Ihren Code nicht hässlich, wenn Sie versuchen, den Compiler zu überlisten.
Blrfl
2
In dieser Frage zu StackOverflow fand ein Mikrobenchmark in Python 3 eine etwas bessere Leistung für die Multiplikation mit 2 als für eine äquivalente Linksverschiebung bei ausreichend kleinen Zahlen. Ich glaube, ich habe den Grund darauf zurückgeführt, dass kleine Multiplikationen (derzeit) anders optimiert werden als Bitverschiebungen. Nur um zu zeigen, dass man nicht davon ausgehen kann, was theoretisch schneller läuft.
Dan Getz

Antworten:

15

Schauen wir uns zwei kleine C-Programme an, die eine kleine Verschiebung und eine Teilung bewirken.

#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;
}

Diese werden dann jeweils kompiliert, um gcc -Szu sehen, wie die eigentliche Baugruppe aussehen wird.

Mit der Bitverschiebungsversion vom Aufruf bis atoizur Rückkehr:

    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

Während die Divide-Version:

    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

Wenn man sich das nur ansieht, gibt es in der Divide-Version im Vergleich zur Bitverschiebung mehrere Anweisungen mehr.

Der Schlüssel ist, was machen sie?

In der Bitverschiebungsversion ist der Schlüsselbefehl shll $2, %eaxeine logische Verschiebung nach links - es gibt die Teilung, und alles andere bewegt nur Werte.

In der Divide-Version sehen Sie das idivl %r8d- aber genau darüber befindet sich ein cltd(Long in Double konvertieren) und eine zusätzliche Logik rund um das Verschütten und Nachladen. Diese zusätzliche Arbeit, in dem Wissen, dass es sich eher um eine Mathematik als um Bits handelt, ist häufig erforderlich, um verschiedene Fehler zu vermeiden, die nur durch Bit-Mathematik auftreten können.

Machen wir eine schnelle Multiplikation:

#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;
}

Anstatt all dies durchzugehen, gibt es eine andere Zeile:

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

Hier konnte der Compiler feststellen, dass die Mathematik mit einer Verschiebung durchgeführt werden kann, jedoch anstelle einer logischen Verschiebung eine arithmetische Verschiebung. Der Unterschied zwischen diesen wäre offensichtlich, wenn wir diese ausführen würden - sarlbewahrt das Zeichen. Also -2 * 4 = -8das shlltut das nicht.

Schauen wir uns das in einem kurzen Perl-Skript an:

#!/usr/bin/perl

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

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

Ausgabe:

16
16
18446744073709551600
-16

Ähm ... -4 << 2ist 18446744073709551600nicht genau das, was Sie wahrscheinlich erwarten, wenn Sie sich mit Multiplikation und Division befassen. Es ist richtig, aber es ist keine ganzzahlige Multiplikation.

Und seien Sie daher vorsichtig bei vorzeitiger Optimierung. Lassen Sie den Compiler für Sie optimieren - er weiß, was Sie wirklich versuchen, und wird es wahrscheinlich mit weniger Fehlern besser machen.


quelle
12
Es könnte klarer sein, << 2mit * 4und >> 2mit / 4zu koppeln, um die Verschiebungsrichtungen in jedem Beispiel gleich zu halten.
Greg Hewgill
5

Die vorhandenen Antworten haben die Hardware-Seite der Dinge nicht wirklich angesprochen, daher hier ein wenig zu diesem Aspekt. Die übliche Weisheit ist, dass Multiplikation und Division viel langsamer sind als Verschiebung, aber die heutige Geschichte ist nuancierter.

Zum Beispiel ist es sicher richtig, dass die Multiplikation eine komplexere Operation ist, die in Hardware implementiert werden muss, aber sie muss nicht immer langsamer sein . Wie sich herausstellt, addist die Implementierung auch wesentlich komplexer als xor(oder im Allgemeinen jede bitweise Operation), aber add(und sub) erhalten normalerweise genug Transistoren für ihre Operation, die am Ende genauso schnell sind wie die bitweisen Operatoren. Sie können also nicht nur die Komplexität der Hardware-Implementierung als Leitfaden für die Geschwindigkeit betrachten.

Schauen wir uns also die Verschiebung im Vergleich zu den "vollständigen" Operatoren wie Multiplikation und Verschiebung genauer an.

Verschiebung

Auf fast jeder Hardware ist das Verschieben um einen konstanten Betrag (dh einen Betrag, den der Compiler zur Kompilierungszeit bestimmen kann) schnell . Insbesondere geschieht dies normalerweise mit einer Latenz von einem einzelnen Zyklus und mit einem Durchsatz von 1 pro Zyklus oder besser. Auf einigen Hardwarekomponenten (z. B. einigen Intel- und ARM-Chips) können bestimmte Verschiebungen durch eine Konstante sogar "frei" sein, da sie in einen anderen Befehl integriert werden können ( leabei Intel die besonderen Verschiebungsfähigkeiten der ersten Quelle in ARM).

Die Verschiebung um einen variablen Betrag ist eher eine Grauzone. Bei älterer Hardware war dies manchmal sehr langsam, und die Geschwindigkeit änderte sich von Generation zu Generation. Zum Beispiel war bei der ersten Veröffentlichung von Intels P4 das Schalten um einen variablen Betrag notorisch langsam - was Zeit erfordert, die proportional zum Verschiebungsbetrag ist! Auf dieser Plattform könnte die Verwendung von Multiplikationen als Ersatz für Schichten rentabel sein (dh die Welt ist auf den Kopf gestellt worden). Bei früheren Intel-Chips sowie nachfolgenden Generationen war eine Verschiebung um einen variablen Betrag nicht so schmerzhaft.

Bei aktuellen Intel-Chips ist die Verschiebung um einen variablen Betrag nicht besonders schnell, aber auch nicht schrecklich. Die x86-Architektur ist in Bezug auf variable Verschiebungen stark eingeschränkt, da sie die Operation auf ungewöhnliche Weise definiert: Verschiebungsbeträge von 0 ändern die Bedingungsflags nicht, alle anderen Verschiebungen jedoch. Dies verhindert das effiziente Umbenennen des Flags-Registers, da nicht bestimmt werden kann, bis die Verschiebung ausgeführt wird, ob nachfolgende Anweisungen die von der Verschiebung geschriebenen Bedingungscodes oder eine vorherige Anweisung lesen sollen. Darüber hinaus schreiben Verschiebungen nur in einen Teil des Flags-Registers, was zu einem teilweisen Stillstand der Flags führen kann.

Das Ergebnis ist dann, dass bei neueren Intel-Architekturen eine Verschiebung um einen variablen Betrag drei "Mikrooperationen" erfordert, während die meisten anderen einfachen Operationen (Addieren, bitweise Operationen, sogar Multiplizieren) nur 1 benötigen. Solche Verschiebungen können höchstens einmal alle 2 Zyklen ausgeführt werden .

Multiplikation

Der Trend bei moderner Desktop- und Laptop- Hardware geht dahin, die Multiplikation zu einem schnellen Vorgang zu machen. Bei neueren Intel- und AMD-Chips kann tatsächlich eine Multiplikation pro Zyklus ausgegeben werden (wir nennen dies gegenseitigen Durchsatz ). Die Latenz einer Multiplikation beträgt jedoch 3 Zyklen. Das bedeutet, dass Sie das Ergebnis einer bestimmten Multiplikation 3 Zyklen nach dem Start erhalten, aber Sie können in jedem Zyklus eine neue Multiplikation starten. Welcher Wert (1 Zyklus oder 3 Zyklen) wichtiger ist, hängt von der Struktur Ihres Algorithmus ab. Wenn die Multiplikation Teil einer kritischen Abhängigkeitskette ist, ist die Latenz wichtig. Wenn nicht, können der wechselseitige Durchsatz oder andere Faktoren wichtiger sein.

Der Schlüssel zum Erfolg ist, dass bei modernen Laptop- Chips (oder besser) die Multiplikation eine schnelle Operation ist und wahrscheinlich schneller als die 3- oder 4-Befehlssequenz ist, die ein Compiler ausgeben würde, um die richtige Rundung für kraftreduzierte Verschiebungen zu erzielen. Bei variablen Verschiebungen würde bei Intel aufgrund der oben genannten Probleme im Allgemeinen auch die Multiplikation bevorzugt.

Auf Plattformen mit kleinerem Formfaktor kann die Multiplikation immer noch langsamer sein, da der Aufbau eines vollständigen und schnellen 32-Bit- oder insbesondere 64-Bit-Multiplikators viel Transistoren und Leistung erfordert. Wenn jemand Einzelheiten über die Leistung der Multiplikation auf neueren mobilen Chips angeben kann, wäre er sehr dankbar.

Teilen

Teilen ist sowohl in Bezug auf die Hardware als auch in Bezug auf die Multiplikation eine komplexere Operation und kommt im tatsächlichen Code auch viel seltener vor - was bedeutet, dass ihm wahrscheinlich weniger Ressourcen zugewiesen werden. Der Trend bei modernen Chips geht immer noch zu schnelleren Teilern, aber selbst moderne Spitzen-Chips benötigen 10 bis 40 Zyklen, um eine Teilung durchzuführen, und sie sind nur teilweise per Pipeline. Im Allgemeinen sind 64-Bit-Teilungen sogar langsamer als 32-Bit-Teilungen. Im Gegensatz zu den meisten anderen Operationen kann die Division abhängig von den Argumenten eine variable Anzahl von Zyklen dauern.

Vermeiden Sie Teilungen und ersetzen Sie sie durch Verschiebungen (oder lassen Sie den Compiler dies tun, aber Sie müssen möglicherweise die Baugruppe überprüfen), wenn Sie können!

BeeOnRope
quelle
2

BINARY_LSHIFT und BINARY_RSHIFT sind algorithmisch einfachere Prozesse als BINARY_MULTIPLY und BINARY_FLOOR_DIVIDE und benötigen möglicherweise weniger Taktzyklen. Das heißt, wenn Sie eine Binärzahl haben und um N bitverschieben müssen, müssen Sie nur die Ziffern über so viele Leerzeichen verschieben und durch Nullen ersetzen. Die binäre Multiplikation ist im Allgemeinen komplizierter , obwohl Techniken wie der Dadda-Multiplikator sie ziemlich schnell machen.

Zugegeben, ein optimierender Compiler kann Fälle erkennen, in denen Sie mit Zweierpotenzen multiplizieren / dividieren und durch die entsprechende Links- / Rechtsverschiebung ersetzen. Wenn man sich den zerlegten Bytecode anschaut, tut Python dies anscheinend nicht:

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

Auf meinem Prozessor habe ich jedoch festgestellt, dass Multiplikation und Links- / Rechtsverschiebung ein ähnliches Timing haben und die Bodenteilung (um eine Zweierpotenz) etwa 25% langsamer ist:

>>> 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]
Dr. Jimbob
quelle