Compiladores

Lo que todo programador debería conocer sobre las optimizaciones del compilador

Hadi Brais

Descargar el código de muestra

Los lenguajes de programación de alto nivel ofrecen muchas construcciones de programación abstractas como funciones, instrucciones condicionales y bucles que nos permiten ser increíblemente productivos. Sin embargo, un inconveniente de escribir código en un lenguaje de programación de alto nivel es la considerable disminución del rendimiento. Idealmente, debería escribir código que sea fácil de mantener y comprensible, sin comprometer el rendimiento. Por este motivo, los compiladores intentan optimizar automáticamente el código para mejorar su rendimiento y actualmente tienen una forma muy sofisticada de conseguirlo. Pueden transformar bucles, instrucciones condicionales y funciones recursivas, eliminar bloques enteros del código y aprovechar la arquitectura de conjunto de instrucciones (ISA) de destino, para que el código sea rápido y compacto. Es mucho mejor centrarse en escribir código comprensible que realizar optimizaciones manuales que den como resultado un código críptico y difícil de mantener. De hecho, la optimización manual del código podría impedir que el compilador realizase optimizaciones adicionales o más eficaces.

En lugar de optimizar manualmente el código, debería considerar los aspectos de su diseño, por ejemplo mediante algoritmos más rápidos, con incorporación de paralelismo de nivel de subproceso y el uso de características específicas del marco (por ejemplo, mediante constructores de movimiento).

Este artículo trata sobre las optimizaciones del compilador de Visual C++. Explicaré las técnicas de optimización más importantes y las decisiones que un compilador debe tomar antes de aplicarlas. El objetivo no es explicar cómo optimizar manualmente el código, sino mostrar por qué puede confiar en el compilador para que optimice el código por usted. Este artículo no es un examen completo de las optimizaciones que realiza el compilador de Visual C++. Sin embargo, muestra las optimizaciones que le interesa conocer y cómo comunicarse con el compilador para aplicarlas.

Hay otras optimizaciones importantes que van más allá de las capacidades actuales de cualquier compilador, como por ejemplo, la sustitución de un algoritmo ineficaz por uno eficaz o el cambio del diseño de una estructura de datos para mejorar su localidad. Sin embargo, estas optimizaciones están fuera del ámbito de este artículo.

Definición de las optimizaciones del compilador

Una optimización es el proceso de transformación de un fragmento de código en otro trozo de código funcionalmente equivalente para mejorar una o varias de sus características. Las dos características más importantes son la velocidad y el tamaño del código. Otras características incluyen la cantidad de energía necesaria para ejecutar el código, el tiempo necesario para compilar el código y, si el código resultante requiere la compilación Just-in-Time (JIT), el tiempo que tarda JIT en compilar el código.

Los compiladores mejoran constantemente en relación a las técnicas que utilizan para optimizar el código. Sin embargo, no son perfectos. Aun así, en lugar de dedicar tiempo a ajustar manualmente un programa, normalmente es mucho más provechoso usar determinadas características que proporciona el compilador y dejar que el compilador ajuste el código.

Hay cuatro maneras de ayudar a que el compilador optimice el código de forma más eficaz:

  1. Escribir código fácil de mantener y comprensible. No considerar las características orientadas a objetos de Visual C++ como enemigos del rendimiento. La versión más reciente de Visual C++ puede reducir al mínimo esa sobrecarga y en algunos casos eliminarla por completo.
  2. Usar directivas de compilador. Por ejemplo, indicar al compilador que use una convención de llamada a función que sea más rápida que la predeterminada.
  3. Usar funciones intrínsecas del compilador. Una función intrínseca es una función especial cuya implementación proporciona automáticamente el compilador. El compilador tiene un conocimiento profundo de la función y sustituye la llamada a la función con una secuencia de instrucciones muy eficaz que aprovecha la ISA de destino. Actualmente, Microsoft .NET Framework no admite funciones intrínsecas, por lo que ninguno de los lenguajes administrados las admiten. Sin embargo, Visual C++ tiene una amplia compatibilidad para esta característica. Tenga en cuenta que aunque el uso de funciones intrínsecas puede mejorar el rendimiento del código, se reduce su legibilidad y portabilidad.
  4. Use la optimización guiada por perfiles (PGO). Con esta técnica, el compilador conoce más información acerca de la forma en que el código se comporta en tiempo de ejecución y puede optimizarlo según corresponda.

