Quando provo la differenza di tempo tra spostamento e moltiplicazione in C, non c'è differenza. Perché?


28

Mi è stato insegnato che lo spostamento in binario è molto più efficiente della moltiplicazione per 2 ^ k. Quindi volevo sperimentare e ho usato il seguente codice per testarlo:

#include <time.h>
#include <stdio.h>

int main() {
    clock_t launch = clock();
    int test = 0x01;
    int runs;

    //simple loop that oscillates between int 1 and int 2
    for (runs = 0; runs < 100000000; runs++) {


    // I first compiled + ran it a few times with this:
    test *= 2;

    // then I recompiled + ran it a few times with:
    test <<= 1;

    // set back to 1 each time
    test >>= 1;
    }

    clock_t done = clock();
    double diff = (done - launch);
    printf("%f\n",diff);
}

Per entrambe le versioni, la stampa era di circa 440000, dare o prendere 10000. Non c'era alcuna differenza (visivamente, almeno) significativa tra gli output delle due versioni. Quindi la mia domanda è: c'è qualcosa che non va nella mia metodologia? Dovrebbe esserci anche una differenza visiva? Ha qualcosa a che fare con l'architettura del mio computer, del compilatore o di qualcos'altro?


47
Chiunque ti abbia insegnato che era chiaramente sbagliato. Questa convinzione non è più vera dagli anni '70, per compilatori di uso comune su architetture di uso comune. Buon per te per testare questa affermazione. Ho sentito questa affermazione senza senso fatta su JavaScript per l'amor del cielo.
Eric Lippert,

21
Il modo migliore per rispondere a domande come queste è guardare il codice assembly che il compilatore sta producendo. I compilatori in genere hanno un'opzione per produrre una copia del linguaggio assembly che stanno generando. Per i compilatori GNU GCC questo è '-S'.
Charles E. Grant,

8
Bisogna sottolineare che dopo aver visto questo con gcc -S, il codice per test *= 2viene effettivamente compilato in shll $1, %eax Quando invocato con gcc -O3 -Snon c'è nemmeno un ciclo. Le due chiamate dell'orologio sono separate da una linea:callq _clock movq %rax, %rbx callq _clock

6
"Mi è stato insegnato che lo spostamento in binario è molto più efficiente della moltiplicazione per 2 ^ k"; ci vengono insegnate molte cose che risultano essere sbagliate (o almeno non aggiornate). Un compilatore smartish utilizzerà la stessa operazione shift per entrambi.
John Bode,

9
Controlla sempre il codice assembly generato quando lavori su questo tipo di ottimizzazione, per essere sicuro di misurare ciò che pensi di misurare. Un gran numero di domande "perché vedo questi tempi" su SO finiscono per ridursi al compilatore eliminando completamente le operazioni perché i risultati non vengono utilizzati.
Russell Borogove,

Risposte:


44

Come detto nell'altra risposta, la maggior parte dei compilatori ottimizzerà automaticamente le moltiplicazioni da eseguire con i bitshift.

Questa è una regola molto generale durante l'ottimizzazione: la maggior parte delle "ottimizzazioni" in realtà guiderà la compilazione su ciò che realmente intendi e potrebbe anche ridurre le prestazioni.

Ottimizza solo quando hai notato un problema di prestazioni e misurato il problema. (e la maggior parte del codice che scriviamo non viene eseguito così spesso, quindi non dobbiamo preoccuparci)

Il grande svantaggio dell'ottimizzazione è che il codice "ottimizzato" è spesso molto meno leggibile. Quindi nel tuo caso, vai sempre alla moltiplicazione quando stai cercando di moltiplicare. E vai a spostare i bit quando vuoi spostare i bit.


20
Utilizzare sempre l'operazione semanticamente corretta. Se stavate manipolando maschere di bit o posizionando interi piccoli all'interno di interi più grandi, shift è l'operazione appropriata.
lugubre il

2
Ci sarebbe mai (praticamente parlando) la necessità di ottimizzare una moltiplicazione per un operatore a turni in un'applicazione software di alto livello? Sembra, dal momento che il compilatore già ottimizza, che l'unica volta che è utile avere questa conoscenza è quando si programma a un livello molto basso (almeno, al di sotto del compilatore).
NicholasFolk,

