Codice C ++ per testare la congettura di Collatz più velocemente dell'assemblaggio scritto a mano - perché?


833

Ho scritto queste due soluzioni per Project Euler Q14 , in assembly e in C ++. Sono lo stesso identico approccio della forza bruta per testare la congettura di Collatz . La soluzione di assemblaggio è stata assemblata con

nasm -felf64 p14.asm && gcc p14.o -o p14

È stato compilato il C ++

g++ p14.cpp -o p14

Assemblaggio, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Conosco le ottimizzazioni del compilatore per migliorare la velocità e tutto il resto, ma non vedo molti modi per ottimizzare ulteriormente la mia soluzione di assemblaggio (parlando programmaticamente non matematicamente).

Il codice C ++ ha modulo ogni termine e divisione ogni termine pari, dove assembly è solo una divisione per termine pari.

Ma l'assembly richiede in media 1 secondo in più rispetto alla soluzione C ++. Perchè è questo? Chiedo principalmente per curiosità.

Tempi di esecuzione

Il mio sistema: Linux a 64 bit su Intel Celeron 2955U (microarchitettura Haswell) a 1,4 GHz.


232
Hai esaminato il codice assembly che GCC genera per il tuo programma C ++?
Ruakh

69
Compilare con -Sper ottenere l'assembly generato dal compilatore. Il compilatore è abbastanza intelligente da capire che il modulo fa la divisione allo stesso tempo.
user3386109

267
Penso che le tue opzioni siano 1. La tua tecnica di misurazione è imperfetta, 2. Il compilatore scrive un assemblaggio migliore che tu, o 3. Il compilatore usa la magia.
Galik,


18
@jefferson Il compilatore può usare una forza bruta più veloce. Ad esempio, forse con le istruzioni SSE.
user253751

Risposte:


1896

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 il 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, 1fa 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 -O0dall'output di asm di gcc ( Godbolt compiler explorer ), usa solo le istruzioni di turni . clang -O0si 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_13nel 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). -O0la 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=bdver3e g++ -O3 -march=skylakefarà 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 nun 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 nnon 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 longsolo 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 edxinvece 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 cmovcposto 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ù nvalori 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 nvalori 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 nvalori non sono stati raggiunti 1prima di ottenere un'altra coppia di nvalori 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 nnon erano 1ancora stati raggiunti . E quindi per nascondere la latenza ancora più lunga di un'implementazione con incremento condizionale SIMD, dovresti mantenere più vettori di nvalori 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 1in 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/=2iterazioni in un solo passaggio. Questo è probabilmente meglio del vettorializzare SIMD; nessuna istruzione SSE o AVX può farlo. nTuttavia, è 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é nnon 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_ctzllanche 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 ke poi aggiungerlo per countcambiare 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)


4
Ho provato l'approccio vettorializzato qualche tempo fa, non mi è stato di aiuto (perché puoi fare molto meglio con il codice scalare tzcnte sei bloccato nella sequenza più lunga tra i tuoi elementi vettoriali nel caso vettorializzato).
EOF

3
@EOF: no, volevo dire uscire dal circuito interno quando uno degli elementi vettoriali colpisce 1, invece di quando tutti (facilmente rilevabile con PCMPEQ / PMOVMSK). Quindi usi PINSRQ e roba per giocherellare con l'unico elemento che ha terminato (e i suoi contatori), e tornare indietro nel loop. Ciò può facilmente trasformarsi in una perdita, quando si esce troppo spesso dal ciclo interno, ma significa che si ottengono sempre 2 o 4 elementi di lavoro utile in ogni iterazione del ciclo interno. Un buon punto sulla memoizzazione, però.
Peter Cordes,

4
@jefferson Il migliore che ho gestito è godbolt.org/g/1N70Ib . Speravo di poter fare qualcosa di più intelligente, ma sembra di no.
Veedrac,

87
La cosa che mi stupisce di risposte incredibili come questa è la conoscenza mostrata a tale dettaglio. Non conoscerò mai una lingua o un sistema a quel livello e non saprei come. Ben fatto signore.
camden_kid

8
Risposta leggendaria !!
Sumit Jain,

104

Affermare che il compilatore C ++ può produrre codice più ottimale di un programmatore di linguaggio assembly competente è un errore molto grave. E soprattutto in questo caso. L'umano può sempre rendere il codice migliore del compilatore, e questa particolare situazione è una buona illustrazione di questa affermazione.

La differenza di temporizzazione che stai vedendo è perché il codice assembly nella domanda è molto lontano dall'ottimale nei loop interni.

(Il codice seguente è a 32 bit, ma può essere facilmente convertito in 64 bit)

Ad esempio, la funzione sequenza può essere ottimizzata in sole 5 istruzioni:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

