¿Es 'cambiar' más rápido que 'si'?

242

¿Es una switchdeclaración realmente más rápida que una ifdeclaración?

Ejecuté el siguiente código en el compilador x64 C ++ de Visual Studio 2010 con la /Oxbandera:

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

#define MAX_COUNT (1 << 29)
size_t counter = 0;

size_t testSwitch()
{
    clock_t start = clock();
    size_t i;
    for (i = 0; i < MAX_COUNT; i++)
    {
        switch (counter % 4 + 1)
        {
            case 1: counter += 4; break;
            case 2: counter += 3; break;
            case 3: counter += 2; break;
            case 4: counter += 1; break;
        }
    }
    return 1000 * (clock() - start) / CLOCKS_PER_SEC;
}

size_t testIf()
{
    clock_t start = clock();
    size_t i;
    for (i = 0; i < MAX_COUNT; i++)
    {
        const size_t c = counter % 4 + 1;
        if (c == 1) { counter += 4; }
        else if (c == 2) { counter += 3; }
        else if (c == 3) { counter += 2; }
        else if (c == 4) { counter += 1; }
    }
    return 1000 * (clock() - start) / CLOCKS_PER_SEC;
}

int main()
{
    printf("Starting...\n");
    printf("Switch statement: %u ms\n", testSwitch());
    printf("If     statement: %u ms\n", testIf());
}

y obtuve estos resultados:

Declaración de cambio: 5261 ms
Declaración de if: 5196 ms

Por lo que he aprendido, las switchdeclaraciones aparentemente usan tablas de salto para optimizar la ramificación.

Preguntas:

  1. ¿Cómo sería una mesa de salto básica, en x86 o x64?

  2. ¿Este código está usando una tabla de salto?

  3. ¿Por qué no hay diferencia de rendimiento en este ejemplo? ¿Hay alguna situación en la que no es una diferencia significativa del rendimiento?


Desmontaje del código:

testIf:

13FE81B10 sub  rsp,48h 
13FE81B14 call qword ptr [__imp_clock (13FE81128h)] 
13FE81B1A mov  dword ptr [start],eax 
13FE81B1E mov  qword ptr [i],0 
13FE81B27 jmp  testIf+26h (13FE81B36h) 
13FE81B29 mov  rax,qword ptr [i] 
13FE81B2E inc  rax  
13FE81B31 mov  qword ptr [i],rax 
13FE81B36 cmp  qword ptr [i],20000000h 
13FE81B3F jae  testIf+0C3h (13FE81BD3h) 
13FE81B45 xor  edx,edx 
13FE81B47 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81B4E mov  ecx,4 
13FE81B53 div  rax,rcx 
13FE81B56 mov  rax,rdx 
13FE81B59 inc  rax  
13FE81B5C mov  qword ptr [c],rax 
13FE81B61 cmp  qword ptr [c],1 
13FE81B67 jne  testIf+6Dh (13FE81B7Dh) 
13FE81B69 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81B70 add  rax,4 
13FE81B74 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81B7B jmp  testIf+0BEh (13FE81BCEh) 
13FE81B7D cmp  qword ptr [c],2 
13FE81B83 jne  testIf+89h (13FE81B99h) 
13FE81B85 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81B8C add  rax,3 
13FE81B90 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81B97 jmp  testIf+0BEh (13FE81BCEh) 
13FE81B99 cmp  qword ptr [c],3 
13FE81B9F jne  testIf+0A5h (13FE81BB5h) 
13FE81BA1 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81BA8 add  rax,2 
13FE81BAC mov  qword ptr [counter (13FE835D0h)],rax 
13FE81BB3 jmp  testIf+0BEh (13FE81BCEh) 
13FE81BB5 cmp  qword ptr [c],4 
13FE81BBB jne  testIf+0BEh (13FE81BCEh) 
13FE81BBD mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81BC4 inc  rax  
13FE81BC7 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81BCE jmp  testIf+19h (13FE81B29h) 
13FE81BD3 call qword ptr [__imp_clock (13FE81128h)] 
13FE81BD9 sub  eax,dword ptr [start] 
13FE81BDD imul eax,eax,3E8h 
13FE81BE3 cdq       
13FE81BE4 mov  ecx,3E8h 
13FE81BE9 idiv eax,ecx 
13FE81BEB cdqe      
13FE81BED add  rsp,48h 
13FE81BF1 ret       

testSwitch:

13FE81C00 sub  rsp,48h 
13FE81C04 call qword ptr [__imp_clock (13FE81128h)] 
13FE81C0A mov  dword ptr [start],eax 
13FE81C0E mov  qword ptr [i],0 
13FE81C17 jmp  testSwitch+26h (13FE81C26h) 
13FE81C19 mov  rax,qword ptr [i] 
13FE81C1E inc  rax  
13FE81C21 mov  qword ptr [i],rax 
13FE81C26 cmp  qword ptr [i],20000000h 
13FE81C2F jae  testSwitch+0C5h (13FE81CC5h) 
13FE81C35 xor  edx,edx 
13FE81C37 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81C3E mov  ecx,4 
13FE81C43 div  rax,rcx 
13FE81C46 mov  rax,rdx 
13FE81C49 inc  rax  
13FE81C4C mov  qword ptr [rsp+30h],rax 
13FE81C51 cmp  qword ptr [rsp+30h],1 
13FE81C57 je   testSwitch+73h (13FE81C73h) 
13FE81C59 cmp  qword ptr [rsp+30h],2 
13FE81C5F je   testSwitch+87h (13FE81C87h) 
13FE81C61 cmp  qword ptr [rsp+30h],3 
13FE81C67 je   testSwitch+9Bh (13FE81C9Bh) 
13FE81C69 cmp  qword ptr [rsp+30h],4 
13FE81C6F je   testSwitch+0AFh (13FE81CAFh) 
13FE81C71 jmp  testSwitch+0C0h (13FE81CC0h) 
13FE81C73 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81C7A add  rax,4 
13FE81C7E mov  qword ptr [counter (13FE835D0h)],rax 
13FE81C85 jmp  testSwitch+0C0h (13FE81CC0h) 
13FE81C87 mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81C8E add  rax,3 
13FE81C92 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81C99 jmp  testSwitch+0C0h (13FE81CC0h) 
13FE81C9B mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81CA2 add  rax,2 
13FE81CA6 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81CAD jmp  testSwitch+0C0h (13FE81CC0h) 
13FE81CAF mov  rax,qword ptr [counter (13FE835D0h)] 
13FE81CB6 inc  rax  
13FE81CB9 mov  qword ptr [counter (13FE835D0h)],rax 
13FE81CC0 jmp  testSwitch+19h (13FE81C19h) 
13FE81CC5 call qword ptr [__imp_clock (13FE81128h)] 
13FE81CCB sub  eax,dword ptr [start] 
13FE81CCF imul eax,eax,3E8h 
13FE81CD5 cdq       
13FE81CD6 mov  ecx,3E8h 
13FE81CDB idiv eax,ecx 
13FE81CDD cdqe      
13FE81CDF add  rsp,48h 
13FE81CE3 ret       

Actualizar:

Resultados interesantes aquí . Sin embargo, no estoy seguro de por qué uno es más rápido y uno es más lento.

usuario541686
fuente
47
¿Qué demonios vota la gente para cerrar este pensamiento? ¿Son tan creyentes en la noción del compilador perfectamente optimizado que cualquier idea de que genere un código menos que ideal es herejía? ¿La idea misma de alguna optimización en algún lugar los ofende?
Crashworks
66
¿Qué está mal exactamente con esta pregunta?
Tugrul comió el
25
Para cualquiera que se pregunte qué tiene de malo esta pregunta : para empezar, no es una pregunta, son 3 preguntas, lo que significa que muchas de las respuestas ahora abordan diferentes cuestiones. Esto significa que será difícil aceptar cualquier respuesta que responda a todo . Además, la típica reacción instintiva a la pregunta anterior es cerrarla como "realmente no tan interesante", principalmente debido al hecho de que a este nivel de optimización, casi siempre estás optimizando prematuramente . Por último, 5196 vs. 5261 no debería ser suficiente para preocuparse realmente. Escribe el código lógico que tenga sentido.
Lasse V. Karlsen
40
@Lasse: ¿ Realmente hubieras preferido que publicara tres preguntas en SO? Además: 5196 vs. 5261 shouldn't be enough to actually care-> No estoy seguro de si entendió mal la pregunta o si mal entendí su comentario, pero ¿no es el objetivo de mi pregunta preguntar por qué no hay una diferencia? (¿Alguna vez pretendo que una diferencia significativa para preocuparse?)
user541686
55
@Robert: Bueno, solo tiene más de 20 comentarios porque son meta-comentarios. Solo hay 7 comentarios realmente relacionados con la pregunta aquí. Opinión: No veo cómo hay "opinión" aquí. Hay una razón por la que no veo una diferencia de rendimiento, ¿no? ¿Es solo sabor? Debate: Tal vez, pero me parece un tipo de debate saludable, como lo he visto en otros lugares en SO (avíseme si hay algo contrario a eso). Argumentos: No veo nada discutidor aquí (a menos que lo esté tomando como sinónimo de 'debate'?). Discusión extendida: si incluye estos meta-comentarios.
user541686

Respuestas:

122

Hay varias optimizaciones que un compilador puede hacer en un switch. Sin embargo, no creo que la "tabla de salto" mencionada con frecuencia sea muy útil, ya que solo funciona cuando la entrada se puede limitar de alguna manera.

C El seudocódigo para una "tabla de salto" sería algo como esto : tenga en cuenta que el compilador en la práctica necesitaría insertar algún tipo de prueba if en la tabla para asegurarse de que la entrada era válida en la tabla. Tenga en cuenta también que solo funciona en el caso específico de que la entrada sea una serie de números consecutivos.

Si el número de ramas en un conmutador es extremadamente grande, un compilador puede hacer cosas como usar la búsqueda binaria en los valores del conmutador, lo que (en mi opinión) sería una optimización mucho más útil, ya que aumenta significativamente el rendimiento en algunos escenarios, es tan general como lo es un switch, y no da como resultado un mayor tamaño de código generado. Pero para ver eso, su código de prueba necesitaría MUCHO más ramas para ver alguna diferencia.

Para responder a sus preguntas específicas:

  1. Tañido genera uno que se parece a esto :

    test_switch(char):                       # @test_switch(char)
            movl    %edi, %eax
            cmpl    $19, %edi
            jbe     .LBB0_1
            retq
    .LBB0_1:
            jmpq    *.LJTI0_0(,%rax,8)
            jmp     void call<0u>()         # TAILCALL
            jmp     void call<1u>()         # TAILCALL
            jmp     void call<2u>()         # TAILCALL
            jmp     void call<3u>()         # TAILCALL
            jmp     void call<4u>()         # TAILCALL
            jmp     void call<5u>()         # TAILCALL
            jmp     void call<6u>()         # TAILCALL
            jmp     void call<7u>()         # TAILCALL
            jmp     void call<8u>()         # TAILCALL
            jmp     void call<9u>()         # TAILCALL
            jmp     void call<10u>()        # TAILCALL
            jmp     void call<11u>()        # TAILCALL
            jmp     void call<12u>()        # TAILCALL
            jmp     void call<13u>()        # TAILCALL
            jmp     void call<14u>()        # TAILCALL
            jmp     void call<15u>()        # TAILCALL
            jmp     void call<16u>()        # TAILCALL
            jmp     void call<17u>()        # TAILCALL
            jmp     void call<18u>()        # TAILCALL
            jmp     void call<19u>()        # TAILCALL
    .LJTI0_0:
            .quad   .LBB0_2
            .quad   .LBB0_3
            .quad   .LBB0_4
            .quad   .LBB0_5
            .quad   .LBB0_6
            .quad   .LBB0_7
            .quad   .LBB0_8
            .quad   .LBB0_9
            .quad   .LBB0_10
            .quad   .LBB0_11
            .quad   .LBB0_12
            .quad   .LBB0_13
            .quad   .LBB0_14
            .quad   .LBB0_15
            .quad   .LBB0_16
            .quad   .LBB0_17
            .quad   .LBB0_18
            .quad   .LBB0_19
            .quad   .LBB0_20
            .quad   .LBB0_21
    
  2. Puedo decir que no está usando una tabla de salto: 4 instrucciones de comparación son claramente visibles:

    13FE81C51 cmp  qword ptr [rsp+30h],1 
    13FE81C57 je   testSwitch+73h (13FE81C73h) 
    13FE81C59 cmp  qword ptr [rsp+30h],2 
    13FE81C5F je   testSwitch+87h (13FE81C87h) 
    13FE81C61 cmp  qword ptr [rsp+30h],3 
    13FE81C67 je   testSwitch+9Bh (13FE81C9Bh) 
    13FE81C69 cmp  qword ptr [rsp+30h],4 
    13FE81C6F je   testSwitch+0AFh (13FE81CAFh) 
    

    Una solución basada en la tabla de salto no usa comparación en absoluto.

  3. O no hay suficientes ramas para hacer que el compilador genere una tabla de salto, o su compilador simplemente no las genera. No estoy seguro de cuál.

EDITAR 2014 : ha habido alguna discusión en otras partes de personas familiarizadas con el optimizador LLVM que dicen que la optimización de la tabla de salto puede ser importante en muchos escenarios; por ejemplo, en casos donde hay una enumeración con muchos valores y muchos casos contra valores en dicha enumeración. Dicho esto, mantengo lo que dije anteriormente en 2011: con demasiada frecuencia veo personas que piensan "si hago un cambio, será al mismo tiempo, sin importar cuántos casos tenga", y eso es completamente falso. Incluso con una tabla de salto, obtienes el costo de salto indirecto y pagas las entradas en la tabla para cada caso; y el ancho de banda de la memoria es un gran problema en el hardware moderno.

