Está en la página 1de 22

Consideraciones de la programación en paralelo:

El objetivo principal de este capítulo es presentar los problemas comunes que


enfrenta un programador al implementar una aplicación paralela. El tratamiento
supone que el lector está familiarizado con la programación de un
monoprocesador utilizando un lenguaje convencional, como Fortran. El principal
desafío de la programación paralela es descomponer el programa en
subcomponentes que puedan ejecutarse en paralelo. Sin embargo, para
comprender algunos de los problemas de descomposición de bajo nivel, el
programador debe tener una visión simplificada de la arquitectura de máquinas
paralelas. Por lo tanto, comenzamos nuestro tratamiento con una revisión de este
tema, con el objetivo de identificar las características que son más importantes de
comprender para el programador paralelo. Esta discusión, que se encuentra en la
Sección 3.1, se centra en dos organizaciones principales de máquinas paralelas,
la memoria compartida y la memoria distribuida, que caracterizan a la mayoría de
las máquinas actuales. La sección también trata híbridos de los dos diseños de
memoria principales.
Las arquitecturas paralelas estándar admiten una variedad de estrategias de
descomposición, como la descomposición por tarea (paralelismo de tareas) y la
descomposición por datos (paralelismo de datos). Nuestro tratamiento
introductorio se concentrará en el paralelismo de datos porque representa la
estrategia más común para programas científicos en máquinas paralelas. En el
paralelismo de datos, la aplicación se descompone subdividiendo el espacio de
datos sobre el cual opera y asignando diferentes procesadores al trabajo asociado
con diferentes subespacios de datos. Normalmente, esta estrategia implica
compartir algunos datos en los límites, y el programador es responsable de
garantizar que este intercambio de datos se maneje correctamente, es decir, que
los datos calculados por un procesador y utilizados por otro estén sincronizados
correctamente.
Una vez que se elige una estrategia de descomposición específica, se debe
implementar. Aquí, el programador debe elegir el modelo de programación a
utilizar. Los dos modelos más comunes son los siguientes:
 El modo de memoria compartida, en el que se supone que todas las
estructuras de datos están asignadas en un espacio común que es
accesible desde todos los procesos.
 El medio de transmisión de mensajes, en el que se supone que dicho
