Articulo de referencia

Compilador optimizador

Un compilador optimizador es un compilador diseñado para generar código optimizado en aspectos como la minimización del tiempo de ejecución del programa , el uso de memoria , el...

Un compilador optimizador es un compilador diseñado para generar código optimizado en aspectos como la minimización del tiempo de ejecución del programa , el uso de memoria , el tamaño de almacenamiento y el consumo de energía . [ 1 ] La optimización generalmente se implementa como una secuencia de transformaciones optimizadoras , también conocidas como optimizaciones del compilador : algoritmos que transforman el código para producir código semánticamente equivalente optimizado para algún aspecto.

La optimización está limitada por varios factores. El análisis teórico indica que algunos problemas de optimización son NP-completos , o incluso indecidibles . [ 2 ] Además, no es posible producir código perfectamente óptimo , ya que optimizar un aspecto suele degradar el rendimiento de otro (véase: superoptimización ). La optimización es un conjunto de métodos heurísticos para mejorar el uso de recursos en programas típicos. [ 3 ] : 585

Categorización

Alcance local frente a alcance global

El alcance describe qué parte del código de entrada se considera para aplicar las optimizaciones.

Las optimizaciones de ámbito local utilizan información local a un bloque básico . [ 4 ] Dado que los bloques básicos no contienen instrucciones de flujo de control, estas optimizaciones requieren un análisis mínimo, lo que reduce el tiempo y los requisitos de almacenamiento. Sin embargo, no se conserva información entre saltos.

Las optimizaciones de alcance global, también conocidas como optimizaciones intraprocedimentales, operan sobre funciones individuales. [ 4 ] Esto les proporciona más información para trabajar, pero a menudo requiere cálculos costosos. Es necesario asumir el peor escenario posible cuando se realizan llamadas a funciones o se accede a variables globales, ya que se dispone de poca información sobre ellas.

Optimización de mirillas

Las optimizaciones de tipo "peephole" se realizan generalmente al final del proceso de compilación, después de que se haya generado el código máquina . Esta optimización examina algunas instrucciones adyacentes (similar a "mirar a través de un agujero" el código) para ver si se pueden reemplazar por una sola instrucción o una secuencia más corta de instrucciones. [ 3 ] : 554 Por ejemplo, una multiplicación de un valor por dos podría ejecutarse de manera más eficiente desplazando el valor a la izquierda o sumándolo a sí mismo (este ejemplo también es una instancia de reducción de fuerza ).

Optimización interprocedimental

Las optimizaciones interprocedimentales analizan todo el código fuente de un programa. Cuanta más información haya disponible, más efectivas serán las optimizaciones. Esta información se puede utilizar para diversas optimizaciones, como la inserción de funciones en línea , donde una llamada a una función se reemplaza por una copia de su cuerpo.

La optimización en tiempo de enlace (LTO), u optimización de todo el programa, es una clase más general de optimización interprocedimental. Durante la LTO, el compilador tiene visibilidad de todas las unidades de traducción, lo que le permite realizar optimizaciones más agresivas, como la inserción en línea entre módulos y la desvirtualización .

Optimización del código máquina y del código objeto

La optimización del código máquina implica el uso de un optimizador de código objeto para analizar el programa una vez que se ha enlazado todo el código máquina . Técnicas como la compresión de macros, que ahorra espacio al condensar secuencias de instrucciones comunes, resultan más efectivas cuando se dispone de la imagen completa de la tarea ejecutable para su análisis. [ 5 ]

Independiente del idioma frente a dependiente del idioma

La mayoría de los lenguajes de programación de alto nivel comparten construcciones y abstracciones comunes, como estructuras de ramificación (if, switch), bucles (for, while) y encapsulación (estructuras, objetos). Por lo tanto, se pueden usar técnicas de optimización similares en diferentes lenguajes. Sin embargo, ciertas características del lenguaje dificultan algunas optimizaciones. Por ejemplo, los punteros en C y C++ dificultan la optimización de arreglos; véase el análisis de alias . No obstante, lenguajes como PL/I , que también admiten punteros, implementan optimizaciones para arreglos. Por el contrario, algunas características del lenguaje facilitan ciertas optimizaciones. Por ejemplo, en algunos lenguajes, las funciones no pueden tener efectos secundarios . Por lo tanto, si un programa realiza varias llamadas a la misma función con los mismos argumentos, el compilador puede inferir que el resultado de la función solo necesita calcularse una vez. En lenguajes donde se permiten efectos secundarios, el compilador puede restringir dicha optimización a las funciones que puede determinar que no tienen efectos secundarios.

Independiente de la máquina frente a dependiente de la máquina

Muchas optimizaciones que operan sobre conceptos de programación abstractos (bucles, objetos, estructuras) son independientes de la máquina a la que apunta el compilador, pero muchas de las optimizaciones más efectivas son aquellas que mejor aprovechan las características especiales de la plataforma de destino. Ejemplos de ello son las instrucciones que realizan varias acciones a la vez, como decrementar un registro y saltar a un salto si no es cero.