Escribir código para facilitar la lectura. Cualquier compilador que valga la pena verá una escalera if / else if y la transformará en un interruptor equivalente o viceversa si sería más rápido hacerlo.

Billy ONeal
fuente
3
+1 por responder realmente la pregunta y por información útil. :-) Sin embargo, una pregunta: por lo que entiendo, una tabla de salto utiliza saltos indirectos ; ¿Es eso correcto? Si es así, ¿no suele ser más lento debido a la captación / canalización más difícil?
user541686
1
@Mehrdad: Sí, usa saltos indirectos. Sin embargo, un salto indirecto (con el bloqueo de la tubería con el que viene) puede ser inferior a cientos de saltos directos. :)
Billy ONeal
1
@Mehrdad: No, desafortunadamente. :( Me alegro de estar en el campo de personas que siempre piensan que el IF es más legible! :)
Billy ONeal
1
Pocos trucos: "[interruptores] solo funciona cuando la entrada se puede acotar de alguna manera" "necesita insertar algún tipo de prueba if alrededor de la tabla para garantizar que la entrada sea válida en la tabla. Tenga en cuenta también que solo funciona en el caso de que la entrada sea una serie de números consecutivos. ": es completamente posible tener una tabla escasamente poblada, donde se lee el puntero potencial y solo si se realiza un salto no NULL, de lo contrario el caso predeterminado si se salta alguno, entonces las switchsalidas. Soren dijo varias otras cosas que quería decir después de leer esta respuesta.
Tony Delroy
2
"Cualquier compilador que valga la pena verá una escalera if / else if y la transformará en un conmutador equivalente o viceversa", ¿algún soporte para esta afirmación? un compilador podría suponer que el orden de sus ifcláusulas ya se ha ajustado a mano para que coincida con las necesidades de frecuencia y rendimiento relativo, mientras que a switchse ve tradicionalmente como una invitación abierta para optimizar, sin embargo, el compilador elige. Buen punto para saltar más allá switch:-). El tamaño del código depende de los casos / rango, podría ser mejor. Finalmente, algunas enumeraciones, campos de bits y charescenarios son inherentemente válidos / acotados y sin gastos generales.
Tony Delroy
47

A su pregunta:

1. ¿Cómo sería una mesa de salto básica, en x86 o x64?

La tabla de salto es una dirección de memoria que mantiene el puntero a las etiquetas en algo así como la estructura de la matriz. El siguiente ejemplo lo ayudará a comprender cómo se organizan las tablas de salto

00B14538  D8 09 AB 00 D8 09 AB 00 D8 09 AB 00 D8 09 AB 00  Ø.«.Ø.«.Ø.«.Ø.«.
00B14548  D8 09 AB 00 D8 09 AB 00 D8 09 AB 00 00 00 00 00  Ø.«.Ø.«.Ø.«.....
00B14558  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00B14568  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

ingrese la descripción de la imagen aquí

Donde 00B14538 es el puntero a la tabla Jump, y un valor como D8 09 AB 00 representa el puntero de etiqueta.

2.¿Este código usa una tabla de salto? No en este caso

3. ¿Por qué no hay diferencia de rendimiento en este ejemplo?

No hay diferencia de rendimiento porque la instrucción para ambos casos se ve igual, no hay tabla de salto.

4. ¿Hay alguna situación en la que haya una diferencia de rendimiento significativa?

Si tiene una secuencia muy larga de verificación if , en ese caso el uso de una tabla de salto mejora el rendimiento (las instrucciones de ramificación / jmp son caras si no predicen casi a la perfección) pero conlleva el costo de la memoria.

El código para todas las instrucciones de comparación también tiene cierto tamaño, por lo que especialmente con punteros o compensaciones de 32 bits, una sola búsqueda en la tabla de salto podría no costar mucho más tamaño en un ejecutable.

Conclusión: el compilador es lo suficientemente inteligente como para manejar este caso y generar las instrucciones apropiadas :)

encriptado
fuente
(edit: nvm, la respuesta de Billy ya tiene lo que estaba sugiriendo. Supongo que este es un buen suplemento). Sería bueno incluir la gcc -Ssalida: una secuencia de entradas .long L1/ .long L2tabla es más significativa que un hexdump, y más útil para alguien que quiere aprender a mirar el compilador. (Aunque supongo que solo mirarías el código del interruptor para ver si era un jmp indirecto o un montón de jcc).
Peter Cordes
31

El compilador es libre de compilar la instrucción switch como un código que es equivalente a la instrucción if, o de crear una tabla de salto. Probablemente elegirá uno sobre el otro en función de lo que se ejecutará más rápido o generará el código más pequeño, dependiendo de lo que haya especificado en las opciones del compilador, por lo que, en el peor de los casos, será la misma velocidad que las declaraciones if