procesador (o prensa) tiene su propio espacio de datos privado, y el mástil
de datos se mueve explícitamente entre espacios según sea necesario.
En el modelo de paso de mensajes, distribuimos las estructuras de datos entre las
memorias del procesador; Si un procesador necesita utilizar una raíz de datos que
no está almacenada localmente, el procesador propietario de esa raíz de datos
debe "enviar" explícitamente un al procesador solicitante. Este último debe
ejecutar una operación de recepción explícita, que está en banda sincronizada con
el envío, antes de poder utilizar el elemento de datos comunicado.
Para lograr un alto rendimiento en máquinas paralelas, el programador debe
preocuparse por la escalabilidad y la culpa de la carga. Generalmente, se piensa
que una aplicación es escalable si grandes configuraciones paralelas pueden
resolver problemas proporcionalmente mayores en el mismo tiempo de ejecución
que problemas más pequeños en configuraciones más pequeñas.
El equilibrio de carga generalmente significa que los procesadores tienen
aproximadamente la misma cantidad de trabajo, de modo que ningún procesador
detiene la solución completa. Para equilibrar la carga computacional en una
máquina con procesadores de igual potencia, el programador debe dividir el
trabajo y la comunicación de manera uniforme. Esto puede ser un desafío en
aplicaciones aplicadas a problemas que se desconocen hasta que se ejecutan
correctamente.
Un cuello de botella particular en la mayoría de las máquinas paralelas es el
rendimiento de la memoria, tanto en un nodo de ángulo como en toda la máquina.
Estas estrategias generalmente implican algún conjunto de "empleo" de bucles o
"minería a cielo abierto", de modo que todo el subcómputo encaje en la caché.
Los problemas irregulares o adaptativos presentan desafíos especiales para las
máquinas paralelas porque es difícil mantener el equilibrio de carga cuando el
tamaño de los subproblemas se desconoce hasta el momento o si el problema
puede cambiar después de que comienza la ejecución. Para abordar estos
problemas se requieren estos especiales que implican la reconfiguración en
tiempo de ejecución de un cálculo.
Varios aspectos de la programación de máquinas paralelas son mucho más
complicados que sus contrapartes para sistemas secuenciales. La depuración
paralela, por ejemplo, debe abordar las posibilidades de condiciones de carrera o
ejecución fuera de ceder. Los análisis y ajustes de actuación deben abordar los
problemas especialmente desafiantes de detectar desequilibrios de carga y
problemas de comunicación. Además, deben presentar información de diagnóstico
en el formato de usuario que esté relacionada con la estructura del programa y el
modelo de programación. Finalmente, entrada/salida en máquinas paralelas,
particularmente aquellas con memoria distribuida. Presenta problemas de cómo
convertir archivos que se distribuyen en discos de un sistema en memorias que se
distribuyen con el proceso.
Estos temas no representan todos los problemas de la programación paralela.
Esperamos, sin embargo, que una discusión sobre ellos transmita algo de la
terminología y la intuición de la programación paralela. Al hacerlo, sentará las
bases para el resto de este libro.
Consideraciones arquitectónicas:
El Capítulo 2 proporcionó una revisión detallada de las arquitecturas de
computadoras paralelas. En este capítulo, proporcionamos una introducción
simple a estos temas que cubre la mayoría de las cuestiones importantes
necesarias para comprender la programación paralela. Primero, como se analizó
en el Capítulo 2, observamos que la mayoría de las máquinas paralelas modernas
se dividen en dos categorías básicas:
1. Máquinas de memoria compartida, que tienen un único espacio de
direcciones compartido al que puede acceder cualquier procesador.
2. Máquinas de memoria distribuida, en las que la memoria del sistema está
empaquetada con nodos individuales de uno o más procesadores y se
requiere comunicación para proporcionar datos desde la memoria de un
procesador a un procesador diferente.
Memoria compartida:
La organización de una máquina de memoria compartida se muestra en la Figura
2.5. La Figura 3.1 muestra un diagrama ligeramente más detallado de un sistema
de memoria compartida con cuatro procesadores, cada uno con un caché privado,
interconectados a una memoria compartida global a través de un único bus del
sistema. Esta organización suele denominarse multiprocesador simétrico (SMP).
En un multiprocesador simétrico, cada procesador puede acceder a todas las
ubicaciones de la memoria global mediante operaciones de carga estándar. El
hardware garantiza que las cachés
son "coherentes" al observar el bus del sistema e invalidar las copias en caché de
cualquier bloque en el que se escriba. Este mecanismo generalmente es invisible
para el usuario, excepto cuando diferentes procesadores intentan escribir
simultáneamente en la misma línea de caché, lo que puede causar que la línea de
caché haga ping-pong entre dos cachés diferentes, una situación conocida como
desperdiciar. Para evitar este problema, el programador y el sistema de
programación deben tener cuidado con las estructuras de datos compartidas y las
estructuras de datos no compartidas que pueden ubicarse en el mismo bloque de
caché, una situación conocida como intercambio falso. La sincronización de los
accesos a estructuras de datos compartidas es un problema importante en los
sistemas de memoria compartida: corresponde al programador garantizar que las
operaciones realizadas por diferentes procesadores en una estructura de datos
compartida dejen esa estructura de datos en un estado consistente. En la Sección
2.2.1 se analizan varios modelos de coherencia de la memoria.
El principal problema con el sistema de memoria compartida descrito
anteriormente es que no es escalable a una gran cantidad de procesadores. La
mayoría de los sistemas basados en bus están limitados a 32 procesadores o
menos debido a la contención en el bus. Si el bus se reemplaza por un
conmutador de barra transversal, los sistemas pueden escalar hasta 128
procesadores, aunque el costo del conmutador aumenta como el cuadrado del
número de procesadores, lo que hace que esta organización no sea práctica para
un número realmente grande de procesadores. Se pueden hacer cambios de
varias etapas para escalar mejor a costa de latencias más largas en la memoria.
3.1.2 Memoria distribuida:
Las limitaciones de escalabilidad de la memoria compartida han llevado a los
diseñadores a utilizar organizaciones de memoria distribuida como la que se
muestra en la Figura 3.2. Aquí la memoria compartida global ha sido reemplazada
por una memoria local más pequeña adjunta a cada procesador. La comunicación
entre las configuraciones de procesador-memoria se realiza a través de una red
de interconexión. Estos sistemas pueden hacerse escalables si se utiliza una red
de interconexión escalable. Por ejemplo, un hipercubo ha costado una tonelada
proporcional lg(n), donde n es el número de procesadores.
La ventaja de un diseño de memoria distribuida es que el acceso a los datos
locales puede ser bastante rápido. Por otro lado, el acceso a memorias remotas
requiere mucho más
Esfuerzo. La mayoría de los sistemas de memoria distribuida admiten un modelo
de programación de paso de mensajes, en el que el procesador que posee un
dato debe enviarlo a cualquier procesador que lo necesite. Estos pasos de
comunicación de "envío-recepción" normalmente implican largos tiempos de inicio,
aunque el ancho de banda después del inicio puede ser alto. Por lo tanto, en los
sistemas de transmisión de mensajes, normalmente vale la pena enviar menos
mensajes y más largos.
El principal problema de programación para los sistemas de memoria distribuida
es la gestión de la comunicación entre procesadores. Por lo general, esto significa
consolidación de mensajes entre el mismo par de procesadores y superposición
de comunicación y cálculo para ocultar largas latencias. Además, la ubicación de
los datos es importante para que sea necesario comunicar la menor cantidad
posible de referencias de datos.
3.1.3 Sistemas híbridos:
Como se vio en el capítulo 2, hay varias maneras en que se combinan los dos
paradigmas de la memoria. Algunas máquinas de memoria distribuida permiten
que un procesador acceda directamente a un dato en una memoria remota. En
estos sistemas de memoria compartida distribuida (DSM), la latencia asociada con
una carga varía según la distancia a la memoria remota. La coherencia de la
caché en los sistemas DSM es un problema complejo que normalmente se maneja
mediante una unidad de interfaz de red sofisticada. Dado que los sistemas DSM
tienen tiempos de acceso más prolongados a la memoria remota, la ubicación de
los datos es una consideración de programación importante.
Para sistemas paralelos muy grandes, es común una arquitectura híbrida llamada
clúster SMP. Un clúster SMP parece un sistema de memoria distribuida en el que
cada uno de los componentes individuales es un multiprocesador simétrico en
lugar de un nodo de procesador único. Este diseño permite una alta eficiencia
paralela dentro de un nodo multiprocesador, al tiempo que permite que los
sistemas escalen a cientos o incluso miles de procesadores. La programación
para clústeres SMP ofrece todos los desafíos de los sistemas de memoria
distribuida y compartida. Además, requiere una reflexión cuidadosa sobre cómo
dividir el paralelismo dentro y entre los nodos computacionales.
3.1.4 Jerarquía de memoria:
Como se analizó en el Capítulo 2, el diseño de jerarquías de memoria es una parte
integral del diseño de sistemas informáticos paralelos porque la jerarquía de
memoria es un factor determinante en el rendimiento de los nodos individuales en
el conjunto de procesadores. En la Figura 3.3 se muestra una jerarquía de
memoria típica. Aquí el procesador y una memoria caché de nivel 1 (L1) se
encuentran en el chip, y una caché más grande de nivel 2 (1.2) se encuentra entre
el chip y la memoria.
Cuando un procesador ejecuta una instrucción de carga, primero se interroga la
caché L1 para determinar si el dato deseado está disponible. Si es así, los datos
se pueden entregar al procesador en dos a cinco ciclos de procesador. Si el dato
no se encuentra en la caché L1, el procesador se detiene mientras se interroga la
caché 12. Si el dato deseado se encuentra en 12, entonces la pérdida puede durar
sólo de 10 a 20 ciclos. Si el dato no se encuentra en ninguno de los cachés, se
toma un error de caché completo con un retraso de posiblemente 100 ciclos o
cada vez que se produce un error, el dato se guarda en cada caché de la
jerarquía, si aún no está allí. Tenga en cuenta que en las máquinas modernas, los
cachés transfieren datos en un bloque de caché de tamaño mínimo, de modo que
cada vez que se carga un dato en ese caché, todo el bloque que contiene ese
dato viene con él.
El rendimiento de la jerarquía de memoria está determinado por dos parámetros
de hardware: latencia, que es el tiempo necesario para recuperar un dato deseado
de la memoria, y ancho de banda, que es el número de bytes por unidad de
tiempo que se pueden entregar desde la memoria en a toda velocidad. Las
latencias largas aumentan el costo de los errores de caché, lo que ralentiza el
rendimiento, mientras que el ancho de banda limitado puede hacer que las
aplicaciones queden "limitadas a la memoria", es decir, se estanquen
continuamente esperando datos. Estos dos factores se ven complicados por la
naturaleza multinivel de las jerarquías de memoria, porque cada nivel tendrá un
ancho de banda y una latencia diferentes para el siguiente nivel. Por ejemplo, SGI
Origin 2000 puede entregar aproximadamente 4 bytes por ciclo de máquina desde
la caché L1 al procesador y 4 bytes por ciclo desde la caché L2 a la caché L1,
pero solo puede entregar aproximadamente 0,8 bytes por ciclo desde la memoria
a L1. caché [272].
Otro parámetro importante que afecta el rendimiento de la memoria en un
monoprocesador es la longitud del bloque de caché estándar (o línea de caché).
La mayoría de los sistemas de caché sólo transferirán bloques de datos entre
niveles de la jerarquía de memoria. Si se utilizan todos los datos transferidos en un
bloque, no se desperdicia ancho de banda. En ese caso, el costo de la pérdida de
caché se puede amortizar entre todos los datos del bloque. Si sólo se utilizan uno
o dos elementos de datos, la latencia promedio es mucho mayor y el ancho de
banda efectivo mucho menor.
Hay dos tipos de estrategias para superar los problemas de latencia. El
ocultamiento de latencia intenta superponer la latencia de un error con el cálculo.
Precarga de caché
líneas es una estrategia para ocultar la latencia. La tolerancia a la latencia, por
otro lado, intenta reestructurar un cálculo para que esté menos sujeto a problemas
de rendimiento debido a latencias prolongadas. La técnica de tolerancia de
latencia más importante es el bloqueo de caché, que acerca en el tiempo los
accesos a las mismas ubicaciones, de modo que los accesos posteriores al
primero probablemente encuentren los datos deseados en la caché.
Las estrategias que mejoran la reutilización de la memoria caché también mejoran
la utilización eficaz del ancho de banda. Quizás la forma más importante de
garantizar una buena utilización del ancho de banda sea organizar los datos y los
cálculos para utilizar todos los elementos de una línea de caché cada vez que se
recuperan de la memoria. Garantizar que los cálculos accedan a matrices de datos
a pasos agigantados es un ejemplo de cómo se podría hacer esto.
Las jerarquías de memoria en máquinas paralelas son más complicadas debido a
la existencia de múltiples cachés en sistemas de memoria compartida y las largas
latencias de las memorias remotas en configuraciones de memoria distribuida.
También puede haber interferencia entre las transferencias de datos entre
memorias y desde la memoria local a un procesador.
3.2 Programas de descomposición por paralelismo:
Dado que ha decidido implementar un programa para una máquina paralela, hay
cuatro cuestiones principales que debe abordar. Primero, debe tener una forma de
identificar los componentes del cálculo que se pueden ejecutar en paralelo de
forma segura. En segundo lugar, es necesario adoptar una estrategia para
descomponer el programa en componentes paralelos. En tercer lugar, debe
escribir el programa paralelo, lo que requiere que elija un modelo de programación
y una interfaz para la implementación. Finalmente, debe elegir un estilo de
implementación que sea efectivo para la aplicación dada y que funcione bien con
el modelo de programación elegido. En esta sección analizamos cada una de
estas cuestiones. E ilustrarlos con un ejemplo ampliado al final.
3.2.1 Identificación del paralelismo:
La primera tarea en una implementación paralela es identificar las partes del
código donde existe paralelismo para explotar. Para ello debemos abordar una
pregunta fundamental: ¿Cuándo podremos ejecutar dos cálculos diferentes en
paralelo? No podemos responder a esta pregunta sin pensar en lo que significa
que dos cálculos se ejecuten en paralelo. La mayoría de los programadores
piensan que el significado de un programa se define mediante la implementación
secuencial. Es decir, para que una implementación paralela sea correcta, debe
producir las mismas respuestas que la versión secuencial cada vez que se
ejecuta. Entonces la pregunta es: ¿Cuándo podemos ejecutar dos cálculos de un
programa secuencial dado en paralelo y esperar que las respuestas sean las
mismas que las producidas por el programa secuencial? Cuando decimos
"ejecutar en paralelo", queremos decir de forma asincrónica, con sincronización al
final. Por lo tanto, la versión paralela del programa generará una serie de procesos
paralelos para manejar diferentes cálculos, y cada uno de los cálculos se ejecutará
hasta el final, cuando se sincronizarán.
La respuesta ingenua a la pregunta es que podemos ejecutar cálculos en paralelo
si no comparten datos. Sin embargo, podemos refinar esto sustancialmente.
Ciertamente, no causa ningún problema si dos cálculos leen los mismos datos
desde una ubicación de memoria compartida. Por lo tanto, para que el intercambio
de datos cause un problema, uno de los cálculos debe escribir en una memoria a
la que el otro accede ya sea leyendo o escribiendo. Si este es el caso, entonces el
orden de esas operaciones de memoria es importante. Si el programa secuencial
escribe en una ubicación en el primer cálculo y luego lee desde la misma
ubicación en el segundo cálculo, paralelizar el cálculo podría hacer que la lectura
se ejecute primero, lo que generaría respuestas incorrectas. Esta situación se
denomina carrera de datos.
En la década de 1960, Bernstein [101] formalizó un conjunto de tres condiciones
que capturaban esta noción. A los efectos de la paralelización, estas tres
condiciones se pueden establecer de la siguiente manera: Se pueden ejecutar dos
cálculos C₁ y C₂ en paralelo sin sincronización si y sólo si no se cumple ninguna
de las siguientes condiciones:
1. C₁ escribe en una ubicación que luego es leída por C₂, una carrera de
lectura después de escritura (RAW).
2. C₁ lee desde una ubicación en la que luego C₂ escribe una escritura
después de lectura (WAR) carrera.
3. C₁ escribe en una ubicación que luego se sobrescribe con C₂, una carrera
de escritura tras escritura (WAW).
Veremos cómo estas condiciones pueden aplicarse en la práctica a estructuras de
programación comunes.
3.2.2 Estrategia de descomposición
Otra tarea importante al preparar un programa para ejecución paralela es elegir
una estrategia para descomponer el programa en partes que puedan ejecutarse
en paralelo. En términos generales, hay dos formas de hacer esto. Primero, podría
identificar las tareas (fases principales) en el programa y las dependencias entre
ellas y luego programar aquellas tareas que no son interdependientes para que se
ejecuten en paralelo. En otras palabras, diferentes procesadores llevan a cabo
diferentes funciones. Este enfoque se conoce como paralelismo de tareas. Por
ejemplo, un procesador podría manejar la entrada de datos desde el
almacenamiento secundario, mientras que un segundo genera una cuadrícula
basada en la entrada recibida previamente.
Una segunda estrategia, llamada paralelismo de datos, subdivide el dominio de
datos de un problema en múltiples regiones y asigna diferentes procesadores para
calcular los resultados para cada región. Por lo tanto, en una simulación 2-D en
una grilla de 1000 x 1000, se podrían usar efectivamente 100 procesadores
asignando cada uno a una subcuadrícula de 100 x 100. Luego, los procesadores
se organizarían como una matriz de procesadores de 10 x 10. El paralelismo de
datos se usa más comúnmente en problemas científicos porque puede mantener
ocupados a más procesadores; el paralelismo de tareas generalmente se limita a
pequeños grados de paralelismo. Además, el paralelismo de datos exhibe una
forma natural de escalabilidad. Si tiene 10 000 procesadores para aplicar al
problema anterior, podría resolver un problema en una cuadrícula de 10 000 x 10
000 celdas, con cada procesador todavía asignado a un subdominio de 100 x 100.
Dado que el cálculo por procesador permanece
De la misma manera, el problema más grande debería tomar solo un tiempo de
ejecución modestamente más largo que el problema más pequeño se hace cargo
de la configuración de la máquina más pequeña.
Como veremos, el paralelismo de tareas y datos se puede combinar. La forma
más común de hacer esto es utilizar canalización, una estrategia de software
análoga al método de hardware descrito en la Sección.
2.1.1, en la que cada procesador se asigna a una etapa diferente de un cálculo
secuencial de varios pasos. Si se pasan muchos conjuntos de datos
independientes a través de la canalización, cada etapa puede realizar su cálculo
en un conjunto de datos diferente al mismo tiempo. Por ejemplo, supongamos que
el oleoducto tiene cuatro etapas. La cuarta etapa trabajaría en el primer conjunto
de datos, mientras que la tercera etapa trabajaría en el segundo conjunto de
datos, y así sucesivamente. Si los pasos son aproximadamente iguales en tiempo,
la canalización en cuatro etapas proporciona una aceleración adicional de un
factor de cuatro con respecto al tiempo necesario para procesar un solo conjunto
de datos, una vez que se ha llenado la canalización.
3.2.3 Modelos de programación
Otra consideración a la hora de formar un programa paralelo es qué modelo de
programación utilizar. Esta decisión afectará la elección del sistema de lenguaje
de programación y la implementación de la biblioteca de la aplicación. Las dos
opciones principales estaban originalmente pensadas para su uso con las
arquitecturas paralelas correspondientes.
• En el modelo de programación de memoria compartida, todos los datos a
los que accede la aplicación ocupan una memoria global accesible desde todos
los procesadores paralelos. Esto significa que cada procesador puede recuperar y
almacenar datos en cualquier ubicación de la memoria de forma independiente. La
programación paralela de memoria compartida se caracteriza por la necesidad de
sincronización para preservar la integridad de las estructuras de datos
compartidas.