El siguiente es un ejemplo de optimización dependiente de la máquina local. Para establecer un registro a 0, la forma obvia es usar la constante '0' en una instrucción que establece un valor de registro a una constante. Una forma menos obvia es realizar una operación XOR con un registro consigo mismo o restarlo de sí mismo. Es responsabilidad del compilador saber qué variante de instrucción usar. En muchas máquinas RISC , ambas instrucciones serían igualmente apropiadas, ya que tendrían la misma longitud y tomarían el mismo tiempo. En muchos otros microprocesadores , como la familia Intel x86 , resulta que la variante XOR es más corta y probablemente más rápida, ya que no habrá necesidad de decodificar un operando inmediato, ni de usar el "registro de operando inmediato" interno; lo mismo se aplica en IBM System/360 y sucesores para la variante de resta. [ 6 ] Un problema potencial con esto es que XOR o resta pueden introducir una dependencia de datos con el valor anterior del registro, causando un bloqueo de la tubería , que ocurre cuando el procesador debe retrasar la ejecución de una instrucción porque depende del resultado de una instrucción anterior. Sin embargo, los procesadores suelen tratar la operación XOR de un registro consigo mismo o la resta de un registro de sí mismo como un caso especial que no provoca bloqueos.

Factores que afectan a la optimización

Máquina de tiro
La posibilidad y la conveniencia de aplicar optimizaciones específicas pueden depender de las características de la máquina de destino. Algunos compiladores, como GCC y Clang, parametrizan factores dependientes de la máquina para poder utilizarlos en la optimización para diferentes máquinas. [ 7 ]
Arquitectura de CPU objetivo
  • Número de registros : Los registros se pueden usar para optimizar el rendimiento. Las variables locales se pueden almacenar en registros en lugar de en la pila . Se puede acceder a los resultados temporales/intermedios en los registros en lugar de en la memoria, que es más lenta.
  • RISC vs. CISC : Los conjuntos de instrucciones CISC suelen tener longitudes de instrucción variables, [ 8 ] suelen tener un mayor número de instrucciones posibles que se pueden usar, y cada instrucción puede tomar cantidades de tiempo diferentes. Los conjuntos de instrucciones RISC intentan limitar la variabilidad en cada uno de estos: los conjuntos de instrucciones suelen tener una longitud constante, con pocas excepciones, suelen tener menos combinaciones de registros y operaciones de memoria, y la tasa de emisión de instrucciones (el número de instrucciones completadas por período de tiempo, generalmente un múltiplo entero del ciclo de reloj) suele ser constante en los casos en que la latencia de la memoria no es un factor. Puede haber varias formas de realizar una tarea determinada, y CISC suele ofrecer más alternativas que RISC. Los compiladores tienen que conocer los costos relativos entre las distintas instrucciones y elegir la mejor secuencia de instrucciones (véase selección de instrucciones ).
  • Pipelines : Un pipeline es una CPU dividida en una cadena de montaje . Permite el uso de partes de la CPU para diferentes instrucciones, dividiendo la ejecución de las instrucciones en varias etapas: decodificación de instrucciones, decodificación de direcciones, búsqueda en memoria, búsqueda en registros, cálculo, almacenamiento en registros, etc. Una instrucción podría estar en la etapa de almacenamiento en registros, mientras que otra podría estar en la etapa de búsqueda en registros. Los conflictos de pipeline ocurren cuando una instrucción en una etapa del pipeline depende del resultado de otra instrucción anterior en el pipeline que aún no se ha completado. Los conflictos de pipeline pueden provocar bloqueos en el pipeline : donde la CPU desperdicia ciclos esperando a que se resuelva un conflicto. Los compiladores pueden programar , o reordenar, las instrucciones para que los bloqueos de pipeline ocurran con menos frecuencia.
  • Número de unidades funcionales : Algunas CPU cuentan con varias ALU y FPU que les permiten ejecutar múltiples instrucciones simultáneamente. Pueden existir restricciones sobre qué instrucciones pueden combinarse (la combinación implica la ejecución simultánea de dos o más instrucciones) y qué unidad funcional puede ejecutar cada instrucción. También presentan problemas similares a los conflictos de segmentación. Las instrucciones pueden programarse para que las unidades funcionales se carguen por completo.
Arquitectura de máquinas
  • Tamaño y tipo de caché de la CPU (mapeo directo, asociativa de 2/4/8/16 vías, totalmente asociativa): Técnicas como la expansión en línea y el desenrollado de bucles pueden aumentar el tamaño del código generado y reducir la localidad del código. El programa puede ralentizarse drásticamente si una sección de código muy utilizada (como los bucles internos en varios algoritmos) ya no cabe en la caché debido a optimizaciones que aumentan el tamaño del código. Además, las cachés que no son totalmente asociativas tienen mayores probabilidades de colisiones de caché, incluso cuando están vacías.
  • Tasas de transferencia de caché/memoria: Estas proporcionan al compilador una indicación de la penalización por fallos de caché. Se utilizan principalmente en aplicaciones especializadas.
