1. Concurrencia compartida imperativa (Phtreads)
La concurrencia de estado compartido, concurrencia de recursos compartidos, o simplemente concurrencia compartida ocurre cuando varios trabajadores (hilos de ejecución) realizan una tarea concurrentemente y pueden acceder a regiones compartidas de memoria porque están dentro del mismo espacio de direcciones de memoria del proceso.
Los hilos de ejecución siguen este esquema, dado que comparten todos los segmentos del proceso (código, datos, y memoria dinámica), excepto el segmento de pila. Por ejemplo, un dato que un hilo escriba en la memoria compartida, otro hilo puede leerlo inmediatamente, sin hacer ninguna preparación especial. De esta forma, la memoria compartida se convierte en un mecanismo directo de comunicación entre los hilos de ejecución.
Se estudiarán en el curso tres tecnologías para implementar concurrencia compartida: Pthreads, OpenMP, y OpenACC. Éste último es de carácter opcional.
Los POSIX Threads es un estándar que permite a los desarrolladores crear y controlar hilos de ejecución en los sistemas operativos basados en Unix. Cree en una carpeta ejemplos/pthreads
para los siguientes ejemplos en su repositorio personal de control de versiones para el curso. Para cada ejemplo cree una subcarpeta que utilice el nombre dado entre corchetes.
1.1. Hola mundo (thread)
En la siguiente solución se incluye además un archivo de reglas Makefile
que permite a un desarrollador compilar el código fuente sin tener que recordar los argumentos con los que se debe invocar al compilador. Bastará que el programador emita el comando make
en la carpeta, y éste invocará al compilador siguiendo la primera regla estipulada en el Makefile
.
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde el hilo principal y desde un hilo secundario. |
||
Un archivo para compilar el código fuente sin tener que recordar los argumentos de invocación al compilador. También permite correr pruebas durante el desarrollo como quitar pelusas (linting), revisar accesos inválidos y fugas de memoria ( |
Para instalar el compilador (GCC), el analizador estático de código (cpplint
), el analizador dinámico de código (valgrind
), y el compilador Clang que instrumenta código con sanitizers, puede instalar los siguientes paquetes en un Linux basado en Debian:
sudo apt update
sudo apt install build-essential python3-pip valgrind clang
sudo pip3 install cpplint
Para compilar con GCC –o un compilador compatible– código fuente que use Pthreads, se necesita el parámetro -pthread
:
cc -g -Wall -Wextra hello.c -o hello -pthread
echo hello > .gitignore
git add hello.c .gitignore
Recuerde siempre ignorar los ejecutables en su repositorio de control de versiones. Para ello, cree o agregue a su archivo .gitignore
el nombre del ejecutable. Luego agregue el código fuente y el .gitignore
a control de versiones. Los dos últimos comandos de arriba hacen este trabajo. La Resultado de rastrear la memoria del programa hello muestra el resultado final de rastrear la memoria durante la ejecución del código.
1.2. Hola mundo múltiple (indeterminismo)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads dicen "hola mundo" junto con el hilo principal. |
||
Utiliza una variable para facilitar la reutilización del |
1.3. Hola mundo numerado (memoria privada)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads saludan indicando su identificador en la salida estándar. Esta versión usa una variable global para el mutex que no es necesario dado que la entrada y salida con formato de C internamente usa exclusión mutua. Se dejó como variable global sólo para silenciar el diagnóstico falso positivo que reporta Helgrind. En el siguiente ejemplo se corrige este problema. |
||
El Makefile cambia la variable para especificar el nombre del ejecutable y del archivo fuente que facilita modificaciones. |
1.4. Hola mundo numerado (memoria compartida)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads saludan indicando su identificador en la salida estándar. |
||
Cambia el nombre de la carpeta usando la variable. |
El código en este ejemplo es de suma importancia, porque es el código base para crear programas concurrentes con Pthreads. Este código permite a los hilos tanto compartir memoria como tener su propia memoria privada de trabajo sin incurrir en malas prácticas como el uso de variables globales.
1.5. Hola mundo ordenado (espera activa)
La espera activa (busy waiting) es un ciclo que hace a un hilo de ejecución esperar repetitivamente hasta que una condición se haga falsa. Por ejemplo, si la variable next_thread
indica el número del próximo thread que puede realizar una tarea que debe ser serializada en orden, el código
// Wait until it is my turn
while ( next_thread < my_thread_id )
; // busy-wait
// Do the ordered-task here
task();
// Allow subsequent thread to do the task
++next_thread;
Archivo | Descripción | Cambios |
---|---|---|
Varios threads saludan en orden gracias a la espera activa. |
||
Obtiene automáticamente el nombre del ejecutable de la carpeta donde se encuentre el |
1.6. Posición en la carrera (mutex)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads compiten en una carrera, y reportan el orden en que llegan a la meta. Utilizan exclusión mutua cuando llegan a la meta para incrementar la variable contadora correctamente y para evitar la condición de carrera en la impresión en la salida estándar. |
||
Sin cambios respecto a la versión anterior |
1.7. Hola mundo ordenado (semáforos)
Archivo | Descripción | Cambios |
---|---|---|
Utiliza una colección de semáforos, uno para cada hilo de ejecución. Sólo el semáforo para el hilo 0 está en verde. Cuando un hilo pasa su semáforo, saluda en la salida estándar y enciende el semáforo del siguiente. De esta forma, las impresiones se hacen en orden. |
||
Agrega variables para el compilador de C ( |
1.8. Hola mundo ordenado (seguridad condicional)
Archivo | Descripción | Cambios |
---|---|---|
Imprime en orden sin usar mecanismos de control de concurrencia. Usa una estructura de datos (un arreglo) donde cada hilo de ejecución escribe sus resultados sin afectar a los demás. |
||
Sin cambios. |
2. Concurrencia de tareas
Los dos tipos de problemas que resuelve el paradigma de programación concurrente son los que requieren incremento de desempeño y separación de asuntos.
-
El incremento del desempeño se busca principalmente al optimizar algorimos que requieren mucho poder de cómputo o procesar grandes volúmenes de datos. Son de especial interés para otras disciplinas que requieren apoyo de la computación. Se busca principalmente maximizar el paralelismo de datos a través de entes de ejecución (hilos o procesos) y disminuir la cantidad de comunicación entre los entes.
-
La separación de asuntos o concurrencia de tareas no busca tanto la optimización, sino que los entes realicen tareas distintas de forma correcta. Los problemas en esta categoría son de especial interés para la computación misma, dado que son aplicables a variedad de herramientas como sistemas operativos, motores de bases de datos, servidores web, entre otros. Típicamente los problemas en esta categoría buscan que el paralelismo de tareas genere resultados correctos a través de la aplicación de mecanismos de sincronización.
Esta sección se concentra en problemas del segundo tipo de la lista anterior, muchos de los cuales tienen aspecto de acertijos y resultan muy interesantes para el profesional de la computación. Están basados en el libro libre The Little Book of Semaphores escrito por Allen B. Downey.
2.1. Primitivas
2.1.1. Algoritmos (pseudocódigo)
Notación simplificada de pseudocódigo para expresar la parte concurrente de las soluciones.
// Declares a shared variable by all threads (ie. stored in shared_data record)
shared shared_x := initial_value
// Main subroutine that will be executed by the main thread
main:
// Create 3 threads that will execute the secondary function concurrently
// If the number of threads is omitted, 1 thread is created
// Optional remaning args are passed to the secondary subroutiny
create_thread(3, secondary, args)
// Threads are implicitly joined after main() finishes
secondary(args):
// A subroutine that will be executed by secondary threads
Archivo | Descripción | Cambios |
---|---|---|
Lista todas las posibles rutas de ejecución. |
||
Red de petri que representa el mismo programa que el pseudocódigo dado. Fue elaborado con la aplicación WoPeD. |
2.1.2. Redes de Petri
Archivo | Descripción | Cambios |
---|---|---|
Conjetura los potenciales resultados. |
2.1.3. Semáforos
La definición original de semáforo por Dijkstra puede variar ligeramente de algunas implementaciones. Para Dijkstra un semáforo es un entero con signo, con tres características:
-
Cuando se crea un semáforo, éste se inicializa con un entero cualquiera (negativo, cero, o positivo). Pero después de inicializado las únicas dos operaciones que están permitidas es incrementar en uno (signal) y decrementar en uno (wait) al semáforo. No se puede leer el valor actual del semáforo.
-
Cuando un hilo decrementa un semáforo, si el resultado es negativo, el hilo es bloqueado y no puede continuar hasta que otro hilo incremente el semáforo.
-
Cuando un hilo incrementa un semáforo, si hay otros threads esperando, uno de ellos será desbloqueado. Tanto el hilo que incrementa el semáforo como el que fue desbloqueado siguen ejecutándose concurrentemente. Si hay varios hilos esperando, no hay forma de saber cuál de ellos será el desbloqueado por el scheduler del sistema operativo. El programador no tiene forma de saber si al incrementar un semáforo, se desbloqueará o no un hilo en espera, dado que no se puede leer el valor actual del semáforo por la regla 1.
El valor actual de un semáforo indica lo siguiente. Si el valor es positivo indica la cantidad de hilos que pueden decrementar el semáforo sin bloquearse. Si es negativo indica la cantidad de hilos que están bloqueados esperando actualmente por el semáforo. Si el valor es cero, indica que no hay hilos esperando por el semáforo, pero si un thread trata de decrementarlo, será bloqueado.
Algunas implementaciones, por ejemplo POSIX, permiten probar la espera (sem_trywait). Esta función nunca bloquea al hilo que la invoca. Si se trata de esperar un semáforo que tiene un valor positivo, se decrementará el semáforo y el hilo continuará ejecutándose como una invocación normal a wait()
. Pero si se trata de esperar por un semáforo que quedaría en un valor negativo, la función sem_trywait()
no decrementa al semáforo ni bloquea al hilo, sino que retorna de inmediato indicando un código de error (por ejemplo, -1
). Dado que el hilo mantiene su ejecución, el programador puede decidir, condicionando (if
) el valor de retorno del sem_trywait()
y tomar distintas acciones que realice el hilo cuando se pudo o no esperar por el semáforo.
En los siguientes ejemplos se seguirá la definición original de Dijkstra, que no permite probar la espera. Se usará pseudocódigo con la siguiente sintaxis para los hilos y semáforos, con el fin de centrar la atención en estos temas y no en detalles de implementación de una tecnología particular (ej.: Pthreads):
sem := semaphore(3) // Create a semaphore with initial value 3
signal(sem) // Increment and wake a waiting thread if any
wait(sem) // Decrement and block if the result is negative
2.2. Patrones básicos
2.2.1. Avisar/notificar (signal)
Archivo | Descripción | Cambios |
---|---|---|
Usa un semáforo que indica cuando la instrucción |
||
Serializa tres hilos usando dos semáforos. El primer hilo avisa/notifica al segundo, y éste al tercero. |
||
Generaliza la solución para serializar w hilos de ejecución con w - 1 semáforos. El hilo i notifica al siguiente y el último notifica al primero. Este pseudocódigo representa en pocas líneas la solución hecha al problema Actividad 9 [hello_order_semaphor]. |
2.2.2. Encuentro (rendezvous)
Archivo | Descripción | Cambios |
---|---|---|
Usa dos semáforos para asegurar que dos threads han llegado a cierto punto. En esta solución, los dos threads avisan apenas hayan terminado de ejecutar las instrucciones |
||
En esta versión un thread primero espera y el otro avisa. Es una solución correcta aunque ligeramente menos eficiente que la versión 1. Hay dos variantes equivalentes: a) El hilo |
||
En esta versión ambos threads esperan a que el otro haya terminado de ejecutar su instrucción. No es una solución al problema porque genera un bloqueo mutuo (deadlock). |
2.2.3. Exclusión mutua con semáforos (mutex)
2.2.4. Exclusión mutua acotada (multiplex)
2.2.5. Barrera (barrier)
Archivo | Descripción | Cambios |
---|---|---|
Implementa una barrera de una pasada. Es decir, después de usada, la barrera no se debe reutilizar. |
La segunda implementación permite reutilizar barreras. Es decir en la solución de nuevos problemas puede suponer que tiene disponible barreras con la siguiente interfaz:
1
2
3
4
5
6
7
main:
shared my_barrier := barrier(3)
secondary:
...
wait(my_barrier)
...
2.2.6. Carrera de relevos (barrera)
Archivo | Descripción | Cambios |
---|---|---|
Diseño de una simulación de carrera de relevos con equipos de hilos de ejecución. |
||
relay_race.c |
Simula una carrera de relevos con equipos de hilos de ejecución en Pthreads. |
|
Sin cambios |
2.2.7. Misterio (variable de condición)
2.3. Productor-consumidor
2.3.1. Problema de buffer acotado
2.3.2. Problema de buffer no acotado
2.3.3. Patrón productor-consumidor
Archivo | Descripción | Cambios |
---|---|---|
Carpeta con los archivos fuente que simulan una red de un productor, un repartidor, y una cantidad de consumidores dada por argumentos de línea de comandos. |
||
La subcarpeta |
2.4. Filósofos comensales
(Pendiente)
2.5. Lectores y escritores
El problema de los lectores y escritores es de mucha importancia en la disciplina de la computación porque ocurre en muchos escenarios, como sistemas operativos y bases de datos.
2.5.1. Arreglo reentrante y thread-safe (candado de lectura-escritura)
2.6. Otros problemas de sincronización
2.6.1. Parejas de baile (colas con semáforos)
Archivo | Descripción | Cambios |
---|---|---|
dancing_pairs_1a.alg.c |
En el fondo esta solución es simplemente un encuentro (rendezvous). Cuando una persona llega a su fila A, enviará una señal a la otra fila B indicando que está listo para formar pareja. Si la fila B está vacía, la persona se quedará esperando hasta que alguien llegue a la fila B y le envíe una señal. Sin embargo, puede ocurrir que varios hombres bailen sin pareja o entre ellos, o viceversa. Busque una ruta de ejecución que produzca este comportamiento. |
|
dancing_pairs_1b.alg.c |
La versión anterior fue realizada en clase, donde un valor negativo en la cola de hombres significa la cantidad de mujeres esperando a que llegue un hombre (y viceversa). Esta es otra versión simétrica donde un valor negativo en la cola de hombres indica la cantidad de hombres esperando por ella (y viceversa). |
Archivo | Descripción | Cambios |
---|---|---|
dancing_pairs_2a.alg.c |
Solución propuesta por Julián y Roy. |
|
dancing_pairs_2b.alg.png |
Solución propuesta por Kevin Wang Qiu |
|
dancing_pairs_2c.alg.c |
Solución propuesta por Roy Rojas. |
2.6.2. Fumadores de cigarrillos
Originalmente presentado por Suhas Patil. Participan cuatro hilos:
-
Un agente que representa un sistema operativo que asigna recursos.
-
Tres fumadores que representan aplicaciones que necesitan los recursos para realizar su labor.
Los cuatro hilos están en un ciclo infinito. Los fumadores esperan por tres ingredientes que representan los recursos: papel, tabaco, y fósforos. Una vez que obtienen los tres ingredientes pueden fabricar un cigarrillo y fumarlo.
El agente tiene un suminitro infinito de todos los tres ingredientes, y cada fumador tiene un suministro infinito de uno de los tres ingredientes. Es decir, un fumador tiene sólo papel, otro tiene sólo tabaco, y el tercero tiene sólo fósforos. Sin embargo, los fumadores no se comunican entre ellos.
El agente repetidamente escoge dos ingredientes al azar y los hace disponible a los fumadores. El fumador con el tercer ingrediente debería recoger los dos disponibles y fabricar su cigarrillo. Por ejemplo, si el agente provee papel y tabaco, el fumador que tiene fósforos debería recogerlos y avisarle al agente que ya puede sacar otros dos ingredientes.
El reto de la solución es permitir a las aplicaciones, que cuentan con los recursos que necesitan, correr concurrentemente, y evitar despertar una aplicación que no puede proceder. Usted tiene el rol de un desarrollador de aplicaciones, quien no puede modificar el código del agente (sistema operativo). Esta es una restricción realista, dado que no se modifica el sistema operativo cada vez que alguien desarrolla una aplicación para el usuario.
El siguiente pseudocódigo provee la implementación del agente, el cual delega trabajo en sub-agentes que proveen los materiales. Se provee una implementación intuitiva para cada fumador. ¿Qué problema presenta esta solución?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
main:
shared agent_sem := semaphore(1)
shared match := semaphore(0)
shared paper := semaphore(0)
shared tobacco := semaphore(0)
create_thread(agent)
create_thread(smoker_with_matches)
create_thread(smoker_with_paper)
create_thread(smoker_with_tobacco)
agent:
create_thread(agent_without_matches)
create_thread(agent_without_paper)
create_thread(agent_without_tobacco)
agent_without_matches:
while true do
wait(agent_sem)
signal(paper)
signal(tobacco)
agent_without_paper:
while true do
wait(agent_sem)
signal(match)
signal(tobacco)
agent_without_tobacco:
while true do
wait(agent_sem)
signal(match)
signal(paper)
smoker_with_matches:
while true do
wait(paper)
wait(tobacco)
make_cigarette()
signal(agent_sem)
smoke()
smoker_with_paper:
while true do
wait(match)
wait(tobacco)
make_cigarette()
signal(agent_sem)
smoke()
smoker_with_tobacco:
while true do
wait(match)
wait(paper)
make_cigarette()
signal(agent_sem)
smoke()
Archivo | Descripción | Cambios |
---|---|---|
Solución propuesta por el autor original del problema. Adaptada del Pequeño libro de los semáforos. |
2.6.3. Formar agua
Este problema aparentemente está basado en un ejercicio de libro de programación concurrente de Gregory Andrews, y popularizado en el curso de sistemas operativos de la Universidad de California en Berkeley.
3. Paralelismo de datos
3.1. Optimización
La optimización es una etapa normalmente opcional del proceso de resolución de problemas (Fase de optimización en el proceso de resolución de problemas), que surge cuando las implementaciones no logran satisfacer los requerimientos de eficiencia de los usuarios. Consiste regresar a la fase de diseño, y encontrar un modelo solución más eficiente. Este modelo típicamente es más elaborado, complejo, con un efecto de hacer más difícil de comprender al código o hacerlo más dependiente de un contexto, por lo que debe tenerse el cuidado de que exista un retorno de la inversión. Resulta muy apremiante realizar esta fase del proceso de resolución de problemas de forma sistemática, por lo que se propone un método para optimizar del Método sugerido para optimizar.
3.2. Profiling
Para encontrar regiones de código que podrían ser optimizadas por su alto consumo de recursos se puede usar una herramienta de profiling (análisis dinámico de código ejecutable con fines de optimización) como callgrind
de valgrind
:
# Install tools
sudo apt install build-essential valgrind kcachegrind
# Compile executable including its source code
cc -g -Wall -Wextra source.c -o executable -pthread
# Run the program under callgrind
valgrind --tool=callgrind --separate-threads=yes ./executable args_for_executable
# Visualize the data collected by callgrind in the current directory
kcachegrind &
Callgrind realiza contabilidad de invocaciones de subrutinas y cantidad de instrucciones ejecutadas. Se requiere incluir el código fuente dentro del ejecutable al compilar el programa. Luego se corre el programa con callgrind
, el cual crea archivos en la carpeta donde se invoca con nombres como callgrind.PID-TH
donde PID
es el número de proceso y TH
el número de hilo, si se pide separar las estadísticas de cada hilo ejecutado por el programa. Finalmente se requiere un programa de visualización que tome las estadísticas almacenadas en los archivos y los presente de forma que ayude a las personas a comprender el comportamiento del programa, como KCachegrind
(en algunas distribuciones de Linux podría llamarse qcachegrind
).
KCachegrind permite encontrar rápida y visualmente las líneas de código fuente que consumen más instrucciones de procesador, y por tanto, las líneas críticas que convienen optimizar, incluso en una base de código extensa. En la esquina superior izquierda de la Visualización de KCachegrind de las líneas que consumen más procesamiento muestra un gráfico de consumo por cada hilo de ejecución. En la esquina inferior izquierda muestra las subrutinas que han ejecutado más instrucciones de procesador en porcentajes, isPrime()
está seleccionada. En la esquina superior derecha se muestra las líneas de código fuente de isPrime()
que consumieron más instrucciones. Se puede ver la invocación a qSqrt()
y el operador módulo (%
) son los que causan casi la totalidad del procesamiento. Estos son los puntos críticos a optimizar para esta aplicación.
3.3. Métricas
3.3.1. Incremento de velocidad (speedup)
Para determinar en cada iteración de optimización si hubo realmente una ganancia de rendimiento (pasos 1 y 5 del método sugerido), se puede utilizar varias métricas. La medida relativa speedup \$S\$ (incremento de velocidad), se calcula como la relación entre el tiempo que tarda una computación previa a la optimización (\$T_\text{before}\$), contra el tiempo que tarda la misma computación posterior la optimización (\$T_\text{after}\$):
La paralización de código serial es una forma de optimización. Una comparación de incremento de velocidad común es el tiempo de ejecución serial (antes) respecto al tiempo de ejecución posterior a la paralización (después). En este caso, el speedup indica la cantidad de veces que la computación paralela es más rápida que la computación serial. Un valor mayor a 1 indica un incremento de velocidad, 1 que la velocidad se mantiene igual, y un valor menor a 1 un decremento en la velocidad.
3.3.2. Eficiencia
El speedup sólo considera los tiempos de ejecución pero no los recursos que se invirtieron en conseguir el incremento de desempeño. La métrica de eficiencia (\$E\$, efficiency) es una relación entre el incremento de velocidad y la cantidad de trabajadores (\$w\$) que tuvieron que involucrarse para conseguirlo:
La eficiencia es un valor entre 0 y 1, donde 0 indica un sistema no eficiente, y 1.0 es la eficiencia ideal donde todo el trabajo es realizado en paralelo por los trabajadores, en forma equitativa y sin necesidad de control de concurrencia. Sin embargo, es poco probable que un programa logre una eficiencia de 1.0. Normalmente los programas tienen porciones de código secuencial (por ejemplo, antes de crear los hilos de ejecución, o al usar control de concurrencia como exclusión mutua) y porciones de código paralelo, como ocurre en la línea de tiempo de la Gráfico de tiempo de un programa con tres fases: serial inicial, paralela, y serial final.
3.3.3. Ley de Amdahl
La Ley de Amdahl establece que el máximo speedup que un programa puede alcanzar por paralelización está acotado por la porción del programa que se mantiene o sólo puede ser serial. Por ejemplo, para la Gráfico de tiempo de un programa con tres fases: serial inicial, paralela, y serial final la duración total del trabajo si se realizara en forma serial sería \$T_s = 1 + 24 + 1 = 26\$ horas. Si se tuviera una paralelización ideal, la fase paralelizable de 24h se podría repartir entre \$w\$ trabajadores y reducir a \$\frac{24}{w}\$ horas, por ejemplo en la Gráfico de tiempo de un programa con tres fases: serial inicial, paralela, y serial final se ilustran \$w = 3\$ trabajadores que reducirían la fase central a \$\frac{24}{3} = 8\$ horas. Por consiguiente la duración total de la versión paralela del programa sería \$2 + \frac{24}{w}\$ horas, y por lo tanto, el incremento de velocidad se obtendría por la relación:
Por ejemplo, con \$w = 3\$ trabajadores, el speedup sería \$S = \frac{26}{2 + \frac{24}{8}} = 2.6\$. Con ocho trabajadores se tendría el doble del speedup anterior \$S = 5.2\$. Para maximizar el speedup es natural pensar en incrementar la cantidad de trabajadores \$w\$, procurando que el denominador se acerque a cero. Sin embargo, en caso extremo, cuando \$w\$ tiende a infinito número de trabajadores se obtiene que
Es decir, para la situación del programa de la Gráfico de tiempo de un programa con tres fases: serial inicial, paralela, y serial final, el speedup está acotado a 13. Aunque la Ley de Amdahl puede verse como una limitación negativa al paralelismo, también puede usarse a favor. Si se sabe que para un programa concurrente el speedup máximo es 13, con unos pocos hilos o alquilar unas pocas máquinas en la nube, se podría alcanzar un speedup satisfactorio, en lugar de invertir en recursos costosos que poco incrementarían la velocidad, dado a que está acotada como muestra la El incremento de velocidad está acotado por la porción serial de un programa para 100 trabajadores.
3.4. Descomposición y mapeo
En un problema, la descomposición es identificar unidades de trabajo que se pueden realizar de forma independiente. El mapeo es asignar esas unidades de trabajo a los trabajadores.
La Actividad de mapear tres conjuntos de datos a cuatro trabajadores muestra el resultado de la actividad de mapear tres conjuntos de datos de distinta naturaleza, representados como arreglos en fondo celeste. El valor en cada celda celeste significa una unidad de trabajo medida como una potencial duración. Los números sobre el arreglo de fondo celeste indican el índice basado en cero.
Debajo de los arreglos celestes en la Actividad de mapear tres conjuntos de datos a cuatro trabajadores se anota el número de trabajador que realiza esa unidad de trabajo. El número varía de acuerdo al mapeo. Los mapeos pueden clasificarse en estáticos o dinámicos. En el mapeo estático la asignación de las unidades de trabajo se conoce de antemano, es decir, antes de iniciar el trabajo, sólo depende de conocer el número total de unidades de trabajo y trabajadores. El mapeo por bloque y cíclico son dos formas de mapeo estático. En el mapeo dinámico la asignación ocurre durante la realización del trabajo, es decir, los trabajadores se asignan las unidades de trabajo conforme ellos terminan las previas. Aunque normalmente consigue mejores distribuciones, es más costoso en recursos que los mapeos estáticos, dado que requiere control de concurrencia y manipulación de estructuras de datos dinámicas.
3.4.1. Mapeo por bloque
El mapeo estático por bloque asigna rangos continuos de trabajo a cada trabajador. Es el mapeo que potencialmente puede disminuir más fallos de caché o false sharing si se trabaja con memoria continua. El bloque de un trabajador \$i\$ está dado por el rango de índices enteros \$[\text{start}, \text{finish}[\$, donde \$\text{start}\$ y \$\text{finish}\$ son funciones dadas por
donde \$i\$ es el número del trabajador (iniciando en 0), \$D\$ es la cantidad de datos, y \$w\$ es la cantidad total de trabajadores.
3.4.2. Mapeo cíclico
El mapeo estático cíclico asigna al trabajador \$i\$ todas las unidades de trabajo con índice \${i,i+w, i+2w, ...}\$. Puede tener ventajas cuando las unidades de datos presentan algún patrón predecible y que no es apto para el mapeo por bloque. Se puede hacer un híbrido del mapeo cíclico por bloque que reparte no una unidad, sino bloques de unidades de trabajo de igual tamaño de forma cíclica a cada trabajador.
3.4.3. Mapeo dinámico
3.5. Concurrencia compartida declarativa (OpenMP)
OpenMP es una tecnología desarrollada por varias empresas para implementar concurrencia de manera más declarativa y menos imperativa. Es una especificación para C y Fortran que varios compiladores implementan. Conviene tener a mano la guía de referencia al iniciar con esta tecnología. Para los ejemplos de esta sección cree una carpeta openmp/
en su repositorio personal de control de versiones para el curso.
3.5.1. Hola mundo (región paralela, directivas)
El siguiente es un "Hola mundo" en OpenMP. En adelante se usará C++, aunque OpenMP puede usarse perfectamente con C.
Para compilar con GCC o un compilador compatible debe habilitarse OpenMP con la opción -fopenmp
. El Makefile
del ejemplo incluye esta bandera:
gcc -g -Wall -Wextra -fopenmp source.c -o executable
g++ -g -Wall -Wextra -fopenmp source.cpp -o executable
3.5.2. Hola mundo múltiple (funciones de biblioteca)
3.5.3. Repartir las iteraciones (omp parallel for
)
La directiva omp parallel
crea siempre una región paralela, que implica la creación y destrucción (join) de threads. La instrucción o bloque paralelizado es ejecutado por todos los _threads del equipo. La directiva omp parallel for
siempre debe estar seguida por un ciclo por contador (for
) estructurado (sin terminaciones abruptas como break
, continue
, y return
). Por ser una región paralela (por la palabra parallel
) también crea y destruye threads, pero a diferencia de omp parallel
, omp parallel for
reparte las iteraciones del ciclo entre los threads creados:
3.5.4. Reutilizar hilos (omp for
)
Si se tienen varias regiones parallel for
una tras otra que utilizan la misma cantidad de threads, es ineficiente crearlos y destruirlos cada vez que se ingresa y sale de estas secciones, de ahí la utilidad de la directiva for
.
3.5.5. Ordenamiento par-impar
3.5.6. Medición de duraciones con gprof
y perf
Los siguientes comandos resumen el uso de gprof
:
# Compile a modified executable that measures function call durations
gcc -g -Wall -Wextra -pg -no-pie source.c -o executable
# Run the executable as usual, it will generate binary file gmon.out
./executable args
# Generate a report from the gmon.out binary log
gprof ./executable gmon.out > report.txt
La herramienta perf
provee información menos detallada que gprof
, pero tiene la ventaja de que no es necesario modificar el ejecutable. Para obtener la duración se puede anteceder el comando con perf stat
de la forma:
perf stat ./executable args
3.5.7. Descomposición y mapeo (schedule
)
3.5.8. Reducciones (reduction
)
4. Concurrencia distribuida
El paradigma de programación distribuido permite crear programas escalables que aprovechan máquinas que pueden correr procesos y pueden comunicarse a través de redes de computadoras. Se distingue de la distribución compartida en que los hilos pueden acceder a la misma memoria y comparten el mismo reloj. En la concurrencia distribuida, los procesos tienen cada uno su propia memoria exclusiva y los relojes pueden ser distintos, por lo que tienen que comunicarse a través de paso de mensajes.
Existen dos variantes de la distribución. En la distribución heterogénea, los ambientes en el que corre el programa son distintos: diferente hardware, sistema operativo, huso horario, etc., lo que forma una malla de computadoras. En la distribución homogénea, el ambiente (tanto el hardware como el software) debe ser idéntico para todos los procesos del programa, lo que se llama un clúster de computadoras. La distribución homogénea logra conseguir los mayores niveles de paralelismo.
4.1. Distribución simétrica (MPI)
Message Passing Interface (MPI) es una tecnología de distribución homogénea creado por un grupo de académicos con el fin de facilitar el parelismo de aplicaciones científicas, que se convirtió en un estándar de facto. Existen otras tecnologías como Charm++ de más alto nivel.
Cree una carpeta ejemplos/mpi/
en su repositorio de control de versiones.
4.1.1. Hola mundo (proceso)
El siguiente es un "Hola mundo" en MPI:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde uno o varios procesos en una o varias máquinas. |
||
Para compilar programas con MPI se requiere instalar una implementación de MPI como |
||
Si se quiere que el programa corra en varios nodos de un clúster, se requiere un archivo que los liste. Este archivo está diseñado para mpich y el clúster arenal de la ECCI. Después de generado el ejecutable se puede invocar con |
4.1.2. Distribución híbrida (proceso-hilo)
4.1.3. Comunicación punto a punto (send-receive)
La comunicación punto a punto envía mensajes de un proceso a otro. MPI provee una familia de funciones para enviar y recibir mensajes. Las dos más básicas son: MPI_Send y MPI_Recv.
4.1.4. Lectura distribuida
MPI permite que sólo el proceso 0 lea de la entrada estándar. Más específicamente, la entrada la lee el comando mpiexec
, la captura y la envía al proceso 0. Los demás procesos no reciben la entrada, por tanto si intentan leer, se quedarán bloqueados perennemente. Si un proceso debe leer de la entrada estándar, el proceso 0 tendrá que usar comunicación para enviarla a los demás procesos.
4.1.5. Medición de tiempo de pared
Para calcular la duración en segundos del tiempo en la pared use la función MPI_Wtime.
4.1.6. Comunicación colectiva: broadcast
4.1.7. Comunicación colectiva: reduce
4.1.8. Comunicación colectiva: all-reduce
4.2. Distribución heterogénea: aceleración gráfica
Cree una carpeta ejemplos/gpu
en su repositorio personal.
4.2.1. Transmisión de calor
Archivo | Descripción | Cambios |
---|---|---|
heat_perf.ods |
Hoja de cálculo para anotar duraciones de ejecución de las actividades siguientes. |
|
Algunos casos de prueba binarios. |
Archivo | Descripción | Cambios |
---|---|---|
heat_serial.c |
(Versión serial de libro/documentación) |
|
heat_serial.c |
Recibe dos argumentos: un archivo binario que contiene una matrix de flotantes de doble precisión, y un epsilon. Simula transmisión de calor desde el borde de la matriz sobre toda la superficie, hasta que el calor se equilibre. Es decir, hasta que ninguna diferencia supere el epsilon. |
|
Un makefile para compilar con GCC. |
||
Un makefile para compilar con PGI (antiguamente The Portland Group). Establece la variable PATH dependiente del laboratorio 102-IF. Para usarlo use la opción |