paint-brush
Optimizaciones del compilador: ¡aumento del rendimiento del código con ajustes mínimos!by@durganshu
1,253
1,253

Optimizaciones del compilador: ¡aumento del rendimiento del código con ajustes mínimos!

Durganshu Mishra13m2023/11/30
Read on Terminal Reader

Los desarrolladores que buscan optimizar el rendimiento de su código C++ deberían descubrir optimizaciones del compilador: un conjunto de indicadores de C++ muy efectivos que mejoran el rendimiento del código sin mucho esfuerzo. El único requisito previo es que sepas lo que estás haciendo. Explore indicadores adecuados para compiladores Intel C++ como -fno-alias, -xHost, -xCORE-AVX512, IPO, etc., con un enfrentamiento práctico sobre un código C++ de iteración de Jacobi.
featured image - Optimizaciones del compilador: ¡aumento del rendimiento del código con ajustes mínimos!
Durganshu Mishra HackerNoon profile picture
0-item
1-item


Desbloquear el máximo rendimiento de su código C++ puede ser desalentador y exige una creación de perfiles meticulosa, ajustes complejos de acceso a la memoria y optimización de la caché. ¿Existe algún truco para simplificar esto un poco? Afortunadamente, existe un atajo para lograr mejoras notables en el rendimiento con un mínimo esfuerzo, siempre que tenga la información adecuada y sepa lo que está haciendo. Ingrese optimizaciones del compilador que pueden elevar significativamente el rendimiento de su código.


Los compiladores modernos sirven como aliados indispensables en este viaje hacia un rendimiento óptimo, particularmente en la paralelización automática. Estas sofisticadas herramientas poseen la habilidad de examinar patrones de código complejos, especialmente dentro de bucles, y ejecutar optimizaciones sin problemas.


Este artículo tiene como objetivo destacar la potencia de las optimizaciones de compiladores, centrándose en los compiladores Intel C++ , conocidos por su popularidad y uso generalizado.


En esta historia, desentrañamos las capas de magia del compilador que pueden transformar su código en una obra maestra de alto rendimiento, que requiere menos intervención manual de lo que podría pensar.


Aspectos destacados: ¿Qué son las optimizaciones del compilador? | -En | Arquitectura dirigida | Optimización interprocedimental | -fno-aliasing | Informes de optimización del compilador

¿Qué son las optimizaciones del compilador?

Las optimizaciones del compilador abarcan varias técnicas y transformaciones que un compilador aplica al código fuente durante la compilación. ¿Pero por qué? Para mejorar el rendimiento, la eficiencia y, en algunos casos, el tamaño del código de máquina resultante. Estas optimizaciones son fundamentales para influir en varios aspectos de la ejecución del código, incluida la velocidad, el uso de la memoria y el consumo de energía.


Cualquier compilador ejecuta una serie de pasos para convertir el código fuente de alto nivel al código de máquina de bajo nivel. Estos implican análisis léxico, análisis de sintaxis, análisis semántico, generación de código intermedio (o IR), optimización y generación de código.


Durante la fase de optimización, el compilador busca meticulosamente formas de transformar un programa, apuntando a una salida semánticamente equivalente que utilice menos recursos o se ejecute más rápidamente. Las técnicas empleadas en este proceso abarcan, entre otras , el plegado constante, la optimización de bucles, la inserción de funciones y la eliminación de códigos muertos .


No voy a discutir todas las opciones disponibles, sino cómo podemos indicarle al compilador que realice una optimización específica que pueda mejorar el rendimiento del código. Entonces la solución???? Banderas del compilador.

Los desarrolladores pueden especificar un conjunto de indicadores del compilador durante el proceso de compilación, una práctica familiar para quienes usan opciones como " -g" o "-pg" con GCC para depurar y crear perfiles. A medida que avancemos, analizaremos indicadores de compilador similares que podemos usar al compilar nuestra aplicación con el compilador Intel C++. Estos podrían ayudarle a mejorar la eficiencia y el rendimiento de su código.


Go Kick Off GIF de CAF



Entonces, ¿con qué estamos trabajando?

No profundizaré en la teoría seca ni lo inundaré con documentación tediosa que enumera cada indicador del compilador. En lugar de ello, intentemos comprender por qué y cómo funcionan estas banderas.