Confiaría en que el compilador haga la mejor elección y se centre en lo que hace que el código sea más legible.

Si el número de casos se vuelve muy grande, una tabla de salto será mucho más rápida que una serie de if. Sin embargo, si los pasos entre los valores son muy grandes, la tabla de salto puede llegar a ser grande y el compilador puede optar por no generar uno.

Soren
fuente
13
No creo que esto responda la pregunta del OP. En absoluto.
Billy ONeal
55
@Soren: Si esa fuera la "pregunta básica", entonces no me habría molestado con las otras 179 líneas de la pregunta, solo habría sido 1 línea. :-)
user541686
8
@Soren: Veo al menos 3 subpreguntas numeradas como parte de la pregunta del OP. Simplemente ha dicho exactamente la misma respuesta que se aplica a todas las preguntas de "rendimiento", es decir, que primero debe medir. Considere que tal vez Mehrdad ya ha medido y ha aislado este fragmento de código para que sea un punto caliente. En tales casos, su respuesta es peor que inútil, es ruido.
Billy ONeal
2
Hay una línea borrosa entre lo que es una tabla de salto y lo que no depende de su definición. He proporcionado información sobre la subpregunta parte 3.
Soren
2
@wnoise: Si es la única respuesta correcta, entonces nunca habría una razón para hacer alguna pregunta de rendimiento. Sin embargo, hay algunos de nosotros en el mundo real que medimos nuestro software, y a veces no sabemos cómo hacer que un código sea más rápido una vez que se ha medido. Es obvio que Mehrdad puso un poco de esfuerzo en esta pregunta antes de formularla; y creo que sus preguntas específicas son más que respondibles.
Billy ONeal
13

¿Cómo sabe que su computadora no realizaba alguna tarea no relacionada con la prueba durante el ciclo de prueba del interruptor y realizaba menos tareas durante el ciclo de prueba if? Los resultados de su prueba no muestran nada como:

  1. la diferencia es muy pequeña
  2. solo hay un resultado, no una serie de resultados
  3. hay muy pocos casos

Mis resultados:

Agregué:

printf("counter: %u\n", counter);

hasta el final para que no optimice el bucle ya que el contador nunca se usó en su ejemplo, entonces ¿por qué el compilador realizaría el bucle? Inmediatamente, el cambio siempre estaba ganando incluso con un micro punto de referencia.

El otro problema con su código es:

switch (counter % 4 + 1)

en su ciclo de cambio, versus

const size_t c = counter % 4 + 1; 

en tu bucle if. Muy gran diferencia si arreglas eso. Creo que poner la declaración dentro de la declaración del interruptor provoca que el compilador envíe el valor directamente a los registros de la CPU en lugar de colocarlo primero en la pila. Por lo tanto, esto es a favor de la declaración de cambio y no una prueba equilibrada.

Ah, y creo que también deberías restablecer el contador entre pruebas. De hecho, probablemente debería usar algún tipo de número aleatorio en lugar de +1, +2, +3, etc., ya que probablemente optimizará algo allí. Por número aleatorio, me refiero a un número basado en la hora actual, por ejemplo. De lo contrario, el compilador podría convertir ambas funciones en una operación matemática larga y ni siquiera molestarse con ningún bucle.

Modifiqué el código de Ryan lo suficiente para asegurarme de que el compilador no pudiera resolver las cosas antes de que se ejecutara el código:

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

#define MAX_COUNT (1 << 26)
size_t counter = 0;

long long testSwitch()
{
    clock_t start = clock();
    size_t i;
    for (i = 0; i < MAX_COUNT; i++)
    {
        const size_t c = rand() % 20 + 1;

        switch (c)
        {
                case 1: counter += 20; break;
                case 2: counter += 33; break;
                case 3: counter += 62; break;
                case 4: counter += 15; break;
                case 5: counter += 416; break;
                case 6: counter += 3545; break;
                case 7: counter += 23; break;
                case 8: counter += 81; break;
                case 9: counter += 256; break;
                case 10: counter += 15865; break;
                case 11: counter += 3234; break;
                case 12: counter += 22345; break;
                case 13: counter += 1242; break;
                case 14: counter += 12341; break;
                case 15: counter += 41; break;
                case 16: counter += 34321; break;
                case 17: counter += 232; break;
                case 18: counter += 144231; break;
                case 19: counter += 32; break;
                case 20: counter += 1231; break;
        }
    }
    return 1000 * (long long)(clock() - start) / CLOCKS_PER_SEC;
}