El propósito de este artículo es mostrarle por qué puede confiar en el compilador con una demostración de las optimizaciones efectuadas en código ineficaz pero comprensible (con la aplicación del primer método). Además, ofrezco una breve introducción a la optimización guiada por perfiles y menciono algunas de las directivas de compilador que le permiten ajustar algunas partes del código.

Existen muchas técnicas de optimización del compilador que van desde transformaciones simples, como el plegamiento constante, a transformaciones extremas, como la programación de instrucciones. Sin embargo, en este artículo, limitaré el contenido a algunas de las optimizaciones más importantes: aquellas que pueden mejorar significativamente el rendimiento (en un porcentaje de dos dígitos) y que reducen el tamaño del código: inserción de funciones, optimizaciones de COMDAT y optimizaciones de bucles. Hablaré de los dos primeros en la sección siguiente y, a continuación, mostraré cómo se pueden controlar las optimizaciones que realiza Visual C++. Por último, echaré un vistazo a las optimizaciones en .NET Framework. En este artículo usaré Visual Studio 2013 para compilar el código.

Generación de código en tiempo de vínculo

La generación de código de tiempo de vínculo (LTCG) es una técnica para realizar optimizaciones de todo el programa (WPO) en código C o C++. El compilador de C o C++ compila cada archivo de código fuente por separado y genera el archivo objeto correspondiente. Esto significa que el compilador solo puede aplicar optimizaciones en un único archivo de origen y no en todo el programa. Sin embargo, algunas optimizaciones importantes solo pueden realizarse sobre todo el programa. Puede aplicar estas optimizaciones en tiempo de vínculo en lugar de en tiempo de compilación porque el vinculador tiene una visión completa del programa.

Cuando se habilita LTCG (mediante la especificación del modificador de compilador /GL), el controlador del compilador (cl.exe) invoca solamente el front-end del compilador (c1.dll o c1xx.dll) y pospone el trabajo del back-end (c2.dll) hasta el momento del vínculo. Los archivos objeto resultantes contienen lenguaje intermedio C (CIL) en lugar de código de ensamblado dependiente de la máquina. A continuación, cuando se invoca el vinculador (link.exe), verá que los archivos objeto contienen código CIL e invocará el back-end del compilador, que, a su vez, realiza WPO, genera los archivos objeto binarios y vuelve al vinculador para unir todos los archivos objeto y generar el archivo ejecutable.

El front-end en realidad lleva a cabo algunas optimizaciones, como el plegamiento constante, independientemente de si las optimizaciones están habilitadas o deshabilitadas. Sin embargo, todas las optimizaciones importantes las realiza el back-end del compilador y pueden controlarse mediante los modificadores de compilador.

LTCG permite que el back-end realice muchas optimizaciones de forma agresiva (especificando /GL junto con los modificadores de compilador /O1 u /O2 y /Gw y los modificadores de vinculador /OPT:REF y /OPT:ICF). En este artículo, analizaré solo la inserción de funciones y las optimizaciones de COMDAT. Para obtener una lista completa de las optimizaciones de LTCG, consulte la documentación. Tenga en cuenta que el vinculador puede realizar LTCG en archivos objeto nativos, archivos objeto mixtos nativos/administrados, archivos objeto administrados puros, archivos objeto administrados seguros y .netmodules seguros.

Crearé un programa que consta de dos archivos de código fuente (source1.c y source2.c) y un archivo de encabezado (source2.h). Se muestran los archivos source1.c y source2.c en la figura 1 y la figura 2, respectivamente. El archivo de encabezado, que contiene los prototipos de todas las funciones de source2.c, es bastante sencillo, por lo que no se muestra aquí.

