2009-02-05 13 views
6

Estoy tratando de optimizar una función pequeña y muy utilizada que utiliza los bits altos en un int corto sin signo para indicar los valores de una matriz para sumar. Al principio estaba usando el enfoque obvio que se muestra a continuación. Tenga en cuenta que el desenrollado de bucles no se muestra explícitamente, ya que el compilador debe hacerlo.¿Es más eficiente ramificar o multiplicar?

int total = 0; 
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){ 
    if (i & mask){ 
     total += value[j]; 
    } 
} 

Sin embargo, más tarde pensé que podría ser mejor para quitar la ramificación para ayudar a la canalización de la CPU y se le ocurrió la siguiente.

int total = 0; 
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){ 
    total += ((i & mask) != 0) * value[j]; 
} 

Tenga en cuenta que ya que (i & máscara) no da lugar a una respuesta booleana, la comparación con 0 fuerza que el resultado sea 1 ó 0. A pesar de este segundo enfoque elimina la sentencia if de esta sección del el código, la segunda solución necesita ejecutar una multiplicación de 0 o 1 en cada iteración además del resto de la ecuación.

¿Qué código se ejecutará más rápido?

+0

ambos deberían recopilar a lo mismo, dado un compilador cuerdo. iría con la primera opción más legible. ¿Su plataforma admite la ejecución predicada? funcionaría bien aquí, solo hay 1 instrucción para predicar (el complemento), por lo que no necesitaría una bifurcación bona fide en este caso. –

+2

Algo a tener en cuenta: puedes reemplazar '((i & mask)! = 0)' con '!! (i & mask)'. "¡¡¡!!" es un abuso de! operador para crear un operador de "emitir a bool" aplicándolo dos veces. Esto no debería cambiar el ensamblaje generado, pero es un idioma común y más legible para mi ojo. – kquinn

+0

Un recordatorio de que ((i & máscara)! = 0) puede no ser portátil .... falso es 0, verdadero no es 0 .... – Calyth

Respuesta

7

Puede hacerlo sin sucursales sin multiplicar. Parece que para cada conjunto de bits está utilizando esa posición de bit como un índice en una matriz.

En primer lugar, se puede extraer fácilmente los bits puestos con:

unsigned short set_mask= i & -i; 
i&= i - 1; 

Entonces, se puede obtener el índice de bit contando los bits puestos en (set_mask - 1). Hay una fórmula de tiempo constante para esto.

Algunas plataformas también tienen un valor intrínseco para obtener el bit de un bit establecido, que probablemente sea más rápido. x86 tiene bsr, PPC tiene cntlz.

Así que la respuesta es la versión multiplyless sin sucursales es probablemente más rápido :)

+0

Muy interesante, pero me pregunto si la "fórmula de tiempo constante" podría no valer la pena, ¿pueden proporcionar más detalles sobre esta fórmula? – Nixuz

+0

http://graphics.stanford.edu/~seander/bithacks.html#CountBitsSetParallel – MSN

+1

Gracias, esta es una solución muy elegante. – Nixuz

2

Esto depende totalmente en el compilador, el conjunto de instrucciones de la máquina y, probablemente, la fase de la luna.

No hay una respuesta correcta específica debido a esto. Si realmente quieres saber, comprueba el resultado de ensamblaje del compilador.

Desde un punto de vista simplista, diría que el segundo es más lento, ya que implica todos los cálculos del primer plus más un multiplicador. Pero el compilador puede ser lo suficientemente inteligente como para optimizarlo.

Así que la respuesta correcta es: depende.

+0

+1. Además, desenrollar el bucle seguramente mejorará el rendimiento más que jugar con la rama frente a la multiplicación. – Zooba

+0

Aparte de la vez que mejoró el rendimiento enrollando un bucle (esa función tomó el 80% del tiempo de ejecución, así que estaba desesperado por la optimización). La vieja sabiduría de optimización convencional está atrasado para una revisión. –

1