• En el modelo de paso de mensajes, los datos se consideran asociados con


procesadores particulares, por lo que se requiere comunicación para acceder a
una ubicación de datos remota. Generalmente, para obtener un dato de una
memoria remota, el procesador propietario debe enviar el dato y el procesador
solicitante debe recibirlo. En este modelo, las primitivas de envío y recepción
reemplazan la sincronización.
Aunque estos dos modelos de programación están inspirados en las
correspondientes arquitecturas informáticas paralelas, su uso no está restringido.
Es posible implementar el modelo de memoria compartida en una computadora
con memoria distribuida, ya sea a través de hardware (memoria compartida
distribuida) o sistemas de software que simulan DSM (por ejemplo, Tread-Marks
[31]). De manera simétrica, se puede hacer que el paso de mensajes funcione con
una eficiencia razonable en un sistema de memoria compartida. En cada caso
puede haber alguna pérdida de rendimiento. Sin embargo, durante el resto de esta
sección asumiremos que el modelo de memoria compartida está asociado con
SMP y el modelo de paso de mensajes se utiliza en sistemas de memoria
distribuida.
3.2.4 Estilos de implementación
Pasemos ahora a las cuestiones relacionadas con la implementación de
programas paralelos. Comenzamos con el paralelismo de datos, la forma más
común de paralelismo en la ciencia códigos. Normalmente hay dos fuentes de
paralelismo de datos: bucles iterativos y recorrido recursivo de estructuras de
datos en forma de árbol. A continuación analizamos cada uno de estos por
separado. Los bucles de datos paralelos normalmente se implementan usando
dos estilos: en sistemas de memoria compartida, corresponden a bucles
explícitamente paralelos en los que las iteraciones no están sincronizadas,
mientras que en sistemas de memoria distribuida, el estilo de programa único,
datos múltiples (SPMD) es más utilizado. Utilizado a menudo.
Programación de bucle paralelo
Los bucles representan la fuente más importante de paralelismo en los programas
científicos. La forma típica de paralelizar bucles es asignar diferentes iteraciones,
o diferentes bloques de iteraciones, a diferentes procesadores. En los sistemas de
memoria compartida, esta descomposición suele codificarse como una especie de
bucle DO PARALELO. Según Bernstein, podemos hacer esto sin sincronización
sólo si no hay carreras de datos entre las iteraciones del bucle. Por lo tanto,
debemos examinar el bucle cuidadosamente para ver si hay lugares donde se
produce un intercambio de datos de este tipo. En la literatura sobre construcción
de compiladores, este tipo de carreras se identifican como dependencias [27].
Estos conceptos se pueden ilustrar con un ejemplo sencillo. Considere el bucle:
¿Yo 1, norte?
A(I) = A(I) + C
ENDO
Aquí, cada iteración del bucle accede a un elemento diferente de la matriz A para
que no se compartan datos. Por otro lado, en el bucle
¿Yo 1, norte?
A(yo) A(+1) + C
ENDO
habría una carrera de escritura tras lectura porque el elemento de A que se lee en
cualquier iteración dada es el mismo que el elemento de A que se escribe en la
siguiente iteración. Si las iteraciones se ejecutan en paralelo, la escritura podría
tener lugar antes de la lectura, lo que provocaría resultados incorrectos. Por tanto,
el objetivo principal de la paralelización de bucles es el descubrimiento de bucles
que no tienen carreras. En algunos casos, es posible lograr un paralelismo
significativo en presencia de razas. Por ejemplo, considere:
18
SUMA = 0.0
¿Yo 1, norte?
R = F(B(I),C(I)) ! un cálculo costoso
YO SOY +R
ENDO
Hay una carrera en este bucle que involucra la variable SUM, que se escribe y lee
en cada iteración. Sin embargo, si asumimos que la suma de punto flotante es
conmutativa y asociativa (lo cual no es así en la mayoría de las máquinas),
entonces el orden en el que se obtienen los resultados.
agregado a SUM no importa. Dado que suponemos que el cálculo de la función
Fist es costoso, aún se puede lograr cierta ganancia si calculamos los valores de F
en paralelo y luego actualizamos SUM en el orden en que finalizan esos cálculos.
Para que esto funcione, debemos asegurarnos de que solo un procesador
actualice SUM a la vez y que cada uno finalice antes de que se permita que
comience el siguiente. En los sistemas de memoria compartida, las regiones
críticas (segmentos de código que pueden ser ejecutados por un solo procesador
a la vez) están diseñados para hacer exactamente esto. Aquí hay una posible
realización de la versión paralela:
SUMA = 0.0
PARALELO DO I = 1, N
R F(B(I),C(I)) ! un cálculo costoso COMIENZO REGIÓN CRÍTICA
SUMA SUMA
FINALIZAR LA REGIÓN CRÍTICA
ENDO
La región crítica garantiza que SUM sea actualizado por un procesador a la vez
por orden de llegada. Debido a que las reducciones de suma de este tipo son
realmente importantes en el cálculo paralelo, la mayoría de los sistemas ofrecen
una función primitiva que calcula dichas reducciones utilizando un esquema que
requiere un tiempo proporcional al logaritmo del número de procesadores.
Programación SPMD
Un programador que desee realizar la reducción de la suma anterior en un sistema
de paso de mensajes de memoria distribuida necesitará reescribir el programa
para utilizar el paso de mensajes explícito. Por conveniencia, el programador
empleará a menudo el estilo SPMD [246, 525]. En un programa SPMD, todos los
procesadores ejecutan el mismo código, pero lo aplican a diferentes partes de los
datos. Las variables escalares normalmente se replican en todos los procesadores
y se calculan de forma redundante (con valores idénticos) en cada procesador.
Además, el programador debe insertar primitivas de comunicación explícitas para
poder pasar los datos compartidos entre procesadores. Para el cálculo de
reducción de suma anterior, el programa SPMD podría verse así:
! Este código es ejecutado por todos los procesadores.
! MYSUM, MYFIRST, MYLAST, R e I son variables locales privadas
! ¡MYFIRST y MYLAST se calculan por separado en cada procesador! para
señalar las secciones que no se cruzan de B y C
! GLOBALSUM es una primitiva de comunicación colectiva global.
MISUMA = 0.0
¿YO = MI PRIMERO, MI ÚLTIMO?
Aquí la comunicación está integrada en la función GLOBALSUM, que toma un
valor de su parámetro de entrada de cada procesador y calcula la suma de todas
esas entradas, almacenando el resultado en una variable que se replica en cada
procesador. La implementación de GLOBALSUM suele utilizar un algoritmo
logarítmico. Las primitivas de comunicación explícita y la programación SPMD se
ilustrarán con más detalle en el ejemplo de paralelismo de canalización en la
Sección 3.2.5.
3.2.5 Programación de tareas recursiva
Para manejar el paralelismo recursivo en una estructura de datos en forma de
árbol, el programador normalmente crearía un nuevo proceso o subproceso
siempre que sea necesario recorrer dos caminos diferentes en el árbol en paralelo.
Por ejemplo, una búsqueda de un valor particular en un árbol desordenado
examinaría primero la raíz. Si no se encuentra el valor, se bifurcaría un proceso
separado para buscar en el subárbol derecho y luego buscar en el subárbol
izquierdo.
Un ejemplo sencillo
Concluimos esta sección con una discusión de un problema simple que pretende
parecerse a un cálculo en diferencias finitas. Mostramos cómo se podría
implementar este ejemplo utilizando un modelo de bucle paralelo de memoria
compartida y un modelo de bucle paralelo distribuido.
Modelo de memoria SPMD. Supongamos que comenzamos con un código Fortran
simple que calcula un nuevo valor promedio para cada punto de datos en la matriz
A usando una plantilla de dos puntos y almacena el promedio en la matriz ANEW.
El código podría verse como el siguiente:
REAL A(100), NUEVO(100)
¿YO = 2, 99
NUEVO(I) = (A(I-1) + A(+1)) * 0,5
ENDO
Supongamos que deseamos implementar una versión paralela de este código en
una máquina de memoria compartida con cuatro procesadores. Usando un
dialecto de bucle paralelo de Fortran, el código podría verse así:
REAL A(100), NUEVO (100)
PARALELO DO I = 2, 99
NUEVO(I) (A(1-1) + A(I+1)) * 0.5
ENDO
Si bien este código logrará el resultado deseado, es posible que no tenga
suficiente granularidad para compensar la sobrecarga de enviar subprocesos
paralelos. En la mayoría de los casos, es mejor que cada procesador ejecute un
bloque de iteraciones para lograr mayores agregado a SUM no importa. Dado que
suponemos que el cálculo de la función Fi es costoso, aún se puede lograr cierta
ganancia si calculamos los valores de F en paralelo y luego actualizamos SUM en
el orden en que finalizan esos cálculos. Para que esto funcione, debemos
asegurarnos de que solo un procesador actualice SUM a la vez y que cada uno
finalice antes de que se permita que comience el siguiente. En los sistemas de
memoria compartida, las regiones críticas (segmentos de código que pueden ser
ejecutados por un solo procesador a la vez) están diseñados para hacer
exactamente esto. Aquí hay una posible realización de la versión paralela:
SUMA = 0.0
PARALELO DO I = 1, N
R F(B(I),C(I)) ! un cálculo costoso
COMENZAR REGIÓN CRÍTICA SUMA SUMR
ENDO
FINALIZAR LA REGIÓN CRÍTICA
La región crítica garantiza que SUM sea actualizado por un procesador a la vez
por orden de llegada. Debido a que las reducciones de suma de este tipo son
realmente importantes en el cálculo paralelo, la mayoría de los sistemas ofrecen
una función primitiva que calcula dichas reducciones utilizando un esquema que
requiere un tiempo proporcional al logaritmo del número de procesadores.
Programación SPMD
Un programador que desee realizar la reducción de la suma anterior en un sistema
de paso de mensajes de memoria distribuida necesitará reescribir el programa
para utilizar el paso de mensajes explícito. Por conveniencia, el programador
empleará a menudo el estilo SPMD [246, 525]. En un programa SPMD, todos los
procesadores ejecutan el mismo código, pero lo aplican a diferentes partes de los
datos. Las variables escalares normalmente se replican en todos los procesadores
y se calculan de forma redundante (con valores idénticos) en cada procesador.
Además, el programador debe insertar primitivas de comunicación explícitas para
poder pasar los datos compartidos entre procesadores. Para el cálculo de
reducción de suma anterior, el programa SPMD podría verse así:
! Este código es ejecutado por todos los procesadores.
! ¡MYSUM, MYFIRST, MYLAST, R y I son variables locales privadas! MYFIRST y
MYLAST se calculan por separado en cada procesador
! para señalar secciones de B y C que no se cruzan ! GLOBALSUM es una
primitiva de comunicación colectiva global.
MYSUM = 0.0 DO I = MI PRIMERO, MILO ÚLTIMO
R F(B(I),C(I)) ! un cálculo costoso MYSUM MYSUM + R
ENDO
SOY GLOBAL
Aquí la comunicación está integrada en la función GLOBALSUM, que toma un
valor de su parámetro de entrada de cada procesador y calcula la suma de todas
esas entradas, almacenando el resultado en una variable que se replica en cada
procesador. La implementación de GLOBALSUM suele utilizar un algoritmo
logarítmico. Las primitivas de comunicación explícita y la programación SPMD se
ilustrarán con más detalle en el ejemplo de paralelismo de canalización en la
Sección 3.2.5.
Programación de tareas recursiva
Para manejar el paralelismo recursivo en una estructura de datos en forma de
árbol, el programador normalmente crearía un nuevo subproceso de proceso
siempre que sea necesario recorrer dos caminos diferentes en el árbol en paralelo.
Por ejemplo, una búsqueda de un valor particular en un árbol desordenado
examinaría primero la raíz. Si no se encuentra el valor, se bifurcaría un proceso
separado para buscar en el subárbol derecho y luego buscar en el subárbol
izquierdo.
Un ejemplo sencillo
Concluimos esta sección con una discusión de un problema simple que pretende
parecerse a un cálculo en diferencias finitas. Mostramos cómo se podría
implementar este ejemplo utilizando un modelo de bucle paralelo de memoria
compartida y un modelo SPMD de memoria distribuida.
Supongamos que comenzamos con un código Fortran simple que calcula un
nuevo valor promedio para cada punto de datos en la matriz A usando una plantilla
de dos puntos y almacena el promedio en la matriz ANEW. El código podría verse
como el siguiente:
REAL A(100), NUEVO(100)
:
¿YO = 2, 99
NUEVO(I) (A(1-1)+A(I+1))* 0,5
ENDO
Supongamos que deseamos implementar una versión paralela de este código en
una máquina de memoria compartida con cuatro procesadores. Usando un
dialecto de bucle paralelo de Fortran, el código podría verse así:
REAL A(100), NUEVO (100)
PARALELO DO I = 2, 99
ENDO
NUEVO(I) (A(1-1)+ A(I+1)) * 0.5
Si bien este código logrará el resultado deseado, es posible que no tenga
suficiente granularidad para compensar la sobrecarga de enviar subprocesos
paralelos. En la mayoría de los casos, es mejor que cada procesador ejecute un
bloque de iteraciones para lograr una mayor granularidad. En nuestro ejemplo,
podemos asegurarnos de que cada procesador obtenga un bloque de 24 o 25
iteraciones sustituyendo una versión minada con solo el bucle externo.
paralelo:
REAL A(100), NUEVO(100)
PARALELO DO IB 1, 100, 25
Es
PRIVADO Yo, miNombre, miÚltimo
miPrimer MAX(IB, 2)
A
13
18
miÚltimo MIN(IB + 24, 99)
¿Hago miPrimero, miÚltimo NUEVO(I) (A(1-1) + A(I+1)) * 0.5
ENDDO ENDDO
Aquí hemos introducido una nueva característica de idioma. La declaración
PRIVATE especifica que cada iteración del bucle IB tiene su propio valor privado
de cada variable de la lista. Esto permite que cada instancia del bucle interno se
ejecute de forma independiente sin actualizaciones simultáneas de las variables
que controlan la iteración del bucle interno. El ejemplo anterior garantiza que las
iteraciones 2 a 25 se ejecuten como un bloque en un único procesador. De
manera similar, las iteraciones 26 a 50, 51 a 75 y 76 a 99 se ejecutan como
bloques. Este código tiene varias ventajas sobre la versión más simple. Lo más
importante es que debe tener un rendimiento razonablemente bueno en una
máquina con memoria compartida distribuida en la que las matrices se almacenan
en un procesador.
Finalmente, pasamos a la versión del código para pasar mensajes. Este código
está escrito en estilo SPMD para que las variables escalares myP, myFirst y
myLast se repliquen automáticamente en cada procesador, el equivalente a las
variables PRIVADAS en la memoria compartida. En el estilo SPMD, cada matriz
global se reemplaza por una colección de matrices locales en cada memoria. Por
lo tanto, los arreglos globales de 100 elementos A y ANEW se convierten en
arreglos de 25 elementos en cada procesador llamados Alocal y ANEWlocal,
respectivamente. Además, asignaremos dos ubicaciones de almacenamiento
adicionales en cada procesador, A (0) y A (26), para guardar los valores
comunicados desde los procesadores vecinos. Estas celdas a menudo se
denominan celdas fantasma, celdas de halo o áreas superpuestas.
Ahora estamos listos para presentar la versión de paso de mensajes:
¡Este código es ejecutado por todos los procesadores! myP es una variable local
privada que contiene el número de procesador. myP va de 0 a 3
! Alocal y ANEWlocal son versiones locales de los arreglos A y ANEW
SI (myP .NE. 0) envía Alocal (1) a myP-1 IF (myP .NE. 3) envía Alocal (25) a
myP+1
SI (myP .NE. 0) recibe Alocal (0) de myP-1
Tenga en cuenta que el bucle de cálculo está precedido por cuatro pasos de
comunicación en los que se envían y reciben valores de procesadores vecinos.
Estos valores se almacenan en las áreas de superposición de cada matriz local.
Una vez hecho esto, el cálculo puede continuar en cada uno de los procesadores
utilizando las versiones locales de A y ANEW. Como veremos más adelante en el
libro, el rendimiento se puede mejorar insertando un cálculo puramente local entre
los envíos y las recepciones en el ejemplo anterior. Esta es una mejora porque la
comunicación se superpone con el cálculo local para lograr un mejor paralelismo
general. El siguiente fragmento de código inserta el cálculo en el interior de la
región antes de las operaciones de recepción, que solo son necesarias para
calcular los valores límite.
Este código es ejecutado por todos los procesadores.
myP es una variable local privada que contiene el número del procesador
! myP va de 0 a 3! Los arreglos A y ANEW son versiones locales de los arreglos A
y ANEW
SI (myP .NE. 0) envía Alocal (1) a myP-1
IF (myP .NE. 3) envía Alocal (25) a myP+1 ENDDO recibe Alocal (0) de myP-1
ANEWlocal(1) (Alocal (0) + Alocal (2)) * 0.5 ENDIF recibe Alocal (26) de myP+1
ANEWlocal (25) (Alocal (24) + Alocal (26))* 0,5
HAGO 2, 24
ANEWlocal (I) (Alocal (I-1) + Alocal (I+1))* 0,5
SI (myP .NE. 0) ENTONCES
SI (myP .NE. 3) ENTONCES
TERMINARA SI
3.3 Mejora del rendimiento paralelo
La programación paralela es difícil en parte porque un alto rendimiento no se
deriva automáticamente de la implementación paralela. Para lograr el mayor
rendimiento posible, el implementador debe tener en cuenta otras
consideraciones. En primer lugar, debe equilibrar las cargas sobre los
componentes del sistema informático.
configuración para que ningún componente domine el tiempo de ejecución. En
segundo lugar, resolver problemas muy grandes requiere que el cálculo se escale
a un gran número de procesadores paralelos; la implementación debe diseñarse
para lograr este objetivo. En tercer lugar, algunos componentes del problema,
aunque sean seriales, pueden acelerarse mediante una estrategia de
paralelización parcial conocida como canalización. Finalmente, el implementador
puede necesitar estrategias especiales para lidiar con cálculos irregulares. Los
cálculos irregulares incluyen cálculos matriciales dispersos y cálculos definidos en
cuadrículas irregulares, como los que emplean mallado adaptativo. Esta sección
proporciona una breve introducción. a cada una de estas cuestiones.
3.3.1
15
18
Escalabilidad y equilibrio de carga
El objetivo ideal de la computación paralela es reducir el tiempo de ejecución de
una aplicación en un factor que sea inversamente proporcional al número de
procesadores utilizados. Es decir, si se utiliza un segundo procesador, el tiempo
de ejecución debería ser la mitad de lo que se requiere en un procesador. Si se
utilizan cuatro procesadores, el tiempo de ejecución debería ser un cuarto.
Cualquier aplicación que logre este objetivo se dice que es escalable. Otra forma
de expresar el objetivo es en términos de aceleración, que se define como la
relación entre el tiempo de ejecución en un solo procesador y el tiempo de
ejecución en la configuración paralela. Eso es.
Aceleración(n)T(1)/T(n)
Se dice que una aplicación es escalable si la aceleración en n procesadores es
cercana a n. La escalabilidad de este tipo tiene sus límites, en algún momento la
cantidad de paralelismo disponible en la aplicación se agotará y agregar más
procesadores puede incluso restar rendimiento.
Esto nos lleva a considerar una segunda definición de escalabilidad, llamada
aceleración escalada: se dirá que una aplicación es escalable si, cuando el
número de procesadores y el tamaño del problema aumentan en un factor de n, el
tiempo de ejecución sigue siendo el mismo [418 ]. Esto captura la noción de que
configuraciones de máquinas más grandes hacen posible resolver problemas
científicos correspondientemente más grandes.
Hay tres razones principales por las que no se logra la escalabilidad en algunas
aplicaciones. En primer lugar, la aplicación puede tener una región grande que
debe ejecutarse secuencialmente. Si asumimos que Ts es el tiempo requerido por
esta región y Tp es el tiempo requerido por la región paralela, la aceleración para
este código viene dada por:
Aceleración(n) =Ts+TpT(1)
Esto significa que la aceleración total está limitada por la relación entre el tiempo
de ejecución secuencial y el tiempo de ejecución de la región secuencial. Por lo
tanto, si el 20 por ciento del tiempo de ejecución es secuencial, la aceleración no
puede exceder el 5. Esta observación se conoce como Ley de Amdahl [30].
Un segundo impedimento para la escalabilidad es el requisito de un alto grado de
comunicación o coordinación. En el ejemplo de suma global anterior, si elSi el
cálculo de la función F es rápido, entonces el costo del cálculo está dominado por
el tiempo necesario para obtener la suma, que en el mejor de los casos es
logarítmica. Esto se puede modelar para producir una ecuación de aceleración
revisada (338):
Iniciar sesión
texto o herramientas
D
control de calidad
Aceleración (n) =
T(1) Ts++clg(n)
1 Ig(s)
Incluso si c es pequeño, el factor logarítmico en el denominador crecerá con el
número de procesadores hasta alcanzar un tamaño significativo. Cuando el
número de procesadores sea lo suficientemente grande, la aceleración dejará de
aumentar y comenzará a disminuir.
El tercer impedimento importante para la escalabilidad es el equilibrio de carga
deficiente. Si uno de los procesadores toma la mitad del trabajo paralelo, la
aceleración se limitará a un factor de dos, sin importar cuántos procesadores
estén involucrados. Por tanto, un objetivo importante de la programación paralela
es garantizar un buen equilibrio de carga.
Si todas las iteraciones de un bucle determinado se ejecutan durante exactamente
la misma cantidad de tiempo, se puede lograr el equilibrio de carga dando a cada
procesador exactamente la misma cantidad de trabajo por hacer. Por lo tanto, las
iteraciones podrían dividirse en bloques de igual número, de modo que cada
procesador obtenga aproximadamente la misma cantidad de trabajo, como en el
siguiente ejemplo:
K = CEIL (N/P)
PARALELO HACER I 1, N, K
HACER 1 MIN(I+K-1,N)
A(ii) B(i+1) + C
ENDDO ENDDO
Sin embargo, esta estrategia falla si el trabajo en cada iteración lleva una cantidad
de tiempo variable. En máquinas de memoria compartida, esto se puede mejorar
aprovechando la forma en que se programan los bucles paralelos. En tales
máquinas, cada procesador regresa a la cola que reparte iteraciones cuando no
tiene trabajo que hacer. Por lo tanto, al reducir la cantidad de trabajo en cada
iteración (manteniéndola por encima del umbral) y aumentar el número total de
iteraciones, podemos garantizar que otros procesadores tomen el relevo de un
procesador que tiene una iteración larga. Si el mismo ejemplo fuera codificado
como
K TECHO (N/(P*4))
PARALELO DO I = 1, N, K DO i I, MIN(I+K-1,N)
A(ii) B(i+1)+ C
ENDO
ENDO
luego, en promedio, cada procesador debería ejecutar cuatro iteraciones del bucle
paralelo. Sin embargo, si un procesador se atasca, los demás realizarán más
iteraciones para equilibrar la carga de forma natural.
En los casos en los que ninguna de estas estrategias es apropiada, como cuando
la computadora es un sistema de paso de mensajes con memoria distribuida o
cuando la carga no se conoce hasta el tiempo de ejecución, puede ser necesario
un esquema de equilibrio de carga dinámico, en el que la como Hasta este punto,
hemos estado tratando principalmente con paralelismo asincrónico en el sentido
de que no se necesita sincronización durante la ejecución paralela. (La excepción
fue el ejemplo de suma, que requería una sección crítica). Idealmente, siempre
deberíamos poder encontrar paralelismo asincrónico, porque esto nos brinda la
mejor oportunidad de escalabilidad. Sin embargo, incluso cuando esto no sea
posible, se puede lograr cierto paralelismo escalonando el inicio de las tareas y
sincronizándolas de modo que las subsecciones sin interdependencias se
ejecuten al mismo tiempo. Esta estrategia se conoce como canalización porque es
el análogo de software de la canalización en el hardware de la CPU, que se
describe en la Sección 2.1.1. Para ver cómo funciona esto, considere la siguiente
variante de relajación excesiva sucesiva:
HACER J = 2, N-1
HACER I = 2, N-1 A(I,J) (A(I-1,J) + A(I+1,J) + A(I,J-1) + A(I,J+1) ) * 0,25 ENDDO
ENDO
Aunque ninguno de los bucles se puede ejecutar en paralelo, existe cierto
paralelismo en este ejemplo, como se ilustra en la Figura 3.4. Todos los valores de
la diagonal sombreada se pueden calcular en paralelo porque no existen
dependencias entre ninguno de estos elementos.
Supongamos, sin embargo, que deseamos calcular todos los elementos en
cualquier columna en el mismo procesador, de modo que A(*,J) se calcule en el
mismo procesador para todos los valores de J. Si calculamos los elementos en
cualquier columna en secuencia, se satisfacen todas las dependencias a lo largo
de esa columna. Sin embargo, todavía debemos preocuparnos por las filas. Para
obtener el resultado correcto, debemos retrasar el cálculo en cada fila lo suficiente
para garantizar que el elemento de matriz correspondiente en la fila anterior se
complete antes de que se calcule el elemento en la fila actual. Esta estrategia se
puede implementar mediante el uso de mecanismos de sincronización de eventos
que hacen posible que un proceso "espera" a que suceda algo (un evento que se
"publica") en otro proceso. (Consulte el Capítulo 12 para obtener más información
sobre eventos). El siguiente pseudocódigo demuestra este enfoque:
EVENTO LISTO (N,N)! Inicializado a falso
PARALELO DO I = 1, N
PUBLICAR (LISTO(1,1))
ENDO
PARALELO DO J = 2, N-1 DO I = 2, N-1
3.3.2
TE
Activar Windows
Ir a Configuración de PC para activar Windows.
12:14a. metro.
09/04/2023
q
Iniciar sesión
control de calidad
herramientas
D
La asignación de trabajo a los procesadores se realiza en tiempo de ejecución.
Con suerte, este paso de equilibrio de carga será necesario con poca frecuencia
para que el costo se amortice en varios pasos de cálculo.
Inicialmente, todos los eventos son falsos: una espera en un evento falso
suspenderá el hilo en ejecución hasta que se ejecute una publicación para el
evento. Luego se publican todos los eventos READY de la primera columna, para
que pueda comenzar el cálculo. El cálculo de la primera columna calculada, A(*,2),
comienza inmediatamente. A medida que se calcula cada uno de los elementos,
se publica su evento READY para que la siguiente columna pueda comenzar a
calcular el elemento correspondiente. El momento del cálculo se ilustra en la
Figura 3.5. Tenga en cuenta que la publicación del evento ha alineado la región de
paralelismo para que todos los procesadores estén trabajando simultáneamente
en cálculos independientes.
Problemas regulares versus irregulares
La mayoría de los ejemplos que hemos utilizado en este capítulo son problemas
regulares, definidos en una cuadrícula fija y regular en un cierto número de
dimensiones. Aunque una gran fracción de las aplicaciones científicas se centran
en problemas regulares, un número creciente de aplicaciones abordan problemas
que tienen una estructura irregular o utilizan mallas adaptativas para mejorar la
eficiencia. Esto significa que la estructura de la red subyacente normalmente no se
conoce hasta el momento de la ejecución. Por tanto, estas aplicaciones presentan
especiales dificultades para el funcionamiento paralelo.

También podría gustarte