Optimización del rendimiento del programa de CPU

Un programa primero debe garantizar la corrección y, sobre la base de garantizar la corrección, el rendimiento también es una consideración importante. Para escribir programas de alto rendimiento, primero, debe elegir algoritmos y estructuras de datos apropiados; segundo, debe escribir código fuente que el compilador pueda optimizar efectivamente para convertirlo en código ejecutable eficiente. Para hacer esto, necesita comprender las capacidades del compilador y limitaciones; en tercer lugar, debemos entender cómo funciona el hardware y optimizar sus características. Este artículo se centra en el segundo y tercer punto.

Comprender brevemente el compilador.

Para escribir código de alto rendimiento, primero debe tener un conocimiento básico de los compiladores. La razón es que los compiladores modernos tienen fuertes capacidades de optimización, pero algunos compiladores de código no pueden optimizar. Sólo con un conocimiento básico de los compiladores se puede escribir código de alto rendimiento y compatible con compiladores.

Opciones de optimización del compilador

Tomando GCCpor ejemplo , GCC admite los siguientes niveles de optimización:

  • -O<número>, donde el número es 0/1/2/3, cuanto mayor sea el número, mayor será el nivel de optimización. El valor predeterminado es -O0.

  • -Ofast, además de activar todas las opciones de optimización de -O3, también activará -ffast-math y -fallow-store-data-races. Tenga en cuenta que estas dos opciones pueden causar errores de ejecución del programa.

-ffast-math: establece las opciones -fno-math-errno, -funsafe-math-optimizations, -ffinite-math-only, -fno-rounding-math, -fno-signaling-nans, -fcx-limited-range y -fexcess-precision=rápido. Puede dar lugar a resultados incorrectos para programas que dependen de una implementación exacta de reglas/especificaciones IEEE o ISO para funciones matemáticas. Sin embargo, puede generar código más rápido para programas que no requieren las garantías de estas especificaciones.

-fallow-store-data-races: permite que el compilador realice optimizaciones que pueden introducir nuevas carreras de datos en las tiendas, sin demostrar que otros subprocesos no pueden acceder a la variable simultáneamente. No afecta la optimización de los datos locales. Es seguro utilizar esta opción si se sabe que varios subprocesos no accederán a los datos globales.

  • -Og, el nivel de optimización recomendado al depurar código.

gcc -Q --help=optimizer -Ox puede ver las opciones de optimización habilitadas en cada nivel de optimización.

Enlace de referencia: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html

Limitaciones del compilador

Para garantizar la exactitud del funcionamiento del programa, el compilador no hará suposiciones sobre los escenarios de uso del código, por lo que algunos compiladores de código no optimizarán. Aquí hay dos ejemplos más oscuros.

1. alias de memoria

void twiddle1(long *xp, long *yp) {
    *xp += *yp;
    *xp += *yp;
}
void twiddle2(long *xp, long *yp) {
    *xp += 2 * *yp;
}

Cuando xpy ypapuntan a la misma memoria (aliasing de memoria), twiddle1y twiddle2son dos funciones completamente diferentes, por lo que el compilador no intentará twiddle1optimizar en twiddle2. Si la intención original es realizar twiddle2la función de, debe escribirse en twiddle2lugar twwidle1de. twiddle2Solo requiere 2 lecturas y 1 escritura, mientras que twiddle1se requieren 4 lecturas y 2 escrituras.

Puede usar explícitamente para __restrictmodificar el puntero para indicar que no hay ningún puntero que apunte a la misma memoria que el puntero modificado. En este caso, el compilador optimizará twiddle3para que sea equivalente a y twiddle2. Puede observar el código ensamblador durante el desmontaje para una mayor comprensión.

void twiddle3(long *__restrict xp, long *__restrict yp) {
    *xp += *yp;
    *xp += *yp;
}

2 、 efecto secundario

long f();
long func1() {
    return f() + f() + f() + f();
}
long func2() {
    return 4 * f();
}