Uso previsto
  • Depuración : Durante el desarrollo, las optimizaciones suelen desactivarse para acelerar la compilación o facilitar la depuración del código ejecutable. Las transformaciones de optimización, en particular las que reordenan el código, pueden dificultar la relación entre el código ejecutable y el código fuente.
  • Uso general: Se espera que el software preempaquetado se ejecute en diversas máquinas que pueden compartir el mismo conjunto de instrucciones, pero que presentan características de rendimiento diferentes. Es posible que el código no esté optimizado para ninguna máquina en particular o que esté ajustado para funcionar mejor en la máquina más popular, mientras que su rendimiento sea menos óptimo en otras.
  • Uso con fines específicos: Si el software se compila para máquinas con características uniformes, el compilador puede optimizar considerablemente el código generado para esas máquinas.
Entre los casos más destacados se incluye el código diseñado para procesadores paralelos y vectoriales, para los que se utilizan compiladores de paralelización especiales .
El firmware para un sistema embebido puede optimizarse para la CPU y la memoria de destino. El costo o la confiabilidad del sistema pueden ser más importantes que la velocidad del código. Por ejemplo, los compiladores para software embebido suelen ofrecer opciones que reducen el tamaño del código a costa de la velocidad. La temporización del código puede requerir precisión, en lugar de la máxima velocidad posible, por lo que el almacenamiento en caché del código podría deshabilitarse, junto con las optimizaciones del compilador que lo requieran.

Temas comunes

La optimización incluye los siguientes temas, a veces contradictorios.

Optimizar el caso común
El caso común puede presentar características únicas que permiten una ruta rápida a costa de una ruta lenta . Si se opta con mayor frecuencia por la ruta rápida, el resultado es un mejor rendimiento general.
Evite la redundancia
Reutilice los resultados que ya se han calculado y guárdelos para su uso posterior, en lugar de volver a calcularlos.
Menos código
Elimine los cálculos innecesarios y los valores intermedios. Menos trabajo para la CPU, la caché y la memoria suele traducirse en una ejecución más rápida. En cambio, en los sistemas embebidos , menos código reduce el coste del producto.
Menos saltos al usar código de línea recta , también llamado código sin bifurcaciones.
Código menos complejo. Los saltos (condicionales o incondicionales ) interfieren con la precarga de instrucciones, lo que ralentiza el código. El uso de la inserción de código en línea o el desenrollado de bucles puede reducir las ramificaciones, a costa de aumentar el tamaño del archivo binario en la longitud del código repetido. Esto tiende a fusionar varios bloques básicos en uno solo.
Localidad
El código y los datos a los que se accede en un lapso de tiempo muy cercano deben ubicarse cerca unos de otros en la memoria para aumentar la localidad espacial de referencia .
Explotar la jerarquía de memoria
El acceso a la memoria es cada vez más costoso en cada nivel de la jerarquía de memoria , por lo que conviene colocar primero los elementos más utilizados en los registros, luego en las cachés, después en la memoria principal y, finalmente, en el disco.
Paralelizar
Reordenar las operaciones para permitir que se realicen múltiples cálculos en paralelo, ya sea a nivel de instrucción, memoria o hilo de ejecución.
Cuanto más precisa sea la información, mejor.
Cuanto más precisa sea la información que tenga el compilador, mejor podrá emplear cualquiera de estas técnicas de optimización, o todas ellas.
Las métricas de tiempo de ejecución pueden ayudar
La información recopilada durante una prueba puede utilizarse en la optimización guiada por perfiles . La información recopilada en tiempo de ejecución, idealmente con una sobrecarga mínima , puede ser utilizada por un compilador JIT para mejorar dinámicamente la optimización.
Reducción de fuerza
Sustituir operaciones complejas, difíciles o costosas por otras más sencillas. Por ejemplo, sustituir la división por una constante por la multiplicación por su recíproco, o utilizar el análisis de variables por inducción para sustituir la multiplicación por el índice de un bucle por la suma.

Técnicas específicas

Optimizaciones de bucles

La optimización de bucles actúa sobre las instrucciones que componen un bucle, como un bucle for ; por ejemplo, el movimiento de código invariante de bucle . Las optimizaciones de bucles pueden tener un impacto significativo porque muchos programas pasan un gran porcentaje de su tiempo dentro de bucles. [ 3 ] : 596

Algunas técnicas de optimización diseñadas principalmente para operar en bucles incluyen:

Análisis de variables de inducción
En términos generales, si una variable en un bucle es una función lineal simple de la variable de índice, como , se puede actualizar adecuadamente cada vez que se modifica la variable del bucle. Esto supone una reducción de la robustez y también puede permitir que las definiciones de la variable de índice se conviertan en código muerto . [ 3 ] : 596–598 Esta información también es útil para la eliminación de comprobaciones de límites y el análisis de dependencias , entre otras cosas.j := 4*i + 1
Fisión de bucle o distribución de bucle
La división de bucles intenta fragmentar un bucle en varios bucles sobre el mismo rango de índices, de modo que cada nuevo bucle solo toma una parte del cuerpo del bucle original. Esto puede mejorar la localidad de referencia tanto a los datos a los que se accede dentro del bucle como al código dentro del mismo.
fusión de bucles o combinación de bucles o embestida de bucles o bloqueo de bucles
Otra técnica que intenta reducir la sobrecarga de los bucles. Cuando dos bucles adyacentes iterarían el mismo número de veces, independientemente de si ese número se conoce en tiempo de compilación, sus cuerpos se pueden combinar siempre que no hagan referencia a los datos del otro.
Inversión de bucle
Esta técnica transforma un bucle while estándar en un bucle do/while (también conocido como repeat/until ) envuelto en una condición if , reduciendo a la mitad el número de saltos en los casos en que se ejecuta el bucle. Si bien esto duplica la comprobación de la condición (aumentando el tamaño del código), resulta más eficiente, ya que los saltos suelen provocar un bloqueo en la canalización . Además, si la condición inicial se conoce en tiempo de compilación y se sabe que no tiene efectos secundarios , se puede omitir la condición if .
Intercambio en bucle
Estas optimizaciones intercambian bucles internos por bucles externos. Cuando las variables del bucle indexan un array, dicha transformación puede mejorar la localidad de referencia, dependiendo de la estructura del array.
Movimiento de código invariante de bucle
Si una cantidad se calcula dentro de un bucle en cada iteración, y su valor es el mismo para cada iteración, puede mejorar enormemente la eficiencia sacarla del bucle y calcular su valor solo una vez antes de que comience el bucle. [ 3 ] : 596 Esto es particularmente importante con las expresiones de cálculo de direcciones generadas por bucles sobre arreglos. Para una implementación correcta, esta técnica debe usarse con inversión de bucle , porque no todo el código es seguro para sacarlo del bucle.
Optimización de bucles anidados
Algunos algoritmos de uso generalizado, como la multiplicación de matrices, presentan un comportamiento de caché muy deficiente y accesos excesivos a la memoria. La optimización de bucles anidados aumenta el número de aciertos de caché al operar sobre bloques pequeños y mediante el intercambio de bucles.
Inversión del bucle
La inversión de bucle invierte el orden en que se asignan los valores a la variable de índice. Esta es una optimización sutil que puede ayudar a eliminar dependencias y, por lo tanto, permitir otras optimizaciones. Además, en algunas arquitecturas, la inversión de bucle contribuye a un código más pequeño, ya que cuando se decrementa el índice del bucle, la condición que debe cumplirse para que el programa en ejecución salga del bucle es una comparación con cero. Esta suele ser una instrucción especial sin parámetros, a diferencia de una comparación con un número, que necesita el número con el que comparar. Por lo tanto, la inversión de bucle ahorra la cantidad de bytes necesarios para almacenar el parámetro. Adicionalmente, si el número de comparación excede el tamaño de palabra de la plataforma, en el orden de bucle estándar, se necesitarían ejecutar múltiples instrucciones para evaluar la comparación, lo cual no ocurre con la inversión de bucle.
Desenrollado del bucle
El desenrollado duplica el cuerpo del bucle varias veces para disminuir la cantidad de veces que se comprueba la condición del bucle y la cantidad de saltos; las comprobaciones y los saltos pueden perjudicar el rendimiento al afectar la canalización de instrucciones. Una optimización de "menos saltos". El desenrollado completo de un bucle elimina toda la sobrecarga, pero requiere que se conozca el número de iteraciones en tiempo de compilación.
División de bucles
La división de bucles intenta simplificar un bucle o eliminar dependencias dividiéndolo en múltiples bucles con el mismo cuerpo, pero que iteran sobre diferentes porciones contiguas del rango de índices. Un caso especial útil es el pelado de bucles , que puede simplificar un bucle con una primera iteración problemática al realizar dicha iteración por separado antes de entrar en el bucle.
Desactivación del bucle
La operación de desconexión traslada una condición del interior de un bucle al exterior del mismo duplicando el cuerpo del bucle dentro de cada una de las cláusulas if y else de la condición.
Pipeline de software
El bucle se reestructura de tal manera que el trabajo realizado en una iteración se divide en varias partes y se ejecuta a lo largo de varias iteraciones. En un bucle ajustado, esta técnica oculta la latencia entre la carga y el uso de los valores.
Paralelización automática
Un bucle se convierte en código multihilo o vectorizado (o incluso ambos) para utilizar varios procesadores simultáneamente en una máquina multiprocesador de memoria compartida (SMP), incluidas las máquinas multinúcleo.

Optimizaciones de tiendas con visión de futuro

Las optimizaciones de almacenamiento predictivas permiten que las operaciones de almacenamiento se realicen antes de lo que se permitiría en el contexto de hilos y bloqueos. El proceso necesita saber de antemano qué valor almacenará la asignación que debería haber seguido. El propósito de esta flexibilización es permitir que la optimización del compilador realice ciertos tipos de reordenamientos de código que preserven la semántica de los programas correctamente sincronizados. [ 9 ]

Optimizaciones del flujo de datos