Aunque el segundo ejemplo no tiene una rama explícita, puede haber una implícita para convertir el resultado de la comparación en un bool. Puede obtener una pequeña idea activando el resultado de la lista de ensamblados para su compilador y analizando eso.

Por supuesto, la única forma de saberlo con certeza es tomar algunas sincronizaciones en ambos sentidos.

+0

Sí, creo que tienes razón, hay una rama implícita. Gracias por señalar eso. – Nixuz

+1

Depende de la arquitectura: en x86, int-to-bool se puede hacer sin ramificación con las dos instrucciones 'cmp' y 'setne'. –

1

La respuesta seguramente debe ser: pruébela en el hardware de destino y vea. Y asegúrese de seguir los consejos de la multitud de preguntas de micro-benchmark/benchmark-benchmark publicadas aquí en SO durante las últimas semanas.

Enlace a una pregunta de evaluación comparativa: Is stopwatch benchmarking acceptable?

En lo personal, me gustaría ir con el caso, a menos que haya una razón muy convincente para utilizar la alternativa "ofuscado".

13

¿Qué código se ejecutará más rápido?

Pruébelo para averiguarlo.

Además, observe la versión en lenguaje ensamblador del código que emite el compilador, porque puede ver elementos que le sorprenden y que sugieren optimizaciones adicionales (por ejemplo, usar short mientras usa la lata) necesita más instrucciones que utilizando el tamaño entero natural de la máquina).

9

Cualquiera podría ser más rápido. Para algunos procesadores, los datos de entrada reales pueden cambiar la respuesta.Tendrá que perfilar ambos enfoques con datos reales. Aquí hay algunas cosas que pueden afectar el rendimiento real en el hardware x86.

Supongamos por el momento que está utilizando un último modelo de Pentium 4. Ese procesador tiene dos niveles de predictores de bifurcación en la CPU. Si los predictores de rama pueden adivinar correctamente la dirección de la rama, sospecho que la primera será la más rápida. Esto es más probable que ocurra si las banderas tienen casi el mismo valor o si se alternan en un patrón muy simple la mayor parte del tiempo. Si las banderas son verdaderamente aleatorias, entonces el predictor de bifurcación estará equivocado la mitad del tiempo. Para nuestro hipotético Pentium 4 de 32 etapas, esto matará el rendimiento. Para los chips Pentium 3, Core 2, Core i7 y la mayoría de los chips AMD, los conductos son más cortos, por lo que el costo de la predicción de ramas defectuosas es mucho menor.

Si su vector de valor es notablemente más grande que el caché del procesador, cualquiera de los enfoques estará limitado por el ancho de banda de la memoria. Ambos tendrán características de rendimiento esencialmente idénticas. Si el vector de valores se adapta cómodamente a la memoria caché, tenga cuidado de cómo hacer los perfiles para que uno de los bucles de prueba no se penalice por rellenar la memoria caché y la otra se beneficie de ella.

1

La única manera de determinar la verdad de un enunciado es poner a prueba. Con eso en mente, estaría de acuerdo con las publicaciones anteriores que dicen ¡pruébalo!

En la mayoría de los procesadores modernos, la bifurcación es un proceso costoso, especialmente las sucursales que se toman con poca frecuencia. Esto se debe a que la tubería debe enjuagarse, lo que hace que la CPU no pueda intentar ejecutar una o más instrucciones al mismo tiempo, simplemente porque no sabe de dónde vendrá la próxima instrucción. Con algunas ramas, los posibles flujos de control se vuelven complejos para que la CPU intente todas las posibilidades simultáneamente, por lo que debe hacer la derivación y luego comenzar a hacer muchas instrucciones a la vez.

4

¿Qué tal esta revisión?

int total = 0; 
for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++){ 
    total += (mask & 0x0001) * value[j]; 
} 

he hecho mask en una copia de i limitado a la gama de 16 bits sin signo, pero el código comprueba si se fija el último bit de máscara, multiplicando el valor de la matriz por ese bit. Esto debería ser más rápido simplemente porque hay menos operaciones por iteración, y solo se necesitan las ramas y las condiciones del bucle principal. Además, el ciclo puede salir temprano si i es pequeño para empezar.