Dado que fla implementación de la función puede existir de la siguiente manera side effect, el compilador no func1optimizará para func2. Si la intención original es implementar func2la versión, debe escribirse directamente func2en el formulario, lo que puede reducir 3 llamadas a funciones.

long counter = 0;
long f() {
    return counter++;
}

Optimización del rendimiento del programa

Antes de presentarlo, primero presentamos una métrica de rendimiento del programa 每元素的周期数(Cycles Per Element, CPE), que es la cantidad de ciclos necesarios para procesar un elemento, que puede representar el rendimiento del programa y guiar la optimización del rendimiento.

A continuación se utiliza un ejemplo para presentar varios medios para optimizar el rendimiento del programa. Primero defina un vector de estructura de datos y algunas funciones auxiliares. El vector se implementa utilizando una matriz almacenada continuamente y el typedeftipo de datos del elemento se puede especificar a través de data_t.

typedef struct {
    long len;
    data_t *data;
} vec_rec, *vec_ptr;

/* 创建vector */
vec_ptr new_vec(long len) {
    vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
    if (!result)
        return NULL;
    data_t *data = NULL;
    result->len = len;
    if (len > 0) {
        data = (data_t*)calloc(len, sizeof(data_t));
        if (!data) {
            free(result);
            return NULL;
        }
    }
    result->data = data;
    return result;
}

/* 根据index获取vector元素 */
int get_vec_element(vec_ptr v, long index, data_t *dest) {
    if (index < 0 || index >= v->len)
        return 0;
    *dest = v->data[index];
    return 1;
}

/* 获取vector元素个数 */
long vec_length(vec_ptr v) {
    return v->len;
}

La función de la siguiente función es utilizar alguna operación para combinar todos los elementos de un vector en un solo elemento. Las siguientes IDENTson OPdefiniciones de macros #define IDENT 0y #define OP +realizan operaciones acumulativas #define IDENT 1y #define OP *realizan operaciones de multiplicación acumulativas.

void combine1(vec_ptr v, data_t *dest) {
    long i;

    *dest = IDENT;
    for (i = 0; i < vec_length(v); i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OP val;
    }
}

Para lo anterior combine1, se pueden realizar las siguientes tres optimizaciones básicas.

1. Para funciones que se ejecutan varias veces y devuelven el mismo resultado, utilice variables temporales para guardarlas.

combine1La implementación de llama repetidamente a la función en la condición de prueba de bucle vec_length. En este escenario, varias llamadas de vec_lengthdevolverán el mismo resultado, por lo que se puede reescribir como combine2la implementación para optimización. En casos extremos, es más eficaz tener cuidado de evitar llamar a funciones que devuelvan el mismo resultado repetidamente. Por ejemplo, si se llama a una función que prueba la longitud de una cadena en la condición de fin del bucle, la complejidad temporal de la función suele ser 0. O(n)Si está claro que la longitud de la cadena no cambiará, las llamadas repetidas provocarán un muchos gastos generales adicionales.

void combine2(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);

    *dest = IDENT;
    for (i = 0; i < length; i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OP val;
    }
}

2. Reducir las llamadas a procedimientos

Las llamadas a procedimientos (funciones) incurrirán en ciertos gastos generales, como el paso de parámetros, el guardado y restauración de registros de clobber y la transferencia de control. Por lo tanto, puede agregar una función get_vec_startque devuelva un puntero al comienzo de la matriz y evitar llamar a la función en el bucle get_vec_element. Esta optimización tiene una compensación: por un lado, puede mejorar el rendimiento del programa y, por otro lado, esta optimización requiere conocer los detalles de implementación de la estructura de datos vectoriales, lo que destruirá la abstracción del programa. Una vez que el vector se modifica para almacenar datos sin usar matrices, al mismo tiempo es necesario modificar la implementación combine3.

data_t *get_vec_start(vec_ptr v) {
    return v->data;
}
void combine3(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);

    *dest = IDENT;
    for (i = 0; i < length; i++) {
        *dest = *dest OP data[i];
    }
}

3. Eliminar referencias a la memoria innecesarias

