Eres víctima de una falla de predicción de rama .
¿Qué es la predicción de rama?
Considere un cruce de ferrocarril:
Imagen de Mecanismo, vía Wikimedia Commons. Usado bajo la licencia CC-By-SA 3.0 .
Ahora, en aras de la discusión, supongamos que esto ocurre en el siglo XIX, antes de la comunicación a larga distancia o por radio.
Eres el operador de un cruce y escuchas que viene un tren. No tienes idea en qué dirección se supone que debe ir. Usted detiene el tren para preguntarle al conductor qué dirección quiere. Y luego configura el interruptor adecuadamente.
Los trenes son pesados y tienen mucha inercia. Por eso tardan una eternidad en comenzar y reducir la velocidad.
¿Hay una mejor manera? ¡Adivina en qué dirección irá el tren!
- Si acertó, continúa.
- Si adivinaste mal, el capitán se detendrá, retrocederá y te gritará que actives el interruptor. Entonces puede reiniciar por la otra ruta.
Si aciertas siempre , el tren nunca tendrá que detenerse.
Si adivina mal con demasiada frecuencia , el tren pasará mucho tiempo deteniéndose, retrocediendo y reiniciando.
Considere una declaración if: a nivel de procesador, es una instrucción de bifurcación:
Eres un procesador y ves una rama. No tienes idea de qué camino tomará. ¿Qué haces? Detiene la ejecución y espera hasta que se completen las instrucciones anteriores. Luego continúas por el camino correcto.
Los procesadores modernos son complicados y tienen tuberías largas. Por eso tardan una eternidad en "calentarse" y "reducir la velocidad".
¿Hay una mejor manera? ¡Adivina en qué dirección irá la rama!
- Si acertó, continúa ejecutando.
- Si adivinó mal, debe vaciar la tubería y volver a la rama. Luego puede reiniciar por la otra ruta.
Si aciertas siempre , la ejecución nunca tendrá que detenerse.
Si adivina mal con demasiada frecuencia , pasa mucho tiempo deteniéndose, retrocediendo y reiniciando.
Esta es la predicción de rama. Admito que no es la mejor analogía ya que el tren podría señalar la dirección con una bandera. Pero en las computadoras, el procesador no sabe en qué dirección irá una rama hasta el último momento.
Entonces, ¿cómo adivinaría estratégicamente para minimizar la cantidad de veces que el tren debe retroceder y seguir el otro camino? ¡Miras la historia pasada! Si el tren sale a la izquierda el 99% del tiempo, entonces supones que se fue. Si alterna, entonces alterna sus conjeturas. Si va en una dirección cada tres veces, adivina lo mismo ...
En otras palabras, intenta identificar un patrón y seguirlo. Así es más o menos cómo funcionan los predictores de rama.
La mayoría de las aplicaciones tienen ramas con buen comportamiento. Por lo tanto, los predictores de sucursales modernos generalmente alcanzarán tasas de éxito> 90%. Pero cuando se enfrentan con ramas impredecibles sin patrones reconocibles, los predictores de ramas son prácticamente inútiles.
Lectura adicional: artículo "Predictor de rama" en Wikipedia .
Como se insinuó desde arriba, el culpable es esta declaración if:
if (data[c] >= 128)
sum += data[c];
Observe que los datos se distribuyen uniformemente entre 0 y 255. Cuando se ordenan los datos, aproximadamente la primera mitad de las iteraciones no ingresará la instrucción if. Después de eso, todos ingresarán la declaración if.
Esto es muy amigable para el predictor de rama ya que la rama va consecutivamente en la misma dirección muchas veces. Incluso un simple contador de saturación predecirá correctamente la rama, excepto por las pocas iteraciones después de que cambie de dirección.
Visualización rápida:
T = branch taken
N = branch not taken
data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N N N N N ... N N T T T ... T T T ...
= NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT (easy to predict)
Sin embargo, cuando los datos son completamente aleatorios, el predictor de rama se vuelve inútil, porque no puede predecir datos aleatorios. Por lo tanto, probablemente habrá alrededor del 50% de predicción errónea (no es mejor que adivinar al azar).
data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118, 14, 150, 177, 182, 133, ...
branch = T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ...
= TTNTTTTNTNNTTTN ... (completely random - hard to predict)
Entonces, ¿qué puede hacerse?
Si el compilador no puede optimizar la rama en un movimiento condicional, puede probar algunos hacks si está dispuesto a sacrificar la legibilidad por el rendimiento.
Reemplazar:
if (data[c] >= 128)
sum += data[c];
con:
int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
Esto elimina la rama y la reemplaza con algunas operaciones bit a bit.
(Tenga en cuenta que este truco no es estrictamente equivalente a la instrucción if original. Pero en este caso, es válido para todos los valores de entrada de data[]
).
Puntos de referencia: Core i7 920 @ 3.5 GHz
C ++ - Visual Studio 2010 - Lanzamiento x64
// Branch - Random
seconds = 11.777
// Branch - Sorted
seconds = 2.352
// Branchless - Random
seconds = 2.564
// Branchless - Sorted
seconds = 2.587
Java - NetBeans 7.1.1 JDK 7 - x64
// Branch - Random
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
Observaciones:
- Con la rama: existe una gran diferencia entre los datos ordenados y no clasificados.
- Con el Hack: no hay diferencia entre los datos ordenados y no clasificados.
- En el caso de C ++, el pirateo es en realidad un poco más lento que con la rama cuando se ordenan los datos.
Una regla general es evitar la ramificación dependiente de los datos en los bucles críticos (como en este ejemplo).
Actualizar:
GCC 4.6.1 con -O3
o -ftree-vectorize
en x64 es capaz de generar un movimiento condicional. Por lo tanto, no hay diferencia entre los datos ordenados y no clasificados, ambos son rápidos.
(O algo rápido: para el caso ya ordenado, cmov
puede ser más lento, especialmente si GCC lo coloca en la ruta crítica en lugar de solo add
, especialmente en Intel antes de Broadwell, donde cmov
tiene latencia de 2 ciclos: el indicador de optimización de gcc -O3 hace que el código sea más lento que -O2 )
VC ++ 2010 no puede generar movimientos condicionales para esta rama incluso debajo /Ox
.
Intel C ++ Compiler (ICC) 11 hace algo milagroso. Se intercambia los dos bucles , el izado de este modo la rama impredecible para el bucle externo. Entonces, no solo es inmune a las predicciones erróneas, ¡también es dos veces más rápido que lo que puedan generar VC ++ y GCC! En otras palabras, ICC aprovechó el bucle de prueba para vencer el punto de referencia ...
Si le da al compilador Intel el código sin bifurcación, simplemente lo vectoriza a la derecha ... y es tan rápido como con la bifurcación (con el intercambio de bucle).
Esto demuestra que incluso los compiladores modernos maduros pueden variar enormemente en su capacidad para optimizar el código ...