L'intero codice è simile a:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Per compilare questo codice, è necessario FreshLib .

Nei miei test (processore AMD A4-1200 da 1 GHz), il codice sopra riportato è circa quattro volte più veloce del codice C ++ dalla domanda (se compilato con -O0: 430 ms contro 1900 ms) e più di due volte più veloce (430 ms vs. 830 ms) quando viene compilato il codice C ++ -O3.

L'output di entrambi i programmi è lo stesso: sequenza massima = 525 su i = 837799.


6
Huh, è intelligente. SHR imposta ZF solo se EAX era 1 (o 0). L'ho perso durante l'ottimizzazione -O3dell'output di gcc , ma ho individuato tutte le altre ottimizzazioni apportate al ciclo interno. (Ma perché usi LEA per l'incremento del contatore invece di INC? Va bene in questo momento bloccare le bandiere e portare a un rallentamento su qualsiasi cosa tranne forse P4 (falsa dipendenza dalle vecchie bandiere sia per INC che per SHR). LEA può " funzionare su altrettante porte e potrebbe portare a conflitti di risorse che ritardano il percorso critico più spesso.)
Peter Cordes

4
Oh, in realtà Bulldozer potrebbe strozzare il throughput con l'output del compilatore. Ha un CMOV a latenza inferiore e un LEA a 3 componenti rispetto a Haswell (che stavo prendendo in considerazione), quindi la catena di dep trasportata da loop ha solo 3 cicli nel tuo codice. Inoltre non ha istruzioni MOV a latenza zero per i registri di numeri interi, quindi le istruzioni MOV sprecate di g ++ aumentano effettivamente la latenza del percorso critico e sono un grosso problema per Bulldozer. Quindi sì, l'ottimizzazione della mano batte davvero il compilatore in modo significativo per le CPU che non sono abbastanza ultramoderne da masticare le istruzioni inutili.
Peter Cordes,

95
" Affermare meglio il compilatore C ++ è un errore molto grave. E soprattutto in questo caso. L'umano può sempre migliorare il codice che questo e questo particolare problema sono una buona illustrazione di questa affermazione. " Puoi invertirlo e sarebbe altrettanto valido . " Affermare che un essere umano è meglio è un grave errore. E soprattutto in questo caso. L'essere umano può sempre peggiorare il codice che la e questa domanda particolare è una buona illustrazione di questa affermazione. " Quindi non penso che tu abbia un punto qui , tali generalizzazioni sono sbagliate.
luk32,

5
@ luk32 - Ma l'autore della domanda non può essere affatto un argomento, perché la sua conoscenza del linguaggio assembly è vicina allo zero. Ogni argomento sul compilatore umano vs, presuppone implicitamente umano con almeno un livello medio di conoscenza asm. Inoltre: il teorema "Il codice scritto umano sarà sempre migliore o uguale al codice generato dal compilatore" è molto facile da dimostrare formalmente.
johnfound

30
@ luk32: un umano abile può (e di solito dovrebbe) iniziare con l'output del compilatore. Quindi, purché tu analizzi i tuoi tentativi di assicurarti che siano effettivamente più veloci (sull'hardware di destinazione per cui stai ottimizzando), non puoi fare di peggio del compilatore. Ma sì, devo ammettere che è un po 'una dichiarazione forte. I compilatori di solito fanno molto meglio dei programmatori asm principianti. Ma di solito è possibile salvare un'istruzione o due rispetto a ciò che viene fornito dai compilatori. (Non sempre sul percorso critico, tuttavia, a seconda di Uarch). Sono pezzi molto utili di macchinari complessi, ma non sono "intelligenti".
Peter Cordes,

24

Per maggiori prestazioni: una semplice modifica sta osservando che dopo n = 3n + 1, n sarà pari, quindi puoi dividere immediatamente per 2. E n non sarà 1, quindi non è necessario testarlo. Quindi potresti salvare alcune istruzioni if ​​e scrivere:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Ecco una grande vittoria: se guardi gli 8 bit più bassi di n, tutti i passaggi fino a quando non dividi 2 per otto volte sono completamente determinati da quegli otto bit. Ad esempio, se gli ultimi otto bit sono 0x01, questo è in binario il tuo numero è ???? 0000 0001 quindi i passi successivi sono:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Quindi tutti questi passaggi possono essere previsti e 256k + 1 viene sostituito con 81k + 1. Qualcosa di simile accadrà per tutte le combinazioni. Quindi puoi creare un ciclo con una grande istruzione switch:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Esegui il ciclo fino a n ≤ 128, perché a quel punto n potrebbe diventare 1 con meno di otto divisioni per 2, e fare otto o più passaggi alla volta ti farebbe perdere il punto in cui raggiungi 1 per la prima volta. Quindi continua il ciclo "normale" o prepara una tabella che ti dice quanti altri passaggi sono necessari per raggiungere 1.