¿¿¿Cómo logramos esto???


Tomaremos una función de C++ no optimizada responsable de calcular una iteración de Jacobi y, paso a paso, desentrañaremos el impacto de cada indicador del compilador. A lo largo de esta exploración, mediremos la aceleración comparando sistemáticamente cada iteración con la versión base, comenzando sin indicadores de optimización (-O0).


Las aceleraciones (o tiempo de ejecución) se midieron en una máquina con procesador Intel® Xeon® Platinum 8174 . Aquí, el método de Jacobi resuelve una ecuación diferencial parcial 2D (ecuación de Poisson) para modelar la distribución de calor en una rejilla rectangular.


El método Jacobi


u(x,y,t) es la temperatura en el punto (x,y) en el tiempo t.


Resolvemos el estado estable cuando la distribución ya no cambia:

Resolviendo el estado estable


En la frontera se ha aplicado un conjunto de condiciones de frontera de Dirichlet.


Básicamente tenemos una codificación C++ que realiza las iteraciones de Jacobi en cuadrículas de tamaños variables (que llamamos resoluciones). Básicamente, un tamaño de cuadrícula de 500 significa resolver una matriz de tamaño 500x500, y así sucesivamente.


La función para realizar una iteración de Jacobi es la siguiente:


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


Seguimos realizando la iteración de Jacobi hasta que el residual alcanza un valor umbral (dentro de un bucle). El cálculo residual y la evaluación del umbral se realizan fuera de esta función y no son de interés aquí. Entonces, ¡hablemos ahora del elefante en la habitación!

¿Cómo funciona el código base?

Sin optimizaciones (-O0), obtenemos los siguientes resultados:


Tiempo de ejecución en segundos y MFLOP/s para el caso base (“-O0”)


Aquí, medimos el rendimiento en términos de MFLOP/s. Esta será la base de nuestra comparación.


MFLOP/s significa "Millones de operaciones de coma flotante por segundo". Es una unidad de medida utilizada para cuantificar el rendimiento de una computadora o procesador en términos de operaciones de punto flotante. Las operaciones de punto flotante implican cálculos matemáticos con números decimales o reales representados en formato de punto flotante.


MFLOP/s se utiliza a menudo como punto de referencia o métrica de rendimiento, especialmente en aplicaciones científicas y de ingeniería donde prevalecen los cálculos matemáticos complejos. Cuanto mayor sea el valor de MFLOP/s, más rápido el sistema o procesador realiza operaciones de punto flotante.


Nota 1: Para proporcionar un resultado estable, ejecuto el ejecutable 5 veces para cada resolución y tomo el valor promedio de los valores MFLOP/s.

Nota 2: Es importante tener en cuenta que la optimización predeterminada en el compilador Intel C++ es -O2. Por lo tanto, es importante especificar -O0 al compilar el código fuente.


¡Sigamos adelante y veamos cómo estos tiempos de ejecución variarán a medida que probamos diferentes indicadores del compilador!

Los más comunes: -O1, -O2, -O3 y -Ofast

Estos son algunos de los indicadores del compilador más utilizados cuando se comienza con las optimizaciones del compilador. En un caso ideal, el rendimiento de Ofast > O3 > O2 > O1 > O0 . Sin embargo, esto no necesariamente sucede. Los puntos críticos de estas opciones son los siguientes:


-O1:

  • Objetivo: optimizar la velocidad y evitar el aumento del tamaño del código.
  • Características clave: Adecuado para aplicaciones con códigos de gran tamaño, muchas ramas y donde el tiempo de ejecución no está dominado por el código dentro de los bucles.

-O2:

  • Mejoras sobre -O1:
    • Permite la vectorización.
    • Permite la incorporación de intrínsecos y la optimización interprocedimiento dentro del archivo.

-O3:

  • Mejoras sobre -O2:
    • Permite transformaciones de bucle más agresivas (Fusion, Block-Unroll-and-Jam).
    • Es posible que las optimizaciones solo superen consistentemente a -O2 si se producen transformaciones de acceso a la memoria y al bucle. Incluso puede ralentizar el código.
  • Recomendado para:
    • Aplicaciones con cálculos de punto flotante con muchos bucles y grandes conjuntos de datos.