En la implementación anterior, cada bucle se leerá una vez y se escribirá una vez dest. Debido a que puede existir memory aliasing, el compilador lo optimizará cuidadosamente. Los siguientes son los códigos ensambladores de la parte del bucle en los niveles de optimización -O1y respectivamente . Se puede ver que cuando la optimización -O2 está activada, el compilador nos ayuda a almacenar los resultados intermedios en variables temporales (registro %xmm0), en lugar de leer desde la memoria cada vez como cuando la optimización -O1 está habilitada; pero considerando la situación, incluso si la optimización -O2 aún necesita guardar los resultados intermedios en la memoria para cada bucle.-O2combine3formemory aliasing

// combine3 -O1
.L1:
    vmovsd (%rbx), %xmm0
    vmulsd (%rdx), %xmm0, %xmm0
    vmovsd %xmm0, (%rbx)
    addq $8, %rdx
    cmpq %rax, %rdx
    jne .L1

// combine3 -O2
.L1
    vmulsd (%rdx), %xmm0, %xmm0
    addq $8, %rdx
    cmpq %rax, %rdx
    vmovsd %xmm0, (%rbx)
    jne .L1

Para evitar lecturas y escrituras frecuentes en la memoria, puede utilizar artificialmente una variable temporal para guardar los resultados intermedios, como combine4se muestra.

void combine4(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    for (i = 0; i < length; i++) {
        acc = acc OP data[i];
    }
    *dest = acc;
}
// combine4 -O1
.L1
    vmulsd (%rdx), %xmm0, %xmm0
    addq $8, %rdx
    cmpq %rax, %rdx
    jne .L1

El efecto del método de optimización anterior se puede medir mediante CPE. Los resultados de la prueba en Intel Core i7 Haswell son los siguientes. A juzgar por los resultados de la prueba:

  • La versión combine1 tiene diferentes niveles de optimización de compilación. El rendimiento de -O1 es el doble que el de -O0, lo que indica que es necesario activar el nivel de optimización de compilación apropiado.

  • Después de que combine2 saca vec_length del bucle y se compila al mismo nivel de optimización, el rendimiento mejora ligeramente en comparación con combine1.

  • Sin embargo, combine3 no tiene ninguna mejora en el rendimiento en comparación con combine2. La razón es que el tiempo que consumen otras operaciones en el bucle puede cubrir el tiempo que lleva llamar a get_vec_element. La razón por la que puede ocultarse se debe al soporte de la CPU y Estos dos conceptos se presentarán brevemente más adelante en este 分支预测artículo .乱序执行

  • De manera similar, la versión -O2 de combine3 tiene un rendimiento mucho mejor que la versión -O1. Como se puede ver en el código ensamblador, -O2 reduce la lectura de (%rbx) una vez por ciclo en comparación con -O1. Más importante aún, elimina la necesidad de (%rbx) dependencia de acceso a la memoria de lectura tras escritura.

  • Después de la optimización de combine4 para almacenar temporalmente los resultados intermedios en variables temporales, se puede ver que incluso si se usa la optimización de compilación de -O1, el rendimiento de optimización de la compilación es mejor que el de combine3 -O2, lo que indica que incluso si el compilador tiene poderosas capacidades de optimización, debe prestar atención a los detalles y escribir código de alto rendimiento también es muy necesario.

Los siguientes datos de prueba se citan del Capítulo 5 de "Comprensión profunda de los sistemas informáticos".

función Mejoramiento entero + En t * flotar + flotar *
combinar1 -O0 22,68 20.02 19,98 20.18
combinar1 -O1 10.12 10.12 10.17 11.14
combinar2 mover vec_length -O1 7.02 9.03 9.02 11.03
combinar3 Reducir llamadas a procedimientos -O1 7.17 9.02 9.02 11.03
combinar3 Reducir llamadas a procedimientos -O2 1,60 3.01 3.01 5.01
combinar4 Acumular a la variable temporal -O1 1.27 3.01 3.01 5.01

Paralelismo a nivel de instrucción