Figura 1 El archivo source1.c

#include <stdio.h> // scanf_s and printf.
#include "Source2.h"
int square(int x) { return x*x; }
main() {
  int n = 5, m;
  scanf_s("%d", &m);
  printf("The square of %d is %d.", n, square(n));
  printf("The square of %d is %d.", m, square(m));
  printf("The cube of %d is %d.", n, cube(n));
  printf("The sum of %d is %d.", n, sum(n));
  printf("The sum of cubes of %d is %d.", n, sumOfCubes(n));
  printf("The %dth prime number is %d.", n, getPrime(n));
}

Figura 2 El archivo source2.c

#include <math.h> // sqrt.
#include <stdbool.h> // bool, true and false.
#include "Source2.h"
int cube(int x) { return x*x*x; }
int sum(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += i;
  return result;
}
int sumOfCubes(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += cube(i);
  return result;
}
static
bool isPrime(int x) {
  for (int i = 2; i <= (int)sqrt(x); ++i) {
    if (x % i == 0) return false;
  }
  return true;
}
int getPrime(int x) {
  int count = 0;
  int candidate = 2;
  while (count != x) {
    if (isPrime(candidate))
      ++count;
  }
  return candidate;
}

El archivo source1.c contiene dos funciones: la función square, que toma un entero y devuelve su cuadrado, y la función main del programa. La función main llama a la función square y a todas las funciones de source2.c excepto isPrime. El archivo source2.c contiene cinco funciones: la función cube devuelve el cubo de un entero dado; la función sum devuelve la suma de todos los enteros del 1 a un entero dado; la función sumOfcubes devuelve la suma de los cubos de todos los enteros del 1 a un entero dado; la función isPrime determina si un entero dado es primo; y la función getPrime, que devuelve el número primo número x. He omitido la comprobación de errores porque no es de interés para este artículo.

El código es simple, pero muy útil. Hay una serie de funciones que realizan cálculos sencillos; algunas requieren bucles for sencillos. La función getPrime es la más compleja porque contiene un bucle while y, dentro del bucle, llama a la función isPrime, que también contiene un bucle. Usaré este código para mostrar una de las optimizaciones del compilador más importantes, conocida como inserción de funciones, y algunas otras optimizaciones.

Compilaré el código con tres configuraciones diferentes y analizaré los resultados para determinar cómo lo ha transformado el compilador. Si desea seguir las indicaciones paso por paso, necesitará el archivo de salida del ensamblador (generado con el modificador del compilador /FA[s]) para examinar el código de ensamblado resultante y el archivo de asignación (generado con el modificador del vinculador /MAP) para determinar las optimizaciones de COMDAT que se han realizado (el vinculador también puede notificar esto si usa los modificadores /verbose:icf y /verbose:ref). Asegúrese de que estos modificadores se especifican en todas las configuraciones que comento a continuación. Además,usaré el compilador de C (/TC) para que sea más fácil examinar el código generado. Sin embargo, todo que comento en este artículo también se aplica al código de C++.

La configuración de depuración

La configuración de depuración se usa principalmente porque todas las optimizaciones de back-end se deshabilitan cuando se especifica el modificador del compilador /Od sin especificar el modificador /GL. Al compilar el código con esta configuración, los archivos objeto resultantes contendrán un código binario que se corresponde exactamente con el código fuente. Puede examinar los archivos de salida del ensamblador resultantes y el archivo de asignación para confirmarlo. Esta configuración es equivalente a la configuración de depuración de Visual Studio.

La configuración de Release de la generación de código en tiempo de compilación

Esta configuración es similar a la configuración de Release en la que las optimizaciones están habilitadas (especificando los modificadores de compilación /O1, /O2 u /Ox), pero sin especificar el modificador del compilador /GL. Con esta configuración, los archivos objeto resultantes contendrán código binario optimizado. Sin embargo, no se realizan optimizaciones a nivel de todo el programa.

