Compiladores

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

Hadi Brais

Descargar el código de muestra

Bienvenido a la segunda parte de mi serie sobre optimizaciones del compilador. En el primer artículo (msdn.microsoft.com/magazine/dn904673), analicé la inclusión de funciones, la reversión de bucles, el movimiento de código invariable en el bucle, la vectorización automática y las optimizaciones de COMDAT. En este segundo artículo, voy a mirar otras dos optimizaciones: la asignación de registros y la programación de instrucciones. Como siempre, me centraré en el compilador de Visual C++, con una breve exposición acerca de cómo funcionan las cosas en Microsoft .NET Framework. Usaré Visual Studio 2013 para compilar el código. Comencemos.

Asignación de registros

La asignación de registros es el proceso de asignar un conjunto de variables para los registros disponibles de manera que no tengan que estar asignados en memoria. Este proceso se realiza normalmente en el nivel de una función completa. Sin embargo, especialmente cuando la Generación de código en tiempo de vínculo (/ LTCG) está habilitada, el proceso se puede llevar a cabo a través de funciones, lo que puede generar una asignación más eficaz. (En esta sección, todas las variables son automáticas —aquellas cuya duración se determina sintácticamente— a menos que se mencione lo contrario.)

La asignación de registros es una optimización especialmente importante. Para comprenderlo, veamos cuánto se tarda en acceder a los distintos niveles de memoria. El acceso a un registro lleva menos tiempo que un ciclo de procesador. El acceso a la memoria caché es algo más lento y tarda de unos pocos ciclos a decenas de ellos. El acceso a la memoria DRAM (remota) es incluso más lento. Por último, el acceso a la unidad de disco duro es muy lento y puede tardar millones de ciclos. Además, un acceso a memoria aumenta el tráfico a cachés compartidas y a la memoria principal. La asignación de registros reduce el número de accesos a memoria mediante el uso de los registros disponibles tanto como sea posible.

El compilador intenta asignar un registro a cada variable, idealmente hasta que se ejecutan todas las instrucciones que implican a esa variable. Si esto no es posible, lo que es común por razones que abordaré en breve, tienen que volcarse una o más variables en la memoria, por lo que deben cargarse y almacenarse con frecuencia. La presión de registro hace referencia al número de registros que se han derramado debido a la falta de disponibilidad de los registros. La mayor presión de registro implica más accesos a memoria y más accesos a memoria pueden ralentizar no solo el propio programa, sino que puede llevar a todo el sistema a un rastreo.

Los procesadores x86 modernos ofrecen que se asignen los siguientes registros por los compiladores: ocho registros de propósito general de 32 bits, ocho registros de punto flotante de 80 bits y ocho registros vectoriales de 128 bits. Todos los procesadores x64 ofrecen 16 registros de uso general de 64 bits, ocho registros de punto flotante de 80 bits y al menos 16 registros vectoriales, cada uno de ellos con un ancho de al menos 128 bits. Los procesadores ARM de 32 bits modernos ofrecen 15 registros de uso general de 32 bits y 32 registros de punto flotante de 64 bits. Todos los procesadores ARM de 64 bits ofrecen 31 registros de uso general de 64 bits, 32 registros de punto flotante de 128 bits y 16 registros vectoriales de 128 bits (NEON). Todos ellos están disponibles para la asignación de registros (y también puede agregar a la lista los registros ofrecidos por la tarjeta gráfica). Cuando una variable local no se puede asignar a ninguno de los registros disponibles, debe estar asignada en la pila. Esto ocurre, como analizaremos, en casi todas las funciones por diversas razones. Observe, por ejemplo, el programa de la Figura 1. El programa no hace nada significativo, pero sirve como un buen ejemplo para demostrar la asignación de registros.

Figura 1 Programa de ejemplo de asignación de registros

#include <stdio.h>
int main() {
  int n = 0, m;
  scanf_s("%d", &m);
  for (int i = 0; i < m; ++i){
    n += i;
  }
  for (int j = 0; j < m; ++j){
    n += j;
  }
  printf("%d", n);
  return 0;
}