La optimización anterior no depende de ninguna característica de la máquina de destino, simplemente reduce la sobrecarga de las llamadas a procedimientos y elimina algunos "factores que obstaculizan la optimización" que dificultan la optimización del compilador. Para una mayor optimización, es necesario comprender algunas características del hardware. La siguiente figura es la parte back-end de la estructura de hardware de Intel Core i7 Haswell:haswell.png

Para conocer la estructura de hardware completa de Intel Core i7 Haswell, consulte: https://en.wikichip.org/w/images/c/c7/haswell_block_diagram.svg

Rendimiento del equipo

Esta CPU admite las siguientes características:

  • Paralelismo a nivel de instrucción: es decir, a través de la tecnología de canalización de instrucciones, admite la evaluación de múltiples instrucciones al mismo tiempo.

  • Ejecución fuera de orden: el orden de ejecución de las instrucciones puede no ser coherente con el orden en que están escritas, lo que puede permitir que el hardware logre un mejor paralelismo a nivel de instrucciones. Principalmente a través del mecanismo de ejecución desordenada y envío secuencial, es posible obtener resultados consistentes con la ejecución secuencial.

  • Predicción de bifurcación: cuando se encuentra una bifurcación, el hardware predecirá la dirección de la bifurcación. Si la predicción tiene éxito, puede acelerar la ejecución del programa. Sin embargo, si la predicción falla, los resultados de la ejecución temprana deben ser descartado y recargado para ejecutar la instrucción correcta, lo que provocará consecuencias relativamente grandes penalización por error de predicción.

En la figura anterior, la atención se centra principalmente en las unidades de ejecución (UE), que se componen de múltiples unidades funcionales. El desempeño de una unidad funcional se puede medir mediante 延迟, 发射时间y 容量.

  • Latencia: el número de ciclos de reloj necesarios para completar una instrucción.

  • Tiempo de lanzamiento: el número mínimo de ciclos de reloj necesarios entre dos operaciones consecutivas del mismo tipo.

  • Capacidad: el número de unidades de ejecución de un determinado tipo. Como se puede ver en la figura anterior, EUshay 4 unidades de suma de enteros (INT ALU), 1 unidad de multiplicación de enteros (INT MUL), 1 unidad de suma de punto flotante (FP ADD) y 2 unidades de multiplicación de punto flotante (FP MUL).

Los datos de rendimiento de la unidad funcional (las unidades son ciclos) de Intel Core i7 Haswell son los siguientes, citados del Capítulo 5 de "Comprensión profunda de los sistemas informáticos":

Operación retraso (int) Hora de lanzamiento (int) Capacidad (int) Retraso (flotación) Tiempo de emisión (flotación) Capacidad (flotación)
suma 1 1 4 3 1 1
multiplicación 3 1 1 5 1 2

La latencia, el tiempo de lanzamiento y la capacidad de estas operaciones aritméticas afectarán combineel rendimiento de las funciones anteriores, y utilizamos dos límites en CPE para describir este efecto. El límite de rendimiento es el rendimiento óptimo teórico.

  • Límite de latencia: el CPE mínimo requerido por cualquier función que deba completar operaciones en estricto orden , igual al retraso de la unidad funcional.combine

  • Límite de rendimiento: la velocidad máxima a la que una unidad funcional puede producir resultados, 容量/发射时间determinada por. Si se utiliza la métrica CPE, es igual al 容量/发射时间recíproco de .

Dado que combinela función necesita cargar datos, también está sujeta a la limitación de la unidad de carga. Dado que sólo hay dos unidades de carga y su tiempo de emisión es de 1 ciclo, el rendimiento límite para la suma de enteros es en este caso sólo 0,5 en lugar de 0,25.

límite entero + En t * flotar + flotar *
Demora 1.0 3.0 3.0 5.0
Vacilación 0,5 1.0 1.0 0,5

Modelo abstracto de funcionamiento del procesador.

Para analizar el rendimiento de los programas a nivel de máquina que se ejecutan en procesadores modernos, presentamos 数据流图una representación gráfica de cómo las dependencias de datos entre diferentes operaciones limitan su orden de ejecución. Estos límites forman la figura 关键路径, que es un límite inferior de los ciclos de reloj necesarios para ejecutar un conjunto de instrucciones de máquina.

