Se ritieni che un'istruzione DIV a 64 bit sia un buon modo per dividere per due, allora non c'è da meravigliarsi che l'output asm del compilatore abbia battuto il tuo codice scritto a mano, anche con -O0
(compilazione veloce, nessuna ottimizzazione aggiuntiva e memorizzazione / ricarica in memoria dopo / prima di ogni istruzione C in modo che un debugger possa modificare le variabili).
Consulta la guida al gruppo di ottimizzazione di Agner Fog per imparare a scrivere in modo efficiente. Ha anche tabelle di istruzioni e una guida al microarch per dettagli specifici per CPU specifiche. Vedi anche ilX 86 tag wiki per ulteriori collegamenti perf.
Vedi anche questa domanda più generale su come battere il compilatore con asm scritto a mano: il linguaggio assembly inline è più lento del codice C ++ nativo? . TL: DR: sì, se lo fai in modo sbagliato (come questa domanda).
Di solito stai bene lasciando che il compilatore faccia la sua cosa, specialmente se provi a scrivere C ++ che può compilare in modo efficiente . Vedi anche l' assemblaggio più veloce delle lingue compilate? . Una delle risposte si collega a queste diapositive ordinate che mostrano come vari compilatori C ottimizzano alcune funzioni davvero semplici con trucchi interessanti. Il discorso di Matt Godbolt su CppCon2017 “ Cosa ha fatto di recente il mio compilatore per me? Unbolting the Compiler's Lid ”ha una vena simile.
even:
mov rbx, 2
xor rdx, rdx
div rbx
Su Intel Haswell, div r64
è 36 uops, con una latenza di 32-96 cicli e una velocità effettiva di uno per 21-74 cicli. (Inoltre le 2 istruzioni per configurare RBX e zero RDX, ma l'esecuzione fuori servizio può essere eseguita in anticipo). Le istruzioni di conteggio elevato come DIV sono microcodificate, il che può anche causare colli di bottiglia front-end. In questo caso, la latenza è il fattore più rilevante perché fa parte di una catena di dipendenze trasportata da loop.
shr rax, 1
fa la stessa divisione senza segno: è 1 uop, con 1c di latenza e può eseguire 2 per ciclo di clock.
Per fare un confronto, la divisione a 32 bit è più veloce, ma è comunque orribile rispetto ai turni. idiv r32
è 9 uops, latenza 22-29c e una velocità effettiva 8-11c su Haswell.
Come puoi vedere -O0
dall'output di asm di gcc ( Godbolt compiler explorer ), usa solo le istruzioni di turni . clang -O0
si compila ingenuamente come pensavi, anche usando IDIV a 64 bit due volte. (Durante l'ottimizzazione, i compilatori usano entrambi gli output di IDIV quando l'origine esegue una divisione e un modulo con gli stessi operandi, se usano affatto IDIV)
GCC non ha una modalità totalmente ingenua; si trasforma sempre tramite GIMPLE, il che significa che alcune "ottimizzazioni" non possono essere disabilitate . Ciò include il riconoscimento della divisione per costante e l'utilizzo di turni (potenza di 2) o un inverso moltiplicativo in virgola fissa (non potenza di 2) per evitare l'IDIV (vedere div_by_13
nel link godbolt sopra).
gcc -Os
(Ottimizza per dimensione) fa uso IDIV per la divisione non-potere-su-2, purtroppo anche nei casi in cui il codice inverso moltiplicativo è solo leggermente più grande, ma molto più veloce.
Aiutare il compilatore
(riepilogo per questo caso: utilizzare uint64_t n
)
Prima di tutto, è interessante solo guardare l'output del compilatore ottimizzato. ( -O3
). -O0
la velocità è praticamente insignificante.
Guarda il tuo output asm (su Godbolt o vedi Come rimuovere il "rumore" dall'output dell'assieme GCC / clang? ). Quando il compilatore non crea il codice ottimale in primo luogo: scrivere il tuo sorgente C / C ++ in un modo che guidi il compilatore a creare codice migliore è di solito l'approccio migliore . Devi conoscere asm e sapere cos'è efficiente, ma applichi questa conoscenza indirettamente. I compilatori sono anche una buona fonte di idee: a volte clang farà qualcosa di interessante e puoi tenere a mano gcc nel fare la stessa cosa: vedi questa risposta e cosa ho fatto con il ciclo non srotolato nel codice di @ Veedrac di seguito.)
Questo approccio è portatile e in 20 anni alcuni compilatori futuri potranno compilarlo su qualsiasi cosa sia efficiente su hardware futuro (x86 o no), magari usando la nuova estensione ISA o l'auto-vettorializzazione. Asma x86-64 scritto a mano di 15 anni fa di solito non sarebbe stato ottimizzato in modo ottimale per Skylake. ad esempio la macro-fusione comparata e ramificata non esisteva allora. Ciò che è ottimale ora per asm fatti a mano per una microarchitettura potrebbe non essere ottimale per altre CPU attuali e future. I commenti sulla risposta di @ johnfound discutono delle principali differenze tra AMD Bulldozer e Intel Haswell, che hanno un grande effetto su questo codice. Ma in teoria, g++ -O3 -march=bdver3
e g++ -O3 -march=skylake
farà la cosa giusta. (Or -march=native
.) O -mtune=...
semplicemente per sintonizzarsi, senza usare le istruzioni che altre CPU potrebbero non supportare.
La mia sensazione è che guidare il compilatore ad affermare che è buono per una CPU attuale a cui tieni non dovrebbe essere un problema per i futuri compilatori. Si spera che siano migliori degli attuali compilatori nel trovare modi per trasformare il codice e trovare un modo che funzioni per le future CPU. Indipendentemente da ciò, il futuro x86 probabilmente non sarà terribile in nulla di buono sull'attuale x86 e il compilatore futuro eviterà eventuali insidie specifiche dell'asm mentre implementa qualcosa come il movimento dei dati dalla tua sorgente C, se non vede qualcosa di meglio.
L'asm scritto a mano è una scatola nera per l'ottimizzatore, quindi la propagazione costante non funziona quando l'inline rende un input una costante di tempo di compilazione. Anche altre ottimizzazioni sono interessate. Leggi https://gcc.gnu.org/wiki/DontUseInlineAsm prima di utilizzare asm. (Ed evitare l'asm inline in stile MSVC: gli input / output devono passare attraverso la memoria che aggiunge overhead .)
In questo caso : hai n
un tipo con segno e gcc usa la sequenza SAR / SHR / ADD che fornisce l'arrotondamento corretto. (IDIV e spostamento aritmetico "arrotondati" in modo diverso per gli ingressi negativi, vedere l' inserzione manuale di riferimento dell'inser SAR ). (IDK se gcc ha provato e non è riuscito a dimostrare che n
non può essere negativo, o cosa. L'overflow firmato è un comportamento indefinito, quindi avrebbe dovuto essere in grado di farlo.)
Avresti dovuto usare uint64_t n
, quindi può solo SHR. E quindi è portabile su sistemi con long
solo 32 bit (ad esempio Windows x86-64).
A proposito, l' output asm ottimizzato di gcc sembra piuttosto buono (usando unsigned long n
) : il ciclo interno in cui è allineato main()
fa questo:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
Il ciclo interno è privo di diramazioni e il percorso critico della catena di dipendenza trasportata dal ciclo è:
- LEA a 3 componenti (3 cicli)
- cmov (2 cicli su Haswell, 1c su Broadwell o successivo).
Totale: 5 cicli per iterazione, collo di bottiglia della latenza . L'esecuzione fuori ordine si occupa di tutto il resto in parallelo con questo (in teoria: non ho testato con i contatori perf per vedere se funziona davvero a 5c / iter).
L'ingresso FLAGS di cmov
(prodotto da TEST) è più veloce da produrre dell'ingresso RAX (da LEA-> MOV), quindi non è sul percorso critico.
Allo stesso modo, MOV-> SHR che produce l'ingresso RDI di CMOV è fuori dal percorso critico, perché è anche più veloce del LEA. MOV su IvyBridge e successivamente ha latenza zero (gestita al momento della ridenominazione del registro). (Ci vuole ancora un passaggio e uno slot nella pipeline, quindi non è gratuito, ma solo latenza zero). Il MOV aggiuntivo nella catena di dep di LEA fa parte del collo di bottiglia di altre CPU.
Anche il cmp / jne non fa parte del percorso critico: non è portato in loop, perché le dipendenze di controllo sono gestite con la previsione del ramo + esecuzione speculativa, a differenza delle dipendenze dei dati sul percorso critico.
Battere il compilatore
GCC ha fatto un ottimo lavoro qui. Potrebbe salvare un byte di codice usando inc edx
invece diadd edx, 1
, perché a nessuno importa di P4 e delle sue false dipendenze per le istruzioni di modifica del flag parziale.
Potrebbe anche salvare tutte le istruzioni MOV e TEST: SHR imposta CF = il bit spostato, quindi possiamo usare al cmovc
posto di test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Vedi la risposta di @ johnfound per un altro trucco intelligente: rimuovi il CMP ramificando il risultato della bandiera di SHR e utilizzandolo per CMOV: zero solo se n era 1 (o 0) per iniziare. (Fatto curioso : SHR con conteggio! = 1 su Nehalem o precedente provoca una stalla se leggi i risultati della bandiera . È così che l'hanno resa single-up. La codifica speciale shift-by-1 va bene, però.)
Evitare MOV non aiuta affatto con la latenza su Haswell ( Il MOV di x86 può davvero essere "libero"? Perché non riesco a riprodurlo affatto? ). Aiuta significativamente su CPU come Intel pre-IvB e la famiglia AMD Bulldozer, dove MOV non ha latenza zero. Le istruzioni MOV sprecate del compilatore influiscono sul percorso critico. Il complesso LEA e CMOV di BD hanno entrambi una latenza inferiore (2c e 1c rispettivamente), quindi è una frazione maggiore della latenza. Inoltre, i colli di bottiglia del throughput diventano un problema, perché ha solo due pipe ALU intere. Vedi la risposta di @ johnfound , dove ha i risultati di temporizzazione da una CPU AMD.
Anche su Haswell, questa versione può aiutare un po 'evitando alcuni ritardi occasionali in cui un uop non critico ruba una porta di esecuzione da una sul percorso critico, ritardando l'esecuzione di 1 ciclo. (Questo si chiama conflitto di risorse). Inoltre salva un registro, che può essere d'aiuto quando si eseguono più n
valori in parallelo in un ciclo interfogliato (vedere di seguito).
La latenza di LEA dipende dalla modalità di indirizzamento , dalle CPU della famiglia Intel SnB. 3c per 3 componenti ( [base+idx+const]
che richiede due aggiunte separate), ma solo 1c con 2 o meno componenti (una aggiunta). Alcune CPU (come Core2) eseguono persino un LEA a 3 componenti in un singolo ciclo, ma la famiglia SnB no. Peggio ancora, la famiglia Intel SnB standardizza le latenze in modo che non ci siano 2c uops , altrimenti il LEA a 3 componenti sarebbe solo 2c come il Bulldozer. (Anche il LEA a 3 componenti è più lento su AMD, ma non altrettanto).
Quindi lea rcx, [rax + rax*2]
/ inc rcx
è solo 2c latenza, più veloce di lea rcx, [rax + rax*2 + 1]
, su CPU della famiglia Intel SnB come Haswell. Break-even su BD, e peggio su Core2. Costa un extra in più, che normalmente non vale la pena per risparmiare 1c di latenza, ma la latenza è il principale collo di bottiglia qui e Haswell ha una pipeline abbastanza ampia da gestire il throughput in più di uop.
Né gcc, icc, né clang (su godbolt) hanno usato l'uscita CF di SHR, usando sempre un AND o TEST . Compilatori sciocchi. : P Sono pezzi fantastici di macchinari complessi, ma un essere umano intelligente può spesso batterli su problemi su piccola scala. (Dato migliaia o milioni di volte in più per pensarci, ovviamente! I compilatori non usano algoritmi esaurienti per cercare tutti i modi possibili per fare le cose, perché ciò richiederebbe troppo tempo per ottimizzare un sacco di codice incorporato, che è ciò che fanno meglio. Inoltre, non modellano la pipeline nella microarchitettura di destinazione, almeno non nello stesso dettaglio di IACA o altri strumenti di analisi statica; usano solo alcune euristiche.)
Lo srotolamento semplice del ciclo non aiuta ; questo collo di bottiglia colma la latenza di una catena di dipendenze trasportata da un ciclo, non sull'overhead / throughput del ciclo. Ciò significa che andrebbe bene con l'hyperthreading (o qualsiasi altro tipo di SMT), poiché la CPU ha molto tempo per intercalare le istruzioni da due thread. Ciò significherebbe parallelizzare il loop in main
, ma va bene perché ogni thread può semplicemente controllare un intervallo di n
valori e produrre una coppia di numeri interi come risultato.
Anche l'interleaving manuale all'interno di un singolo thread potrebbe essere praticabile . Forse calcola la sequenza per una coppia di numeri in parallelo, poiché ognuno prende solo un paio di registri e tutti possono aggiornare lo stesso max
/ maxi
. Questo crea più parallelismo a livello di istruzione .
Il trucco sta nel decidere se attendere fino a quando tutti i n
valori non sono stati raggiunti 1
prima di ottenere un'altra coppia di n
valori iniziali o se uscire e ottenere un nuovo punto iniziale per uno che ha raggiunto la condizione finale, senza toccare i registri per l'altra sequenza. Probabilmente è meglio mantenere ogni catena lavorando su dati utili, altrimenti dovresti incrementare condizionatamente il suo contatore.
Potresti anche farlo con roba di confronto di SSE per incrementare condizionalmente il contatore di elementi vettoriali che n
non erano 1
ancora stati raggiunti . E quindi per nascondere la latenza ancora più lunga di un'implementazione con incremento condizionale SIMD, dovresti mantenere più vettori di n
valori in aria. Forse vale solo con 256b vettoriale (4x uint64_t
).
Penso che la migliore strategia per rendere il rilevamento di un 1
"appiccicoso" sia mascherare il vettore di tutti quelli che aggiungi per incrementare il contatore. Quindi dopo aver visto a 1
in un elemento, il vettore di incremento avrà uno zero e + = 0 è un no-op.
Idea non testata per la vettorializzazione manuale
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Puoi e dovresti implementarlo con intrinseci invece di asm scritti a mano.
Miglioramento algoritmico / di implementazione:
Oltre a implementare la stessa logica con un asm più efficiente, cerca modi per semplificare la logica o evitare lavori ridondanti. ad esempio memoize per rilevare finali comuni alle sequenze. O ancora meglio, guarda 8 bit finali contemporaneamente (risposta di Gnasher)
@EOF sottolinea che tzcnt
(o bsf
) potrebbe essere utilizzato per eseguire più n/=2
iterazioni in un solo passaggio. Questo è probabilmente meglio del vettorializzare SIMD; nessuna istruzione SSE o AVX può farlo. n
Tuttavia, è comunque compatibile con l'esecuzione di più scalari in parallelo in diversi registri interi.
Quindi il loop potrebbe apparire così:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Ciò può comportare un numero significativamente inferiore di iterazioni, ma gli spostamenti del conteggio variabile sono lenti sulle CPU della famiglia Intel SnB senza BMI2. 3 uops, 2c latenza. (Hanno una dipendenza di input dai FLAGS perché count = 0 significa che i flag non sono modificati. Gestiscono questo come una dipendenza di dati e prendono più uops perché un uop può avere solo 2 input (pre-HSW / BDW comunque)). Questo è il tipo a cui si riferiscono le persone che si lamentano del design crazy-CISC di x86. Rende le CPU x86 più lente di quanto sarebbero se l'ISA fosse stata progettata da zero oggi, anche in modo per lo più simile. (ovvero fa parte della "tassa x86" che costa velocità / potenza.) SHRX / SHLX / SARX (BMI2) sono una grande vittoria (latenza 1 uop / 1c).
Mette anche tzcnt (3c su Haswell e versioni successive) sul percorso critico, quindi allunga in modo significativo la latenza totale della catena di dipendenze trasportata da loop. Tuttavia, rimuove qualsiasi necessità di un CMOV o di preparare un registro n>>1
. La risposta di @Veedrac supera tutto ciò rinviando tzcnt / shift per più iterazioni, il che è molto efficace (vedi sotto).
Possiamo tranquillamente usare BSF o TZCNT in modo intercambiabile, perché n
non possiamo mai essere zero in quel punto. Il codice macchina di TZCNT decodifica come BSF su CPU che non supportano BMI1. (I prefissi privi di significato vengono ignorati, quindi REP BSF funziona come BSF).
TZCNT funziona molto meglio di BSF su CPU AMD che lo supportano, quindi può essere una buona idea usare REP BSF
, anche se non ti interessa impostare ZF se l'ingresso è zero anziché l'uscita. Alcuni compilatori lo fanno quando lo usi __builtin_ctzll
anche con -mno-bmi
.
Funzionano allo stesso modo sulle CPU Intel, quindi salva il byte se è tutto ciò che conta. TZCNT su Intel (pre-Skylake) ha ancora una falsa dipendenza dal presunto operando di output di sola scrittura, proprio come BSF, per supportare il comportamento non documentato che BSF con input = 0 lascia la sua destinazione non modificata. Quindi è necessario aggirare questo a meno che non si ottimizzi solo per Skylake, quindi non c'è nulla da guadagnare dal byte REP aggiuntivo. (Intel va spesso al di là di ciò che richiede il manuale ISA x86, per evitare di rompere il codice ampiamente usato che dipende da qualcosa che non dovrebbe o che è vietato retroattivamente. Ad esempio, Windows 9x non assume alcun prefetching speculativo delle voci TLB , il che era sicuro quando è stato scritto il codice, prima che Intel aggiornasse le regole di gestione TLB .)
Ad ogni modo, LZCNT / TZCNT su Haswell hanno lo stesso falso dep di POPCNT: vedi queste domande e risposte . Questo è il motivo per cui nell'output asm di gcc per il codice di @ Veedrac, lo vedi spezzare la catena di dep con xor-zero sul registro che sta per usare come destinazione TZCNT quando non usa dst = src. Poiché TZCNT / LZCNT / POPCNT non lasciano mai la loro destinazione indefinita o non modificata, questa falsa dipendenza dall'output su CPU Intel è un bug / limitazione delle prestazioni. Presumibilmente vale la pena di alcuni transistor / potenza per farli comportare come altri uops che vanno alla stessa unità di esecuzione. L'unico lato positivo di perf è l'interazione con un'altra limitazione uarch: possono microfondere un operando di memoria con una modalità di indirizzamento indicizzato su Haswell, ma su Skylake in cui Intel ha rimosso il falso dep per LZCNT / TZCNT hanno "dis-laminato" le modalità di indirizzamento indicizzato mentre POPCNT può ancora microfondere qualsiasi modalità addr.
Miglioramenti alle idee / al codice da altre risposte:
La risposta di @ hidefromkgb ha una bella osservazione secondo cui sei sicuro di poter fare un turno giusto dopo un 3n + 1. Puoi calcolarlo in modo ancora più efficiente che tralasciando i controlli tra i passaggi. L'implementazione asm in quella risposta è interrotta, tuttavia (dipende da OF, che non è definito dopo SHRD con un conteggio> 1), e lento: ROR rdi,2
è più veloce di SHRD rdi,rdi,2
, e l'uso di due istruzioni CMOV sul percorso critico è più lento di un ulteriore TEST che può funzionare in parallelo.
Ho messo in ordine / migliorato C (che guida il compilatore a produrre meglio asm), e testato + lavorando più velocemente asm (nei commenti sotto la C) su Godbolt: vedi il link nella risposta di @ hidefromkgb . (Questa risposta ha raggiunto il limite di 30k caratteri dagli URL Godbolt di grandi dimensioni, ma i collegamenti brevi possono marcire ed erano comunque troppo lunghi per goo.gl.)
Migliorata anche la stampa di output per convertirla in una stringa e crearne una write()
invece di scrivere un carattere alla volta. Questo riduce al minimo l'impatto sul cronometraggio dell'intero programma perf stat ./collatz
(per registrare i contatori delle prestazioni) e ho offuscato alcune delle asm non critiche.
@ Codice di Veedrac
Ho avuto una piccola accelerazione dal cambio a destra tanto quanto sappiamo che bisogna fare, e controllando per continuare il ciclo. Da 7.5s per limite = 1e8 fino a 7.275s, su Core2Duo (Merom), con un fattore di srotolamento di 16.
codice + commenti su Godbolt . Non usare questa versione con clang; fa qualcosa di stupido con il differimento. Usare un contatore tmp k
e poi aggiungerlo per count
cambiare in seguito cosa fa clang, ma questo fa leggermente male a gcc.
Vedi la discussione nei commenti: il codice di Veedrac è eccellente su CPU con BMI1 (cioè non Celeron / Pentium)