¿Por qué un interruptor no está optimizado de la misma manera que encadenado si no en c/c++?

7 minutos de lectura

avatar de usuario
chacham15

La siguiente implementación de square produce una serie de sentencias cmp/je como esperaría de una sentencia if encadenada:

int square(int num) {
    if (num == 0){
        return 0;
    } else if (num == 1){
        return 1;
    } else if (num == 2){
        return 4;
    } else if (num == 3){
        return 9;
    } else if (num == 4){
        return 16;
    } else if (num == 5){
        return 25;
    } else if (num == 6){
        return 36;
    } else if (num == 7){
        return 49;
    } else {
        return num * num;
    }
}

Y lo siguiente produce una tabla de datos para el retorno:

int square_2(int num) {
    switch (num){
        case 0: return 0;
        case 1: return 1;
        case 2: return 4;
        case 3: return 9;
        case 4: return 16;
        case 5: return 25;
        case 6: return 36;
        case 7: return 49;
        default: return num * num;
    }
}

¿Por qué gcc no puede optimizar el superior en el inferior?

Desmontaje para referencia: https://godbolt.org/z/UP_igi

EDITAR: curiosamente, MSVC genera una tabla de salto en lugar de una tabla de datos para el caso del interruptor. Y, sorprendentemente, clang los optimiza para obtener el mismo resultado.

  • ¿Qué quieres decir con “comportamiento indefinido”? Siempre que el comportamiento observable sea el mismo, el compilador puede generar cualquier código ensamblador/máquina que desee.

    – bolov

    7 de febrero de 2020 a las 8:58

  • @ usuario207421 ignorando el returns; los casos no tienen breaks, por lo que el interruptor también tiene un orden específico de ejecución. La cadena if/else tiene retornos en cada rama, la semántica en este caso es equivalente. La optimización no es imposible. Como contraejemplo icc no optimiza ninguna de las funciones.

    – usuario1810087

    7 de febrero de 2020 a las 9:16


  • Tal vez la respuesta más simple … gcc simplemente no puede ver esta estructura y optimizarla (todavía).

    – usuario1810087

    7 de febrero de 2020 a las 9:19


  • Estoy de acuerdo con @ usuario1810087. Simplemente encontró el límite actual del proceso de refinamiento del compilador. Un sub-sub-caso que actualmente no se reconoce como optimizable (por algunos compiladores). De hecho, no todas las cadenas else-if se pueden optimizar de esa manera, sino solo el subconjunto en el que la MISMA variable se prueba con valores constantes.

    – Roberto Caboni

    7 de febrero de 2020 a las 9:24


  • El if-else tiene un orden de ejecución diferente, de arriba a abajo. Aún así, reemplazar el código con solo si las declaraciones no mejoraron el código de la máquina. El interruptor, por otro lado, no tiene un orden de ejecución predefinido y es esencialmente solo una tabla de salto glorificada. Dicho esto, un compilador puede razonar sobre el comportamiento observable aquí, por lo que la mala optimización de la versión if-else es bastante decepcionante.

    – Lundin

    7 de febrero de 2020 a las 13:53

avatar de usuario
th33lf

El código generado para switch-case utiliza convencionalmente una tabla de salto. En este caso, la devolución directa a través de una tabla de búsqueda parece ser una optimización que aprovecha el hecho de que todos los casos aquí implican una devolución. Aunque el estándar no ofrece garantías en ese sentido, me sorprendería que un compilador generara una serie de comparaciones en lugar de una tabla de saltos para una caja de interruptores convencional.

ahora viene a if-else, es exactamente lo contrario. Tiempo switch-case se ejecuta en tiempo constante, independientemente del número de ramas, if-else está optimizado para un número menor de sucursales. Aquí, esperaría que el compilador genere básicamente una serie de comparaciones en el orden en que las ha escrito.

Así que si hubiera usado if-else porque espero que la mayoría de las llamadas sean square() ser para 0 o 1 y rara vez para otros valores, luego ‘optimizar’ esto a una tabla de búsqueda podría causar que mi código se ejecute más lento de lo que espero, anulando mi propósito de usar un if en lugar de un switch. Entonces, aunque es discutible, creo que GCC está haciendo lo correcto y clang está siendo demasiado agresivo en su optimización.