Por lo general, el bucle for ocupa la mayor parte del tiempo de ejecución del programa. La siguiente figura es el combine4diagrama de flujo de datos correspondiente al bucle for. Las flechas indican el flujo de datos. Los registros se pueden dividir en cuatro categorías:

  1. Solo lectura: Estos registros se usan solo como valores fuente y no se modifican durante el ciclo, en este caso %rax.

  2. Sólo escritura: A efectos de transferencia de datos. Este ejemplo no tiene tal registro.

  3. Local: Modificado y usado dentro del bucle, no relacionado entre iteraciones, el código de condición se registra en proporción.

  4. Bucles : Estos registros sirven como valores tanto de origen como de destino. Los valores producidos en una iteración se utilizan en la siguiente iteración, en este caso %rdxy %xmm0. Las operaciones en dichos registros son a menudo el factor limitante en el rendimiento del programa debido a las dependencias de datos entre iteraciones .flujo_datos1.png

Se puede obtener un diagrama de flujo de datos simplificado reorganizando el diagrama anterior y dejando solo las rutas relacionadas con el registro de bucle.flujo_de_datos_simplificar.png

Simplemente repita el diagrama de flujo de datos simplificado para obtener la ruta crítica, como se muestra a continuación. Si combine4el cálculo es una multiplicación de punto flotante, debido al soporte para el paralelismo a nivel de instrucción, el retraso de la multiplicación de punto flotante puede cubrir el retraso de la suma de enteros (movimiento del puntero, la ruta en la mitad derecha de la figura), por lo que el límite inferior teórico de CPE es el retraso de la multiplicación de punto flotante combine4.5.0, que es básicamente consistente con los datos de prueba 5.01 proporcionados anteriormente.flujo_de_datos_crítico.png

bucle desenrollado

Hasta ahora, el rendimiento de nuestro programa solo ha alcanzado el límite de latencia, esto se debe a que la siguiente multiplicación de punto flotante debe esperar hasta que se complete la última multiplicación y no puede utilizar completamente el paralelismo a nivel de instrucción del hardware. Utilizando la tecnología de desenrollado de bucles, se puede mejorar el paralelismo de las instrucciones de la ruta crítica.

void combine5(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length - 1;
    data_t *data = get_vec_start(v);
    data_t acc0 = IDENT;
    data_t acc1 = IDENT;

    for (i = 0; i < limit; i += 2) {
        acc0 = acc0 OP data[i];
        acc1 = acc1 OP data[i + 1];
    }

    for (; i < length; ++i) {
        acc0 = acc0 OP data[i];
    }

    *dest = acc0 OP acc1;
}

combine5El diagrama de flujo de datos de la ruta crítica es el siguiente. Hay dos rutas críticas en el diagrama, pero las dos rutas críticas se pueden paralelizar en el nivel de instrucción. Cada ruta crítica solo contiene 1 operación, por lo que el rendimiento puede superar el retraso. límite En teoría, n/2el CPE de multiplicación de punto flotante es de aprox 5.0/2=2.5.flujo_de_datos_crítico2.png

Si se aumenta el número de variables temporales y el número de desenrollamientos del bucle se aumenta aún más, en teoría se puede aumentar el grado de paralelismo de las instrucciones y eventualmente se puede alcanzar el límite de rendimiento. Sin embargo, el número de desenrollamientos de bucle no se puede aumentar sin límite. Primero, debido a las unidades funcionales limitadas del hardware, el límite inferior de CPE está limitado por el límite de rendimiento. Después de alcanzar un cierto nivel, continuar aumentando no puede mejorar el grado. del paralelismo de instrucciones; en segundo lugar, debido a los recursos de registro limitados, el aumento del desenrollado del bucle aumentará el uso de registros. Después de que el número de registros utilizados exceda los recursos de registros proporcionados por el hardware, se producirá un desbordamiento de registros. Puede ser necesario guardar temporalmente la memoria del registro en la memoria y luego restaurarla desde la memoria al registro cuando se use, lo que provocará un rendimiento deficiente. Como se muestra en la siguiente tabla, el rendimiento del desenrollado del bucle 20 veces es ligeramente menor que el del desenrollado 10 veces. Afortunadamente, la mayoría del hardware alcanza su límite de rendimiento antes de que se produzca un desbordamiento de registros.