-Orápido:

  • Establece las siguientes banderas:
    • “-O3”
    • “- no-prec-div” : permite optimizaciones que brindan resultados rápidos y ligeramente menos precisos que la división IEEE completa. Por ejemplo, A/B se calcula como A * (1/B) para mejorar la velocidad de cálculo.
    • -fp-model fast=2" : permite optimizaciones de punto flotante más agresivas.


La guía oficial habla en detalle sobre exactamente qué optimizaciones ofrecen estas opciones.


Al usar estas opciones en nuestro código Jacobi, obtenemos estos tiempos de ejecución:

Comparación de banderas -On

Es claramente evidente que todas estas optimizaciones son mucho más rápidas que nuestro código base (con “-O0”). El tiempo de ejecución es entre 2 y 3 veces menor que el del caso base. ¿Qué pasa con los MFLOP/s?


Comparación de banderas -On


Bueno eso es algo!!!


Hay una gran diferencia entre los MFLOP/s del caso base y aquellos con optimización.


En general, aunque sólo ligeramente, “-O3” tiene el mejor rendimiento.


Los indicadores adicionales utilizados por "- Ofast " (" -no-prec-div -fp-model fast=2 ") no proporcionan ninguna aceleración adicional.

Arquitectura de destino (-xHost,-xCORE-AVX512)

La arquitectura de la máquina se destaca como un factor fundamental que influye en las optimizaciones del compilador. Puede mejorar significativamente el rendimiento cuando el compilador conoce los conjuntos de instrucciones disponibles y las optimizaciones admitidas por el hardware (como la vectorización y SIMD).


Por ejemplo, mi máquina Skylake tiene 3 unidades SIMD: 1 AVX 512 y 2 unidades AVX-2.


¿Realmente puedo hacer algo con este conocimiento???


La respuesta está en las banderas estratégicas del compilador. Experimentar con opciones como “ -xHost ” y, más precisamente, “ -xCORE-AVX512 ” puede permitirnos aprovechar todo el potencial de las capacidades de la máquina y adaptar las optimizaciones para un rendimiento óptimo.


Aquí hay una descripción rápida de de qué se tratan estas banderas:


-xHost:

  • Objetivo: Especifica que el compilador debe generar código optimizado para el conjunto de instrucciones más alto de la máquina host.
  • Funciones clave: aprovecha las últimas funciones y capacidades disponibles en el hardware. Puede dar una aceleración asombrosa en el sistema de destino.
  • Consideraciones: si bien este indicador optimiza la arquitectura del host, puede generar archivos binarios que no sean portátiles entre diferentes máquinas con diferentes arquitecturas de conjuntos de instrucciones.

-xCORE-AVX512:

  • Objetivo: indicar explícitamente al compilador que genere código que utilice el conjunto de instrucciones Intel Advanced Vector Extensions 512 (AVX-512).

  • Características clave: AVX-512 es un conjunto de instrucciones SIMD (instrucción única, datos múltiples) avanzado que ofrece registros vectoriales más amplios y operaciones adicionales en comparación con versiones anteriores como AVX2. Habilitar este indicador permite al compilador aprovechar estas funciones avanzadas para optimizar el rendimiento.

  • Consideraciones: la portabilidad vuelve a ser la culpable aquí. Es posible que los archivos binarios generados con instrucciones AVX-512 no se ejecuten de manera óptima en procesadores que no admitan este conjunto de instrucciones. ¡Puede que no funcionen en absoluto!


Las instrucciones del conjunto AVX-512 utilizan registros Zmm, que son un conjunto de registros de 512 bits de ancho. Estos registros sirven como base para el procesamiento de vectores.


De forma predeterminada, “ -xCORE-AVX512 ” supone que es poco probable que el programa se beneficie del uso de registros zmm. El compilador evita el uso de registros zmm a menos que se garantice una ganancia de rendimiento.


Si se planea utilizar los registros zmm sin restricciones, " -qopt-zmm-usage " se puede configurar en alto. Eso es lo que haremos también.


No olvide consultar la guía oficial para obtener instrucciones detalladas.


Veamos cómo funcionan estas banderas para nuestro código:

Efectos de -xHost y -xCORE-AVX512

¡Guau!


Ahora cruzamos la marca de 1200 MFLOP/s para la resolución más pequeña. Los valores MFLOP/s para otras resoluciones también han aumentado.


