Tutorial para aprovechar los cores de tu microprocesador con OpenMP

Hoy día todos los ordenadores de sobremesa o los servidores tienen al menos dos cores o más, sin embargo, muchos programas aún no son capaces de hacer uso de ellos. Vamos a ver como usar los cores de tu microprocesador con OpenMP y mejorar así el rendimiento de tus aplicaciones.
Aunque hay varias técnicas a la hora de implementar sistemas multiprocesadores, vamos a quedarnos con las dos más comunes. Por un lado, tenemos los sistemas multiprocesadores con memoria compartida, por ejemplo, un PC con varios procesadores pero que comparten todo el rango de direccionamiento de la memoria y, por otra parte, tenemos los sistemas con memoria distribuida en los que cada microprocesador dispone de su propio rango de memoria y no pueden acceder, al menos directamente, a la memoria de otro microprocesador. Un ejemplo de este último esquema podría ser un cluster de ordenadores conectado mediante una red local en los que cada uno dispone de su propia memoria local.
Cada una de estas dos variantes tiene sus pros y sus contras, pero se hace evidente que el modelo de programación para uno y otro modelo ha de ser diferente. En el primero nos encontramos con variables y estructuras de datos compartidas entre los microprocesasores que pueden dar lugar a condiciones de carrera al manipular los datos. En el segundo modelo nos enfrentamos al problema de enviar y recibir información para cada nodo y a las latencias asociadas a la transmisión por la red local. En este artículo nos vamos a centrar en el primer modelo (de memoria compartida), y hablaremos del segundo modelo en un próximo post.
Una de las soluciones más sencillas para crear programas paralelos (aquellos que ejecutan porciones de su código de forma paralela y distribuida entre más de un procesador) es la librería de Intel OpenMP. Lo bueno de OpenMP es que ya está integrado en el compilador GCC y, por lo tanto, no necesitamos instalar ningún componente extra en nuestro sistema. Otra ventaja es que utiliza la etiqueta #pragma del preprocesador de GCC para definir las secciones paralelas, por lo que es fácil adaptar los programas existentes y además permite hacer los programas más portables, de forma que si un compilador no soporta OpenMP, podamos seguir compilando sin cambios.
Lógicamente, las secciones de nuestro código que más nos interesa paralelizar son los grandes bucles. Por ejemplo, si un bucle tiene n iteraciones y tenemos 2 microprocesadores, nos interesa que cada procesador ejecute n/2 iteraciones. Así, en un entorno ideal el bucle se ejecutaría en la mitad del tiempo que en un sistema con un sólo microprocesador. Veámoslo con un ejemplo:

int main() {         
    const int TAM=500;
    int i,j,k;
    
    float matrix1[TAM][TAM];
    float matrix2[TAM][TAM];
    float matrix3[TAM][TAM];
     
    /* asignamos valores a las las matrices */
    for (i=0; i<TAM; i++) {
        for (j=0; j<TAM; j++) {
            matrix1[i][j]=(float)i+j;
            matrix2[i][j]=(float)i-j;
            matrix3[i][j]=(float)0;
        }
    } 

    #pragma omp parallel for schedule (static) private (i, j, k)  
    for(i=0; i<TAM; i++) {  
      for(j=0; j<TAM; j++) {    
        for(k=0; k<TAM; k++) {  
          matrix3[i][j] += matrix1[i][k] * matrix2[k][j];
        }  
      }  
    }  
    
    return 0;
}

Este programa simplemente realiza un montón de multiplicaciones mientras recorre unas matrices de un tamaño considerable. En fin, un trabajo pesado de esos a los que sometemos a nuestros ordenadores. Vamos a compilarlo dos veces, una con OpenMP y otra sin él, para poder comparar. Para compilar con OpenMP sólo hemos de poner el flag -fopenmp en GCC. Es decir:

gcc -fopenmp pruebaomp.c -o conopm

Y ahora lo compilamos sin soporte OpenMP:

gcc pruebaomp.c -o sinopm

Como ves no hemos tenido que tocar una línea del código para hacer las dos versiones.
Antes de entrar a explicar cómo funciona, vamos a ver si realmente funciona, así que ejecutamos primero la versión sin OpenMP usando la orden time para ver cuánto tarda en ejecutarse (mi equipo es un dual core).

time ./sinopm 

real	0m1.221s
user	0m1.099s
sys	0m0.005s

Y ahora con OpenMP:

time ./conopm 

real	0m0.718s
user	0m1.403s
sys	0m0.008s

Nos fijamos en el tiempo real de ejecución (primera línea) y vemos que con OpenMP conseguimos un tiempo de 0,7 segundos, mientras que sin OpenMP el programa se ejecuta en 1,2 segundos. Con una simple directiva #pragma colocada estratégicamente hemos conseguido llevar casi a la mitad el tiempo de ejecución del programa. No llega a ser la mitad ya que OpenMP agrega cierto overhead en cada hilo de ejecución, pero no está nada mal para empezar.
Analicemos la directiva que hemos usado:

#pragma omp parallel for schedule (static) private (i, j, k)

Todas las directivas de OpenMP comienzan con #pragma omp seguido de una instrucción. La más usada de lejos es parallel for, ya que habitualmente lo que interesa paralelizar es la ejecución de bucles largos o con mucho uso del procesador, así que nos centraremos en ella. Como ya se ha dicho, esta directiva trata de crear tantos hilos como cores o procesadores tengamos disponibles, y asigna la ejecución de una parte del bucle a cada hilo (procesador).
Tras el parallell for vemos dos cláusulas. Por un lado schedule (static) y en seguida vemos la otra que es private (i, j, k).
Empecemos con la primera. schedule nos permite indicarle a OpenMP qué política queremos aplicar a la hora de asignar trabajo a cada hilo de ejecución. Hay varias políticas, pero las más comunes son static y dynamic. En la asignación estática OpenMP trata de repartir el trabajo de antemano entre los hilos. Por ejemplo, si el bucle hace 100 iteraciones, asignará 50 a cada uno antes de entrar en el bucle. Si la variable contadora del bucle es i, el hilo 1 realizará las iteraciones para i=0 hasta i=49 y el el hilo 2 de i=50 hasta i=100. Este método tiene la ventaja de que la asignación de trabajo a los hilos es directa, pero ¿qué ocurre si no todas las iteraciones tienen la misma carga de proceso? Observa el siguiente código:

int i=j=k=0;
#pragma omp parallel for schedule (static)
for (i=0; i<100; i++) {
    for (j=0; j<i; j++) {
        k=k*(i+j);
    }
}

Si analizamos este código llegamos a la conclusión de que las primeras iteraciones son mucho más ligeras en cuanto a carga de proceso que las últimas ya que el bucle interno realiza más trabajo cuanto mayor es la variable i. Esto quiere decir que si se asignan la primera mitad de iteraciones al primer hilo y la segunda mitad al segundo hilo, el primero terminará mucho antes y quedará ocioso hasta que termine el segundo (para obtener rendimiento nos interesa conseguir que todos los procesadores se usen el mayor tiempo posible).
En este escenario nos interesa más usar una planificación dinámica, en la que a cada iteración, OpenMP decide a qué hilo se la asigna. Esto hace que la carga se equilibre mejor, pero también conlleva una sobrecarga mayor ya que OpenMP tiene que tomar decisiones en cada vuelta del bucle.
La segunda cláusula que hemos usado, private (i, j, k), indica a OpenMP que cada hilo de ejecución va a tener su propia copia privada de las variables i, j y k. Es decir, si un hilo modifica la variable i, el otro seguirá manteniendo su propia copia con su valor original. Si queremos indicar explícitamente que una variable será compartida por todos los hilos, podemos hacerlo con shared(i, j, k). Hay que tener mucho cuidado cuando se diseñan algoritmos paralelos que comparten variables, ya que si no tenemos cuidado podemos obtener resultados imprevistos (una de las premisas de la programación paralela es que el algoritmo paralelo y el secuencia deben dar el mismo resultado, y si no tenemos cuidado con el orden al que accedemos a las variables esto podría no cumplirse).
Espero que estas pinceladas sobre OpenMP te animen a seguir investigando, ya que el tema da para escribir muchas páginas. Ten en cuenta que para no complicar demasiado la explicación se han hecho algunas simplificaciones a la hora de describir las políticas de planificación.
Otro día hablaremos del modelo de programación que se usa en multiprocesadores de memoria distribuida, como los clusters, y realizaremos un pequeño ejemplo para ilustrarlo.

 

Tags: , , , ,

Comentarios: 4

Responder »

 
  • Información Bitacoras.com…

    Valora en Bitacoras.com: Hoy día todos los ordenadores de sobremesa o los servidores tienen al menos dos cores o más, sin embargo, muchos programas aún no son capaces de hacer uso de ellos. Vamos a ver como usar los cores de tu microprocesador con Op…..

     
     
     
  • Néstor

    Buen post!
    Justamente andaba indagando sobre el tema, pero en las bibliotecas no he encontrado muchos libros que se centren en el tema.
    Lanzo una pregunta… crees que merece la pena la paralelización haciendo uso de las GPU (nVidia CUDA o ATI Stream) en lugar de la CPU? Lo digo para programas que tienen bucles tremendamente largos ya que he visto tarjetas con un número elevadísimo de cores

    Un saludo!

     
     
     
  • Alberto García

    En mi opinión, si tienes que hacer un tratamiento de datos de tipo SIMD (http://es.wikipedia.org/wiki/SIMD) es más interesante usar una GPU ya que el rendimiento se dispara.
    En todo caso, creo que son dos aproximaciones complementarias y dependiendo de cada caso concreto nos va a interesar usar una u otra aproximación.

     
     
     
  • […] Aprovechar multicores con OpenMP […]

     
     
     
  • Dejar un comentario
     
    Tu gravatar
    Tu nombre