Velocità di << >> moltiplicazione e divisione


9

Puoi usare <<per moltiplicare e >>dividere i numeri in Python quando li trovo che trovo che usando il modo di spostamento binario di farlo sia 10 volte più veloce della divisione o moltiplicazione del modo normale.

Perché usare <<e >>molto più veloce di *e /?

Quali sono i processi dietro la scena in corso di realizzazione *e /così lenti?


2
Il bit shift è più veloce in tutte le lingue, non solo in Python. Molti processori hanno un'istruzione bit shift nativa che la realizzerà in uno o due cicli di clock.
Robert Harvey,

4
Va tenuto presente, tuttavia, che il bitshifting, anziché utilizzare i normali operatori di divisione e moltiplicazione, è generalmente una cattiva pratica e può ostacolare la leggibilità.
Azar,

6
@crizly Perché nella migliore delle ipotesi si tratta di una micro-ottimizzazione e ci sono buone probabilità che il compilatore lo cambi comunque in un bytecode (se possibile). Ci sono eccezioni a questo, come quando il codice è estremamente critico per le prestazioni, ma il più delle volte tutto ciò che stai facendo è offuscare il tuo codice.
Azar,

7
@Crizly: qualsiasi compilatore con un ottimizzatore decente riconoscerà le moltiplicazioni e le divisioni che possono essere fatte con i bit shift e genererà il codice che li utilizza. Non rovinare il tuo codice cercando di superare in astuzia il compilatore.
Blrfl,

2
In questa domanda su StackOverflow un microbenchmark ha trovato prestazioni leggermente migliori in Python 3 per la moltiplicazione per 2 rispetto a un turno sinistro equivalente, per numeri abbastanza piccoli. Penso di aver rintracciato la ragione in base a piccole moltiplicazioni (attualmente) ottimizzate in modo diverso rispetto ai bit shift. Dimostra solo che non puoi dare per scontato ciò che funzionerà più velocemente in base alla teoria.
Dan Getz,

Risposte:


15

Vediamo due piccoli programmi in C che fanno un po 'di spostamento e una divisione.

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

Questi vengono quindi compilati gcc -Sper vedere quale sarà l'assemblaggio effettivo.

Con la versione bit shift, dalla chiamata a atoiper tornare:

    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

Mentre la versione di divisione:

    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

Solo guardando questo ci sono molte più istruzioni nella versione di divisione rispetto al bit shift.

La chiave è cosa fanno?

Nella versione bit shift l'istruzione chiave è shll $2, %eaxche è un turno lasciato logico: c'è la divisione e tutto il resto sta semplicemente spostando i valori.

Nella versione di divisione, puoi vedere idivl %r8d- ma proprio sopra quello è un cltd(converti long in double) e qualche logica aggiuntiva attorno alla fuoriuscita e alla ricarica. Questo lavoro aggiuntivo, sapendo che abbiamo a che fare con una matematica piuttosto che con i bit, è spesso necessario per evitare vari errori che possono verificarsi facendo solo un po 'di matematica.

Facciamo una rapida moltiplicazione:

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

Invece di passare attraverso tutto questo, c'è una riga diversa:

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

Qui il compilatore è stato in grado di identificare che la matematica poteva essere fatta con uno spostamento, tuttavia invece di uno spostamento logico fa uno spostamento aritmetico. La differenza tra questi sarebbe ovvia se li eseguessimo - sarlpreserva il segno. In modo che -2 * 4 = -8mentre il shllnon lo fa.

Vediamo questo in un rapido script perl:

#!/usr/bin/perl

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

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

Produzione:

16
16
18446744073709551600
-16

Um ... -4 << 2è 18446744073709551600che non è esattamente quello che probabilmente ti aspetti quando hai a che fare con la moltiplicazione e la divisione. È giusto, ma non è una moltiplicazione intera.

E quindi diffidare dell'ottimizzazione prematura. Lascia che il compilatore ottimizzi per te: sa cosa stai davvero cercando di fare e probabilmente farà un lavoro migliore, con meno bug.


12
Potrebbe essere più chiaro accoppiarsi << 2con * 4e >> 2con / 4per mantenere le direzioni di spostamento uguali all'interno di ciascun esempio.
Greg Hewgill,

5

Le risposte esistenti non riguardavano davvero il lato hardware delle cose, quindi ecco un po 'su questo punto di vista. La saggezza convenzionale è che la moltiplicazione e la divisione sono molto più lente dello spostamento, ma la storia attuale oggi è più sfumata.

Ad esempio, è certamente vero che la moltiplicazione è un'operazione più complessa da implementare nell'hardware, ma non necessariamente finisce sempre più lentamente . A quanto pare, addè anche significativamente più complesso da implementare rispetto a xor(o in generale qualsiasi operazione bit a bit), ma add(e sub) di solito ottengono abbastanza transistor dedicati alle loro operazioni che finiscono per essere veloci quanto gli operatori bit a bit. Quindi non puoi guardare alla complessità dell'implementazione dell'hardware come una guida alla velocità.

Diamo quindi un'occhiata in dettaglio allo spostamento rispetto agli operatori "completi" come la moltiplicazione e lo spostamento.

Mutevole