Antes de asignar los registros disponibles a las variables, el compilador analiza primero el uso de todas las variables declaradas dentro de la función (o en las funciones en el caso de /LTCG) para determinar qué conjuntos de variables están activas al mismo tiempo y calcula el número de veces que se tiene acceso a cada variable. Se pueden asignar dos variables de conjuntos diferentes al mismo registro. Si no hay ningún registro adecuado para algunas variables del mismo conjunto, estas variables se tienen que derramar. El compilador trata de elegir las variables de menor acceso que se van a derramar en un intento de reducir el número total de accesos a memoria. Esa es la idea general. Sin embargo, hay muchos casos especiales en los que es posible encontrar una mejor asignación. Los compiladores modernos son capaces de idear una asignación válida, pero no la óptima. Sin embargo, resulta muy difícil para un mortal hacerlo mejor.

Teniendo esto en cuenta, voy a compilar el programa en la Figura 1 con las optimizaciones habilitadas y voy a ver la manera en que el compilador asignará las variables locales a los registros. Hay cuatro variables para asignar: n, m, i y j. Supondré que el destino en este caso es la plataforma x86. Al examinar el código de ensamblado generado (/ FA), me doy cuenta de que la variable n se ha asignado al registro ESI, la variable m se ha asignado a ECX y tanto i como j se han asignado a EAX. Observe cómo el compilador ha reutilizado inteligentemente EAX para dos variables porque sus duraciones no se cruzan. Observe también que el compilador ha reservado un espacio en la pila para m porque se ha usado su dirección. En la plataforma x64, se asignará la variable n al registro EDI, la variable m se asignará a EDX, i a EAX y j a EBX. Por algún motivo, el compilador no asignó i y j al mismo registro en esta ocasión.

¿Fue esa una asignación óptima? No. El problema radica en el uso de ESI y EDI. Estos registros son registros guardados por destinatarios, lo que significa que la función llamada tiene que asegurarse de que los valores que esos registros contienen al salir son los mismos en la entrada. Por eso el compilador tuvo que emitir una instrucción en la entrada de la función para insertar ESI o EDI en la pila y otra instrucción en la salida para que aparecieran desde la pila. El compilador podría haber evitado esto en ambas plataformas utilizando un registro guardado por el autor de la llamada, como EDX. Esas deficiencias en el algoritmo de asignación de registros se pueden mitigar mediante la inclusión de funciones. Muchas otras optimizaciones pueden representar el código dispuesto en una asignación de registros más eficaz, como la eliminación de código muerto, la eliminación de subexpresiones comunes y la programación de instrucciones.

En realidad es común que las variables tengan duraciones independientes, por lo que la asignación del mismo registro a todos ellos es muy económica. Pero ¿qué ocurre si se queda sin registros para acomodar a cualquiera de ellos? Tiene que derramarlos. Sin embargo, puede hacerlo de una manera inteligente. Derrame todos ellos en la misma ubicación de la pila. Esta optimización se denomina empaquetado de pila y es compatible con Visual C++. El empaquetado de pila reduce el tamaño del marco de la pila y puede mejorar la proporción de aciertos de la caché de datos, lo que da lugar a un mejor rendimiento.

Lamentablemente, las cosas no son tan sencillas. Desde una perspectiva teórica, se puede lograr la asignación de registros (casi) óptima. Sin embargo, en la práctica, hay muchas razones por las que puede que esto no sea posible:

  • Los registros disponibles en las plataformas x86 y x64 (mencionadas anteriormente) y cualquier otra plataforma moderna (como ARM) no se pueden usar de manera arbitraria. Hay restricciones complejas. Cada instrucción impone restricciones respecto a qué registros se pueden usar como operandos. Por lo tanto, si desea usar una instrucción, debe usar los registros permitidos para pasarle los operandos requeridos. Asimismo, los resultados de algunas instrucciones se almacenan en los registros predeterminados cuyos valores las instrucciones suponen que son volátiles. Podría haber una secuencia diferente de instrucciones que realizan el mismo cálculo pero que le permite realizar una asignación de registros más eficaz. Los problemas de la selección de instrucciones, la programación de instrucciones y la asignación de registros son espantosamente enredados.
  • No todas las variables son de tipos primitivos. No es raro tener matrices y estructuras automáticas. Dichas variables no se pueden considerar directamente para la asignación de registros. Sin embargo, se pueden asignar discretamente a registros. Los compiladores actuales todavía no son tan buenos.
  • La convención de llamada de una función impone una asignación fija para algunos argumentos mientras que representan otros no válidos para asignación, independientemente de la disponibilidad de los registros. Hablaré más sobre este problema más adelante. Además, las nociones de registros guardados por llamador y guardados por destinatario complican más las cosas.
  • Si se ha usado la dirección de una variable, es mejor que la variable esté almacenada en una ubicación que tenga una dirección. Un registro no tiene una dirección, por lo que se tiene que almacenar en memoria si está disponible o no.

