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.