Esto demuestra por qué la medición es importante. Estoy usando un Sun SPARC anticuado. Escribí un programa de prueba como se muestra, con los dos contendientes de la pregunta como prueba 0 y prueba 1, y mi propia respuesta como prueba 2. Y luego ejecuté las pruebas de tiempo. La 'suma' se imprime como un control de cordura, para garantizar que todos los algoritmos dan la misma respuesta.

de 64 bits no optimizado:

gcc -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4 

Test 0: (sum = 1744366) 7.973411 us 
Test 1: (sum = 1744366) 10.269095 us 
Test 2: (sum = 1744366) 7.475852 us 

Niza: el mío es un poco más rápido que el original, y la versión sobrealimentado es más lento.

64-bits optimizado:

gcc -O4 -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4 

Test 0: (sum = 1744366) 1.101703 us 
Test 1: (sum = 1744366) 1.915972 us 
Test 2: (sum = 1744366) 2.575318 us 

Darn - mi versión es ahora drásticamente el más lento. ¡El optimizador es bueno!

32 bits optimizado:

gcc -O4 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4 

Test 0: (sum = 1744366) 0.839278 us 
Test 1: (sum = 1744366) 1.905009 us 
Test 2: (sum = 1744366) 2.448998 us 

32-bit no optimizado:

gcc -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4 

Test 0: (sum = 1744366) 7.493672 us 
Test 1: (sum = 1744366) 9.610240 us 
Test 2: (sum = 1744366) 6.838929 us 

mismo código en (32-bit) Cygwin y un ordenador portátil no tan geriátrica (32-bit, optimizado)

Test 0: (sum = 1744366) 0.557000 us 
Test 1: (sum = 1744366) 0.553000 us 
Test 2: (sum = 1744366) 0.403000 us 

Ahora mi código es el más rápido. ¡Es por eso que mides! También muestra por qué las personas que manejan puntos de referencia para ganarse la vida se angustian.

Mazo de prueba (gritar si desea que el código timer.h y timer.c):

#include <stdio.h> 
#include "timer.h" 

static volatile int value[] = 
{ 
    12, 36, 79, 21, 31, 93, 24, 15, 
    56, 63, 20, 47, 62, 88, 9, 36, 
}; 

static int test_1(int i) 
{ 
    int total = 0; 
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++) 
    { 
     if (i & mask) 
      total += value[j]; 
    } 
    return(total); 
} 

static int test_2(int i) 
{ 
    int total = 0; 
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++) 
    { 
     total += ((i & mask) != 0) * value[j]; 
    } 
    return(total); 
} 

static int test_3(int i) 
{ 
    int total = 0; 
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++) 
    { 
     total += (mask & 0x0001) * value[j]; 
    } 
    return(total); 
} 

typedef int(*func_pointer)(int); 

static func_pointer test[] = { test_1, test_2, test_3 }; 

#define DIM(x)(sizeof(x)/sizeof(*(x))) 

int main() 
{ 
    int i, j, k; 
    char buffer[32]; 
    for (i = 0; i < DIM(test); i++) 
    { 
     Clock t; 
     long sum = 0; 
     clk_init(&t); 
     clk_start(&t); 
     for (j = 0; j < 0xFFFF; j += 13) 
     { 
      int rv; 

      for (k = 0; k < 1000; k++) 
       rv = (*test[i])(j); 
      sum += rv; 
     } 
     clk_stop(&t); 
     printf("Test %d: (sum = %ld) %9s us\n", i, sum, 
       clk_elapsed_us(&t, buffer, sizeof(buffer))); 
    } 
} 

no he pasado tiempo trabajando por qué mi código es más lento cuando optimizado.

+0

Intenté un test_4() que es test_3() pero con total + = - (máscara & 1) & valor [j]. En una MacBook, 4 es ligeramente más lento que 3 en -O4, ligeramente más rápido sin optimizar. Una mirada al desmontaje muestra una multiplicación real y una real, por lo que el color me sorprendió: MUL más rápido que NEG y AND! Guay. –