Todo esto puede hacerle pensar que los compiladores actuales son terribles en la asignación de registros. Sin embargo, son razonablemente buenos en eso y continúan mejorando, muy lentamente. ¿Además, puede imaginarse escribiendo código de ensamblado mientras reflexiona acerca de todo esto?

Puede ayudar al compilador a encontrar potencialmente una mejor asignación habilitando /LTCG cuando tenga como destino arquitecturas x86. Si especifica el conmutador de compilador /GL, los archivos OBJ generados contendrán código de lenguaje intermedio C (CIL) en lugar de código de ensamblado. Las convenciones de llamada de función no están incluidas en el código CIL. Si no se ha definido una función concreta para exportarla desde el ejecutable de salida, el compilador puede infringir su convención de llamada para mejorar su rendimiento. Esto es posible porque puede identificar todos los sitios de llamadas de la función. Visual C++ aprovecha esto haciendo que todos los argumentos de la función sean elegibles para la asignación de registros independientemente de la convención de llamada. Aunque la asignación de registros no se pueda mejorar, el compilador intentará reordenar parámetros para una alineación más económica e incluso quitar parámetros sin usar. Sin el conmutador /GL, los archivos OBJ resultantes contienen código binario en el que ya se han considerado convenciones de llamada. Si un archivo OBJ de ensamblado tiene un sitio de llamada a una función en un archivo OBJ de CIL, o si se toma la dirección de la función en cualquier lugar o si es virtual, el compilador ya no podrá optimizar su convención de llamada. Sin /LTCG, de forma predeterminada, todas las funciones y métodos tienen vinculación externa, por lo que el compilador no puede aplicar esta técnica. Sin embargo, si una función en un archivo OBJ se ha definido explícitamente con vinculación interna, el compilador puede aplicarle esta técnica, pero solo dentro de un archivo OBJ. Esta técnica, a la que se hace referencia en la documentación como una convención de llamada personalizada, es importante cuando se tienen como objetivo arquitecturas x86 porque la convención de llamada predeterminada, es decir, __cdecl, no es eficaz. Por otra parte, la convención de llamada __fastcall en la arquitectura x64 es muy eficaz porque los cuatro primeros argumentos se pasan a través de registros. Por este motivo, la convención de llamada personalizada solo se realiza cuando se tiene como objetivo x86.

Tenga en cuenta que aunque /LTCG está habilitada, la convención de llamada de un método o función exportada no se puede infringir porque es imposible que el compilador encuentre todos los sitios de llamada, al igual que en todos los casos mencionados anteriormente.

La eficacia de la asignación de registros depende de la precisión del número estimado de accesos a las variables. La mayoría de las funciones contienen instrucciones condicionales, lo que pone en peligro la precisión de estos cálculos. La optimización guiada por perfiles puede usarse para ajustar estas estimaciones.

Cuando /LTCG está habilitada y la plataforma de destino es x64, el compilador lleva a cabo la asignación de registros entre procedimientos. Esto significa que tendrá en cuenta las variables declaradas dentro de una cadena de funciones y que intentará buscar una mejor asignación según las restricciones impuestas por el código de cada función. De lo contrario, el compilador realiza la asignación de registros global en la que cada función se procesa por separado ("global" aquí se refiere a la función completa).

