2012-06-28 22 views
13

Sto sperimentando un lexer e ho scoperto che passare da un while-loop a un if-statement e un do-while-loop in una parte del programma portava a un codice ~ 20% più veloce, il che sembrava assurdo. Ho isolato la differenza nel codice generato dal compilatore in questi frammenti di assembly. Qualcuno sa perché il codice veloce è più veloce?Perché questo codice assembly è più veloce?

Nell'assembly, 'edi' è la posizione del testo corrente, 'ebx' è la fine del testo e 'isAlpha' è una tabella di ricerca che ha un 1 se il carattere è alfabetico e 0 altrimenti.

Il codice lento:

slow_loop: 
00401897 cmp edi,ebx 
00401899 je slow_done (4018AAh) 
0040189B movzx eax,byte ptr [edi] 
0040189E cmp byte ptr isAlpha (4533E0h)[eax],0 
004018A5 je slow_done (4018AAh) 
004018A7 inc edi 
004018A8 jmp slow_loop (401897h) 
slow_done: 

Il codice veloce:

fast_loop: 
0040193D inc edi 
0040193E cmp edi,ebx 
00401940 je fast_done (40194Eh) 
00401942 movzx eax,byte ptr [edi] 
00401945 cmp byte ptr isAlpha (4533E0h)[eax],0 
0040194C jne fast_loop (40193Dh) 
fast_done: 

Se corro solo questi frammenti di assemblaggio nei confronti di un megabyte di testo costituito solo la lettera 'a', il codice veloce è il 30% più veloce. La mia ipotesi è che il codice lento sia lento a causa di una errata previsione del ramo, ma ho pensato in un ciclo che sarebbe costato una volta.

Ecco il programma che ho usato per testare entrambi i frammenti:

#include <Windows.h> 
#include <string> 
#include <iostream> 

int main(int argc, char* argv[]) 
{ 
    static char isAlpha[256]; 
    for (int i = 0; i < sizeof(isAlpha); ++i) 
     isAlpha[i] = isalpha(i) ? 1 : 0; 

    std::string test(1024*1024, 'a'); 

    const char* start = test.c_str(); 
    const char* limit = test.c_str() + test.size(); 

    DWORD slowStart = GetTickCount(); 
    for (int i = 0; i < 10000; ++i) 
    { 
     __asm 
     { 
      mov edi, start 
      mov ebx, limit 

      inc edi 

     slow_loop: 
      cmp edi,ebx 
      je slow_done 
      movzx eax,byte ptr [edi] 
      cmp byte ptr isAlpha [eax],0 
      je slow_done 
      inc edi 
      jmp slow_loop 

     slow_done: 
     } 
    } 
    DWORD slowEnd = GetTickCount(); 
    std::cout << "slow in " << (slowEnd - slowStart) << " ticks" << std::endl; 

    DWORD fastStart = GetTickCount(); 
    for (int i = 0; i < 10000; ++i) 
    { 
     __asm 
     { 
      mov edi, start 
      mov ebx, limit 

     fast_loop: 
      inc edi 
      cmp edi,ebx 
      je fast_done 
      movzx eax,byte ptr [edi] 
      cmp byte ptr isAlpha [eax],0 
      jne fast_loop 

     fast_done: 
     } 
    } 
    DWORD fastEnd = GetTickCount(); 
    std::cout << "fast in " << (fastEnd - fastStart) << " ticks" << std::endl; 

    return 0; 
} 

L'uscita del programma di test è

slow in 8455 ticks 
fast in 5694 ticks 
+4

Questo * è * pazzesco - è un ottimizzazione molto comune per i compilatori di fare da soli. Per quanto riguarda il motivo per cui è più veloce, c'è un salto in meno per iterazione nel codice veloce, e salti hanno solo un throughput limitato. – harold

+2

un profiler del registro basato sulle prestazioni probabilmente darebbe la risposta migliore, ma a parte l'ovvio salto, sto indovinando che il secondo bit di codice è più veloce perché si adatta meglio alla cache del codice (ci sono anche pochi byte per fetch-and- decodificare, ma quello overhead non ha senso qui). l'allineamento del target di salto può anche essere un altro fattore, ma questo è difficile da dire qui senza indirizzi – Necrolis

+0

Brian, qual è la tua CPU? Date un'occhiata a http://www.agner.org/optimize/ E harold, i jmps statici sono previsti come sempre presi su CPU x86 moderne (non Atom), quindi non dovrebbero costare nulla. – osgx

risposta

11

Siamo spiacenti, non ero in grado di riprodurre il codice esattamente su GCC (linux), ma ho alcuni risultati e penso che l'idea principale è stata salvata nel mio codice.

C'è uno strumento di Intel per analizzare le prestazioni del frammento di codice: http://software.intel.com/en-us/articles/intel-architecture-code-analyzer/ (Intel IACA). È libero di scaricarlo e testarlo.

Nel mio esperimento, rapporto per ciclo lento:

Intel(R) Architecture Code Analyzer Version - 2.0.1 
Analyzed File - ./l2_i 
Binary Format - 32Bit 
Architecture - SNB 
Analysis Type - Throughput 