11
@NicholasFolk no. Fai ciò che è più semplice da capire. Se stavi scrivendo direttamente assembly può essere utile ... o se stavi scrivendo un compilatore ottimizzante, potrebbe essere utile. Ma al di fuori di questi due casi è un trucco che oscura ciò che stai facendo e fa sì che il prossimo programmatore (che è un omicidio con ascia che sa dove vivi ) maledica il tuo nome e pensa di prendere un hobby.

2
@ NicholasFolk: le ottimizzazioni a questo livello sono quasi sempre oscurate o rese discutibili dall'architettura della CPU. Chi se ne frega se si salvano 50 cicli quando si recuperano gli argomenti dalla memoria e li riscrive ne prende oltre 100? Le micro-ottimizzazioni come questa avevano senso quando la memoria correva (o quasi) alla velocità della CPU, ma non così tanto oggi.
TMN,

2
Perché sono stanco di vedere quel 10% di quella citazione, e perché colpisce il punto qui: "Non c'è dubbio che il graal dell'efficienza porti ad abusi. I programmatori sprecano enormi quantità di tempo a pensare o a preoccuparsi a proposito, la velocità di parti non critiche dei loro programmi, e questi tentativi di efficienza in realtà hanno un forte impatto negativo per il debug e la manutenzione sono considerati Noi. dovremmo dimenticare piccole efficienze, dire circa il 97% del tempo: l'ottimizzazione prematura è la radice di tutto il male ...
cHao,

25

Il compilatore riconosce le costanti e converte le moltiplicazioni in turni ove appropriato.


Il compilatore riconosce costanti che sono potenze di 2 .... e si converte in turni. Non tutte le costanti possono essere cambiate in turni.
quick_now

4
@quickly_now: possono essere convertiti in combinazioni di turni e aggiunte / sottrazioni.
Mehrdad,

2
Un classico bug dell'ottimizzatore del compilatore è convertire le divisioni in turni giusti, che funzionano con dividendi positivi ma sono disattivati ​​di 1 per negativi.
lugubre il

1
@quickly_now Credo che il termine 'dove appropriato' copre l'idea che alcune costanti non possano essere riscritte come turni.
Pharap,

21

Se lo spostamento è più veloce della moltiplicazione dipende dall'architettura della CPU. Ai tempi del Pentium e precedenti, lo spostamento era spesso più veloce della moltiplicazione, a seconda del numero di 1 bit nel multiplicando. Ad esempio, se il tuo multiplicando era 320, vale 101000000, due bit.

a *= 320;               // Slower
a = (a<<7) + (a<<9);    // Faster

Ma se avessi più di due bit ...

a *= 324;                        // About same speed
a = (a<<2) + (a<<7) + (a<<9);    // About same speed

a *= 340;                                 // Faster
a = (a<<2) + (a<<4) + (a<<7) + (a<<9);    // Slower

Su un piccolo microcontrollore come un PIC18 con moltiplicazione a ciclo singolo, ma senza cambio a barilotto , la moltiplicazione è più veloce se si sposta di oltre 1 bit.

a  *= 2;   // Exactly the same speed
a <<= 1;   // Exactly the same speed

a  *= 4;   // Faster
a <<= 2;   // Slower

Si noti che questo è l' opposto di ciò che era vero sulle vecchie CPU Intel.

Ma non è ancora così semplice. Se ricordo bene, grazie alla sua architettura Superscalar, un Pentium è stato in grado di elaborare contemporaneamente un'istruzione di moltiplicazione o due istruzioni di spostamento (purché non dipendessero l'una dall'altra). Ciò significa che se si desidera moltiplicare due variabili per una potenza di 2, lo spostamento potrebbe essere migliore.

a  *= 4;   // 
b  *= 4;   // 

a <<= 2;   // Both lines execute in a single cycle
b <<= 2;   // 

5
+1 "Se lo spostamento è più veloce della moltiplicazione dipende dall'architettura della tua CPU." Grazie per essere entrato nella storia e aver dimostrato che la maggior parte dei miti del computer hanno effettivamente delle basi logiche.
Pharap,

