¿Cómo funcionan las macros probables/improbables en el kernel de Linux y cuál es su beneficio?

11 minutos de lectura

avatar de usuario de terminal
término

Estuve investigando algunas partes del kernel de Linux y encontré llamadas como esta:

if (unlikely(fd < 0))
{
    /* Do something */
}

o

if (likely(!err))
{
    /* Do something */
}

He encontrado la definición de ellos:

#define likely(x)       __builtin_expect((x),1)
#define unlikely(x)     __builtin_expect((x),0)

Sé que son para optimización, pero ¿cómo funcionan? ¿Y cuánta disminución de rendimiento/tamaño se puede esperar al usarlos? Y vale la pena la molestia (y probablemente perder la portabilidad) al menos en el código de cuello de botella (en el espacio de usuario, por supuesto).

  • Esto realmente no es específico del kernel de Linux ni de las macros, sino una optimización del compilador. ¿Debería ser reetiquetado para reflejar eso?

    – Serafina Brocious

    20 de septiembre de 2008 a las 23:13

  • El papel Lo que todo Programador debe saber sobre la Memoria (pág. 57) contiene una explicación detallada.

    – Torsten Marek

    20 de septiembre de 2008 a las 23:15

  • ver también BOOST_LIKELY

    -Ruggero Turra

    12/07/2015 a las 21:38

  • Relacionado: un punto de referencia sobre el uso de __builtin_expect en otra pregunta.

    – YSC

    15 de marzo de 2016 a las 14:53

  • No hay problema de portabilidad. Puedes hacer cosas trivialmente como #define likely(x) (x) y #define unlikely(x) (x) en plataformas que no admiten este tipo de sugerencias.

    –David Schwartz

    19 de abril de 2016 a las 7:25

1800 Avatar de usuario de INFORMACION
1800 INFORMACIÓN

Son una sugerencia para que el compilador emita instrucciones que harán que la predicción de bifurcación favorezca el lado “probable” de una instrucción de salto. Esto puede ser una gran victoria, si la predicción es correcta, significa que la instrucción de salto es básicamente gratuita y tomará cero ciclos. Por otro lado, si la predicción es incorrecta, significa que la tubería del procesador debe vaciarse y puede costar varios ciclos. Siempre que la predicción sea correcta la mayor parte del tiempo, esto tenderá a ser bueno para el rendimiento.

Al igual que todas las optimizaciones de rendimiento de este tipo, solo debe hacerlo después de un perfilado exhaustivo para asegurarse de que el código realmente se encuentre en un cuello de botella, y probablemente dada la naturaleza micro, que se está ejecutando en un ciclo cerrado. En general, los desarrolladores de Linux tienen bastante experiencia, así que me imagino que lo habrían hecho. Realmente no les importa demasiado la portabilidad, ya que solo apuntan a gcc, y tienen una idea muy cercana del ensamblado que quieren que genere.

  • Estas macros se utilizaron principalmente para la comprobación de errores. Porque el error deja menos probabilidades que el funcionamiento normal. Algunas personas hacen perfiles o cálculos para decidir la hoja más utilizada…

    – givekoa

    4 mayo 2012 a las 21:12


  • En cuanto al fragmento "[...]that it is being run in a tight loop"muchas CPU tienen un predictor de rama, por lo tanto, el uso de estas macros solo ayuda la primera vez que se ejecuta el código o cuando una rama diferente con el mismo índice sobrescribe la tabla de historial en la tabla de ramificación. En un ciclo cerrado, y suponiendo que una rama va en una dirección la mayor parte del tiempo, es probable que el predictor de ramas comience a adivinar la rama correcta muy rápidamente. – tu amigo en la pedantería.

    –Ross Rogers

    11 de noviembre de 2013 a las 21:56

  • @RossRogers: lo que realmente sucede es que el compilador organiza las ramas para que el caso común sea el que no se toma. Esto es más rápido incluso cuando la predicción de bifurcación funciona. Las ramas tomadas son problemáticas para la obtención de instrucciones y la decodificación, incluso cuando se predicen perfectamente. Algunas CPU predicen estáticamente ramas que no están en su tabla de historial, generalmente con la suposición de que no se toman para las ramas hacia adelante. Las CPU Intel no funcionan de esa manera: no intentan verificar que la entrada de la tabla predictora sea para este rama, simplemente lo usan de todos modos. Una rama caliente y una rama fría podrían alias la misma entrada…

    – Peter Cordes

    15/02/2016 a las 21:41

  • Esta respuesta es en su mayoría obsoleta, ya que la afirmación principal es que ayuda a la predicción de bifurcaciones y, como señala @PeterCordes, en la mayoría del hardware moderno no hay predicción de bifurcaciones estáticas implícitas o explícitas. De hecho, el compilador utiliza la sugerencia para optimizar el código, ya sea que se trate de sugerencias de rama estática o cualquier otro tipo de optimización. Para la mayoría de las arquitecturas actuales, lo que importa es “cualquier otra optimización”, por ejemplo, hacer que las rutas activas sean contiguas, programar mejor la ruta activa, minimizar el tamaño de la ruta lenta, vectorizar solo la ruta esperada, etc., etc.

    – BeeOnRope

    18 de enero de 2017 a las 21:54

  • @BeeOnRope debido a la captura previa de caché y el tamaño de palabra, todavía hay una ventaja en ejecutar un programa de forma lineal. La siguiente ubicación de memoria ya se buscará y en caché, el objetivo de la rama tal vez o no. Con una CPU de 64 bits, toma al menos 64 bits a la vez. Dependiendo de la intercalación de DRAM, pueden ser 2x 3x o más bits los que se toman.

    – Bryce

    15 de febrero de 2017 a las 19:39