Su quasi tutto l'hardware, lo spostamento di una quantità costante (ovvero una quantità che il compilatore può determinare in fase di compilazione) è veloce . In particolare, ciò si verifica in genere con una latenza di un singolo ciclo e con una velocità di trasmissione pari o superiore a 1 per ciclo. Su alcuni hardware (ad es. Alcuni chip Intel e ARM), alcuni spostamenti di una costante possono persino essere "liberi" poiché possono essere integrati in un'altra istruzione ( leasu Intel, le speciali capacità di spostamento della prima fonte in ARM).

Lo spostamento di una quantità variabile è più di un'area grigia. Sull'hardware più vecchio, questo a volte era molto lento e la velocità cambiava di generazione in generazione. Ad esempio, nella versione iniziale di Intel P4, lo spostamento di una quantità variabile era notoriamente lento - richiedendo un tempo proporzionale alla quantità di spostamento! Su quella piattaforma, usare le moltiplicazioni per sostituire i turni potrebbe essere redditizio (cioè il mondo è capovolto). Sui precedenti chip Intel, così come sulle generazioni successive, lo spostamento di una quantità variabile non era così doloroso.

Sui chip Intel attuali, lo spostamento di una quantità variabile non è particolarmente veloce, ma non è nemmeno terribile. L'architettura x86 è ostacolata quando si tratta di turni variabili, perché hanno definito l'operazione in modo insolito: i turni di quantità 0 non modificano i flag di condizione, ma tutti gli altri turni lo fanno. Ciò inibisce l'efficiente ridenominazione del registro flag poiché non può essere determinato fino a quando il turno non esegue se le istruzioni successive devono leggere i codici delle condizioni scritti dal turno o alcune istruzioni precedenti. Inoltre, i turni scrivono solo in parte del registro dei flag, il che può causare uno stallo parziale dei flag.

Il risultato è quindi che nelle recenti architetture Intel, lo spostamento di una quantità variabile richiede tre "micro-operazioni", mentre la maggior parte delle altre operazioni semplici (aggiunta, operazioni bit a bit, persino moltiplicazione) richiede solo 1. Tali spostamenti possono essere eseguiti al massimo una volta ogni 2 cicli .

Moltiplicazione

La tendenza dei moderni hardware desktop e laptop è quella di rendere la moltiplicazione un'operazione rapida. Sui recenti chip Intel e AMD, infatti, è possibile emettere una moltiplicazione per ogni ciclo (chiamiamo questo throughput reciproco ). La latenza , tuttavia, di una moltiplicazione è di 3 cicli. Ciò significa che ottieni il risultato di una determinata moltiplicazione 3 cicli dopo averlo avviato, ma sei in grado di iniziare una nuova moltiplicazione ogni ciclo. Quale valore (1 ciclo o 3 cicli) è più importante dipende dalla struttura dell'algoritmo. Se la moltiplicazione fa parte di una catena di dipendenze critica, la latenza è importante. In caso contrario, il rendimento reciproco o altri fattori potrebbero essere più importanti.

Il punto chiave è che sui moderni chip per laptop (o meglio), la moltiplicazione è un'operazione veloce e probabilmente più veloce della sequenza di istruzioni 3 o 4 che un compilatore emetterebbe per "ottenere l'arrotondamento" giusto per turni ridotti di forza. Per i turni variabili, su Intel, anche la moltiplicazione sarebbe generalmente preferita a causa dei problemi di cui sopra.

Su piattaforme con fattore di forma più piccolo, la moltiplicazione può essere ancora più lenta, poiché la creazione di un moltiplicatore a 32 bit completo e veloce o in particolare a 64 bit richiede molti transistor e potenza. Se qualcuno può compilare con i dettagli delle prestazioni di moltiplicare sui recenti chip mobili sarebbe molto apprezzato.

Dividere

La divisione è un'operazione più complessa, dal punto di vista hardware, della moltiplicazione ed è anche molto meno comune nel codice reale, il che significa che probabilmente sono allocate meno risorse. La tendenza nei chip moderni è ancora verso divisori più veloci, ma anche i chip top di gamma moderni impiegano 10-40 cicli per fare una divisione e sono pipeline solo parzialmente. In generale, le divisioni a 64 bit sono persino più lente delle divisioni a 32 bit. A differenza della maggior parte delle altre operazioni, la divisione può richiedere un numero variabile di cicli a seconda degli argomenti.

Evita le divisioni e sostituiscile con i turni (o lascia che il compilatore lo faccia, ma potresti dover controllare l'assemblaggio) se puoi!


2

BINARY_LSHIFT e BINARY_RSHIFT sono processi più semplici algoritmicamente di BINARY_MULTIPLY e BINARY_FLOOR_DIVIDE e potrebbero richiedere meno cicli di clock. Cioè se hai un numero binario e devi spostare i bit di N, tutto ciò che devi fare è spostare le cifre su quegli spazi e sostituirli con zeri. La moltiplicazione binaria è generalmente più complicata , anche se tecniche come il moltiplicatore Dadda lo rendono abbastanza veloce.

Certo, è possibile per un compilatore ottimizzatore riconoscere i casi quando si moltiplica / si divide per potenze di due e si sostituisce con lo spostamento sinistro / destro appropriato. Guardando il codice byte disassemblato, Python apparentemente non lo fa:

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

Tuttavia, sul mio processore, trovo che la moltiplicazione e lo spostamento sinistro / destro abbiano un tempismo simile e la divisione del pavimento (per una potenza di due) è più lenta di circa il 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]
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.