Si examina el archivo de lista del ensamblado generado de source1.c, observará que se han realizado dos optimizaciones. En primer lugar, la primera llamada a función square, square(n), en la figura 1 se ha eliminado por completo al evaluar el cálculo en tiempo de compilación. ¿Cómo ha podido ocurrir? El compilador determina que la función square es pequeña, por lo que debe insertarse como inline. Después de la inserción, el compilador determina que el valor de la variable local n se conoce y no cambia entre la instrucción de asignación y la llamada a la función. Por lo tanto, concluye que es seguro ejecutar la multiplicación y sustituye el resultado (25). En la segunda optimización, la segunda llamada a la función square, square(m), también se inserta como inline. Sin embargo, dado que no se conoce el valor m en tiempo de compilación, el compilador no puede evaluar el cálculo, por lo que se emite el código real.

Ahora examinaremos el archivo de lista de ensamblado de source2.c, que es mucho más interesante. La llamada a la función cube en sumOfCubes ha sido insertada como inline. A su vez, esto ha permitido que el compilador realice optimizaciones significativas en el bucle (tal y como se verá en la sección "Optimizaciones de bucle"). Además, el conjunto de instrucciones SSE2 se usa en la función isPrime para convertir de int a double al llamar a la función sqrt, y también para convertir de double a int al volver de sqrt. Y se llama a sqrt solo una vez antes de que comience el bucle. Tenga en cuenta que si no se ha especificado el modificador /arch en el compilador, el compilador x86 usa el SSE2 de forma predeterminada. Los procesadores x86 más implementados, así como todos los procesadores x86-64, admiten SSE2.

La configuración de Release de la generación de código en tiempo de vínculo

La configuración de Release de LTCG es idéntica a la configuración de Release de Visual Studio. Con esta configuración, las optimizaciones están habilitadas y se especifica el modificador de compilador /GL. Este modificador se define implícitamente cuando se usa /O1 u /O2. Indica al compilador que emita archivos objeto CIL en lugar de archivos objeto de ensamblado. De este modo, el vinculador invoca el back-end del compilador para realizar WPO tal y como se ha descrito anteriormente. Ahora analizaremos algunas optimizaciones WPO para mostrar la inmensa ventaja de LTCG. Las listas de código de ensamblado que se han generado con esta configuración están disponibles en línea.

Siempre y cuando se habilite la inclusión de funciones (/Ob, que se activa cada vez que se solicitan optimizaciones), el conmutador /GL permite que el compilador inserte funciones inline definidas en otras unidades de traducción, independientemente de si se ha especificado el modificador del compilador /Gy (que se explica más adelante). El modificador del vinculador /LTCG es opcional y proporciona instrucciones solo para el vinculador.

Si examina el archivo de listas de ensamblado de source1.c, podrá comprobar que todas las llamadas a funciones, salvo scanf_s, están insertadas de forma inline. Como resultado, el compilador ha sido capaz de ejecutar los cálculos de cube, sum y sumOfCubes. Solo la función isPrime no se ha insertado. Sin embargo, si se ha insertado manualmente en getPrime, el compilador aún insertaría getPrime en main.

Como puede ver, la inserción de funciones es importante no solo porque optimice una llamada a una función, sino también porque como resultado permite que el compilador realice muchas otras optimizaciones. La inserción de una función suele mejorar el rendimiento a costa de aumentar el tamaño del código. El uso excesivo de esta optimización conduce a un fenómeno que se denomina código inflado. En cada sitio de llamada, el compilador realiza un análisis de coste y beneficios, y entonces decide si inserta la función.