PS. Sospetto fortemente che il suggerimento di Peter Cordes lo renderebbe ancora più veloce. Non ci saranno affatto rami condizionali tranne uno, e quello sarà previsto correttamente tranne quando il ciclo termina effettivamente. Quindi il codice sarebbe qualcosa di simile

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

In pratica, si misurerebbe se l'elaborazione degli ultimi 9, 10, 11, 12 bit di n alla volta sarebbe più veloce. Per ogni bit, il numero di voci nella tabella raddoppierebbe e mi aspetto un rallentamento quando le tabelle non rientrano più nella cache L1.

PPS. Se hai bisogno del numero di operazioni: in ogni iterazione facciamo esattamente otto divisioni per due e un numero variabile di (3n + 1) operazioni, quindi un metodo ovvio per contare le operazioni sarebbe un altro array. Ma possiamo effettivamente calcolare il numero di passaggi (basato sul numero di iterazioni del ciclo).

Potremmo ridefinire leggermente il problema: sostituire n con (3n + 1) / 2 se dispari e sostituire n con n / 2 se pari. Quindi ogni iterazione farà esattamente 8 passaggi, ma potresti considerare che barare :-) Quindi supponi che ci fossero r operazioni n <- 3n + 1 e s operazioni n <- n / 2. Il risultato sarà esattamente n '= n * 3 ^ r / 2 ^ s, perché n <- 3n + 1 significa n <- 3n * (1 + 1 / 3n). Prendendo il logaritmo troviamo r = (s + log2 (n '/ n)) / log2 (3).

Se eseguiamo il ciclo fino a n ≤ 1.000.000 e disponiamo di una tabella pre-calcolata quante iterazioni sono necessarie da qualsiasi punto iniziale n ≤ 1.000.000, il calcolo di r come sopra, arrotondato al numero intero più vicino, darà il risultato giusto a meno che s sia veramente grande.


2
Oppure crea tabelle di ricerca dei dati per la moltiplicazione e aggiungi costanti, anziché uno switch. L'indicizzazione di due tabelle a 256 voci è più rapida di una tabella di salto e probabilmente i compilatori non stanno cercando tale trasformazione.
Peter Cordes,

1
Hmm, ho pensato per un minuto che questa osservazione potesse provare la congettura di Collatz, ma no, certo che no. Per ogni possibile trailing 8 bit, c'è un numero finito di passaggi fino a quando non sono andati tutti. Ma alcuni di questi schemi finali a 8 bit allungheranno il resto della stringa di bit di oltre 8, quindi questo non può escludere una crescita illimitata o un ciclo ripetuto.
Peter Cordes,

Per aggiornare count, è necessario un terzo array, giusto? adders[]non ti dice quanti spostamenti a destra sono stati fatti.
Peter Cordes,

Per tabelle più grandi, varrebbe la pena utilizzare tipi più stretti per aumentare la densità della cache. Sulla maggior parte delle architetture, un carico a estensione zero da a uint16_tè molto economico. Su x86, è economico quanto l'estensione zero da 32-bit unsigned inta uint64_t. (MOVZX dalla memoria su CPU Intel necessita solo di un UOP carico porte, ma le CPU AMD hanno bisogno l'ALU pure.) Oh BTW, perché stai usando size_tper lastBits? È un tipo a 32 bit con -m32e persino -mx32(modalità lunga con puntatori a 32 bit). È sicuramente il tipo sbagliato per n. Basta usare unsigned.
Peter Cordes,

20

