Ir al contenido

Paralelismo híbrido MPI + OpenMP

  • Ley de Moore: la potencia de los supercomputadores sigue creciendo, pero ya no por relojes más rápidos sino por más núcleos. Un nodo actual tiene 2 sockets de 52 a 96 núcleos (Intel Xeon Max 9470, AMD EPYC 9004) y hasta 8 sockets en Sapphire Rapids.
  • Cómputo vs. memoria: los FLOPs crecieron mucho más rápido que el ancho de banda de la memoria y de la red. Mover datos es el cuello de botella.
  • Energía: la potencia dinámica de un chip CMOS crece como el cubo de la frecuencia: P_dyn(f) ≈ α · f³. Subir el reloj ya no compensa.

La consecuencia (lámina 6) es usar muchos núcleos lentos en vez de pocos rápidos (“the chickens and the ox”), aprovechar la memoria local de cada nodo (híbrido) y lograr mucho paralelismo. Para el programador eso significa conocer mejor el hardware y escribir código más complejo.

Memoria compartida (OpenMP)Memoria distribuida (MPI)
Los hilos leen y escriben en la RAM comúnLos procesos envían y reciben mensajes
Se sincronizan porque compiten por los datosSolo usan sus variables locales
Los hilos pertenecen a un procesoCada proceso tiene uno o más hilos

Según la lámina 7: MPI sigue predominando; el híbrido MPI + OpenMP (+ CUDA) se usa cada vez más en supercomputadores; y aparecen lenguajes PGAS (Partitioned Global Address Space: GASPI, Coarray Fortran, UPC, Chapel).

MPI se usa para la comunicación entre procesos y OpenMP dentro de cada proceso.

VentajasDesventajas
Mayor escalabilidad: aumenta la granularidad (menos procesos, más trabajo por proceso)Código más complejo
Mejor balance de carga (los hilos se reparten el trabajo del proceso)Hay que hacer bien las dos cosas: MPI y OpenMP
Mejor uso de memoria (menos copias de datos y de halos)Efectos NUMA: un hilo que lee la memoria del otro socket es más lento

Algoritmos que se benefician (lámina 11):

  1. los que tienen escalabilidad MPI limitada;
  2. los que necesitan balance de carga dinámico;
  3. los que corren en hardware con poca memoria por núcleo y mucha comunicación MPI;
  4. las aplicaciones de paralelismo masivo.

Para un nodo de 16 núcleos hay un espectro:

ModoProcesos MPIHilos por proceso
MPI puro (pure MPI node)161
Híbrido44
SMP puro (pure SMP node)116

En el híbrido, el hilo maestro de cada proceso es el que hace las llamadas MPI.

MPI puro: 16 × 1r0r1r2r3r4r5r6r7r8r9r10r11r12r13r14r1516 procesos, 1 hilo c/uhíbrido: 4 × 4h0h1h2h3h0h1h2h3h0h1h2h3h0h1h2h34 procesos (colores) × 4 hilosSMP puro: 1 × 16h0h1h2h3h4h5h6h7h8h9h10h11h12h13h14h151 proceso, 16 hiloscuadro = núcleo · rN = rank MPI · hN = hilo OpenMP del proceso de ese color

Cómo leerlo: el mismo nodo de 16 núcleos en los tres modos de la tabla. A la izquierda cada núcleo es un proceso MPI distinto (r0…r15). En el centro hay 4 procesos (un color cada uno) y cada proceso tiene 4 hilos OpenMP (h0…h3) que comparten su memoria. A la derecha un solo proceso con 16 hilos. En los tres casos se usan los 16 núcleos; cambia quién comparte memoria y cuántos mensajes hacen falta.

Repartir un nodo: procesos MPI × hilos OpenMP

Cada nodo tiene 8 núcleos. Elige cuántos procesos MPI van en cada nodo; el resto de núcleos se llena con hilos OpenMP dentro de cada proceso.