Por a la importancia que tiene la inserción, el compilador de Visual C++ proporciona mucha más compatibilidad de lo que dicta el estándar con respecto al control de inserción. Puede indicar que el compilador nunca inserte una serie de funciones mediante el uso de la pragma auto_inline. Puede indicar que el compilador nunca inserte una función o un método específicos si los marca con __declspec(noinline). Puede marcar una función con la palabra clave inline para proporcionar una sugerencia para que el compilador inserte la función (aunque el compilador puede optar por omitir esta sugerencia si la inserción implicaría una pérdida neta). La palabra clave inline ha estado disponible desde la primera versión de C++, que se presentó en C99. Puede usar la palabra clave específica de Microsoft __inline en el código de C y C++; es útil cuando se usa una versión antigua de C que no admite esta palabra clave. Además, puede utilizar la palabra clave __forceinline (C y C++) para forzar que el compilador inserte una función siempre que sea posible. Y por último, pero no por ello menos importante, puede indicar que el compilador desdoble una función recursiva a una profundidad específica o indefinida, mediante su inserción con la pragma inline_recursion. Tenga en cuenta que actualmente el compilador no ofrece características que le permitan controlar la inserción en el sitio de llamada en lugar de en la definición de función.

El modificador /Ob0 deshabilita completamente la inserción, que actúa de forma predeterminada. Debería usar este modificador al depurar (se especifica automáticamente en la configuración de depuración de Visual Studio). El modificador /Ob1 indica que el compilador solo tenga en cuenta las funciones de inserción que están marcadas con inline, __inline o __forceinline. El modificador /Ob2, que actúa cuando se especifica /O[1|2|x], indica que el compilador debe considerar cualquier función para la inserción. En mi opinión, el único motivo para usar las palabras clave inline o __inline es para controlar la inserción con el modificador /Ob1.

El compilador no podrá insertar una función en determinadas condiciones. Un ejemplo es al llamar virtualmente a una función virtual; la función no se puede insertar dado que el compilador no puede saber a qué función se va a llamar. Otro ejemplo es cuando se llama a una función a través de un puntero a la función en lugar de usar su nombre. Procure evitar estas condiciones para habilitar la inserción. Consulte la documentación de MSDN para obtener una lista completa de las condiciones.

La inserción de funciones no es la única optimización que es más efectiva cuando se aplica a nivel de todo el programa. De hecho, la mayoría de las optimizaciones son más eficaces en ese nivel. En el resto de esta sección, hablaré sobre una clase específica de estas optimizaciones llamada optimizaciones de COMDAT.

De forma predeterminada, cuando se compila una unidad de traducción, todo el código se almacenará en una única sección en el archivo objeto resultante. El vinculador funciona en el nivel de sección. Es decir, puede quitar secciones, combinar secciones y reorganizar secciones. Esto impide que el vinculador realice tres optimizaciones que puedan reducir significativamente (porcentaje de dos dígitos) el tamaño del archivo ejecutable y mejora su rendimiento. La primera es la eliminación de las funciones y variables globales sin referencia. La segunda es el plegamiento de variables globales constantes y funciones idénticas. La tercera es la reordenación de funciones y variables globales para que las funciones que estén en la misma ruta de ejecución y las variables a las que se tiene acceso conjunto se encuentren ubicadas más cercanas físicamente en la memoria para mejorar la localidad.

Para habilitar estas optimizaciones del vinculador, puede indicar que el compilador empaquete funciones y variables en secciones independientes si especifica los modificadores de compilador /Gy (vinculación en el nivel de función) y /Gw (optimización de datos globales), respectivamente. Estas secciones se denominan COMDAT. También puede marcar una variable de datos globales concreta con __declspec(selectany) para indicar que el compilador empaquete la variable en una COMDAT. Después, mediante la especificación del modificador de vinculador /OPT:REF, el vinculador eliminará las variables globales y funciones sin referencias. Además, si se especifica el modificador /OPT:ICF, el vinculador doblará las variables constantes globales y las funciones idénticas. (ICF significa plegamiento de COMDAT idénticas). Con el modificador de vinculador /ORDER, puede indicar que el vinculador coloque COMDAT en la imagen resultante en un orden específico. Tenga en cuenta que todas estas optimizaciones son optimizaciones del vinculador y no requieren el modificador de compilador /GL. Los modificadores /OPT:REF y /OPT:ICF deben deshabilitarse mientras se depura por razones obvias.