Las optimizaciones del flujo de datos , basadas en el análisis del flujo de datos , dependen principalmente de cómo ciertas propiedades de los datos se propagan mediante las aristas de control en el grafo de flujo de control . Algunas de estas incluyen:

eliminación de subexpresiones comunes
En la expresión (a + b) - (a + b)/4, "subexpresión común" se refiere a la duplicada (a + b). Los compiladores que implementan esta técnica se dan cuenta de que (a + b)no cambiará y, por lo tanto, solo calculan su valor una vez. [ 3 ] : 592–594
Plegado y propagación constantes
Reemplazar expresiones que consisten en constantes (por ejemplo, 3 + 5) con su valor final ( 8) en tiempo de compilación, en lugar de realizar el cálculo en tiempo de ejecución. [ 10 ] Se utiliza en la mayoría de los lenguajes modernos.
Reconocimiento y eliminación de variables de inducción
Véase la discusión anterior sobre el análisis de variables de inducción .
Clasificación de alias y análisis de punteros
En presencia de punteros , resulta difícil realizar optimizaciones, ya que cualquier variable podría haber sido modificada al asignarle una ubicación de memoria. Al especificar qué punteros pueden ser alias de qué variables, se pueden ignorar los punteros no relacionados.
eliminación de tiendas muertas
Eliminación de las asignaciones a variables que no se leen posteriormente, ya sea porque finaliza la vida útil de la variable o debido a una asignación posterior que sobrescribirá el primer valor.

Optimizaciones basadas en SSA

Estas optimizaciones deben realizarse tras transformar el programa a un formato especial denominado formato de asignación única estática (SSA) , en el que cada variable se asigna en un único lugar. Si bien algunas funcionan sin SSA, resultan más efectivas con este formato. Muchas optimizaciones descritas en otras secciones también se benefician sin necesidad de realizar cambios especiales, como la asignación de registros.

Numeración de valor global
GVN elimina la redundancia mediante la construcción de un grafo de valores del programa y la posterior determinación de qué valores se calculan con expresiones equivalentes. GVN puede identificar cierta redundancia que la eliminación de subexpresiones convencional no detecta, y viceversa.
Propagación de constantes condicionales dispersas
Combina la propagación de constantes, el plegado de constantes y la eliminación de código muerto , y mejora lo que es posible al ejecutarlos por separado. [ 11 ] [ 12 ] Esta optimización ejecuta simbólicamente el programa, propagando simultáneamente valores constantes y eliminando porciones del grafo de flujo de control que esto hace inaccesibles.

Optimizaciones del generador de código

Asignación de registro
Las variables más utilizadas deben almacenarse en los registros del procesador para un acceso más rápido. Para determinar qué variables colocar en los registros, se crea un grafo de interferencia. Cada variable es un vértice y, cuando dos variables se utilizan simultáneamente (tienen un rango de vida que se interseca), se crea una arista entre ellas. Este grafo se colorea, por ejemplo, utilizando el algoritmo de Chaitin con el mismo número de colores que de registros. Si la coloración falla, una variable se "desborda" a la memoria y se vuelve a intentar la coloración.
Selección de instrucciones
La mayoría de las arquitecturas, en particular las arquitecturas CISC y aquellas con múltiples modos de direccionamiento , ofrecen varias formas diferentes de realizar una operación específica, utilizando secuencias de instrucciones completamente distintas. La función del selector de instrucciones es elegir de forma óptima qué instrucciones implementar para cada operador en la representación intermedia de bajo nivel . Por ejemplo, en muchos procesadores de la familia 68000 y la arquitectura x86, se pueden utilizar modos de direccionamiento complejos en instrucciones como lea 25(a1,d5*4), a0, lo que permite que una sola instrucción realice una cantidad significativa de operaciones aritméticas con menor almacenamiento.
Programación de la instrucción
La planificación de instrucciones es una optimización importante para los procesadores modernos con arquitectura segmentada, ya que evita bloqueos o interrupciones en la segmentación agrupando las instrucciones sin dependencias, al tiempo que se preserva la semántica original.
Rematerialización
La rematerialización recalcula un valor en lugar de cargarlo desde la memoria, eliminando así el acceso a la misma. Esto se realiza simultáneamente con la asignación de registros para evitar desbordamientos.
factorización de código
Si varias secuencias de código son idénticas, o pueden parametrizarse o reordenarse para que sean idénticas, pueden reemplazarse con llamadas a una subrutina compartida. Esto a menudo permite compartir código para la configuración de la subrutina y, en ocasiones, para la recursión de cola. [ 13 ]
Trampolines
Muchas CPU tienen instrucciones de llamada a subrutinas más pequeñas para acceder a la memoria baja. Un compilador puede ahorrar espacio utilizando estas llamadas pequeñas en el cuerpo principal del código. Las instrucciones de salto en la memoria baja pueden acceder a las rutinas en cualquier dirección. Esto multiplica el ahorro de espacio derivado de la factorización del código. [ 13 ]
Reordenamiento de cálculos
Basándose en la programación lineal entera , los compiladores de reestructuración mejoran la localidad de los datos y exponen un mayor paralelismo mediante la reordenación de los cálculos. Los compiladores optimizadores de espacio pueden reordenar el código para alargar secuencias que se pueden factorizar en subrutinas.