procesos MPI por nodo:
nodo-01 · memoria compartida
rank 0
nodo-02 · memoria compartida
rank 1
modoun proceso MPI por nodo (máximo OpenMP)
procesos MPI (-np)2
hilos por proceso8
núcleos trabajando16/16
En el cómputo trabajan todos los núcleos: 2 procesos × 8 hilos = 16. Dentro de un nodo los hilos comparten memoria (no envían mensajes); entre procesos se comunican con MPI.
script Slurm (estilo Khipu)
#!/bin/bash
#SBATCH --job-name=hibrido_mpi_openmp
#SBATCH --output=hibrido_mpi_openmp.out
#SBATCH --nodes=2
#SBATCH --ntasks=2
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=8

module load mpich/4.0
export OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK   # = 8
mpirun ./hibrido
salida de peekaboo (16 líneas, orden no determinista)
Peekaboo desde el hilo 0 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 4 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 7 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 6 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 2 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 1 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 2 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 3 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 6 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 5 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 4 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 0 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 1 de un total de 8 y desde el proceso 1 de un total de 2 en nodo-02
Peekaboo desde el hilo 7 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 5 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
Peekaboo desde el hilo 3 de un total de 8 y desde el proceso 0 de un total de 2 en nodo-01
  1. Se inicializa MPI.
  2. Se crean regiones paralelas OpenMP dentro de cada proceso.
  3. Se pueden llamar funciones MPI en las partes seriales y dentro de las regiones paralelas.
  4. Se finaliza MPI.
U4.5 — peekaboo_mpi_omp.c
#include <stdio.h>
#include "mpi.h"
#include <omp.h>
int main(int argc, char *argv[]) {
int numprocs, rank, namelen;
char processor_name[MPI_MAX_PROCESSOR_NAME];
int iam = 0, np = 1;
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Get_processor_name(processor_name, &namelen);
#pragma omp parallel num_threads(4) default(shared) private(iam, np)
{
np = omp_get_num_threads();
iam = omp_get_thread_num();
printf("Peekaboo desde el hilo %d de un total de %d y desde el proceso %d de un total de %d en %s\n",
iam, np, rank, numprocs, processor_name);
}
MPI_Finalize();
}

Compilar y ejecutar: hay que pasar las dos cosas, el compilador de MPI y la bandera de OpenMP.

Ventana de terminal
mpicc -fopenmp peekaboo_mpi_omp.c
mpirun -np 2 ./a.out

Salida real con 2 procesos × 4 hilos en una sola máquina (el nombre del equipo se cambió por mi-pc):

Peekaboo desde el hilo 3 de un total de 4 y desde el proceso 0 de un total de 2 en mi-pc
Peekaboo desde el hilo 1 de un total de 4 y desde el proceso 0 de un total de 2 en mi-pc
Peekaboo desde el hilo 0 de un total de 4 y desde el proceso 0 de un total de 2 en mi-pc
Peekaboo desde el hilo 1 de un total de 4 y desde el proceso 1 de un total de 2 en mi-pc
Peekaboo desde el hilo 3 de un total de 4 y desde el proceso 1 de un total de 2 en mi-pc
Peekaboo desde el hilo 2 de un total de 4 y desde el proceso 1 de un total de 2 en mi-pc
Peekaboo desde el hilo 0 de un total de 4 y desde el proceso 1 de un total de 2 en mi-pc
Peekaboo desde el hilo 2 de un total de 4 y desde el proceso 0 de un total de 2 en mi-pc

8 líneas (2 × 4) en orden no determinista: los dos procesos y sus hilos corren a la vez.

Una máquina de 268 nodos con 8 núcleos cada uno puede usarse como:

  • 1 proceso MPI × 8 hilos OpenMP por nodo;
  • 2 procesos × 4 hilos;
  • 4 procesos × 2 hilos.

Con 2 nodos, 1 proceso por nodo y 4 hilos, cada proceso imprime desde su nodo:

0 running on compute-2-25.local thread= 0 of 4
...
1 running on compute-3-14.local thread= 3 of 4
U4.5 — script Slurm híbrido en Khipu
#!/bin/bash
# Un Job script para la ejecución de un código híbrido MPI-OpenMP
#SBATCH --job-name=hibrido_mpi_openmp
#SBATCH --output=hibrido_mpi_openmp.out
#SBATCH --ntasks=4
#SBATCH --cpus-per-task=8
#SBATCH --partition=docencia
module load mpich/4.0
# OMP_NUM_THREADS = número de CPUs por tarea pedido a Slurm
export OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK
# No hace falta -np: mpirun lo toma de la configuración de Slurm
mpirun ./hibrido
module unload mpich/4.0

