Aquí hay un fragmento de código C ++ que muestra un comportamiento muy peculiar. Por alguna extraña razón, ordenar los datos milagrosamente hace que el código sea casi seis veces más rápido:
#include <algorithm>
#include <ctime>
#include <iostream>
int main()
{
// Generate data
const unsigned arraySize = 32768;
int data[arraySize];
for (unsigned c = 0; c < arraySize; ++c)
data[c] = std::rand() % 256;
// !!! With this, the next loop runs faster.
std::sort(data, data + arraySize);
// Test
clock_t start = clock();
long long sum = 0;
for (unsigned i = 0; i < 100000; ++i)
{
// Primary loop
for (unsigned c = 0; c < arraySize; ++c)
{
if (data[c] >= 128)
sum += data[c];
}
}
double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
std::cout << elapsedTime << std::endl;
std::cout << "sum = " << sum << std::endl;
}
- Sin
std::sort(data, data + arraySize);
, el código se ejecuta en 11.54 segundos. - Con los datos ordenados, el código se ejecuta en 1.93 segundos.
Inicialmente, pensé que esto podría ser solo una anomalía de lenguaje o compilador, así que probé Java:
import java.util.Arrays;
import java.util.Random;
public class Main
{
public static void main(String[] args)
{
// Generate data
int arraySize = 32768;
int data[] = new int[arraySize];
Random rnd = new Random(0);
for (int c = 0; c < arraySize; ++c)
data[c] = rnd.nextInt() % 256;
// !!! With this, the next loop runs faster
Arrays.sort(data);
// Test
long start = System.nanoTime();
long sum = 0;
for (int i = 0; i < 100000; ++i)
{
// Primary loop
for (int c = 0; c < arraySize; ++c)
{
if (data[c] >= 128)
sum += data[c];
}
}
System.out.println((System.nanoTime() - start) / 1000000000.0);
System.out.println("sum = " + sum);
}
}
Con un resultado similar pero menos extremo.
Mi primer pensamiento fue que la clasificación lleva los datos al caché, pero luego pensé lo tonto que era porque la matriz acababa de generarse.
- Que esta pasando?
- ¿Por qué procesar una matriz ordenada es más rápido que procesar una matriz no ordenada?
El código está resumiendo algunos términos independientes, por lo que el orden no debería importar.
java
c++
performance
optimization
branch-prediction
GManNickG
fuente
fuente
Respuestas:
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 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 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:
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:
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).
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:
con:
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
Java - NetBeans 7.1.1 JDK 7 - x64
Observaciones:
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 soloadd
, especialmente en Intel antes de Broadwell, dondecmov
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 ...
fuente
Predicción de rama.
Con una matriz ordenada, la condición
data[c] >= 128
es primerofalse
para una raya de valores, luego se conviertetrue
para todos los valores posteriores. Eso es fácil de predecir. Con una matriz sin clasificar, paga el costo de ramificación.fuente
La razón por la cual el rendimiento mejora drásticamente cuando se ordenan los datos es que se elimina la penalización de predicción de rama, como se explica maravillosamente en la respuesta de Mysticial .
Ahora, si miramos el código
podemos encontrar que el significado de esta
if... else...
rama en particular es agregar algo cuando se cumple una condición. Este tipo de rama se puede transformar fácilmente en una instrucción de movimiento condicional , que se compilaría en una instrucción de movimiento condicional:cmovl
en unx86
sistema. Se elimina la rama y, por lo tanto, la posible penalización de predicción de rama.En
C
, por lo tantoC++
, la declaración, que se compilaría directamente (sin ninguna optimización) en la instrucción de movimiento condicionalx86
, es el operador ternario... ? ... : ...
. Entonces reescribimos la declaración anterior en una equivalente:Mientras mantenemos la legibilidad, podemos verificar el factor de aceleración.
En un Intel Core i7 -2600K @ 3.4 GHz y Visual Studio 2010 Release Mode, el punto de referencia es (formato copiado de Mysticial):
x86
x64
El resultado es robusto en múltiples pruebas. Obtenemos una gran aceleración cuando el resultado de la rama es impredecible, pero sufrimos un poco cuando es predecible. De hecho, cuando se usa un movimiento condicional, el rendimiento es el mismo independientemente del patrón de datos.
Ahora echemos un vistazo más de cerca al investigar el
x86
ensamblaje que generan. Para simplificar, usamos dos funcionesmax1
ymax2
.max1
usa la rama condicionalif... else ...
:max2
usa el operador ternario... ? ... : ...
:En una máquina x86-64,
GCC -S
genera el siguiente ensamblaje.max2
usa mucho menos código debido al uso de instruccionescmovge
. Pero la ganancia real es quemax2
no implica saltos de ramajmp
, lo que tendría una penalización de rendimiento significativa si el resultado predicho no es correcto.Entonces, ¿por qué un movimiento condicional funciona mejor?
En un
x86
procesador típico , la ejecución de una instrucción se divide en varias etapas. Aproximadamente, tenemos hardware diferente para lidiar con diferentes etapas. Por lo tanto, no tenemos que esperar a que termine una instrucción para comenzar una nueva. Esto se llama tubería .En un caso derivado, la siguiente instrucción está determinada por la precedente, por lo que no podemos hacer la canalización. Tenemos que esperar o predecir.
En un caso de movimiento condicional, la instrucción de movimiento condicional de ejecución se divide en varias etapas, pero a las etapas anteriores les gusta
Fetch
yDecode
no dependen del resultado de la instrucción anterior; solo las últimas etapas necesitan el resultado. Por lo tanto, esperamos una fracción del tiempo de ejecución de una instrucción. Es por eso que la versión de movimiento condicional es más lenta que la rama cuando la predicción es fácil.El libro Computer Systems: A Programmer's Perspective, segunda edición, explica esto en detalle. Puede consultar la Sección 3.6.6 para ver las Instrucciones de movimiento condicional , el Capítulo 4 completo para la Arquitectura del procesador y la Sección 5.11.2 para obtener un tratamiento especial para las Penalizaciones de predicción de rama y de predicción errónea .
A veces, algunos compiladores modernos pueden optimizar nuestro código para ensamblar con un mejor rendimiento, a veces algunos compiladores no pueden (el código en cuestión está usando el compilador nativo de Visual Studio). Conocer la diferencia de rendimiento entre la rama y el movimiento condicional cuando es impredecible puede ayudarnos a escribir código con un mejor rendimiento cuando el escenario se vuelve tan complejo que el compilador no puede optimizarlos automáticamente.
fuente
-O0
ejemplo engañoso y mostrar la diferencia en el asm optimizado en sus dos casos de prueba.Si tiene curiosidad sobre aún más optimizaciones que se pueden hacer a este código, considere esto:
Comenzando con el bucle original:
Con el intercambio de bucles, podemos cambiar este bucle de manera segura a:
Luego, puede ver que el
if
condicional es constante durante la ejecución deli
bucle, por lo que puede izar laif
salida:Luego, verá que el bucle interno se puede contraer en una sola expresión, suponiendo que el modelo de coma flotante lo permita (
/fp:fast
se lanza, por ejemplo)Ese es 100,000 veces más rápido que antes.
fuente
i
de una unidad = 1e5. No importa el resultado final, pero solo quería dejar las cosas claras ya que esta es una página tan frecuentada.if
en este punto podría convertirse en: losum += (data[j] >= 128) ? data[j] * 100000 : 0;
que el compilador puede reducircmovge
o equivalente.Sin duda, algunos de nosotros estaríamos interesados en formas de identificar código que sea problemático para el predictor de rama de la CPU. La herramienta Valgrind
cachegrind
tiene un simulador de predicción de rama, habilitado mediante el uso de la--branch-sim=yes
bandera. Ejecutarlo sobre los ejemplos en esta pregunta, con el número de bucles externos reducidos a 10000 y compiladosg++
, da estos resultados:Ordenados:
Sin clasificar:
Profundizando en la salida línea por línea producida por
cg_annotate
el bucle en cuestión:Ordenados:
Sin clasificar:
Esto le permite identificar fácilmente la línea problemática: en la versión no ordenada, la
if (data[c] >= 128)
línea está causando 164.050.007 ramas condicionales mal predichas (Bcm
) bajo el modelo de predicción de ramas de cachegrind, mientras que solo está causando 10.006 en la versión ordenada.Alternativamente, en Linux puede usar el subsistema de contadores de rendimiento para realizar la misma tarea, pero con rendimiento nativo usando contadores de CPU.
Ordenados:
Sin clasificar:
También puede hacer anotaciones de código fuente con desmontaje.
Vea el tutorial de rendimiento para más detalles.
fuente
data[c] >= 128
(que tiene una tasa de fallas del 50% como sugiere) y otra para la condición del buclec < arraySize
que tiene una tasa de fallas de ~ 0% .Acabo de leer sobre esta pregunta y sus respuestas, y siento que falta una respuesta.
Una forma común de eliminar la predicción de rama que he encontrado que funciona particularmente bien en lenguajes administrados es buscar una tabla en lugar de usar una rama (aunque no lo he probado en este caso).
Este enfoque funciona en general si:
Antecedentes y por qué
Desde la perspectiva del procesador, su memoria es lenta. Para compensar la diferencia de velocidad, hay un par de cachés integrados en su procesador (caché L1 / L2). Así que imagine que está haciendo sus buenos cálculos y descubra que necesita un pedazo de memoria. El procesador realizará su operación de 'carga' y cargará la memoria en el caché, y luego usará el caché para hacer el resto de los cálculos. Debido a que la memoria es relativamente lenta, esta 'carga' ralentizará su programa.
Al igual que la predicción de bifurcación, esto se optimizó en los procesadores Pentium: el procesador predice que necesita cargar un dato e intenta cargarlo en el caché antes de que la operación realmente llegue al caché. Como ya hemos visto, la predicción de bifurcación a veces va terriblemente mal: en el peor de los casos, debe retroceder y esperar una carga de memoria, lo que tomará una eternidad ( en otras palabras: la predicción de bifurcación errónea es mala, un recuerdo ¡cargar después de que una predicción de rama falle es simplemente horrible! ).
Afortunadamente para nosotros, si el patrón de acceso a la memoria es predecible, el procesador lo cargará en su caché rápida y todo estará bien.
Lo primero que debemos saber es qué es pequeño . Mientras más pequeño es generalmente mejor, una regla general es apegarse a las tablas de búsqueda que tienen un tamaño <= 4096 bytes. Como límite superior: si su tabla de búsqueda es mayor a 64K, probablemente valga la pena reconsiderarla.
Construyendo una mesa
Entonces hemos descubierto que podemos crear una tabla pequeña. Lo siguiente que debe hacer es establecer una función de búsqueda. Las funciones de búsqueda suelen ser funciones pequeñas que utilizan un par de operaciones enteras básicas (y, o, xor, shift, add, remove y quizás se multipliquen). Desea que su entrada sea traducida por la función de búsqueda a algún tipo de 'clave única' en su tabla, que luego simplemente le da la respuesta de todo el trabajo que quería que hiciera.
En este caso:> = 128 significa que podemos mantener el valor, <128 significa que nos deshacemos de él. La forma más fácil de hacerlo es usando un 'Y': si lo conservamos, lo hacemos Y con 7FFFFFFF; si queremos deshacernos de él, Y lo hacemos con 0. Observe también que 128 es una potencia de 2, por lo que podemos seguir adelante y hacer una tabla de números enteros 32768/128 y llenarla con un cero y mucho 7FFFFFFFF's.
Idiomas gestionados
Quizás se pregunte por qué esto funciona bien en los lenguajes administrados. Después de todo, los idiomas administrados verifican los límites de las matrices con una rama para asegurarse de que no se equivoque ...
Bueno no exactamente... :-)
Se ha trabajado bastante en eliminar esta rama para los idiomas administrados. Por ejemplo:
En este caso, es obvio para el compilador que la condición de límite nunca se verá afectada. Al menos el compilador JIT de Microsoft (pero espero que Java haga cosas similares) lo notará y eliminará la comprobación por completo. WOW, eso significa que no hay rama. Del mismo modo, se ocupará de otros casos obvios.
Si tiene problemas con las búsquedas en los idiomas administrados, la clave es agregar una
& 0x[something]FFF
a su función de búsqueda para hacer que la verificación de límites sea predecible, y ver que va más rápido.El resultado de este caso.
fuente
sum += lookup[data[j]]
dóndelookup
está una matriz con 256 entradas, las primeras son cero y las últimas son iguales al índice?Como los datos se distribuyen entre 0 y 255 cuando se ordena la matriz, alrededor de la primera mitad de las iteraciones no entrarán en la
if
declaración-(laif
declaración se comparte a continuación).La pregunta es: ¿qué hace que la declaración anterior no se ejecute en ciertos casos como en el caso de los datos ordenados? Aquí viene el "predictor de rama". Un predictor de rama es un circuito digital que trata de adivinar en qué dirección
if-then-else
irá una rama (por ejemplo, una estructura) antes de que esto sea seguro. El propósito del predictor de rama es mejorar el flujo en la tubería de instrucciones. ¡Los predictores de rama juegan un papel crítico para lograr un alto rendimiento efectivo!Hagamos algunas marcas de banco para entenderlo mejor
El rendimiento de una
if
declaración depende de si su condición tiene un patrón predecible. Si la condición es siempre verdadera o siempre falsa, la lógica de predicción de ramificación en el procesador recogerá el patrón. Por otro lado, si el patrón es impredecible, laif
declaración será mucho más costosa.Midamos el rendimiento de este bucle con diferentes condiciones:
Aquí están los tiempos del ciclo con diferentes patrones de verdadero-falso:
Una “ mala ” patrón de verdadero o falso puede hacer una
if
-statement hasta seis veces más lento que una “ buena ” patrón! Por supuesto, qué patrón es bueno y cuál es malo depende de las instrucciones exactas generadas por el compilador y el procesador específico.Por lo tanto, no hay dudas sobre el impacto de la predicción de sucursales en el rendimiento.
fuente
Una forma de evitar errores de predicción de rama es crear una tabla de búsqueda e indexarla utilizando los datos. Stefan de Bruijn lo discutió en su respuesta.
Pero en este caso, sabemos que los valores están en el rango [0, 255] y solo nos importan los valores> = 128. Eso significa que podemos extraer fácilmente un solo bit que nos dirá si queremos un valor o no: cambiando los datos a la derecha son 7 bits, nos quedan 0 bits o 1 bit, y solo queremos agregar el valor cuando tenemos 1 bit. Llamemos a este bit el "bit de decisión".
Al usar el valor 0/1 del bit de decisión como un índice en una matriz, podemos hacer un código que será igualmente rápido si los datos se ordenan o no. Nuestro código siempre agregará un valor, pero cuando el bit de decisión es 0, agregaremos el valor en algún lugar que no nos interese. Aquí está el código:
Este código desperdicia la mitad de las adiciones pero nunca tiene una falla de predicción de rama. Es tremendamente más rápido en datos aleatorios que la versión con una declaración if real.
Pero en mis pruebas, una tabla de búsqueda explícita fue ligeramente más rápida que esto, probablemente porque la indexación en una tabla de búsqueda fue un poco más rápida que el desplazamiento de bits. Esto muestra cómo mi código se configura y usa la tabla de búsqueda (inimaginablemente llamada
lut
"Tabla de búsqueda" en el código). Aquí está el código C ++:En este caso, la tabla de búsqueda tenía solo 256 bytes, por lo que cabe muy bien en un caché y todo fue rápido. Esta técnica no funcionaría bien si los datos fueran valores de 24 bits y solo quisiéramos la mitad de ellos ... la tabla de búsqueda sería demasiado grande para ser práctica. Por otro lado, podemos combinar las dos técnicas que se muestran arriba: primero cambiar los bits, luego indexar una tabla de búsqueda. Para un valor de 24 bits que solo queremos el valor de la mitad superior, podríamos potencialmente desplazar los datos a la derecha en 12 bits, y quedarnos con un valor de 12 bits para un índice de tabla. Un índice de tabla de 12 bits implica una tabla de 4096 valores, lo que podría ser práctico.
La técnica de indexar en una matriz, en lugar de usar una
if
instrucción, se puede usar para decidir qué puntero usar. Vi una biblioteca que implementó árboles binarios, y en lugar de tener dos punteros con nombre (pLeft
y /pRight
o lo que sea) tenía una matriz de punteros de longitud 2 y usé la técnica de "bit de decisión" para decidir cuál seguir. Por ejemplo, en lugar de:esta biblioteca haría algo como:
Aquí hay un enlace a este código: Red Black Trees , Eternally Confuzzled
fuente
data[c]>>7
cual también se trata aquí) Intencionalmente dejé fuera esta solución, pero por supuesto que tienes razón. Solo una pequeña nota: la regla general para las tablas de búsqueda es que si cabe en 4KB (debido al almacenamiento en caché), funcionará, preferiblemente haga que la tabla sea lo más pequeña posible. Para los lenguajes administrados lo empujaría a 64 KB, para los lenguajes de bajo nivel como C ++ y C, probablemente lo reconsideraría (esa es solo mi experiencia). Desde entoncestypeof(int) = 4
, trataría de mantener un máximo de 10 bits.sizeof(int) == 4
? Eso sería cierto para 32 bits. Mi teléfono celular de dos años tiene un caché L1 de 32 KB, por lo que incluso una tabla de búsqueda 4K podría funcionar, especialmente si los valores de búsqueda eran un byte en lugar de un int.j
método igual a 0 o 1, ¿por qué no simplemente multiplica su valorj
antes de agregarlo en lugar de usar la indexación de matriz (posiblemente debería multiplicarse por en1-j
lugar dej
)int c = data[j]; sum += c & -(c >> 7);
que no requiere multiplicación alguna.En el caso ordenado, puede hacerlo mejor que confiar en una predicción de rama exitosa o en cualquier truco de comparación sin rama: elimine completamente la rama.
De hecho, la matriz se divide en una zona contigua con
data < 128
y otra condata >= 128
. Por lo tanto, debe encontrar el punto de partición con una búsqueda dicotómica (usandoLg(arraySize) = 15
comparaciones), luego hacer una acumulación directa desde ese punto.Algo así (sin marcar)
o, un poco más ofuscado
Un enfoque aún más rápido, que brinda una solución aproximada para ambos, clasificados o no clasificados es:
sum= 3137536;
(suponiendo una distribución verdaderamente uniforme, 16384 muestras con el valor esperado 191.5) :-)fuente
sum= 3137536
- inteligente Obviamente, ese no es el punto de la pregunta. La pregunta se trata claramente de explicar características de rendimiento sorprendentes. Me inclino a decir que la adición de hacer enstd::partition
lugar destd::sort
es valiosa. Aunque la pregunta real se extiende más allá del punto de referencia sintético dado.El comportamiento anterior está sucediendo debido a la predicción de Branch.
Para comprender la predicción de ramificación, primero se debe comprender la canalización de instrucciones :
Cualquier instrucción se divide en una secuencia de pasos para que se puedan ejecutar diferentes pasos simultáneamente en paralelo. Esta técnica se conoce como canalización de instrucciones y se usa para aumentar el rendimiento en los procesadores modernos. Para entender esto mejor, vea este ejemplo en Wikipedia .
En general, los procesadores modernos tienen tuberías bastante largas, pero para mayor facilidad consideremos estos 4 pasos solamente.
Tubería de 4 etapas en general para 2 instrucciones.
Volviendo a la pregunta anterior, consideremos las siguientes instrucciones:
Sin predicción de rama, ocurriría lo siguiente:
Para ejecutar la instrucción B o la instrucción C, el procesador tendrá que esperar hasta que la instrucción A no llegue hasta la etapa EX en la tubería, ya que la decisión de ir a la instrucción B o la instrucción C depende del resultado de la instrucción A. Por lo tanto, la tubería Se verá así.
cuando si la condición devuelve verdadero:
Cuando la condición devuelve falso:
Como resultado de esperar el resultado de la instrucción A, el total de ciclos de CPU gastados en el caso anterior (sin predicción de rama; tanto para verdadero como para falso) es 7.
Entonces, ¿qué es la predicción de rama?
El predictor de rama intentará adivinar en qué dirección irá una rama (una estructura si-entonces-otra) antes de que esto sea seguro. No esperará a que la instrucción A llegue a la etapa EX de la tubería, pero adivinará la decisión e irá a esa instrucción (B o C en el caso de nuestro ejemplo).
En caso de una suposición correcta, la tubería se ve así:
Si luego se detecta que la suposición fue incorrecta, las instrucciones ejecutadas parcialmente se descartan y la tubería comienza de nuevo con la rama correcta, lo que genera un retraso. El tiempo que se desperdicia en caso de una predicción errónea de la rama es igual al número de etapas en la tubería desde la etapa de recuperación hasta la etapa de ejecución. Los microprocesadores modernos tienden a tener tuberías bastante largas, por lo que el retraso de predicción errónea es de entre 10 y 20 ciclos de reloj. Cuanto más larga sea la tubería, mayor será la necesidad de un buen predictor de rama .
En el código del OP, la primera vez que es condicional, el predictor de rama no tiene ninguna información para basar la predicción, por lo que la primera vez elegirá aleatoriamente la siguiente instrucción. Más adelante en el ciclo for, puede basar la predicción en el historial. Para una matriz ordenada en orden ascendente, hay tres posibilidades:
Supongamos que el predictor siempre asumirá la rama verdadera en la primera ejecución.
Entonces, en el primer caso, siempre tomará la rama verdadera ya que históricamente todas sus predicciones son correctas. En el segundo caso, inicialmente pronosticará mal, pero después de algunas iteraciones, pronosticará correctamente. En el tercer caso, inicialmente pronosticará correctamente hasta que los elementos sean inferiores a 128. Después de lo cual fallará durante un tiempo y se corregirá cuando vea un fallo de predicción de rama en la historia.
En todos estos casos, la falla será demasiado menor en número y, como resultado, solo unas pocas veces tendrá que descartar las instrucciones parcialmente ejecutadas y comenzar de nuevo con la rama correcta, lo que resulta en menos ciclos de CPU.
Pero en el caso de una matriz aleatoria sin clasificar, la predicción deberá descartar las instrucciones ejecutadas parcialmente y comenzar de nuevo con la rama correcta la mayor parte del tiempo y resultar en más ciclos de CPU en comparación con la matriz ordenada.
fuente
Una respuesta oficial sería de
También puede ver en este hermoso diagrama por qué el predictor de rama se confunde.
Cada elemento en el código original es un valor aleatorio
entonces el predictor cambiará de lado como el
std::rand()
golpe.Por otro lado, una vez que se ordena, el predictor se moverá primero a un estado de fuerte no tomado y cuando los valores cambien al valor alto, el predictor cambiará en tres ejecuciones desde fuertemente no tomado hasta fuertemente tomado.
fuente
En la misma línea (creo que esto no fue resaltado por ninguna respuesta) es bueno mencionar que a veces (especialmente en software donde el rendimiento importa, como en el kernel de Linux) puede encontrar algunas declaraciones if como las siguientes:
o similarmente:
Ambas,
likely()
yunlikely()
de hecho, son macros que se definen usando algo como los GCC__builtin_expect
para ayudar al compilador a insertar el código de predicción para favorecer la condición teniendo en cuenta la información proporcionada por el usuario. GCC admite otras funciones integradas que podrían cambiar el comportamiento del programa en ejecución o emitir instrucciones de bajo nivel, como borrar la memoria caché, etc. Consulte esta documentación que analiza las funciones integradas de GCC disponibles.Normalmente, este tipo de optimizaciones se encuentran principalmente en aplicaciones en tiempo real o sistemas integrados donde el tiempo de ejecución es importante y es crítico. Por ejemplo, si está buscando alguna condición de error que solo ocurre 1/10000000 veces, ¿por qué no informar al compilador sobre esto? De esta manera, por defecto, la predicción de rama supondría que la condición es falsa.
fuente
Las operaciones booleanas de uso frecuente en C ++ producen muchas ramas en el programa compilado. Si estas ramas están dentro de bucles y son difíciles de predecir, pueden ralentizar significativamente la ejecución. Las variables booleanas se almacenan como enteros de 8 bits con el valor
0
parafalse
y1
paratrue
.Las variables booleanas están sobredeterminadas en el sentido de que todos los operadores que tienen variables booleanas como entrada verifican si las entradas tienen otro valor que no sea
0
o1
, pero los operadores que tienen booleanos como salida no pueden producir ningún otro valor que0
o1
. Esto hace que las operaciones con variables booleanas como entrada sean menos eficientes de lo necesario. Considere un ejemplo:Normalmente, el compilador lo implementa de la siguiente manera:
Este código está lejos de ser óptimo. Las ramas pueden tardar mucho tiempo en caso de predicciones erróneas. Las operaciones booleanas pueden hacerse mucho más eficientes si se sabe con certeza que los operandos no tienen otros valores que
0
y1
. La razón por la cual el compilador no hace tal suposición es que las variables pueden tener otros valores si no se inicializan o provienen de fuentes desconocidas. El código anterior se puede optimizar sia
yb
se ha inicializado a valores válidos o si vienen de los operadores que producen salida booleana. El código optimizado se ve así:char
se usa en lugar debool
para hacer posible el uso de operadores bit a bit (&
y|
) en lugar de los operadores booleanos (&&
y||
). Los operadores bit a bit son instrucciones únicas que toman solo un ciclo de reloj. El operador O (|
) funciona incluso sia
yb
tienen valores distintos0
o1
. El operador AND (&
) y el operador EXCLUSIVO OR (^
) pueden dar resultados inconsistentes si los operandos tienen otros valores distintos de0
y1
.~
No se puede utilizar para NO. En cambio, puede hacer un NOT booleano en una variable que se sabe que es0
o1
haciendo XOR con1
:se puede optimizar para:
a && b
no se puede reemplazar cona & b
ifb
es una expresión que no debe evaluarse ifa
isfalse
(&&
no evaluaráb
,&
will). Del mismo modo,a || b
no se puede reemplazar cona | b
ifb
es una expresión que no debe evaluarse ifa
istrue
.Usar operadores bit a bit es más ventajoso si los operandos son variables que si los operandos son comparaciones:
es óptimo en la mayoría de los casos (a menos que espere que la
&&
expresión genere muchas predicciones erróneas de rama).fuente
¡Eso es seguro!...
¡La predicción de bifurcación hace que la lógica funcione más lentamente, debido al cambio que ocurre en su código! Es como si estuvieras yendo por una calle recta o una calle con muchas curvas, ¡seguro que la recta se hará más rápido! ...
Si se ordena la matriz, su condición es falsa en el primer paso:,
data[c] >= 128
luego se convierte en un valor verdadero para todo el camino hasta el final de la calle. Así es como llegas al final de la lógica más rápido. Por otro lado, al usar una matriz no ordenada, necesita una gran cantidad de giros y procesamientos que hacen que su código se ejecute más lento con seguridad ...Mira la imagen que creé para ti a continuación. ¿Qué calle se va a terminar más rápido?
Entonces, mediante programación, la predicción de ramificación hace que el proceso sea más lento ...
También al final, es bueno saber que tenemos dos tipos de predicciones de rama que afectarán su código de manera diferente:
1. Estática
2. Dinámico
fuente
Esta pregunta ya ha sido respondida excelentemente muchas veces. Aún así, me gustaría llamar la atención del grupo sobre otro análisis interesante.
Recientemente, este ejemplo (modificado muy ligeramente) también se usó como una forma de demostrar cómo se puede perfilar un fragmento de código dentro del programa en Windows. En el camino, el autor también muestra cómo usar los resultados para determinar dónde pasa la mayor parte del tiempo el código, tanto en el caso ordenado como en el no ordenado. Finalmente, la pieza también muestra cómo usar una característica poco conocida de la HAL (Capa de abstracción de hardware) para determinar cuánta predicción de ramificación está ocurriendo en el caso no ordenado.
El enlace está aquí: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm
fuente
When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping.
autor está tratando de discutir la creación de perfiles en el contexto del código publicado aquí y en el proceso tratando de explicar por qué el caso ordenado es mucho más rápido.Como lo que ya han mencionado otros, lo que está detrás del misterio es Branch Predictor .
No estoy tratando de agregar algo, sino explicar el concepto de otra manera. Hay una introducción concisa en la wiki que contiene texto y diagrama. Me gusta la explicación a continuación que usa un diagrama para elaborar el Predictor de rama intuitivamente.
Basado en el escenario descrito, he escrito una demostración de animación para mostrar cómo se ejecutan las instrucciones en una tubería en diferentes situaciones.
El ejemplo contiene tres instrucciones y la primera es una instrucción de salto condicional. Las últimas dos instrucciones pueden ir a la tubería hasta que se ejecute la instrucción de salto condicional.
Tomará 9 ciclos de reloj para completar 3 instrucciones.
Tomará 7 ciclos de reloj para completar 3 instrucciones.
Tomará 9 ciclos de reloj para completar 3 instrucciones.
Como puede ver, parece que no tenemos una razón para no usar Branch Predictor.
Es una demostración bastante simple que aclara la parte más básica de Branch Predictor. Si esos gifs son molestos, no dude en eliminarlos de la respuesta y los visitantes también pueden obtener el código fuente de demostración en vivo de BranchPredictorDemo
fuente
if()
bloque puede ejecutarse antes de que se conozca la condición de ramificación. O para un ciclo de búsqueda comostrlen
omemchr
, las interacciones pueden superponerse. Si tuviera que esperar a que se conozca el resultado de coincidencia o no antes de ejecutar cualquiera de las próximas iteraciones, tendría un cuello de botella en la carga de caché + latencia ALU en lugar de rendimiento.Ganancia de predicción de rama!
Es importante comprender que la predicción errónea de la rama no ralentiza los programas. El costo de una predicción perdida es como si la predicción de la rama no existiera y esperara la evaluación de la expresión para decidir qué código ejecutar (más explicaciones en el siguiente párrafo).
Siempre que haya una instrucción
if-else
\switch
, la expresión debe evaluarse para determinar qué bloque debe ejecutarse. En el código de ensamblaje generado por el compilador, se insertan instrucciones de ramificación condicional .Una instrucción de bifurcación puede hacer que una computadora comience a ejecutar una secuencia de instrucciones diferente y, por lo tanto, se desvíe de su comportamiento predeterminado de ejecutar instrucciones en orden (es decir, si la expresión es falsa, el programa omite el código del
if
bloque) dependiendo de alguna condición, que es La evaluación de la expresión en nuestro caso.Dicho esto, el compilador intenta predecir el resultado antes de que se evalúe realmente. Obtendrá instrucciones del
if
bloque, y si la expresión resulta ser verdadera, ¡entonces maravillosa! Ganamos el tiempo necesario para evaluarlo e hicimos progresos en el código; si no, entonces estamos ejecutando el código incorrecto, la tubería se vacía y se ejecuta el bloque correcto.Visualización:
Digamos que necesita elegir la ruta 1 o la ruta 2. Esperando a que su compañero revise el mapa, se detuvo en ## y esperó, o simplemente podría elegir la ruta1 y si tuvo suerte (la ruta 1 es la ruta correcta), entonces genial, no tuviste que esperar a que tu compañero revisara el mapa (ahorraste el tiempo que le habría tomado revisar el mapa), de lo contrario simplemente regresarás.
Si bien el vaciado de tuberías es súper rápido, hoy en día vale la pena tomar esta apuesta. Predecir datos ordenados o datos que cambian lentamente siempre es más fácil y mejor que predecir cambios rápidos.
fuente
En ARM, no se necesita una bifurcación, porque cada instrucción tiene un campo de condición de 4 bits, que prueba (a costo cero) cualquiera de las 16 condiciones diferentes que pueden surgir en el Registro de estado del procesador, y si la condición de una instrucción es falso, se omiten las instrucciones. Esto elimina la necesidad de ramificaciones cortas, y no habría éxito de predicción de ramificación para este algoritmo. Por lo tanto, la versión ordenada de este algoritmo sería más lenta que la versión no ordenada en ARM, debido a la sobrecarga adicional de la clasificación.
El bucle interno para este algoritmo se vería similar al siguiente en lenguaje ensamblador ARM:
Pero esto es realmente parte de una imagen más grande:
CMP
los códigos de operación siempre actualizan los bits de estado en el Registro de estado del procesador (PSR), porque ese es su propósito, pero la mayoría de las otras instrucciones no tocan el PSR a menos que agregue unS
sufijo opcional a la instrucción, especificando que el PSR debe actualizarse en función del resultado de la instrucción. Al igual que el sufijo de condición de 4 bits, poder ejecutar instrucciones sin afectar el PSR es un mecanismo que reduce la necesidad de ramificaciones en ARM y también facilita el envío fuera de servicio a nivel de hardware , porque después de realizar alguna operación X se actualiza los bits de estado, posteriormente (o en paralelo) puede hacer un montón de otro trabajo que explícitamente no debería afectar los bits de estado, luego puede probar el estado de los bits de estado establecidos anteriormente por X.El campo de prueba de condición y el campo opcional "bit de estado establecido" se pueden combinar, por ejemplo:
ADD R1, R2, R3
realizaR1 = R2 + R3
sin actualizar ningún bit de estado.ADDGE R1, R2, R3
realiza la misma operación solo si una instrucción previa que afectó los bits de estado resultó en una condición Mayor o Igual.ADDS R1, R2, R3
realiza la suma y luego actualiza losN
,Z
,C
yV
banderas en el Registro de estado del procesador en función de si el resultado fue negativo, cero, realizado (para la adición sin firmar), o se desbordó (para la adición firmado).ADDSGE R1, R2, R3
realiza la adición solo si laGE
prueba es verdadera y luego actualiza los bits de estado según el resultado de la adición.La mayoría de las arquitecturas de procesador no tienen esta capacidad de especificar si los bits de estado deben actualizarse o no para una operación determinada, lo que puede requerir escribir código adicional para guardar y luego restaurar bits de estado, o puede requerir ramificaciones adicionales, o puede limitar la salida del procesador de eficiencia de ejecución de órdenes: uno de los efectos secundarios de la mayoría de las arquitecturas de conjuntos de instrucciones de CPU que actualizan a la fuerza los bits de estado después de la mayoría de las instrucciones es que es mucho más difícil separar qué instrucciones se pueden ejecutar en paralelo sin interferir entre sí. La actualización de bits de estado tiene efectos secundarios, por lo tanto, tiene un efecto de linealización en el código.La capacidad de ARM de mezclar y combinar pruebas de condición sin ramificación en cualquier instrucción con la opción de actualizar o no actualizar los bits de estado después de cualquier instrucción es extremadamente poderosa, tanto para los programadores como para los compiladores de lenguaje ensamblador, y produce un código muy eficiente.
Si alguna vez se ha preguntado por qué ARM ha sido tan exitoso, la brillante efectividad y la interacción de estos dos mecanismos son una gran parte de la historia, porque son una de las mayores fuentes de eficiencia de la arquitectura ARM. La brillantez de los diseñadores originales de ARM ISA en 1983, Steve Furber y Roger (ahora Sophie) Wilson, no puede ser exagerada.
fuente
R2 = data + arraySize
, luego comience conR1 = -arraySize
. La parte inferior del bucle se convierte enadds r1, r1, #1
/bnz inner_loop
. Los compiladores no usan esta optimización por alguna razón: / Pero de todos modos, la ejecución predicada del complemento no es fundamentalmente diferente en este caso de lo que puede hacer con el código sin ramificación en otras ISA, como x86cmov
. Aunque no es tan agradable: el indicador de optimización de gcc -O3 hace que el código sea más lento que -O2cmov
con un operando de origen de memoria. La mayoría de los ISA, incluido AArch64, solo tienen operaciones de selección de ALU. Por lo tanto, la predicción ARM puede ser poderosa, y utilizable de manera más eficiente que el código sin ramificación en la mayoría de las ISA.)Se trata de la predicción de rama. ¿Qué es?
Un predictor de rama es una de las antiguas técnicas de mejora del rendimiento que aún encuentra relevancia en las arquitecturas modernas. Si bien las técnicas de predicción simples proporcionan una búsqueda rápida y eficiencia energética, sufren una alta tasa de predicción errónea.
Por otro lado, las predicciones de ramas complejas, ya sean neurales o variantes de predicción de ramas de dos niveles, proporcionan una mejor precisión de predicción, pero consumen más potencia y la complejidad aumenta exponencialmente.
Además de esto, en las técnicas de predicción complejas, el tiempo necesario para predecir las ramas es en sí mismo muy alto (rango de 2 a 5 ciclos), que es comparable al tiempo de ejecución de las ramas reales.
La predicción de rama es esencialmente un problema de optimización (minimización) en el que se hace hincapié en lograr la tasa de fallas más baja posible, el bajo consumo de energía y la baja complejidad con recursos mínimos.
Realmente hay tres tipos diferentes de ramas:
Ramificaciones condicionales de reenvío : en función de una condición de tiempo de ejecución, la PC (contador de programa) se cambia para apuntar a una dirección hacia adelante en la secuencia de instrucciones.
Ramas condicionales hacia atrás : la PC se cambia para apuntar hacia atrás en la secuencia de instrucciones. La bifurcación se basa en alguna condición, como la bifurcación hacia atrás al comienzo de un bucle de programa cuando una prueba al final del bucle indica que el bucle debe ejecutarse nuevamente.
Ramas incondicionales : esto incluye saltos, llamadas a procedimientos y devoluciones que no tienen una condición específica. Por ejemplo, una instrucción de salto incondicional puede codificarse en lenguaje ensamblador simplemente como "jmp", y la secuencia de instrucciones debe dirigirse inmediatamente a la ubicación de destino señalada por la instrucción de salto, mientras que un salto condicional que puede codificarse como "jmpne" redirigiría la secuencia de instrucciones solo si el resultado de una comparación de dos valores en instrucciones anteriores de "comparación" muestra que los valores no son iguales. (El esquema de direccionamiento segmentado utilizado por la arquitectura x86 agrega complejidad adicional, ya que los saltos pueden ser "cercanos" (dentro de un segmento) o "lejanos" (fuera del segmento). Cada tipo tiene diferentes efectos en los algoritmos de predicción de ramas.
Predicción de bifurcación estática / dinámica : el microprocesador usa la predicción de bifurcación estática la primera vez que se encuentra una bifurcación condicional, y la predicción de bifurcación dinámica se usa para ejecutar con éxito el código de bifurcación condicional.
Referencias
Predictor de rama
Una demostración de autoperforación
Revisión de predicción de rama
Predicción de rama
fuente
Además del hecho de que la predicción de ramificación puede ralentizarlo, una matriz ordenada tiene otra ventaja:
Puede tener una condición de detención en lugar de simplemente verificar el valor, de esta manera solo recorre los datos relevantes e ignora el resto.
La predicción de rama se perderá solo una vez.
fuente
Las matrices ordenadas se procesan más rápido que una matriz no clasificada, debido a un fenómeno llamado predicción de ramificación.
El predictor de rama es un circuito digital (en arquitectura de computadora) que intenta predecir en qué dirección irá una rama, mejorando el flujo en la tubería de instrucciones. El circuito / computadora predice el siguiente paso y lo ejecuta.
Hacer una predicción incorrecta lleva a volver al paso anterior y ejecutar con otra predicción. Suponiendo que la predicción es correcta, el código continuará con el siguiente paso. Una predicción incorrecta da como resultado la repetición del mismo paso, hasta que se produce una predicción correcta.
La respuesta a tu pregunta es muy simple.
En una matriz sin clasificar, la computadora realiza múltiples predicciones, lo que aumenta la posibilidad de errores. Mientras que, en una matriz ordenada, la computadora hace menos predicciones, lo que reduce la posibilidad de errores. Hacer más predicciones requiere más tiempo.
TATTEO
Matriz sin clasificar: carretera curva
Predicción de rama: adivinar / predecir qué camino es recto y seguirlo sin verificar
Aunque ambas carreteras llegan al mismo destino, la carretera recta es más corta y la otra es más larga. Si luego elige el otro por error, no hay vuelta atrás, por lo que perderá más tiempo si elige el camino más largo. Esto es similar a lo que sucede en la computadora, y espero que esto te haya ayudado a entender mejor.
También quiero citar @Simon_Weaver de los comentarios:
fuente
Probé el mismo código con MATLAB 2011b con mi MacBook Pro (Intel i7, 64 bit, 2.4 GHz) para el siguiente código MATLAB:
Los resultados para el código MATLAB anterior son los siguientes:
Los resultados del código C como en @GManNickG obtengo:
Basado en esto, parece que MATLAB es casi 175 veces más lento que la implementación de C sin clasificar y 350 veces más lento con la clasificación. En otras palabras, el efecto (de la predicción de ramificación) es 1.46x para la implementación de MATLAB y 2.7x para la implementación de C.
fuente
La suposición de otras respuestas de que uno necesita ordenar los datos no es correcta.
El siguiente código no clasifica toda la matriz, sino solo segmentos de 200 elementos, y por lo tanto se ejecuta más rápido.
La clasificación de solo secciones de elementos k completa el preprocesamiento en tiempo lineal
O(n)
, en lugar delO(n.log(n))
tiempo necesario para ordenar toda la matriz.Esto también "prueba" que no tiene nada que ver con ningún problema algorítmico, como el orden de clasificación, y de hecho es una predicción de rama.
fuente
pcmpgtb
para encontrar elementos con su conjunto de bits alto, luego AND a cero elementos más pequeños). Pasar cualquier tiempo realmente clasificando trozos sería más lento. Una versión sin sucursales tendría un rendimiento independiente de los datos, lo que también demuestra que el costo proviene de la predicción errónea de la sucursal. O simplemente use los contadores de rendimiento para observar eso directamente, como Skylakeint_misc.clear_resteer_cycles
oint_misc.recovery_cycles
para contar los ciclos inactivos frontales de las predicciones erróneasLa respuesta de Bjarne Stroustrup a esta pregunta:
Eso suena como una pregunta de entrevista. ¿Es verdad? ¿Cómo sabrías? Es una mala idea responder preguntas sobre la eficiencia sin primero hacer algunas mediciones, por lo que es importante saber cómo medir.
Entonces, probé con un vector de un millón de enteros y obtuve:
Lo corrí varias veces para estar seguro. Sí, el fenómeno es real. Mi código clave fue:
Al menos, el fenómeno es real con este compilador, la biblioteca estándar y la configuración del optimizador. Las diferentes implementaciones pueden y dan diferentes respuestas. De hecho, alguien hizo un estudio más sistemático (una búsqueda rápida en la web lo encontrará) y la mayoría de las implementaciones muestran ese efecto.
Una razón es la predicción de rama: la operación clave en el algoritmo de clasificación es
“if(v[i] < pivot]) …”
o equivalente. Para una secuencia ordenada, esa prueba siempre es verdadera, mientras que, para una secuencia aleatoria, la rama elegida varía aleatoriamente.Otra razón es que cuando el vector ya está ordenado, nunca necesitamos mover elementos a su posición correcta. El efecto de estos pequeños detalles es el factor de cinco o seis que vimos.
Quicksort (y clasificación en general) es un estudio complejo que ha atraído a algunas de las mejores mentes de la informática. Una buena función de clasificación es el resultado de elegir un buen algoritmo y prestar atención al rendimiento del hardware en su implementación.
Si desea escribir código eficiente, necesita saber un poco sobre la arquitectura de la máquina.
fuente
Esta pregunta tiene sus raíces en los modelos de predicción de sucursales en las CPU. Recomiendo leer este artículo:
Aumento de la tasa de obtención de instrucciones a través de la predicción de múltiples sucursales y un caché de direcciones de sucursal
Cuando haya ordenado elementos, IR no podría molestarse en buscar todas las instrucciones de la CPU, una y otra vez, las recupera de la memoria caché.
fuente
Una forma de evitar errores de predicción de rama es crear una tabla de búsqueda e indexarla utilizando los datos. Stefan de Bruijn lo discutió en su respuesta.
Pero en este caso, sabemos que los valores están en el rango [0, 255] y solo nos interesan los valores> = 128. Eso significa que podemos extraer fácilmente un solo bit que nos dirá si queremos un valor o no: cambiando los datos a la derecha son de 7 bits, nos quedan 0 bits o 1 bit, y solo queremos agregar el valor cuando tenemos 1 bit. Llamemos a este bit el "bit de decisión".
Al usar el valor 0/1 del bit de decisión como un índice en una matriz, podemos hacer un código que será igualmente rápido si los datos se ordenan o no. Nuestro código siempre agregará un valor, pero cuando el bit de decisión es 0, agregaremos el valor en algún lugar que no nos interese. Aquí está el código:
// Prueba
Este código desperdicia la mitad de las adiciones, pero nunca tiene una falla de predicción de rama. Es tremendamente más rápido en datos aleatorios que la versión con una declaración if real.
Pero en mis pruebas, una tabla de búsqueda explícita fue ligeramente más rápida que esto, probablemente porque la indexación en una tabla de búsqueda fue un poco más rápida que el desplazamiento de bits. Esto muestra cómo mi código se configura y usa la tabla de búsqueda (sin imaginación llamada lut para "Tabla de búsqueda" en el código). Aquí está el código C ++:
// Declara y luego completa la tabla de búsqueda
En este caso, la tabla de búsqueda tenía solo 256 bytes, por lo que cabe muy bien en un caché y todo fue rápido. Esta técnica no funcionaría bien si los datos fueran valores de 24 bits y solo quisiéramos la mitad de ellos ... la tabla de búsqueda sería demasiado grande para ser práctica. Por otro lado, podemos combinar las dos técnicas que se muestran arriba: primero cambiar los bits, luego indexar una tabla de búsqueda. Para un valor de 24 bits que solo queremos el valor de la mitad superior, podríamos desplazar los datos a la derecha en 12 bits, y quedarnos con un valor de 12 bits para un índice de tabla. Un índice de tabla de 12 bits implica una tabla de 4096 valores, lo que podría ser práctico.
La técnica de indexar en una matriz, en lugar de usar una instrucción if, se puede usar para decidir qué puntero usar. Vi una biblioteca que implementó árboles binarios, y en lugar de tener dos punteros con nombre (pLeft y pRight o lo que sea) tenía una matriz de punteros de longitud 2 y usé la técnica de "bit de decisión" para decidir cuál seguir. Por ejemplo, en lugar de:
es una buena solución tal vez funcione
fuente
mask = tmp < 128 : 0 : -1UL;
/total += tmp & mask;