Optimizaciones del lenguaje funcional

Aunque muchas de estas características también se aplican a los lenguajes no funcionales, tienen su origen en lenguajes funcionales como Lisp y ML , o son particularmente importantes en ellos .

Optimización de llamadas de cola
Una llamada a función consume espacio en la pila e implica cierta sobrecarga relacionada con el paso de parámetros y el vaciado de la caché de instrucciones. Los algoritmos recursivos de cola se pueden convertir en iteración mediante un proceso denominado eliminación de recursión de cola u optimización de llamadas de cola.
Deforestación ( fusión de estructuras de datos )
En los lenguajes donde es común aplicar una secuencia de transformaciones a una lista, la deforestación intenta eliminar la construcción de estructuras de datos intermedias.
Evaluación parcial
Los cálculos que producen el mismo resultado independientemente de la entrada dinámica en tiempo de ejecución pueden evaluarse en tiempo de compilación.

Otras optimizaciones

Eliminación de la comprobación de límites
Muchos lenguajes, como Java , imponen la comprobación de límites en todos los accesos a matrices. Esto supone un grave cuello de botella en el rendimiento de ciertas aplicaciones, como el código científico. La eliminación de la comprobación de límites permite al compilador eliminarla de forma segura en muchas situaciones en las que puede determinar que el índice debe estar dentro de límites válidos; por ejemplo, si se trata de una variable de bucle simple.
Optimización del desplazamiento de ramas (depende de la máquina)
Elija el desplazamiento de rama más corto que alcance el objetivo.
Reordenamiento de bloques de código
La reordenación de bloques de código altera el orden de los bloques básicos de un programa para reducir las bifurcaciones condicionales y mejorar la localidad de referencia.
eliminación de código muerto
Elimina las instrucciones que no afectan al comportamiento del programa, por ejemplo, las definiciones que no tienen utilidad, denominadas código muerto . Esto reduce el tamaño del código y elimina los cálculos innecesarios.
Factorización de invariantes ( invariantes de bucle )
Si una expresión se ejecuta tanto cuando se cumple una condición como cuando no, puede escribirse una sola vez fuera de la sentencia condicional. Del mismo modo, si ciertos tipos de expresiones (por ejemplo, la asignación de una constante a una variable) aparecen dentro de un bucle, pueden sacarse de él, ya que su efecto será el mismo independientemente de si se ejecutan varias veces o solo una. Esto se conoce como eliminación total de redundancia. Una optimización similar, pero más potente, es la eliminación parcial de redundancia (PRE).
Expansión en línea o expansión macro
Cuando un código invoca un procedimiento , es posible insertar directamente el cuerpo del procedimiento dentro del código que lo llama, en lugar de transferirle el control. Esto ahorra la sobrecarga asociada a las llamadas a procedimientos, además de brindar la oportunidad de realizar diversas optimizaciones específicas para cada parámetro, pero tiene un costo en términos de espacio; el cuerpo del procedimiento se duplica cada vez que se llama al procedimiento en línea. Generalmente, la inserción en línea es útil en código crítico para el rendimiento que realiza un gran número de llamadas a procedimientos pequeños. Esta es una optimización de "menos saltos". Las sentencias de los lenguajes de programación imperativos también son un ejemplo de dicha optimización. Si bien las sentencias podrían implementarse con llamadas a funciones, casi siempre se implementan con inserción de código en línea.
enhebrado de salto
En esta optimización, se fusionan los saltos condicionales consecutivos que dependen total o parcialmente de la misma condición.
Por ejemplo:
if ( cond ) { foo (); } if ( cond ) { bar (); }// se convierte en: if ( cond ) { foo (); bar (); }
y:
if ( cond ) { foo (); } if ( ! cond ) { bar (); }// se convierte en: if ( cond ) { foo (); } else { bar (); }
Macrocompresión
Una optimización de espacio que reconoce secuencias de código comunes, crea subprogramas ("macros de código") que contienen el código común y reemplaza las ocurrencias de las secuencias de código comunes con llamadas a los subprogramas correspondientes. [ 5 ] Esto se realiza de manera más efectiva como una optimización de código máquina , cuando todo el código está presente. La técnica se utilizó por primera vez para conservar espacio en un flujo de bytes interpretado utilizado en una implementación de Macro Spitbol en microcomputadoras . [ 14 ] Se sabe que el problema de determinar un conjunto óptimo de macros que minimice el espacio requerido por un segmento de código dado es NP-completo , [ 5 ] pero las heurísticas eficientes alcanzan resultados casi óptimos. [ 15 ]
Reducción de colisiones de caché
(por ejemplo, alterando la alineación dentro de una página)
Reducción de la altura de la pila
Reorganizar un árbol de expresiones para minimizar los recursos necesarios para la evaluación de expresiones.
Reordenamiento de prueba
Si tenemos dos pruebas que constituyen la condición para algo, podemos abordar primero las pruebas más sencillas (por ejemplo, comparar una variable con algo) y solo después las pruebas complejas (por ejemplo, aquellas que requieren una llamada a una función). Esta técnica complementa la evaluación perezosa , pero solo se puede usar cuando las pruebas no dependen unas de otras. La semántica de cortocircuito puede dificultar esto.