Ciro Santilli Avatar de usuario de OurBigBook.com
Ciro Santilli OurBigBook.com

Vamos a descompilar para ver qué hace GCC 4.8 con él

Sin __builtin_expect

#include "stdio.h"
#include "time.h"

int main() {
    /* Use time to prevent it from being optimized away. */
    int i = !time(NULL);
    if (i)
        printf("%d\n", i);
    puts("a");
    return 0;
}

Compilar y descompilar con GCC 4.8.2 x86_64 Linux:

gcc -c -O3 -std=gnu11 main.c
objdump -dr main.o

Producción:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       75 14                   jne    24 <main+0x24>
  10:       ba 01 00 00 00          mov    $0x1,%edx
  15:       be 00 00 00 00          mov    $0x0,%esi
                    16: R_X86_64_32 .rodata.str1.1
  1a:       bf 01 00 00 00          mov    $0x1,%edi
  1f:       e8 00 00 00 00          callq  24 <main+0x24>
                    20: R_X86_64_PC32       __printf_chk-0x4
  24:       bf 00 00 00 00          mov    $0x0,%edi
                    25: R_X86_64_32 .rodata.str1.1+0x4
  29:       e8 00 00 00 00          callq  2e <main+0x2e>
                    2a: R_X86_64_PC32       puts-0x4
  2e:       31 c0                   xor    %eax,%eax
  30:       48 83 c4 08             add    $0x8,%rsp
  34:       c3                      retq

El orden de las instrucciones en la memoria no cambió: primero el printf y luego puts y el retq devolver.

Con __builtin_expect

Ahora reemplaza if (i) con:

if (__builtin_expect(i, 0))

y obtenemos:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       74 11                   je     21 <main+0x21>
  10:       bf 00 00 00 00          mov    $0x0,%edi
                    11: R_X86_64_32 .rodata.str1.1+0x4
  15:       e8 00 00 00 00          callq  1a <main+0x1a>
                    16: R_X86_64_PC32       puts-0x4
  1a:       31 c0                   xor    %eax,%eax
  1c:       48 83 c4 08             add    $0x8,%rsp
  20:       c3                      retq
  21:       ba 01 00 00 00          mov    $0x1,%edx
  26:       be 00 00 00 00          mov    $0x0,%esi
                    27: R_X86_64_32 .rodata.str1.1
  2b:       bf 01 00 00 00          mov    $0x1,%edi
  30:       e8 00 00 00 00          callq  35 <main+0x35>
                    31: R_X86_64_PC32       __printf_chk-0x4
  35:       eb d9                   jmp    10 <main+0x10>

El printf (compilado para __printf_chk) se movió al final de la función, después de puts y el retorno para mejorar la predicción de sucursales como se menciona en otras respuestas.

Entonces es básicamente lo mismo que:

int main() {
    int i = !time(NULL);
    if (i)
        goto printf;
puts:
    puts("a");
    return 0;
printf:
    printf("%d\n", i);
    goto puts;
}

Esta optimización no se hizo con -O0.

Pero buena suerte al escribir un ejemplo que se ejecute más rápido con __builtin_expect que sin, las CPU son realmente inteligentes en estos días. mis ingenuos intentos están aquí.

C++20 [[likely]] y [[unlikely]]

C ++ 20 ha estandarizado esos elementos integrados de C ++: cómo usar el atributo probable/improbable de C ++ 20 en la declaración if-else Es probable (¡un juego de palabras!) que hagan lo mismo.

avatar de usuario de dvorak
dvorak

Estas son macros que dan pistas al compilador sobre el camino que puede tomar una rama. Las macros se expanden a extensiones específicas de GCC, si están disponibles.

GCC los utiliza para optimizar la predicción de bifurcaciones. Por ejemplo, si tienes algo como lo siguiente

if (unlikely(x)) {
  dosomething();
}

return x;

Entonces puede reestructurar este código para que sea algo más como:

if (!x) {
  return x;
}

dosomething();
return x;

El beneficio de esto es que cuando el procesador toma una bifurcación por primera vez, hay una sobrecarga significativa, porque puede haber estado cargando y ejecutando código especulativamente más adelante. Cuando determina que tomará la rama, entonces tiene que invalidar eso y comenzar en el objetivo de la rama.