Su una nota piuttosto non correlata: più hack di prestazioni!

  • [la prima «congettura» è stata finalmente sfatata da @ShreevatsaR; rimosso]

  • Durante l'attraversamento della sequenza, possiamo ottenere solo 3 casi possibili nel 2 vicinato dell'elemento corrente N(mostrato per primo):

    1. [pari dispari]
    2. [pari e dispari]
    3. [pari] [pari]

    Superare questi 2 elementi significa calcolare (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1e N >> 2, rispettivamente.

    Let`s dimostrano che per entrambi i casi (1) e (2) è possibile utilizzare la prima formula (N >> 1) + N + 1.

    Il caso (1) è ovvio. Il caso (2) implica (N & 1) == 1, quindi se assumiamo (senza perdita di generalità) che N è lungo 2 bit e che i suoi bit sono badal più al meno significativo, quindi a = 1, e vale quanto segue:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    dove B = !b. Spostando a destra il primo risultato ci dà esattamente ciò che vogliamo.

    QED: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Come dimostrato, possiamo attraversare gli elementi della sequenza 2 alla volta, usando un'unica operazione ternaria. Un'altra riduzione del tempo 2 ×.

L'algoritmo risultante è simile al seguente:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Qui confrontiamo n > 2perché il processo può fermarsi a 2 invece di 1 se la lunghezza totale della sequenza è dispari.

[MODIFICARE:]

Traduciamolo in assembly!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Utilizzare questi comandi per compilare:

nasm -f elf64 file.asm
ld -o file file.o

Guarda la C e una versione migliorata / corretta dell'asm di Peter Cordes su Godbolt . (nota del redattore: mi dispiace di aver inserito le mie cose nella tua risposta, ma la mia risposta ha raggiunto il limite di 30k caratteri dai link Godbolt + testo!)


2
Non esiste un integrale Qtale 12 = 3Q + 1. Il tuo primo punto non è giusto, pensa.
Veedrac,

1
@Veedrac: ci sto giocando: può essere implementato con una migliore rispetto all'implementazione in questa risposta, usando ROR / TEST e un solo CMOV. Questo codice asm loop infinito sulla mia CPU, poiché apparentemente si basa su OF, che non è definito dopo SHRD o ROR con conteggio> 1. Va anche molto lontano per cercare di evitare mov reg, imm32, apparentemente per salvare byte, ma poi usa il Versione a 64 bit del registro ovunque, anche per xor rax, rax, quindi ha molti prefissi REX non necessari. Ovviamente abbiamo solo bisogno di REX sui reg che si tengono nnel circuito interno per evitare il trabocco.
Peter Cordes,

1
Risultati di temporizzazione (da un Core2Duo E6600: Merom 2.4GHz. Complex-LEA = latenza 1c, CMOV = 2c) . La migliore implementazione a ciclo interno asm a singolo passaggio (da Johnfound): 111 ms per ciclo di questo ciclo @main. Uscita del compilatore dalla mia versione de-offuscata di questo C (con alcuni tmp vars): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. Dalla mia versione migliorata dell'asm inner loop di clang: 92ms (dovrebbe vedere un miglioramento molto più grande sulla famiglia SnB, dove il LEA complesso è 3c e non 1c). Dalla mia versione migliorata + funzionante di questo asm loop (usando ROR + TEST, non SHRD): 87ms. Misurato con 5 ripetizioni prima di stampare
Peter Cordes,

2
Ecco i primi 66 record-setter (A006877 su OEIS); Ho segnato quelli in grassetto: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 1501353 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
@hidefromkgb Ottimo! E ora apprezzo anche gli altri tuoi punti: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1 e 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Bella osservazione!
ShreevatsaR,

6

I programmi C ++ vengono tradotti in programmi di assemblaggio durante la generazione del codice macchina dal codice sorgente. Sarebbe praticamente sbagliato dire che l'assemblaggio è più lento del C ++. Inoltre, il codice binario generato differisce da compilatore a compilatore. Quindi un compilatore C ++ intelligente può produrre un codice binario più ottimale ed efficiente del codice di un muto assemblatore.

Tuttavia, credo che la tua metodologia di profilazione abbia alcuni difetti. Di seguito sono riportate le linee guida generali per la profilazione:

  1. Assicurarsi che il sistema sia nello stato normale / inattivo. Interrompere tutti i processi in esecuzione (applicazioni) avviati o che utilizzano la CPU in modo intensivo (o polling sulla rete).
  2. La dimensione dei dati deve essere maggiore.
  3. Il test deve essere eseguito per più di 5-10 secondi.
  4. Non fare affidamento su un solo campione. Esegui il tuo test N volte. Raccogliere i risultati e calcolare la media o la mediana del risultato.

Sì, non ho eseguito alcuna profilazione formale, ma le ho eseguite entrambe alcune volte e sono in grado di distinguere 2 secondi da 3 secondi. Comunque grazie per aver risposto. Ho già raccolto molte informazioni qui
jeffer son

9
Probabilmente non è solo un errore di misurazione, il codice asm scritto a mano utilizza un'istruzione DIV a 64 bit anziché uno spostamento a destra. Vedi la mia risposta Ma sì, anche la misurazione corretta è importante.
Peter Cordes,

7
I punti elenco hanno una formattazione più appropriata di un blocco di codice. Smetti di inserire il testo in un blocco di codice, perché non è un codice e non beneficia di un carattere a spaziatura fissa.
Peter Cordes,

16
Non vedo davvero come questo risponda alla domanda. Questa non è una vaga domanda se il codice assembly o il codice C ++ potrebbe essere più veloce --- è una domanda molto specifica sul codice effettivo , che è stato utile nella domanda stessa. La tua risposta non menziona nemmeno nessuno di quel codice, né fa alcun tipo di confronto. Certo, i tuoi consigli su come eseguire il benchmark sono sostanzialmente corretti, ma non abbastanza per dare una risposta effettiva.
Cody Grey

6

Per il problema Collatz, è possibile ottenere un aumento significativo delle prestazioni memorizzando nella cache le "code". Questo è un compromesso tempo / memoria. Vedi: memoization ( https://en.wikipedia.org/wiki/Memoization ). Potresti anche cercare soluzioni di programmazione dinamica per altri compromessi tempo / memoria.

Esempio di implementazione di Python:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
La risposta di gnasher mostra che puoi fare molto di più della semplice memorizzazione nella cache delle code: i bit alti non influiscono su ciò che accade dopo, e aggiungi / mul propagano solo il trasporto a sinistra, quindi i bit alti non influenzano ciò che accade ai bit bassi. cioè puoi usare le ricerche LUT per andare 8 (o qualsiasi numero) di bit alla volta, con moltiplicare e aggiungere costanti da applicare al resto dei bit. memorizzare le code è sicuramente utile in molti problemi come questo e per questo problema quando non hai ancora pensato all'approccio migliore o non lo hai dimostrato correttamente.
Peter Cordes,

2
Se capisco correttamente l'idea di gnasher sopra, penso che la memoizzazione della coda sia un'ottimizzazione ortogonale. Quindi puoi concepibilmente fare entrambe le cose. Sarebbe interessante indagare quanto potresti guadagnare aggiungendo la memoizzazione all'algoritmo di gnasher.
Emanuel Landeholm,

2
Possiamo forse rendere la memorizzazione più economica memorizzando solo la parte densa dei risultati. Imposta un limite superiore su N e, soprattutto, non controlla nemmeno la memoria. Di seguito, usa hash (N) -> N come funzione hash, quindi key = position nella matrice e non ha bisogno di essere memorizzato. Una voce di 0mezzi non ancora presente. Possiamo ottimizzare ulteriormente memorizzando solo N dispari nella tabella, quindi la funzione hash è n>>1, scartando 1. Scrivere il codice passo per finire sempre con un n>>tzcnt(n)o qualcosa per assicurarsi che sia dispari.
Peter Cordes,

1
Si basa sulla mia idea (non testata) secondo cui valori N molto grandi nel mezzo di una sequenza hanno meno probabilità di essere comuni a più sequenze, quindi non perdiamo troppo a non memorizzarli. Inoltre, una N di dimensioni ragionevoli farà parte di molte sequenze lunghe, anche quelle che iniziano con una N molto grande (questo può essere un pio desiderio; se è sbagliato, solo la memorizzazione nella cache di un intervallo denso di N consecutivi può perdere rispetto a un hash tabella in grado di memorizzare chiavi arbitrarie.) Hai mai fatto qualche tipo di test della frequenza dei colpi per vedere se N vicino nelle vicinanze tende ad avere qualche somiglianza nei loro valori di sequenza?
Peter Cordes,

2
Puoi semplicemente memorizzare i risultati pre-calcolati per tutti n <N, per alcuni N. grandi Quindi non hai bisogno del sovraccarico di una tabella hash. I dati in quella tabella verranno eventualmente utilizzati per ogni valore iniziale. Se vuoi solo confermare che la sequenza Collatz termina sempre in (1, 4, 2, 1, 4, 2, ...): questo può essere dimostrato essere equivalente a provare che per n> 1, la sequenza alla fine essere inferiore all'originale n. E per questo, la cache delle code non aiuterà.
gnasher729,

5

Dai commenti:

Ma questo codice non si ferma mai (a causa del trabocco di numeri interi)!?! Yves Daoust

Per molti numeri non traboccerà.

Se sarà traboccherà - per uno di quei semi iniziali sfortunati, il numero è sorvolato molto probabilmente convergere verso 1 senza un'altra troppo pieno.

Ciò pone ancora una domanda interessante, c'è qualche numero di seme ciclico traboccante?

Qualsiasi semplice serie convergente finale inizia con una potenza di due valori (abbastanza ovvio?).

2 ^ 64 si sovrapporrà a zero, che è un ciclo infinito indefinito secondo l'algoritmo (termina solo con 1), ma la soluzione più ottimale in risposta finirà a causa della shr raxproduzione di ZF = 1.

Possiamo produrre 2 ^ 64? Se il numero iniziale è 0x5555555555555555, è un numero dispari, il numero successivo è quindi 3n + 1, che è 0xFFFFFFFFFFFFFFFF + 1= 0. Teoricamente in uno stato indefinito di algoritmo, ma la risposta ottimizzata di johnfound si riprenderà uscendo da ZF = 1. The cmp rax,1of Peter Cordes terminerà con un ciclo infinito (variante 1 QED, "cheapo" attraverso un 0numero indefinito ).

Che ne dici di un numero più complesso, che creerà il ciclo senza 0? Francamente, non sono sicuro, la mia teoria matematica è troppo confusa per avere un'idea seria, come affrontarla in modo serio. Ma intuitivamente direi che la serie converge in 1 per ogni numero: 0 <numero, poiché la formula 3n + 1 trasformerà lentamente ogni fattore primo non-2 del numero originale (o intermedio) in una potenza di 2, prima o poi . Quindi non dobbiamo preoccuparci del loop infinito per le serie originali, solo un overflow può ostacolarci.

Quindi ho messo pochi numeri nel foglio e ho dato un'occhiata ai numeri troncati a 8 bit.

Ci sono tre valori traboccanti di 0: 227, 170e 85( 85andando direttamente a 0, altri due progredendo verso 85).

Ma non ha valore creare semi di overflow ciclici.

Stranamente ho fatto un controllo, che è il primo numero a soffrire di troncamento a 8 bit, e già 27è interessato! Raggiunge il valore 9232in serie non troncate appropriate (il primo valore troncato è 322nella dodicesima fase) e il valore massimo raggiunto per uno qualsiasi dei numeri di ingresso 2-255 in modo non troncato è 13120(per 255se stesso), il numero massimo di fasi a cui convergere 1è circa 128(+ -2, non sono sicuro se "1" deve contare, ecc ...).

È interessante notare che (per me) il numero 9232è massimo per molti altri numeri sorgente, cosa c'è di così speciale? : -O 9232= 0x2410... hmmm .. non ne ho idea.

Sfortunatamente non riesco ad avere una comprensione approfondita di questa serie, perché converge e quali sono le implicazioni del troncamento in k bit, ma con la cmp number,1condizione finale è certamente possibile mettere l'algoritmo in un ciclo infinito con un particolare valore di input che termina come 0dopo troncamento.

Ma il valore 27traboccante per il caso a 8 bit è una sorta di avviso, questo sembra che se si conta il numero di passaggi per raggiungere il valore 1, si otterrà un risultato errato per la maggior parte dei numeri dall'insieme totale di k-bit di numeri interi. Per i numeri interi a 8 bit i 146 numeri su 256 hanno interessato le serie per troncamento (alcuni di essi potrebbero comunque colpire il numero corretto di passaggi per caso, forse sono troppo pigro per controllare).


"il numero in overflow converrà molto probabilmente verso 1 senza un altro overflow": il codice non si ferma mai. (Questa è una congettura poiché non posso aspettare fino alla fine dei tempi per essere sicuro ...)
Yves Daoust

@YvesDaoust oh, ma sì? ... ad esempio la 27serie con troncamento 8b si presenta così: 82 41 124 62 31 94 47 142 71 214 107 66 (troncato) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (il resto funziona senza troncamento). Non ti capisco, scusa. Non si fermerebbe mai se il valore troncato fosse uguale ad alcuni di quelli precedentemente raggiunti nelle serie attualmente in corso, e non riesco a trovare alcun valore simile rispetto al troncamento dei bit k (ma non riesco nemmeno a capire la teoria matematica dietro, perché questo vale per il troncamento dei bit 8/16/32/64, solo intuitivamente penso che funzioni).
Ped7g

1
Avrei dovuto verificare prima la descrizione del problema originale: "Anche se non è stato ancora dimostrato (Problema di Collatz), si pensa che tutti i numeri iniziali finiscano a 1." ... ok, non c'è da meravigliarsi non riesco a cogliere di esso con la mia limitata conoscenza nebbioso Math ...: D E dai miei esperimenti foglio vi posso assicurare che converge per ogni 2- 255il numero, sia senza troncamento (a 1), o con troncamento a 8 bit (a previsto 1o a 0per tre numeri).
Ped7g

Hem, quando dico che non si ferma mai, intendo ... che non si ferma. Il codice specificato viene eseguito per sempre, se preferisci.
Yves Daoust,

1
Eseguito l'upgrade per l'analisi di ciò che accade in overflow. Il loop basato su CMP potrebbe utilizzare cmp rax,1 / jna(ovvero do{}while(n>1)) per terminare anche su zero. Ho pensato di realizzare una versione strumentata del loop che registra il massimo nvisto, per dare un'idea di quanto siamo vicini allo straripamento.
Peter Cordes,

5

Non hai pubblicato il codice generato dal compilatore, quindi qui ci sono alcune congetture, ma anche senza averlo visto, si può dire che:

test rax, 1
jpe even

... ha una probabilità del 50% di prevedere in modo errato il ramo, e questo sarà costoso.

Il compilatore esegue quasi certamente entrambi i calcoli (che costano in modo trascurabile in più poiché div / mod ha una latenza piuttosto lunga, quindi il moltiplicare è "libero") e segue un CMOV. Il che, ovviamente, ha una probabilità pari allo zero per cento di essere maltrattato.


1
C'è qualche modello nella ramificazione; ad es. un numero dispari è sempre seguito da un numero pari. Ma a volte 3n + 1 lascia più zero zero finali, ed è allora che questo commette un errore. Ho iniziato a scrivere di divisione nella mia risposta e non ho affrontato questa altra grande bandiera rossa nel codice del PO. (Si noti inoltre che l'utilizzo di una condizione di parità è davvero strano, rispetto a JZ o CMOVZ. È anche peggio per la CPU, poiché le CPU Intel possono fondere macro TEST / JZ, ma non TEST / JPE. Agner Fog afferma che AMD può fondere qualsiasi TEST / CMP con qualsiasi JCC, quindi in quel caso è solo peggio per i lettori umani)
Peter Cordes,

5

Anche senza guardare l'assemblaggio, il motivo più ovvio è che /= 2probabilmente è ottimizzato poiché >>=1molti processori hanno un'operazione di cambio molto rapida. Ma anche se un processore non ha un'operazione shift, la divisione intera è più veloce della divisione in virgola mobile.

Modifica: la tua media può variare sull'istruzione "divisione intera è più veloce della divisione in virgola mobile" sopra. I commenti che seguono rivelano che i moderni processori hanno dato la priorità all'ottimizzazione della divisione fp rispetto alla divisione intera. Quindi, se qualcuno stesse cercando la ragione più probabile per l'aumento di velocità, che la domanda di questa discussione chiede, quindi compilatore ottimizzato /=2come >>=1sarebbe il miglior primo posto dove guardare.


Su una nota non correlata , se nè dispari, l'espressione n*3+1sarà sempre pari. Quindi non è necessario controllare. Puoi cambiare quel ramo in

{
   n = (n*3+1) >> 1;
   count += 2;
}

Quindi l'intera dichiarazione sarebbe quindi

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
La divisione intera non è in realtà più veloce della divisione FP sulle moderne CPU x86. Penso che ciò sia dovuto al fatto che Intel / AMD spendono più transistor sui loro divisori FP, perché è un'operazione più importante. (La divisione intera per costanti può essere ottimizzata per moltiplicare per un inverso modulare). Controlla le tabelle Insn di Agner Fog e confronta DIVSD (float a doppia precisione) con DIV r32(intero senza segno a 32 bit) o DIV r64(intero senza segno a 64 bit molto più lento). Soprattutto per il throughput, la divisione FP è molto più veloce (single-up invece di micro-codificato e parzialmente pipeline), ma anche la latenza è migliore.
Peter Cordes,

1
ad es. sulla CPU Haswell dell'OP: DIVSD è 1 uop, latenza 10-20 cicli, una per throughput 8-14c. div r64è 36 uops, latenza 32-96c e una per throughput 21-74c. Skylake ha un throughput della divisione FP ancora più veloce (pipeline a uno per 4c con latenza non molto migliore), ma div integer molto più veloce. Le cose sono simili sulla famiglia di bulldozer AMD: DIVSD è 1M-op, latenza 9-27c, una per throughput 4.5-11c. div r64è 16M-op, latenza 16-75c, una per throughput 16-75c.
Peter Cordes,

1
La divisione FP non è sostanzialmente la stessa degli esponenti di sottrazione di numeri interi, di mantissa di divisione di interi, di rilevare denormali? E quei 3 passaggi possono essere fatti in parallelo.
Salterio

2
@MSalters: sì, suona bene, ma con un passaggio di normalizzazione alla fine di spostare i bit tra esponente e mantide. doubleha una mantissa a 53 bit, ma è ancora significativamente più lenta rispetto div r32a Haswell. Quindi è sicuramente solo una questione di quanto hardware Intel / AMD esponga al problema, perché non usano gli stessi transistor per divisori sia interi che fp. Quello intero è scalare (non esiste una divisione intero-SIMD) e quello vettoriale gestisce i vettori 128b (non 256b come gli altri ALU vettoriali). La cosa grande è che il numero intero div è molto elevato, con un grande impatto sul codice circostante.
Peter Cordes,

Err, non spostare i bit tra la mantissa e l'esponente, ma normalizzare la mantissa con uno spostamento e aggiungere la quantità di spostamento all'esponente.
Peter Cordes,

4

Come risposta generica, non specificamente indirizzata a questo compito: in molti casi, è possibile accelerare in modo significativo qualsiasi programma apportando miglioramenti ad alto livello. Come calcolare i dati una volta anziché più volte, evitando completamente il lavoro non necessario, utilizzando le cache nel modo migliore e così via. Queste cose sono molto più facili da fare in un linguaggio di alto livello.

Scrivendo il codice assembler, è possibile migliorare ciò che fa un compilatore ottimizzante, ma è un duro lavoro. E una volta fatto, il tuo codice è molto più difficile da modificare, quindi è molto più difficile aggiungere miglioramenti algoritmici. A volte il processore ha funzionalità che non è possibile utilizzare da un linguaggio di alto livello, l'assemblaggio in linea è spesso utile in questi casi e consente comunque di usare un linguaggio di alto livello.

Nei problemi di Eulero, la maggior parte delle volte riesci costruendo qualcosa, scoprendo perché è lento, costruendo qualcosa di meglio, scoprendo perché è lento, e così via e così via. È molto, molto difficile usare l'assemblatore. Un algoritmo migliore a metà della velocità possibile di solito batte un algoritmo peggiore a piena velocità e ottenere la massima velocità nell'assemblatore non è banale.


2
Totalmente d'accordo con questo. gcc -O3fatto codice che era entro il 20% di ottimale su Haswell, per quell'algoritmo esatto. (L'ottenimento di tali accelerazioni è stato l'obiettivo principale della mia risposta solo perché è quello che la domanda ha posto, e ha una risposta interessante, non perché è l'approccio giusto.) Sono state ottenute accelerazioni molto più grandi dalle trasformazioni che il compilatore sarebbe estremamente improbabile da cercare , come rinviare i turni giusti o fare 2 passi alla volta. Accelerazioni molto più grandi di quelle che si possono ottenere dalle tabelle di memoization / lookup. Test ancora esaustivi, ma non pura forza bruta.
Peter Cordes,

2
Tuttavia, avere un'implementazione semplice che è ovviamente corretta è estremamente utile per testare altre implementazioni. Quello che farei probabilmente è solo guardare l'output di asm per vedere se gcc lo ha fatto senza ramificazioni come mi aspettavo (principalmente per curiosità), e poi passare ai miglioramenti algoritmici.
Peter Cordes,

-2

La semplice risposta:

  • fare un MOV RBX, 3 e MUL RBX è costoso; basta aggiungere due volte RBX, RBX

  • ADD 1 è probabilmente più veloce di INC qui

  • MOV 2 e DIV sono molto costosi; basta spostarsi a destra

  • Il codice a 64 bit è di solito notevolmente più lento del codice a 32 bit e i problemi di allineamento sono più complicati; con piccoli programmi come questo devi comprimerli in modo da fare un calcolo parallelo per avere qualche possibilità di essere più veloce del codice a 32 bit

Se generi l'elenco di assembly per il tuo programma C ++, puoi vedere in che modo differisce dal tuo assembly.


4
1): l'aggiunta di 3 volte sarebbe stupida rispetto al LEA. Anche mul rbxsulla CPU Haswell dell'OP sono 2 uops con latenza 3c (e 1 per throughput di clock). imul rcx, rbx, 3è solo 1 uop, con la stessa latenza 3c. Due istruzioni ADD sarebbero 2 uops con latenza 2c.
Peter Cordes,

5
2) ADD 1 è probabilmente più veloce di INC qui . No, l'OP non utilizza un Pentium4 . Il tuo punto 3) è l'unica parte corretta di questa risposta.
Peter Cordes,

5
4) sembra una totale assurdità. Il codice a 64 bit può essere più lento con strutture di dati pesanti per puntatori, poiché puntatori più grandi significano un ingombro della cache maggiore. Ma questo codice funziona solo nei registri e i problemi di allineamento del codice sono gli stessi in modalità 32 e 64 bit. (Lo stesso vale per i problemi di allineamento dei dati, nessun indizio di cosa tu stia parlando, poiché l'allineamento è un problema maggiore per x86-64). Ad ogni modo, il codice non tocca nemmeno la memoria all'interno del loop.
Peter Cordes,

Il commentatore non ha idea di cosa stia parlando. Fare un MOV + MUL su una CPU a 64 bit sarà circa tre volte più lento dell'aggiunta di un registro a se stesso due volte. Le sue altre osservazioni sono ugualmente errate.
Tyler Durden,

6
Bene MOV + MUL è decisamente stupido, ma MOV + ADD + ADD è ancora sciocco (in realtà fare ADD RBX, RBXdue volte si moltiplicherebbe per 4, non 3). Di gran lunga il modo migliore è lea rax, [rbx + rbx*2]. Oppure, al costo di renderlo un LEA a 3 componenti, fai anche il +1 con lea rax, [rbx + rbx*2 + 1] (latenza 3c su HSW anziché 1, come ho spiegato nella mia risposta) Il mio punto era che la moltiplicazione a 64 bit non è molto costosa su recenti CPU Intel, perché hanno unità di numero intero follemente veloci (anche rispetto ad AMD, dove la stessa MUL r64è la latenza 6c, con una velocità di trasmissione per 4c: nemmeno completamente pipeline.
Peter Cordes,
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.