Optimizaciones interprocedimentales

La optimización interprocedimental opera sobre todo el programa, abarcando procedimientos y archivos. Colabora estrechamente con sus contrapartes intraprocedimentales, mediante la cooperación de una parte local y una parte global. Las optimizaciones interprocedimentales típicas incluyen la inserción de código en línea, la eliminación de código muerto, la propagación de constantes y la reordenación de procedimientos. Como es habitual, el compilador debe realizar un análisis interprocedimental antes de sus optimizaciones. Estos análisis incluyen el análisis de alias, el análisis de acceso a matrices y la construcción de un grafo de llamadas .

La optimización interprocedimental es común en los compiladores comerciales modernos de SGI , Intel , Microsoft y Sun Microsystems . Durante mucho tiempo, el compilador de código abierto GCC fue criticado por la falta de análisis y optimizaciones interprocedimentales potentes, aunque esto está mejorando. [ 16 ] Otro compilador de código abierto con una infraestructura completa de análisis y optimización es Open64 .

Debido al tiempo y espacio adicionales que requiere el análisis interprocedimental, la mayoría de los compiladores no lo realizan por defecto. Los usuarios deben usar opciones del compilador explícitamente para indicarle que habilite el análisis interprocedimental y otras optimizaciones costosas.

Consideraciones prácticas

Un compilador puede realizar una amplia gama de optimizaciones, desde optimizaciones simples y directas que requieren poco tiempo de compilación hasta optimizaciones elaboradas y complejas que implican una cantidad considerable de tiempo de compilación. [ 3 ] : 15 En consecuencia, los compiladores suelen proporcionar opciones a su comando o procedimiento de control para permitir al usuario elegir cuánta optimización solicitar; por ejemplo, el compilador IBM FORTRAN H permitía al usuario especificar ninguna optimización, optimización solo a nivel de registros o optimización completa. [ 3 ] : 737 Para la década de 2000, era común que los compiladores, como Clang , tuvieran varias opciones de comando que podían afectar a diversas opciones de optimización, comenzando con el conocido -O2interruptor. [ 17 ]

Un método para aislar la optimización consiste en el uso de los llamados optimizadores de post-paso (algunas versiones comerciales de los cuales datan del software para mainframes de finales de la década de 1970). [ 18 ] Estas herramientas toman el código ejecutable generado por un compilador optimizador y lo optimizan aún más. Los optimizadores de post-paso suelen trabajar a nivel de lenguaje ensamblador o código máquina (a diferencia de los compiladores que optimizan representaciones intermedias de los programas). Un ejemplo de ello es el Portable C Compiler (PCC) de la década de 1980, que tenía un paso opcional que realizaba post-optimizaciones en el código ensamblador generado. [ 3 ] : 736

Otra consideración es que los algoritmos de optimización son complejos y, especialmente cuando se utilizan para compilar lenguajes de programación grandes y complejos, pueden contener errores que introducen fallos en el código generado o causan errores internos durante la compilación. Los errores de compilación de cualquier tipo pueden resultar desconcertantes para el usuario, pero especialmente en este caso, ya que puede no estar claro que la lógica de optimización sea la causante del problema. [ 19 ] En el caso de errores internos, el problema puede mitigarse parcialmente mediante una técnica de programación "a prueba de fallos" en la que la lógica de optimización del compilador se codifica de forma que se detecte un fallo, se emita un mensaje de advertencia y el resto de la compilación continúe hasta su finalización con éxito. [ 20 ]

Historia

Los primeros compiladores de la década de 1960 se centraban principalmente en compilar el código de forma correcta o eficiente, por lo que los tiempos de compilación eran una preocupación importante. Un compilador optimizador temprano notable fue el compilador IBM FORTRAN H de finales de la década de 1960. [ 3 ] : 737 Otro de los primeros e importantes compiladores optimizadores, que fue pionero en varias técnicas avanzadas, fue el de BLISS (1970), que se describió en The Design of an Optimizing Compiler (1975). [ 3 ] : 740, 779 A finales de la década de 1980, los compiladores optimizadores eran lo suficientemente efectivos como para que la programación en lenguaje ensamblador disminuyera. Esto coevolucionó con el desarrollo de los chips RISC y características avanzadas de los procesadores, como los procesadores superescalares , la ejecución fuera de orden y la ejecución especulativa , que fueron diseñados para ser aprovechados por compiladores optimizadores en lugar de por código ensamblador escrito por humanos.

Lista de análisis de código estático

Véase también