Debería usar /LTCG siempre que sea posible. La única razón para no utilizar /LTCG es porque se desee distribuir el objeto resultante y los archivos de biblioteca. Recuerde que estos archivos contienen código CIL en lugar de código de ensamblado. El código CIL solo pueden usarlo el compilador y el vinculador de la misma versión que lo generó, lo que puede limitar considerablemente la facilidad de uso de los archivos objeto porque los desarrolladores deben tener la misma versión del compilador para utilizar estos archivos. En este caso, a menos que esté dispuesto a distribuir los archivos objeto de cada versión del compilador, debería usar generación de código en tiempo de compilación en su lugar. Además de una facilidad de uso limitada, estos archivos objeto son mucho más grandes que los archivos objeto correspondientes al ensamblador. Sin embargo, tenga en cuenta el enorme beneficio de los archivos objeto CIL, que es permitir WPO.

Optimizaciones de bucle

El compilador de Visual C++ admite varias optimizaciones de bucle, pero analizaré sólo tres: reversión de bucles, vectorización automática y movimiento de código invariable en el bucle. Si modifica el código en la figura 1 para que se pase m a sumOfCubes en lugar de n, el compilador no podrá determinar el valor del parámetro, por lo que debe compilar la función para controlar cualquier argumento. La función resultante está muy optimizada y su tamaño es bastante grande, por lo que el compilador no la insertará.

La compilación del código con el modificador /O1 da como resultado un código de ensamblado que está optimizado para el espacio. En este caso, no se realizarán optimizaciones en la función sumOfCubes. La compilación con el modificador /O2 da como resultado un código optimizado para la velocidad. El tamaño del código será mucho mayor, pero significativamente más rápido, porque se ha revertido y vectorizado el bucle dentro de sumOfCubes. Es importante comprender que la vectorización no sería posible sin la inserción de la función cube. Además, la reversión de bucles no sería tan eficaz sin la inserción. Se muestra una representación gráfica simplificada del código de ensamblado resultante en la figura 3. El gráfico de flujo es el mismo para las arquitecturas x86 y x86-64.

Gráfico de flujo de control de sumOfCubes
Figura 3 Gráfico de flujo de control de sumOfCubes

En la figura 3, el rombo verde es el punto de entrada y los rectángulos rojos son los puntos de salida. Los rombos azules representan las condiciones que se ejecutan como parte de la función sumOfCubes en tiempo de ejecución. Si SSE4 es compatible con el procesador y x es mayor o igual a ocho, las instrucciones de SSE4 se usarán para realizar cuatro multiplicaciones a la vez. El proceso de ejecución de la misma operación simultáneamente en varios valores se denomina vectorización. Además, el compilador revierte el bucle dos veces; es decir, el cuerpo del bucle se repetirá dos veces en cada iteración. El efecto combinado es que se realizarán ocho multiplicaciones en cada iteración. Cuando x es inferior a ocho, se usarán las instrucciones tradicionales para ejecutar el resto de los cálculos. Tenga en cuenta que el compilador ha emitido tres puntos de salida que contienen epílogos separados en la función en lugar de solo uno. Así se reduce el número de saltos.

La reversión de bucles es el proceso de repetir el cuerpo del bucle dentro del bucle para que se ejecute más de una iteración del bucle en una sola iteración del bucle revertido. El motivo por el que esto mejora el rendimiento es que las instrucciones de control de bucles se ejecutarán con menos frecuencia. Y lo que quizás es más importante, podría permitir que el compilador realice muchas otras optimizaciones, como la vectorización. La desventaja de la reversión es que aumenta la presión del registro y el tamaño del código. Sin embargo, según el cuerpo del bucle, podría mejorar el rendimiento en un porcentaje de dos dígitos.

A diferencia de los procesadores x86, todos los procesadores x86-64 son compatibles con SSE2. Además, puede aprovechar los conjuntos de instrucciones AVX/AVX2 de las últimas microarquitecturas x86-64 de Intel y AMD si especifica el modificador /arch. La especificación de AVX2 permite que el compilador también use los conjuntos de instrucciones FMA e IMC.