long long testIf()
{
    clock_t start = clock();
    size_t i;
    for (i = 0; i < MAX_COUNT; i++)
    {
        const size_t c = rand() % 20 + 1;
        if (c == 1) { counter += 20; }
        else if (c == 2) { counter += 33; }
        else if (c == 3) { counter += 62; }
        else if (c == 4) { counter += 15; }
        else if (c == 5) { counter += 416; }
        else if (c == 6) { counter += 3545; }
        else if (c == 7) { counter += 23; }
        else if (c == 8) { counter += 81; }
        else if (c == 9) { counter += 256; }
        else if (c == 10) { counter += 15865; }
        else if (c == 11) { counter += 3234; }
        else if (c == 12) { counter += 22345; }
        else if (c == 13) { counter += 1242; }
        else if (c == 14) { counter += 12341; }
        else if (c == 15) { counter += 41; }
        else if (c == 16) { counter += 34321; }
        else if (c == 17) { counter += 232; }
        else if (c == 18) { counter += 144231; }
        else if (c == 19) { counter += 32; }
        else if (c == 20) { counter += 1231; }
    }
    return 1000 * (long long)(clock() - start) / CLOCKS_PER_SEC;
}

int main()
{
    srand(time(NULL));
    printf("Starting...\n");
    printf("Switch statement: %lld ms\n", testSwitch()); fflush(stdout);
    printf("counter: %d\n", counter);
    counter = 0;
    srand(time(NULL));
    printf("If     statement: %lld ms\n", testIf()); fflush(stdout);
    printf("counter: %d\n", counter);
} 

conmutador: 3740
si: 3980

(resultados similares en múltiples intentos)

También reduje el número de casos / ifs a 5 y la función de cambio aún ganó.

BobTurbo
fuente
Idk, no puedo probarlo; ¿obtienes resultados diferentes?
user541686
+1: La evaluación comparativa es difícil, y realmente no se pueden sacar conclusiones de una pequeña diferencia de tiempo en una sola ejecución en una computadora normal. Puede intentar ejecutar una gran cantidad de pruebas y hacer algunas estadísticas sobre los resultados. O contar los ciclos del procesador en la ejecución controlada en un emulador.
Thomas Padron-McCarthy
Er, ¿ dónde agregaste exactamente la printdeclaración? Lo agregué al final de todo el programa y no vi ninguna diferencia. Tampoco entiendo cuál es el "problema" con el otro ... ¿te importaría explicar cuál es la "gran diferencia"?
user541686
1
@BobTurbo: 45983493 es más de 12 horas. ¿Fue eso un error tipográfico?
Gus
1
genial, ahora tengo que hacerlo otra vez :)
BobTurbo
7

Un buen compilador de optimización como MSVC puede generar:

  1. una tabla de salto simple si los casos están dispuestos en un largo rango agradable
  2. una tabla de salto escasa (de dos niveles) si hay muchas brechas
  3. una serie de ifs si el número de casos es pequeño o los valores no están juntos
  4. una combinación de lo anterior si los casos representan varios grupos de rangos muy cercanos.

En resumen, si el cambio parece ser más lento que una serie de ifs, el compilador podría convertirlo en uno. Y es probable que no sea solo una secuencia de comparaciones para cada caso, sino un árbol de búsqueda binario. Ver aquí para un ejemplo.

Igor Skochinsky
fuente
En realidad, un compilador también puede reemplazarlo con un hash y jump, que funciona mejor que la escasa solución de dos niveles que propone.
Alice
5

Contestaré 2) y haré algunos comentarios generales. 2) No, no hay una tabla de salto en el código de ensamblaje que ha publicado. Una tabla de salto es una tabla de destinos de salto, y una o dos instrucciones para saltar directamente a una ubicación indexada desde la tabla. Una mesa de salto tendría más sentido cuando hay muchos destinos de cambio posibles. Tal vez el optimizador sabe que la lógica es simple si no es más rápida a menos que el número de destinos sea mayor que algún umbral. Intente su ejemplo nuevamente con digamos 20 posibilidades en lugar de 4.

Bill Forster
fuente
¡+1 gracias por la respuesta al # 2! :) (Por cierto, aquí están los resultados con más posibilidades.)
user541686
4

Estaba intrigado y eché un vistazo a lo que podría cambiar sobre su ejemplo para que ejecute la declaración de cambio más rápido.

Si llega a 40 sentencias if y agrega un caso 0, entonces el bloque if se ejecutará más lentamente que la sentencia switch equivalente. Tengo los resultados aquí: https://www.ideone.com/KZeCz .

El efecto de eliminar el caso 0 se puede ver aquí: https://www.ideone.com/LFnrX .

Ryan Gross
fuente
1
Tus enlaces se han roto.
TS
4

Estos son algunos resultados del antiguo benchmark bench ++ (ahora difícil de encontrar):

