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.