+1

Por cierto, usaría j <= 0xFFFF en el ciclo interno, en lugar de <(no es que importe). También tuve que cambiarlo para usar clock.h. Gracias por hackear esto, era demasiado vago. –

+0

Er, clock() de time.h, eso es. –

1

por qué no hacer esto (i suponiendo es de 32 bits)

for (i2 = i; i2; i2 = i3) { 
    i3 = i2 & (i2-1); 
    last_bit = i2-i3; 
    a = last_bit & 0xffff; 
    b = (last_bit << 16); 
    j = place[a] + big_place[b]; 
    total += value[j]; 
    } 

Donde lugar es una tabla de tamaño 2^15 + 1 de tal manera que lugar [0] = 0, lugar [1] = 1 , place [2] = 2, place [4] = 3, place [8] = 4 ... place [15] = 16 (el resto de los valores no importa). y big_place es casi idéntica: big_place [0] = 0, big_place [1] = 17 .... big_place [15] = 32.

1

Trate

total += (-((i & mask) != 0)) & value[j]; 

en lugar de

total += ((i & mask) != 0) * value[j]; 

Esto evita la multiplicación. Si habrá una rama o no depende de si el compilador es lo suficientemente inteligente como para encontrar un código libre de ramificación para - (foo! = 0). (Lo cual es posible, pero me gustaría ser un poco sorprendido.)

(Por supuesto, esto depende de la representación en complemento a dos;. El estándar C es agnóstico en eso)

que te pueden ayudar a cabo el compilador como tal, asumiendo enteros de 32 bits y que se propaga >> firmado el bit de signo:

total += (((int)((i & mask) << (31 - j))) >> 31) & value[j]; 

es decir, cambiar el conjunto de bits posiblemente-izquierda a la posición más significativa, elegida como int firmado, luego a la derecha todo el camino de regreso a la posición menos significativa, produciendo todos los 0 o todos los 1, bajo los supuestos definidos en la implementación anterior. (No he probado esto.)

Otra posibilidad: considere bloques de (digamos) 4 bits a la vez. Hay 16 secuencias de adición diferentes; puede enviar a código desenrollado para cada uno de ellos, sin pruebas en absoluto dentro de cada bloque de código. La esperanza aquí es que un salto indirecto cueste menos de 4 pruebas y ramas.

Actualización: El uso de andamios de Jonathan Leffler, el método 4 bits-en-un-tiempo es más rápido por un amplio margen en mi MacBook. Negar, y resulta ser casi lo mismo que multiplicar. Me pregunto si el procesador multiplica casos especiales como 0 y 1 más rápido (o no es un caso tan especial si es más rápido para la mayoría de los bits claros o la mayoría de los conjuntos de bits en general).

No codifiqué la respuesta aceptada, ya que es poco probable que sea la más rápida en este punto de referencia en particular (debe obtener la mayor parte de su beneficio al enumerar solo los bits establecidos, obteniendo mejores resultados en conjuntos dispersos, pero la mitad completa se establecen en este punto de referencia). Aquí están mis cambios en el código de Leffler, en caso de que alguien más está extrañamente motivados para dedicar tiempo a esto:

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

static int value[] = 
{ 
    12, 36, 79, 21, 31, 93, 24, 15, 
    56, 63, 20, 47, 62, 88, 9, 36, 
}; 

static int test_1(int i) 
{ 
    int total = 0; 
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++) 
    { 
     if (i & mask) 
      total += value[j]; 
    } 
    return(total); 
} 

static int test_2(int i) 
{ 
    int total = 0; 
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++) 
    { 
     total += ((i & mask) != 0) * value[j]; 
    } 
    return(total); 
} 

static int test_3(int i) 
{ 
    int total = 0; 
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++) 
    { 
     total += (mask & 0x0001) * value[j]; 
    } 
    return(total); 
} 