Actualmente, el compilador de Visual C++ no permite controlar la reversión del bucle. No obstante, puede emular esta técnica mediante plantillas junto con la palabra clave __ forceinline. Puede deshabilitar la vectorización automática en un bucle concreto mediante la pragma de bucle con la opción no_vector.

Al examinar el código de ensamblado generado, los más astutos se darán cuenta de que el código se puede optimizar un poco más. Sin embargo, el compilador ya ha realizado un gran trabajo y no le dedicaremos mucho más tiempo a analizar el código y aplicar optimizaciones menores.

someOfCubes no es la única función cuyo bucle se ha revertido. Si modifica el código para que se pase m a la función sum en lugar de n, el compilador no podrá evaluar la función y, por tanto, debe emitir su código. En este caso, se revertirá el bucle dos veces.

La última optimización que analizaré es el movimiento de código invariable en el bucle. Considere el siguiente fragmento de código:

int sum(int x) {
  int result = 0;
  int count = 0;
  for (int i = 1; i <= x; ++i) {
    ++count;
    result += i;
  }
  printf("%d", count);
  return result;
}

El único cambio aquí es que tengo una variable adicional que se incrementa en cada iteración y, a continuación, se imprime. No es difícil ver que este código puede optimizarse si se traslada el incremento de la variable de contador fuera del bucle. Es decir, sólo puedo asignar x a la variable contador. Esta optimización se denomina movimiento de código invariable en el bucle. La parte del bucle invariante indica claramente que esta técnica solo funciona cuando el código no depende de ninguna de las expresiones del encabezado del bucle.

Aquí viene lo interesante: Si aplica manualmente esta optimización, el código resultante podría presentar un rendimiento reducido en determinadas condiciones. ¿Puede ver por qué? Tenga en cuenta lo que sucede cuando x no es positivo. El bucle nunca se ejecuta, lo que significa que en la versión no optimizada, no se toca el contador de la variable. En cualquier caso, en la versión optimizada manualmente, se ejecuta una asignación innecesaria de x al contador fuera del bucle. Además, si x fuera negativo, el contador contendría el valor incorrecto. Tanto las personas como los compiladores son susceptibles a estos problemas. Afortunadamente, el compilador de Visual C++ es lo suficientemente inteligente para darse cuenta de esto mediante la emisión de la condición del bucle antes de la asignación, lo que resulta en un rendimiento mejorado para todos los valores de x.

En resumen, si no es un compilador ni un experto en optimizaciones de compilador, debería evitar realizar transformaciones manuales en el código solo para que parezca más rápido. Relájese y confié en el compilador para optimizar el código.

Control de las optimizaciones

Además de los modificadores de compilador /O1, /O2 y /Ox, puede controlar las optimizaciones de funciones específicas mediante la pragma optimize, que tiene el siguiente aspecto:

#pragma optimize( "[optimization-list]", {on | off} )

La lista de optimización puede estar vacía o contener uno o varios de los siguientes valores: g, s, t e y. Estos corresponden a los modificadores de compilador /Og, /Os, /Ot y /Oy, respectivamente.

Una lista vacía con el parámetro off hará que todas estas optimizaciones se desactiven, independientemente de los modificadores de compilador que se hayan especificado. Una lista vacía con el parámetro on hará que los modificadores de compilador especificados actúen.

El modificador /Og habilita las optimizaciones globales, que son las que se pueden realizar examinando solo la función que se optimiza, no en cualquiera de las funciones a las que llama. Si LTCG está habilitado, /Og habilita WPO.

La pragma optimize es útil cuando desee que las distintas funciones se optimicen de diferentes maneras: algunas por espacio y otras por velocidad. No obstante, si desea tener ese nivel de control, debería considerar la optimización guiada por perfiles (PGO), que es el proceso de optimización del código mediante un perfil que contenga información de comportamiento registrada mientras se ejecuta una versión instrumentada del código. El compilador usa el perfil para tomar mejores decisiones sobre cómo optimizar el código. Visual Studio proporciona las herramientas necesarias para aplicar esta técnica en código nativo y administrado.

