Declaración de juicio condicional y predicción de rama

Inserte la descripción de la imagen aquíEste trabajo está licenciado bajo el Acuerdo de Licencia Internacional de Atribución-Uso No Comercial-Compartir 4.0 de Creative Commons de la misma manera .
Inserte la descripción de la imagen aquíEste trabajo ( Lizhao Long Bowen por la creación de Li Zhaolong ) por la confirmación de Li Zhaolong , por favor indique los derechos de autor.

Introducción

La razón por la que escribí este artículo es ver el problema de [1]. Aunque he aprendido antes sobre la predicción de ramas, no esperaba que el impacto en el rendimiento en condiciones extremas fuera tan grande. Originalmente quería describir esta pregunta y su respuesta en detalle según mi propio entendimiento, pero descubrí que muchos artículos ya han logrado este objetivo, por lo que este artículo hablará brevemente sobre ello.

Descripción del problema

El primero es mirar un código muy clásico:

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    
    
    const unsigned ARRAY_SIZE = 50000;
    int data[ARRAY_SIZE];
    const unsigned DATA_STRIDE = 256;

    for (unsigned c = 0; c < ARRAY_SIZE; ++c) data[c] = std::rand() % DATA_STRIDE;

    std::sort(data, data + ARRAY_SIZE);

    {
    
      // 测试部分
        clock_t start = clock();
        long long sum = 0;

        for (unsigned i = 0; i < 100000; ++i) {
    
    
            for (unsigned c = 0; c < ARRAY_SIZE; ++c) {
    
    
                if (data[c] >= 128) sum += data[c];
            }
        }

        double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

        std::cout << elapsedTime << "\n";
        std::cout << "sum = " << sum << "\n";
    }
    return 0;
}

Echamos un vistazo y comentamos el tipo no ha comentado qué tipo de influencia en el programa, por supuesto, de acuerdo con los términos del sentido común no tiene ningún impacto, pero ordenando más lentamente, porque la complejidad del tiempo de la O(N)rosa O(NlogN), los siguientes son los resultados:
Inserte la descripción de la imagen aquí
arriba Se agrega la ordenación, pero no se agrega lo siguiente.

¿Cómo está, conmocionado? Hay una brecha de desempeño de más de tres veces, lo cual es completamente inconsistente con las expectativas. La respuesta a la pregunta es en realidad 分支预测.

Cuando aprendimos el principio de las microcomputadoras, sabíamos que las BLUinstrucciones de captación previa en realidad se realizarían en él, porque para la CPU, una instrucción tiene que seguir los siguientes pasos de principio a fin:

  1. Recuperar: Recuperar
  2. La traducción se refiere a: Decodificar
  3. Ejecutar: ejecutar
  4. Recuperación: escritura diferida

Obviamente, la ejecución es solo una parte, entonces las canalizaciones de la aplicación (canalizaciones) son obviamente una mejor manera de exprimir la CPU, porque la CPU puede ejecutarse sin desperdiciar recursos del bus, y no hay necesidad de esperar el proceso de obtención de instrucciones después de ejecutar una instrucción. . Sin embargo, puede haber un problema al encontrar una sentencia de juicio condicional. Dependiendo del verdadero / falso de la condición de juicio, puede ocurrir un salto y no sabemos a qué rama saltar antes del juicio condicional. Obviamente, hay dos métodos, uno es esperar sincrónicamente, de modo que no se obtenga la instrucción incorrecta, pero es muy lento; el segundo método es elegir una rama en función de una determinada condición, cargarla primero en la cola de instrucciones y predecir el éxito Estar bien. Si ocurre un error, vacíe el búfer, retroceda a la rama anterior y vuelva a buscar las instrucciones. Para estrategias específicas de predicción de rama, consulte [5] [3].