Test Name:   F000003                         Class Name:  Style
CPU Time:       0.781  nanoseconds           plus or minus     0.0715
Wall/CPU:        1.00  ratio.                Iteration Count:  1677721600
Test Description:
 Time to test a global using a 2-way if/else if statement
 compare this test with F000004

Test Name:   F000004                         Class Name:  Style
CPU Time:        1.53  nanoseconds           plus or minus     0.0767
Wall/CPU:        1.00  ratio.                Iteration Count:  1677721600
Test Description:
 Time to test a global using a 2-way switch statement
 compare this test with F000003

Test Name:   F000005                         Class Name:  Style
CPU Time:        7.70  nanoseconds           plus or minus      0.385
Wall/CPU:        1.00  ratio.                Iteration Count:  1677721600
Test Description:
 Time to test a global using a 10-way if/else if statement
 compare this test with F000006

Test Name:   F000006                         Class Name:  Style
CPU Time:        2.00  nanoseconds           plus or minus     0.0999
Wall/CPU:        1.00  ratio.                Iteration Count:  1677721600
Test Description:
 Time to test a global using a 10-way switch statement
 compare this test with F000005

Test Name:   F000007                         Class Name:  Style
CPU Time:        3.41  nanoseconds           plus or minus      0.171
Wall/CPU:        1.00  ratio.                Iteration Count:  1677721600
Test Description:
 Time to test a global using a 10-way sparse switch statement
 compare this test with F000005 and F000006

Lo que podemos ver de esto es que (en esta máquina, con este compilador - VC ++ 9.0 x64), cada ifprueba toma aproximadamente 0.7 nanosegundos. A medida que aumenta el número de pruebas, el tiempo se escala casi perfectamente linealmente.

Con la declaración de cambio, casi no hay diferencia en la velocidad entre una prueba de 2 vías y una de 10 vías, siempre que los valores sean densos. La prueba de 10 vías con valores dispersos toma aproximadamente 1,6 veces más tiempo que la prueba de 10 vías con valores densos, pero incluso con valores dispersos, aún mejor que el doble de la velocidad de una de 10 vías if/ else if.

En pocas palabras: usar solo una prueba de 4 vías realmente no le mostrará mucho sobre el rendimiento de switchvs if/ else. Si observa los números de este código, es bastante fácil interpolar el hecho de que para una prueba de 4 vías, esperaríamos que los dos produzcan resultados bastante similares (~ 2.8 nanosegundos para un if/ else, ~ 2.0 para switch).

Jerry Coffin
fuente
1
Es un poco difícil saber qué hacer con eso si no sabemos si la prueba busca deliberadamente un valor que no coincide o solo coincide al final de la cadena if/ en lugar de elsedispersarlos, etc. No puedo encontrar las bench++fuentes después de 10 minutos googleando.
Tony Delroy
3

Tenga en cuenta que cuando un interruptor NO se compila en una tabla de salto, a menudo puede escribir si es más eficiente que el interruptor ...

(1) si los casos tienen un orden, en lugar de la prueba del peor de los casos para todos los N, puede escribir sus if para probar si en la mitad superior o inferior, luego en cada mitad de ese estilo de búsqueda binaria ... lo que resulta en el peor de los casos es logN en lugar de N

(2) si ciertos casos / grupos son mucho más frecuentes que otros casos, entonces diseñar sus if para aislar esos casos primero puede acelerar el tiempo promedio

Brian Kennedy
fuente
Esto es notablemente falso; Los compiladores son más que capaces de hacer AMBAS de estas optimizaciones.
Alice
1
Alice, ¿cómo se supone que un compilador sabe qué casos ocurrirán con más frecuencia que otros casos en tus cargas de trabajo esperadas? (R: No puede saberlo, por lo que no puede hacer tal optimización.)
Brian Kennedy
(1) se puede hacer fácilmente, y se hace en algunos compiladores, simplemente haciendo una búsqueda binaria. (2) puede predecirse de varias formas o indicarse al compilador. ¿Nunca ha usado "probable" o "improbable" de GCC?
Alice
Y algunos compiladores permiten ejecutar el programa en un modo que recopila estadísticas y luego optimiza a partir de esa información.
Phil1970
2

No, estos son si luego salta más si luego saltas más ... Una tabla de salto tendría una tabla de direcciones o usaría un hash o algo así.

Más rápido o más lento es subjetivo. Podría, por ejemplo, hacer que el caso 1 sea lo último en lugar de lo primero y si su programa de prueba o programa del mundo real usó el caso 1 la mayor parte del tiempo, el código sería más lento con esta implementación. Entonces, solo reorganizar la lista de casos, dependiendo de la implementación, puede hacer una gran diferencia.

