miércoles, 14 de mayo de 2014

Cómo [no] ayudar al compilador

Siempre les digo a mis alumnos: "el compilador es más vivo que todos nosotros juntos", y la experiencia me ha demostrado que es una buena máxima para respetar a la hora de programar. Me refiero a esos momentos donde programamos algo de cierto modo que no es precisamente el más prolijo ni el más simple, pero imaginamos que hacerlo como dicen los libros sería agregar algún tipo de sobrecarga evitable. Y esto se debe a que imaginamos mal o desconocemos qué va a hacer con eso el compilador.

Por dar un ejemplo simple, he usado mil veces macros de preprocesador (#define func(x) bla...bla) en lugar de funciones para evitar la sobrecarga que significa invocar a una función. Está claro que a nivel ensamblador la llamada a una función, por simple que sea ésta, implica varias instrucciones adicionales (preparar el stack, crear variables locales, pasar los argumentos, recibir el resultado, etc). Pero debería estar igual de claro que considerar eso al codificar es un gran gran error. Es un problema que un buen compilador sabrá resolver mucho mejor que nosotros. En este post quiero dejar algunos tips sobre cosas como estas no hay que hacer y otras  cosas sí, si queremos que lo que hacemos de verdad le sirva al compilador para generar un mejor ejecutable.

Siguiendo entonces con el ejemplo de la macro, lo que realmente ocurre es que los compiladores de C++ saben hacer inlining automático. "inline" para el lenguaje C++ es una palabra clave que se usa antes de una función implementada en un .h (para los métodos está implícito) para indicar que si el linker la ve compilada varias veces (porque se incluyó en varios .cpp), confíe en que es siempre la misma (si no debería dar un error). Pocos saben eso, porque el concepto de "inline" más popular es el que se usa en la jerga del compilador para decir que en una llamada a una función no se compila la llamada como tal, sino que se compila, digamos que como si fuera una macro. Es decir, pegando el contenido de la función en el lugar de la llamada, y evitando así la sobrecarga de la que hablaba antes. Hay una relación entre ambos conceptos, el primero es requisito para aplicar el segundo (al menos hasta que mejoren las técnicas de lto). Cuando compilamos en modo Release (con optimizaciones), el compilador hace esto solo, y cuando él lo cree conveniente. Como él es el que genera el assembler, sabe exáctamente cuanto cuesta una llamada, cuanto cuesta una función, etc, y entonces tiene mucho mejores argumentos que nosotros para decidir cuándo conviene hacerlo y cuando no.

Entonces, un código con funciones es mucho más legible y mantenible y menos propenso a errores que uno con macros, o que uno sin nada de eso. Por ejemplo, hacer un atributo público en una clase para evitar hacer métodos getter por miedo a esta sobrecarga, sabiendo lo del inlining, no tiene sentido. Todo lo que hay que hacer es implementarlos en el .h, y dejar que el compilador se avive. Claro que solo se hace para métodos pequeños, porque tener tooodo en los .h lleva a tiempos de compilación exagerados. Un ejemplo (de tiempos exagerados) es la biblioteca CImg, que consta de un solo header más de 1MB (y tiene sus motivos para ello), con una gran gran clase templatizada. Tarda años en compilar, y no es por lo grande del header en sí, sino por el trabajo del inlining. Desactivar esta optimización al compilar en un ejemplo básico me significó pasar de 40 a 13 segundos de tiempo de compilación (para que vean el esfuerzo que hace el compilador para optimizar).


Otra ejemplo del mismo estilo es lo que se conoce como loop-unrolling o loop-unwinding (en español sería desdoblamiento de ciclos). Supongamos que hay un for de 0 a 2, que inicializa las componentes de un vector. Siendo tan pocas iteraciones muchas veces es más trabajo crear un contador, incrementarlo, compararlo y destruirlo (el trabajo del for), que copiar y pegar tres veces la inicialización (lo que haríamos para evitar el for). Pero no debemos hacerlo, repetir código no es buena idea, esos copy-paste son propensos a errores, y encima si después hay que cambiar cosas hay que hacerlo por triplicado. El compilador sabe lo del for, y si están activas las optimizaciones decide con su criterio (que de nuevo es más informado que el nuestro), cuando conviene compilar como si fuera un copy-paste, y cuando utilizar verdaderamente el contador. De igual forma, el compilador decide cuando una variable se usa tan poco que ni la guarda en memoria (queda en un registro), cuando una expresión se puede precalcular en tiempo de compilación, cuando hacer trucos como un desplazamiento de bits en lugar de una división/multiplicación por 2, cuando intercambiar el orden de dos instrucciones que no dependen una de la otra para mejorar el uso de cache, o cuando una copia se puede evitar, etc. A esta altura de la vida, conoce y puede aplicar todos esos trucos y muchos más automágicamente. Hasta puede por ejemplo decidir saltearse algunas líneas porque no tienen efecto en la salida del programa (un cálculo cuyo resultado no se usa para nada). Así que dejemos de ensuciar el código con operaciones crípticas que no aportan y confiemos más en el trabajo de los humanoides que desarrollan los compiladores.

Que C++ utilice tipado estático suele ser marcado como una desventaja frente a lenguaje más flexibles o de más alto nivel. Pero, a la luz de estas consideraciones es una gran gran ventaja también, tanto para la velocidad del ejecutable compilado, como para la seguridad de nuestro código y la detección temprana de errores (en tiempo de compilación). Un ejemplo de primera mano es el que da el mismísimo Stroustroup en esta charla comparando el qsort de C con el sort de C++ sobre un std::vector. Muestra que a pesar de los niveles de abstracción que agregan sort y vector, y las muchas llamadas a sobrecargas de operadores, bien optimizado (por el compilador) es mucho más rápido que la versión C. Siguiendo con esa línea, ahora uso mucho más que antes los templates y la sobrecarga de funciones/operadores. Porque los templates al especializarse le brindan mucha información de tipos específica al compilador para optimizar. Pero además porque la especialización, tanto como la sobrecarga, son mecanismos que permiten tomar decisiones en tiempo de compilación, con cero costo en la ejecución (por ejemplo, para reemplazar el uso de macros). Y encima los variadic-templates que agregaron en C++11 permiten hacer muchos más trucos que antes, pero eso será tema para otro post. Estas son cosas que creo que sí conviene hacer para "ayudar" al compilador.

Resumiendo, algunos consejos iniciales:
 - No quieran hacer optimizaciones manuales pensando en la compilación.
 - Conozcan su compilador, denle toda la información que puedan y confíen en él.
 - Sigan un buen estilo de codificación y exploten al máximo el tipado estático.
 - No tengan miedo de agregar funciones, clases, o wrappers para hacer más claro el código o dividir mejor el problema.
 - Escriban los métodos muy simples y muy usados de forma "inline".
 - Eviten usar macros de preprocesador. Piensen alternativas, y reconsideren el uso templates.
 - Las herramientas de análisis estático pueden ayudarlos en algunos casos sugiriendo buenas prácticas.

No puedo dejar de advertir que el contenido de este post fue muy muy básico para lo que prometía su título (por ejemplo, aquí hay otros casos más particulares, y aquí algunos contraejemplos con detalles muy finos). Pero son tips que llevan a un código más prolijo, fácil de escribir y de leer, y a no detenernos en detalles irrelevantes para la lógica del algoritmo. Para el 99% de los programadores, creo que son buenos consejos. He visto tanto principiantes como gente con mucha experiencia intentando ayudar al compilador de formas equivocadas o innecesarias. Yo lo he hecho mil veces, y no se si alguna vez vi resultados suficientes como para justificarlo. Ahora ya no tengo dudas de que GCC es mucho más astuto que yo, así que mejor me preocupo por escribir código "bonito" y le dejo lo de los trucos sucios a él.

6 comentarios:

  1. Completamente de acuerdo con tu apreciación.
    Aunque depende de la aplicación y el nivel de necesidad de velocidad de respesta, en estos tiempos con dispositivos con tantos nucleos y tanta ram, lo principal como desarroldores (y docentes de futuros programadores) es saber que lo más importante es escribir el código lo más claro y "limpio" posible. Ya habrá quien se encargue de optimizar en el futuro (ya sea el compilador, el mismo hardware o tal vez el programador en alguna revisión si realmente es necesario)
    En cuanto a lo de "Que C++ sea 'tan' fuertemente tipado" creo que se confunden los conceptos de "tipado Estático" y "tipado fuerte".
    http://latecladeescape.com/t/Lenguajes+fuertemente,+d%C3%A9bilmente,+est%C3%A1ticamente+y+din%C3%A1micamente+tipados

    C++ es de tipado estático, pero no es tan fuerte como parece:

    #include
    using namespace std;
    int main(int argc, char *argv[]) {
    char letra = 'A';
    int numero = 1;
    int suma = letra + numero;
    cout<<suma;
    return 0;
    }

    Compila sin errores!!

    ResponderEliminar
    Respuestas
    1. Gracias por la corrección, ya lo modifiqué. Las dos cosas (estático y fuerte) son importantes. Aunque la primera es obligada en C++, mientras que la segunda es opcional en muchos casos. El compilador tiene sus libertades, especialmente para los tipos de datos fundamentales, pero se pueden limitar en las clases que uno hace, por ejemplo con el uso de explicit en un constructor.

      Eliminar
  2. Buenos dias inicio recien con Zinjai, lo instale en una computadora con Ubuntu 14.04 y sin problemas, sin embargo trato de instalarlo en otra tambien con Ubuntu 14.04 y me indica error pues un gzip truncado, que puedo hacer? llevo 2 dias tratando pero como que hay algo roto, lo he bajado varias veces del sitio pero no

    ResponderEliminar
    Respuestas
    1. Si el mismo archivo en una pc se puede descomprimir sin problemas y en la otra no, será problema del descompresor de la segunda, ya que la primera demuestra que el archivo está completo.

      Por otro lado, si intentaste volver a bajar el archivo en la segunda, habría que ver si se bajó completo (no se por qué, pero me ha pasado más de una vez que algún navegador me dice que la descarga finalizó con normalidad y en realidad el archivo queda truncado). Probá hacer "md5sum " en la consola en ambas pcs para verificar si es el mismo archivo (debe dar el mismo número).

      Finalmente, agradecería que estas preguntas las hagan en el foro que existe para tal fin y no en los comentarios de un post que no tiene mucho que ver con el tema, donde sugiere el comentario de Bodega.

      Eliminar
    2. En el comentario anterior el formateado me comió parte del comando... luego de md5sum va el nombre del tgz.

      Eliminar
  3. Anónimo: http://sourceforge.net/p/zinjai/discussion/

    ResponderEliminar