Wiki tiene la siguiente descripción de predicción de rama [3]:

  • Sin la predicción de rama, el procesador tendría que esperar hasta que la instrucción de salto condicional haya pasado la etapa de ejecución antes de que la siguiente instrucción pueda ingresar a la etapa de recuperación en la canalización. El predictor de rama intenta evitar esta pérdida de tiempo tratando de adivinar si es más probable que se realice o no el salto condicional. La rama que se supone que es la más probable se recupera y se ejecuta especulativamente. Si más tarde se detecta que la suposición fue incorrecta, las instrucciones ejecutadas especulativamente o parcialmente ejecutadas se descartan y la canalización comienza de nuevo con la rama correcta, incurriendo en un retraso.
  • El tiempo que se pierde en caso de una predicción errónea de la rama es igual al número de etapas en la canalización 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 está entre 10 y 20 ciclos de reloj. Como resultado, alargar una canalización aumenta la necesidad de un predictor de rama más avanzado.
  • Si no hay una predicción de bifurcación, el procesador tendrá que esperar hasta que la instrucción de salto condicional pase la etapa de ejecución, y luego la siguiente instrucción podrá ingresar a la etapa de recuperación en la canalización. El predictor de bifurcaciones intenta evitar perder el tiempo intentando adivinar si es probable que se produzca un salto condicional. Luego, tome la rama que es más probable que adivine y ejecútela especulativamente. Si más tarde se detecta que la suposición es incorrecta, la instrucción ejecutada especulativamente o parcialmente ejecutada se descartará y la tubería se reiniciará desde la rama correcta, lo que provocará un retraso.
  • El tiempo perdido en el caso de una predicción de rama incorrecta es igual al número de etapas en la tubería desde la etapa de adquisición hasta la etapa de ejecución. Los microprocesadores modernos a menudo tienen tuberías muy largas, por lo que el retraso de predicción errónea es de entre 10 y 20 ciclos de reloj. Como resultado, alargar la tubería aumenta la demanda de predictores de rama más avanzados.

Entonces, podemos sacar una conclusión simple de la demostración simple anterior: recuerde estar atento al juicio de la lógica de optimización bajo la condición de una gran cantidad de bucles.


Por supuesto, usar el patrón de estrategia en el patrón de diseño puede evitar múltiples if-elseproblemas, pero la implementación del patrón de estrategia común se basa básicamente en la búsqueda de tablas [6], es decir, se coloca una tabla hash en ella y diferentes tipos de parámetros se pasan. Para ejecutar diferentes bloques de código. La definición del patrón de estrategia es la siguiente:

  • Defina una familia de algoritmos, encapsule cada uno y conviértalos en intercambiables. La estrategia permite que el algoritmo varíe independientemente de los clientes que lo utilicen.
  • Defina una familia de clases de algoritmos, encapsule cada algoritmo por separado, para que puedan reemplazarse entre sí. El modo de estrategia puede realizar cambios de algoritmo independientemente de los clientes que los utilicen.

Se puede ver que lo que el patrón de estrategia quiere hacer no es simplemente hacerlo más eficiente, sino hacer que la persona que llama y el proveedor de código estén débilmente acoplados e incluso que cumplan con el principio de apertura y cierre.

Por supuesto, lo que necesitamos con más frecuencia es la legibilidad y la capacidad de mantenimiento del código. La optimización del rendimiento es un asunto muy tardío y creo que hay menos escenarios que requieren optimización a nivel de instrucción, como los escenarios de almacenamiento y kernel.

mejoramiento

El código anterior es realmente extremo, ¿podemos optimizarlo? Por supuesto, es posible usar operaciones de bits para optimizar las ramas condicionales, de esta manera, la predicción de las ramas no es necesaria para la CPU y, por supuesto, no habrá cambios de rendimiento turbulentos como el código anterior.

La estrategia proviene de [2]:

|x| >> 31 = 0 # 非负数右移31为一定为0
~(|x| >> 31) = -1 # 0取反为-1
 
-|x| >> 31 = -1 # 负数右移31为一定为0xffff = -1
~(-|x| >> 31) = 0 # -1取反为0
 
-1 = 0xffff
-1 & x = x # 以-1为mask和任何数求与,值不变
int t = (data[c] - 128) >> 31; # statement 1
sum += ~t & data[c]; # statement 2

Más inteligente es esto:

int t=-((data[c]>=128)); # generate the mask
sum += ~t & data[c]; # bitwise AND

De hecho data[c], &el valor mayor que 128 es el valor correspondiente 0xffff; por el contrario, es 0. Este tipo de enunciado de juicio que es mayor que una condición es bastante común, por lo que podemos modificarlo en muchos lugares, lo cual es una especie de habilidad de programación. Puede ver un mayor uso de las operaciones de alineación en [7].

Inspiración para nuestro código

De hecho, de lo que quiero hablar son dos GCC, que built-in functionsestán relacionados con la predicción de rama y uno de ellos debería ser utilizable en nuestro código.

__builtin_speculation_safe_value

