martes, 19 de mayo de 2015

Programación paralela (y IV) - La directiva for

En todas las versiones del programa que nos calcula el número PI siempre hemos hecho algo en común: Hemos dividido las N iteraciones entre el número de threads y mediante la expresión

 for (i = stage ; i < stage + hop; i++)

 hemos fraccionado el trabajo de forma uniforme entre todas las threads recordando que el valor de la variable stage depende del identificador de thread en el que se está ejecutando y que obtenemos usando la función omp_get_thread_num()

 De forma gráfica se podría ver esto así:



La directiva for


 En OMP tenemos una directiva que nos permite hacer esto de forma más simple. Esta directiva tiene es homónima de la instrucción en C a la que pretende complementar, es decir la directiva for


 #pragma omp parallel
  {
  #pragma omp for
 .........
 ..........

  }

}

El programa para el cálculo pi si usamos esta directiva queda así:

#include <stdio.h>
#include <omp.h>
#include <time.h>
#include <sys/types.h>
static long num_steps = 100000000;
#define NUM_TRHEADS  2
double step, pi,x;
double sum =0.0; //[NUM_TRHEADS];
int main()
{

int i;
step = 1.0/(double) num_steps ;
omp_set_num_threads(NUM_TRHEADS);
#pragma omp parallel private(i,x) reduction(+:sum)                                                                
{


#pragma omp for schedule(static)
for (i = 0 ; i < num_steps; i++) {
x = (i + 0.5) * step;
sum += 4.0/(1.0 +x*x);
}
}
pi = step * sum;
printf("El numero Pi es: %2.10f\n", pi);
}

Como hay bastantes diferencias las vamos a ir viendo, no en secuencia de arriba abajo, como hemos hecho otras veces, si no de de la parte más interna del código, la que tiene más llaves a su alrededor.

La parte más interna corresponde al bucle 


 for (i = 0 ; i < num_steps; i++)                                                                                               


Cuyas condiciones de condiciones son las misma que teníamos en la primera versión del programa, la que no era multiproceso. 

Nos hemos "liberado" de tener que poner el código necesario para realizar el fraccionamiento del bucle que teníamos  para las versiones con proceso paralelo: 


#pragma omp parallel
    {
int i;
double x;
int ID=omp_get_thread_num();
int long stage;
sum[ID] =0.0;
stage = hop * ID;
for (i = stage ; i < stage + hop; i++) {                                                                              
x = (i + 0.5) * step;
sum[ID] += 4.0/(1.0 +x*x);
}

    }                       

Núcleo del programa en programación paralela


La directiva #pragma omp for schedule(static)  es quien nos gestiona este trabajo. Pero esta directiva es mucho más potente. En este ejemplo hemos tratado con la forma más sencilla. La directiva schedule tiene la siguente sintaxis:

schedule (tipo [,fraccionamiento])

  • tipo admite los valores: static, dynamic, guided.
  • fraccionamiento: es el rango de iteraciones que se va a asignar a cada proceso paralelo.
Si se especifica static y no se pone ningún valor para fraccionamiento, éste toma el valor del número de iteraciones divido entre el número de tareas, que en nuestro ejemplo es el algoritmo que usamos para calcular 

i = stage ; i < stage + hop

A partir de esta base podemos jugar con todas las demás posibilidades que tiene la directiva schedule y que será muy útil se cada iteración tiene dentro cálculos más complejos y menos uniformes que nuestro ejemplo. 

Saltando un escalón más hacia arriba nos encontramos con la ya conocida directiva  #pragma omp parallel  a la que hemos añadido algunos parámetros más:  

 private(i,x) reduction(+:sum)  

El primero  de ellos [private(v1, v2, ... vn)] da instrucciones para que en tiempo de ejecución se cree un juego de  esas variables en cada proceso paralelo y sólo accesible dentro de cada uno de ellos. Nos vale para sustituir las declaraciones de variables:


int i;
double x;

La sentencia [reduction(+:sum)] nos evita tener que difinir expresamente una matriz para para almacenar el resultado de cada proceso. Recordemos que debíamos de definir una matriz para los resultados y cada proceso almacenaba su resultado en un elemento de esa matriz. 