La clave: --ntasks = número de procesos MPI, --cpus-per-task = hilos por proceso, y OMP_NUM_THREADS se iguala a $SLURM_CPUS_PER_TASK.

Sistema heterogéneo (plataforma Cray) con 3072 CPUs (15.4 PFLOPs pico) y 1792 GPUs (69.5 PFLOPs pico). Cada AMD EPYC 7713 tiene 64 núcleos físicos y 128 hilos.

Un proceso MPI por núcleo físico
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=128
srun check-mpi.gnu.pm
Un proceso MPI por socket y un hilo OpenMP por núcleo físico
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=2
#SBATCH --cpus-per-task=128
export OMP_PROC_BIND=spread
export OMP_PLACES=threads
export OMP_NUM_THREADS=64
srun check-hybrid.gnu.pm

OMP_PROC_BIND=spread reparte los hilos lo más lejos posible entre sí (por todo el socket) y OMP_PLACES=threads fija cada hilo a un hilo de hardware. Así los hilos no migran de núcleo y cada proceso usa la memoria de su socket (evita efectos NUMA).

Ejemplo completo: matriz × vector híbrido (matvec_hibrido.cpp, semana 11)

Sección titulada «Ejemplo completo: matriz × vector híbrido (matvec_hibrido.cpp, semana 11)»

Es el programa de la práctica matriz × vector del parcial, ahora con MPI entre procesos y OpenMP dentro de cada uno:

PasoCódigoParadigma
1MPI_Init_thread(…, MPI_THREAD_FUNNELED, &provided)MPI: avisa que habrá hilos, pero solo el hilo maestro llama a MPI
2#pragma omp parallel for que pone A_loc y y_loc en 0first touch (abajo)
3MPI_Scatter(A, filas·N, …) + MPI_Bcast(x, N, …)MPI: cada proceso recibe N/p filas y el vector completo
4#pragma omp parallel for schedule(static) sobre las filas localesOpenMP: los hilos se reparten las filas del proceso
5MPI_Gather(y_loc, filas, …)MPI: rank 0 junta y
6verifica y[i] = (i+1)·N(N−1)/2resultado conocido, error relativo 0

Lo nuevo:

  • MPI_THREAD_FUNNELED: los niveles de soporte de hilos son SINGLE < FUNNELED < SERIALIZED < MULTIPLE. Aquí basta FUNNELED porque todas las llamadas MPI están fuera de las regiones paralelas, en el hilo maestro. Lo correcto es comprobar que provided ≥ el nivel pedido.
  • First touch (efecto NUMA): en Linux, una página de memoria se asigna en el banco de memoria del socket del hilo que la escribe primero. Si MPI_Scatter, que corre en el hilo maestro, escribiera A_loc primero, toda la matriz quedaría en el socket del maestro, y los hilos del otro socket leerían memoria remota (más lenta). Por eso cada hilo pone a 0 sus filas con el mismo schedule(static) que usará después. Es la “desventaja: efectos NUMA” de la lámina 10, resuelta en el código.
  • OMP_PROC_BIND=close OMP_PLACES=cores fija cada hilo a un núcleo, para que no migre y pierda esa localidad.
  • reps = 50: la matriz se reparte una vez (N² datos) y se multiplica 50 veces. El comentario del código separa “Cómputo (por producto)” de “Comunicación (una vez)”.

Medido al ejecutarlo en una laptop de 8 núcleos y 12 hilos (N = 4096, 50 productos), una corrida por configuración:

Procesos × hilosCómputo por productoComunicación (Scatter + Bcast + Gather)
1 × 80.0109 s (3.1 GFLOP/s)0.026 s
2 × 40.0090 s (3.7 GFLOP/s)0.024 s
4 × 20.0127 s (2.7 GFLOP/s)0.112 s
8 × 10.0030 s (11.2 GFLOP/s) ⚠️0.696 s