11

Hai diversi problemi con il tuo programma di test.

Innanzitutto, non stai effettivamente utilizzando il valore di test. Non c'è modo, all'interno dello standard C, che il valore delle testcose. L'ottimizzatore è completamente gratuito per rimuoverlo. Una volta rimosso, il loop è effettivamente vuoto. L'unico effetto visibile sarebbe impostare runs = 100000000, ma runsnon viene utilizzato. Quindi l'ottimizzatore può (e dovrebbe!) Rimuovere l'intero ciclo. Correzione semplice: stampa anche il valore calcolato. Si noti che un ottimizzatore sufficientemente determinato potrebbe ancora ottimizzare il ciclo (si basa interamente su costanti note al momento della compilazione).

In secondo luogo, esegui due operazioni che si annullano a vicenda. L'ottimizzatore può notarlo e cancellarlo . Ancora una volta lasciando un anello vuoto e rimosso. Questo è decisamente difficile da risolvere. Puoi passare a un unsigned int(quindi l'overflow non è un comportamento indefinito), ma quello ovviamente porta solo a 0. E cose semplici (come, diciamo, test += 1) sono abbastanza facili da capire per l'ottimizzatore, e lo fa.

Infine, supponi che test *= 2verrà effettivamente compilato in modo moltiplicato. Questa è un'ottimizzazione molto semplice; se lo spostamento dei bit è più veloce, l'ottimizzatore lo utilizzerà invece. Per ovviare a questo, dovresti usare qualcosa come un assembly specifico per l'implementazione in linea.

Oppure, suppongo, basta controllare la scheda tecnica del microprocessore per vedere quale è più veloce.

Quando ho verificato l'output dell'assemblaggio della compilazione del programma con l' gcc -S -O3utilizzo della versione 4.9, l'ottimizzatore in realtà ha visto ogni semplice variazione sopra e molte altre. In tutti i casi, ha rimosso il ciclo (assegnando una costante a test), l'unica cosa rimasta erano le chiamate a clock(), la conversione / sottrazione e il printf.


1
Si noti inoltre che l'ottimizzatore può (e intende) ottimizzare le operazioni su costanti (anche in un ciclo) come mostrato in sqrt c # vs sqrt c ++ in cui l'ottimizzatore è stato in grado di sostituire un ciclo sommando un valore con la somma effettiva. Per annullare tale ottimizzazione è necessario utilizzare qualcosa determinato in fase di esecuzione (come un argomento della riga di comando).

@MichaelT Yep. Questo è ciò che intendevo con "Si noti che un ottimizzatore sufficientemente determinato potrebbe ancora ottimizzare il ciclo (si basa interamente su costanti note al momento della compilazione)".
derobert,

Ho capito cosa stai dicendo, ma non credo che il compilatore stia rimuovendo l'intero loop. Puoi facilmente provare questa teoria semplicemente aumentando il numero di iterazioni. Vedrai che aumentare le iterazioni rende il programma più lungo. Se il ciclo fosse completamente rimosso, non sarebbe così.
DollarAkshay,