función Tiempos de expansión entero + En t * flotar + flotar *
combinar5 2 0,81 1.51 1.51 2.51
combinar5 10 0,55 1.00 1.01 0,52
combinar5 20 0,83 1.03 1.02 0,68
retraso limitado / 1.00 3.00 3.00 5.00
límite de rendimiento / 0,50 1.00 1.00 0,50

SIMD (datos múltiples de instrucción única)

SIMDEs otro método eficaz de optimización del rendimiento, que es diferente del paralelismo a nivel de instrucción 数据级并行. SIMD significa Datos múltiples de instrucción única. Una instrucción opera un lote de datos vectoriales y requiere soporte de hardware. La CPU de arquitectura X86 admite el conjunto de instrucciones AVX y la CPU ARM admite el conjunto de instrucciones NEON. En MegCC, un compilador de aprendizaje profundo que desarrollamos, la tecnología SIMD se utiliza ampliamente. MegCC es un compilador de aprendizaje profundo desarrollado por el equipo de Megvii Tianyuan. Acepta un modelo en formato MegEngine como entrada y genera todos los núcleos necesarios para ejecutar el modelo, lo que facilita su implementación. Es de alto rendimiento y liviano. Para facilitar a los usuarios la conversión de modelos en otros formatos a modelos en formato MegEngine, el equipo de Megvii Tianyuan también proporciona la herramienta de conversión de modelos MgeConvert, que puede convertir el modelo onnxy luego utilizar MgeConvert para convertirlo a un modelo en formato MegEngine. Al mismo tiempo, si desea probar el rendimiento y la latencia de una determinada instrucción en su dispositivo para guiar su optimización, puede utilizar MegPeak .

En MegCC se implementan muchos operadores de aprendizaje profundo de alto rendimiento. La convolución y la multiplicación de matrices son operadores típicos con uso intensivo de computación. Al mismo tiempo, la convolución también se puede implementar con la ayuda de la multiplicación de matrices (algoritmo im2col/winograd, etc.).

MegCC admite la multiplicación y convolución de matrices implementadas por instrucciones NEON DOT e I8MM en la plataforma ARM. Una DOTinstrucción puede completar 32 operaciones de multiplicación y suma (16 multiplicaciones y 16 sumas); una I8MMinstrucción puede completar 64 operaciones de multiplicación y suma (32 multiplicaciones y 32 sumas). Así es como la tecnología SIMD puede acelerar la informática.

Referencias

  1. Randal E. Bryant, David R. O'Hallaron. Sistemas informáticos: la perspectiva de un programador, Capítulo 5.

  2. Antonio González, Fernando Latorre, Grigorios Magklis. Microarquitectura del procesador: una perspectiva de implementación, Capítulo 1.

  3. https://github.com/MegEngine/MegCC

Alibaba Cloud sufrió un grave fallo que afectó a todos los productos (ha sido restaurado). El sistema operativo ruso Aurora OS 5.0, una nueva interfaz de usuario, se presentó en Tumblr. Muchas empresas de Internet reclutaron urgentemente programadores de Hongmeng . .NET 8 es oficialmente GA, el último Versión LTS Tiempo UNIX A punto de ingresar a la era de los 1.7 mil millones (ya ingresó), Xiaomi anunció oficialmente que Xiaomi Vela es completamente de código abierto y el kernel subyacente es .NET 8 en NuttX Linux. El tamaño independiente se reduce en un 50%. FFmpeg 6.1 " Se lanza Heaviside". Microsoft lanza una nueva "aplicación para Windows"
{{o.nombre}}
{{m.nombre}}

Supongo que te gusta

Origin my.oschina.net/u/5265910/blog/10143748
Recomendado
Clasificación