Referencias

  1. Godbolt, Matt (12 de noviembre de 2019). "Optimizaciones en compiladores de C++" . ACM Queue . Vol.  17, n.º  5.
  2. "Clase 15: NP-completitud, optimización y separación" (PDF) . IE 511: Programación entera, primavera de 2021 .
  3. 1 2 3 4 5 6 7 8 9 10 11 Aho, Alfred V.; Sethi, Ravi; Ullman, Jeffrey D. (1986). Compiladores: Principios, técnicas y herramientas . Reading, Massachusetts: Addison-Wesley. ISBN 0-201-10088-6.
  4. 1 2 Cooper, Keith D. ; Torczon, Linda (2003) [2002-01-01]. Ingeniería de un compilador . Morgan Kaufmann . págs. 404, 407. ISBN  978-1-55860-698-2.
  5. 1 2 3 Goss, Clinton F. (agosto de 2013) [Publicado por primera vez en junio de 1986]. Optimización del código máquina: mejora del código objeto ejecutable (PDF) (tesis doctoral). Vol. Informe técnico del Departamento de Ciencias de la Computación n.° 246. Instituto Courant, Universidad de Nueva York. arXiv : 1308.4815 . Bibcode : 2013arXiv1308.4815G . Archivado (PDF) del original el 9 de octubre de 2022. Recuperado el 22 de agosto de 2013 . 
    • Clinton F. Goss (2013) [1986]. Optimización del código máquina: mejora del código objeto ejecutable (tesis doctoral). Instituto Courant, Universidad de Nueva York.
  6. Introducción al lenguaje ensamblador de IBM System/360 para programadores (PDF) . IBM . pág. 42. GC20-1645-5. 
  7. "GCC – Opciones dependientes de la máquina" . Proyecto GNU .
  8. "RISC vs. CISC" . cs.stanford.edu . Consultado el 15 de octubre de 2024 .
  9. James Gosling ; Bill Joy ; Guy Steele . "17 Hilos y bloqueos" . Especificación del lenguaje Java ( ed. 1.0). 17.8 Acciones de almacenamiento prescientíficas. 
  10. Muchnick, Steven; Muchnick and Associates (15 de agosto de 1997). Diseño e implementación avanzados de compiladores . Morgan Kaufmann. pp. 329 –. ISBN  978-1-55860-320-2. plegado constante.
  11. Wegman, Mark N.; Zadeck, F. Kenneth (abril de 1991). "Propagación constante con ramificaciones condicionales" (PDF) . ACM Transactions on Programming Languages ​​and Systems . 13 (2): 181– 210. doi : 10.1145/103135.103136 .
  12. Click, Clifford; Cooper, Keith. (marzo de 1995). "Combining Analyses, Combining Optimizations" (PDF) . ACM Transactions on Programming Languages ​​and Systems . 17 (2): 181– 196. doi : 10.1145/201059.201061 .
  13. 1 2 Manual del compilador Cx51, versión 09.2001, pág. 155, Keil Software Incorporated.
  14. Dewar, Robert BK ; Golumbic, Martin Charles ; Goss, Clinton F. (agosto de 2013) [octubre de 1979]. MICRO SPITBOL . Informe técnico del Departamento de Ciencias de la Computación. Vol. 11. Instituto Courant de Ciencias Matemáticas. arXiv : 1308.6096 . Bibcode : 2013arXiv1308.6096D . 
  15. Golumbic, Martin Charles ; Dewar, Robert BK ; Goss, Clinton F. (1980). "Sustituciones macro en MICRO SPITBOL: un análisis combinatorio". Actas de la 11.ª Conferencia del Sudeste sobre Combinatoria, Teoría de Grafos y Computación, Congressus Numerantium, Utilitas Math., Winnipeg, Canadá . 11.ª Conferencia del Sudeste sobre Combinatoria, Teoría de Grafos y Computación. Vol. 29. págs. 485–495 .  
  16. Glazunov, NM (25 de noviembre de 2012). "Fundamentos de la investigación científica". arXiv : 1212.1651 [ cs.OH ].
  17. Guelton, Serge (5 de agosto de 2019). "Personalice el proceso de compilación con Clang: opciones de optimización" . Red Hat.
  18. Evans, Michael (diciembre de 1982). "Ingeniería de software para el entorno Cobol" . Communications of the ACM . 25 (12): 874– 882. doi : 10.1145/358728.358732 . Recuperado el 10 de agosto de 2013 .
  19. Sun, Chengnian; et al. (18-20 de julio de 2016). «Hacia la comprensión de los errores del compilador en GCC y LLVM» . Actas del 25.º Simposio Internacional sobre Pruebas y Análisis de Software . Issta 2016. págs. 294-305 . doi : 10.1145/2931037.2931074 . ISBN   9781450343909. S2CID 8339241 . 
  20. Schilling, Jonathan L. (agosto de 1993). "Programación a prueba de fallos en la optimización de compiladores". ACM SIGPLAN Notices . 28 (8): 39– 42. doi : 10.1145/163114.163118 . S2CID 2224606 . 
  • Manuales de optimización de Agner Fog : documentación sobre la arquitectura del procesador x86 y la optimización de código de bajo nivel.