int ID=omp_get_thread_num();
int long stage;
sum[ID] =0.0;        
stage = hop * ID;
for (i = stage ; i < stage + hop; i++) {
x = (i + 0.5) * step;
sum[ID] += 4.0/(1.0 +x*x);

Y posteriormente debíamos "ensamblar" los resultados parciales: 


for (j = 0; j < NUM_TRHEADS; j++) pi += sum[j] * step;
printf("El numero Pi es: %2.10f\n", pi);

Resumiendo.

Gracias a la directiva for logramos poder realizar una programación paralela pero dejando los núcleos del algoritmo independiente de si el proceso de va a ejecutar en uno o varios threads. 
Me dejo bastantes más cosas pendientes pero que haría esta serie demasiado larga. 

En el ámbito de visibilidad de datos hemos visto private y reduction, pero tenemos otras como shared, firstprivate, lastprivate.

En la sincronización hemos visto la directiva critical, pero está: barrier, taskwait, atomic.

Y para finalizar la directiva for es una de las estructuras de comparticion de tareas, pero están también las directivas section, single.

Información sobre todas estas estructura se pueden encontrar en esta dirección:  https://computing.llnl.gov/tutorials/openMP/




miércoles, 29 de abril de 2015

Programación paralela (III) - Las secciones críticas.


En el post anterior tuvimos que buscar un medio para guardar los resultados parciales de cada thread. Aunque en ese caso la solución fue sencilla en otros casos no sólo la solución pues ser más compleja si no que no hay solución. Así de simple.
Estamos en el caso de variables que puedan servir para controlar ciertos comportamientos de los threads y que puedan ser modificados por cualquiera de ellos.
Siguiendo con nuestro programa para el cálculo del número pi vamos a usar una sola variable para el resultado y deberemos controlar el acceso a la misma.

La directiva critical


Este es el programa modificado:
#include <stdio.h>
#include <omp.h>
#include <sys/types.h>
static long num_steps = 100000000;
#define NUM_TRHEADS  2

int main()
{
double sum[NUM_TRHEADS], sum1;
double step;
double pi;
int j, hop, threads;

omp_set_num_threads(NUM_TRHEADS);
step = 1.0/(double) num_steps ;
hop = num_steps / NUM_TRHEADS;
sum1 = 0.0;
inicio = clock();
#pragma omp parallel
    {
int i;
double x;
int ID=omp_get_thread_num();
int long stage;
stage = hop * ID;
for (i = stage ; i < stage + hop; i++) {
x = (i + 0.5) * step;
#pragma omp critical
sum1 += 4.0/(1.0 +x*x);
}



pi = 0;
//for (j = 0; j < threads; j++ ) pi += step * sum[j];
pi = step * sum1;
printf("El numero Pi es: %2.10f\n", pi);
printf("Threads; %d -Tiempo consumido %2.5f segundos\n", threads, intervalo);
}
}

No usamos la matriz sum[NUM_TRHEADS] para almacenar los resultado parciales. En su lugar usaremos la variabla global sum1

Justo delante de la instrucción que suma el resultado de esa iteración colocaremos la directiva #pragma omp critical

Con esta sencilla orden estaremos colocando un semáforo que impedirá que esa parte de código se ejecute simultáneamente.
Una observación importante: la directiva critical no afecta a zonas de memoria compartida, si no a trozos de código que no se deben ejecutar simultáneamente.

Compilamos nuestro programa y lo ejecutamos. Sorprendentemente, o no tan sorprendentemente, el programa tarda mucho más que en las ejecuciones anteriores, incluso en la que solo tenemos un thread. Sin darle muchas vueltas, las colisiones/esperas  en la ejecución de la sección critica nos dan resultados no deseados.


Afinando la ubicación de las secciones críticas.


Haré unas pequeñas modificaciones en el programa y queda así:

#include <stdio.h>
#include <omp.h>
#include <sys/types.h>
static long num_steps = 100000000;
#define NUM_TRHEADS  2

int main()
{
//double sum[NUM_TRHEADS], sum1;
double step;
double pi;
int j, hop, threads;
clock_t inicio, fin;
double intervalo;

omp_set_num_threads(NUM_TRHEADS);
step = 1.0/(double) num_steps ;
hop = num_steps / NUM_TRHEADS ;
pi = 0.0;
inicio = clock();
#pragma omp parallel
    {
int i;
double x, sum;
int ID=omp_get_thread_num();
int long stage;
stage = hop * ID;
for (i = stage ; i < stage + hop; i++) {
x = (i + 0.5) * step;
sum += 4.0/(1.0 +x*x);
}
sum = sum * step;
#pragma omp critical
pi += sum;
    }



//for (j = 0; j < threads; j++ ) pi += step * sum[j];

printf("El numero Pi es: %2.10f\n", pi);
}
Y analicemos esas modificaciones.

  1. Seguimos con la variable global sum.
  2. Hemos creado una variable local en cada thread llamada sum y que es sobre la que haremos las sumas parciales.
  3. Hemos llevado la sección crítica fuera de la iteración del for aunque todavía dentro del bloque que se ejecutará en paralelo.

Compilamos y ejecutamos. 

La curva de ejecución ya se ve similar a la que tenemos para el ejemplo del bucle segmentado vimos en el post anterior.
En el primer ejemplo creamos la sección crítica dentro de la iteración por lo que corriamos el riesgo de tener num_steps / NUM_TRHEADS colisiones.
En el segundo ejemplo solo corremos el riesgo de tener NUM_TRHEADS colisiones, que en nuestro caso pasa de 50.000.000 a 2.


Conclusiones.

La más importante es que debemos de tener muy clara la necesidad de las secciones críticas. En nuestro caso se ha usado de forma didáctica. Y si tenemos clara la necesidad de usar secciones críticas hemos de analizar muy bien donde las colocamos pues una ubicación inadecuada puede tener resultados contraproducente.
En nuestro ejemplo esa incorrecta ubicación ha conseguido que el programa estrese dos procesadores y tenga peor rendimiento que si hubiéramos usado uno solo. Vaya un negocio que hemos hecho.
Y a veces nos soprendemos cuando metemos más CPUs y no se obtiene nada. Cosa que pasa con más frecuencia de la que sería deseable.

lunes, 9 de marzo de 2015

Programación paralela (II)

En el anterior post se introdujo un programa para el cálculo del número pi. Este programa es un intenso consumidor de instrucciones y en su bucle principal carece de llamadas las sistema por lo que no genera interrupciones.

Se puede hacer más o menos largo la duración del mismo (y la precisión del resultado) mediante la directiva:

static long num_steps = 100000000;

Su compilación, al no tener nada especial es muy sencilla:

gcc -o pi pi.c



La directiva pragma omp paralell


Modifiquemos nuestro programa para que pueda ser ejecutado paralelamente y quedará así:

#include <stdio.h>
#include <stdio.h>
#include <omp.h>
#include <sys/types.h>
static long num_steps = 100000000;
#define NUM_TRHEADS  2

int main()
{
double sum[NUM_TRHEADS];
double step;
double pi;
int j, hop;

omp_set_num_threads(NUM_TRHEADS);
step = 1.0/(double) num_steps ;
hop = num_steps / NUM_TRHEADS;


#pragma omp parallel
{
int i;
double x;
int ID=omp_get_thread_num();
int long stage;
sum[ID] =0.0;
stage = hop * ID;
for (i = stage ; i < stage + hop; i++) {
x = (i + 0.5) * step;
sum[ID] += 4.0/(1.0 +x*x);
}

}
pi = 0;
for (j = 0; j < NUM_TRHEADS; j++) pi += sum[j] * step;
printf("El numero Pi es: %2.10f\n", pi);

}


Analicemos las modificaciones:

Hemos creado una directiva del compilador con el número de threads, que como solo tengo dos procesadores será de dos. No tiene sentido poner más threads que procesadores disponibles.
Recalco que el número de THREADS debe ser una directiva  y no una variable.  Gran parte de las estructuras necesarias para la ejecución se construye en tiempo de compilación.

Hemos modificado la variable sum, que pasa a ser una matriz con tantos elementos como threads tengamos. Es la única variable que se necesita fuera del bucle para obtener el resultado final.

En la variable hop obtener el número de iteraciones que deberá realizar cada threads (en nuestro caso cada thread realizará la mitad de iteraciones.


La sentencia #pragma omp parallel señala la parte de código que se va a ejecutar en paralelo y la analizaremos posteriormente. Una vez finalizadas cada una de las threads que se ejecutan en paralelo hemos de sumar los resultados parciales para obtener el resultado final.

Lo compilaremos con esta instrucción:

gcc -fopenmp -o pi1 pi1.c


La parte de ejecución paralela.

Empieza la esta parte con la definición de las variables locales que necesitaremos.

Identificaremos cada thread usando la función omp_get_thread_num() y el valor obtenido será usado como índice en la matriz se resultados

      sum[ID] = 0;



El resto del algoritmo se puede traducir como:

Thread 0.
for(i = 0; i < 50000000; i ++) {
....
}

Thread 1.
for(i = 50000000; i < 100000000; i ++) {
....
}

Fraccionamos el total de iteraciones entre el número de threads que vamos a arrancar y asignamos a cada thread un segmento.

Ejecución.


Ejecutamos el programa sin paralelismo y vemos la gráfica de ocupación de CPU con el monitor del sistema.



La gráfica muestra tres ejecuciones la primera de ellas con una alternancia de ejecución en cada procesador. Probablemente debida a alguna interrupción generada en el sistema.
Las dos siguientes son más puras y podemos como cada ejecución del programa nos crea una meseta en cada ejecución.

Ejecutamos ahora el programa que hemos paralelizado y vemos su evolución en el monitor del sistema.  Se puede ver como ambos procesadores son ocupados por sendas threads.





Ejecutemos ahora el programa serializado y el programa que hemos paralelizado uno a continuación del otro y miremos la gráfica:




Podemos ver las mesetas creadas por cada ejecución. La meseta del programa serializado es de trazo simple (una línea) y es el doble de larga (más o menos) que la correspondiente al programa paralelizado. Siendo la meseta correspondiente al último programa de trazo doble.