La optimización puede ocurrir en varios niveles. Normalmente, los niveles más altos tienen un mayor impacto, y son más difíciles de cambiar más adelante en un proyecto, requiriendo cambios significativos o una reescritura completa si es necesario cambiarlos. Por lo tanto, la optimización puede llevarse a cabo por medio de un refinamiento de mayor a menor nivel, en el que las ganancias iniciales son mayores y se logran con menos trabajo, y las ganancias posteriores son menores y requieren más trabajo. Sin embargo, en algunos casos el rendimiento general depende del rendimiento de partes de muy bajo nivel de un programa, y los pequeños cambios en una etapa tardía o la consideración temprana de los detalles de bajo nivel pueden tener un impacto enorme. Normalmente se tiene en cuenta la eficiencia a lo largo de un proyecto -aunque esto varía significativamente-, pero una optimización importante suele considerarse un refinamiento que debe hacerse tarde, si es que se hace. En los proyectos de mayor duración suele haber ciclos de optimización, en los que la mejora de un área pone de manifiesto las limitaciones de otra, y éstos suelen reducirse cuando el rendimiento es aceptable o las ganancias se vuelven demasiado pequeñas o costosas.
Como el rendimiento es parte de la especificación de un programa – un programa que es inusualmente lento no es apto para el propósito: un videojuego con 60 Hz (fotogramas por segundo) es aceptable, pero 6 fotogramas por segundo es inaceptablemente entrecortado- el rendimiento es una consideración desde el principio, para asegurar que el sistema es capaz de ofrecer un rendimiento suficiente, y los primeros prototipos deben tener un rendimiento más o menos aceptable para que haya confianza en que el sistema final (con la optimización) logrará un rendimiento aceptable. A veces se omite esto en la creencia de que la optimización siempre puede hacerse más tarde, lo que da lugar a prototipos de sistemas que son demasiado lentos -a menudo por un orden de magnitud o más- y a sistemas que finalmente son un fracaso porque arquitectónicamente no pueden alcanzar sus objetivos de rendimiento, como el Intel 432 (1981); o a otros que requieren años de trabajo para alcanzar un rendimiento aceptable, como Java (1995), que sólo alcanzó un rendimiento aceptable con HotSpot (1999). El grado en que el rendimiento cambia entre el prototipo y el sistema de producción, y lo susceptible que es a la optimización, puede ser una fuente significativa de incertidumbre y riesgo.
Nivel de diseñoEditar
En el nivel más alto, el diseño puede ser optimizado para hacer el mejor uso de los recursos disponibles, dados los objetivos, las restricciones, y el uso / carga esperada. El diseño arquitectónico de un sistema afecta de forma abrumadora a su rendimiento. Por ejemplo, un sistema que está limitado por la latencia de la red (donde la latencia de la red es la principal restricción del rendimiento general) se optimizaría para minimizar los viajes por la red, realizando idealmente una única petición (o ninguna petición, como en un protocolo push) en lugar de múltiples viajes de ida y vuelta. La elección del diseño depende de los objetivos: cuando se diseña un compilador, si la prioridad principal es la compilación rápida, un compilador de una sola pasada es más rápido que un compilador de varias pasadas (suponiendo que se realice el mismo trabajo), pero si el objetivo es la velocidad del código de salida, un compilador de varias pasadas más lento cumple mejor el objetivo, aunque tarde más en hacerlo. La elección de la plataforma y el lenguaje de programación se produce a este nivel, y cambiarlos frecuentemente requiere una reescritura completa, aunque un sistema modular puede permitir la reescritura de sólo algún componente – por ejemplo, un programa Python puede reescribir secciones críticas para el rendimiento en C. En un sistema distribuido, la elección de la arquitectura (cliente-servidor, peer-to-peer, etc.) se produce a nivel de diseño, y puede ser difícil de cambiar, particularmente si todos los componentes no pueden ser reemplazados en sincronía (por ejemplo, clientes antiguos).
Algoritmos y estructuras de datosEditar
Dado un diseño general, una buena elección de algoritmos y estructuras de datos eficientes, y una implementación eficiente de estos algoritmos y estructuras de datos viene a continuación. Después del diseño, la elección de algoritmos y estructuras de datos afecta a la eficiencia más que cualquier otro aspecto del programa. Por lo general, las estructuras de datos son más difíciles de cambiar que los algoritmos, ya que la suposición de una estructura de datos y sus supuestos de rendimiento se utilizan en todo el programa, aunque esto puede minimizarse mediante el uso de tipos de datos abstractos en las definiciones de las funciones, y manteniendo las definiciones concretas de las estructuras de datos restringidas a unos pocos lugares.
Para los algoritmos, esto consiste principalmente en asegurar que los algoritmos son constantes O(1), logarítmicos O(log n), lineales O(n), o en algunos casos log-lineales O(n log n) en la entrada (tanto en espacio como en tiempo). Los algoritmos con complejidad cuadrática O(n2) no escalan, e incluso los algoritmos lineales causan problemas si son llamados repetidamente, y son típicamente reemplazados por constantes o logarítmicos si es posible.
Más allá del orden de crecimiento asintótico, los factores constantes importan: un algoritmo asintóticamente más lento puede ser más rápido o más pequeño (porque más simple) que un algoritmo asintóticamente más rápido cuando ambos se enfrentan a una entrada pequeña, que puede ser el caso que ocurre en la realidad. A menudo un algoritmo híbrido proporcionará el mejor rendimiento, debido a que esta compensación cambia con el tamaño.
Una técnica general para mejorar el rendimiento es evitar el trabajo. Un buen ejemplo es el uso de una ruta rápida para casos comunes, mejorando el rendimiento al evitar el trabajo innecesario. Por ejemplo, utilizar un algoritmo de diseño de texto simple para el texto latino, y sólo cambiar a un algoritmo de diseño complejo para las escrituras complejas, como el devanagari. Otra técnica importante es el almacenamiento en caché, en particular la memoización, que evita los cálculos redundantes. Debido a la importancia del almacenamiento en caché, a menudo hay muchos niveles de almacenamiento en caché en un sistema, lo que puede causar problemas por el uso de la memoria, y problemas de corrección por cachés obsoletas.
Nivel de código fuenteEditar
Más allá de los algoritmos generales y su implementación en una máquina abstracta, las elecciones concretas a nivel de código fuente pueden hacer una diferencia significativa. Por ejemplo, en los primeros compiladores de C, while(1)
era más lento que for(;;)
para un bucle incondicional, porque while(1)
evaluaba 1 y luego tenía un salto condicional que comprobaba si era verdadero, mientras que for (;;)
tenía un salto incondicional . Algunas optimizaciones (como ésta) pueden ser realizadas hoy en día por los compiladores de optimización. Esto depende del lenguaje fuente, el lenguaje máquina de destino y el compilador, y puede ser difícil de entender o predecir y cambia con el tiempo; este es un lugar clave donde la comprensión de los compiladores y el código máquina puede mejorar el rendimiento. El movimiento de código invariante del bucle y la optimización del valor de retorno son ejemplos de optimizaciones que reducen la necesidad de variables auxiliares y pueden incluso dar lugar a un rendimiento más rápido al evitar optimizaciones de ida y vuelta.
Nivel de compilaciónEditar
Entre el nivel de código fuente y el de compilación, se pueden utilizar directivas y banderas de compilación para ajustar las opciones de rendimiento en el código fuente y en el compilador, respectivamente, como el uso de definiciones del preprocesador para desactivar características de software innecesarias, la optimización para modelos de procesador o capacidades de hardware específicos, o la predicción de ramificaciones, por ejemplo. Los sistemas de distribución de software basados en el código fuente, como Ports de BSD y Portage de Gentoo, pueden aprovechar esta forma de optimización.
Nivel de compilaciónEdit
El uso de un compilador optimizador tiende a asegurar que el programa ejecutable está optimizado al menos tanto como el compilador puede predecir.
Nivel de ensamblajeEditar
En el nivel más bajo, escribir código utilizando un lenguaje ensamblador, diseñado para una plataforma de hardware particular puede producir el código más eficiente y compacto si el programador aprovecha todo el repertorio de instrucciones de la máquina. Por esta razón, muchos sistemas operativos utilizados en sistemas embebidos se han escrito tradicionalmente en código ensamblador. Los programas (que no sean muy pequeños) rara vez se escriben de principio a fin en ensamblador debido al tiempo y al coste que supone. La mayoría se compilan de un lenguaje de alto nivel a ensamblador y se optimizan a mano a partir de ahí. Cuando la eficiencia y el tamaño son menos importantes, las partes grandes pueden escribirse en un lenguaje de alto nivel.
Con los compiladores de optimización más modernos y la mayor complejidad de las CPUs recientes, es más difícil escribir un código más eficiente que el que genera el compilador, y pocos proyectos necesitan este paso de optimización «definitivo».
Mucho del código que se escribe hoy en día está pensado para ejecutarse en tantas máquinas como sea posible. Como consecuencia, los programadores y compiladores no siempre aprovechan las instrucciones más eficientes proporcionadas por las nuevas CPUs o las peculiaridades de los modelos más antiguos. Además, el código ensamblador ajustado para un determinado procesador sin utilizar dichas instrucciones podría seguir siendo subóptimo en un procesador diferente, esperando un ajuste diferente del código.
Típicamente, hoy en día, en lugar de escribir en lenguaje ensamblador, los programadores utilizarán un desensamblador para analizar la salida de un compilador y cambiar el código fuente de alto nivel para que pueda ser compilado de manera más eficiente, o entender por qué es ineficiente.
Tiempo de ejecuciónEditar
Los compiladores justo a tiempo pueden producir código máquina personalizado basado en datos de tiempo de ejecución, a costa de la sobrecarga de compilación. Esta técnica se remonta a los primeros motores de expresiones regulares, y se ha generalizado con Java HotSpot y V8 para JavaScript. En algunos casos la optimización adaptativa puede ser capaz de realizar una optimización en tiempo de ejecución que excede la capacidad de los compiladores estáticos ajustando dinámicamente los parámetros de acuerdo con la entrada real u otros factores.
La optimización guiada por perfiles es una técnica de optimización de compilación por adelantado (AOT) basada en perfiles en tiempo de ejecución, y es similar a un análogo estático de «caso medio» de la técnica dinámica de optimización adaptativa.
El código auto-modificable puede alterarse a sí mismo en respuesta a las condiciones de tiempo de ejecución con el fin de optimizar el código; esto era más común en los programas de lenguaje ensamblador.
Algunos diseños de CPU pueden realizar algunas optimizaciones en tiempo de ejecución. Algunos ejemplos son la ejecución fuera de orden, la ejecución especulativa, los pipelines de instrucciones y los predictores de rama. Los compiladores pueden ayudar al programa a aprovechar estas características de la CPU, por ejemplo a través de la programación de instrucciones.
Optimizaciones dependientes e independientes de la plataformaEditar
La optimización del código también puede clasificarse en términos generales como técnicas dependientes e independientes de la plataforma. Mientras que estas últimas son eficaces en la mayoría o en todas las plataformas, las técnicas dependientes de la plataforma utilizan propiedades específicas de una plataforma, o dependen de parámetros que dependen de la plataforma única o incluso del procesador único. Por lo tanto, puede ser necesario escribir o producir diferentes versiones del mismo código para diferentes procesadores. Por ejemplo, en el caso de la optimización a nivel de compilación, las técnicas independientes de la plataforma son técnicas genéricas (como el desenrollado de bucles, la reducción de las llamadas a funciones, las rutinas eficientes en memoria, la reducción de condiciones, etc.), que afectan a la mayoría de las arquitecturas de CPU de forma similar. Un gran ejemplo de optimización independiente de la plataforma se ha mostrado con el bucle for interno, donde se observó que un bucle con un bucle for interno realiza más cálculos por unidad de tiempo que un bucle sin él o uno con un bucle while interno. En general, estos sirven para reducir la longitud total de la ruta de instrucciones necesaria para completar el programa y/o reducir el uso total de memoria durante el proceso. Por otro lado, las técnicas dependientes de la plataforma implican la programación de instrucciones, el paralelismo a nivel de instrucciones, el paralelismo a nivel de datos, las técnicas de optimización de la caché (es decir, parámetros que difieren entre varias plataformas) y la programación óptima de las instrucciones podría ser diferente incluso en diferentes procesadores de la misma arquitectura.