Lo notable es que logramos estos resultados sin ninguna intervención manual sustancial, simplemente incorporando un puñado de indicadores del compilador durante el proceso de compilación de la aplicación.


Sin embargo, es fundamental resaltar que el ejecutable compilado sólo será compatible con una máquina que utilice el mismo conjunto de instrucciones.


La compensación entre optimización y portabilidad es evidente, ya que el código optimizado para un conjunto de instrucciones particular puede sacrificar la portabilidad entre diferentes configuraciones de hardware. ¡Así que asegúrate de saber lo que estás haciendo!


Nota: No se preocupe si su hardware no es compatible con AVX-512. El compilador Intel C++ admite optimizaciones para AVX, AVX-2 e incluso SSE. ¡La documentación tiene todo lo que necesitas saber!

Optimización interprocedimental (IPO)

La optimización interprocedural implica analizar y transformar código en múltiples funciones o procedimientos, mirando más allá del alcance de las funciones individuales.


IPO es un proceso de varios pasos que se centra en las interacciones entre diferentes funciones o procedimientos dentro de un programa. La IPO puede incluir muchos tipos diferentes de optimizaciones, incluida la sustitución directa, la conversión de llamadas indirectas y la inserción.


Intel Compiler admite dos tipos comunes de IPO: compilación de un solo archivo y compilación de varios archivos (optimización completa del programa) [ 3 ]. Hay dos indicadores de compilador comunes que realizan cada uno de ellos:


-ipó:

  • Objetivo: permite la optimización entre procedimientos, lo que permite al compilador analizar y optimizar todo el programa, más allá de los archivos fuente individuales, durante la compilación.

  • Características clave: - Optimización de todo el programa: " -ipo " realiza análisis y optimización en todos los archivos fuente, considerando las interacciones entre funciones y procedimientos en todo el programa. - Optimización entre funciones y módulos: la bandera facilita la inserción de funciones y la sincronización de optimizaciones y análisis de flujo de datos en diferentes partes del programa.

  • Consideraciones: Requiere un paso de enlace independiente. Después de compilar con " -ipo ", se necesita un paso de enlace particular para generar el ejecutable final. El compilador realiza optimizaciones adicionales basadas en la vista completa del programa durante la vinculación.


-IP:

  • Objetivo: permite la propagación y el análisis entre procedimientos, lo que permite al compilador realizar algunas optimizaciones entre procedimientos sin requerir un paso de enlace por separado.

  • Características clave: - Análisis y propagación: " -ip " permite al compilador realizar investigaciones y propagación de datos a través de diferentes funciones y módulos durante la compilación. Sin embargo, no realiza todas las optimizaciones que requieren la vista completa del programa. - Compilación más rápida: a diferencia de " -ipo ", " -ip " no necesita un paso de enlace separado, lo que resulta en tiempos de compilación más rápidos. Esto puede resultar beneficioso durante el desarrollo cuando la retroalimentación rápida es esencial.

  • Consideraciones: solo se producen algunas optimizaciones interprocedimientos limitadas, incluida la incorporación de funciones.


-ipo generalmente proporciona capacidades de optimización interprocedimientos más amplias, ya que implica un paso de enlace separado, pero tiene el costo de tiempos de compilación más largos. [ 4 ]

-ip es una alternativa más rápida que realiza algunas optimizaciones entre procedimientos sin requerir un paso de enlace separado, lo que la hace adecuada para las fases de desarrollo y prueba.[ 5 ]


Dado que solo estamos hablando de rendimiento y diferentes optimizaciones, los tiempos de compilación o el tamaño del ejecutable no son de nuestra incumbencia, nos centraremos en " -ipo ".

Efecto de -ipo

-fno-alias

Todas las optimizaciones anteriores dependen de qué tan bien conozca su hardware y cuánto experimentaría. Pero eso no es todo. Si intentamos identificar cómo el compilador vería nuestro código, podemos identificar otras optimizaciones potenciales.


Echemos un vistazo nuevamente a nuestro código:


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


La función jacobi() toma un par de punteros para duplicarlos como parámetros y luego hace algo dentro de los bucles for anidados. Cuando cualquier compilador ve esta función en el archivo fuente, debe tener mucho cuidado.


¿¿Por qué??