El papel de esta función se describe en el manual GNU de la siguiente manera:

  • Esta función incorporada se puede utilizar para ayudar a mitigar la ejecución especulativa insegura. type puede ser cualquier tipo integral o cualquier tipo de puntero.
  • Esta función incorporada se puede utilizar para ayudar a aliviar la ejecución predictiva insegura. El tipo puede ser cualquier tipo de entero o cualquier tipo de puntero.

Los siguientes ejemplos se dan en el manual:

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return array[untrusted_index];
  return 0;
}

En realidad, esto no es un problema en nuestro proceso de codificación diario, pero de hecho este código es un poco peligroso y el problema radica en la predicción de ramas.

Si llama a esta función varias veces con un valor menor que 500, y luego llama a la función con un valor fuera de rango, todavía intentará ejecutar el bloque de código primero, hasta que la CPU determine que la predicción es incorrecta (la CPU cancelar todo funcionamiento incorrecto). Sin embargo, dependiendo de cómo se use el resultado de la función, algunos rastros pueden quedar en la caché y estos rastros pueden revelar el contenido almacenado en ubicaciones fuera de los límites . Esto __builtin_speculation_safe_valuese puede evitar usando :

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return array[__builtin_speculation_safe_value (untrusted_index)];
  return 0;
}

Cuando cambiamos el código al formato anterior, se puede garantizar la seguridad. El manual describe que la función incorporada tiene dos comportamientos posibles en este momento:

  1. Hará que la ejecución se detenga hasta que la rama condicional se haya resuelto por completo;
  2. Se puede permitir que continúe la ejecución especulativa, pero si se excede el límite, se usa 0 en su lugar untrusted_value;

El manual describe que puede ser inseguro acceder a cualquier ubicación de la memoria cuando la especulación se ejecuta incorrectamente. En este momento, el código se puede reescribir como:

int array[500];
int f (unsigned untrusted_index)
{
    
    
  if (untrusted_index < 500)
    return *__builtin_speculation_safe_value (&array[untrusted_index], NULL);
  return 0;
}

Esta situación es en realidad más confusa. Creo que puede describir la segunda posibilidad de ejecución, es decir, usar 0 es un comportamiento inseguro. En este momento, si array[untrusted_index]es un valor predicho, lo colocamos directamente en la cachéNULL

__builtin_expect

Una función simple y grosera, descrita en el manual de la siguiente manera:

  • Puede usar __builtin_expect para proporcionar al compilador información de predicción de rama.

Por supuesto, puede usar esto si está seguro de la frecuencia de acceso de su programa, pero como se ridiculiza en el manual:

ya que los programadores son notoriamente malos para predecir cómo funcionan realmente sus programas.

Pero muchos escenarios siguen siendo muy útiles. Para un ejemplo muy clásico, se requiere el registro gettid. Esta es una llamada al sistema, que obviamente es muy costosa, pero no cambiará durante la ejecución del subproceso. Por lo tanto, el valor de la caché es muy normal y Operación razonable, podemos escribir así, el código proviene de adlserver de adl :

//currentTheread.h
extern __thread int t_cachedTid;
void cacheTid();
inline int tid() {
    
    
  if (__builtin_expect(t_cachedTid == 0, 0)) {
    
    
    cacheTid();
  }
  return t_cachedTid;
}
//currentTheread.cpp
__thread int CurrentThread::t_cachedTid = 0;

void CurrentThread::cacheTid() {
    
    
  if (t_cachedTid == 0) {
    
    
    t_cachedTid = adl::gettid();
  }
}

El código es muy fácil de entender, así que no lo explicaré.

para resumir

¡Deseo que todos recojan más dinero de Nochevieja el día de Año Nuevo! ¡En el nuevo año, el cuerpo estará más saludable y el estado de ánimo será particularmente bueno! ¡Buena suerte todos los días, el sabor es delicioso! El oro está en casa y los billetes son largos en la pared.

referencia:

  1. "¿Qué pasa con la rama if-else en el código?" Además de la mantenibilidad, ¿tiene algún impacto en la eficiencia de la operación del programa?
  2. " Comprensión profunda del modelo de predicción de rama de CPU (Predicción de rama) "
  3. Predictor de rama wiki
  4. Manual de GNU https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html
  5. " Predicción de rama "
  6. " La belleza del patrón de diseño Geek Time "
  7. Trucos para jugar un poco

Supongo que te gusta

Origin blog.csdn.net/weixin_43705457/article/details/113797121
Recomendado
Clasificación