Si había usado los casos 0-3 en lugar de 1-4, el compilador podría haber usado una tabla de salto, el compilador debería haber descubierto la eliminación de su +1 de todos modos. Quizás fue la pequeña cantidad de artículos. Si lo hubiera hecho 0 - 15 o 0 - 31, por ejemplo, podría haberlo implementado con una tabla o haber utilizado algún otro acceso directo. El compilador es libre de elegir cómo implementa las cosas siempre que cumpla con la funcionalidad del código fuente. Y esto entra en diferencias de compilador y diferencias de versión y diferencias de optimización. Si quieres una mesa de salto, haz una tabla de salto, si quieres un árbol if-then-else haz un árbol if-then-else. Si desea que el compilador decida, use una instrucción switch / case.

viejo contador de tiempo
fuente
2

Sin embargo, no estoy seguro de por qué uno es más rápido y el otro es más lento.

En realidad, eso no es demasiado difícil de explicar ... Si recuerdas que las ramas mal predichas son decenas a cientos de veces más caras que las ramas correctamente predichas.

En la % 20versión, el primer caso / if es siempre el que golpea. Las CPU modernas "aprenden" qué ramas se toman generalmente y cuáles no, por lo que pueden predecir fácilmente cómo se comportará esta rama en casi cada iteración del bucle. Eso explica por qué la versión "if" vuela; nunca tiene que ejecutar nada más allá de la primera prueba y predice (correctamente) el resultado de esa prueba para la mayoría de las iteraciones. Obviamente, el "interruptor" se implementa de manera ligeramente diferente, tal vez incluso una tabla de salto, que puede ser lenta gracias a la rama calculada.

En la % 21versión, las ramas son esencialmente aleatorias. Entonces, no solo muchos de ellos ejecutan cada iteración, la CPU no puede adivinar en qué dirección irán. Este es el caso en el que una tabla de salto (u otra optimización de "interruptor") puede ayudar.

Es muy difícil predecir cómo funcionará un código con un compilador y una CPU modernos, y se vuelve más difícil con cada generación. El mejor consejo es "no te molestes en intentarlo, siempre perfila". Ese consejo mejora, y el conjunto de personas que pueden ignorarlo con éxito se reduce cada año.

Todo lo cual es para decir que mi explicación anterior es en gran medida una suposición. :-)

Nemo
fuente
2
No veo de dónde pueden venir cientos de veces más lento. El peor caso de una rama mal predicha es un bloqueo de la tubería, que sería ~ 20 veces más lento en la mayoría de las CPU modernas. No cientos de veces. (De acuerdo, si está utilizando un chip NetBurst antiguo, podría ser
35 veces
@Billy: OK, así que estoy mirando un poco hacia adelante. En los procesadores Sandy Bridge , "cada rama mal predicha vaciará toda la tubería, perdiendo el trabajo de hasta un centenar de instrucciones en vuelo". Las tuberías realmente se vuelven más profundas con cada generación, en general ...
Nemo
1
No es verdad. El P4 (NetBurst) tenía 31 etapas de tubería; Sandy Bridge tiene significativamente menos etapas. Creo que "perder el trabajo de más o menos 100 instrucciones" se da por supuesto que el caché de instrucciones se invalida. Para un salto indirecto general que de hecho sucede, pero para algo así como una tabla de salto, es probable que el objetivo del salto indirecto se encuentre en algún lugar de la caché de instrucciones.
Billy ONeal
@ Billy: No creo que estemos en desacuerdo. Mi declaración fue: "Las ramas mal predichas son decenas a cientos de veces más caras que las ramas correctamente predichas". Una ligera exageración, tal vez ... Pero hay algo más que solo golpes en la profundidad de la tubería de ejecución y el caché I; Por lo que he leído, la cola para decodificar solo es de ~ 20 instrucciones.
Nemo
Si el hardware de predicción de bifurcación predice erróneamente la ruta de ejecución, los uops de la ruta incorrecta que están en la tubería de instrucciones simplemente se eliminan donde están, sin detener la ejecución. No tengo idea de cómo esto es posible (o si lo estoy malinterpretando), pero aparentemente no hay puestos de ductos con ramas impredecibles en Nehalem. (Por otra parte, no tengo un i7; tengo un i5, por lo que esto no se aplica a mi caso.)
user541686
1

Ninguna. En los casos más particulares en los que ingresa al ensamblador y realiza mediciones reales del rendimiento, su pregunta es simplemente la incorrecta. Para el ejemplo dado, su pensamiento es definitivamente demasiado corto ya que

counter += (4 - counter % 4);

me parece que es la expresión de incremento correcta que deberías usar.

Jens Gustedt
fuente