Algoritmos genéticos en la paralelización automática de código

Anuncio
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.
Descargar