Algoritmos genéticos en la paralelización automática de código fuente Java Alfredo Cuesta-Infante1 , J. Manuel Colmenar1 , José L. Risco-Martı́n2 , J. Ignacio Hidalgo2 , Juan Lanchares2 Resumen— La paralelización automática de código es una tarea difı́cil que requiere conocimientos avanzados de programación ası́ como del programa que se desea paralelizar y del paradigma de paralelización seleccionado. El hardware objetivo, donde se ejecutará, también se debe tener en cuenta a la hora de decidir una estrategia de paralelización ya que la generación de muchas hebras puede llegar a reducir el rendimiento. Por otro lado, la paralelización de código se puede ver como la búsqueda de una combinación de métodos, algunos de los cuales serán paralelizados, de tal modo el conjunto del programa se ejecuta sin error y tarda el menor tiempo posible en el hardware objetivo. Desde este punto de vista nosotros proponemos un flujo de optimización que aplica Algoritmos Genéticos para encontrar la mejor combinación. Uno de los puntos clave de nuestra optimización es un generador totalmente automático de métodos paralelizados que, dada la codificación de un individuo, proporciona el código paralelo correspondiente. Nuestro generador alcanza, de media, un speedup de 2.02× comparado con la exploración exhaustiva en el benchmark analizado. Además los códigos paralelizados obtenidos con el método propuesto son más rápidos que aquellos encontrados mediante la explicación por fuerza bruta. Palabras clave— Arquitecturas Multi-núcleo, Paralelización Automática, Java, Algoritmos Genéticos. I. Introducción Los continuos avances y mejoras en hardware han sido tan rápidos que, en tan sólo 20 iteraciones de la ley de Moore, se ha establecido la microarquitectura y la velocidad de reloj está alcanzando su lı́mite. Los consumidores están acostumbrados a este ritmo de crecimiento y demandan computadores más rápidos continuamente. Para ser competitivos y satisfacer la demanda, y dado que el número de transistores integrados ya no es un problema, los fabricantes y vendedores de hardware han comenzado a ofrecer multiples núcleos en el chip del procesador; siendo los ordenadores con dos y cuatro nucleos un producto de consumo desde hace tiempo [1]. Por otra parte, la mayor parte de las aplicaciones son mono-hebra por lo que, al final, los núcleos añadidos no proporcionan ninguna mejorı́a en cuanto al tiempo de ejecución de una aplicación concreta. Al contrario, estas aplicaciones mantienen un núcleo ocupado mientras los demás no lo están. Las aplicaciones multi-hebra son por tanto una necesidad creciente ya que hacen un uso eficiente 1 C.E.S. Felipe II - U. Complutense de Madrid, 28300 Aranjuez, Spain. E-mail: {alfredo.cuesta,jmcolmenar}@ajz.ucm.es 2 DACYA - U. Complutense de Madrid; 28040 Madrid, Spain. E-mail: {jlrisco,hidalgo,julandan}@dacya.ucm.es de los procesadores multi-núcleo, ejecutándose mucho más rápido y equilibrando el desgaste de todos núcleos. Sin embargo la programación en paralelo (multi-hebra) presenta varios inconvenientes. En primer lugar la mayor parte de los programadores sólo saben desarrollar código secuencial. La programación en paralelo requiere conocimientos y técnicas extra de depuración de código para asegurar aspectos como la sincronización, integridad o interdependencias. Además la cantidad de código preexistente hace imposible transformar todas las aplicaciones secuenciales manualmente. Por estos motivos una aproximación automática a la paralelización de código serı́a muy útil en el contexto de máquinas multi-núcleo. El paralelismo puede ser logrado implı́citamente o explı́citamente. El primero es invisible al programador, el segundo requiere que el programador incluya directivas y métodos que controlen la ejecución en paralelo. El paralelismo implı́cito es automático pero mucho menos eficiente, mientras que el explı́cito tiene las desventajas mencionadas en el párrafo anterior. Se han propuesto diversas aproximaciones con el objetivo de facilitar la tarea de paralelización. Entre los primeros trabajos, se presentó el modelo poliédrico (también llamado politopo) [2]. Se proponı́a un complemento (plugin) para el proceso de compilación convencional que ayudaba a la paralelización de bucles anidados. El programa original era, en primer lugar, analizado para obtener un árbol sintáctico abstracto que se traducı́a en una representación algebraica lineal. A continuación una función de reordenación proporcionaba un nuevo orden de ejecución. Finalmente el código ejecutable se recuperaba del modelo. El modelo poliédrico ha sido aplicado en escenarios de memoria distribuida [3] y de memoria compartida [4] y se ha optimizado para procesadores multi-núcleo en [5]. También a nivel de bucle, [6] introducı́a la el desenrollado especulativo de bucles, el aislamiento de dependencias poco frecuentes y el lanzamiento especulativo para descubrir más opotunidades de paralelización en el código secuencial. Otro avance reciente en paralelismo implı́cito se debe al compilador Paralax [7]. Este compilador estaba especialmente dirigido a aplicaciones con uso intensivo de punteros. También realizaba un análisis de dependencias de grano grueso de los bucles externos; proporcionando una ayuda al programador para anotar el código fuente y ası́ facilitar al compilador la tarea de decidir como realizar la paralelización. Una aproximación más reciente y prometedora consiste en aplicar análisis afı́n a ficheros ejecutables en serie binarios [8]. La falta de información simbólica en ficheros binarios se supera calculando vectores de dependencia convenientemente adaptados a los elementos que un código de bajo nivel presenta tales como registros o variables direccionadas. Se trata de un método determinista y puede ser aplicado sin importar el compilador, el código legado ni tan siquiera el lenguaje de programación. Sin embargo el método es bastante complejo y, de momento, restringido a bucles afines. Aunque alguna de las aproximaciones mencionadas son automáticas, todas ellas son más apropiadas para aplicaciones cientı́ficas, en las que hay numerosos bucles y referencias a arrays. Por el contrario las aplicaciones de propósito general se pueden beneficiar del paralelismo corriendo métodos independientes, o incluso bloques enteros, en diferentes núcleos. En esta lı́nea, [9] han presentado la herramienta ParaGraph, que ayuda al programador a generar paralelismo explı́cito. Se trata de un plugin del entorno de desarrollo Eclipse para C que puede funcionar en modo manual, automático o hı́brido, insertando directivas OpenMP. Sin embargo los peores resultados se obtienen en modo automático, con un 1,25× de mejora (speedup) en la ejecución frente al 3× del caso hı́brido en el problema más extenso de los probados. Por tanto la intervención humana sigue siendo necesaria. Además, los programas que hacen mucho uso de estructuras de datos dinámicas basadas en punteros como las escritas en C, C++ o Java tampoco se benefician demasiado de la paralelización de bucles. Java ofrece simplicidad trabajando con punteros, las aplicaciones se pueden ejecutar en cualquier parte siempre que la máquina virtual de Java (JVM) esté instalada y las multi-hebras se implementan de manera sencilla. Por este motivo en [9] los autores analizan los métodos invocados en vez de los bucles y, si las dependencias de datos no son violadas, dichos métodos se lanzan en paralelo en tiempo de ejecución. También para Java, [10] presentan seis leyes para transformar código secuencial introduciendo nuevas hebras cuando es posible. Ambas propuestas crean nuevas hebras siempre que pueden, sin preocuparse del número de ellas. Como resultado cientos de ellas podrı́an estar esperando ser ejecutadas en un número mucho menor de núcleos. Teniendo en cuenta el tiempo de comunicación, esta asimetrı́a puede causar que la versión paralela sea incluso más lenta. La contribución de este artı́culo es presentar una nueva técnica de paralelización automática de programas secuenciales en Java basada en algoritmos genéticos (AG). En nuestra propuesta, en primer lugar se obtiene un perfil del codigo fuente secuencial que proporciona la frecuencia y el tiempo total de ejecución de los métodos invocados y las clases a las que pertenecen. Los métodos más invocados y/o aquellos con mayor tiempo de ejecución se denominan hot spots y son seleccionados como candidatos para ser paralelizados. La paralelización se considera entonces como un problema de optimización que busca la combinación de métodos, algunos paralelizados y otros no, que den lugar a una ejecución correcta en el menor tiempo posible. Aplicamos una codificación binaria a cada posible solución (denominada individuo) y corremos un algoritmo genético para buscar el mejor, que se determina según el tiempo de ejecución. De esta manera, en la fase de evaluación, el algoritmo realiza la paralelización automática del código Java, la compilación y la ejecución de cada individuo. Como se detalla en las secciones posteriores, el código paralelo está basado en una clase que toma en consideración las restricciones hardware (número de núcleos) y gestiona las hebras vivas. El resultado final es una versión paralela del código fuente objetivo dado. Se ha elegido Java por las siguientes razones. En primer lugar facilita el trabajo con punteros. Además las aplicaciones escritas en Java son portables porque se ejecutan en la JVM en vez de en un sistema operativo especı́fico. Y finalmente la concurrencia vı́a hebras es fácil de implementar gracias a los interfaces genéricos Callable y Future. La elección de AG se justifica cuando el espacio de soluciones es demasiado grande para emplear búsqueda exhaustiva. En el problema planteado, escogiendo sólo los primeros 10 métodos de lista obtenida tras el perfil como hot spots, i.e. candidatos a ser paralelizados, tenemos un espacio de 210 posibles soluciones; demasiado extenso para probarlos todos pero lo suficientemente compacto para ser explorado mediante un AG. En nuestro AG, cada individuo representa un código paralelo diferente y para calcular su fitness primero se compila y después se ejecuta. De esta manera el método propuesto logra: (i) un código de salida que funciona correctamente, (ii) con algunos (no necesariamente todos) métodos paralelizados, (iii) que maximiza la utilización de los núcleos (iv) de una manera totalmente automática, sin intervención humana de ningún tipo. El resto del artı́culo está organizado del siguiente modo: La sección 2 plantea el problema y detalla el flujo completo de optimización, incorporando el AG. La sección 3 describe la manera de generar código paralelo. La sección 4 muestra los resultados obtenidos con un programa de prueba. Por último se presentan las conclusiones. II. Descripción del algoritmo genético Como se mencionó previamente, en este trabajo se presenta el proceso de paralelización de código fuente Java como un problema de optimización en el cual el objetivo consiste en obtener un código cuyo tiempo de ejecución sea el más corto posible, libre de errores. En consecuencia, se ha desarrollado un flujo de optimización que integra un algoritmo genético (AG) encargado de la búsqueda del código óptimo. Este flujo incluye un módulo que genera el código Java paralelo de manera automática en función de las caracterı́sticas del hardware objetivo. La Figura 1 muestra el diagrama del flujo de optimización que se presenta en esta contribución. Como se aprecia en el diagrama, el perfil de la aplicación se utiliza en la generación de la población inicial, cuya evaluación también se realiza en ese primer paso. Seguidamente, el AG aplica iterativamente los operadores de selección, cruce y mutación a la población, evaluando los descendientes. En el flujo que se aquı́ se presenta, la evaluación de fitness incluye la generación automática del código paralelo de cada individuo, cuyo funcionamiento se explicará en la siguiente sección. Una vez ejecutada la última generación, el algoritmo termina devolviendo el individuo cuyo tiempo de ejecución es menor. A. Espacio de búsqueda y codificación El espacio de búsqueda teórico para el problema presentado está formado por todos los posibles códigos fuente resultado de paralelizar los métodos disponibles en el código Java proporcionado. Por tanto, es conveniente elegir un conjunto de métodos candidatos a ser paralelizados con objeto de reducir el espacio de búsqueda. En ese sentido, en esta contribución se propone realizar un perfil de la aplicación objetivo que determine cuáles son los métodos más relevantes tanto en tiempo de ejecución como en número de llamadas. Estos métodos, denominados hot spots, y su perfil se han obtenido a través de la herramienta NetBeans. Cabe destacar que pueden existir métodos con un elevado número de llamadas y un tiempo de ejecución corto por cada llamada. Estos métodos se podrı́an considerar hot spots, pero su paralelización podrı́a ser contraproducente puesto que la creación, invocación y sincronización de la nueva hebra creada podrı́a emplear más tiempo que la propia ejecución del método. La penalización, por tanto, serı́a importante, y la paralelización inadecuada. En consecuencia, la estrategia de selección debe elegir métodos cuyo tiempo de ejecución sea significativo y, preferiblemente, su número de llamadas sea elevado. En esta contribución, sin embargo, no se ha estudiado el umbral de tiempo de ejecución y número de llamadas para los hot spots. En cambio, sı́ se ha mostrado cómo el número de métodos elegidos determina el tamaño del espacio de búsqueda. La meta del AG es determinar cuáles serán los métodos a paralelizar. Por tanto, en una solución se deberá indicar, para el cada uno de los métodos seleccionados en el perfil, cuáles se paralelizarán y cuáles no. Esta descripción conduce a una codificación binaria donde el gen se compone de tantos bits Fig. 1. Flujo completo de optimización. como métodos candidatos a paralelizar haya, y donde el valor 1 indica que el método se paraleliza y el 0 indica que el método no se modifica. Por tanto, el tamaño del espacio de búsqueda depende del número de métodos candidatos a ser paralelizados, y se obtiene a través de la siguiente operación: T am. espacio búsqueda = 2núm. métodos B. Operadores genéticos Se ha elegido la selección por torneo binario, en contraposición a la selección por ordenación (ranking) porque el coste del torneo en tiempo de ejecución es menor que la selección por ordenación. En este caso se implementó el torneo binario. Para asegurar la supervivencia de las mejores soluciones se utilizó elitismo. Los progenitores se seleccionan de la población, se les aplica el cruce y la mutación y, como resultado, se obtienen los descendientes. Una vez evaluados los descendientes, ambos conjuntos de individuos se mezclan manteniendo en la población a los N con mejor fitness, suponiendo N como tamaño de la población. Respecto al cruce, cada pareja de progenitores genera dos descendientes a través del cruce basado en un punto. Ambos cromosomas se cortan en un punto y sus mitades se intercambian. A continuación se muestra un ejemplo: Progenitores → Descendientes 010.00101 010.11101 110.11101 110.00101 Finalmente, existe una probabilidad de mutación para un bit seleccionado aleatoriamente del cromosoma de los descendientes. La mutación en codificación binaria simplemente asigna el bit complementario a la posición seleccionada. Sigue un ejemplo donde el primer bit muta: Antes de mutar → Después de mutar 11011101 01011101 C. Evaluación de fitness La evaluación de fitness es la tarea que más tiempo consume en el flujo de optimización puesto que requiere la compilación y evaluación del código fuente correspondiente a cada individuo. Por tanto, para reducir el tiempo empleado por el algoritmo, el fitness de cada individuo se almacena en memoria realizando una sola evaluación para cada uno de ellos. Como muestra la Figura 1, la evaluación de cada individuo comienza preguntando si hubo evaluación previamente. En caso afirmativo, su fitness se lee de memoria. En caso negativo, se procede a la generación del código Java paralelo que representa el cromosoma del individuo y se compila. Como se explicará en la sección III, la generación de código es completamente automática, por lo que puede producir un código incorrecto que no compile. Si la compilación presenta errores, se asigna el peor fitness al individuo, denotado en este trabajo como MAXFITNESS. Si la compilación tiene éxito, el código resultante se ejecuta. En este paso existen dos enfoques diferentes para su realización. Por un lado, se podrı́a ejecutar el código resultante en el hardware objetivo. Esta solución requerirı́a la conexión y sincronización de la máquina ejecutora de la optimización con el hardware objetivo. Por otro lado, se podrı́an simular las caracterı́sticas hardware, principalmente el número de núcleos disponibles, utilizando un gestor de hebras implementado en el código. Este enfoque permitirı́a ejecutar las evaluaciones en la misma máquina donde se ejecuta la optimización. Ası́, una máquina con cuatro núcleos podrı́a simular objetivos de cuatro, dos y un núcleos. Esta aproximación proporciona flexibilidad en los experimentos, aunque en un trabajo futuro se estudiarán otras alternativas. Fig. 2. Pasos en la generación de código automático. De nuevo, debido a la generación automática de código, es posible que una fuente que ha compilado no ejecute correctamente debido a errores que sólo aparecen en la propia ejecución. Si sucede este hecho, se asigna al individuo un fitness malo, pero no el peor. Como se muestra en la Figura 1, ese fitness se denota como la mitad de MAXFITNESS. Si la ejecución fue correcta, el tiempo de ejecución obtenido se asigna al fitness del individuo y se pasa al siguiente. Una vez que todos los descendientes se han evaluado, el AG mezcla las poblaciones de progenitores y descendientes y sigue iterando hasta alcanzar la última generación. III. Proceso de paralelización En esta sección se describe el proceso de generación de código Java paralelo. Este proceso es totalmente automático por lo que no requiere intervención humana en ningún momento. Como se puede ver en la Figura 2, hay tres entradas: la codificación del individuo del cual se generará código, el código secuencial Java y las restricciones del hardware objetivo. La salida de este proceso es un código Java paralelo donde aquellos métodos indicados en la codificación han sido paralelizados. Antes de entrar en detalles conviene introducir los siguientes conceptos, a los cuales nos referiremos a lo largo de esta sección: Método paralelizable: es el método seleccionado para ser paralelizado. Clase paralelizable: es la clase que contiene el método paralelizable. Objeto paralelizable: instancia de la clase paralelizable que invoca al método paralelizable. Método invocante: método desde el cual el método paralelizable es invocado. Clase invocante: es la clase que contiene el método invocante. De aquı́ en adelante estos conceptos también pueden aparecer en fuente courier cuando nos referimos a ellos como código Java. En general, la paralelización se consigue creando una nueva hebra por cada tarea que debe ser ejecutada en otro núcleo. Por ello, se considera a cada método paralelizable como una tarea por lo que el código inicial debe ser modificado. Las hebras se implementan con sencillez usando los interfaces Runnable y Callable junto con un servicio ejecutor adecuado de entre los muchos disponibles. En esta aproximación un método paralelizable será una tarea paralela ası́ que es probable que ambos tengan tanto entradas como salidas. Sin embargo, dado que el interfaz Runnable no devuelve nada hemos decidido usar solamente implementaciones del interfaz Callable. De este modo, como se ve en la Figura 2, nuestro proceso de paralelización involucra tres pasos: 1. Generación de clases objetivo paralelas: para cada método paralelizable se creará una nueva clase paralela (TargetClassParallel). Estas clases implementarán el interfaz Callable, rellenando el método call con el código fuente de targetMethod. 2. Creación del ThreadManager, una nueva clase que envı́a y finaliza las hebras. 3. Reescribir todas las clases llamadoras para incorporar las invocaciones a los nuevos TargetClassParallel. El resto de la sección está dedicada a detallar cada una de estas acciones. A. Generación de clases objetivo paralelas Por cada uno de los métodos objetivo se genera una nueva clase llamada TargetClassParallel. Cada una de ellas es un clon de la clase paralelizable a la cual se añade un método call dado que se debe implementar a partir de un interfaz Callable. El método call contendrá exactamente las mismas sentencias que targetMethod pero devuelve un Object en vez de la salida original de la clase. La Figura 3 muestra esquema del proceso. Debido a las restricciones impuestas por la implementación del interfaz, el método call no puede recibir ningún parámetro de entrada. Por tanto, si el método paralelizable recibe alguno, nuestro generador de código añadirá automáticamente éstos como atributos públicos del nuevo TargetClassParallel. Ası́, las invocaciones a esas variables desde el método funcionarán correctamente. Los parámetros de targetMethod se traducen en nuevo atributos, incluidos después de la cabecera de la clase targetClassParallel. Ası́, como se explicará más adelante, si un método paralelizable recibe datos de entrada, la re-escritura de los métodos llamadores debe incluir la inicialización de esos nuevos atributos públicos antes de llamar al método paralelo. Nuestro generador de código producirá una nueva clase para cada uno de los métodos objetivo. Después, si una clase paralelizable contiene dos méto- Fig. 3. Esquemas de las transformaciones de código. dos diferentes, el código resultante incluirá la clase original y dos nuevas clases paralelas. B. Gestión de hebras Se incorpora la nueva clase ThreadManager para crear y destruir las hebras que se ejecutarán en diferentes núcleos. Consta de dos métodos estáticos: runInNewThread, que lanza el objeto Callable dado en una nueva hebra. finishThread, que destruye la hebra que está ejecutando el objeto Callable. El método runInNewThread hace uso de un objeto estático, threadPool, que mantiene un conjunto de hebras listo para ser usado. Además el número de hebras que threadPool gestiona viene dado por las restricciones de hardware, convertidas en entradas por este módulo del flujo de optimización. Si por ejemplo el hardware objetivo tiene cuatro núcleos, el número máximo de hebras activas será cuatro. El objeto threadPool, que pertenece la clase de Java Executors, permite el envı́o de objeto Callable y devuelve un objeto de la clase Future. Un Future representa el resultado de una computación ası́ncrona (paralela). Este interfaz proporciona métodos para comprobar si la computación se ha completado, esperar a que se complete y recuperar el resultado. Esta última acción sólo puede ser realizada mediante el método get cuando la computación se ha completado. Hay que tener en cuenta que al ejecutar get se bloquea la marcha del programa principal si el dato no estaba listo. Después, por cada hebra creada en el gestor, se añade su objeto Future a una lista de futuros, de modo que el conteo de hebras ejecutándose se actualiza continuamente. Una vez que la tarea paralela se ha completado, se invoca el método finishThread. El objeto futuro es entonces eliminado de la lista y, si dicha lista está vacı́a, el conjunto de hebras se destruye para evitar retardos. Estos pasos han sido esquematizados en la Figura 3, donde los números indican el orden de ejecución. Dado que las restricciones de hardware son constantes, la clase ThreadManager se crea para el primer individuo y después es copiado en el último código paralelo generado. C. Re-escribir las clases llamadoras La transformación de la clase invocante es la más sensible porque hay muchas posibilidades de invocar el método paralelizable. La Figura 3 muestra un esquema de como se transforma una invocación estándar de un objeto paralelizable. En primer lugar un nuevo objeto de la clase TargetClassParallel debe ser instanciado. Una vez se inicializan sus miembros con los parámetros de entrada originales, el objeto que runInNewThread debe recibir está listo y es invocado. A continuación el código es parseado buscando la lı́nea donde se utiliza la salida. El método get del futuro se inserta en la lı́nea inmediatamente anterior para llevar a cabo la sincronización. Puesto que se persigue alcanzar un rendimiento similar al obtenido por un programador experto en paralelismo, tiene sentido tener en cuenta que un constructor no puede ser TargetMethod y que la salida de un TargetMethod (si tiene alguna) no debe ser usada inmediatamente después de su obtención. El primer caso es trivial porque el tiempo de ejecución del constructor es muy corto y el objeto generalmente se utiliza inmediatamente después de su llamada, por lo que no se aprovecha el paralelismo, o incluso aumenta el retardo. Para entender el segundo podemos recurrir al código ejemplo usado. Cuanto más tiempo se emplee en ejecutar instrucciones de la hebra principal entre la creación de la nueva hebra y la recuperación del valor de salida (indicado como statements where ‘y’ is not used en el código) mejor es la paralelización. El beneficio se produce porque la hebra principal puede ejecutar instrucciones mientras la hebra paralela termina. IV. Resultados experimentales En este trabajo se ha utilizado la aplicación SciMark 2.0 [11] como banco de pruebas con objeto de validar el flujo de optimización descrito. Se trata de una aplicación Java que ejecuta una serie de cálculos numéricos midiendo el tiempo obtenido en cada uno de ellos. Su código fuente, no paraleliza- do, implementa cinco tareas diferentes: transformada rápida de Fourier (FFT), sucesiones jacobianas relajadas (SOR), multiplicación de matrices dispersas (MAT), integración Monte Carlo (MC) y factorización de matrices densas (LUT). La ejecución utiliza valores generados aleatoriamente para cada una de las tareas, por lo que el tiempo de ejecución varı́a. Tras diferentes ejecuciones, se ha verificado que SciMark emplea un promedio de 72,5 segundos ejecutando en una máquina de cuatro núcleos Intel i5 660 a 3,33 GHz con 8 Gb de RAM. Una vez obtenido el tiempo de ejecución de la versión serie (no paralela), el primer paso del flujo de optimización consiste en obtener el perfil que indique el tiempo de ejecución y número de llamadas para cada método. Una vez obtenido, se eligió un conjunto de ocho métodos como candidatos a ser paralelizados, lo cual permite reducir enormemente el espacio de búsqueda. Esta decisión ha sido motivada por la idea de verificar el flujo de optimización, por lo que se requiere un espacio de búsqueda manejable. La tabla I muestra el nombre de los métodos elegidos ası́ como la clase a la que pertenecen. TABLA I Métodos candidatos a ser paralelizados. Clase kernel kernel kernel kernel kernel FFT Random kernel Método measureFFT(int, double, Random) measureSOR(int, double, Random) measureMonteCarlo(double, Random) measureSparseMatmult(int, int, double, Random) measureLU(int, double, Random) log2(int) initialize(int) RandomVector(int, Random) El orden en que se muestran los métodos no es relevante para la optimización, pero sı́ para la codificación de soluciones, puesto que el primer gen indica si el primero de los métodos de la tabla se ha paralelizado, mientras que el gen más a la derecha lo hace para el último método de la lista. Dado que el número de métodos elegidos es pequeño, el espacio de búsqueda también lo es, y está formado por 256 soluciones (28 combinaciones). Este tamaño permitió que se pudiese ejecutar una exploración exhaustiva cinco veces, donde se generó código para todas las combinaciones posibles, se compiló y se ejecutó, midiendo el tiempo de ejecución tanto de la exploración completa como de cada una de las soluciones comprobadas. La segunda columna de la Tabla II muestra el tiempo de ejecución de las exploraciones, presentando un promedio de 2965,32 segundos. La Tabla II también muestra, para cada exploración, el cromosoma de la mejor solución obtenida, el tiempo de ejecución de la solución y el speedup (abreviado como Sp.) de la solución con respecto a la ejecución de la aplicación serie, cuyo tiempo es de 72,5 segundos. Los resultados presentan el esquema común 1111**00 en los cinco casos. De hecho, yendo al código fuente se puede apreciar que los cuatro TABLA II Estadı́sticas de exploraciones exhaustivas. # Expl. 1 2 3 4 5 Promedio T. Expl. 2988,74 2980,34 2949,04 2922,49 2985,98 2965,32 Mejor Sol. 11111000 11111100 11110000 11110100 11111100 - T. Sol. 14,04 18,01 14,49 14,51 18,10 15,83 Sp. Sol. 5,16 4,03 5,00 5,00 4,01 4,64 primeros genes representan cuatro tareas independientes que pueden ejecutar en paralelo en la máquina de cuatro núcleos. Además, la paralelización de los dos últimos métodos no es posible debido a errores de compilación del código generado automáticamente. Respecto a los métodos en quinto y sexto lugar, los resultados ofrecen diferentes tiempos de ejecución en cada ejecución concreta. Como resultado, la exploración exhaustiva obtiene soluciones cuyo tiempo promedio de ejecución es de 15,83 segundos, que representa un speedup promedio de 4,64 con respecto al código serie. El siguiente paso consiste en ejecutar la optimización basada en AG y comparar, tanto su tiempo de ejecución, como el tiempo de ejecución de las soluciones obtenidas en cada optimización. Tras diferentes pruebas, se eligieron los valores indicados en la Tabla III para el AG. TABLA III Parámetros del AG. Parámetro Tamaño de población Número de generaciones Probabilidad de cruce Probabilidad de mutación Valor 10 15 0.9 0.125 TABLA IV Soluciones obtenidas en las optimizaciones AG. Sol. #O. T.P. σ Speedup 11111100 11 15,36 2,00 4,72 11110000 9 17,02 3,66 4,26 11110100 7 14,77 1,29 4,91 11111000 3 15,72 1,12 4,61 #O. = Núm. ocurrencias, T.P. = Tiempo promedio, σ = Desviación tı́pica. Como era de esperar, la optimización basada en AG emplea menos tiempo que la búsqueda exhaustiva. La Figura 4 (a) muestra el speedup obtenido por las 30 optimizaciones basadas en AG que se ejecutaron. Estas optimizaciones emplearon un tiempo promedio de 1463,11 segundos, lo que representa un speedup de 2,07 con respecto al tiempo empleado por la búsqueda exhaustiva. Tres de las optimizaciones AG obtuvieron speedups por encima de 2,5, con un máximo de 2,77. La Figura 4 (b) muestra la proporción normalizada de individuos evaluados (compilados y ejecutados) en cada ejecución de la optimización AG. Como se aprecia en la figura, el AG examina un promedio (a) Speedup de las optimizaciones AG (b) Núm. de soluciones evaluadas en cada optimización AG Fig. 4. Comparativa respecto a la busqueda exhaustiva. de 0,29 de los 256 individuos evaluados por la exploración exhaustiva. Esta proporción representa un promedio de 73,4 individuos evaluados. Por lo tanto, el AG es capaz de reducir el tiempo de ejecución drásticamente debido a la reducción del número de evaluaciones, tarea que determina el tiempo total de optimización. Es necesario recordar en este punto que cada individuo se evalúa una sola vez, almacenando su fitness en memoria. La primera conclusión es que el AG, realizando la evaluación de un tercio del espacio de búsqueda, es capaz de obtener un speedup promedio de 2,07, alcanzando picos superiores a 2,5 en el tiempo total de optimización con respecto al tiempo de exploración exhaustiva. Por otro lado, se ha analizado la calidad de las soluciones proporcionadas por el AG. De las 30 ejecuciones se obtuvieron cuatro soluciones diferentes, detalladas en la Tabla IV. Como se ha explicado previamente, la aplicación bajo estudio presenta diferentes tiempos de ejecución en función de los datos de entrada generados aleatoriamente. Por tanto, en la tabla se presenta, para cada solución, el número de ocasiones en que el AG elige ese individuo como solución (# Ocurr., ocurrencias), el tiempo de ejecución promedio del individuo (T. Prom.), la desviación tı́pica de sus tiempos de ejecución (Desv. Tı́p.) y el speedup promedio con respecto a tiempo de ejecución promedio de la optimización exhaustiva. Los resultados del AG son muy satisfactorios, alcanzando en todas las ejecuciones soluciones que verifican el esquema 1111**00, del mismo modo que sucede en la búsqueda exhaustiva. Este es un resultado muy importante puesto que la población inicial es pequeña (10 individuos), se obtuvo aleatoriamente, y el número de generaciones es también bajo (15 generaciones). Además se puede apreciar que tres de las cuatro soluciones dadas por el AG obtuvieron menores tiempos de ejecución promedio y, por tanto, mejor speedup, que las soluciones obtenidas por la exploración exhaustiva (15,83 segundos y speedup de 4,64, ver Tabla II). También se puede ver que la solución cuyo speedup es 4,26 tiene una desviación tı́pica de 3,66 segundos, lo que significa que algunas de sus ejecuciones obtuvieron tiempos de ejecución menores que la solución promedio de la búsqueda exhaustiva. En consecuencia, la optimización basada en AG obtiene soluciones de calidad en la paralelización de la aplicación bajo estudio en la máquina objetivo. Estos resultados muestran cómo el AG converge rápidamente hacia el esquema que comparten las mejores soluciones. Por tanto es altamente probable que el enfoque basado en AG sea capaz de paralelizar códigos cuyo tiempo de ejecución sea elevado, siempre que se implemente un mecanismo de detección de esquemas que ayude en la reducción del tiempo de optimización. V. Conclusiones La paralelización automática se considera la mejor solución para reducir la distancia entre el rápido desarrollo de procesadores con múltiples núcleos y la tradicional programación en serie. En esta contribución se presenta una técnica de paralelización completamente automática que toma como entrada un código fuente Java y genera como resultado el código Java paralelo con menor tiempo de ejecución para el hardware requerido. En esta propuesta se ha modelado la paralelización de código como un problema de optimización en el que el espacio de búsqueda está determinado por el número de métodos candidatos a ser paralelizados. La solución se obtiene a través de un algoritmo genético que, utilizando una codificación binaria, representa las diferentes combinaciones en la paralelización de métodos, generando de manera automática el código paralelo y evaluando cada individuo para obtener su tiempo de ejecución. Los resultados experimentales muestran que la exploración basada en AGs obtiene un speedup promedio de 2,2 en comparación con el método de exploración por fuerza bruta. La población inicial de 10 individuos alcanzó buenos resultados en sólo 15 generaciones evaluando, en promedio, un tercio del total de soluciones del espacio de búsqueda. Dado que la población es pequeña y el número de generaciones bajo, estos resultados apuntan a que el enfoque del AG tiene potencial en esta clase de problemas. En el trabajo futuro se pretende estudiar la generación de soluciones iniciales, donde un enfoque dirigido permitirı́a al AG mejorar sus resultados en las primeras iteraciones. Además, la escalabilidad de es- ta propuesta de optimización es difı́cil. Si el número de métodos candidatos a paralelizar crece, el espacio de búsqueda se incrementa de manera exponencial, por lo que serı́a necesario estudiar un lı́mite en el número de “unos” en los cromosomas para ajustar la diferencia con el número de núcleos del hardware objetivo y ası́ reducir el espacio de búsqueda. En cuanto al tiempo de ejecución total, se trabajará en un mecanismo de detección de esquemas en las soluciones obtenidas en cada generación que vigile la convergencia y detenga la optimización al obtener mejoras pequeñas o nulas. Agradecimientos Este trabajo ha sido financiado por los proyectos TIN2008-00508 y MEC Consolider Ingenio CSD00C07-20811 del Ministerio de Ciencia y Tecnologı́a. Referencias [1] Shekhar Borkar and Andrew A. Chien, “The future of microprocessors,” Commun. ACM, vol. 54, pp. 67–77, May 2011. [2] Christian Lengauer, “Loop parallelization in the polytope model,” in CONCUR’93, Eike Best, Ed., vol. 715 of Lecture Notes in Computer Science, pp. 398–416. Springer Berlin / Heidelberg, 1993, 10.1007/3-540-57208-2-28. [3] Cedric Bastoul, “Code generation in the polyhedral model is easier than you think,” in Proceedings of the 13th International Conference on Parallel Architectures and Compilation Techniques, Washington, DC, USA, 2004, PACT ’04, pp. 7–16, IEEE Computer Society. [4] M. Classen and M. Griebl, “Automatic code generation for distributed memory architectures in the polytope model,” in Parallel and Distributed Processing Symposium, 2006. IPDPS 2006. 20th International, april 2006, p. 7 pp. [5] U. Bondhugula, M. Baskaran, A. Hartono, S. Krishnamoorthy, J. Ramanujam, A. Rountev, and P. Sadayappan, “Towards effective automatic parallelization for multicore systems,” in Parallel and Distributed Processing, 2008. IPDPS 2008. IEEE International Symposium on, april 2008, pp. 1 –5. [6] Hongtao Zhong, M. Mehrara, S. Lieberman, and S. Mahlke, “Uncovering hidden loop level parallelism in sequential applications,” in High Performance Computer Architecture, 2008. HPCA 2008. IEEE 14th International Symposium on, feb. 2008, pp. 290 –301. [7] Hans Vandierendonck, Sean Rul, and Koen De Bosschere, “The paralax infrastructure: automatic parallelization with a helping hand,” in Proceedings of the 19th international conference on Parallel architectures and compilation techniques, New York, NY, USA, 2010, PACT ’10, pp. 389–400, ACM. [8] A. Kotha, K. Anand, M. Smithson, G. Yellareddy, and R. Barua, “Automatic parallelization in a binary rewriter,” in Microarchitecture (MICRO), 2010 43rd Annual IEEE/ACM International Symposium on, dec. 2010, pp. 547 –557. [9] I. Bluemke and J. Fugas, “C code parallelization with paragraph,” in Information Technology (ICIT), 2010 2nd International Conference on, june 2010, pp. 163 – 166. [10] Rafael Duarte, Alexandre Mota, and Augusto Sampaio, “Introducing concurrency in sequential java via laws,” Inf. Process. Lett., vol. 111, pp. 129–134, January 2011. [11] R. Pozo and B. Miller, “Scimark 2.0 http://math.nist.gov/scimark2/,” 2011.