Throughput Analysis Report 
-------------------------- 
Block Throughput: 3.05 Cycles  Throughput Bottleneck: Port5 

Port Binding In Cycles Per Iteration: 
------------------------------------------------------------------------- 
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 
------------------------------------------------------------------------- 
| Cycles | 0.5 0.0 | 0.5 | 1.0 1.0 | 1.0 1.0 | 0.0 | 3.0 | 
------------------------------------------------------------------------- 

N - port number or number of cycles resource conflict caused delay, DV - Divide 
D - Data fetch pipe (on ports 2 and 3), CP - on a critical path 
F - Macro Fusion with the previous instruction occurred 

| Num Of |    Ports pressure in cycles    | | 
| Uops | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | | 
--------------------------------------------------------------------- 
| 1 |   |  |   |   |  | 1.0 | CP | cmp edi, 
| 0F |   |  |   |   |  |  | | jz 0xb 
| 1 |   |  | 1.0 1.0 |   |  |  | | movzx ebx 
| 2 |   |  |   | 1.0 1.0 |  | 1.0 | CP | cmp cl, b 
| 0F |   |  |   |   |  |  | | jz 0x3 
| 1 | 0.5  | 0.5 |   |   |  |  | | inc edi 
| 1 |   |  |   |   |  | 1.0 | CP | jmp 0xfff 

ciclo For veloce:

Throughput Analysis Report 
-------------------------- 
Block Throughput: 2.00 Cycles  Throughput Bottleneck: Port5 

Port Binding In Cycles Per Iteration: 
------------------------------------------------------------------------- 
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 
------------------------------------------------------------------------- 
| Cycles | 0.5 0.0 | 0.5 | 1.0 1.0 | 1.0 1.0 | 0.0 | 2.0 | 
------------------------------------------------------------------------- 

N - port number or number of cycles resource conflict caused delay, DV - Divide 
D - Data fetch pipe (on ports 2 and 3), CP - on a critical path 
F - Macro Fusion with the previous instruction occurred 

| Num Of |    Ports pressure in cycles    | | 
| Uops | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | | 
--------------------------------------------------------------------- 
| 1 | 0.5  | 0.5 |   |   |  |  | | inc edi 
| 1 |   |  |   |   |  | 1.0 | CP | cmp edi, 
| 0F |   |  |   |   |  |  | | jz 0x8 
| 1 |   |  | 1.0 1.0 |   |  |  | | movzx ebx 
| 2 |   |  |   | 1.0 1.0 |  | 1.0 | CP | cmp cl, b 
| 0F |   |  |   |   |  |  | | jnz 0xfff 

Così nel ciclo lento JMP è un'istruzione in più in Critical Path. Tutte le coppie di cmp + jz/jnz vengono unite (Macro-fusione) in un'unica u-op. E nella mia implementazione del codice la risorsa critica è Port5, che può eseguire ALU + JMP (ed è l'unica porta con capacità JMP).

PS: Se qualcuno non ha idea di dove si trovano le porte, ci sono le immagini firstsecond; e articolo: rwt

PPS: IACA presenta alcune limitazioni; modella solo parte della CPU (unità di esecuzione) e non tiene conto di errori di cache, errate previsioni delle branche, diverse penalità, modifiche di frequenza/potenza, interruzioni del sistema operativo, conflitto HyperThreading per unità di esecuzione e molti altri effetti. Ma è uno strumento utile perché può darti un rapido sguardo all'interno del nucleo più interno della moderna CPU Intel. E funziona solo per i loop interni (proprio come i loop in questa domanda).

+0

Quindi, a causa del modo in cui le istruzioni possono essere programmate come micro-op, il ciclo lento richiede 3,05 cicli per l'esecuzione e il ciclo veloce richiede 2 cicli. Questo è il motivo per cui c'è una grande differenza tra il loro tempo di esecuzione, anche se c'è solo 1 istruzione aggiuntiva nel ciclo lento. È giusto? – briangreenery

+0

L'IACA è un simulatore e non può essere completamente esatto. 'Block Throughput:' è calcolato per alcuni casi ideali (ad es. In loop infinito senza cachemisses) e per alcuni modelli di CPU (non molto esatti). Questo strumento può stimare che nel migliore dei casi ci sarà un collo di bottiglia in Execution Unit # 5 (Port5) - e il tempo minimo per l'iterazione singola è di 3 o 2 cicli di clock. Può essere calcolato da chiunque abbia conoscenza della traduzione di istruzioni di base su microops e della conoscenza dell'hardware richiesto dalle istruzioni JE/JNE/JMP. – osgx

+0

Questa ulteriore istruzione rende il percorso critico più lungo, quindi influenzerà il caso migliore. Grazie per la domanda interessante! – osgx

2

Il testo di prova causa il ciclo di correre fino al completamento per ogni iterazione e il ciclo veloce ha una istruzione in meno.

+0

All'improvviso, hai ragione anche tu. – osgx

Problemi correlati