Tanto C como C++ ofrecen la palabra clave register, lo que permite al programador proporcionar una sugerencia al compilador con respecto a qué variables almacenar en los registros. De hecho, la primera versión de C introdujo esta palabra clave y fue útil en ese momento (alrededor de 1972) porque nadie sabía cómo realizar la asignación de registros de manera eficaz. (Sin embargo, IBM Corp. desarrolló un compilador FORTRAN IV a finales de la década de los 60 para que la serie S/360 pudiera realizar la asignación de registros simple. La mayoría de los modelos S/360 ofrecían 16 registros de uso general de 32 bits y cuatro registros de punto flotante de 64 bits.) Además, al igual que con muchas otras características de C, la palabra clave register facilita la creación de compiladores de C. Casi una década después, se creó C++ y ofrecía la palabra clave register porque C se consideraba un subconjunto de C++. (Lamentablemente, hay muchas diferencias sutiles.) Desde los primeros años de la década de los 80, se han implementado muchos algoritmos de asignación de registros efectivos, por lo que la existencia de la palabra clave ha creado mucha confusión a día de hoy. La mayoría de los lenguajes de producción que se han creado desde entonces no ofrecen esta palabra clave (incluidos C# y Visual Basic). Esta palabra clave ha estado en desuso desde C ++ 11, pero no en la versión más reciente de C, C11. Esta palabra clave se debe usar solo para escribir bancos de pruebas. El compilador de Visual C++ respeta esta palabra clave, si es posible. C no permite que se use la dirección de una variable de registro. Sin embargo, C++ la permite pero el compilador debe almacenar la variable en una ubicación direccionable en lugar de en un registro, infringiendo su clase de almacenamiento especificada manualmente.

Cuando se tenga como destino el CLR, el compilador tiene que emitir código de Lenguaje intermedio común (CIL) que modela un equipo de la pila. En este caso, el compilador no realizará la asignación de registros (aunque, por supuesto, si el código emitido es nativo, se realizará la asignación de registro en él) y lo pospondrá hasta el tiempo de ejecución que se va a realizar por el compilador just-in-time (JIT) (o el back-end de Visual C++ en el caso de compilación nativa .NET). RyuJIT, el compilador de JIT que se incluye con .NET Framework 4.5.1 y versiones posteriores, implementa un algoritmo de asignación de registros bastante decente.

Programación de instrucciones

La asignación de registros y la programación de instrucciones se encuentran entre las últimas optimizaciones llevadas a cabo por el compilador antes de que emita el binario.

Todas las instrucciones excepto las más sencillas se ejecutan en varias fases, donde cada fase se controla mediante una unidad específica del procesador. Para utilizar todas estas unidades tanto como sea posible, el procesador emite varias instrucciones de forma canalizada de manera que diferentes instrucciones se ejecutan en diferentes fases al mismo tiempo. Esto puede mejorar significativamente el rendimiento. Sin embargo, si una de estas instrucciones no está lista para su ejecución por alguna razón, se detiene toda la canalización. Esto puede deberse a muchos motivos, incluidos la espera de otra instrucción para confirmar su resultado; la espera de datos procedentes de la memoria o el disco; o la espera a que se complete una operación de E/S.

La programación de instrucciones es una técnica que puede mitigar este problema. Hay dos tipos de programación de instrucciones:

  • Basada en el compilador: el compilador analiza las instrucciones de una función para determinar las instrucciones que pueden detener la canalización. A continuación, intenta encontrar un orden diferente de las instrucciones para minimizar el costo de las pausas esperadas a la vez que conserva la corrección del programa. Esto se denomina reordenación de instrucciones.
  • Basada en hardware: la mayoría de los procesadores x86, x64 y ARM modernos pueden mirar hacia delante en la secuencia de instrucciones (micro-operaciones, para ser precisos) y emitir esas instrucciones cuyos operandos y la unidad funcional necesaria están disponibles para su ejecución. Esto se denomina ejecución dinámica o fuera de secuencia (OoOE o 3OE). El resultado es que el programa se está ejecutando en un orden diferente del original.

Hay otros motivos que podrían hacer que el compilador reordenara determinadas instrucciones. Por ejemplo, el compilador podría reordenar bucles anidados para que el código muestre la mejor ubicación de referencia (esta optimización se denomina intercambio de bucles). Otro ejemplo es reducir los costos de derrame de registros realizando instrucciones que usen el mismo valor que se carga de la memoria consecutiva para que el valor se cargue una vez. Sin embargo, otro ejemplo es reducir los errores de caché de instrucciones y datos.

Como programador, no tiene que saber cómo un compilador o un procesador lleva a cabo la programación de instrucciones. Sin embargo, debe conocer las ramificaciones de esta técnica y cómo controlarlas.

Mientras que la programación de instrucciones conserva la corrección de la mayoría de los programas, puede producir algunos resultados no intuitivos y sorprendentes. En la Figura 2 se muestra un ejemplo donde la programación de instrucciones hace que el compilador emita código incorrecto. Para verlo, compile el programa como código C (/TC) en el modo Release. Puede establecer la plataforma de destino en x86 o x64. Dado que va a examinar el código de ensamblado resultante, especifique /FA para que el compilador emita una lista de ensamblados.

Figura 2 Programa de ejemplo de programación de instrucciones

#include <stdio.h>
#include <time.h>
__declspec(noinline) int compute(){
  /* Some code here */
  return 0;
}
int main() {
  time_t t0 = clock();
  /* Target location */
  int result = compute();
  time_t t1 = clock(); /* Function call to be moved */
  printf("Result (%d) computed in %lld ticks.", result, t1 - t0);
  return 0;
}

En este programa, quiero medir el tiempo de ejecución de la función de cálculo. Para ello, normalmente encapsulo la llamada a la función mediante llamadas a una función de control de tiempo, como el reloj. A continuación, calculando la diferencia en los valores del reloj, obtengo una estimación del tiempo que tardó la función en ejecutarse. Tenga en cuenta que el propósito de este código no es mostrarle la mejor forma de medir el rendimiento de algún fragmento de código, sino demostrar los peligros de la programación de instrucciones.

Dado que esto es código C y que el programa es muy sencillo, es fácil comprender el código de ensamblado resultante. Si examina el código de ensamblado y se centra en las instrucciones de llamada, observará que la segunda llamada a la función de reloj precede a la llamada a la función de cálculo (se ha movido a la "ubicación de destino"), haciendo que la medición esté completamente equivocada.

Tenga en cuenta que esta reordenación no infringe los requisitos mínimos impuestos por la norma sobre cumplir las implementaciones, por lo que es legal.

Pero, ¿por qué el compilador hace eso? El compilador pensaba que la segunda llamada al reloj no dependía de la llamada para calcular (de hecho, para el compilador, estas funciones no se afectan entre sí en absoluto). Además, después de la primera llamada al reloj, es probable que la caché de instrucciones contenga algunas de las instrucciones de esa función y que la caché de datos contenga algunos de los datos requeridos por estas instrucciones. El proceso de llamadas puede hacer que se sobrescriban estas instrucciones y datos, por lo que el compilador reordenó el código en consecuencia.

El compilador de Visual C++ no ofrece un conmutador para desactivar la programación de instrucciones a la vez que se mantienen todas las demás optimizaciones. Además, este problema puede producirse debido a la ejecución dinámica si la función COMPUTE estaba insertada. En función de cómo vaya la ejecución de la función de cálculo y con que anticipación puede ver un procesador, un procesador 3OE podría decidir comenzar la ejecución de la segunda llamada al reloj antes de que se complete la función COMPUTE. Al igual que con el compilador, la mayoría de los procesadores no le permiten desactivar la ejecución dinámica. Pero para ser justos, es muy poco probable que este problema se produzca debido a la ejecución dinámica. ¿De todas formas, cómo podría saber si se ha producido?

El compilador de Visual C++ realmente tiene mucho cuidado al realizar esta optimización. Es tan cuidadoso que hay muchas cosas que le impiden que reordene una instrucción (por ejemplo, una instrucción de llamada). He observado las siguientes situaciones que hacían que el compilador no moviera la llamada de la función de reloj a una ubicación concreta (la ubicación de destino):

  • Llamada a una función importada desde cualquiera de las funciones a las que se llama entre la ubicación de la llamada de función y la ubicación de destino. Como se muestra en este código, llamar a cualquier función importada desde la función COMPUTE hace que el compilador no mueva la segunda llamada al reloj:
__declspec(noinline) int compute(){
  int x;
  scanf_s("%d", &x); /* Calling an imported function */
  return x;
}
  • Llamada a una función importada entre la llamada para calcular y la segunda llamada al reloj:
int main() {
  time_t t0 = clock();
  int result = compute();
  printf("%d", result); /* Calling an imported function */
  time_t t1 = clock();
  printf("Result (%d) computed in %lld.", result, t1 - t0);
  return 0;
}
  • Acceso a cualquier variable global o estática desde cualquiera de las funciones a las que se está llamando entre la ubicación de la llamada de función y la ubicación de destino. Esto contiene si la variable se está leyendo o escribiendo. Lo siguiente muestra que el acceso a una variable global de la función de cálculo hace que el compilador no mueva la segunda llamada al reloj:
int x = 0;
__declspec(noinline) int compute(){
  return x;
}
  • Marcado de t1 como volátil.

Hay otras situaciones que impiden que el compilador reordene instrucciones. Se trata de la regla as-if de C++, que indica que el compilador puede transformar un programa que no contenga operaciones sin definir de cualquier forma que le guste siempre que se garantice que el comportamiento observable del código sigue siendo el mismo. Visual C++ no sólo se adhiere a esta regla, sino que también es mucho más conservador para reducir el tiempo necesario para compilar el código. Una función importada puede producir efectos secundarios. Las funciones de E/S de biblioteca y el acceso a variables volátiles producen efectos secundarios.

Volatile, Restrict y /favor

Calificar una variable con la palabra clave volatile afecta tanto a la asignación de registros como a la reordenación de instrucciones. En primer lugar, la variable no se asignará a ningún registro. (La mayoría de las instrucciones requieren que algunos de sus operandos se almacenen en registros, lo que significa que la variable se cargará en un registro, pero solo para ejecutar algunas de las instrucciones que usen esa variable). Es decir, leer o escribir en la variable siempre provocará un acceso a memoria. En segundo lugar, escribir en una variable volátil tiene semántica de Release, lo que significa que todos los accesos a memoria que se producen sintácticamente antes de la escritura a esa variable se producirán antes. En tercer lugar, la lectura de una variable volátil tiene semántica de Acquire, lo que significa que todos los accesos a memoria que se producen sintácticamente después de la lectura desde esa variable se realizarán después. Pero hay un problema: Estas garantías de reordenación solo se ofrecen especificando el conmutador /volatile:ms. En cambio, el conmutador /volatile:iso indica al compilador que cumpla con la norma de lenguaje, que no ofrece ninguna garantía de este tipo a través de esta palabra clave. Para ARM, /volatile:iso surte efecto de forma predeterminada. Para otras arquitecturas, el valor predeterminado es /volatile:ms. Antes de C++ 11, el conmutador /volatile:ms era útil porque la norma no ofrecía ninguna acción para programas multiproceso. Sin embargo, comenzando con C11/C++11, el uso de /volatile:ms hace que el código sea no portátil y no es recomendable; debería utilizar tipos atómicos en su lugar. Merece la pena tener en cuenta que si el programa funciona correctamente en /volatile:iso, funcionará correctamente bajo /volatile:ms. Sin embargo, es más importante, que si funciona correctamente en /volatile:ms puede que no funcione correctamente en /volatile:iso porque el primero proporciona mayores garantías que el último.

El conmutador /volatile:ms implementa la semántica de Acquire y Release. No es suficiente mantener estos elementos en tiempo de compilación; (en función de la plataforma de destino) el compilador puede emitir instrucciones adicionales (por ejemplo, mfence y xchg) para indicar a un procesador de 3OE que mantenga esta semántica mientras se ejecuta el código. Por tanto, las variables volátiles degradan el rendimiento no solo porque las variables no se almacenan en caché en registros, sino también debido a las instrucciones adicionales que se emiten.

La semántica de la palabra clave volatile según la especificación de lenguaje C# es similar a la ofrecida por el compilador de Visual C++ con el conmutador /volatile:ms especificado. Sin embargo, hay una diferencia. La palabra clave volatile en C# implementa la semántica de Acq/Rel coherente secuencialmente (SC), mientras que volatile de C o C++ en /volatile:ms implementa la semántica pura de Acq/Rel. Recuerde que volatile de C/C++ en /volatile:iso no tiene ninguna semántica de Acq/Rel. Los detalles están fuera del ámbito de este artículo. En general, los límites de memoria pueden impedir que el compilador realice muchas optimizaciones entre ellos.

Es muy importante comprender que si el compilador no ofrece dichas garantías en primer lugar, cualquier garantía correspondiente ofrecida por el procesador será nula automáticamente.

La palabra clave __restrict (o restrict) también afecta a la eficacia tanto de la asignación de registros como de la programación de instrucciones. Sin embargo, a diferencia de volatile, restrict puede mejorar considerablemente estas optimizaciones. Una variable de puntero marcada con esta palabra clave en un ámbito indica que no hay ninguna otra variable que apunte al mismo objeto, creado fuera del ámbito y usado para modificarlo. Esta palabra clave también puede permitir que el compilador realice muchas optimizaciones en punteros, incluyendo con confianza optimizaciones de bucles y vectorización automática; además, reduce el tamaño del código generado. Puede pensar en la palabra clave restrict como arma antioptimización, de alta tecnología y de alto secreto. Merece un artículo completo por sí misma; por lo tanto, no se tratará aquí.

Si una variable se marca tanto con volatile como con __restrict, la palabra clave volatile tendrá prioridad al tomar decisiones acerca de cómo optimizar el código. De hecho, el compilador puede ignorar totalmente restrict, pero debe respetar volatile.

El conmutador /favor podría permitir que el compilador realizara la programación de instrucciones que se ajusta a la arquitectura especificada. También puede reducir el tamaño del código generado porque el compilador podría tener la capacidad de no emitir instrucciones que comprueban si el procesador admite una característica específica. Esto lleva a su vez una proporción de aciertos de caché de instrucciones mejorada y a un mejor rendimiento. El valor predeterminado es/favor:blend, lo que produce código con buen rendimiento entre los procesadores x86 y x64 de Intel Corp. y AMD.

Resumen

He analizado dos optimizaciones importantes realizadas por el compilador de Visual C++: asignación de registros y programación de instrucciones.

La asignación de registros es la optimización más importante realizada por el compilador, porque el acceso a un registro es mucho más rápido que el acceso incluso a la caché. La programación de instrucciones también es importante. Sin embargo, los procesadores recientes tienen excelentes capacidades de ejecución dinámica, lo que hace que la programación de instrucciones sea menos significativa que antes. Aún así, el compilador puede ver todas las instrucciones de una función, independientemente de su tamaño, mientras que un procesador solo puede ver un número limitado de instrucciones. Además, el hardware de ejecución fuera de secuencia tiene muchas ansias de poder ya que siempre funciona en tanto que el núcleo esté funcionando. Además, los procesadores x86 y x64 implementan un modelo de memoria más robusto que el modelo de memoria de C11/C++11 e impide determinada reordenación de instrucciones que podría mejorar el rendimiento. Por lo tanto, la programación de instrucciones basada en el compilador es todavía muy importante para dispositivos de energía limitada.

Varias palabras clave y conmutadores de compilador pueden afectar al rendimiento, ya sea positiva o negativamente, así que asegúrese de usarlos correctamente para garantizar que el código se ejecuta tan rápido como sea posible y que produce resultados correctos. Todavía hay muchas más optimizaciones de las que hablar; permanezca atento.


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 por su ayuda en la revisión de este artículo: Jim Hogg (equipo de Microsoft Visual C++)