Alguien, en los comentarios, compartió un enlace donde clang hace esta optimización y genera código basado en tablas de búsqueda para if-else también. Algo notable sucede cuando reducimos el número de casos a solo dos (y uno predeterminado) con clang. Una vez más, genera un código idéntico para if y switch, pero esta vez,
cambia a compara y se mueve en lugar del enfoque de tabla de búsqueda, para ambos. ¡Esto significa que incluso el clan que favorece el cambio sabe que el patrón ‘si’ es más óptimo cuando el número de casos es pequeño!

En resumen, una secuencia de comparaciones para if-else y una mesa de salto para switch-case es el patrón estándar que los compiladores tienden a seguir y los desarrolladores tienden a esperar cuando escriben código. Sin embargo, para ciertos casos especiales, algunos compiladores pueden optar por romper este patrón cuando sientan que proporciona una mejor optimización. Otros compiladores podrían optar por apegarse al patrón de todos modos, incluso si aparentemente no es óptimo, confiando en que el desarrollador sepa lo que quiere. Ambos son enfoques válidos con sus propias ventajas y desventajas.

  • Sí, la optimización es una espada de múltiples filos: lo que escriben, lo que quieren, lo que obtienen y a quién maldecimos por eso.

    – Deduplicador

    7 febrero 2020 a las 17:27

  • “…entonces ‘optimizar’ esto a una tabla de búsqueda en realidad haría que mi código se ejecutara más lento de lo que esperaba…” ¿Puede proporcionar una justificación para esto? ¿Por qué una mesa de salto sería más lenta que dos posibles ramas condicionales (para verificar las entradas contra 0 y 1)?

    – Cody gris

    7 febrero 2020 a las 18:20


  • @CodyGray Tengo que confesar que no llegué al nivel de los ciclos de conteo: simplemente tuve la sensación de que la carga de la memoria a través de un puntero podría tomar más ciclos que una comparación y un salto, pero podría estar equivocado. Sin embargo, espero que esté de acuerdo conmigo en que incluso en este caso, al menos para ‘0’, if es obviamente más rápido? Ahora, aquí hay un ejemplo de una plataforma donde tanto 0 como 1 serían más rápidos cuando se usa if que cuando se usa el interruptor: godbolt.org/z/wcJhvS (Tenga en cuenta que aquí también hay muchas otras optimizaciones en juego)

    – th33lf

    10 de febrero de 2020 a las 9:44


  • Bueno, contar ciclos no funciona en arquitecturas OOO superescalares modernas de todos modos. 🙂 Las cargas desde la memoria no van a ser más lentas que las bifurcaciones mal predichas, por lo que la pregunta es qué tan probable es que se prediga la bifurcación. Esa pregunta se aplica a todo tipo de ramas condicionales, ya sean generadas por if sentencias o automáticamente por el compilador. No soy un experto en ARM, por lo que no estoy seguro de si la afirmación que está haciendo sobre switch siendo más rápido que if es verdad. Dependería de la penalización por bifurcaciones mal predichas, y eso en realidad dependería de cual BRAZO.

    – Cody gris

    10 de febrero de 2020 a las 18:56

avatar de usuario
VLL

Una posible razón es que si los valores bajos de num son más probables, por ejemplo siempre 0, el código generado para el primero podría ser más rápido. El código generado para cambiar toma el mismo tiempo para todos los valores.

Comparando los mejores casos, según Esta mesa. Vea esta respuesta para la explicación de la tabla.

Si num == 0, por “si” tienes xor, prueba, je (con salto), ret. Latencia: 1 + 1 + salto. Sin embargo, xor y test son independientes, por lo que la velocidad de ejecución real sería más rápida que 1 + 1 ciclos.

Si num < 7, para “cambiar” tienes mov, cmp, ja (sin salto), mov, ret. Latencia: 2 + 1 + sin salto + 2.

Una instrucción de salto que no da como resultado un salto es más rápida que una que da como resultado un salto. Sin embargo, la tabla no define la latencia para un salto, por lo que no me queda claro cuál es mejor. Es posible que el último siempre sea mejor y GCC simplemente no sea capaz de optimizarlo.

  • Hmm, teoría interesante, pero para ifs vs switch tienes: xor, test, jmp vs mov, cmp jmp. Tres instrucciones, cada una de las cuales es un salto. Parece igual en el mejor de los casos, ¿no?

    – chacham15

    7 de febrero de 2020 a las 9:03

  • “Una instrucción de salto que no da como resultado un salto es más rápida que una que da como resultado un salto”. Es la predicción de rama lo que importa.

    – geza

    7 de febrero de 2020 a las 9:49

¿Ha sido útil esta solución?