La expresión para calcular unew usando u implica el promedio de 4 valores de u vecinos. ¿Qué pasa si tanto u como unnew apuntan al mismo lugar? Este se convertiría en el clásico problema de los punteros con alias [ 7 ].


Los compiladores modernos son muy inteligentes y, para garantizar la seguridad, suponen que el alias podría ser posible. Y para escenarios como este, evitan cualquier optimización que pueda afectar la semántica y la salida del código.


En nuestro caso, sabemos que u y unew son ubicaciones de memoria diferentes y están destinadas a almacenar valores diferentes. Por lo tanto, podemos informarle fácilmente al compilador que no habrá ningún alias aquí.


¿Como hacemos eso?


Hay dos métodos. La primera es la palabra clave C “ restringir . Pero requiere cambiar el código. No queremos eso por ahora.


¿Algo sencillo? Probemos con " -fno-alias ".


-fno-alias:

  • Objetivo: indicar al compilador que no asuma alias en el programa.

  • Características clave: Suponiendo que no haya alias, el compilador puede optimizar más libremente el código, mejorando potencialmente el rendimiento.

  • Consideraciones: el desarrollador debe tener cuidado al utilizar este indicador, ya que en caso de cualquier alias injustificado, el programa puede generar resultados inesperados.


Se pueden encontrar más detalles en la documentación oficial .


¿Cómo funciona esto para nuestro código?

Efecto de -fno-alias

Pues ya tenemos algo!!!


Hemos logrado una aceleración notable aquí, casi 3 veces más que las optimizaciones anteriores. ¿Cuál es el secreto detrás de este impulso?


Al indicarle al compilador que no asuma alias, le hemos dado la libertad de liberar potentes optimizaciones de bucle.


Un examen más detallado del código ensamblador (aunque no se comparte aquí) y el informe de optimización de compilación generado (ver más abajo ) revela la inteligente aplicación del compilador de intercambio y desenrollado de bucles . Estas transformaciones contribuyen a un rendimiento altamente optimizado, lo que muestra el impacto significativo de las directivas del compilador en la eficiencia del código.

Gráficos finales

Así es como funcionan todas las optimizaciones entre sí:


Comparación de todos los indicadores de optimización.

Informe de optimización del compilador (-qopt-report)

El compilador Intel C++ proporciona una característica valiosa que permite a los usuarios generar un informe de optimización que resume todos los ajustes realizados con fines de optimización [ 8 ]. Este informe completo se guarda en formato de archivo YAML y presenta una lista detallada de las optimizaciones aplicadas por el compilador dentro del código. Para obtener una descripción detallada, consulte la documentación oficial en " -qopt-report ".

¿Qué sigue?

Discutimos un puñado de indicadores del compilador que pueden mejorar drásticamente el rendimiento de nuestro código sin que tengamos que hacer mucho. El único requisito previo: no hacer nada a ciegas; ¡¡Asegúrate de saber lo que estás haciendo!!


Hay cientos de indicadores de compilador de este tipo y esta historia habla de unos pocos. Por lo tanto, vale la pena consultar la guía oficial del compilador de su preferencia (especialmente la documentación relacionada con la optimización).


Además de estos indicadores del compilador, existen una gran cantidad de técnicas como vectorización, intrínsecos SIMD, optimización guiada por perfiles y paralelismo automático guiado , que pueden mejorar sorprendentemente el rendimiento de su código.


De manera similar, los compiladores Intel C++ (y todos los populares) también admiten directivas pragma, que son características muy interesantes. Vale la pena consultar algunos de los pragmas como ivdep, paralelo, simd, vector, etc., en Intel-Specific Pragma Reference .


Eso es todo amigos en Giphy

Lecturas sugeridas

[1] Optimización y programación (intel.com)

[2] Computación de alto rendimiento con “Elwetritsch” en la Universidad de Kaiserslautern-Landau (rptu.de)

[3] Optimización interprocedimiento (intel.com)

[4] ipo, Qipo (intel.com)

[5] ip, Qip (intel.com)

[6] Compilador Intel, optimización y otros indicadores para uso de SPEChpc

[7] Alias: documentación de IBM

[8] Informes de optimización del compilador Intel®


Foto destacada de Igor Omilaev en Unsplash .


También publicado aquí .