@AkshayLAradhya Non posso dire cosa stia facendo il tuo compilatore, ma ho confermato di nuovo che gcc -O3(ora con 7.3) rimuove ancora del tutto il loop. (Assicurati di passare a long invece di int, se necessario, altrimenti lo ottimizza in un loop infinito a causa dell'overflow).
derobert,

8

Penso che sarebbe più utile per l'interrogante avere una risposta più differenziata, perché vedo diverse ipotesi non esaminate nelle domande e in alcune delle risposte o dei commenti.

Il runtime relativo risultante di spostamento e moltiplicazione non ha nulla a che fare con C. Quando dico C, non intendo l'istanza di un'implementazione specifica, come quella o quella versione di GCC, ma il linguaggio. Non intendo prendere questo annuncio assurdo, ma usare un esempio estremo per l'illustrazione: potresti implementare un compilatore C completamente conforme agli standard e far sì che la moltiplicazione richieda un'ora, mentre lo spostamento richiede millisecondi - o viceversa. Non sono a conoscenza di tali limiti di prestazione in C o C ++.

Potrebbe non interessarti di questo tecnicismo nell'argomentazione. La tua intenzione era probabilmente quella di testare le prestazioni relative dei turni rispetto alle moltiplicazioni e hai scelto C, perché è generalmente percepito come un linguaggio di programmazione di basso livello, quindi ci si può aspettare che il suo codice sorgente si traduca in istruzioni corrispondenti più direttamente. Tali domande sono molto comuni e penso che una buona risposta dovrebbe sottolineare che anche in C il codice sorgente non si traduce in istruzioni così direttamente come si potrebbe pensare in una determinata istanza. Di seguito ti ho dato alcuni possibili risultati di compilazione.

È qui che entrano in gioco i commenti che mettono in dubbio l'utilità di sostituire questa equivalenza nel software del mondo reale. Puoi vedere alcuni dei commenti alla tua domanda, come quello di Eric Lippert. È in linea con la reazione che generalmente otterrete da ingegneri più esperti in risposta a tali ottimizzazioni. Se usi i turni binari nel codice di produzione come mezzo generale di moltiplicazione e divisione, molto probabilmente le persone si piegheranno al tuo codice e avranno un certo grado di reazione emotiva ("Ho sentito questa affermazione senza senso fatta su JavaScript per l'amor del cielo") per ciò potrebbe non avere senso per i programmatori alle prime armi, a meno che non comprendano meglio le ragioni di quelle reazioni.

Tali ragioni sono principalmente una combinazione della ridotta leggibilità e futilità di tale ottimizzazione, come forse avrete già scoperto confrontando le loro prestazioni relative. Tuttavia, non credo che le persone avrebbero una reazione così forte se la sostituzione del cambiamento con la moltiplicazione fosse l'unico esempio di tali ottimizzazioni. Domande come la tua spesso sorgono in varie forme e in vari contesti. Penso che ciò a cui più ingegneri senior effettivamente reagiscono così fortemente, almeno a volte ho, è che esiste un potenziale per una gamma molto più ampia di danno quando le persone impiegano tali micro-ottimizzazioni liberamente attraverso la base di codice. Se lavori in un'azienda come Microsoft su una base di codice di grandi dimensioni, passerai molto tempo a leggere il codice sorgente di altri ingegneri o tenterai di individuare un determinato codice al suo interno. Potrebbe anche essere il tuo codice che proverai a dare un senso tra qualche anno, in particolare in alcuni dei momenti più inopportuni, come quando devi risolvere un'interruzione della produzione a seguito di una chiamata che hai ricevuto mentre cercavi dovere un venerdì sera, in procinto di uscire per una serata di divertimento con gli amici ... Se passi così tanto tempo a leggere il codice, apprezzerai che è il più leggibile possibile. Immagina di leggere il tuo romanzo preferito, ma l'editore ha deciso di pubblicare una nuova edizione in cui usano abbrv. tutto ovr th pl bc tuo thnk it svs spc. Questo è simile alle reazioni che altri ingegneri potrebbero avere sul tuo codice, se li cospargi di tali ottimizzazioni. Come hanno indicato altre risposte, è meglio indicare chiaramente cosa intendi,

Anche in quegli ambienti, tuttavia, potresti trovarti a risolvere una domanda di intervista in cui dovresti conoscere questa o qualche altra equivalenza. Conoscerli non è male e un buon ingegnere sarebbe consapevole dell'effetto aritmetico dello spostamento binario. Nota che non ho detto che questo rende un buon ingegnere, ma che un buon ingegnere lo saprebbe, secondo me. In particolare, potresti ancora trovare un manager, di solito verso la fine del tuo ciclo di interviste, che ti sorriderà ampiamente in attesa della delizia di rivelarti questo "trucco" ingegnoso intelligente in una domanda di codifica e dimostrare che lui / lei anch'esso era o è uno degli ingegneri esperti e non "solo" un manager. In quelle situazioni, cerca di sembrare impressionato e ringrazilo per l'intervista illuminante.

Perché non hai visto una differenza di velocità in C? La risposta più probabile è che entrambi hanno prodotto lo stesso codice assembly:

int shift(int i) { return i << 2; }
int multiply(int i) { return i * 2; }

Entrambi possono compilare in

shift(int):
    lea eax, [0+rdi*4]
    ret

Su GCC senza ottimizzazioni, ovvero utilizzando il flag "-O0", è possibile ottenere questo:

shift(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    sal eax, 2
    pop rbp
    ret
multiply(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    add eax, eax
    pop rbp
    ret

Come puoi vedere, passare "-O0" a GCC non significa che non sarà in qualche modo intelligente riguardo al tipo di codice che produce. In particolare, si noti che anche in questo caso il compilatore ha evitato l'uso di un'istruzione moltiplicata. Puoi ripetere lo stesso esperimento con turni di altri numeri e persino moltiplicazioni per numeri che non sono potenze di due. È probabile che sulla tua piattaforma vedrai una combinazione di turni e aggiunte, ma nessuna moltiplicazione. Sembra un po 'una coincidenza per il compilatore apparentemente evitare di usare le moltiplicazioni in tutti quei casi se le moltiplicazioni e i turni avessero davvero lo stesso costo, non è vero? Ma non intendo fornire supposizione come prova, quindi andiamo avanti.

Puoi rieseguire il test con il codice sopra e vedere se noti una differenza di velocità ora. Anche allora non stai testando shift contro moltiplicare, come puoi vedere dall'assenza di una moltiplicazione, ma il codice che è stato generato con un certo set di flag da GCC per le operazioni C di shift e si moltiplica in una particolare istanza . Pertanto, in un altro test è possibile modificare manualmente il codice assembly e utilizzare invece un'istruzione "imul" nel codice per il metodo "moltiplicare".

Se volessi sconfiggere alcuni di quei furbi del compilatore, potresti definire un metodo di spostamento e moltiplicazione più generale e finirai con qualcosa del genere:

int shift(int i, int j) { return i << j; }
int multiply(int i, int j) { return i * j; }

Che può produrre il seguente codice assembly:

shift(int, int):
    mov eax, edi
    mov ecx, esi
    sal eax, cl
    ret
multiply(int, int):
    mov eax, edi
    imul    eax, esi
    ret

Qui finalmente abbiamo, anche al massimo livello di ottimizzazione di GCC 4.9, l'espressione nelle istruzioni di assemblaggio che potresti aspettarti quando hai iniziato il tuo test. Penso che di per sé possa essere una lezione importante nell'ottimizzazione delle prestazioni. Possiamo vedere la differenza che ha fatto per sostituire le variabili con costanti concrete nel nostro codice, in termini di intelligenza che il compilatore è in grado di applicare. Le micro-ottimizzazioni come la sostituzione di moltiplicare i turni sono alcune ottimizzazioni di livello molto basso che un compilatore può fare facilmente da solo. Altre ottimizzazioni che hanno un impatto molto maggiore sulle prestazioni richiedono una comprensione dell'intenzione del codiceche spesso non è accessibile dal compilatore o può essere solo ipotizzato da un euristico. È qui che entri come ingegnere del software e di solito non comporta la sostituzione di moltiplicazioni con turni. Implica fattori come evitare una chiamata ridondante a un servizio che produce I / O e può bloccare un processo. Se vai sul tuo disco rigido o, per favore, in un database remoto per alcuni dati extra che potresti aver derivato da ciò che hai già in memoria, il tempo che passi in attesa supera l'esecuzione di un milione di istruzioni. Ora, penso che ci siamo allontanati un po 'dalla tua domanda originale, ma penso di indicarlo a un interrogante, specialmente se supponiamo che qualcuno abbia appena iniziato a capire la traduzione e l'esecuzione del codice,

Quindi, quale sarà più veloce? Penso che sia stato scelto un buon approccio per testare effettivamente la differenza di prestazioni. In generale, è facile essere sorpresi dalle prestazioni di runtime di alcune modifiche al codice. Esistono molte tecniche impiegate dai moderni processori e anche l'interazione tra software può essere complessa. Anche se dovessi ottenere risultati di prestazione vantaggiosi per un determinato cambiamento in una situazione, penso che sia pericoloso concludere che questo tipo di cambiamento produrrà sempre vantaggi in termini di prestazioni. Penso che sia pericoloso eseguire tali test una volta, dire "Okay, ora so quale è più veloce!" e quindi applicare indiscriminatamente la stessa ottimizzazione al codice di produzione senza ripetere le misurazioni.

E se lo spostamento fosse più veloce della moltiplicazione? Vi sono certamente indicazioni sul perché ciò sia vero. GCC, come puoi vedere sopra, sembra pensare (anche senza ottimizzazione) che evitare una moltiplicazione diretta a favore di altre istruzioni sia una buona idea. Il Manuale di riferimento per l'ottimizzazione delle architetture Intel 64 e IA-32 ti darà un'idea del costo relativo delle istruzioni della CPU. Un'altra risorsa, più focalizzata sulla latenza e il throughput delle istruzioni, è http://www.agner.org/optimize/instruction_tables.pdf. Si noti che non sono un buon predicatore del runtime assoluto, ma dell'esecuzione delle istruzioni l'una rispetto all'altra. In un circuito ristretto, mentre il test è in simulazione, la metrica del "throughput" dovrebbe essere più pertinente. È il numero di cicli per i quali un'unità di esecuzione verrà generalmente legata quando si esegue una determinata istruzione.

Quindi cosa succede se lo spostamento NON è più veloce della moltiplicazione? Come ho detto sopra, le architetture moderne possono essere piuttosto complesse e cose come la previsione del ramo, la memorizzazione nella cache, il pipelining e le unità di esecuzione parallela possono rendere difficile prevedere le prestazioni relative di due pezzi di codice logicamente equivalenti a volte. Voglio davvero enfatizzare questo aspetto, perché è qui che non sono contento della maggior parte delle risposte a domande come queste e con l'accampamento di persone che afferma apertamente che semplicemente non è più vero (più) che lo spostamento è più veloce della moltiplicazione.

No, per quanto ne so, negli anni '70 o in qualsiasi occasione non abbiamo inventato un po 'di salsa ingegneristica segreta per annullare improvvisamente la differenza di costo di un'unità di moltiplicazione e un po' di cambio. Una moltiplicazione generale, in termini di porte logiche, e certamente in termini di operazioni logiche, è ancora più complessa di uno spostamento con un cambio a barilotto in molti scenari, su molte architetture. Il modo in cui questo si traduce in un runtime complessivo su un computer desktop può essere un po 'opaco. Non so con certezza come siano implementati in processori specifici, ma ecco una spiegazione di una moltiplicazione: la moltiplicazione intera è davvero la stessa velocità dell'aggiunta sulla CPU moderna

Mentre qui è una spiegazione di un cambio a botte . I documenti a cui ho fatto riferimento nel paragrafo precedente forniscono un'altra visione del costo relativo delle operazioni, mediante delega delle istruzioni della CPU. Gli ingegneri di Intel sembrano spesso avere domande simili: i cicli di clock dei forum della zona degli sviluppatori Intel per la moltiplicazione dei numeri interi e l'aggiunta nel processore core 2 duo

Sì, nella maggior parte degli scenari della vita reale, e quasi sicuramente in JavaScript, tentare di sfruttare questa equivalenza per motivi di prestazioni è probabilmente un'impresa futile. Tuttavia, anche se abbiamo forzato l'uso delle istruzioni di moltiplicazione e non abbiamo riscontrato alcuna differenza nel tempo di esecuzione, ciò è più dovuto alla natura della metrica di costo che abbiamo usato, per essere precisi e non perché non vi è alcuna differenza di costo. Il runtime end-to-end è una metrica e se è l'unica a cui teniamo, va tutto bene. Ma ciò non significa che tutte le differenze di costo tra moltiplicazione e spostamento siano semplicemente scomparse. E penso che non sia certamente una buona idea trasmettere quell'idea a un interrogante, implicitamente o meno, che ovviamente sta appena iniziando a farsi un'idea dei fattori coinvolti nel tempo di esecuzione e nel costo del codice moderno. L'ingegneria riguarda sempre i compromessi. Indagine e spiegazione su quali compromessi hanno fatto i moderni processori per mostrare i tempi di esecuzione che noi, come utenti, finiscono per vedere, possono dare una risposta più differenziata. E penso che una risposta più differenziata di "questo semplicemente non è più vero" è giustificata se vogliamo vedere meno ingegneri controllare il codice micro-ottimizzato che annulla la leggibilità, perché ci vuole una comprensione più generale della natura di tali "ottimizzazioni" per individuare le sue varie e diverse incarnazioni rispetto al semplice riferimento ad alcuni casi specifici come obsoleti.


6

Quello che vedi è l'effetto dell'ottimizzatore.

Il lavoro degli ottimizzatori è quello di rendere il codice compilato risultante più piccolo o più veloce (ma raramente entrambi allo stesso tempo ... ma come molte cose ... DIPENDE da quale codice sia).

In PRINCIPIO, qualsiasi chiamata a una libreria di moltiplicazione o, spesso, persino l'uso di un moltiplicatore hardware sarà più lenta di un semplice spostamento di bit.

Quindi ... se il compilatore ingenuo generasse una chiamata a una libreria per l'operazione * 2, ovviamente funzionerebbe più lentamente di uno spostamento bit a bit *.

Tuttavia, gli ottimizzatori sono lì per rilevare schemi e capire come rendere il codice più piccolo / più veloce / qualunque cosa. E quello che hai visto è il compilatore che rileva che * 2 è lo stesso di un turno.

Proprio per motivi di interesse, proprio oggi stavo guardando l'assemblatore generato per alcune operazioni come * 5 ... non in realtà, ma altre cose, e lungo la strada noto che il compilatore aveva trasformato * 5 in:

  • cambio
  • cambio
  • aggiungi il numero originale

Quindi l'ottimizzatore del mio compilatore era abbastanza intelligente (almeno per alcune piccole costanti) per generare turni in linea e aggiunge invece di chiamate a una libreria di moltiplicazione per scopi generici.

L'arte degli ottimizzatori del compilatore è un argomento completamente separato, pieno di magia e veramente ben compreso da circa 6 persone su tutto il pianeta :)


3

Prova a cronometrarlo con:

for (runs = 0; runs < 100000000; runs++) {
      ;
}

Il compilatore dovrebbe riconoscere che il valore di testè invariato dopo ogni iterazione del ciclo e che il valore finale di testè inutilizzato ed eliminare del tutto il ciclo.


2

La moltiplicazione è una combinazione di turni e aggiunte.

Nel caso che hai citato, non credo che sia importante che il compilatore lo ottimizzi o meno: "moltiplica xper due" può essere implementato come:

  • Spostare i bit di xun posto a sinistra.
  • Aggiungi xa x.

Queste sono ciascuna operazioni atomiche di base; uno non è più veloce dell'altro.

Modificalo in "moltiplica xper quattro", (o qualsiasi altro 2^k, k>1) ed è un po 'diverso:

  • Spostare i bit di xdue punti a sinistra.
  • Aggiungi xa xe chiamalo y, aggiungi ya y.

Su un'architettura di base, è semplice vedere che il passaggio è più efficiente - prendendo una o due operazioni, dal momento che non possiamo aggiungere fino ya yquando non sappiamo cosa ysia.

Prova quest'ultimo (o qualsiasi altro 2^k, k>1), con le opzioni appropriate per impedire che tu li ottimizzi affinché siano la stessa cosa nell'implementazione. Dovresti trovare che il turno è più veloce, prendendo O(1)rispetto alla ripetuta aggiunta in O(k).

Ovviamente, dove il multiplicando non è una potenza di due, è necessaria una combinazione di turni e aggiunte (una in cui il numero di ciascuna è diverso da zero).


1
Che cos'è una "operazione atomica di base"? Non si potrebbe sostenere che in uno spostamento, l'operazione può essere applicata a tutti i bit in parallelo, mentre in aggiunta i bit più a sinistra dipendono dagli altri bit?
Bergi,

2
@Bergi: Immagino che significhi che sia shift che add sono istruzioni singole per la macchina. Dovresti consultare la documentazione del set di istruzioni per vedere i conteggi dei cicli per ciascuno, ma sì, un'aggiunta è spesso un'operazione a più cicli mentre uno spostamento viene solitamente eseguito in un singolo ciclo.
TMN,

Sì, potrebbe essere il caso, ma anche la moltiplicazione è una singola istruzione macchina (anche se ovviamente potrebbe richiedere più cicli)
Bergi

@Bergi, anche questo dipende dall'arco. A quale arco stai pensando che si sposta in meno cicli rispetto all'aggiunta a 32 bit (o x-bit, a seconda dei casi)?
OJFord,

Non conosco architetture particolari, no (e i miei corsi di ingegneria informatica sono sbiaditi), probabilmente entrambe le istruzioni richiedono meno di un ciclo. Probabilmente stavo pensando in termini di microcodice o persino porte logiche, dove uno spostamento sarebbe probabilmente più economico.
Bergi,

1

La moltiplicazione dei valori con o senza segno per potenze di due equivale allo spostamento a sinistra e la maggior parte dei compilatori effettuerà la sostituzione. La divisione dei valori senza segno o dei valori con segno che il compilatore può dimostrare non è mai negativa , equivale allo spostamento a destra e la maggior parte dei compilatori effettuerà tale sostituzione (anche se alcuni non sono abbastanza sofisticati da dimostrare quando i valori con segno non possono essere negativi) .

Va notato, tuttavia, che la divisione dei valori con segno potenzialmente negativo non equivale allo spostamento a destra. Un'espressione simile (x+8)>>4non è equivalente a (x+8)/16. Il primo, nel 99% dei compilatori, mapperà i valori da -24 a -9 a -1, da -8 a +7 a 0 e da +8 a +23 a 1 [arrotondando i numeri quasi simmetricamente rispetto allo zero]. Quest'ultimo mapperà da -39 a -24 a -1, da -23 a +7 a 0 e da +8 a +23 a +1 [grossolanamente asimmetrico, e probabilmente non quello che era previsto]. Si noti che anche quando non si prevede che i valori siano negativi, l'uso di >>4probabilmente produrrà codice più veloce rispetto a /16meno che il compilatore non possa dimostrare che i valori non possono essere negativi.


0

Altre informazioni che ho appena verificato.

Su x86_64, il codice operativo MUL ha una latenza di 10 cicli e un throughput di 1/2 ciclo. MOV, ADD e SHL hanno una latenza di 1 ciclo, con un rendimento di 2,5, 2,5 e 1,7 cicli.

Una moltiplicazione per 15 richiederebbe almeno 3 operazioni SHL e 3 ADD e probabilmente un paio di MOV.

https://gmplib.org/~tege/x86-timing.pdf


0

La tua metodologia è difettosa. L'incremento del ciclo e il controllo delle condizioni stesso richiedono molto tempo.

  • Prova a eseguire un ciclo vuoto e misura il tempo (chiamalo base).
  • Ora aggiungi 1 turno di lavoro e misura il tempo (chiamalo s1).
  • Quindi aggiungi 10 operazioni a turni e misura il tempo (chiamalo s2)

Se tutto procede correttamente base-s2dovrebbe essere 10 volte superiore a base-s1. Altrimenti qualcos'altro sta entrando in gioco qui.

Ora ho effettivamente provato questo da solo e ho pensato, se i loop causano un problema, perché non rimuoverli del tutto. Quindi sono andato avanti e ho fatto questo:

int main(){

    int test = 2;
    clock_t launch = clock();

    test << 6;
    test << 6;
    test << 6;
    test << 6;
    //.... 1 million times
    test << 6;

    clock_t done = clock();
    printf("Time taken : %d\n", done - launch);
    return 0;
}

E lì hai il tuo risultato

1 milione di turni in meno di 1 millisecondo? .

Ho fatto la stessa cosa per la moltiplicazione per 64 e ho ottenuto lo stesso risultato. Quindi probabilmente il compilatore sta ignorando completamente l'operazione poiché altri hanno menzionato che il valore di test non viene mai modificato.

Shiftwise Operator Result

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.