domingo, 28 de septiembre de 2014

Sobre templates y tiempos de compilación

Cualquiera que utilice frecuentemente clases genéricas en C++ sabe que el uso de templates puede aumentar considerablemente los tiempos de compilación de un proyecto, dado que una clase/función templatizada no puede compilarse en un .cpp separado del .cpp que la usa, sino que debe estar completamente definida y declarada en el .h... ¿o no?. Siempre uso como ejemplo extremo la biblioteca CImg, que consta de un archivo .h de alrededor de 2MB de código fuente, 99% genérico, casi todo directa o indirectamente asociado a su clase principal (CImg). Esto hace que compilar un ejemplo muy pequeño tarde unos 25 segundos (en un I7-870), tiempo que aumenta notablemente cuando el ejemplo deja de ser tan pequeño. En este artículo les cuento un truco que voy a incluir en la plantilla del complemento para ZinjaI de CImg, que permite bajar esos 25 segundos a alrededor de 9. Es decir, menos de la mitad. Y esta diferencia será mucho más grande cuando el ejemplo se complique.

Veamos el ejemplo simple, un archivo main.cpp como el que sigue:
    #include <CImg.h>
    using namespace cimg_library:
    int main (int argc, char **argv[]) {
        CImg<char> c_orig(argv[1]), c1, c2;
        c_orig.FFT(c1,c2); c1.display(); c2.display();
    }

El ejemplo carga en c_orig una imagen desde un archivo, calcula luego la transformada de Fourier (en su versión rápida, cosa que CImg hace a través de la biblioteca fftw3), y muestra los resultados (2 imágenes, porque en el resultado cada pixel es un número complejo). El #include trae al código ese par de megabytes de templates para especializar y compilar al momento de crear y usar las instancias de la clase genérica CImg. Esto es lo que tarda tanto, las especialización. Un método genérico, al especializarse, invoca a otros que a su vez deben especializarse también, y así sigue la cadena requiriendo al fina muchísimas especializaciones a partir de solo un par de llamadas como las del ejemplo. Y dado que el compilador no se toma el trabajo de especializar métodos que no se utilicen, podría ser aún peor si hacemos más operaciones sobre las imágenes. Y este es el problema, el compilador debe especializar la clase y los métodos que usemos en cada compilación. Y si tuviéramos varios cpps en nuestro proyecto utilizando CImg.h, el proceso se repetiría en cada uno de ellos. El "problema" se debe simplemente a que un compilador C++ compila una "unidad de compilación" (un .cpp) de forma totalmente independiente de las demás. Esto lleva a repetir trabajo, y el trabajo repetido es normalmente directamente proporcional a la complejidad de los archivos de cabecera (.h).

Había en C++ 98 una palabra clave para pedirle al compilador que compile los templates sin especializar de forma que puedan separarse en un .h con la interfaz y un .cpp con las definiciones como ocurre con las clases no genéricas, pero dado que casi ningún compilador implementaba esta funcionalidad (solo había uno, y no era de los conocidos), se eliminó del estándar. El problema es que compilar "sin especializar" tiene poco sentido en un lenguaje como C++. Pero hay una posibilidad intermedia con C++11 que no todos conocen. Es la posibilidad de compilar por separado especializaciones de la clase, e indicar en el .h que no se vuelvan a compilar en cada .cpp que la usa. En el ejemplo, cambiamos la línea del #include <CImg.h> por #include "my_cimg_accelerator.h", archivo que contendrá lo siguiente:
    #include <CImg.h>
    extern template class cimg_library::CImg<char>;
La primer linea incluye a CImg y todas sus declaraciones de templates, pero la segunda le dice al compilador que no intente compilar una especialización para CImg con char. El extern se usa para decir que algo existe, pero está compilado en otra unidad (otro .cpp), por lo que la unidad actual no debe volver a compilarlo, sino que en el enlazado se asociarán sus llamadas a esa versión ya compilada que deberá proveer algún otro objeto (el uso de extern en templates es nuevo de C++11). Para obtener ese objeto, agregamos al proyecto un "my_cimg_accelerator.cpp" con el siguiente contenido:
    #include <CImg.h>
    template class cimg_library::CImg<char>;
Aquí, estamos instanciando el template (ya sin el extern) indicándole al compilador que debe compilarlo. Y en este caso deberá compilarlo completamente, es decir, todos sus métodos, aunque luego no los utilicemos.

Ejemplos del proceso de compilación de dos proyectos: a la izquierda la
forma tradicional, a la derecha la alternativa propuesta utilizando extern

Con esta nueva estructura, compilar el archivo "my_cimg_accelerator.cpp" puede tardar una eternidad, que en la PC del ejemplo es 1min y 1seg. Pero, luego, el tiempo de compilación de main.cpp se reduce a unos 7 segundos, ya que la compilación de la mayor parte del template CImg se delega ahora a my_cimg_accelerator.cpp. Lo que se incrementa levemente es el tiempo de enlazado, ya que el enlazador recibirá el objeto my_cimg_accelerator.o con todos los métodos de CImg, y deberá extraer de allí solo los que main.o requiera. Pero el tiempo de enlazado aumenta desde menos de un segundo (en la versión inicial) a alrededor de 2 segundos (con este cambio). Dado que al trabajar en un proyecto CImg con esta estructura, es esperable que my_cimg_accelerator.cpp se compile solo una vez al principio, y luego ya no cambie, el tiempo de compilación al cambiar los fuentes durante el desarrollo, será el del main y el del enlazado, que suman unos 9 segundos frente a los 25 originales.

Y para hacer más notable la diferencia, pensemos que si el main requiere más funciones de la clase CImg, en la primer versión su tiempo de compilación crecerá (ya que se compila solo lo que se usa), pero en la segunda no (ya que todo está compilado en el objeto acelerador), incrementando así solamente el tiempo de enlazado, incremento que resulta muchísimo menor. Además, si el proyecto es más complejo y se divide en varios fuentes (cpps que usen la biblioteca CImg), esta reducción se multiplica en cada uno (todos usarían el mismo accelerator), por lo que las ganancias podrían ser de varios minutos.

En conclusión, este truco permite, en algunos casos particulares, reducir muchísimo los tiempos de recompilación de un proyecto mientras se lo desarrolla. Estos son los casos en que se utilizan grandes clases genéricas, especializadas para un conjunto reducido y conocido de tipos de datos (en el ejemplo: char). Voy a incluir estas "mejoras" en el complemento de CImg para ZinjaI, pero espero que entender el "truco" les ayude a acelerar también otros casos.

No hay comentarios:

Publicar un comentario en la entrada