static int test_4(int i) 
{ 
    int total = 0; 
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++) 
    { 
     total += -(mask & 0x0001) & value[j]; 
    } 
    return(total); 
} 

static int test_5(int i) 
{ 
    int total = 0; 
    const int *p = value; 
    for (unsigned mask = i & 0xFFFF; mask != 0; mask >>= 4, p += 4) 
    { 
     switch (mask & 0xF) 
     { 
     case 0x0: break; 
     case 0x1: total += p[0]; break; 
     case 0x2: total += p[1]; break; 
     case 0x3: total += p[1] + p[0]; break; 
     case 0x4: total += p[2]; break; 
     case 0x5: total += p[2] + p[0]; break; 
     case 0x6: total += p[2] + p[1]; break; 
     case 0x7: total += p[2] + p[1] + p[0]; break; 
     case 0x8: total += p[3]; break; 
     case 0x9: total += p[3] + p[0]; break; 
     case 0xA: total += p[3] + p[1]; break; 
     case 0xB: total += p[3] + p[1] + p[0]; break; 
     case 0xC: total += p[3] + p[2]; break; 
     case 0xD: total += p[3] + p[2] + p[0]; break; 
     case 0xE: total += p[3] + p[2] + p[1]; break; 
     case 0xF: total += p[3] + p[2] + p[1] + p[0]; break; 
     } 
    } 
    return(total); 
} 

typedef int(*func_pointer)(int); 

static func_pointer test[] = { test_1, test_2, test_3, test_4, test_5 }; 

#define DIM(x)(sizeof(x)/sizeof(*(x))) 

int main() 
{ 
    int i, j, k; 
    for (i = 0; i < DIM(test); i++) 
    { 
     long sum = 0; 
     clock_t start = clock(); 
     for (j = 0; j <= 0xFFFF; j += 13) 
     { 
      int rv; 

      for (k = 0; k < 1000; k++) 
       rv = (*test[i])(j); 
      sum += rv; 
     } 
     clock_t stop = clock(); 
     printf("(sum = %ld) Test %d: %8.6f s\n", sum, i + 1, 
       (stop - start)/(1.0 * CLOCKS_PER_SEC)); 
    } 
} 

resultados (gcc -O4 -std=c99 branchmult2.c):

(sum = 1744366) Test 1: 0.225497 s 
(sum = 1744366) Test 2: 0.221127 s 
(sum = 1744366) Test 3: 0.126301 s 
(sum = 1744366) Test 4: 0.124750 s 
(sum = 1744366) Test 5: 0.064877 s 

Edit 2: decidí la prueba haría ser más realista sin el calificador volatile.

1

Para ser uberfast puede evitar el bucle, los cambios y las multiplicaciones - use el interruptor.

switch (i) { 
    case 0: break; 
    case 1: total = value[0]; break; 
    case 2: total = value[1]; break; 
    case 3: total = value[1] + value[0]; break; 
    case 4: total = value[2]; break; 
    case 5: total = value[2] + value[0]; break; 
    ... 
} 

Es mucho para escribir, pero supongo que será mucho más rápido en tiempo de ejecución. ¡No se puede superar el rendimiento de la tabla de búsqueda!

Prefiero escribir un pequeño script de Perl que genere este código para mí, solo para evitar errores de tipeo.

Si cree que es un poco extremo, puede usar una tabla más pequeña, para 4 bits, y realice varias búsquedas, cambiando la máscara cada vez. El rendimiento sufrirá un poco, pero el código será mucho más pequeño.

+0

Hasta que la instrucción switch sea demasiado grande para una línea de caché de código, y el rendimiento se resiente. –

+0

En este caso, puede usar una tabla de búsqueda más pequeña (como mencioné) y buscar varias veces. – qrdl

+1

Y el código puede ser más rápido, pero el código cercano es más lento porque esta versión ocupa más memoria caché. :-) – Darron

1

solución obvia:

int total = 0; 
for(unsigned j = 0; j < 16; j++){ 
    total += -(i>>j & 1) & value[j]; 
} 
Cuestiones relacionadas