La mayoría de los procesadores modernos ahora tienen algún tipo de predicción de bifurcación, pero eso solo ayuda cuando ha pasado por la bifurcación antes, y la bifurcación todavía está en el caché de predicción de bifurcación.

Hay una serie de otras estrategias que el compilador y el procesador pueden usar en estos escenarios. Puede encontrar más detalles sobre cómo funcionan los predictores de rama en Wikipedia: http://en.wikipedia.org/wiki/Branch_predictor

  • Además, afecta la huella de icache, al mantener fragmentos de código improbables fuera de la ruta activa.

    – fche

    3 de marzo de 2015 a las 1:58

  • Más precisamente, puede hacerlo con gotos sin repetir el return x: stackoverflow.com/a/31133787/895245

    – Ciro Santilli OurBigBook.com

    30 de junio de 2015 a las 9:10

Hacen que el compilador emita las sugerencias de rama apropiadas donde el hardware las admite. Por lo general, esto solo significa cambiar algunos bits en el código de operación de la instrucción, por lo que el tamaño del código no cambiará. La CPU comenzará a buscar instrucciones desde la ubicación predicha, vaciará la tubería y comenzará de nuevo si eso resulta ser incorrecto cuando se alcanza la rama; en el caso de que la sugerencia sea correcta, esto hará que la rama sea mucho más rápida; precisamente, cuánto más rápido dependerá del hardware; y cuánto afecta esto al rendimiento del código dependerá de la proporción de la sugerencia de tiempo que sea correcta.

Por ejemplo, en una CPU PowerPC, una rama no sugerida puede tomar 16 ciclos, una sugerida correctamente 8 y una sugerida incorrectamente 24. En los bucles más internos, una buena sugerencia puede marcar una gran diferencia.

La portabilidad no es realmente un problema; presumiblemente, la definición se encuentra en un encabezado por plataforma; simplemente puede definir “probable” e “improbable” a nada para las plataformas que no admiten sugerencias de rama estática.

Avatar de usuario de Ashish Maurya
Ashish Maurya

long __builtin_expect(long EXP, long C);

Esta construcción le dice al compilador que la expresión EXP probablemente tendrá el valor C. El valor devuelto es EXP.
__builtin_expect está destinado a ser utilizado en una expresión condicional. En casi todos los casos se usará en el contexto de expresiones booleanas, en cuyo caso es mucho más conveniente definir dos macros auxiliares:

#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)

Estas macros se pueden usar como en:

if (likely(a > 1))

Referencia

  • Como se preguntó en un comentario a otra respuesta, ¿cuál es el motivo de la doble inversión en las macros (es decir, por qué usar __builtin_expect(!!(expr),0) en lugar de solo __builtin_expect((expr),0)?

    – Michael Firth

    22 de enero de 2019 a las 13:15

  • @MichaelFirth “doble inversión” !! es equivalente a lanzar algo a un bool. A algunas personas les gusta escribirlo de esta manera.

    – Ben XO

    6 de agosto de 2019 a las 13:05


Avatar de usuario de Andrew Edgecombe
Andrew Edgecombe

(comentario general – otras respuestas cubren los detalles)

No hay ninguna razón por la que deba perder la portabilidad al usarlos.

Siempre tiene la opción de crear un simple “en línea” o macro de efecto nulo que le permitirá compilar en otras plataformas con otros compiladores.

Simplemente no obtendrá el beneficio de la optimización si está en otras plataformas.

  • Como se preguntó en un comentario a otra respuesta, ¿cuál es el motivo de la doble inversión en las macros (es decir, por qué usar __builtin_expect(!!(expr),0) en lugar de solo __builtin_expect((expr),0)?

    – Michael Firth

    22 de enero de 2019 a las 13:15

  • @MichaelFirth “doble inversión” !! es equivalente a lanzar algo a un bool. A algunas personas les gusta escribirlo de esta manera.

    – Ben XO

    6 de agosto de 2019 a las 13:05


Avatar de usuario de la comunidad
Comunidad

Según el comentario de Cody, esto no tiene nada que ver con Linux, pero es una pista para el compilador. Lo que suceda dependerá de la arquitectura y la versión del compilador.

Esta característica particular en Linux se usa un poco mal en los controladores. Como señala osgx en la semántica del atributo caliente, cualquier hot o cold La función llamada dentro de un bloque puede insinuar automáticamente que la condición es probable o no. Por ejemplo, dump_stack() está marcado cold así que esto es redundante,

 if(unlikely(err)) {
     printk("Driver error found. %d\n", err);
     dump_stack();
 }

Futuras versiones de gcc puede alinear selectivamente una función basada en estas sugerencias. También ha habido sugerencias de que no es booleanpero una puntuación como en más probableetc. En general, se debe preferir utilizar algún mecanismo alternativo como cold. No hay razón para usarlo en ningún lugar que no sean caminos calientes. Lo que hará un compilador en una arquitectura puede ser completamente diferente en otra.

¿Ha sido útil esta solución?