Cómo leerlo:

  • La comunicación crece con los procesos: repartir 4096² doubles (128 MB) cuesta 0.03 s con 1–2 procesos y 0.7 s con 8. Con más procesos hay más copias, y en un solo nodo los mensajes MPI son copias de memoria. Esa es la ventaja del híbrido: menos procesos ⇒ menos datos movidos.
  • ⚠️ El 11.2 GFLOP/s de 8 × 1 no es fiable. El tiempo lo mide solo el rank 0 y los MPI_Barrier del código están comentados. Cada proceso empieza a calcular apenas recibe su parte, así que el rank 0 no mide a los demás. Para comparar bien hay que descomentar las barreras y reducir con MPI_MAX el tiempo de todos (observación propia).
  • Matriz × vector hace 2 flops por cada double leído de A: está limitado por memoria, no por cálculo. Con pocos GFLOP/s el ancho de banda ya se satura.

¿Por qué el híbrido no siempre es más eficiente? (lámina 26)

Sección titulada «¿Por qué el híbrido no siempre es más eficiente? (lámina 26)»
  • OpenMP escala con el paralelismo implícito, pero solo hasta el límite de hardware de un nodo.
  • Durante la comunicación MPI, todos los hilos esperan (idle) menos uno.
  • Crear muchos hilos tiene un costo.
  • Faltan librerías y compiladores optimizados para OpenMP.

Ejemplo de la lámina 25 (ecuación de Poisson 3D en un IBM Blue Gene de 10 240 nodos × 4 núcleos): las versiones nuevas (optimizadas), tanto MPI puro como MPI + OMP, escalan mucho mejor que las viejas, y entre ellas la híbrida nueva es ligeramente mejor. La mejora viene sobre todo de optimizar el código, no solo de agregar OpenMP.

La lámina también menciona códigos reales: SPHYNX y SPH-EXA (hidrodinámica SPH) y el problema de N cuerpos con OMP vs HPX, híbrido y MPI + CUDA.

Tienes 3 nodos de 16 núcleos y quieres 2 procesos MPI por nodo. Escribe las líneas #SBATCH y el export necesarios. ¿Cuántas líneas imprime el peekaboo si no fijas num_threads(4)?

Ver solución
Ventana de terminal
#SBATCH --nodes=3
#SBATCH --ntasks-per-node=2 # 6 procesos MPI en total
#SBATCH --cpus-per-task=8 # 16 núcleos / 2 procesos
export OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK
mpirun ./hibrido

Sin num_threads(4), cada proceso usa OMP_NUM_THREADS = 8 hilos: 6 × 8 = 48 líneas. Con num_threads(4) en el código, la cláusula gana sobre la variable de entorno: 6 × 4 = 24 líneas, y quedan 4 núcleos ociosos por proceso.

  • Usar MPI_Init en un programa con hilos que llaman a MPI: hay que pedir el nivel con MPI_Init_thread (como mínimo MPI_THREAD_FUNNELED si solo el maestro llama a MPI).
  • Pedir más procesos × hilos que núcleos (sobresuscripción): los hilos se turnan el núcleo y el tiempo empeora.
  • No igualar OMP_NUM_THREADS a --cpus-per-task en Slurm: o sobran núcleos ociosos o hay sobresuscripción.
  • Suponer que el híbrido siempre gana al MPI puro (la lámina 26 explica por qué no).

Tienes 2 nodos de 16 núcleos y quieres 4 procesos MPI por nodo, con todos los núcleos ocupados. ¿Qué pides a Slurm y cuánto vale OMP_NUM_THREADS?

Ver solución

--nodes=2, --ntasks-per-node=4 (8 procesos en total), --cpus-per-task=4 y OMP_NUM_THREADS=4. Total: 8 procesos × 4 hilos = 32 núcleos = 2 × 16. Es el modo híbrido 4 × 4 de la figura, en cada nodo.

Autoevaluación

0/6 correctas · 0 respondidas
1. En un programa híbrido, MPI se usa para…
2. Un nodo de 8 núcleos con 2 procesos MPI debería usar…
3. En Slurm, el número de hilos OpenMP por proceso se pide con…
4. ¿Cómo se compila un programa híbrido?
5. Una desventaja del híbrido es…
6. La potencia dinámica de un CMOS crece con la frecuencia como…

Fuente ·U4.5 Paralelismo-hibrido.pdf (láminas 2–26) · semana_11/matvec_hibrido.cpp