2012-06-28 29 views
13

Estoy experimentando con un lexer, y encontré que cambiar de un while-loop a un if-statement y un do-while-loop en una parte del programa me llevó a un código ~ 20% más rápido, lo que me pareció una locura. Aislé la diferencia en el código generado por el compilador a estos fragmentos de ensamblaje. ¿Alguien sabe por qué el código rápido es más rápido?¿Por qué este código de ensamblaje es más rápido?

En el conjunto, 'edi' es la posición actual del texto, 'ebx' es el final del texto, y 'isAlpha' es una tabla de búsqueda que tiene 1 si el carácter es alfabético y 0 en caso contrario.

El código lenta:

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: 

El código rápido:

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: 

Si me quedo sólo estos fragmentos de montaje contra un megabyte de texto que consiste solamente en la letra 'a', el código rápido es un 30% más rápido. Mi suposición es que el código lento es lento debido a errores de predicción de las ramas, pero pensé en un ciclo que sería un costo de una sola vez.

Aquí está el programa que he utilizado para probar los dos fragmentos:

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

La salida del programa de prueba es

slow in 8455 ticks 
fast in 5694 ticks 
+4

Eso * es * una locura: es una optimización muy común que los compiladores pueden hacer solos. En cuanto a por qué es más rápido, hay un salto menos por iteración en el código rápido, y los saltos solo tienen un rendimiento limitado. – harold

+2

un perfilador de registro basado en el rendimiento probablemente arroje la mejor respuesta, pero aparte del obvio salto, supongo que el segundo bit de código es más rápido porque se adapta mejor a la caché de código (también hay pocos bytes para buscar y decodificar, pero esa sobrecarga no tiene sentido aquí). la alineación del objetivo de salto también puede ser otro factor, pero eso es difícil de decir aquí sin direcciones – Necrolis

+0

Brian, ¿cuál es tu CPU? Eche un vistazo a http://www.agner.org/optimize/ Y harold, los jmps estáticos se predicen como siempre en las CPU modernas (no Atom) x86, por lo que no deberían costar nada. – osgx

Respuesta

11

Disculpe, no pude reproducir su código exactamente en GCC (linux), pero tengo algunos resultados y creo que la idea principal se guardó en mi código.

Hay una herramienta de Intel para analizar el rendimiento del fragmento de código: http://software.intel.com/en-us/articles/intel-architecture-code-analyzer/ (Intel IACA). Es gratis descargarlo y probarlo.

En mi experimento, el informe de bucle 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 

Para bucle rápido:

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 

Así que en bucle lento JMP es una instrucción adicional en Critical Path. Todos los pares de cmp + jz/jnz se fusionan (macro-fusión) en single-u-op. Y en mi implementación de código, el recurso crítico es Port5, que puede ejecutar ALU + JMP (y es el único puerto con capacidad JMP).

PD: Si alguien no tiene idea de dónde se encuentran los puertos, hay imágenes firstsecond; y artículo: rwt

PPS: IACA tiene algunas limitaciones; modela solo una parte de la CPU (unidades de ejecución) y no tiene en cuenta los errores de caché, errores de predicción de ramas, penalizaciones diferentes, cambios de frecuencia/potencia, interrupciones del sistema operativo, contención de HyperThreading para unidades de ejecución y muchos otros efectos. Pero es una herramienta útil porque puede darle una mirada rápida dentro del núcleo más interno de la CPU Intel moderna. Y solo funciona para bucles internos (como los bucles en esta pregunta).

+0

Por lo tanto, debido a la forma en que las instrucciones se pueden programar como microoperaciones, el ciclo lento toma 3,05 ciclos para ejecutarse y el ciclo rápido toma 2 ciclos. Es por eso que hay una diferencia tan grande entre su tiempo de ejecución a pesar de que solo hay 1 instrucción adicional en el ciclo lento. ¿Está bien? – briangreenery

+0

IACA es simulador y no puede ser completamente exacto. 'Rendimiento de bloque:' se calcula para algunos casos ideales (por ejemplo, en bucle infinito sin caché) y para algunos modelos de CPU (no muy exactos). Esta herramienta puede estimar que en el mejor de los casos habrá un cuello de botella en la Unidad de Ejecución # 5 (Puerto 5), y el tiempo mínimo para la iteración simple es de 3 o 2 ciclos de reloj. Puede ser computado por cualquiera con conocimiento de traducción de instrucciones básicas a microops y conocimiento de hardware requerido por las instrucciones JE/JNE/JMP. – osgx

+0

Esta instrucción adicional hace que la ruta crítica sea más larga, por lo que afectará al mejor de los casos. ¡Gracias por una pregunta interesante! – osgx

2

Su texto de prueba hace que el bucle de llegar hasta la terminación de todos y cada uno iteración, y el ciclo rápido tiene una instrucción menos.

+0

De repente, tienes razón también. – osgx

Cuestiones relacionadas