Optimizaciones en .NET

No hay ningún vinculador implicado en el modelo de compilación de .NET. Sin embargo, hay un compilador de código fuente (compilador de C#) y un compilador JIT. El compilador de código fuente realiza únicamente optimizaciones menores. Por ejemplo, no realizar la inserción de funciones y las optimizaciones de bucle. En su lugar, estas optimizaciones las controla el compilador JIT. El compilador JIT que se incluye con todas las versiones de .NET Framework hasta la 4.5 no es compatible con las instrucciones SIMD. Sin embargo, el compilador JIT que se distribuye con .NET Framework 4.5.1 y las versiones posteriores, llamadas RyuJIT, admite SIMD.

¿Cuál es la diferencia entre RyuJIT y Visual C++ en cuanto a las capacidades de optimización? Dado que lleva a cabo su trabajo en tiempo de ejecución, RyuJIT puede realizar optimizaciones que Visual C++ no. Por ejemplo, en tiempo de ejecución, RyuJIT podría ser capaz de determinar que la condición de una instrucción if nunca es verdadera en esta ejecución concreta de la aplicación y, por tanto, se puede optimizar. De igual forma, RyuJIT puede aprovechar las capacidades del procesador en el que se está ejecutando. Por ejemplo, si el procesador admite SSE4.1, el compilador JIT solo emitirá instrucciones SSE4.1 para la función sumOfcubes, por lo que el código generado será mucho más compacto. Sin embargo, no puede pasar mucho tiempo optimizando el código, ya que el tiempo dedicado a la compilación JIT tiene un impacto en el rendimiento de la aplicación. Por otra parte, el compilador de Visual C++ puede dedicar mucho más tiempo a detectar otras oportunidades de optimización y beneficiarse de ellas. Una interesante nueva tecnología de Microsoft, conocida como .NET Native, permite compilar código administrado en ejecutables autocontenidos optimizados mediante el back-end de Visual C++. Actualmente, esta tecnología solo admite aplicaciones de la Tienda Windows.

Actualmente, la capacidad de controlar las optimizaciones de código administrado está limitada. Los compiladores de C# y Visual Basic sólo ofrecen la posibilidad de activar o desactivar las optimizaciones mediante el modificador /optimize. Para controlar las optimizaciones JIT, puede aplicar el atributo System.Runtime.Compiler­Services.MethodImpl en un método con una opción de MethodImplOptions especificada. La opción NoOptimization desactiva las optimizaciones, la opción NoInlining impide que el método se inserte y la opción AggressiveInlining (.NET 4.5) ofrece una recomendación (algo más que una mera sugerencia) para que el compilador JIT inserte el método.

Resumen

Todas las técnicas de optimización descritas en este artículo pueden mejorar significativamente el rendimiento del código en un porcentaje de dos dígitos, y todas ellas son compatibles con el compilador de Visual C++. Lo que hace que estas técnicas sean importantes es que, cuando se aplican, permiten que el compilador realice otras optimizaciones. No se trata en absoluto de una explicación completa sobre las optimizaciones del compilador que realiza por Visual C++. No obstante, espero que le haya ofrecido una valoración de las capacidades del compilador. Visual C++ puede hacer más, mucho más, así que permanezca atento para la segunda parte.


Hadi Brais es un investigador doctor en el Instituto Indio de Tecnología de Delhi (IITD), donde investiga sobre las optimizaciones de compilador para la tecnología de memorias de próxima generación. Dedica la mayor parte de su tiempo a escribir código en C, C++ y C#, y a profundizar en CLR y CRT. Tiene un blog en hadibrais.wordpress.com. Puede ponerse en contacto con él en la dirección de correo electrónico hadi.b@live.com.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Jim Hogg