Vista previa del material en texto
COMPUTADORES
PARALELOS
Computación de Alta Velocidad
A. Arruabarrena — J. Muguerza
Konputagailuen Arkitektura eta Teknologia saila
Informatika Fakultatea — Euskal Herriko Unibertsitatea
COMPUTADORES
PARALELOS
Computación de Alta Velocidad
A. Arruabarrena — J. Muguerza
Konputagailuen Arkitektura eta Teknologia saila
Informatika Fakultatea — Euskal Herriko Unibertsitatea
septiembre 2011
ÍNDICE
Introducción ......................................................................... 1
Capítulo 1. Computadores Vectoriales ............................................... 7
1.1 ¿Qué es un computador vectorial? ................................................................... 7
1.1.1 Algunos problemas ............................................................................................... 10
1.1.1.1 La memoria de un computador vectorial .......................................... 10
1.1.1.2 Unidades funcionales vectoriales ........................................................ 11
1.1.1.3 Registros vectoriales ............................................................................... 11
1.1.1.4 Programas vectoriales ............................................................................ 12
1.1.2 Arquitectura y lenguaje-máquina ...................................................................... 13
1.2 Dependencias de datos ..................................................................................... 15
1.2.1 Encadenamiento (chaining) ........................................................................................... 16
1.2.1.1 Encadenamiento con dos instrucciones ............................................ 17
1.2.2 Tablas de ejecución de las instrucciones ........................................................ 18
1.3 Dependencias estructurales .............................................................................. 20
1.3.1 Buses de memoria (unidades funcionales LV/SV) ........................................ 20
1.3.2 Conflictos en el uso de los módulos de memoria ........................................ 22
1.3.2.1 Una sola operación de memoria ......................................................... 22
1.3.2.2 Varias operaciones de memoria .......................................................... 26
1.3.3 Longitud de los registros vectoriales (strip mining) ...................................... 29
1.4 Velocidad de cálculo de los computadores vectoriales ............................ 30
1.4.1 Velocidad de cálculo en función de la longitud de los vectores .............. 31
1.4.1.1 R∞ y N1/2..................................................................................................... 31
1.4.1.2 Speed-up o factor de aceleración ....................................................... 33
1.4.1.3 Nv ................................................................................................................. 34
1.4.2 Influencia del código escalar. Ley de Amdahl. .............................................. 34
1.5 Técnicas de compilación para generar código vectorial ........................... 37
1.5.1 Dependencias de datos entre instrucciones .................................................. 38
1.5.2 Vectorización ......................................................................................................... 40
1.5.2.1 Vectores de una dimensión .................................................................. 40
1.5.2.2 Vectores de N dimensiones ................................................................. 44
1.5.2.3 Condición para vectorizar un bucle ................................................... 45
1.5.2.4 Test de dependencias ............................................................................ 46
1.5.3 Optimizaciones...................................................................................................... 50
1.5.3.1 Sustitución global hacia adelante (global forward substitution) ................... 50
▪ vi ▪ ÍNDICE
1.5.3.2 Eliminación de las variables de inducción ......................................... 51
1.5.3.3 Antidependencias (DR, WAR) ...................................................................... 52
1.5.3.4 Dependencias de salida (RR, WAW) ........................................................ 53
1.5.3.5 Intercambio de bucles (loop-interchanging) .......................................... 54
1.5.3.6 Expansión escalar (scalar expansion) ........................................................ 56
1.5.3.7 Fusión de bucles (loop fusion) ..................................................................... 57
1.5.3.8 Colapso de bucles (loop collapsing) ......................................................... 58
1.5.3.9 Otras optimizaciones ............................................................................. 59
1.5.4 Vectores de máscara y vectores de índices ................................................... 60
1.5.4.1 Uso de máscaras ..................................................................................... 60
1.5.4.2 Vectores de índices ................................................................................ 61
1.6 Resumen ................................................................................................................ 64
Capítulo 2. Computadores Paralelos (conceptos básicos) ................. 69
2.1 Introducción ......................................................................................................... 69
2.2 Computadores DM-SIMD ................................................................................. 71
2.3 Computadores MIMD ........................................................................................ 73
2.3.1 Memoria compartida (shared memory) .................................................................. 73
2.3.2 Memoria privada o distribuida (distributed memory) ....................................... 74
2.3.3 Memoria lógicamente compartida pero físicamente distribuida
(distributed shared memory) ......................................................................................... 75
2.3.4 Clusters, constellations... y otros ....................................................................... 76
2.4 Algunos problemas ............................................................................................. 77
2.5 Rendimiento del sistema paralelo (leyes de Amdahl y Gustafson) ......... 79
Capítulo 3. Coherencia de los Datos en los Computadores
SMP ...................................................................................... 83
3.1 Presentación del problema y revisión de conceptos .................................. 83
3.1.1 Coherencia de los datos en los sistemas de un solo procesador ............. 84
3.1.2 Coherencia de los datos en los multiprocesadores de memoria
compartida (SMP) ................................................................................................. 85
3.1.3 Falsa compartición ................................................................................................ 86
3.1.4 Definición de la coherencia................................................................................ 87
3.2 Protocolos de coherencia snoopy .................................................................. 88
3.2.1 Estados de los bloques en la memoria cache y señales de control .......... 90
3.2.2 Protocolos de invalidación ................................................................................. 93
3.2.2.1 Un protocolo de tres estados, MSI ..................................................... 94
3.2.2.2 El protocolo Illinois, MESI ...................................................................... 97
3.2.2.3 El protocoloBerkeley, MOSI ................................................................ 99
3.2.2.4 Resumen de los protocolos de invalidación................................... 100
ÍNDICE ▪ vii ▪
3.2.3 Protocolos de actualización ............................................................................. 101
3.2.3.1 El protocolo Firefly, MSE(I) .................................................................. 101
3.2.3.2 El protocolo Dragon, MOES(I) ........................................................... 103
3.2.4 Resumen de los protocolos de tipo snoopy ................................................ 105
3.3. Implementación de los protocolos snoopy ................................................ 105
3.3.1 Problemas ............................................................................................................. 105
3.3.1.1 Directorio de la memoria cache ........................................................ 106
3.3.1.2 Búferes de escritura .............................................................................. 107
3.3.1.3 Protocolo de petición de bus ............................................................. 108
3.3.1.4 Atomicidad: estado del controlador snoopy .................................. 109
3.3.2 El protocolo Illinois y la atomicidad ................................................................ 110
3.3.2.1 Carreras: estados transitorios, señales BRQ y BGN ..................... 110
3.3.2.2 Deadlock, livelock, starvation ............................................................ 112
3.4 Snoopy jerárquico ............................................................................................. 113
3.4.1 Lecturas .................................................................................................................. 115
3.4.2 Escrituras ................................................................................................................ 116
Capítulo 4. Sincronización de Procesos en los Computado-
res SMP .............................................................................. 119
4.1 Introducción ....................................................................................................... 119
4.2 Exclusión mutua (mutual exclusion) ............................................................. 123
4.2.1 Instrucciones Test&Set y Swap ........................................................................ 125
4.2.1.1 Instrucción Test&Set ............................................................................. 125
4.2.1.2 Instrucción Swap ................................................................................... 125
4.2.1.3 Análisis del tráfico ................................................................................. 126
4.2.1.4 Procedimiento Test&Set with backoff .............................................. 127
4.2.1.5 Procedimiento Test-and-Test&Set ...................................................... 128
4.2.1.6 Resumen de características ................................................................ 130
4.2.2 Instrucciones Load Locked / Store Conditional y Compare&Swap ....... 131
4.2.2.1 Instrucciones LL y SC ........................................................................... 132
4.2.2.2 Instrucción Compare&Swap ............................................................... 134
4.2.2.3 Algunos problemas con las instrucciones LL/SC ........................... 135
4.2.3 Instrucciones Fetch&Op .................................................................................... 136
4.2.4 Alternativas para reducir el tráfico .................................................................. 137
4.2.4.1 Tickets ...................................................................................................... 137
4.2.4.2 Vectores de cerrojos ............................................................................ 139
4.3 Sincronización "punto a punto" mediante eventos ................................... 141
4.4 Sincronización mediante barreras ................................................................. 142
4.4.1 Una barrera sencilla ............................................................................................ 142
4.4.2 Barreras reutilizables .......................................................................................... 143
4.4.3 Eficiencia ................................................................................................................ 145
4.5 Resumen .............................................................................................................. 146
▪ viii ▪ ÍNDICE
Capítulo 5. Consistencia de la Memoria en los Computa-
dores Paralelos ................................................................ 149
5.1 Introducción ....................................................................................................... 149
5.1.1 Sistemas de un solo procesador ...................................................................... 149
5.1.2 Sistemas multiprocesador ................................................................................. 150
5.1.3 Semántica de los programas y orden de ejecución de las
instrucciones ......................................................................................................... 151
5.1.4 Atomicidad de las instrucciones ...................................................................... 153
5.1.5 Modelos de consistencia ................................................................................... 154
5.2 Consistencia secuencial (SC, sequential consistency) .............................. 155
5.2.1 Orden y atomicidad de las instrucciones de memoria .............................. 156
5.2.2 Efectos en el hardware y en el compilador .................................................. 158
5.3 Modelos relajados (relaxed) ........................................................................... 159
5.3.1 Total Store Ordering (TSO) / Processor Consistency (PC) ....................... 160
5.3.2 Partial Store Ordering (PSO) ............................................................................ 162
5.3.3 Modelos más relajados ...................................................................................... 163
5.3.3.1 Weak Ordering (WO) .......................................................................... 164
5.3.3.2 Release Consistency (RC) ................................................................... 164
5.4 Resumen y perspectivas .................................................................................. 166
Capítulo 6 La Red de Comunicación de los Computadores
Paralelos. Comunicación mediante Paso de
Mensajes. .......................................................................... 169
6.1 Introducción ....................................................................................................... 169
6.2 Topología de la red ........................................................................................... 171
6.3 Redes formadas por conmutadores .............................................................. 173
6.3.1 El conmutador (switch) .................................................................................................. 174
6.3.2 Red crossbar ......................................................................................................... 175
6.3.3 Redes multietapa (multistage) .................................................................................... 176
6.3.3.1 La red Omega ........................................................................................ 176
6.3.3.2 Encaminamiento en la red Omega ................................................... 178
6.3.3.3 Conflictos de salida y bloqueos ......................................................... 179
6.3.3.4 Otro patrón de comunicación: broadcast. ..................................... 181
6.3.3.5 Otras redes .............................................................................................181
6.3.3.6 Resumen .................................................................................................. 183
6.4 Redes formadas por encaminadores de mensajes .................................... 184
6.4.1 Encaminadores de mensajes ............................................................................ 184
6.4.2 Topologías de red más utilizadas .................................................................... 185
6.4.2.1 Redes de una dimensión: la cadena y el anillo .................................. 186
6.4.2.2 Mallas y Toros (mesh, torus) ....................................................................... 187
ÍNDICE ▪ ix ▪
6.4.2.3 Hipercubos (hypercube) ............................................................................... 188
6.4.2.4 Árboles y árboles densos (fat tree) ........................................................... 190
6.4.2.5 Resumen de topologías ....................................................................... 191
6.4.2.6 Los enlaces físicos ................................................................................. 193
6.5 La comunicación a través de la red en los sistemas paralelos ............... 193
6.5.1 Los mensajes ........................................................................................................ 194
6.5.2 Patrones de comunicación: con quién y cuándo hay que
efectuar la comunicación. ................................................................................. 195
6.5.3 Construcción del camino (switching strategy) ................................................... 198
6.5.4 Encaminamiento de los mensajes (routing) ......................................................... 199
6.5.4.1 El registro de encaminamiento .......................................................... 200
6.5.4.2 Elección del camino: estático o adaptativo .................................... 203
6.5.5 Control del flujo de información ..................................................................... 206
6.5.5.1 Avance de los paquetes: Store-and-forward,
Wormhole y Cut-through .................................................................... 206
6.5.5.2 Conflictos en el uso de recursos: los búferes ................................. 210
6.5.6 Eficiencia de la comunicación: latencia y throughput ............................... 214
6.5.6.1 Tiempo de comunicación en la red .................................................. 215
6.5.6.2 Considerando el tráfico en la red ...................................................... 217
6.5.6.3 Cálculo del throughput máximo ........................................................ 219
6.5.6.4 Análisis global ......................................................................................... 221
6.5.7 Problemas de la comunicación ....................................................................... 222
6.5.7.1 Deadlock (interbloqueos) Canales virtuales. Giros
controlados (Turn model). Control de la inyección de
paquetes. Utilización de caminos seguros ..................................... 222
6.5.7.2 Problemas de livelock y starvation.................................................... 229
6.5.8 Protocolos de comunicación ........................................................................... 230
6.6 Evolución de los computadores paralelos ................................................... 232
Apéndice. Cálculo de las distancias medias en diferentes topologías .......... 235
Capítulo 7. Coherencia de los Datos en los Computadores
DSM .................................................................................... 241
7.1 Introducción ....................................................................................................... 241
7.2 Directorios de coherencia ............................................................................... 243
7.2.1 Introducción y clasificación ................................................................................ 243
7.2.1.1 Problemas................................................................................................ 245
7.2.2 Estructura de los directorios ............................................................................. 246
7.2.2.1 Directorios implementados en memoria principal ....................... 246
7.2.2.2 Directorios implementados en memoria cache ............................ 251
7.2.3 Optimización del tráfico de coherencia ........................................................ 254
7.2.4 Atomicidad de las operaciones: carreras ...................................................... 257
▪ x ▪ ÍNDICE
7.3 Implementación de los protocolos de coherencia: dos
ejemplos .............................................................................................................. 259
7.3.1 Protocolo de coherencia de los multicomputadores SGI Origin ............ 259
7.3.1.1 Lecturas .................................................................................................... 260
7.3.1.2 Escrituras .................................................................................................. 263
7.3.1.3 Actualización de la memoria principal ............................................ 268
7.3.2 El protocolo de coherencia estándar SCI en la máquina NUMA-Q
de Sequent. ........................................................................................................... 269
7.3.2.1 SCI: estados y operaciones ................................................................. 270
7.3.2.2 Lecturas .................................................................................................... 272
7.3.2.3 Escrituras .................................................................................................. 273
7.3.2.4 Actualización de la memoria principal ............................................ 277
7.3.2.5 Atomicidad y carreras .......................................................................... 277
7.4 Resumen .............................................................................................................. 279
Capítulo 8. Paralelización y Planificación de Bucles .................. 281
8.1 Introducción ....................................................................................................... 281
8.1.1 Ideas básicas sobre paralelización de bucles ............................................... 287
8.2. Estructuras básicas para expresar el paralelismo de los bucles .............. 290
8.2.1 Bucles sin dependencias entre iteraciones: bucles doall .......................... 290
8.2.2 Bucles con dependencias entre iteraciones ................................................. 291
8.2.2.1 Bucles forall (sincronización global .................................................. 292
8.2.2.2 Bucles doacross (sincronización punto a punto) .......................... 293
8.2.3 Efecto de las antidependencias y de las dependencias de salida ........... 298
8.2.4 Atención con las instrucciones if ..................................................................... 299
8.3 Implementación de la sincronización........................................................... 300
8.3.1 Sincronización mediante contadores ............................................................. 301
8.3.2 Un único contador por procesador................................................................ 303
8.4 Optimizaciones para paralelizar bucles de manera eficiente ................. 304
8.4.1 Eliminación del efecto de las dependencias que no son esenciales ...... 304
8.4.2 Fisión de bucles ................................................................................................... 305
8.4.3 Ordenación de las dependencias ................................................................... 306
8.4.4 Alineación de las dependencias (peeling) .................................................... 307
8.4.5 Extracción de threads independientes (switching) .....................................309
8.4.6 Minimización de las operaciones de sincronización ................................. 310
8.4.7 Tratamiento de bucles (reordenación...) ........................................................ 311
8.4.7.1 Intercambio de bucles.......................................................................... 311
8.4.7.2 Cambio de sentido................................................................................ 314
8.4.7.3 Desplazamientos (skew) ....................................................................... 314
8.4.7.4 Colapso y coalescencia de bucles .................................................... 315
8.5 Planificación de bucles (scheduling) ............................................................... 316
8.5.1 Reparto de las iteraciones: consecutivo o entrelazado ............................. 317
ÍNDICE ▪ xi ▪
8.5.2 Planificación estática o dinámica .................................................................... 318
8.5.2.1 Planificación estática ............................................................................ 319
8.5.2.2 Planificación dinámica: autoplanificación (self/chunk
scheduling), autoplanificación guiada (GSS) y trapezoidal
(trapezoid self scheduling) ........................................................................... 319
8.6 Secciones paralelas: Fork / Join ..................................................................... 323
8.7 Análisis del rendimiento................................................................................... 325
Capítulo 9. Computadores de Alta Velocidad. Herramientas
para Programar Aplicaciones Paralelas
(introducción). ..................................................................... 327
9.1 Computadores paralelos de alto rendimiento ............................................ 328
9.2 Herramientas para programar aplicaciones paralelas (introducción) ... 331
9.2.1 OpenMP ................................................................................................................ 332
9.2.2 MPI ......................................................................................................................... 336
Introducción
¿Qué tiempo hará mañana en esta ciudad? ¿Cómo evolucionan las
galaxias? ¿Cómo interaccionan los electrones en una molécula de clorofila?
¿Se comportarán de manera adecuada las alas de un avión en una
turbulencia? Para dar respuesta adecuada a esas y otras muchas preguntas,
científico/as e ingeniera/os utilizan potentes computadores, la herramienta
principal de cualquier laboratorio en la actualidad. Las aplicaciones técnico-
científicas requieren de grandes cantidades de cálculo, casi de manera
ilimitada, y además hay que obtener resultados en el menor tiempo posible,
(prever mañana las lluvias torrenciales de hoy no sirve para mucho!). A
pesar del espectacular incremento en la velocidad de cálculo de los
procesadores, las necesidades van siempre muy por delante. A lo largo de la
evolución de los computadores tres han sido las líneas principales que han
▪ 2 ▪ INTRODUCCIÓN
permitido aumentar de manera continuada la velocidad de los mismos: los
avances en la tecnología electrónica, el desarrollo de nuevas estructuras o
arquitecturas de computadores, y el uso de tecnologías del software
(compiladores, etc.) cada vez más eficientes.
Mediante la tecnología electrónica se ha conseguido integrar en un sólo
chip una cantidad ingente de transistores: hoy en día por encima de 1.000
millones (y cada vez más). A consecuencia de este avance, cada vez son más
las "partes" del computador que se van integrando en un solo chip junto con
el procesador: unidades funcionales específicas, registros, memoria cache... e
incluso varios procesadores o núcleos (core). Del mismo modo, la frecuencia
del reloj del computador es cada vez más alta (aunque la carrera para usar
relojes cada vez más rápidos está detenida en este momento), actualmente en
el intervalo 1-4 GHz, lo que quiere decir que el tiempo de ciclo está por
debajo del nanosegundo (F = 1 GHz → T = 1 ns) y, como consecuencia, se
pueden hacer más operaciones por unidad de tiempo.
Desde el punto de vista de la arquitectura del sistema, todos los
procesadores actuales son superescalares o de tipo VLIW (la ejecución de las
instrucciones es segmentada y se intenta ejecutar más de una instrucción
cada ciclo); la jerarquía de memoria cache permite accesos más rápidos, los
registros se organizan para optimizar el uso de los datos, etc.
Las técnicas de compilación también han avanzado mucho. El objetivo
principal es eliminar el efecto de las dependencias existentes entre las
instrucciones, y ocultar la latencia de la unidades funcionales (aprovechando
ese tiempo para realizar trabajo útil).
Sin embargo, a pesar de que tenemos procesadores superescalares muy
rápidos —que llegan a superar la velocidad de cálculo de 10 Gflop/s— para
muchas aplicaciones, tales como previsiones meteorológicas, simulaciones
de procesos físicos y químicos, diseños de aeronáutica, prospecciones
geológicas, diseño de nuevos materiales, desarrollos diversos en ingeniería,
avances en biología, genética y farmacia, etc., dicha velocidad no es
suficiente. En el periodo 1986-2002, la tasa de crecimiento del rendimiento
de los procesadores fue de un %52 anual (!), pero dicho crecimiento se ha
reducido notablemente estos últimos años, situándose en torno al 20%: la
velocidad que se puede conseguir con un procesador está llegando a sus
límites físicos (y económicos). Por tanto, se necesita de desarrollar otro tipo
de estrategias para conseguir las velocidades de cálculo —Teraflop/s,
Petaflop/s, es decir, 1012, 1015 operaciones de coma flotante por segundo—
que demandan las aplicaciones citadas.
INTRODUCCIÓN ▪ 3 ▪
El paso que hay que dar es bastante claro: utilizar muchos procesadores,
para repartir la ejecución de un programa entre ellos; es decir, utilizar
sistemas paralelos. Además, las tecnologías de fabricación facilitan esta
posibilidad: construido un procesador (chip), se hacen fácilmente miles de
ellos y de manera relativamente barata. Por tanto, ¿por qué no utilizar 100,
1.000, 10.000... procesadores para resolver un problema? Teóricamente, y si
supiéramos cómo hacerlo, utilizando P procesadores podríamos ejecutar un
programa P veces más rápido. Por desgracia, esto no va a ser así, ya que van
a aparecer importantes problemas nuevos: ¿cómo se reparte el trabajo entre
los procesadores? ¿son independientes los procesos o hay que
sincronizarlos? ¿cómo se implementa la comunicación entre procesadores?...
Existen muchas maneras de estructurar un computador de P procesadores.
Algunas características serán comunes en todos ellos, y otras, en cambio, no.
Existen diferentes formas de clasificar estas arquitecturas o estructuras. De
entre ellas, la más conocida o utilizada es, seguramente, la de Flynn (1966),
quizás por lo simple que es. En esta clasificación se tienen en cuenta dos
parámetros: el número de flujos de instrucciones (es decir, el número de PCs
o contadores de programa) y el número de flujos de datos que operan
simultáneamente. La siguiente figura recoge dicha clasificación:
flujos de datos
uno muchos
flujos de
instrucciones
uno SISD SIMD
muchos MIMD
• Computadores de tipo SISD (Single-Instruction-Single-Data)
Se ejecuta un único programa sobre un único conjunto de datos; por
tanto, a esta clase pertenecen los sistemas clásicos de un sólo
procesador (ordenadores personales, estaciones de trabajo…). Aunque
en algunos casos dispongan de más de un procesador, éstos realizan el
trabajo de manera independiente.
Como ya hemos comentado, las instrucciones se ejecutan de manera
segmentada, dividida en varias fases —búsqueda, descodificación,
lectura de operandos, memoria, unidad aritmética, escritura de
resultados…—, y en cada fase habrá una instrucción (o varias, en el
casode los procesadores superescalares). Así pues, se utiliza
▪ 4 ▪ INTRODUCCIÓN
paralelismo a nivel de instrucción (ILP, Instruction Level
Parallelism). Además, el procesador (con ayuda del hardware o del
compilador) es capaz de modificar el orden de ejecución de las
instrucciones para conseguir la mayor eficiencia (velocidad) posible.
A lo largo del texto supondremos que todos esos conceptos son
conocidos.
• Computadores de tipo SIMD (Single-Instruction-Multiple-Data)
En este tipo de computadores se ejecuta simultáneamente el mismo
programa en todos los procesadores, pero sobre diferentes conjuntos
de datos; se aprovecha, por tanto, el paralelismo de datos (DLP, Data
Level Parallelism). Dentro de este grupo podemos distinguir dos
subgrupos: los denominados processor-array (distributed memory
SIMD) y los procesadores vectoriales (shared memory SIMD).
En el primer caso, el computador dispone de muchos procesadores
normalmente muy "simples" (por ejemplo, 16 k procesadores de un
bit); todos los procesadores ejecutan el mismo programa de manera
sincronizada, pero sobre datos diferentes. Se han construido muchas
máquinas de tipo SIMD, sobre todo en los años 80-95, y para ciertas
aplicaciones, tales como cálculo numérico, procesamiento de señal,
etc., ofrecen muy buen rendimiento.
Sin embargo, hoy en día no se fabrican computadores de este modelo
(aunque ideas similares se utilizan para generar entornos virtuales de
dos y tres dimensiones); sí, en cambio, computadores vectoriales.
• Computadores de tipo MIMD (Multiple-Instruction-Multiple-Data)
Es el caso general de un sistema paralelo. Se ejecutan muchos
procesos (muchos PCs) sobre diferentes conjuntos de datos. ¡Ojo! no
se trata de un conjunto de máquinas SISD, ya que los programas que
se ejecutan no son independientes.
Este es el modelo que permite obtener elevadas velocidades de
cómputo: computadores de paralelismo masivo, en los que P
procesadores (un número alto) colaboran en la resolución de un
problema; es decir, se explota el paralelismo a nivel de hilo o proceso
(TLP, Thread Level Parallelism). En cualquier caso, surgen muchos
problemas nuevos, a los que, si se quiere conseguir un buen
rendimiento, habrá que buscar soluciones adecuadas.
INTRODUCCIÓN ▪ 5 ▪
Tal y como veremos en los próximos capítulos, podemos hacer una
subclasificación en el grupo de las máquinas MIMD:
• Sistemas de memoria compartida, en los que todos los
procesadores utilizan el mismo espacio de direccionamiento. La
memoria puede estar centralizada (SMP, symmetric
multiprocessors) o distribuida (DSM, distributed shared
memory). La comunicación entre procesos se realiza mediante el
uso de variables compartidas.
• Sistemas de memoria privada distribuida, en los que cada uno
de los procesadores utiliza su espacio propio de memoria. LA
comunicación entre procesos se realiza mediante paso de
mensajes.
A lo largo de los capítulos del texto vamos a analizar las máquinas
paralelas de tipo MIMD, pero en el primero vamos a tratar sobre un tipo
especial de computador SIMD de muy alto rendimiento: los computadores
vectoriales. Se trata de una arquitectura de procesador específica, destinada
al procesamiento de vectores, que ha conseguido un lugar destacado en la
historia de la computación. En el capítulo 2 haremos una breve presentación
de los sistemas paralelos: principales modelos y arquitecturas, problemas
más importantes, la ley de Amdahl, etc. En el capítulo 3 analizaremos el
problema de la coherencia de los datos en sistemas SMP; en el 4, las
instrucciones y procedimientos básicos para sincronizar procesos paralelos:
T&S, LL, SC...; y en el 5, los modelos de consistencia, secuencial y
relajados, de la memoria de un sistema paralelo. En el capítulo 6,
analizaremos la topología, estructura y funcionamiento de la red de
comunicación de un sistema paralelo, así como la eficiencia de los
mecanismos de comunicación entre procesadores. En el capítulo 7
analizaremos nuevamente el problema de la coherencia de los datos, pero en
los sistemas DSM: los directorios de coherencia. Dedicaremos el capítulo 8 a
presentar las técnicas de paralelización de bucles y el reparto de tareas a los
procesadores. Finalmente, en el capítulo 9 haremos un breve resumen de la
situación actual de los sistema paralelos, analizando la lista top500, así como
una breve presentación de las herramientas básicas para programar
aplicaciones en paralelo: OpenMP, para los sistemas de memoria compartida
SMP, y MPI, para el caso de paso de mensajes (en sistemas DSM o MPP).
▪ 6 ▪ INTRODUCCIÓN
▪ 1 ▪
Computadores Vectoriales
1.1 ¿QUÉ ES UN COMPUTADOR VECTORIAL?
Como hemos comentado en la introducción, las arquitecturas de tipo
MIMD son las más adecuadas para resolver en paralelo aplicaciones de tipo
general. Existen, sin embargo, algunos problemas importantes, desde el
punto de vista del cálculo requerido, en los que es posible utilizar otro tipo
de arquitecturas para lograr ejecuciones con un alto rendimiento.
Como ya se sabe, en los programas de cálculo científico la mayor parte
del tiempo de ejecución se invierte en la ejecución de bucles. Por ejemplo:
do i = 0, N-1
C(i) = A(i) + B(i)
enddo
Si N es muy grande (N = 109, por ejemplo) el tiempo de ejecución de ese
bucle será muy alto, a pesar de su estructura tan simple. Si lo ejecutamos en
un procesador escalar, el código ensamblador será, por ejemplo, el siguiente:
▪ 8 ▪ Capítulo 1: COMPUTADORES VECTORIALES
buc: FLD F1,A(R1)
FLD F2,B(R1)
FADD F3,F2,F1
FST C(R1),F3
ADDI R1,R1,#8
SUBI R2,R2,#1
BNZ R2,buc
En un procesador escalar se ejecutaría, en el mejor de los casos, una
instrucción por ciclo1, por lo que para ejecutar una iteración del bucle se
necesitarían 7 ciclos; por tanto, el tiempo de ejecución de todo el programa
sería de TE = 7N.
El bucle anterior tiene dos características específicas. Por un lado, las
estructuras de datos que utiliza —los vectores A, B y C— son muy regulares;
y, por otro lado, todas las iteraciones del bucle se pueden ejecutar de manera
independiente, ya que no existen dependencias de datos entre ellas.
Para comenzar, definamos qué es, en este contexto, un vector. Un vector
es una estructura que se puede definir mediante tres parámetros:
• dirección de comienzo: dirección de memoria del primer elemento
del vector.
• longitud: número de elementos del vector.
• paso (stride): distancia en memoria entre dos elementos consecutivos
del vector.
Por ejemplo, un vector que esté almacenado en las posiciones 1000, 1002,
1004, 1006, 1008, 1010, 1012 y 1014 de memoria (cada componente ocupa
una posición de memoria) se definiría así:
dirección de comienzo = 1000 longitud = 8 paso = 2
Un procesador escalar, como su nombre indica, trabaja con escalares. Sin
embargo, en las áreas de Ciencia e Ingeniería es muy común el uso de
vectores y el tiempo de ejecución se invierte, principalmente, en la
ejecución, una y otra vez, de bucles como el anterior. ¿Por qué no definir una
arquitectura y un lenguaje máquina que directamente sean capaces de tratar
con vectores? ¿Por qué no escribir el programa anterior de la siguiente
manera?
1 Si el procesador fuera superescalar, quizás se podría conseguir algo más de una instrucción por ciclo.
1.1 ¿QUÉ ES UN COMPUTADOR VECTORIAL? ▪ 9 ▪
LV V1,A(R1) ; leer el vector A
LV V2,B(R1) ; leer el vector B
ADDV V3,V1,V2 ; sumar ambos vectores
SV C(R1),V3 ; escribir el resultado en el vector C
En este nuevo juego de instrucciones, la instrucción LV V1,A(R1)
implicaría lo siguiente (utilizando, a modo de ejemplo, el esquema de
segmentación que se muestra2):
LV V1,A(R1) BD L AM M M M E
M M M E
M M M E
... ... ...
M M M E
Podríamos representar la ejecuciónanterior, de manera simplificada, de la
siguiente forma:
LV V1,A(R1) BD L AM M M M E E ... ... E
Así pues, mediante una única instrucción leemos de memoria un vector
completo de N elementos. Para que esto sea posible la memoria debe de estar
segmentada, con lo que, si no existe algún otro impedimento, en cada ciclo
proporcionará un elemento del vector, que se irán escribiendo en un registro
vectorial.
El siguiente esquema presenta la ejecución del programa anterior fase a
fase (las latencias de las unidades funcionales son un simple ejemplo):
LV V1,A(R1) BD L AM M M M E ... (N ciclos) ...
LV V2,B(R1) BD L AM M M M E ... (N ciclos) ...
ADDV V3,V1,V2 BD . . . . L A A E ... (N ciclos) ...
SV C(R1),V3 BD L AM . . . . L M M M E ... ... E
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... ... 14+N
ti N
(Por ahora, supongamos que los operandos que necesitan las instrucciones ADDV y SV se
pueden obtener en los ciclos 8 y 11, tal como se indica en la tabla).
2 Las fases de ejecución habituales: BD, búsqueda y descodificación de la instrucción; L, lectura de los
operandos; AM, cálculo de la dirección de memoria; A, una operación en una unidad funcional; M, una
operación en memoria; E, escritura del resultado en los registros. Cada instrucción utiliza solamente
las fases que necesita para su ejecución.
▪ 10 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Si el modelo de ejecución es ese, podemos hacer un análisis sencillo para
obtener el tiempo de ejecución del bucle (de manera simplificada; un poco
más adelante formalizaremos este cálculo): existe un tiempo de inicio —ti—
antes de que la última instrucción comience a escribir, y después, para
terminar la ejecución, se necesitan N ciclos, uno por cada elemento del
vector. Por tanto:
TV = ti + N
Si comparamos esta expresión con la que hemos obtenido para un
procesador escalar, la mejora es clara. Por ejemplo, si el número de
elementos de los vectores es N = 128, y si ti = 14 ciclos, tendríamos los
siguientes tiempos de ejecución:
TE = 7 N = 896 ciclos
TV = ti + N = 142 ciclos (un 16%)
No es ésta la única ventaja. Por un lado, han desaparecido las
dependencias de control3 debida al bucle, ya que, por definición, ha
desaparecido el propio bucle. Por otro lado, sólo se han ejecutado 4
instrucciones, y no las 7N que componían el bucle escalar. Esto implica que
el uso de la cache de instrucciones es mucho más bajo, y, por consiguiente,
el tráfico en el bus también.
Pero, por supuesto, todas esas ventajas no salen “gratis”. A decir verdad,
tenemos que analizar con más detalle el esquema de ejecución anterior, para
conocer los recursos que se necesitan para poder ejecutar de esa manera las
instrucciones vectoriales.
1.1.1 Algunos problemas
1.1.1.1 La memoria de un computador vectorial
Un procesador vectorial utiliza la memoria de modo intensivo. Por
ejemplo, en el caso anterior tenemos 3 instrucciones, 2 LV y 1 SV, que están
utilizando simultáneamente la memoria y, además, cada instrucción realiza N
accesos a memoria. Por tanto, hay que solucionar dos aspectos:
3 Las responsables de las dependencias de control son las instrucciones de salto. En general, después
de la instrucción de dirección i se ejecuta la instrucción de dirección i+1, salvo en el caso de los
saltos. Cuando ejecutamos un salto no sabemos qué instrucción será la siguiente hasta que el salto
termine, por lo que hay que parar al procesador (aunque existen muchas técnicas para evitar esos
ciclos "muertos").
1.1 ¿QUÉ ES UN COMPUTADOR VECTORIAL? ▪ 11 ▪
1. ¿Cuántos buses hay para acceder a memoria? El procesador y el
sistema de memoria se comunican mediante el bus de datos. Una
operación vectorial de memoria va a ocupar el bus de datos durante N
ciclos (supongamos que se transfiere una palabra por ciclo). Por tanto,
si sólo hubiera un bus, sólo una instrucción podría acceder a memoria
en cada momento, y todas las demás deberían esperar a que ésta
terminara. Por consiguiente, el tiempo de ejecución no sería de orden
N, sino de kN (con k = 2, 3, 4..., número de instrucciones de memoria).
2. ¿No habrá conflictos en el uso de los módulos de memoria? A pesar
de que el espacio de memoria esté entrelazado entre los diferentes
módulos de memoria, puede suceder que en un momento determinado
se necesite acceder a elementos de vectores almacenados en el mismo
módulo. Si sucede esto, para poder comenzar un acceso habrá que
esperar a que termine el anterior acceso al mismo módulo, con lo que
aumentará el tiempo de ejecución.
Queda claro que el sistema de memoria de un computador vectorial juega
un papel muy importante en el rendimiento final del sistema: hacen falta
múltiples buses, y la memoria debe estar entrelazada en muchos módulos,
para reducir los conflictos de acceso a los mismos.
1.1.1.2 Unidades funcionales vectoriales
Analizando el esquema de ejecución anterior, queda claro que las
unidades funcionales deben estar segmentadas. Una única instrucción
(ADDV, por ejemplo) realiza N operaciones en la unidad funcional, una por
ciclo. Si no estuviera segmentada, no sería posible generar un dato por ciclo.
De la misma manera, parece necesario poder disponer de varias unidades
funcionales de cada tipo, ya que una instrucción ocupa cada unidad
funcional durante N ciclos.
1.1.1.3 Registros vectoriales
¿Qué es un registro vectorial? ¿De qué tamaño son? ¿Cómo se leen y se
escriben? En un registro vectorial se guardan los elementos de un vector.
Cada elemento, normalmente, será un escalar representado en coma flotante,
por ejemplo en 64 bits. Por tanto, en un registro tendremos 64 × N bits. El
tamaño de los registros es, en todo caso, limitado. Es habitual que un registro
vectorial permita almacenar 64 o 128 (Lmax) elementos de un vector, con lo
▪ 12 ▪ Capítulo 1: COMPUTADORES VECTORIALES
que su capacidad sería de 64 (o 128) × 64 = 4 (u 8) kilobits. Si nos fijamos
en el tamaño, se comprende fácilmente que no se disponga de un número
muy elevado de registros vectoriales. Normalmente dispondremos de 8-16
registros (16 × 8 = 128 kilobits). En algunas máquinas, el tamaño de los
registros es variable; es decir, el "espacio de memoria" de que se dispone se
puede utilizar para definir muchos registros de pocos elementos o unos
pocos de muchos elementos.
¿Qué se debe hacer cuando la longitud de los vectores que tenemos que
procesar es mayor que Lmax (64 o 128 elementos)? No hay más remedio que
formar un bucle, y en cada iteración del mismo procesar Lmax elementos
(strip mining). Por tanto, aparecen de nuevo las dependencias de control,
aunque esta vez cada 64 (128) elementos.
En los primeros computadores vectoriales los registros se trataban como
una “unidad”, por lo que no era posible leer y escribir sobre el mismo
registro a la vez. Hoy en día, los elementos que conforman un registro
vectorial se tratan como unidades independientes que pueden direccionarse
de manera separada, con lo que es posible acceder a los primeros elementos
de un vector ya almacenados en un registro mientras se sigue escribiendo el
resto de elementos. Por otro lado, dado que diferentes instrucciones irán
produciendo datos para escribir en el banco de registros vectoriales, y que
cada una de ellas necesitará muchos ciclos para escribir el vector resultado,
serán necesarios varios (muchos) buses de escritura (evidentemente, también
se necesitan “muchos” buses de lectura). Con todo ello, el banco de registros
de un procesador vectorial resulta ser un dispositivo complejo.
1.1.1.4 Programas vectoriales
¿Qué tipo de programas se pueden ejecutar en un computador vectorial?
Los procesadores vectoriales están optimizados para procesar vectores, pero
en los programas reales, además de procesar vectores, habrá que procesar
código escalar.¿Cómo se hace eso? ¿Qué influencia tiene en la velocidad de
cálculo? (como veremos, el efecto del código escalar puede ser muy grande).
registros vectoriales U.F.
1.1 ¿QUÉ ES UN COMPUTADOR VECTORIAL? ▪ 13 ▪
Analicemos de nuevo qué se hace cuando se procesan vectores. Veamos el
siguiente ejemplo:
do i = 0, N-1
A(i) = A(i) + 1
enddo
Si se ejecutara escalarmente, y simplificando, el orden de ejecución de las
diferentes operaciones sería el siguiente (L = load; S = store; + = suma; i =
elemento del vector):
L0 +0 S0 / L1 +1 S1 / L2 +2 S2 / L3 +3 S3 / ... / LN–1 +N–1 SN–1
Si lo ejecutáramos vectorialmente (LV - ADDV - SV), el orden pasaría a
ser el siguiente:
L0 L1 L2 ... LN–1 / +0 +1 +2 ... +N–1 / S0 S1 S2 ... SN–1
Esto es, la ejecución vectorial implica desordenar el código original. Y
como ya sabemos, esto no siempre es posible, ya que hay que respetar las
dependencias de datos entre las instrucciones. Por tanto, para decidir si un
programa se puede ejecutar vectorialmente o no, hay que hacer un
meticuloso análisis de las dependencias, tarea que, como veremos, va a
recaer, en gran medida, en un buen compilador vectorial.
Resumiendo todo lo anterior: aunque hemos definido un modelo de
computador con un rendimiento teórico muy elevado, en la realidad tenemos
que superar muchos problemas para poder llegar a esa velocidad de cálculo.
1.1.2 Arquitectura y lenguaje máquina
Existen diferentes arquitecturas para los computadores vectoriales, casi
tantas como fabricantes. En los primeros diseños, los computadores
vectoriales no tenían registros, y todas las operaciones se hacían con los
operandos en memoria. A este modelo se le denomina "Memoria-Memoria"
(M/M). Pero pronto se añadieron los registros vectoriales; como
consecuencia, los operandos de las operaciones vectoriales se obtienen de
registros y los resultados se dejan en registros (modelo R/R).
En la siguiente figura se muestra un esquema lógico, muy simple, de un
computador vectorial. Podemos distinguir dos secciones: la sección escalar y
la vectorial. El procesador escalar se encarga de la búsqueda y
descodificación de las instrucciones. Si la instrucción es escalar, la ejecuta él
▪ 14 ▪ Capítulo 1: COMPUTADORES VECTORIALES
mismo, utilizando los registros escalares necesarios; pero si es vectorial,
pasa el control al procesador vectorial para que la ejecute. Salvo que
especifiquemos alguna otra opción, vamos a suponer que la unidad de
control es de tipo Tomasulo (desorden/desorden).
Tal y como hemos comentado, aunque vamos a trabajar con vectores, en
la realidad tendremos una mezcla de código vectorial y escalar. Por tanto,
tendremos que utilizar tanto instrucciones vectoriales como escalares. Las
instrucciones escalares son las habituales en cualquier procesador RISC. En
función del computador, existen diferentes juegos de instrucciones
vectoriales y de formatos de instrucciones; las más habituales son las
siguientes (más tarde veremos algunas otras):
OPV Vi,Vj,Vk Vi = Vj OP Vk
(OP = ADD, SUB, MUL, DIV...)
Operación entre dos vectores. El resultado es otro
vector.
OPVS Vi,Vj,Fk Vi = Vj OP Fk
OPVI Vi,Vj,#inm Vi = Vj OP #inm
(OP = ADD, SUB, MUL, DIV...)
Operación entre un vector y un escalar. El
resultado es un vector.
Registros
Unidades
funcionales
Procesador
escalar
(completo)
Control del
procesador
vectorial
Unidad de
direcciones
(datos)
M
em
o
ri
a
(op.)
1.2 DEPENDENCIAS DE DATOS ▪ 15 ▪
LV Vi,A(Rj) Se lee a partir de la dirección de memoria A+Rj
un vector, y se deja en el registro Vi (puede
haber más modos de direccionamiento).
SV A(Rj),Vi Similar a la anterior, pero, en lugar de leer,
escribe un vector en memoria.
Para identificar un vector en memoria, hay que dar tres parámetros:
dirección de comienzo, longitud y paso. La dirección de comienzo se indica
en la propia instrucción LV/SV (de acuerdo al modo de direccionamiento que
se utilice). La longitud del vector y el paso, en cambio, hay que indicarlos
previamente a la operación de lectura o escritura. Para ello utilizaremos dos
registros especiales: VL (vector length), para indicar el número de elementos
del vector, su longitud, y VS (vector stride), para indicar el paso. Si el
contenido de VL es mayor que Lmax (tamaño de los registros vectoriales),
sólo se procesarán Lmax elementos.
Así pues, tenemos que ejecutar las siguientes instrucciones para, por
ejemplo, leer un vector:
MOVI VL,#64 ; los vectores son de 64 elementos
MOVI VS,#8 ; el paso es 8
LV V1,A(R1)
De esta manera se cargarán en el registro V1 64 elementos de un vector,
correspondientes a las direcciones A+R1, A+R1+8, A+R1+16…
En algunos computadores es necesario indicar explícitamente el paso de
los vectores en la propia instrucción, utilizando para ello un registro de
propósito general.
1.2 DEPENDENCIAS DE DATOS
Al igual que sucede con los procesadores (super)escalares, la velocidad de
cálculo de los procesadores vectoriales está limitada por las dependencias de
datos. Una instrucción depende de otra anterior si uno de sus operandos es el
resultado de dicha instrucción, por lo que deberá esperar a que finalice antes
de poder ejecutarse. Ya sabemos que, en los procesadores escalares, para
▪ 16 ▪ Capítulo 1: COMPUTADORES VECTORIALES
atenuar la pérdida de rendimiento debida a las dependencias de datos se
utilizan cortocircuitos (forwarding) entre las unidades funcionales; una idea
similar se aplica también en los procesadores vectoriales.
Para los siguientes ejemplos utilizaremos el siguiente esquema de
segmentación (Tomasulo):
LV/SV → BD L AM M M M E
ADDV → BD L A A E
1.2.1 Encadenamiento (chaining)
Se dice que dos instrucciones se encadenan si la segunda utiliza el vector
generado por la primera sin esperar a que ésta lo haya guardado en el
registro vectorial. Veamos un ejemplo sencillo:
do i = 0, N-1 LV V1,A(R1)
A(i) = A(i) + 1 → ADDVI V2,V1,#1
enddo SV A(R1),V2
El bucle presenta dependencias de datos muy claras: LV → ADDVI (V1) y
ADDVI → SV (V2). Entonces ¿cómo se ejecutará ese programa? Tenemos
dos alternativas: sin realizar encadenamiento entre las dos instrucciones, o
encadenándolas.
a. Si no se realiza encadenamiento, la segunda instrucción deberá esperar
a que termine la primera, para poder leer el registro vectorial
correspondiente (V1). En la figura se muestra un esquema de
ejecución, en el que se puede ver cuándo se realizan las lecturas (L).
LV V1,A(R1) BD L AM M M M E ... E
ADDVI V2,V1,#1 BD . . . . . ... . L A A E ... E
SV A(R1),V2 BD L AM . . ... . . . . . ... . L M M M E ...
ciclos ← 6 → ← N → ← 3 → ← N → ← 4 → ← N
Por tanto, el tiempo de ejecución en este caso es TV = 13 + 3N ciclos.
b. En cambio, si se realiza encadenamiento, según se van generando los
vectores se van utilizando en la siguiente unidad funcional; es decir, se
utiliza el cortocircuito E → L.
1.2 DEPENDENCIAS DE DATOS ▪ 17 ▪
LV V1,A(R1) BD L AM M M M E E ... (N cicl.) ...
ADDVI V2,V1,#1 BD . . . . L A A E E ... (N cicl.) ...
SV A(R1),V2 BD L AM . . . . L M M M E ... ... E
ciclos ← 6 → ← 3 → ← 4 → ← N →
En este segundo caso, el tiempo de ejecución es TV = 13 + N ciclos.
Podemos analizar el mismo comportamiento de manera cualitativa. Por
ejemplo, la siguiente figura muestra un esquema de ejecución muy
simplificado del programa anterior (LV / ADDVI / SV), en función de si se
encadenan o no las instrucciones:
sin encadenamiento: T ~ 3N con encadenamiento: T ~ N
La diferencia entre ambas opciones es clara. En el primer caso, el tiempo
de ejecución es del orden de 3N; en el segundo, en cambio, es deorden N.
Por ejemplo, para N = 64 el tiempo de ejecución bajaría de 13 + 3×64 = 205
ciclos a 13 + 64 = 77 ciclos (un 38%). Así pues, necesitamos poder
encadenar las instrucciones para conseguir un buen rendimiento.
1.2.1.1 Encadenamiento con dos instrucciones
En el ejemplo del apartado anterior, el encadenamiento se ha realizado
con una única instrucción anterior: la instrucción ADDVI con la instrucción
LV, o la instrucción SV con la instrucción ADDVI. En un caso más general,
tendríamos que poder encadenar una instrucción con dos instrucciones
anteriores. Veamos un ejemplo (C = A + B):
LV V1,A(R1) BD L AM M M M E E ... (N ciclos) ...
LV V2,B(R1) BD L AM M M M E E ... (N ciclos) ...
ADDV V3,V1,V2 BD . . . . L A A E E ... (N ciclos) ...
SV C(R1),V3 BD L AM . . . L M M M E ... ... E
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 14+N
LV
ADDVI
SV
ADDVI
SV
LV
▪ 18 ▪ Capítulo 1: COMPUTADORES VECTORIALES
La tercera instrucción (ADDV) necesita los vectores V1 y V2, que son
generados por las dos primeras instrucciones respectivamente. Pero estos dos
vectores no se generan sincronizados: el primero se comienza a generar en el
ciclo 7 y el segundo en el 8 (y a partir de ahí el resto de elementos). Por
tanto, en el ciclo 7 no está preparado el primer elemento del segundo
operando (V2), y en el ciclo 8 se pierde la posibilidad de tomar el primer
elemento del primer operando (V1) (los datos no se “pierden”, claro ya que
se están cargando en el registro vectorial). ¿Qué se puede hacer?
Para poder efectuar el encadenamiento hay que coger un operando según
sale de la unidad funcional y leer el otro del registro correspondiente (V1),
donde ya se está escribiendo. Para ello es necesario que el banco de registros
permita la lectura y escritura simultánea del mismo registro (lo habitual en
las máquinas vectoriales actuales, y que se conoce como flexible chaining o
encadenamiento flexible); si eso no es posible, se perderá la posibilidad de
encadenar (salvo que se aplique alguna otra solución) y habrá que esperar a
que finalice la escritura de ambos operandos.
1.2.2 Tablas de ejecución de las instrucciones
Representar los esquemas de ejecución de un conjunto de instrucciones
vectoriales fase a fase es un poco pesado. Por ello, en lugar de hacer ese tipo
de esquemas, vamos a resumir en una tabla las acciones principales que
suceden cuando se ejecutan las instrucciones:
• Inicio de ejecución: cuántos ciclos han pasado, desde el comienzo,
hasta el momento previo a iniciar la operación en la UF. El inicio
puede ser tras la lectura de los registros, o mediante encadenamiento,
en cuyo caso indicaremos el número de ciclos entre [ ].
(La ejecución de instrucciones es segmentada, y las instrucciones se ejecutan de una
en una, no es superescalar.)
• Latencia de la unidad funcional.
• Ciclo en el que se genera el primer elemento.
• Ciclo en el que se genera el último (N) elemento.
Por ejemplo, para una instrucción LV la tabla correspondiente sería:
BD L AM M M M E ... ... E
comienzo (3) lat. UF (3) dato 1 (6+1) dato N (6+N)
1.2 DEPENDENCIAS DE DATOS ▪ 19 ▪
Las ejecuciones de los dos ejemplos anteriores se pueden resumir así:
sin encadenamiento con encadenamiento
A = A + 1 inic. lat. UF dato 1 dato N inic. lat. UF dato 1 dato N
LV V1,A(R1) 3 3 6+1 6+N 3 3 6+1 6+N
ADDVI V2,V1,#1 6+N+1 2 9+N+1 9+2N [7] 2 9+1 9+N
SV A(R1),V2 9+2N+1 3 13+2N+1 13+3N [10] 3 13+1 13+N
Si la ejecución de las instrucciones no se encadena, la instrucción ADDVI
tiene que esperar a que termine la escritura en el registro V1 (ciclo 6+N) y
luego leer del registro (+1). Lo mismo le sucede a la instrucción SV: tiene
que esperar a que la instrucción ADDVI termine (9+2N), y entonces leer V2
y escribir en memoria.
Si la ejecución de las instrucciones se encadena, la suma puede comenzar
en el ciclo 7 (en ese ciclo llega de memoria el primer elemento del vector), y
la escritura en memoria puede comenzar en el ciclo 10 (ciclo en que la suma
genera el primer dato).
En el segundo ejemplo podemos observar el mismo comportamiento. Si
no se puede encadenar, la instrucción ADDV tiene que esperar a tener listos
ambos operandos (ciclo 7+N) y entonces leerlos. Cuando se encadena, uno
de los operandos (V2) se obtiene directamente de la memoria y el otro (V1)
del registro (donde se ha escrito en el ciclo anterior); el ciclo de
encadenamiento es, por tanto, el ciclo 8.
sin encadenamiento con encadenamiento
C = A + B inic. lat. UF dato 1 dato N inic. lat. UF dato 1 dato N
LV V1,A(R1) 3 3 6+1 6+N 3 3 6+1 6+N
LV V2,B(R1) 4 3 7+1 7+N 4 3 7+1 7+N
ADDV V3,V1,V2 7+N+1 2 10+N+1 10+2N [8] 2 10+1 10+N
SV C(R1),V3 10+2N+1 3 14+2N+1 14+3N [11] 3 14+1 14+N
Nota: estamos aplicando un modelo “didáctico” de ejecución vectorial, y el objetivo
es mostrar el comportamiento general, no los detalles particulares. Lo computadores
comerciales utilizan estrategias similares, aunque los detalles de implementación
pueden variar.
▪ 20 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.3 DEPENDENCIAS ESTRUCTURALES
Después de analizar las dependencias de datos, analicemos las
dependencias estructurales. Recuerda que un conflicto o dependencia
estructural surge cuando se quiere utilizar un recurso mientras está ocupado
por otra instrucción. Además de las unidades funcionales, el recurso más
importante en un computador vectorial es la memoria. Para poder utilizar la
memoria, primeramente hay que disponer de un bus libre. ¿Cuántos buses
tenemos para acceder a la memoria? Por otro lado, se utilizan los propios
módulos de memoria. ¿Están libres los módulos que hay que utilizar? Si
están ocupados, ¿cuánto tiempo hay que esperar?
1.3.1 Buses de memoria (unidades funcionales LV/SV)
La ejecución de las instrucciones LV y SV implica una transferencia con
memoria en la que se utilizan los buses. Cuando se ejecuta una instrucción
LV o SV, el bus se ocupa durante N ciclos; mientras una instrucción está
utilizando el bus, la siguiente deberá esperar hasta que se libere el bus. Por
tanto, si el computador no tuviera un número suficiente de buses, la
velocidad de cálculo de la máquina no sería muy alta.
Analicemos la influencia del número de buses mediante el ejemplo
anterior (A = A + 1; LV / ADDVI / SV). Supongamos que la máquina puede
encadenar las instrucciones, pero que sólo posee un bus para trabajar con
memoria (LV o SV)4. En estas condiciones, cuando la instrucción SV quiere
empezar a escribir en memoria, en el ciclo de encadenamiento, el bus no está
disponible, ya que lo ocupa la instrucción LV (y lo mantendrá ocupado
muchos ciclos). Por tanto, deberá esperar hasta que termine la primera
instrucción (y se libere el bus) y leer entonces el registro en el que se están
escribiendo los resultados (V2)5.
Este sería el esquema de ejecución:
4 Los buses de memoria pueden usarse tanto para una lectura como para una escritura; en algunas
máquinas, en cambio, los buses están "dedicados": unos son sólo para leer y otros sólo para escribir.
5 Si no puede leerse un registro mientras se está escribiendo, entonces habrá que esperar a que finalice
la escritura.
1.3 DEPENDENCIAS ESTRUCTURALES ▪ 21 ▪
LV V1,A(R1) BD L AM M M M E E ... (N ciclos) ... E
ADDVI V2,V1,#1 BD . . . . L A A E E ... (N ciclos) ... E
SV A(R1),V2 BD L AM . . . . ? . ... ... L M M M E ... (N cicl.)
bus ocupado... libre
o, esquemáticamente:
La tabla correspondiente a la ejecución sería la siguiente:
un bus / encadenamiento
A = A + 1 inic. lat. UF dato 1 dato N
LV V1,A(R1) 3 3 6+1 6+N
ADDVI V2,V1,#1 [7] 2 9+1 9+N
SV A(R1),V2 [6+N] 3 9+N+1 9+2N
Repitamos el análisis, pero con el segundo ejemplo que hemos visto antes.
En ambos casos, las instrucciones se encadenan, pero en el primer caso la
máquinacuenta con un solo bus de memoria, y en el otro caso cuenta con
dos buses.
un bus / encadenamiento dos buses / encadenamiento
C = A + B inic. lat. UF dato 1 dato N inic. lat. UF dato 1 dato N
LV V1,A(R1) 3 3 6+1 6+N 3 3 6+1 6+N
LV V2,B(R1) 6+N 3 9+N+1 9+2N 4 3 7+1 7+N
ADDV V3,V1,V2 [10+N] 2 12+N+1 12+2N [8] 2 10+1 10+N
SV C(R1),V3 [9+2N] 3 12+2N+1 12+3N [6+N] 3 9+N+1 9+2N
Cuando sólo hay un bus, la segunda instrucción LV no puede utilizar la
memoria hasta que el primer LV la deje de utilizar, y lo mismo le sucede a la
instrucción SV (para cuando se libera el bus, la escritura en el registro V3
está terminando). Por tanto, el tiempo de ejecución es de orden 3N. Si la
ADDVI
SV
LV
T ~ 2N
▪ 22 ▪ Capítulo 1: COMPUTADORES VECTORIALES
máquina tiene dos buses, las instrucciones LV se ejecutarán a la vez, pero la
instrucción SV tendrá que esperar.
Esquemáticamente:
un bus / encadenamiento: 3N dos buses / encadenamiento: 2N
La conclusión es sencilla: si no existen suficientes recursos (buses) para
poder ejecutar las instrucciones de memoria, a pesar de tener la posibilidad
de encadenar las instrucciones el tiempo de ejecución será elevado.
En resumen, los resultados que hemos obtenido con ambos ejemplos son
los siguientes:
1. A = A + 1 (N = 64)
sin encadenamiento 13 + 3N = 205 ciclos → 3,20 ciclos/dato
encadenamiento / 1 bus 9 + 2N = 137 ciclos → 2,14 c/d
encadenamiento / 2+ buses 13 + N = 77 ciclos → 1,20 c/d
2. C = A + B (N = 64)
sin encadenamiento / 1 bus 16 + 4N = 272 ciclos → 4,25 c/d
sin encadenamiento / 3 buses 14 + 3N = 206 ciclos → 3,22 c/d
encadenamiento / 1 bus 12 + 3N = 204 ciclos → 3,19 c/d
encadenamiento / 2 buses 9 + 2N = 137 ciclos → 2,14 c/d
encadenamiento / 3 buses 14 + N = 78 ciclos → 1,22 c/d
Los datos muestran claramente la importancia de disponer de suficientes
buses a memoria y de que las instrucciones puedan encadenarse para que las
instrucciones se ejecuten eficientemente.
1.3.2 Conflictos en el uso de los módulos de memoria
1.3.2.1 Una sola operación de memoria
Tras haber analizado el problema de los buses en un procesador vectorial,
analicemos ahora el uso de la propia memoria. La memoria de cualquier
computador está entrelazada en varios módulos; así, las direcciones i e i+1
LV
LV
SV
ADDV
LV
LV
SV
ADDV
1.3 DEPENDENCIAS ESTRUCTURALES ▪ 23 ▪
no corresponden al mismo módulo de memoria, sino a módulos
consecutivos. De esta manera es posible, por ejemplo, efectuar una
operación simultánea en dos (en general nm, el número de módulos)
direcciones consecutivas; si estuvieran en el mismo módulo tendríamos que
esperar a que finalizara una operación antes de empezar con la siguiente.
Cuando se ejecuta una instrucción LV o SV se efectúan N lecturas o
escrituras en memoria, una por ciclo. Para que se haga de manera eficiente,
es necesario que se acceda a módulos que estén libres; si no, tendríamos un
conflicto estructural, y no lograríamos efectuar una operación por ciclo.
Veamos el problema con un ejemplo. Hay que leer el vector A(A0:A15); la
memoria está entrelazada en 4 módulos, y el vector se encuentra en módulos
consecutivos (s = 1) a partir de m0. La latencia de la memoria es de 3 ciclos.
La situación de la memoria según se lee el vector A es la siguiente:
m0 m1 m2 m3
A0 A1 A2 A3
A4 A5 A6 A7
A8 A9 A10 A11
A12 A13 ...
→ tiempo (ciclos)
m0 M M M M M M ...
m1 M M M M M M ...
m2 M M M M M M
m3 M M M M M M
La lectura comienza en m0, y sigue en m1, m2, m3, y se vuelve a m0,
para seguir leyendo más elementos del vector. En ese momento, m0 está
libre, puesto que ya ha terminado el primer acceso, y por tanto no tendremos
ningún problema.
Pero si, por ejemplo, la latencia de la memoria fuera de 8 ciclos, al ir a
utilizar nuevamente m0 lo encontraríamos ocupado, ejecutando todavía la
operación anterior. Tendríamos, por tanto, que esperar a que finalizara antes
de poder seguir leyendo el vector. Como consecuencia del conflicto
estructural, el tiempo de ejecución de la operación sería más alto.
El problema puede ser grave, en función de la definición del vector. Por
ejemplo, si el paso del vector del ejemplo anterior fuera s = 4, entonces todos
los elementos del vector estarían en el mismo módulo, m0: todos los accesos
significarían un conflicto, ya que cada acceso dura 3 ciclos.
▪ 24 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Para analizar si surgirán o no conflictos en memoria, hay que considerar
tres parámetros: el tiempo de acceso o latencia de la memoria —tm—, el
número de módulos en que está entrelazada la memoria —nm—, y el paso
de los vectores —s—. Dos de esos parámetros, latencia y número de
módulos, son decisiones de diseño: se deciden al construir la máquina y no
son modificables por el usuario. El tercero, en cambio, el paso de los
vectores, corresponde al programa concreto que se ejecuta, y puede
modificarse para intentar evitar conflictos.
Cuando s = 1, se utilizan todos los módulos de memoria al acceder a un
vector (m0-m1-m2-...). Por tanto, para que no haya conflictos se debe
cumplir que:
mtnm ≥
De esa manera, cuando hay que reutilizar un determinado módulo ya han
pasado al menos nm ciclos, y por tanto estará libre.
Para el caso general, s > 1, hay que calcular cuántos módulos se utilizan
en una determinada operación. Por ejemplo, en el caso anterior, cuando s =
4, sólo se utiliza un módulo de memoria, siempre el mismo (m0). Puede
demostrarse fácilmente que el número de módulos que se utilizan en una
operación de memoria es:
),( snmMCD
nm
(MCD = máximo común divisor)
Así pues, y generalizado el resultado anterior, no habrá conflictos en
memoria si el número de módulos que se van a utilizar es mayor o igual que
la latencia:
mtsnmMCD
nm
≥
),(
Analizando la expresión anterior. se observa que la mejor situación se
corresponde con el caso MCD(nm,s) = 1, es decir cuando, nm y s son primos
entre sí, ya que se utilizan todos los módulos de memoria. En los casos más
habituales, nm es una potencia de 2 (8, 16, 32, 64...). En esos casos, y si no
hay conflicto cuando s = 1, no habrá conflictos para cualquier vector de paso
impar (1, 3, 5...), pero podría haberlos para los casos de s par.
Existe una situación óptima. Si el número de módulos de memoria, nm, es
un número primo, entonces cualquier paso s será primo con él (salvo sus
múltiplos). Por ejemplo, si nm = 5, no hay problemas con los vectores de
1.3 DEPENDENCIAS ESTRUCTURALES ▪ 25 ▪
paso s = 1, 2, 3, 4, 6, 7, 8... Por desgracia, cuando la memoria se entrelaza en
un número primo de módulos, 17 por ejemplo, calcular el módulo y la
dirección dentro del módulo que corresponden a una palabra dada es una
operación “compleja” (operación que debe realizar el controlador de
memoria, para efectuar cualquier acceso a memoria), ya que habrá que
efectuar una división para obtener el cociente y el resto (cuando nm = 2i, los
i bits de menos peso indican el módulo, y el resto la dirección dentro del
módulo). Debido a esa división, no se suele entrelazar la memoria en un
número primo de módulos.
El valor de s es muy variable en los programas vectoriales. Por ejemplo, al
procesar matrices pueden definirse diferentes tipos de vectores: filas,
columnas, diagonales... Para multiplicar dos matrices, por ejemplo, hay que
usar filas en una y columnas en la otra. En algunos casos, para optimizar el
acceso a memoria, las matrices no se guardan en posiciones consecutivas de
memoria, sino que se dejan huecos sin ocupar (padding).
Veamos un ejemplo de la utilidad de esta estrategia. Sea una memoria
entrelazada en 4 módulos y una matriz de tamaño 4×4, de la que se van a
utilizar las filas y las columnas. Como puede verse, no hay problemas en el
acceso a una fila (s = 1), ya que los elementos están en módulos de memoria
diferentes, pero el acceso a cualquier columna es muy conflictivo,ya que
todos los elementos están en el mismo módulo de memoria. Sin embargo, si
se dejan huecos en memoria entre los elementos de la matriz (en la tabla se
muestra un ejemplo), entonces es posible acceder tanto a filas (s = 1) como a
columnas (ahora s = 5) sin conflictos (aunque se generen conflictos en el
acceso a las diagonales6).
m0 m1 m2 m3 m0 m1 m2 m3
A00 A01 A02 A03 A00 A01 A02 A03
A10 A11 A12 A13 → - A10 A11 A12
A20 A21 A22 A23 A13 - A20 A21
A30 A31 A32 A33 A22 A23 - A30
A31 A32 A33 -
sf = 1 sin conflictos sf = 1 sin conflictos
sc = 4 conflictos (todos en m0) sc = 5 sin conflictos
sD = 5 sin conflictos sD = 6 conflictos
sd = 3 sin conflictos sd = 4 conflictos
6 Como hemos comentado, el ideal sería que nm fuera un número primo. Por ejemplo, si nm fuera 5,
los cuatro vectores del ejemplo (f, c, D y d) podrían accederse sin problemas si se dejan los
correspondientes huecos (lo dejamos como ejercicio para el lector).
▪ 26 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.3.2.2 Varias operaciones de memoria
Como acabamos de ver, la ejecución de una instrucción vectorial de
memoria, LV o SV, puede producir problemas en el acceso a los módulos de
memoria. Lo mismo ocurre cuando se están ejecutando más de una
instrucción de memoria. Aunque cada una de ellas no tuviera conflictos
consigo misma, es posible que existan colisiones entre ellas; es decir, que
una segunda instrucción quisiera utilizar un módulo de memoria ocupado en
ese instante por otra instrucción.
Analicemos el problema mediante un ejemplo. Supongamos que la
memoria está entrelazada en 8 módulos y que la latencia es 3 ciclos (con el
mismo esquema de segmentación de los ejemplos anteriores). Hay
suficientes buses a memoria y las instrucciones pueden encadenarse. El
primer elemento de A esta en el módulo m0 y el paso es s = 1.
2 buses / encadenamiento
A = A + 1 inic. lat. UF dato 1 dato N
LV V1,A(R1) 3 3 6+1 6+N
ADDVI V2,V1,#1 [7] 2 9+1 9+N
SV A(R1),V2 [10]
Como hemos visto antes, la instrucción SV puede encadenarse en el ciclo
10, e ir a memoria. Pero, ¿cómo se encuentran en ese momento los módulos
de memoria, libres u ocupados? El esquema siguiente muestra el uso de los
módulos de memoria ciclo a ciclo. La instrucción LV comienza la lectura en
el ciclo 4, en el módulo m0, por ejemplo. Supongamos, por simplificar el
problema, que el paso de A es 1. Por tanto, tras el módulo m0 se accederá a
m1, m2..., m7, y nuevamente a m0, m1... Dado que la latencia es 3 ciclos, la
instrucción no tiene ningún conflicto consigo misma (nm ≥ tm).
t (ciclos)
mem. 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
m0 M M M - M M M m m m
m1 M M M - - M M M m m m
m2 M M M M M M
m3 M M M M M M
m4 M M M M M M
m5 M M M M M M
m6 M M M M M
m7 M M M M
1.3 DEPENDENCIAS ESTRUCTURALES ▪ 27 ▪
La instrucción SV puede encadenarse en el ciclo 10, para empezar en
memoria en el ciclo 11, en el módulo m0 (hay que guardar el mismo vector,
A). ¿Cómo se encuentra en ese momento ese módulo? La instrucción LV está
en ejecución, y mantiene ocupados varios módulos. Primeramente debemos
calcular qué módulo va a acceder LV en ese ciclo, para lo que basta con
saber cuántos ciclos lleva ya en memoria y de qué módulo partió. En este
ejemplo, la distancia en ciclos es de 10 – 3 = 7, y dado que partió del módulo
m0 (y que s = 1), en el ciclo 11 irá a utilizar el módulo m7. Ese módulo, por
tanto, no estará disponible.
Pero habrá más módulos ocupados, ya que todavía estarán ejecutándose
accesos que comenzaron en ciclos anteriores. Si la latencia de la memoria es
tm, se mantiene ocupados tm–1 módulos anteriores. En el ejemplo tm = 3,
por lo que tendremos dos módulos ocupados, m6 y m5. Utilizando el mismo
razonamiento, es necesario dejar libres por delante otros tm–1 módulos, para
que la instrucción LV pueda seguir ejecutándose sin interferencias; en el
ejemplo, los módulos m0 y m1 (si no, LV “chocaría” con SV en el siguiente
ciclo).
Así pues, en este ejemplo en el ciclo 11 están ocupados o reservados los
módulos <m5 – m6 – m7 – m0 – m1>. Si alguna instrucción quiere utilizar
esos módulos, deberá esperar a que se liberen. Ése es el caso de la
instrucción SV, que tiene que utilizar m0, y que tendrá que esperar.
¿Cuántos ciclos? Tantos como la posición que ocupa el módulo a utilizar en
la lista de módulos ocupados. En el ejemplo, 4 ciclos. En el ciclo 11 están
ocupados los módulos m5-...-m1; en el siguiente ciclo, por tanto, los
módulos m6-...-m2; en el siguiente, m7-...-m3, y, en el siguiente, m0-...-m4.
Finalmente, en el siguiente ciclo se liberará m0, que podrá ser utilizado por
la instrucción SV para comenzar la escritura del vector A.
En resumen, para analizar los conflictos en memoria entre las
instrucciones j y k, el procedimiento es el siguiente (todas las operaciones son
módulo nm, siendo nm el número de módulos de memoria):
a. Se calcula qué módulo va a comenzar a utilizar la instrucción j cuando
la instrucción k quiere acceder a memoria:
(inik – inij) + módulo_inij (ini = ciclo inicio en memoria)
b. Se crea la lista de módulos ocupados, añadiendo tm–1 módulos por
delante y por detrás al módulo anterior (tm, latencia de la memoria).
< tm–1 módulos | (inik – inij) + módulo_inij | tm–1 módulos>
▪ 28 ▪ Capítulo 1: COMPUTADORES VECTORIALES
c. Si el primer módulo de memoria que va a utilizar la instrucción k está
ocupado, se calcula el tiempo de espera, que habrá que añadir al
tiempo de inicio de la instrucción (antes de la UF).
El procedimiento anterior se puede generalizar para el caso de acceso a vectores con
paso s, siempre que los pasos de las instrucciones que están accediendo a memoria
sean iguales. En este caso, dado que en cada paso se avanzan s módulos, por un
lado, habrá que hacer (inik – inij) × s + módulo_inij y luego habrá que contar tm–1
módulos de s en s. En caso de que no coincida el paso de todas las instrucciones a
memoria el análisis es más complejo y, normalmente, en función de la máquina, no
se comenzará a ejecutar la segunda instrucción hasta que haya terminado la primera.
Repitamos el ejercicio anterior, pero teniendo en cuenta los conflictos en
memoria. Tal y como se muestra en la tabla, la instrucción SV quiere realizar
un encadenamiento en el ciclo 10. Dado que la instrucción LV está aún en
memoria, no podrá utilizar los siguientes módulos: el módulo (10 – 3) + 0 =
7, los dos anteriores, 6 y 5, y los dos siguientes, 0 y 1. Como necesita
acceder al módulo 0, el tiempo de espera será de 4 ciclos. Tras ese tiempo,
ciclo 14, el encadenamiento no se podrá realizar directamente del sumador,
sino que habrá que realizarlo desde el registro (si esto no fuera posible,
habría que esperar a que la suma terminara de escribir en el registro V2).
2 buses / encadenamiento
A = A + 1 inic. mod. ocup. t. esp. lat. UF dato 1 dato N
LV V1,A(R1) 3 - - 3 6+1 6+N
ADDVI V2,V1,#1 [7] - - 2 9+1 9+N
SV A(R1),V2 [10] ?? 5 / 6 –7– 0 / 1 +4 3 17+1 17+N
En general, para calcular el número de ciclos que hay que esperar para
utilizar la memoria, hay que hacer el análisis con todas las instrucciones que
estén en memoria, ya que cada una de ellas ocupará tm módulos de memoria.
Como consecuencia de ello, no se pueden permitir más de nm div tm
operaciones de memoria simultáneamente, ya que con ese número de
instrucciones se ocupan todos los módulos de memoria. Por ejemplo, para el
anterior caso (nm = 8 y tm = 3) no se pueden procesar simultáneamente más
de 8 div 3 = 2; estaría de sobra, por tanto, un hipotético tercer bus a
memoria.
1.3 DEPENDENCIAS ESTRUCTURALES ▪ 29 ▪
De los párrafos anteriores se pueden deducir dos consecuencias
importantes: por un lado, se necesita que el nivel de entrelazado del sistema
de memoria sea grande, para que sepueda mantener el flujo de datos sin
conflicto; y, por otro, va a ser importante una correcta colocación de los
vectores en memoria para evitar colisiones en el acceso a diferentes vectores,
para lo que puede ser importante el papel que juegue el compilador.
1.3.3 Longitud de los registros vectoriales (strip mining)
Otro factor que limita el rendimiento de la ejecución vectorial es el
tamaño de los registros vectoriales. Los registros vectoriales se utilizan con
el mismo propósito que los escalares: mantener cerca del procesador los
datos que se van a utilizar. Los registros vectoriales son un recurso limitado.
Por una parte, se trata de un número de registros no muy alto, menor en todo
caso que el de registros escalares. Por otra parte, el número de buses de
escritura y lectura también será limitado, y además se mantienen ocupados a
lo largo de muchos ciclos (con la instrucción ADDV V3,V2,V1 se ocupan
tres buses durante N ciclos). Por ello, necesitaremos bastantes buses para
poder efectuar simultáneamente más de una operación con registros.
Una tercera limitación proviene del tamaño de los registros vectoriales. Si
bien los vectores que procesa el usuario pueden ser de cualquier tamaño, los
registros vectoriales suelen ser de un tamaño limitado, habitualmente de 64-
128 palabras. Por tanto, con una instrucción vectorial no se pueden procesar
más elementos que los correspondientes al tamaño de los registros. Para
vectores más largos hay que montar un bucle y procesar el vector en trozos
de tamaño Lmax. A este procedimiento se le denomina strip mining.
El tamaño de los vectores que hay que leer o escribir en memoria se indica
en un registro especial, VL (vector length). Si VL ≤ Lmax, se procesará el
número de elementos indicado en VL; por el contrario, si VL > Lmax, entonces
se procesarán únicamente Lmax elementos. Por ejemplo:
do i = 0, N-1
A(i) = A(i) + 1 →
enddo
MOVI VS,#1
MOVI R1,#N
mas: MOV VL,R1
LV V1,A(R2)
ADDVI V2,V1,#1
SV A(R2),V2
ADDI R2,R2,#Lmax (× tam. pal.)
SUBI R1,R1,#Lmax
BGTZ R1,mas
▪ 30 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Según la longitud de los vectores, puede ser que el último (o el primer)
trozo que se procese sea más pequeño que el resto. Por ejemplo, si tenemos
Lmax = 128 y N = 1.000, en la última iteración sólo se procesarán 104
elementos (7 × 128 + 104 = 1.000).
Todo ello va a repercutir en el tiempo de ejecución de la operación
vectorial (en la velocidad de cálculo, por tanto). Si el tiempo de una
operación vectorial puede darse como TV = ti + tv N, el hecho de que los
registros sean de tamaño Lmax hará que el tiempo de ejecución se exprese
como:
Nttt
L
NT vbuciV ++
= )(
max
N/Lmax indica el número de trozos a procesar (iteraciones del bucle), y tbuc
el tiempo necesario para el control del bucle (ahora el sumando inicial
también depende de N).
El proceso de strip mining es prácticamente inevitable cuando hay que
procesar vectores, ya que, en la mayoría de los casos, la longitud de los
vectores es un parámetro que se decide en ejecución. Por tanto, incluso en el
caso de que cupiera el vector en el registro, habrá que “pagar” una vez el
coste del control del bucle, tbuc.
Veamos un ejemplo:
N = 500 TV = 30 + 3N → TV = 30 + 1.500 = 1.530 ciclos (idea)
3,06 ciclos/elemento
pero
<
Lmax = 64 tbuc = 10 ciclos → TV = 8 × (30+10) + 1.500 = 1.820 ciclos
3,64 ciclos/elemento (+ 19%)
1.4 VELOCIDAD DE CÁLCULO DE LOS COMPUTA-
DORES VECTORIALES
Cuando hemos definido los computadores vectoriales hemos indicado el
deseo de construir una máquina de gran velocidad en la ejecución de
determinados programas. Analicemos un poco más despacio los parámetros
básicos que definen la velocidad de cálculo en estas máquinas.
1.4 VELOCIDAD DE CÁLCULO DE LOS COMPUTA-DORES VECTORIALES ▪ 31 ▪
1.4.1 Velocidad de cálculo en función de la longitud
de los vectores
1.4.1.1 R∞ y N1/2
Recordemos cómo se puede expresar el tiempo de ejecución de un
programa en modo vectorial y en modo escalar:
▪ en modo escalar
TE = te N te = tiempo para ejecutar una iteración
▪ en modo vectorial7
TV = ti + tv N ti = tiempo de inicio
tv = tiempo para procesar un elemento del vector
Los tiempos de ejecución se suelen dar en ciclos o en (nano)segundos
(basta multiplicar por el periodo del reloj). En la figura se representa el
tiempo de ejecución vectorial en función de la longitud de los vectores, una
recta.
A partir de ahí, definimos la velocidad de cálculo o rendimiento
(performance) como el número de elementos que se procesa por unidad de
tiempo (ciclo o segundo):
Ntt
N
T
NR
viV
N +
==
La velocidad de cálculo se suele dar en Mflop/s (Mega FLoat OPeration /
second), es decir, cuántas operaciones de coma flotante se realizan por
segundo, para lo que tenemos que considerar el número de operaciones que
7 Sin considerar el tamaño de los registros vectoriales.
0
5
100
150
200
250
300
0 25 50 75 100 125 150
N (longitud de los vectores)
ti
pendiente = tb
TV = 30 + 2N
N1/2
2ti
TV
▪ 32 ▪ Capítulo 1: COMPUTADORES VECTORIALES
se realizan con los vectores, OpCF8, ya que en total se ejecutarán N × OpCF
operaciones.
El tiempo de ejecución debe estar en segundos; si está en ciclos (como en
los ejemplos vistos hasta ahora) hay que multiplicarlo por el periodo de reloj,
lo que, dado que el tiempo está en el divisor, equivale a multiplicar la
expresión anterior por la frecuencia de reloj (T = 1/F). Por tanto:
FOpCF
Ntt
N
T
NR
viV
N ××+
== Mflop/s (TV en ciclos, F en MHz)
Analicemos gráficamente el comportamiento de la expresión anterior.
Tal como se observa en la figura, la función R tiene una asíntota cuando N
tiende a ∞. Aunque los vectores fueran muy largos, la velocidad de cálculo
tiene un límite. A ese valor máximo se le denomina R∞.
FOpCF
t
RR
v
NN ××== ∞→∞
1lim
También es habitual indicar la eficiencia del sistema, es decir, la fracción
de la velocidad máxima que se consigue:
Eficiencia = R / R∞ en el intervalo [0, 1]
Por definición, ninguna máquina puede alcanzar la velocidad máxima R∞.
Por ello, se suele utilizar otro parámetro más: N1/2, tamaño mínimo de los
vectores que permite alcanzar al menos la mitad de la velocidad máxima. De
acuerdo a la definición, R(N1/2) = R∞/2; por tanto:
N1/2 / (ti + tv × N1/2) = 1/tv × 1/2 → N1/2 = ti / tv (entero superior)
8 En lo que a la velocidad de cálculo respecta, no es lo mismo efectuar una suma con los vectores que
efectuar dos sumas y una multiplicación.
N (número de elementos)
R∞
R
(
re
nd
im
ie
nt
o)
R∞/2
N1/2
1.4 VELOCIDAD DE CÁLCULO DE LOS COMPUTA-DORES VECTORIALES ▪ 33 ▪
¡Si N1/2 es muy alto, lo más probable es que andemos lejos del valor
máximo de velocidad; en cambio, si es pequeño, no necesitaremos procesar
vectores muy largos para acercarnos a la velocidad máxima de cálculo. De la
misma manera que hemos definido N1/2, podemos definir N3/4, N1/4, etc., es
decir, el tamaño mínimo de los vectores para conseguir una determinada
fracción de la velocidad máxima9.
Los dos parámetros que definen la velocidad de cálculo (performance) pueden
obtenerse mediante un experimento muy simple. Se ejecuta el programa vectorial
para diferentes valores de N, se mide el tiempo de ejecución, y se dibuja la función
TV(N), que deberá ser una recta, ya que el tiempo crece linealmente con N. La
ordenada en el origen de esa recta nos indica el valor de ti y la pendiente de la recta
el valor de tv. Así pues, el valor de R∞ será el inverso de la pendiente de la recta. Para
calcular N1/2 basta con medir el valor de N que hace TV = 2 ti (cuando N = N1/2 = ti / tv,
TV = ti + (ti /tv) tv = 2 ti).
El parámetro R∞ es función del programaque se ejecuta. En todo caso, es
sencillo obtener la velocidad teórica máxima (peak performance) que podría
conseguir un computador vectorial. El máximo lo obtendríamos si tv = 1
ciclo y se utilizaran simultáneamente todas las unidades funcionales (OpCF
= #UF). Por ejemplo, un computador vectorial que dispone de 6 unidades
funcionales y cuyo reloj es de F = 500 MHz, podría lograr 6 × 500 = 3.000
Mflop/s = 3 Gflop/s. Sin embargo, ese valor no es representativo de un caso
real, ya que sólo se puede conseguir en casos muy excepcionales. Lo más
habitual es que tv > 1 y que no se estén utilizando todas las unidades
funcionales simultáneamente.
1.4.1.2 Speed-up o factor de aceleración
Para representar la velocidad de cálculo de un computador vectorial
podemos efectuar esta otra comparación: ¿cuántas veces es más rápida la
ejecución del programa en modo vectorial que en modo escalar?
Ntt
Nt
T
TK
vi
e
V
E
V +
==
v
e
t
tK =∞
El comportamiento de la función KV es similar al de R, y obtiene un
máximo cuando N tiende a infinito. El parámetro K∞ indica cuántas veces es
9 Si se prefiere, el tiempo de ejecución y la velocidad de cálculo pueden darse en función de los dos
parámetros que acabamos de definir, N1/2 y R∞:
TV = (N + N1/2) / R∞ RV = R∞ × (1 / (1 + N1/2/N))
▪ 34 ▪ Capítulo 1: COMPUTADORES VECTORIALES
más rápido el proceso de un elemento del vector en modo vectorial que en
modo escalar, siendo los vectores que se procesan muy largos. Inicialmente,
nos interesa que el parámetro K∞ sea grande, porque indica que el procesador
vectorial es muy rápido. Sin embargo, como vamos a ver enseguida, la
situación no es tan clara como parece, ya que habrá que ejecutar también
código escalar junto con el vectorial.
1.4.1.3 NV
Los dos parámetros de “calidad” más utilizados son R∞ y N1/2, aunque
pueden plantearse otros. Por ejemplo, ¿se obtiene siempre un tiempo de
ejecución menor en modo vectorial que en modo escalar? Podemos calcular
el parámetro Nv, longitud de los vectores que hace que TE = TV.
1
2/1
−
=
−
=→+=
∞K
N
tt
tNNttNt
ve
i
vvvive
Por tanto, si los vectores a procesar son más cortos que Nv (función de N1/2
y K∞), entonces no merece la pena ejecutar en modo vectorial, ya que la
ejecución en modo escalar será más rápida.
1.4.2 Influencia del código escalar. Ley de Amdahl.
Los programas adecuados para ejecutar en un procesador vectorial son los
que procesan vectores. Sin embargo, en un programa general habrá que
procesar también, junto al código vectorial, código escalar (no son habituales
los programas que se pueden expresar en un 100% en forma de código
vectorial). Por tanto, para medir correctamente la velocidad de ejecución, es
necesario contar con el tiempo de ejecución del código escalar.
Sea f la fracción de código que puede ser ejecutada vectorialmente y 1 – f
la parte que hay que ejecutar en modo escalar. El tiempo de ejecución del
programa se puede expresar como:
TVE = f TV + (1 – f) TE
Así pues, comparado con el procesador escalar, el factor de aceleración
logrado será:
KKf
K
Tf
K
Tf
T
TffT
T
T
TK
E
E
E
EV
E
VE
E
VE +−
=
−+
=
−+
==
)1()1()1(
1.4 VELOCIDAD DE CÁLCULO DE LOS COMPUTA-DORES VECTORIALES ▪ 35 ▪
La expresión anterior se conoce como ley de Amdahl. Cuando f = 0 (todo
el código es escalar), el factor de aceleración es 1; y cuando f = 1 (todo el
código es código vectorial), el factor de aceleración es KV, tal como hemos
definido anteriormente. Analicemos gráficamente el comportamiento del
factor de aceleración en función de f.
Tal como aparece en la gráfica, para poder obtener factores de aceleración
significativos es necesario que el factor de vectorización sea alto. Por
ejemplo, para KV = 16, si queremos que la ejecución vectorial sea 8 veces
más rápida, se necesita que f > 93%. Analizado desde otro punto de vista, si,
por ejemplo, f = 0,65, el factor de aceleración no será nunca mayor que 3,
aunque KV sea infinito; para KV = ∞, el factor de aceleración es 1 / (1–f).
La vectorización del código de un determinado programa es tarea del
compilador (con la colaboración, tal vez, del programador). En la gráfica
anterior hemos marcado en el eje X los valores de f que suelen lograr los
compiladores vectoriales, normalmente en el intervalo [0,55 - 0,75]. Queda
claro que con esos factores de vectorización no es posible lograr altos
valores de speed-up, aunque KV sea muy grande. Por ello, la eficiencia del
proceso de compilación vectorial es crucial para poder obtener el máximo
rendimiento de un computador vectorial.
Tal como hemos calculado antes el parámetro N1/2, en este caso se puede
obtener un parámetro similar, f1/2, factor de vectorización necesario para
obtener al menos la mitad de la velocidad máxima (KV /2).
1
11
)1(2 2/12/1 −
−=→
+−
=
VVV
VV
K
f
KKf
KK
KV = ∞
16
8
4
2
▪ 36 ▪ Capítulo 1: COMPUTADORES VECTORIALES
En general, por tanto, si se desea calcular los Mflop/s que realmente se
conseguirán con un determinado programa, hay que considerar tanto el
código escalar como el vectorial:
NtKfNttf
N
NtfNttf
N
TffT
N
T
NR
vvieviEVVE
VE
∞−++
=
−++
=
−+
==
)1()()1()()1(
Como siempre, para ponerlo en Mflop/s hay que multiplicar por el número
de operaciones en coma flotante realizadas, y por la frecuencia de reloj (si el
tiempo estaba en ciclos).
En algunos textos, la expresión anterior suele darse de la siguiente manera:
RN,f = R∞ × εN × εf donde εN = 1 / [1 + N1/2/N] y εf = 1 / [f + (1–f) K∞ × εN]
es decir, hay un rendimiento máximo —R∞,1— cuando la longitud de los vectores es infinita y
el factor de vectorización es 1; y luego existen dos limitaciones, una debida a la longitud finita
de los vectores, N, y otra debida a que el factor de vectorización es f y no 1.
Pongamos un ejemplo. Un computador vectorial tiene los siguientes parámetros: R∞ = 800
Mflop/s, N1/2 = 60, K∞ = 10, f = 0,8 y N = 128.
Si fueran N infinito y f = 1, se obtendrían 800 Mflop/s. Como N = 128, el primer límite es εN =
0,68. Además, como f = 0,8 (y N = 128) tenemos un segundo límite, εf = 0,46. En
consecuencia, la velocidad de cálculo que se consiga será: 800 × 0,68 × 0,46 = 252 Mflop/s.
Conviene enfatizar nuevamente la influencia del código escalar en el
rendimiento total del sistema. La siguiente figura presenta la comparación de
dos mejoras efectuadas en un computador vectorial.
El comportamiento en los dos extremos es claro. Cuando los programas se
vectorizan por completo (f = 1), el factor de aceleración es mejor en la
0
2
4
6
8
10
12
14
16
0 0.2 0.4 0.6 0.8 1
Fa
ct
or
d
e
ac
el
er
ac
ió
n
(n
or
m
al
iz
ad
o)
f (factor de vectorización)
Ley de Amdahl
CRAY X-MP
tv = 10 ns
te = 66,6ns
tv = 5 ns
te = 66,6 ns
tv = 10 ns
te = 33,3 ns
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 37 ▪
máquina en la que se ha duplicado KV (la nueva máquina es dos veces más
rápida). En cambio, si el programa no se puede vectorizar (f = 0, todo código
escalar), entonces los resultados son mejores en el computador que ha
mejorado el procesador escalar.
¿Y en un caso general? La respuesta depende de f. Pero atención, si f se
mantiene en el intervalo [0,6 – 0,8], en ese caso no interesa que KV sea muy
alto, y resulta más eficaz mejorar la respuesta del procesador escalar. Por
tanto, salvo que sepamos que nuestros programas se vectorizan siempre en
un factor muy elevado, no resulta interesante que el computador vectorial
sea de KV muy elevado, puesto que no vamos a poder aprovechar sus
características específicas.
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR
CÓDIGO VECTORIAL
Los procesadores vectoriales ejecutan código vectorial, pero, en general,
sólo una parte de los programas puedeejecutarse de esa manera. ¿A quién
corresponde detectar qué partes del código son vectorizables y escribir el
correspondiente código, al programador o al compilador? Lo más adecuado
es que el trabajo del programador sea independiente de la máquina; se
programan algoritmos en alto nivel, y el correspondiente compilador
traducirá esos programas al código más adecuado para la máquina en que se
vayan a ejecutar, teniendo en cuenta las características de la misma.
Afortunadamente, existen buenos compiladores vectoriales que generan
código vectorial de manera eficiente, para lo que previamente analizan las
dependencias entre instrucciones y deciden qué partes del código pueden
ejecutarse vectorialmente. En todo caso, siempre es importante la ayuda de
un programador “inteligente”, ya que a veces no es sencillo traducir
automáticamente de alto nivel a código vectorial. Por ello, algunos lenguajes
(Fortran, por ejemplo) tienen directivas especiales para indicar operaciones
vectoriales y ayudar al compilador.
Como hemos comprobado, es esencial conseguir factores de vectorización
altos. En caso contrario, la velocidad de procesamiento se alejará mucho de
los máximos teóricos. En los siguientes apartados vamos a analizar las
estrategias principales que sigue un compilador vectorial para generar código
vectorial. Utilizaremos las mismas o parecidas estrategias un poco más
adelante, cuando tengamos que ejecutar un bucle entre P procesadores.
▪ 38 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.5.1 Dependencias de datos entre instrucciones
Como ya hemos comentado, vectorizar implica, entre otras cosas, una
determinada reordenación del código original. Sin embargo, las
instrucciones no pueden reordenarse de cualquier manera, puesto que hay
que respetar las dependencias de datos. Recordemos brevemente los tres
tipos de dependencias de datos.
• Dependencias verdaderas (RAW read-after-write, RD)
1: A = B + C
2: D = A
Existe una dependencia entre las instrucciones 1 y 2, porque el
resultado de la instrucción 1 se utiliza en la 2. Representamos las
dependencias en un grafo, el grafo de dependencias, mediante una
flecha que va de 1 a 2, indicando qué se debe hacer antes y qué
después. En este ejemplo, la instrucción 1 debe escribir antes que la
instrucción 2 lea el operando.
Las dependencias RAW no pueden evitarse, puesto que son
intrínsecas al algoritmo que se quiere ejecutar. En algunos casos,
pueden resolverse mediante cortocircuitos; en caso contrario, habrá
que esperar a que se ejecute la operación anterior.
• Antidependencias (WAR write-after-read, DR)
1: A = B + C
2: B = D
Existe una antidependencia entre las instrucciones 1 y 2, puesto que
un operando que necesita la instrucción 1 –B– es modificado por la 2.
En el grafo de dependencias las antidependencias se indican mediante
una flecha cruzada por una raya. En este ejemplo, la flecha indica que
se debe leer B en la instrucción 1 antes que escribir B en la 2.
Las antidependencias no son dependencias “fuertes”, y en muchos
casos desaparecen con una correcta reordenación del código.
1
2
A
1
2
B
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 39 ▪
• Dependencias de salida (WAW write-after-write, RR)
1: A = B + C
2: A = D
Existe una dependencia de salida entre las instrucciones 1 y 2, ya que
la instrucción 2 va a escribir en la misma variable que la 1. La
representamos en el grafo mediante una flecha con un pequeño
círculo, que indica que hay que respetar el orden de las escrituras.
Como en el caso anterior, las dependencias de salida no son “fuertes”
y suelen estar asociadas a la manera de escribir el programa; por tanto,
pueden desaparecer con una correcta ordenación del código vectorial.
Recuerda: sea cual sea el tipo, las dependencias implican un orden
determinado de ciertas operaciones: qué hay que hacer antes y qué después.
En los párrafos anteriores hemos visto las dependencias entre
instrucciones individuales. Pero el código vectorial reemplaza un bucle
completo de instrucciones, por lo que al analizar las dependencias entre
instrucciones hay que tener en cuenta que se pueden producir entre
instrucciones de cualquier iteración. Definimos “distancia” de una
dependencia como el número de iteraciones que hay entre las instrucciones
que tienen dicha dependencia, y la indicaremos en el propio grafo de
dependencias. Por ejemplo, si la dependencia se produce en la misma
iteración, la distancia es 0; si es en la siguiente, la distancia es 1, etc. En los
bucles de más de una dimensión, la distancia se representa como un vector
de distancias, con un elemento por cada dimensión del bucle. Por ejemplo:
do i = 2, N-2
1: A(i) = B(i) + 2
2: C(i) = A(i-2) + A(i+1)
enddo
do i = 2, N-1
do j = 1, N-2
1: A(i,j) = A(i,j-1) * 2
2: C(i,j) = A(i-2,j+1) + 1
enddo
enddo
grafo de dependencias espacio de iteraciones
1
2
A
A, 2
A, 1
i
i=0 i=1 i=2 …
1
2
A, 2 A, 1
1
2
A, (2, –1)
A, (0, 1)
j
i A, (2, –1)
A, (0, 1)
▪ 40 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Cuando la dependencia se produce entre iteraciones diferentes (d > 0), se
dice que es loop carried.
Para poder vectorizar un bucle, el primer paso consiste en efectuar el
análisis de dependencias (no olvides que vectorizar implica desordenar el
código), y generar el grafo de dependencias. En este grafo se representan
las dependencias entre instrucciones —de la instrucción i a la j—, para todas
las iteraciones del bucle. En los casos de más de una dimensión, también es
útil dibujar un segundo grafo, el espacio de iteraciones (como en la figura
anterior), en el que las dependencias no se marcan entre instrucciones, sino
entre iteraciones. En los siguientes ejemplos utilizaremos ambos grafos.
1.5.2 Vectorización
1.5.2.1 Vectores de una dimensión
Antes de formalizar las técnicas de vectorización, veamos algunos
ejemplos10.
1.5.2.1.1 Primer ejemplo
do i = 0, N-1
A(i) = B(i) + C(i)
enddo
No existe ningún tipo de dependencia entre las instrucciones del bucle,
por lo que puede escribirse en forma vectorial, sin problemas:
MOVI VL,#N ; longitud de los vectores
MOVI VS,#1 ; paso de los vectores (stride)
LV V1,B(R1)
LV V2,C(R1)
ADDV V3,V1,V2
SV A(R1),V3
En algunos lenguajes, el código anterior se expresa así: A(0:N:1) =
B(0:N:1) + C(0:N:1), donde A(x:y:z) indica: x, comienzo del vector;
y, número de elementos; y z, paso del vector.
10 Salvo que se indique lo contrario, los vectores son de tamaño N (o N×N); la dirección A indica el
primer elemento del vector, A0; A+1 indica el siguiente elemento, etc. (sin considerar el tamaño de
los elementos y la unidad de direccionamiento de la memoria). Vectores de nombre diferente utilizan
posiciones de memoria diferentes, es decir, no se solapan (no hay aliasing). El contenido inicial del
registro utilizado para direccionar es siempre 0 (en el ejemplo, R1).
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 41 ▪
El código escalar original y el vectorial que acabamos de escribir no son
exactamente equivalentes. El bucle escalar utiliza la variable i para
controlar el número de iteraciones e indicar los elementos del vector, por lo
que al acabar el bucle i contendrá el valor correspondiente a la última
iteración, y, aunque no es habitual, tal vez se utilice dicha variable más
adelante en el programa. El compilador vectorial tiene que generar código
equivalente al original; por ello, aunque no se necesita para nada en las
instrucciones vectoriales, debe dejar en i el valor final correspondiente, en
este caso N–1. Lo mismo habrá que hacer con el resto de variables similares.
Por claridad, vamos a omitir el código correspondiente a esas operaciones.
1.5.2.1.2 Segundo ejemplo
do i = 0, N-1
1: A(i) = B(i)+ C(i)
2: D(i) = A(i)
enddo
grafo de dependencias espacio de iteraciones
Hemos dibujado ambos grafos: el de dependencias y el del espacio de
iteraciones. Existe una dependencia en el bucle, de la primera instrucción a
la segunda, y la dependencia se produce en la misma iteración, como se
observa en el espacio de iteraciones.
En algunos textos, la dependencia anterior se indica de la siguiente manera: 1 δ= 2;
el símbolo = indica que la dependencia es de distancia 0 (si la distancia es mayor
que 0, se utiliza el símbolo <).
La dependencia no implica ningún problema, y el código se puede
vectorizar de la siguiente manera:
MOVI VL,#N
MOVI VS,#1
(1) LV V1,B(R1)
LV V2,C(R1)
ADDV V3,V1,V2
SV A(R1),V3 ; A = B + C
(2) SV D(R1),V3 ; no hay que leer A, ya que está en V3
1
2
A, 0
A, 0
i
▪ 42 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.5.2.1.3 Tercer ejemplo
do i = 1, N-1
1: A(i) = B(i) + C(i)
2: D(i) = A(i-1)
enddo
1 δ< 2
Aunque el grafo de dependencias es similar al del ejemplo anterior, ahora
las dependencias van de iteración a iteración: hay que utilizar en la segunda
instrucción de la iteración i el resultado de la primera instrucción de la
iteración i–1. Aunque se observa una cadena de dependencias en el espacio
de iteraciones, las dependencias son entre instrucciones diferentes: 1i → 2i+1.
Nuevamente, el código puede vectorizarse sin problemas:
MOVI VL,#N-1
MOVI VS,#1
(1) LV V1,B+1(R1)
LV V2,C+1(R1)
ADDV V3,V1,V2
SV A+1(R1),V3 ; se escibe el vector A1–AN-1
(2) LV V4,A(R1) ; se lee de memoria el vector A0–AN-2
SV D+1(R1),V4
Claramente, en esta ocasión no se puede aprovechar en la segunda
instrucción el resultado de la primera, ya que no se trata del mismo vector
(A0-AN-2); por tanto, primero hay que escribir en memoria el vector A1-AN-1 y
luego leer A0-AN-2.
1.5.2.1.4 Cuarto ejemplo
do i = 0, N-2
1: A(i) = B(i) + C(i)
2: D(i) = A(i+1)
enddo
En este bucle existe una antidependencia, de la segunda instrucción a la
primera. El bucle no puede vectorizarse en el orden original, puesto que no
se puede escribir el vector A(i) (todos los elementos) en la primera
instrucción antes que leer el vector A(i+1) en la segunda.
1
2
A, 1
A, 1
i
1
2
A, 1
A, 1
i
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 43 ▪
Formalizaremos este caso un poco más adelante; basta ahora decir que el
problema se arregla con un cambio de orden, tal como el siguiente:
MOVI VL,#N-1
MOVI VS,#1
(2) LV V1,A+1(R1) ; adelantar la lectura de la instrucción 2
SV D(R1),V1
(1) LV V2,B(R1)
LV V3,C(R1)
ADDV V4,V2,V3
SV A(R1),V4 ; escribir el resultado de la instrucción 1
Como puede observarse, el código vectorial respeta la antidependencia
original.
1.5.2.1.5 Quinto ejemplo
do i = 1, N-1
1: A(i) = B(i-1) + 1
2: B(i) = A(i)
enddo
En este bucle aparece el problema más grave de vectorización. Las
dependencias forman un ciclo en el grafo: la instrucción 2 necesita los datos
producidos por la 1 (vector A), y la primera instrucción necesita los datos
producidos por la segunda (casi todo el vector B). No hay nada que hacer;
hay que ejecutar el bucle en modo escalar.
El ejemplo más típico de un ciclo de dependencias es una recurrencia: un
ciclo de una única instrucción. Por ejemplo:
do i = 3, N-1
1: A(i) = A(i-3) * 3
enddo
En cada iteración, se necesita como operando el resultado producido tres
iteraciones antes. Está claro que una recurrencia no puede vectorizarse:
¿cómo leer con una instrucción —LV V1,A(R1)— todo un vector, si
todavía no se han generado los elementos del vector?
1
2
A, 0 B, 1
A, 0
i
B, 1
1 A, 3
▪ 44 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.5.2.2 Vectores de N dimensiones
Los vectores de los ejemplos anteriores son de una dimensión. Pero,
¿cómo se vectoriza, por ejemplo, una operación con matrices?
do i = 0, N-1 (todos los vectores son de tamaño [N, M])
do j = 0, M-1
A(i,j) = B(i,j) + C(i,j)
enddo
enddo
Cuando se trabaja con matrices, se suelen utilizar habitualmente dos tipos
de vectores: filas y columnas. El propio bucle indicará cómo hay que
procesar la matriz, por filas o por columnas, pero, en muchos casos, ambas
posibilidades son correctas (como en el caso anterior, en el que da igual
ejecutar el bucle en el orden do i / do j que en el orden do j / do i).
Para generar el grafo de dependencias, el compilador analizará el bucle
más interior, y en base a ello decidirá qué hacer. En todo caso, para procesar
vectores de dos dimensiones es necesario montar un bucle escalar, que
procese las filas, o las columnas, una a una.
Por ejemplo, en el bucle anterior no hay ninguna dependencia. Por tanto,
tenemos dos posibilidades: vectorizar el bucle interior —j, procesar la
matriz por filas—, o el exterior —i, por columnas—.
Si vectorizamos por filas, el bucle quedaría así:
MOVI R2,#N ; número de filas
MOVI VL,#M ; longitud de las filas
MOVI VS,#1 ; paso
buc: LV V1,B(R1)
LV V2,C(R1)
ADDV V3,V1,V2
SV A(R1),V3
ADDI R1,R1,#M ; siguiente fila
SUBI R2,R2,#1 ; una fila menos
BNZ R2,buc
Después de procesar un fila vectorialmente, se actualiza el registro R1
(+M), para direccionar la fila siguiente. El registro R2 es un simple contador,
para procesar todas las filas.
j
A
s = 1
i
0,0 0,1 … 0,M-1
1,0 1,1 … 1,M-1
… … … …
N-1,0 N-1,1 … N-1,M-1
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 45 ▪
Si se quiere vectorizar la matriz por columnas, el código sería el
siguiente:
MOVI R2,#M ; número de columnas
MOVI VL,#N ; longitud de las columnas
MOVI VS,#M ; paso
buc: LV V1,B(R1)
LV V2,C(R1)
ADDV V3,V1,V2
SV A(R1),V3
ADDI R1,R1,#1 ; siguiente columna
SUBI R2,R2,#1 ; una columna menos
BNZ R2,buc
En este caso, el paso de los vectores (columnas) es M, y para apuntar a la
siguiente columna basta con incrementar (+1) la dirección de comienzo11.
Cuando se utiliza esta segunda opción se dice que se ha efectuado un
intercambio de bucles.
En general, para vectorizar bucles de P dimensiones hay que analizar P
alternativas (una por cada dimensión), para escoger la más adecuada en
función de las dependencias de datos entre las instrucciones.
1.5.2.3 Condición para vectorizar un bucle
Resumamos lo visto en los ejemplos anteriores. El compilador debe
analizar las dependencias entre las instrucciones y generar un grafo de
dependencias. Basándose en ello, debe decidir si el bucle es vectorizable o
no y cómo. A menudo, para generar código vectorial es necesario reordenar
el código original. Al hacerlo, claro está, el compilador debe respetar el
orden de ejecución que imponen las diferentes dependencias de datos: la
dependencia x → y indica que alguna operación de la instrucción x debe ir
antes que alguna de la y.
Por desgracia, no todos los bucles son vectorizables. ¿Cómo saber cuándo
sí y cuándo no? En general, un bucle puede vectorizarse si las
dependencias entre instrucciones no forman ciclos en el grafo de
dependencias.
11 En estos ejemplos hemos supuesto que las matrices están almacenada en memoria por filas, tal como,
por ejemplo, se hace en C; en Fortran, en cambio, las matrices se guardan por columnas.
j A
s = M
i
0,0 0,1 … 0,M-1
1,0 1,1 … 1,M-1
… … … …
N-1,0 N-1,1 … N-1,M-1
▪ 46 ▪ Capítulo 1: COMPUTADORES VECTORIALES
En esos casos, el compilador generará código vectorial para el bucle,
manteniendo en algunos casos el orden original de las instrucciones, y
cambiándolo en otros para respetar las dependencias. La condición anterior
no implica que no se pueda vectorizar el bucle cuando existan ciclos de
dependencias, puesto que, como vamos a ver, pueden aplicarse ciertas
técnicas que “deshacen” dichos ciclos.
Aunque las dependencias formen ciclos en el grafode dependencias,
normalmente sólo algunas instrucciones del bucle tomarán parte en dichos
ciclos. Por ello, aunque no se puedan vectorizar todas la instrucciones del
bucle, el compilador debe intentar vectorizar el mayor número posible de
operaciones; las instrucciones que presenten problemas se ejecutarán
escalarmente, y el resto vectorialmente (loop fission). Por ejemplo:
do i = 1, N-1
A(i) = B(i)
B(i) = B(i-1)
enddo
(puede vectorizarse la primera instrucción,
pero no la segunda, debido a la dependencia, una
recurrencia)
MOVI VL,#N-1
MOVI VS,#1
LV V1,B+1(R1)
SV A+1(R1),V1
MOVI R3,#N-1
buc: FLD F1,B(R2)
FST B+1(R2),F1
ADDI R2,R2,#1
SUBI R3,R3,#1
BNZ R3,buc
1.5.2.4 Test de dependencias
Como hemos comentado, el primer paso del proceso de vectorización es el
análisis de las dependencias. ¿Es sencillo saber si existe una dependencia
entre dos instrucciones dadas? En los ejemplos anteriores era muy simple,
porque los índices utilizados para el acceso a los vectores eran funciones
muy sencillas (i, i+1...). Sin embargo, ¿qué podemos decir en este ejemplo?
do i = L1, L2
X(f(i)) = X(g(i)) + 1
enddo
¿Hay una dependencia en el vector X (sería una recurrencia)? Claro está,
la respuesta depende de las funciones f y g. Por desgracia, el resultado de
las funciones f y g no se puede predecir, en el caso general, en tiempo de
1
2
B, 1
B, 0
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 47 ▪
compilación, por lo que el compilador no tiene información suficiente para
tomar una decisión, por lo que debe suponer que existe la dependencia.
Sin embargo, en algunos casos, que son muy comunes, el compilador
puede analizar y decidir si existe o no una dependencia: en el caso en que f
y g sean funciones lineales de los índices del bucle. Por ejemplo:
do i = L1, L2
X(a*i+b) = ...
... = X(c*i+d)
enddo
Por otro lado, ése es el único caso que se corresponde con la definición
que hemos dado de vector: la distancia entre dos elementos consecutivos es
constante. Más adelante veremos cómo procesar vectores cuyo paso no sea
constante (por ejemplo, A(i2)→ A1, A 4, A 9, A16...).
Para saber si existe una dependencia en el vector X hay que resolver la
siguiente ecuación:
a i1 + b = c i2 + d L1 ≤ i1, i2 ∈ Z ≤ L2
es decir, hay que saber si existen dos valores i1 e i2, dentro de los límites de
iteración del bucle, para los que coincidan las direcciones de acceso al
vector.
La expresión anterior es una ecuación diofántica, y encontrar una solución
general a la misma es muy complejo. Sin embargo, puede afirmarse que:
▪ No existe dependencia (la ecuación anterior no tiene solución),
si (d – b) / MCD(a, c) ∉ Z, es decir, si no es un entero.
Este test se conoce como el test del máximo común divisor (MCD). No es
el único test que aplican los compiladores para analizar las dependencias,
pero es suficiente para los casos más habituales.
a i + b
c i + d
i1 i2 L1 L2
▪ 48 ▪ Capítulo 1: COMPUTADORES VECTORIALES
El test del MCD indica cuándo no hay dependencia, no cuándo la hay.
Esto es, si el resultado es un número entero, las ecuaciones tienen solución,
pero para saber si existe, o no, dependencia habrá que analizar las soluciones
y comprobar que se encuentran dentro de los límites del bucle. Para ello, a
menudo es suficiente con analizar los trozos del vector que accede cada
instrucción: si no se solapan, entonces no hay dependencia; pero si se
solapan, entonces sí que puede haberla y habrá que hacer un análisis más
detallado12. Se pueden diferenciar tres casos:
(1) (2) (3)
En el primer caso, no hay dependencia; es decir, la hipotética solución de
la ecuación está fuera de los límites del bucle (dentro de los límites, las dos
ecuaciones no proporcionan nunca el mismo valor). En el segundo caso,
puede haber dependencia, ya que las dos ecuaciones tienen un trozo de
vector común al que acceden (el tipo de dependencia variará en función del
tipo de operación de cada acceso). El tercer caso es el más complejo. Puede
existir dependencia entre las dos instrucciones; además, si en una se lee y en
la otra se escribe, en un tramo del bucle tendremos antidependencias y en el
otro, dependencias (si las dos operaciones fueran escrituras tendríamos un
problema similar). Por tanto, estas instrucciones no se pueden vectorizar,
algunos elementos hay que leer antes de escribir sobre ellos y otros después
de que se hayan escrito (quizás se pueda dividir el bucle en dos partes, en
función del punto de cruce, y utilizar técnicas distintas en cada parte para
generar el código).
Veamos algunos ejemplos:
(1) do i = 1, 100
A(2*i) = ... → (1 – 0) / MCD(2, 2) = 1/2
... = A(2*i+1)
enddo
Por tanto, no hay dependencias entre las dos instrucciones; en este caso,
una instrucción escribe elementos pares y la otra lee elementos impares.
12 Habrá que tener en cuenta los pasos de los vectores (a y c) y la longitud del segmento que se solapa,
para comprobar si ambos accesos coinciden en, al menos, un elemento del vector.
i L1 L2
i L1 L2
i
L1 L2
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 49 ▪
(2) do i = 5, 100
A(i-5) = ...
... = A(2*i+90) → (90 – (–5)) / MCD(2, 1) = 95
enddo
Por tanto, puede haber una dependencia. Pero no la hay, porque los
intervalos de acceso son disjuntos:
wr: A0 ... ... A95
rd: A100 ... ... A290
(3) do i = 1, 100
A(3*i+100) = ... → (100 – (–1)) / MCD(3, 2) = 101
... = A(2*i-1)
enddo
Podría haber una dependencia; los intervalos de acceso son los siguientes:
wr: A103 ... ... A400
rd: A1 ... ... A199
Los dos intervalos tienen un trozo en común, por lo que puede haber una
dependencia; y en este caso la hay: por ejemplo, la escritura de la
iteración i = 1 en (A103) se lee en la iteración i = 52.
(4) do i = 1, 100
A(6*i+3) = ... → (81 – 3) / MCD(6, 3) = 26
... = A(3*i+81)
enddo
Por tanto, puede haber una dependencia. Los intervalos de acceso son los
siguientes:
wr: A9 ... ... A603
rd: A84 ... ... A381
Un intervalo está dentro del otro; si existe dependencia, seguramente será
de dos tipos. Por ejemplo, en la iteración i = 2 se lee el elemento A87, que
luego se va a escribir (una antidependencia); pero, en la iteración i = 28,
se lee el elemento A165, que es el resultado de la escritura de la iteración i
= 27 (una dependencia verdadera).
Atención. El paso de los accesos a memoria se puede indicar en dos
sitios: en la definición de los límites del bucle y en las propias instrucciones.
Antes de aplicar el test MCD, es necesario normalizar el bucle, efectuando
un cambio de variable que haga que el paso del bucle sea 1. Por ejemplo:
do i = 1, 100, 2 do k = 1, 50, 1
A(i) = ... A(2*k-1) = ...
B(2*i+5) = ... B(4*k+3) = ...
enddo enddo
▪ 50 ▪ Capítulo 1: COMPUTADORES VECTORIALES
En resumen: el compilador vectorial analiza las dependencias entre
instrucciones del bucle y genera el correspondiente grafo. Si no existen
ciclos en dicho grafo, no habrá problemas para vectorizar el bucle; en caso
contrario, se intentará aplicar algunas técnicas sencillas que permiten reducir
o anular el impacto negativo de las dependencias y/o vectorizar parcialmente
el bucle. Analicemos, por tanto, las principales técnicas de optimización.
1.5.3 Optimizaciones
El proceso de compilación es esencial en la obtención de altas velocidades
de cálculo en un computador vectorial. No hay que olvidar que de no obtener
un factor de vectorización elevado el rendimiento de la máquina será
bastante bajo (ley de Amdahl). Acabamos de ver cuál es la condición que
hay que cumplir para poder vectorizar un bucle: que no haya ciclos de
dependencias. En todo caso, algunas de las dependencias que aparecen en los
bucles no son intrínsecas a la operación que serealiza, sino que están
relacionadas con la manera en que se indica dicha operación (por ejemplo,
las antidependencias o las dependencias de salida). En esos casos, es posible
efectuar pequeñas transformaciones del código original que facilitan la
vectorización final. Vamos a ver dos tipos de optimizaciones: las que ayudan
a que desaparezcan las dependencias, y las que ayudan a obtener una mayor
velocidad de cálculo.
1.5.3.1 Sustitución global hacia adelante (global forward substitution)
Analicemos este bucle:
NP1 = L + 1
NP2 = L + 2
...
do i = 1, L
1: B(i) = A(NP1) + C(i)
2: A(i) = A(i) - 1
do j = 1, L
3: D(j,NP1) = D(j-1,NP2) * C(j) + 1
enddo
enddo
¿Existe una antidependencia entre las instrucciones 1 y 2? ¿Hay una
recurrencia en la instrucción 3?
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 51 ▪
Las dos definiciones, NP1 y NP2, que se han hecho antes del bucle son un
obstáculo para poder tomar una decisión. Por ello, antes que nada, el
compilador deshará ambas definiciones en todo el programa, sustituyendo
las variables por su valor original (una constante), y entonces hará el análisis
de dependencias. Recuerda: si no puede analizar los índices de los vectores,
el compilador debe asumir que sí existe la dependencia.
do i = 1, L
1: B(i) = A(L+1) + C(i)
2: A(i) = A(i) - 1
do j = 1, L
3: D(j,L+1) = D(j-1,L+2) * C(j) + 1
enddo
enddo
Ahora la decisión es clara: no existe antidependencia entre 1 y 2, porque
los índices de los vectores (i y L+1) nunca serán iguales; de la misma
manera, no existe recurrencia en la instrucción 3, porque L+1 ≠ L+2.
Esta técnica se aplica para deshacer la definición de cualquier constante.
1.5.3.2 Eliminación de las variables de inducción
Analicemos este bucle:
j = 2
k = 2
do i = 1, L
j = j + 5
R(k) = R(j) + 1
k = k + 3
enddo
¿Existe una recurrencia en el vector R? Tal como está escrito, el
compilador no sabe analizar la dependencia, porque desconoce los valores de
las variables j y k. Sin embargo, un análisis sencillo de cómo se accede a
los vectores nos indica que no existe tal dependencia. La evolución de las
variables j y k con relación a i es la siguiente:
i = 1 2 3 4 5 ...
j = 7 12 17 22 27 ...
k = 2 5 8 11 14 ...
▪ 52 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Los valores que toman j y k forman una progresión aritmética, y no hay
problema en redefinirlas de la siguiente manera:
j = 5 i + 2 y k = 3 i – 1
Las variables que forman una serie aritmética en función del índice del
bucle se conocen como variables de inducción. Eliminando las variables de
inducción, el bucle anterior puede escribirse así:
do i = 1, L
R(3*i-1) = R(5*i+2) + 1
enddo
Ahora sí, un compilador vectorial puede analizar si existe una
dependencia en R. Es bastante común encontrar variables auxiliares de este
tipo en los bucles de cálculo, y, por tanto, el compilador tendrá que
detectarlas y sustituirlas por las funciones correspondientes, para poder
realizar el análisis de dependencias (y, en su caso, para poder vectorizar el
bucle).
1.5.3.3 Antidependencias (DR, WAR)
Tal como ya hemos comentado, las antidependencias son dependencias
“débiles”, y normalmente su efecto en la vectorización del código puede
eliminarse con pequeñas transformaciones del código original.
Por ejemplo:
do i = 0, N-2
1: A(i) = B(i) + C(i)
2: D(i) = A(i) + A(i+1)
enddo
Las dependencias del bucle forman un ciclo en el grafo de dependencias.
Por tanto, si no se hace algo, el bucle no se puede vectorizar. Pero entre las
dependencias que forman el ciclo hay una antidependencia: la segunda
instrucción debe leer el vector A(i+1), antes que la primera instrucción
escriba A(i) (si no, leeríamos los valores nuevos, y no los viejos, que es lo
que indica el programa). ¿Se puede hacer algo? Sí: leer primero el vector
A(i+1). Basta para ello con escribir el bucle de la siguiente manera:
2
1
A, 0 A, 1
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 53 ▪
do i = 0, N-2
0: [T(i)] = A(i+1)
1: A(i) = B(i) + C(i)
2: D(i) = A(i) + [T(i)]
enddo
En la nueva versión, el grafo de dependencias del bucle no presenta
ningún ciclo de dependencias, por lo que puede vectorizarse sin problemas.
Normalmente no es necesario salvar en memoria el vector cuya lectura se ha
adelantado, y basta con dejarlo en un registro, que se utilizará luego para
ejecutar la instrucción correspondiente. Sólo si no tuviéramos un registro
disponible llevaríamos el vector a memoria.
Así quedará el código vectorial:
MOVI VL,#N-1
MOVI VS,#1
(2/0) LV V1,A+1(R1) ; se aadelanta la lectura de A+1
(1) LV V2,B(R1)
LV V3,C(R1)
ADDV V4,V2,V3
SV A(R1),V4
(2) ADDV V5,V1,V4 ; se utiliza lo que se leyó antes (V1)
SV D(R1),V5
1.5.3.4 Dependencias de salida (RR, WAW)
Un caso similar al anterior se puede producir con las dependencias de
salida, como, por ejemplo, en este bucle:
do i = 0, N-3
1: A(i) = B(i) + C(i)
2: A(i+2) = A(i) * D(i)
enddo
El grafo de dependencias presenta un ciclo, en el que toma parte una
dependencia de salida. Si no se efectúa alguna transformación, el bucle no es
vectorizable. Para mantener el significado del bucle, la segunda instrucción
tiene que efectuar la escritura antes que la primera; o, lo que es equivalente,
hay que atrasar la escritura de la primera instrucción, así por ejemplo:
2
1 T, 0
A, 1
0
A, 0
1
2
A, 0 A,2
▪ 54 ▪ Capítulo 1: COMPUTADORES VECTORIALES
do i = 0, N-3
1: [T(i)] = B(i) + C(i)
2: A(i+2) = [T(i)] * D(i)
3: A(i) = [T(i)]
enddo
Como en el caso anterior, no suele ser necesario utilizar el vector auxiliar
(T), y basta con dejar el resultado en un registro, para llevarlo más tarde a
memoria. Así quedará el bucle vectorial:
MOVI VL,#N-2
MOVI VS,#1
(1) LV V1,B(R1) ; instruccción 1, salvo la escritura
LV V2,C(R1)
ADDV V3,V1,V2
(2) LV V4,D(R1)
MULV V5,V3,V4
SV A+2(R1),V5
(1/3) SV A(R1),V3 ; escritura de la instrucción 1
1.5.3.5 Intercambio de bucles (loop-interchanging)
Los vectores de dos dimensiones (en general, de n dimensiones) pueden
vectorizarse de más de una manera, según su definición (por filas, por
columnas...). Para escoger una de ellas, hay que tener en cuenta las
dependencias entre instrucciones. Por ejemplo:
do i = 0, N-1
do j = 1, N-1
A(i,j) = A(i,j-1) + 1
enddo
enddo
Además del grafo de dependencias (sólo hay una instrucción en el bucle,
por lo que de haber alguna dependencia será consigo misma), hemos
dibujado las dependencias en el espacio de iteraciones, para ver cómo se
1
2
3
T, 0
A, 2
T, 0
j
i
1
A, (0, 1)
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 55 ▪
reparten en el tiempo. De dicho grafo es fácil concluir que el código no
puede vectorizarse tal como está escrito, es decir, por filas, ya que en la
iteración j se necesitan los resultados de la iteración j–1. Pero igualmente se
observa que no hay inconveniente en vectorizar la operación por columnas,
de esta manera:
do j = 1, N-1
do i = 0, N-1
A(i,j) = A(i,j-1) + 1
enddo
enddo
Basta con utilizar como vector las columnas de la matriz (s = N), es decir,
intercambiar el orden original de los bucles.
El intercambio de bucles no puede aplicarse a cualquier bucle, ya que, por
supuesto, hay que respetar las dependencias entre instrucciones. Por
ejemplo, no puede aplicarse en el siguiente ejemplo: no se puede procesar la
matriz por columnas, puesto que en la columna j se necesitan los resultados
de la columna j+1.
do i = 1, N-1
do j = 1, N-2
(1) A(i,j) = B(i-1,j+1) + 1
(2) B(i,j) = A(i,j-1)
enddo
enddo
Cuando se intercambia el orden de los bucles, se modifica el vector de
distancias de las dependencias. Por ejemplo, una dependencia de distancia
(2, 1) se convierte en otra de distancia (1, 2). La regla que permite el
intercambioes la siguiente: el primer elemento no cero del nuevo vector
de distancias debe ser positivo.
Por ejemplo, para el ejemplo del grafo anterior:
- sin intercambiar los bucles - tras intercambiar los bucles
d1 → (0, 1) d1 → (1, 0) no hay problemas
d2 → (1, -1) d2 → (-1, 1) esto no es posible
Así pues, no se puede vectorizar por filas y no se puede intercambiar los
bucles.
j
i
j
i 1
2
A, (0, 1)
B, (1, -1)
▪ 56 ▪ Capítulo 1: COMPUTADORES VECTORIALES
En algunos casos, es necesario aplicar fisión e intercambio de bucles para
poder vectorizar el bucle. Por ejemplo,
do i = 1, N-1
do j = 1, N-1
(1) A(i,j) = A(i-1,j) + 1
(2) B(i,j) = B(i,j-1) * 2
enddo
enddo
De acuerdo a las dependencias que aparecen en el espacio de iteraciones,
el bucle no se puede vectorizar ni por filas ni por columnas. Pero, en este
ejemplo, la dependencia por filas corresponde a una instrucción (2) y la de
las columnas a otra (1), tal como vemos en el grafo de dependencias. El
bucle lo dividiremos en dos; luego, la primera instrucción (vector A) la
vectorizaremos por filas, y la segunda (vector B) por columnas,
intercambiando los bucles.
Sea como sea, sólo se intercambian los bucles si con ello se facilita la
vectorización del código o se mejora el rendimiento. Por ejemplo, en este
caso:
do i = 0, 99
do j = 0, 9
A(i,j) = A(i,j) + 1
enddo
enddo
No hay dependencias entre las iteraciones, y por tanto puede vectorizarse
por filas, tal como está escrito, o por columnas, si se cambia el orden de los
bucles. Si se hace por filas, se procesan 100 vectores de 10 elementos. Los
vectores son pequeños, por lo que el rendimiento no será alto (es una función
de N). Sin embargo, si se cambia el orden se procesarán 10 vectores de 100
elementos, con lo que se obtendrá una mayor velocidad de proceso.
1.5.3.6 Expansión escalar (scalar expansion)
Al escribir bucles es habitual utilizar variables auxiliares que facilitan la
escritura del bucle. Por ejemplo:
1 A, (1, 0)
2
B, (0, 1)
j
i
A
B
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 57 ▪
do i = 0, N-1
suma = A(i) + B(i)
C(i) = suma * suma
D(i) = suma * 2
enddo
Aunque procesamos vectores, utilizamos una variable escalar auxiliar,
suma. Sin embargo, esa variable impide la vectorización del bucle, ya que
genera dependencias entre todas las iteraciones. No se trata de una variable
propia del bucle, sino de una simple variable auxiliar, así que ¿por qué no
escribir el código de esta otra manera?
do i = 0, N-1
suma(i) = A(i) + B(i)
C(i) = suma (i) * suma (i)
D(i) = suma (i) * 2
enddo
Lo que antes era una variable escalar ahora es un vector completo:
suma(i). Ya no hay ningún problema para vectorizar el bucle anterior.
Esta técnica, convertir un escalar en un vector, se conoce con el nombre de
expansión escalar.
Como en casos anteriores, no suele ser necesario guardar el vector auxiliar
en memoria, sino que basta con utilizar los registros del procesador. En todo
caso, no hay que olvidar que, al final del bucle, la variable original suma
debe contener el valor correspondiente a la última iteración del bucle:
suma = suma(N-1)
1.5.3.7 Fusión de bucles (loop fusion)
Con esta optimización se intenta fundir dos (o más) bucles en uno solo,
para intentar reducir toda la sobrecarga asociada al control del bucle, y para,
si es posible, reutilizar los resultados almacenados en los registros. Por
ejemplo,
do i = 0, N-1
Z(i) = X(i) + Y(i)
enddo
do i = 0, N-1
R(i) = Z(i) + 1
enddo
do i = 0, N-1
Z(i) = X(i) + Y(i)
R(i) = Z(i) + 1
enddo
▪ 58 ▪ Capítulo 1: COMPUTADORES VECTORIALES
Los dos programas del ejemplo son idénticos, pero el segundo es más
“sencillo” de ejecutar. Para empezar, el compilador puede aprovechar en la
segunda instrucción las operaciones de la primera, leyendo el operando de
un registro (no haremos SV Z y luego LV Z); además de ello, todo el código
asociado con la ejecución del bucle sólo se ejecutará una vez
(direccionamiento, longitud y paso de los vectores...).
De todas maneras, no es seguro que el compilador efectúe esta
optimización automáticamente, puesto que para ello debería realizar el
análisis de dependencias más allá del bloque básico.
En todo caso, claro está, no siempre es posible fundir dos bucles en uno,
puesto que hay que respetar las dependencias de datos. Por ejemplo, estos
dos programas no son iguales:
do i = 1, L
Z(i) = X(i) + Y(i)
enddo ≠
do i = 1, L
R(i) = Z(i+1) + 1
enddo
do i = 1, L
Z(i) = X(i) + Y(i)
R(i) = Z(i+1) + 1
enddo
1.5.3.8 Colapso de bucles (loop collapsing)
Como ya sabemos, los bucles de varias dimensiones pueden vectorizarse
de diferentes maneras: por filas, por columnas... Pero cuando el tamaño de
los vectores es pequeño, puede ser interesante "juntar" dos (o más) bucles en
uno. Por ejemplo:
float A(10,10)
do i = 0, 9
do j = 0, 9
A(i,j) = A(i,j) + 1
enddo
enddo
El bucle puede ejecutarse vectorialmente sin problemas, pero los vectores
(filas o columnas) son muy pequeños. Para aprovechar mejor el tamaño de
los registros vectoriales, podemos transformar el bucle de la siguiente
manera:
float A(10,10)
do i = 0, 99
A(i) = A(i) + 1
enddo
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 59 ▪
Como sabemos, el espacio de memoria es lineal, y las filas de la matriz se
almacenan una tras otra; por tanto, la matriz A[N,N] puede tratarse como si
fuera el vector A[NxN].
1.5.3.9 Otras optimizaciones
Las técnicas de optimización que acabamos de ver son las más habituales,
aunque existen otras. Sin embargo, más de una vez ocurre que lo que parece
muy simple de vectorizar resulta muy complejo de hacer automáticamente
(para el compilador). En esos casos, la ayuda del programador resulta el
camino más sencillo. Esa ayuda suele efectuarse mediante pseudo-
instrucciones para el compilador, al que se le indica qué trozos de código
debe traducir a código vectorial sin preocuparse del análisis de
dependencias. Veamos un ejemplo:
do i = a, b
X(i) = Y(i) + X(i+M)
enddo
Sin más información, el compilador no puede vectorizar el bucle, porque
puede existir una recurrencia en X, en función del valor de M: si M ≥ 0, no
hay problemas para vectorizar el bucle, pero si M < 0, el bucle no es
vectorizable (M no es una constante; en caso contrario, el compilador la
sustituiría por su valor). Sin embargo, puede ser que el usuario tenga
información extra sobre la variable M. Por ejemplo, tal vez sabe que se trata
de un parámetro físico que siempre es positivo (o que, por ejemplo, se acaba
de ejecutar M = A(i) * A(i)). Si es así, bastaría con indicarle al
compilador que vectorizara el bucle, sin más.
Por otro lado, el compilador podría también ejecutar el bucle de la
siguiente manera:
if (M ≥ 0) then
do i = a, b
X(i) = Y(i) + X(i+M)
enddo
else
do i = a, b
X(i) = Y(i) + X(i+M)
enddo
endif
La primera parte (then) se ejecutará como código vectorial; la segunda,
en cambio, escalarmente.
▪ 60 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.5.4 Vectores de máscara y vectores de índices
Todas las operaciones vectoriales que hemos analizado hasta el momento
han sido muy “simples”. Sin embargo, no siempre es ése el caso en los
programas reales. Vamos a analizar dos casos muy habituales, que aparecen
mucho en el cálculo científico: el uso de máscaras y los vectores de paso
variable.
1.5.4.1 Uso de máscaras
En más de una ocasión, no hay que procesar todos los elementos de un
vector, sino solamente algunos de ellos. Por ejemplo:
do i = 0, N-1
if (B(i) > 5) then A(i) = A(i) + 1
enddo
Con lo que hemos analizado hasta el momento, no sabríamos cómo
ejecutar vectorialmente ese bucle, pero es un caso tan habitual que tiene una
solución específica: el uso de un registro de máscara. El registro de
máscara (VM, vectormask) es un registro vectorial booleano (1/0) especial,
que guarda el resultado de una operación lógica sobre vectores. Todas la
operaciones vectoriales toman en consideración el registro VM para decidir
qué elementos del vector hay que procesar y cuáles no.
El procesador dispone de instrucciones específicas para trabajar con el
registro de máscara. Por ejemplo:
SxxV V1,V2 Compara dos vectores, elemento a elemento, y deja los
resultados (1/0) en el registro de máscara VM (xx =
operación de comparación: EQ, NE, GT...).
SxxVS V1,F1 Igual, pero utilizando un escalar para la comparación.
CVM Clear vector mask, para inicializar la máscara.
POP R1,VM Cuenta el número de bits activados en el registro de
máscara; deja el resultado en R1.
Usando esas instrucciones, podemos ejecutar vectorialmente el bucle
anterior de la siguiente manera:
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 61 ▪
MOVI VL,#N
MOVI VS,#1
MOVI F1,#5
LV V1,B(R1)
SGTVS V1,F1 ; Set Greater Than Vector/Scalar VM := V1~F1
LV V2,A(R1)
ADDVI V3,V2,#1
SV A(R1),V3
CVM ; Clear Vector Mask
La instrucción SGTVS compara los elementos de V1 con el contenido de
F1, y el resultado se deja en el registro VM. Las operaciones vectoriales
siguientes sólo tendrán efecto en las posiciones de los vectores indicadas en
el registro VM. La instrucción CVM inicializa nuevamente el registro VM (los
valores concretos dependen del computador).
Ten en cuenta que el tiempo de ejecución no cambia; cuando se ejecuta
ADDVS, únicamente se enmascaran las escrituras en el registro destino.
También hay que estar atentos a las posibles excepciones que se generen en
la unidad funcional correspondiente, puesto que se tratan todos los
elementos. En otras máquinas en cambio, se enmascara tanto la operación en
la unidad funcional como la escritura en el registro.
1.5.4.2 Vectores de índices
En muchas aplicaciones científicas se utilizan estructuras de datos muy
grandes (por ejemplo, una matriz de 10.000 × 10.000 elementos). Sin
embargo, tal vez sólo haya que procesar unos pocos elementos de esas
estructuras. Un ejemplo podría ser el de la figura.
Aunque la matriz es muy grande, sólo se van a procesar los elementos
marcados. Con dichos elementos puede formarse un vector, pero tenemos un
pequeño problema. Hasta el momento, el paso (distancia de un elemento al
s = 3
5
14
4
1 8
▪ 62 ▪ Capítulo 1: COMPUTADORES VECTORIALES
siguiente) de los vectores que hemos utilizado ha sido constante. Sin
embargo, el vector que definimos en la figura no tendría un paso constante.
Por tanto, no podríamos aplicar el mecanismo normal de direccionamiento
para acceder a un elemento a partir del anterior: sumar una constante. En
general, el problema es el siguiente: ¿cómo procesar vectorialmente
vectores cuyo paso no es constante? Necesitamos un nuevo modo de
acceso a memoria (un nuevo modo de direccionamiento) para poder leer o
escribir dicho vector.
El nuevo método de acceso se logra mediante el uso de vectores de
índices. Un vector o registro de índices guarda las posiciones concretas de
los elementos a los que queremos acceder.
Una operación vectorial de este estilo se suele dividir en tres fases:
1 Fase de agrupamiento (gather): se utiliza el registro de índices para
leer los elementos que nos interesan —base + desplazamiento—, y se
cargan en un registro vectorial.
2 Fase de ejecución: se ejecuta la operación indicada.
3. Fase de difusión (scatter): se llevan los resultados de la operación
vectorial a memoria, a las posiciones correspondientes, utilizando
nuevamente el registro de índices.
Para efectuar las operaciones de agrupamiento y difusión y, en general,
para trabajar con índices, se pueden utilizar instrucciones tales como (por
ejemplo):
LVI V1,A(V2) Lee de memoria los elementos A + V2(i), utilizando V2
como registro de índices.
SVI A(V2),V1 Escribe en memoria los elementos A + V2(i), utilizando V2
como registro de índices.
CVI V1,R1 Genera un vector de índices, con los valores 0, R1, 2R1, ...,
(Lmax–1)R1.
Por ejemplo, analicemos este bucle:
do i = 0, M-1
A(i*i) = B(i*i) + 1
enddo
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 63 ▪
Tal como está, no se puede vectorizar por el procedimiento habitual,
puesto que los pasos de los vectores A y B no son constantes (0, 1, 4, 9...).
Sin embargo, tenemos la posibilidad de ejecutarlo vectorialmente así:
MOVI VL,#M
MOVI R1,#1
CVI V4,R1 ; 0, 1, 2, 3...
MULV V5,V4,V4 ; registro de índices: i*i
LVI V1,B(V5) ; direccionamiento indexado
ADDVI V2,V1,#1
SVI A(V5),V2
Para indicar los índices hemos utilizado el registro V5, en el que hemos
cargado previamente los resultados de la función i*i. Después, hemos
utilizado el modo de direccionamiento indexado (base + vector de índices)
para acceder al vector.
El modo de direccionamiento “indexado” puede utilizarse también, por
ejemplo, para ejecutar el bucle del apartado anterior —if (B(i)>5)
then A(i) = A(i) + 1— de la siguiente manera:
MOVI VL,#N
MOVI VS,#1
MOVI F1,#5
LV V1,B(R1)
SGTVS V1,F1 ; generar máscara (VM)
MOVI R2,#1 ; create vector index: 0, 1, 2... teniendo en cuenta VM
CVI V2,R2 ; p.e.: VM = 10011101 → V2 = 03457
POP R1,VM ; contar bits a 1 en el registro VM (5)
MOV VL,R1 ; cargar el registro VL (número de elementos)
CVM ; inicializar máscara
LVI V3,A(V2) ; utilizar V2 como registro de índices,
ADDVI V4,V3,#1 ; y procesar solamente VL elementos
SVI A(V2),V4
De este modo, en la última parte (LVI / SVI) no se leen y escriben todos
los elementos (como se haría con un LV o SV normal), sino solamente los
que se tienen que procesar.
▪ 64 ▪ Capítulo 1: COMPUTADORES VECTORIALES
1.6 RESUMEN
Un computador vectorial es una máquina específicamente diseñada para el
procesamiento de vectores (o, visto de otra, manera para la ejecución de
bucles “largos”), y está compuesta por dos secciones: la que procesa
vectores y la que procesa escalares, tan importante como la primera (los
programas reales incluirán ambos tipos de código, vectorial y escalar, por lo
que es necesario que el computador ejecute código escalar eficientemente).
El conjunto de instrucciones de estas máquinas incluye instrucciones
vectoriales (LV, ADDV, SV...) que permiten la ejecución de una operación
vectorial completa sobre todos los elementos de un vector con una sola
instrucción. Las características arquitecturales básicas de un procesador
vectorial son: el uso de registros vectoriales, el encadenamiento entre
instrucciones, un gran ancho de banda con memoria (múltiples buses de
acceso a memoria) y una memoria entrelazada en muchos módulos. Así, el
modelo de ejecución lleva a que el tiempo de ejecución de los bucles pueda
formularse como TV = ti + tv N (ti = tiempo de inicio; tv = tiempo necesario
para procesar un elemento, 1 ciclo en el caso ideal), en lugar del modelo
escalar tradicional, TE = te N.
Las medidas básicas de rendimiento de un procesador vectorial ejecutando
un determinado programa son R∞ (velocidad de cálculo con vectores de
longitud infinita) y N1/2 (tamaño mínimo de los vectores para conseguir al
menos la mitad de la velocidad máxima). Tal como ocurre con otros modelos
de proceso, la velocidad pico (peak performance) de un procesador vectorial
no es un parámetro adecuado para medir el rendimiento de un sistema
vectorial. El que los vectores que se procesen no sean muy grandes, hace que
el tiempo de inicio del cálculo vectorial (start-up) sea un parámetro muy
importante a considerar.
Sin embargo, hay varios factores que limitan el rendimiento. Por una
parte, el hardware —el tamaño de los registros vectoriales, el número de
buses a memoria, el número de unidades funcionales—. Pero el parámetro
que más puede llegar a reducir el rendimiento de una máquina vectorial es el
factor de vectorización,f: fracción de código que se ejecuta en modo
vectorial.
Por ello, no es posible olvidar el papel que un buen compilador debe
realizar en este tipo de máquinas. El compilador vectorial es el responsable
de generar código vectorial a partir de un código escalar estándar, y debe
lograr el factor de vectorización más alto posible. En caso contrario, y tal
1.6 RESUMEN ▪ 65 ▪
como indica la ley de Amdahl, el rendimiento final del sistema será muy
bajo. Como siempre, para facilitar la tarea del compilador y mejorar su
rendimiento, la ayuda de un usuario experto es siempre importante. Algunas
de las técnicas de vectorización son ya clásicas y las aplican todos los
compiladores. Esas estrategias se basan en el análisis de las dependencias
entre instrucciones, y son comunes a los compiladores que intentan
paralelizar el código para ser ejecutado en sistemas con más de un
procesador. Por ello, las volveremos a analizar en un tema posterior.
▪ Breve historia de los computadores vectoriales
A lo largo de la (breve) historia de la computación, los computadores
vectoriales han estado siempre a la cabeza de las máquinas más rápidas en
cálculo científico, aunque la evolución de los sistemas multiprocesador ha
relegado a estos procesadores a un segundo lugar. Pioneros en el uso de
tecnologías avanzadas (ECL) y aportando soluciones arquitecturales
novedosas, han marcado, hasta hoy en día, la referencia de velocidad de
cómputo.
Aunque el primer computador vectorial fue el CDC STAR-100 (1972), el
computador que marcó la historia de este tipo de máquinas fue el Cray-1
(1975), en la que se utilizaron por vez primera los registros vectoriales y el
encadenamiento. Junto a ello, tomando en consideración los resultados de la
ley de Amdahl, utilizaba un procesador escalar de gran velocidad (el más
rápido del momento). En todo caso, y por limitaciones tecnológicas del
momento, sólo disponía de una unidad vectorial, (es decir, sólo podía
ejecutar una instrucción LV o SV a la vez).
En 1981, la casa CDC pone en el mercado el CYBER 205, evolución
natural del computador STAR: seguía manteniendo el modelo M/M, pero
disponía de muchas unidades de memoria (de hecho, por lo menos se
necesitan tres en un computador vectorial M/M). En ese computador se
utilizaron por primera vez los vectores de índices para procesar matrices de
baja densidad (sparse).
La siguiente máquina de CRAY fue el Cray X-MP; una evolución natural
del computador anterior (reloj más rápido, más buses a memoria, posibilidad
de utilizar más de un procesador). Pronto aparece en el mercado el Cray-2: 4
procesadores, 156 MB de memoria DRAM (palabras de 60 bits), reloj más
rápido, pero latencias más altas (ciclos) en las unidades funcionales, sin
encadenamiento, y un único bus; no era una gran alternativa, salvo por su
gran memoria.
▪ 66 ▪ Capítulo 1: COMPUTADORES VECTORIALES
En los 80 aparecen en el mercado los superminicomputadores, mucho más
baratos que los anteriores. Entre ellos el C-1 y C-2 (2 procesadores) de
Convex. El éxito de estas máquinas, además de en su precio, hay que
buscarlo en su compilador, de una gran eficiencia.
Los computadores Japoneses entran en escena. Los computadores VP100
y VP200 de Fujitsu se comercializan en 1983, y un poco después aparecen
los Hitachi S810 y NEC SX/2. En general, estas máquinas japonesas podían
lograr velocidades pico muy altas, pero los tiempos de inicio de las
operaciones vectoriales (start-up) eran muy altos, lo que los hacía muy
sensibles al procesamiento de vectores no muy largos, logrando resultados
en muchos casos peores que los del X-MP.
En 1988 aparece el Cray-Y-MP, una evolución del X-MP (8 procesadores,
reloj más rápido). La casa Cray continua adelante y ofrece el C90 —16
procesadores a 240 MHz (y 15 millones de dólares)— y, más tarde, el T90.
Comercializa también el J90, una versión CMOS, más barata (1 millón $).
Más tarde, la casa Cray comercializó el computador vectorial Cray Inc.
SV1(ex), sucesor del J90 y del T90, en el que se utiliza tecnología CMOS (y
se abandona definitivamente la rápida y cara ECL, tal como hicieron en su
día Fujitsu y NEC). Se trata de un multiprocesador de memoria compartida
que, en su configuración máxima, utiliza 128 procesadores vectoriales, a 450
MHz y 1,8 Gflop/s. Otra característica a destacar es el uso de una cache
común de 256 kB para escalares y vectores. En anteriores diseños de Cray
no se utilizaba memoria cache, pero en este último la velocidad del sistema
de memoria no es suficiente para mantener ocupado el procesador (efecto del
tradicional gap entre la velocidad del procesador y la de la memoria).
Por su parte, Fujitsu ofreció el computador VPP5000, evolución natural
del VPP700: reloj más rápido (300 MHz) y 16 vector pipes de tipo multiply-
and-add. En teoría, por tanto, cada procesador es capaz de lograr 9,6
Gflop/s. El procesador escalar va a 1,2 Gflop/s. En su configuración mayor,
se trata de un multicomputador de memoria distribuida de 128 procesadores,
en el que la comunicación punto a punto se efectúa a 1,6 GB/s, utilizando
como red de comunicación un full distributed crossbar.
Finalmente, otra máquina japonesa más: el NEC SX-5/16A, un
multiprocesador vectorial de memoria distribuida. Sus características
principales son: reloj a 313 MHz, 16 unidades vectoriales, 10 Gflop/s por
procesador. La versión SX-6 de ese procesador fue la base del
supercomputador Earth Simulator.
1.6 RESUMEN ▪ 67 ▪
Todas las máquinas citadas han sido siempre las más rápidas del
momento, pero también, con diferencia, las más caras. La evolución de los
microprocesadores en los últimos años, junto con el uso del paralelismo, ha
ido arrinconando a este tipo de arquitecturas, con lo que, en un futuro
cercano, parece que jugarán un papel cada vez menor en el campo del
cálculo científico. Para ello, habrá que aprender a programar y utilizar los
sistemas de muchos procesadores de manera eficiente, para aprovechar su
gran potencial de cálculo. En todo caso, es habitual que los procesadores
(super)escalares actuales dispongan de instrucciones de tipo vectorial
(SIMD) que, por ejemplo, dividen los 64 bits de una palabra en 8 palabras de
8 bits que son tratadas como un vector corto, y con las que se realizan
operaciones tipo producto/suma encadenadas.
Hoy en día, los procesadores vectoriales aparecen como nodos
especializados de un sistema paralelo más general. En ese tipo de sistemas,
MPP (massive parallel processors) hay que buscar el futuro del cálculo
paralelo: miles de procesadores colaboran en la resolución de un problema y
se comunican entre ellos mediante un red de comunicación de gran
velocidad. En dicha red, algunos procesadores están especializados en
determinado tipo de cálculo, por ejemplo, cálculo vectorial.
Siempre es posible, en todo caso, encontrarse con sorpresas en la
evolución de los computadores. En el top500 de junio de 2002 (lista de las
500 máquinas más rápidas del mundo, que se publica dos veces al año), se
produjo un cambio significativo. En contra de la línea seguida en los últimos
años, el número 1 de la lista fue un (multi)computador vectorial: Earth
Simulator. Se trataba de un computador japonés de propósito específico con
5.120 procesadores vectoriales. Utilizaba chips NEC SX-6, que contienen
cada uno 8 procesadores vectoriales. Lograba una velocidad de Rmax = 36
Tflop/s (el segundo en dicha lista, junio 2002, el ASCI White, alcanzaba 7,2
TF/s, utilizando 8.192 procesadores). En la lista citada (junio 2002) había 41
computadores vectoriales.
En 2009 disponemos de una nueva versión de dicha máquina (1.280
procesadores NEC SX-9, de 350 millones de transistores, a 3,2 GHz) que ha
logrado una velocidad de cálculo de 122,4 TF/s. Cada CPU dispone de una
unidad superescalar (de 4 vías) y una unidad vectorial con las siguientes
características: 72 registros vectoriales de 256 elementos y 8 conjuntos o
pipes de unidadesfuncionales vectoriales (+, ×, /, lógicas, máscaras y
LV/SV). Cada chip puede alcanzar los 102,4 GF/s.
▪ 68 ▪ Capítulo 1: COMPUTADORES VECTORIALES
El nuevo Earth Simulator es el número 22 de la lista top500 de junio de
2009, pero es ya la única máquina vectorial de la lista; parece, por tanto, que
la arquitectura vectorial tiende a desaparecer.
En el último capítulo haremos un repaso de la situación de la lista top500
y de las principales máquinas, arquitecturas y tendencias que en ella
aparecen.
▪ 2 ▪
Computadores Paralelos
(conceptos básicos)
2.1 INTRODUCCIÓN
Aunque los procesadores son cada vez más rápidos, existen numerosas
aplicaciones para las que la velocidad de cálculo de un único procesador
resulta insuficiente. La alternativa adecuada para esas aplicaciones es el uso
de paralelismo. Con el término paralelismo se indica que la ejecución de un
determinado programa se reparte entre muchos procesadores, que trabajan
simultáneamente.
Pueden utilizarse diferentes niveles de paralelismo. Por ejemplo, se
explota el paralelismo a nivel de instrucción (ILP) cuando se segmenta la
ejecución de las instrucciones de un programa: en un momento dado, se
están ejecutando muchas instrucciones a la vez, pero en fases de ejecución
diferentes. También puede explotarse el paralelismo en los datos. El ejemplo
con más éxito de esa alternativa son los computadores vectoriales que
acabamos de analizar. En todos esos casos (y en otros similares, como
VLIW), sólo existe un contador de programa o PC, es decir sólo se ejecuta
▪ 70 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
un programa bajo una única unidad de control. En los próximos capítulos
vamos a estudiar el paralelismo a nivel de programa, es decir, vamos a
analizar cómo repartir la ejecución de un programa entre P procesadores. Si
fabricar réplicas de un procesador es un proceso relativamente sencillo y
barato, ¿por qué no utilizar P procesadores para intentar ejecutar un
programa P veces más rápido?
Recordemos un momento la clasificación de Flynn de los computadores,
que toma en cuenta el número de flujos de datos y de instrucciones:
▪ SISD: un solo flujo de datos y un solo flujo de instrucciones. Se trata
del modelo de un solo procesador (superescalar, por ejemplo). El
paralelismo se obtiene en el uso simultáneo de unidades funcionales
debido a la segmentación de la ejecución de las instrucciones.
▪ SIMD: un solo flujo de instrucciones (un contador de programa), pero
muchos flujos de datos. En función del uso de la memoria, pueden
diferenciarse dos familias: los procesadores vectoriales (memoria
compartida) y los procesadores en array de memoria distribuida.
▪ MIMD: múltiples flujos de datos y de instrucciones. Se trata del
verdadero modelo de paralelismo, en el que existen muchos
programas en ejecución simultanea. Éste es el tipo de máquina que
vamos a analizar a partir de ahora.
Antes de ello, una pequeña precisión acerca del uso de P procesadores,
puesto que podemos tener diferentes alternativas, en función del objetivo que
busquemos:
▪ Redes de computadores (LAN, WAN...). P usuarios ejecutan cada uno
de ellos un programa diferente, independientemente (tal vez, de vez en
cuando, se produzca alguna transmisión de datos entre los usuarios).
Cada programa se ejecuta según el modelo de un único procesador.
▪ Tolerancia a fallos. En función de la aplicación, existen diferentes
maneras de hacer frente a los fallos del sistema. Por ejemplo, se repite
la ejecución del mismo programa en P procesadores, para obtener un
alto nivel de fiabilidad de los resultados (por ejemplo, en situaciones
especiales en las que no podemos permitirnos un error), o para
disponer de una máquina cuando falla otra (high reliability, en un
banco, por ejemplo) En otros casos, un computador ofrece un
determinado servicio y un segundo computador está a la espera del
posible fallo del primero, y cuando lo detecta toma su función para
que el servicio ofertado esté siempre disponible (high availability).
2.2 COMPUTADORES DM-SIMD ▪ 71 ▪
▪ Se ejecuta el mismo programa, repetido en todos los procesadores,
pero con datos diferentes; por ejemplo, para hacer múltiples
simulaciones independientes de un proceso físico en menor tiempo
(mejora del throughput). O, en los servidores, para poder atender a
múltiples peticiones simultáneas (por ejemplo, en una base de datos).
• Para ejecutar un programa P veces más rápido (high
performance). Éste es el tipo de aplicación que nos interesa.
Comparado con los anteriores casos, la diferencia fundamental va a
estar en la comunicación entre procesos, que va a ser mucho más
intensiva y que habrá que efectuar en tiempos muy breves
(microsegundos). Esta comunicación es una parte de la ejecución y se
produce como consecuencia de ejecutar en paralelo. Existen diferentes
arquitecturas de este tipo, en función del número de procesadores, el
nivel de acoplamiento entre ellos (frecuencia de la comunicación),
capacidad y complejidad de los procesadores, mecanismos de
sincronización y control, tamaño de las tareas, etc.
En los próximos capítulos vamos a analizar las principales características
y problemas de este nuevo modelo de ejecución. Pero antes de ello, vamos a
definir los principales conceptos y terminología de esta área.
2.2 COMPUTADORES DM-SIMD
Acabamos de analizar los computadores vectoriales, máquinas SIMD de
memoria compartida. Aunque no son nuestro objetivo, vamos a hacer un
breve resumen de las características principales del otro tipo de arquitecturas
SIMD, las de memoria distribuida (DM = distributed memory) o
procesadores en array.
Como ya hemos comentado, los computadores SIMD explotan el
paralelismo de datos: con una única instrucción (la misma en todos los
procesadores en el caso de los arrays) se procesan múltiples datos.
Procesador
de control
Computador
front-end
Pr + M + I/O
Red de comunicación
Array de cálculo
▪ 72 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
Las características principales son las siguientes:
- Procesadores: en general, se utilizan muchos procesadores muy
sencillos, por ejemplo, 1.024 procesadores de 1 bit. Así pues,
procesadores baratos, pero no muy rápidos. En el caso de los
procesadores serie, de 1 bit, se procesan datos de cualquier tamaño,
siendo la latencia proporcional al tamaño de los mismos.
- Control: el control es centralizado. Todos los procesadores ejecutan la
misma instrucción en el mismo momento (lock-step), sobre datos
diferentes. Si es necesario, la ejecución puede controlarse mediante
máscaras, que indican en qué procesadores sí y en cuáles no se debe
ejecutar la instrucción actual.
Un procesador especial de control se encarga de repartir las
instrucciones a los procesadores y de comunicarse con el computador
central front-end, desde el que se controla todo el sistema. Como en el
caso de los procesadores vectoriales, el código que no se pueda
ejecutar en el array se ejecutará en serie en el procesador central (o en
el de control).
Normalmente, las operaciones de entrada/salida se realizan en los
procesadores del array, lo que resulta muy adecuado para procesar
datos de manera intensiva.
- Estructura: los procesadores forman una matriz o array, de 2 o 3
dimensiones. Una red especial de comunicación facilita la
comunicación entre los procesadores; las redes más habituales son las
mallas, los toros, etc. En general, y de cara a mejorar la eficiencia del
sistema, la red se suele dividir en diferentes planos o subredes: para
datos, para control, etc.
- Aplicaciones: este tipo de estructura se adecua muy bien a un
determinado tipo de aplicaciones; por ejemplo, procesamiento de
señales y de imágenes, o cierto tipo de simulaciones (Montecarlo...).
Aunque el espacio de memoria sea común, la eficiencia del sistema es
mucho mayor silas comunicaciones son locales (con los vecinos), que
es lo que ocurre en las aplicaciones que hemos citado.
La regularidad de las estructuras de datos que se procesan y el tipo de
operaciones que se ejecutan hacen que los accesos a memoria se
realicen de acuerdo a patrones conocidos, en muchos casos en forma
de “permutaciones".
2.3 COMPUTADORES MIMD ▪ 73 ▪
ILLIAC IV, Solomon, CM1, BSP, DAP, Quadrics Apemille, procesadores
sistólicos... son algunas de las máquinas más conocidas que han utilizado
este tipo de arquitectura. Aunque han tenido su importancia, los
computadores SIMD únicamente han encontrado un hueco en el tipo de
aplicaciones citadas, y hoy no tienen presencia alguna en el mercado.
Los sistemas paralelos actuales son de tipo MIMD; veamos, por tanto, las
características principales de estos sistemas.
2.3 COMPUTADORES MIMD
Como ya hemos comentado, en un sistema MIMD las aplicaciones se
reparten en múltiples procesos que se ejecutan en diferentes procesadores.
Desde el punto de vista de la arquitectura del sistema, la primera cuestión a
aclarar sería: ¿cómo se estructuran los P procesadores en un sistema único?
La respuesta puede ser muy amplia, pero pueden identificarse dos grandes
grupos de arquitecturas, de acuerdo al uso de la memoria: los sistemas de
memoria compartida y los de memoria distribuida o privada.
2.3.1 Memoria compartida (shared memory)
En los sistemas paralelos de memoria compartida, todos los procesadores
comparten la memoria global del sistema, es decir, todos los procesadores
utilizan el mismo espacio de direccionamiento.
De esta manera, la comunicación entre procesos es relativamente sencilla,
utilizando para ello variables compartidas en la memoria común. Para pasar
un dato de un proceso a otro, basta con dejar el dato en una determinada
posición de memoria, donde lo leerá el proceso destino.
P0
M0
Mm–1
P1
Pp–1
Procesadores (+ MC)
Red de comunicación
Memoria principal
sistema
E/S
▪ 74 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
Para conectar los procesadores y la memoria se utiliza una red de
comunicación. La red más sencilla es el bus; se conoce perfectamente su
funcionamiento y no es difícil de controlar. Sin embargo, tendremos un
problema nuevo: si se conectan muchos procesadores al bus, es posible que
éstos lleguen a saturarlo, y que, por tanto, los tiempos de acceso a memoria
sean altos. No hay que olvidar que el bus es una red centralizada que se
comparte en el tiempo, que no admite dos operaciones a la vez. También
pueden utilizarse otro tipo de redes, que analizaremos más adelante. Para
simplificar, vamos a suponer que la red de comunicación es un bus.
A este tipo de arquitectura se le conoce habitualmente con el nombre de
multiprocesador, y también como SMP (symmetric multiprocessor), UMA
(uniform memory access) o sistemas paralelos de alto grado de
acoplamiento. Dada la red de comunicación, un bus, el número de
procesadores de un sistema SMP es relativamente bajo, entre 2 y 32, por lo
que el paralelismo que se puede conseguir es reducido.
2.3.2 Memoria privada o distribuida (distributed memory)
En este segundo modelo, como puede observarse en la figura siguiente,
cada procesador dispone de su propia memoria privada. El espacio de
direcciones no es común: todas las direcciones son locales y hacen referencia
a la memoria propia del procesador. Por ello, la comunicación entre procesos
no puede hacerse, como en el caso anterior, mediante posiciones comunes de
memoria. Así, la comunicación se realiza mediante paso de mensajes,
utilizando para ello la red de comunicación. Si Pi debe enviar datos a Pj,
formará con ellos un mensaje y lo enviará a la red; los controladores de la
red se encargarán de ir retransmitiendo el mensaje hasta que llegue a su
destino.
Nodos:
Procesador (+ MC) + Memoria principal + E/S + Contr. comunic.
Red de comunicación
P0
M
E/S
K
Pp–1
M
E/S
K
2.3 COMPUTADORES MIMD ▪ 75 ▪
El objetivo de este modelo es conseguir paralelismo “masivo”, es decir,
poder utilizar un número grande de procesadores. Por ello, no se utiliza un
bus como de red de comunicación, sino redes tales como mallas y toros de 2
y 3 dimensiones, hipercubos, árboles, etc., que analizaremos más adelante.
A este tipo de arquitectura se le conoce como multicomputador (o
también como sistema débilmente acoplado, MPP o Massively Parallel
Processors...).
2.3.3 Memoria lógicamente compartida pero
físicamente distribuida (distributed shared memory)
Existe una tercera alternativa, que corresponde a una mezcla de las dos
anteriores. Cuando el espacio de memoria es común, la programación de
aplicaciones suele resultar más sencilla, pero la memoria se convierte en un
cuello de botella: se producen grandes atascos de tráfico, provocados por los
procesadores del sistema, que tienen que acceder a la memoria común a
través de una red tipo bus. Cuando la memoria es privada en cada
procesador, este problema desaparece, pero la comunicación entre
procesadores es más compleja y también lo son los modelos de
programación.
Un análisis sencillo de los programas muestra que los procesadores no
hacen un uso homogéneo de la memoria, es decir, no acceden con la misma
probabilidad a cualquier posición de memoria; ello permite pensar en una
alternativa mixta: compartir el espacio de memoria pero distribuirla
físicamente entre los procesadores. La estructura que corresponde a este
modelo mixto es la de la figura anterior, pero todos los procesadores tienen
acceso a todos los bloques de memoria.
Tiene que quedar claro que estamos organizando la memoria principal de
manera jerárquica: los accesos locales serán rápidos, pero los externos
serán mucho más lentos, puesto que hay que salir a la red de comunicación.
Esperamos, en todo caso, que el acceso a la memoria local sea mucho más
frecuente que a la memoria “remota”, y que la red de comunicación se utilice
principalmente para la comunicación entre procesos.
Esta última estructura es la que está obteniendo el mayor éxito y
desarrollo en la actualidad, y habitualmente se conoce como NUMA (Non
Uniform Memory Access) o también como MPP.
▪ 76 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
2.3.4 Clusters, constellations... y otros
Las arquitecturas que hemos citado son las principales, y hacen referencia
al uso de memoria por parte de los procesadores. Es muy habitual que
encontremos todo tipo de mezclas entre ellas. Por ejemplo, en la mayoría de
los supercomputadores actuales los nodos que forman el sistema, y que se
conectan mediante una red de comunicación, no son simples procesadores,
sino pequeños sistemas paralelos SMP con 4-8 procesadores conectados en
un bus. Así, dentro de cada nodo la memoria es compartida, pero la de otros
nodos es privada.
Por otra parte, y tratando de reducir el elevado coste de los
supercomputadores de diseño específico, han aparecido en el mercado con
fuerza los sistemas formados por hardware sencillo y barato: computadores
de propósito general conectados entre sí mediante redes más o menos
sencillas derivadas de las tecnologías de las redes de computadores. En
general, a este tipo de sistemas se les denomina clusters. Así pues, para
formar un cluster se necesita un conjunto de nodos de cómputo y una red de
comunicación (junto con el software de gestión y programación adecuado).
La eficiencia del cluster ejecutando código paralelo será función de ambos,
nodos y red. En el caso más simple, los nodos son simples PCs y la red de
comunicación es (Gigabit) Ethernet. Ese tipo de sistema se conoce como
Beowulf; es la opción más barata, pero también la de menores prestaciones,
aunque ofrece buenos resultados en aquellos casos en los que la
comunicación entre procesos no es relevante.
Para conseguir clusters más eficientes, pueden usarse pequeños sistemasSMP como nodos de cálculo y redes de comunicación más sofisticadas
(Myrinet, Infiniband, Quadrics…); cuando el número de procesadores de
cada nodo del cluster es mucho mayor que el número de nodos, el sistema se
conoce también con el nombre de constellation.
Todos los fabricantes ofrecen hoy en día diversos tipos de clusters en sus
catálogos (custom clusters) como una alternativa interesante para conseguir
máquinas de alto rendimiento a un coste “razonable”. Además, es
relativamente sencillo montar un cluster de no muy alto rendimiento
conectando unos cuantos PC entre sí (commodity clusters).
Sea cual sea la arquitectura del sistema paralelo, en todos ellos es
necesario resolver una serie de problemas comunes para poder lograr un
buen rendimiento. Analicemos brevemente los principales problemas a los
que hay que hacer frente.
2.4 ALGUNOS PROBLEMAS ▪ 77 ▪
2.4 ALGUNOS PROBLEMAS
En cualquiera de sus estructuras, un computador MIMD presenta
numerosos problemas nuevos para resolver. Por ejemplo:
▪ Gestión del sistema: la máquina construida a partir de múltiples
procesadores o, incluso, computadores autónomos, debe aparecer al
usuario como un único sistema integrado. Van a ser necesarios para
ello nuevos sistemas operativos específicos, mecanismos adecuados
para la gestión distribuida de las tareas, nuevas herramientas de
monitorización, controles de seguridad avanzados, etc. Son todas ellas
cuestiones muy importantes, pero no las trataremos en este texto.
▪ Reparto de tareas. ¿Sabemos cómo repartir un programa secuencial
entre P procesadores? En algunos casos será muy sencillo; por
ejemplo, es muy fácil repartir entre N procesadores la ejecución del
bucle do i = 1,N {A(i) = A(i) + 1}; cada uno ejecuta una
iteración del bucle, cualquiera de ellas, ya que todas las iteraciones
son independientes y por tanto da igual cómo se haga. Pero en los
casos más generales puede que no sea sencillo sacar a la luz el
paralelismo inherente a un determinado algoritmo. De hecho, en
muchos casos va a ser necesario desarrollar nuevos algoritmos para
resolver viejos problemas, que saquen partido de las posibilidades de
la máquina paralela. En general, la programación paralela es más
compleja que la programación secuencial o serie.
Junto a ello, es necesario mantener cierto equilibrio en el reparto de
carga de trabajo a los procesadores (load balancing). Si repartimos la
carga %80 - %20 entre dos procesadores, el sistema global no será en
modo alguno dos veces más rápido, ya que la tarea más larga será la
que marque el tiempo final de ejecución. El reparto de carga puede ser
estático —en tiempo de compilación— o dinámico —en tiempo de
ejecución—. El primero es más sencillo y no añade sobrecargas a la
ejecución del programa, pero es más difícil mantener el equilibrio de
la carga de trabajo. El segundo es más costoso en tiempo de ejecución,
pero permite repartos más equilibrados.
▪ Coherencia de los datos. Cuando se utilizan variables compartidas se
cargan copias de dichas variables en las caches de los procesadores.
Cuando se modifica una de dichas copias, ¿cómo se enteran del nuevo
valor de la variable el resto de procesos? es decir, ¿cómo se mantienen
▪ 78 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
"coherentes" los datos compartidos? Como veremos en los próximos
capítulos, la solución depende de la arquitectura del sistema.
▪ Comunicación. Cuando hablamos de paralelismo, la comunicación
entre procesos es el tema principal. Y junto a ello, la red de
comunicación (sobre todo en los sistemas DSM o MPP). En un
sistema paralelo, el tiempo de ejecución de un programa puede
modelarse como:
Tp = Tej + Tcom
donde Tej representa el tiempo de ejecución real y Tcom el de
comunicación. El tiempo de ejecución se reduce (en teoría) con el
número de procesadores, pero el de comunicación en cambio, crece.
La siguiente figura muestra una simplificación de ese compartimiento.
Como se observa en la figura, no siempre es una buena solución
utilizar un número elevado de procesadores, ya que las necesidades de
comunicación pueden echar por tierra cualquier otra ventaja. Es
necesario por ello encontrar un punto de equilibrio.
Un tipo especial de comunicación es la sincronización. Un grupo de
procesadores se sincroniza, por ejemplo, para esperar a que todos
terminen una tarea antes de comenzar con la siguiente. Los procesos
de sincronización pueden generar mucho tráfico en la red y momentos
de gran congestión en el acceso a variables comunes. Analizaremos
este problema un poco más adelante.
Considerando el reparto de tareas y la comunicación, suelen
distinguirse diferentes tipos o niveles de paralelismo:
• paralelismo de grano fino (fine grain): las tareas que se reparten
entre los procesadores son "pequeñas", y la comunicación entre
ellas es muy frecuente, aunque no se intercambian mucha
información.
Núm. procesadores
Tej
Tcom
Tp
2.5 RENDIMIENTO DEL SISTEMA PARALELO (leyes de Amdahl y Gustafson) ▪ 79 ▪
• paralelismo de grano grueso (coarse grain): las tareas que se
reparten entre los procesadores son "grandes", y sólo se comunican
entre ellas de vez en cuando, aunque en esos casos se intercambia
gran cantidad de información.
2.5 RENDIMIENTO DEL SISTEMA PARALELO
(leyes de Amdahl y Gustafson)
El coste de los sistemas paralelos es elevado, y por ello nuestro objetivo
debe ser conseguir ir P veces más rápido cuando se utilizan P procesadores.
Para comparar sistemas de un solo procesador y de P procesadores suelen
utilizarse dos parámetros: el factor de aceleración (speed-up) y la eficiencia
(efficiency).
El factor de aceleración mide cuántas veces más rápido se ha ejecutado un
determinado programa, es decir:
fa = Ts / Tp
donde Ts es el tiempo de ejecución en serie y Tp en paralelo.
Por su parte, la eficiencia se define como:
efic = fa / P (habitualmente en %)
es decir, el tanto por ciento que se consigue del máximo factor de
aceleración posible.
En el mejor de los casos, tendremos que Tp = Ts / P; es decir, que el
programa se ejecuta P veces más rápido usando P procesadores:
fa = Ts / (Ts / P) = P
efic = fa / P = 1
Se trata, en todo caso, de la situación ideal que, debido a múltiples
problemas —reparto no equilibrado de la carga, comunicación,
sincronización...— es difícil de lograr13. En todo caso, aunque no logremos
13 En algunos casos, pueden conseguirse factores de aceleración superlineales, es decir, mayores que P.
En general, son debidos a otros factores, ya que, además de P procesadores, el sistema paralelo
dispone de más memoria, más capacidad de entrada/salida, etc. Tal vez los datos/programas que no
cabían en la memoria de un procesador, sí quepan ahora en todo el sistema, con lo que, como
sabemos, se ahorrará tiempo.
▪ 80 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
que el factor de aceleración sea P, deberíamos conseguir que creciera
linealmente con el número de procesadores (o, lo que es equivalente, que la
eficiencia fuera constante, independiente de P): si se duplica el número de
procesadores, que se duplique también el factor de aceleración.
No todos los programas presentan esas características, ya que no podemos
olvidarnos de la ley de Amdahl. Tal como ha ocurrido en el caso de los
computadores vectoriales, los programas más habituales no pueden
ejecutarse completamente en paralelo: siempre queda una parte del código
que hay que ejecutar en serie (o en un número reducido de procesadores).
Como ejemplo, supongamos que una fracción del código, f, puede
ejecutarse en P procesadores, mientras que el resto, 1–f, debe ejecutarse en
un único procesador. En ese caso, el tiempo de ejecución debe escribirse así:
Tsp = f × Tp + (1–f) × Ts (en general, ∑
=
=
P
i
s
isp i
T
fT
1
)
Si no consideramos el tiempode comunicación, y tomamos el mejor caso,
Tp = Ts / P, entonces el speed-up o factor de aceleración será:
fa = Ts / Tp = P / [ P (1–f) + f ]
Por ejemplo, si P = 1.024 y f = 0,98, entonces fa = 47,7, muy lejos del
hipotético 1.024. Como se muestra en la siguiente figura, el factor de
aceleración se satura, con una asíntota de valor 1 / (1–f), y queda muy lejos
del comportamiento lineal.
2.5 RENDIMIENTO DEL SISTEMA PARALELO (leyes de Amdahl y Gustafson) ▪ 81 ▪
De acuerdo a la ley de Amdahl, el efecto de la parte de código que haya
de ejecutarse en serie es muy grande cuando el número de procesadores es
grande. Si se cumple en la realidad lo que pronostica dicha ley, va a ser muy
difícil conseguir factores de aceleración (speed-up) altos. Como acabamos
de ver en el ejemplo anterior, basta que un 2% del código tenga que
ejecutarse en serie para que el factor de aceleración se reduzca de 1.024 a 47
(a menos del 5%).
Sin embargo, se comprueba que en muchos casos se consiguen
aceleraciones reales mucho mayores que las pronosticadas. ¿Dónde está el
error? Cuando hemos planteado la ley de Amdahl hemos considerado la
siguiente hipótesis: se utilizan P procesadores para hacer que un
determinado algoritmo se ejecute más rápido. Pero en realidad, muchas
veces lo que ocurre es que se utilizan P procesadores para ejecutar un
problema de tamaño más grande en el mismo tiempo. Por ejemplo, se
ejecutan más ciclos de simulación o se hacen análisis considerando una red
de más puntos, etc. En resumen, se mantiene el tiempo de ejecución, no las
dimensiones del problema.
Se ha podido comprobar experimentalmente que cuando se hace crecer el
tamaño del problema (por ejemplo, se usan matrices más grandes) no suele
crecer el tamaño del código que se debe ejecutar en serie (al menos no en la
misma proporción). Esto es equivalente a decir que al crecer el tamaño del
problema crece también f (no es un valor constante). Si es así, para calcular
el factor de aceleración deberíamos comparar estas dos situaciones:
tamaño del problema constante tiempo de ejecución constante
(1–f) Ts f Ts
f Ts × P (1–f) Ts
(1–f) Ts f Ts (1–f) Ts f Ts / P
en paralelo
problema de mayor tamaño
1 procesador
P procesadores
trozo que hay que
ejecutar en serie
trozo que se puede
ejecutar en paralelo
en paralelo
▪ 82 ▪ Capítulo 2: COMPUTADORES PARALELOS (conceptos básicos)
Por tanto, cuando el tiempo de ejecución se mantiene constante:
Ts = (1–f) Ts + f Ts P
Tp = (1–f) Ts + f Ts = Ts → fa = Ts / Tp = (1–f) + f P
La expresión que acabamos de obtener para el factor de aceleración se
conoce como ley de Gustafson, y es lineal con P, lo que asegura que se
pueden conseguir factores de aceleración elevados. Por ejemplo, como en el
caso anterior, si P = 1.024 y f = 0,98, el factor de aceleración que se
consigue resulta ser fa = 1.003,5.
Como comparación, la siguiente figura muestra la evolución con P del
factor de aceleración en su doble versión, para el caso f = 0,9.
En la realidad, y para un programa dado, el factor de aceleración concreto
estará en algún punto entre esos dos extremos.
En los siguientes capítulos vamos a analizar algunos de los problemas que
hay que resolver para poder utilizar de manera eficiente un sistema paralelo
MIMD; entre ellos, la coherencia de los datos (tanto en sistemas SMP como
DSM), la sincronización, el modelo de consistencia, la red de comunicación,
y las estrategias de paralelización de bucles. En el último capítulo
presentaremos brevemente el mercado de sistemas paralelos de alta
velocidad, algunas de las implementaciones de más éxito, así como una
pequeña introducción a las herramientas más utilizadas para programar
aplicaciones paralelas (OpenMP y MPI).
▪ 3 ▪
Coherencia de los Datos
en los Computadores SMP
3.1 PRESENTACIÓN DEL PROBLEMA Y REVISIÓN
DE CONCEPTOS
La velocidad de ejecución de programas que puede alcanzar un
procesador está íntimamente ligada a la estructura y funcionamiento del
sistema de memoria. Desgraciadamente, la velocidad de respuesta de la
memoria principal es significativamente menor que la del procesador, y la
diferencia es cada vez mayor. Por eso, para poder obtener datos e
instrucciones en el menor tiempo posible, la memoria de un computador se
organiza en forma jerárquica: registros, cache interna, cache externa,
memoria principal, (discos...). Cada uno de los niveles es un subconjunto del
nivel superior. Los registros son los más rápidos y cercanos al procesador,
pero su capacidad es pequeña (por ejemplo, 128 registros de 64 bits); en el
extremo opuesto tenemos la memoria principal, de alta capacidad (ya con 2 o
más GB) pero de tiempo de respuesta mucho mayor (p.e., 50 ns).
▪ 84 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
El funcionamiento de la jerarquía de memoria se basa en el hecho de que
el acceso a datos e instrucciones no es aleatorio. Así, podemos utilizar esa
propiedad de los programas para reducir la latencia media de los accesos a
memoria, si vamos llevando a la memoria más rápida los datos que
prevemos va a necesitar el procesador. Ésa es por tanto la función de la
memoria cache: tener preparados los datos (instrucciones) que "pronto" o
con más "frecuencia" utiliza el procesador, ya que el tiempo de respuesta de
la cache es del orden de 5 a 10 veces menor que el de la memoria principal.
Así pues, se copian en la cache de datos del procesador algunos de los
bloques14 de datos de la memoria principal; es decir, el procesador va a
trabajar con copias de los datos.
El hecho de trabajar con copias presenta un nuevo problema en los
multiprocesadores de memoria compartida: hay que asegurar que las
posibles copias de los datos que estén en las caches del sistema sean todas
iguales, es decir, que sean coherentes. De no asegurarse la coherencia de los
datos, los procesos no podrán utilizar variables compartidas, ya que nunca
estarán seguros de sus valores reales.
3.1.1 Coherencia de los datos en los sistemas de un
solo procesador
El problema de coherencia no se presenta exclusivamente en los
multiprocesadores, sino que también aparece en los sistemas con un solo
procesador, ya que también en ese caso se utilizan copias de los datos: una
en la memoria cache y otra en la memoria principal15. El problema sin
embargo no es complicado de resolver, ya que ambas copias están bajo
control del único procesador existente.
Cuando se quiere modificar una palabra de la cache, ¿qué hay que hacer
con las dos copias que existen de dicho bloque? Ya conocemos las dos
políticas de escritura habituales:
▪ Write-through (WT): se actualizan ambas copias, la de la cache y la de
la memoria principal, con lo que el sistema se mantiene siempre
14 El bloque es la unidad de transferencia entre la memoria cache y la memoria principal. Se trata de un
conjunto de palabras consecutivas de memoria (por ejemplo, bloques de 64 bytes: 16 palabras de 32
bits, u 8 palabras de 64 bits), estando el tamaño del bloque directamente relacionado con el nivel de
entrelazado de la memoria. El término inglés para bloque suele ser line.
15 En los procesadores actuales la memoria cache está dividida en dos o tres niveles, por lo que el
número de copias de un determinado bloque de datos puede ser mayor.
3.1 PRESENTACIÓN DEL PROBLEMA Y REVISIÓN DE CONCEPTOS ▪ 85 ▪
coherente. Ello implica que todas las escrituras se efectúan también en
memoria principal, lo que requiere más tiempo.
▪ Write-back (WB): sólo se modifica la copia de la memoria cache, y se
mantiene la memoria principal con el valor antiguo. El objetivo es
reducir el número de accesos a memoria, y con ello el tráfico en el bus
y la latencia de las operaciones. Es la estrategia que habitualmente
usan los procesadores.Por tanto, el sistema de datos no es coherente (ambas copias no son
iguales), y en algunos momentos será necesario recuperar la
coherencia, es decir, actualizar la memoria principal, normalmente al
eliminar el bloque de la cache (por ejemplo, por reemplazo). Para
gestionar los bloques de datos se utilizan algunos bits de control en el
directorio de la cache, que indican el “estado” del bloque de datos. Es
suficiente con dos bits: valid, para indicar que la información
almacenada es útil; y dirty, para indicar que está modificada.
Aunque hemos dicho que es el procesador el único dispositivo que tiene
capacidad de modificar una copia, lo que facilita mucho la gestión de las
mismas, no es estrictamente cierto, ya que en las operaciones de
entrada/salida, por DMA por ejemplo, es un controlador especial el que toma
control del bus y de la operación de escritura. En esa operación se
modificarán varios bloques de datos; ¿qué habría que hacer con las posibles
copias de esos datos en la cache? En algunos casos el problema desaparece
porque se declaran como no “cacheables” los bloques de datos de E/S (nunca
se copian en cache, y todos los accesos se hacen en memoria principal); si no
es así, será el sistema operativo el que tenga que tomar control de esa
operación y mantener la coherencia (flush de la cache). En todo caso, las
operaciones de E/S son de muy baja frecuencia en comparación con las
operaciones del procesador sobre la cache.
3.1.2 Coherencia de los datos en los multiprocesa-
dores de memoria compartida (SMP)
El problema de la coherencia es mucho más peliagudo en los
multiprocesadores de memoria compartida. La comunicación entre procesos
se realiza mediante el uso de variables compartidas. Cada procesador usará
su propia cache local, en la que tendrá copia de dichas variables, por lo que
las copias potenciales de un bloque de datos no serán 2 (las correspondientes
▪ 86 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
a la jerarquía de memoria) sino P+1, siendo P el número de procesadores.
Además, y aquí está el problema principal, cualquier procesador puede
efectuar una modificación en dichos bloques, en su cache local. Tal como
hemos visto, las políticas de escritura WT o WB permiten gestionar la
coherencia de los datos en el caso de un procesador, pero ¿cómo hacer lo
mismo con el resto de las memorias cache del multiprocesador? ¿cómo saber
si otro procesador ha modificado el bloque en su cache, y por tanto ya no es
válida nuestra copia? Por definición, el problema de coherencia sólo existe
con los datos compartidos; con los datos privados el problema se resume al
de un solo procesador.
Como hemos comentado antes, el problema desaparece si se decide no
llevar a las caches las variables compartidas (por ejemplo, las que se utilizan
para la comunicación entre procesos), dado que no se harán copias, pero
dicha decisión puede tener un efecto severo en el rendimiento del sistema, ya
que todos los accesos de los procesadores a dichas variables tendrán que
hacerse en memoria principal: crecerá mucho el tráfico en el bus y, en
consecuencia, debido a los conflictos en el acceso al bus, subirán los tiempos
de respuesta. Algo de ello se muestra en el siguiente ejemplo.
La velocidad de transferencia del bus de un multiprocesador es 1 GB/s y el reloj es de
800 MHz. Los procesadores ejecutan una instrucción por ciclo, y el 2% de las mismas
son operaciones de memoria, LD/ST, sobre variables compartidas. Los datos son de 8
bytes. ¿Cuántos procesadores pueden conectarse en el bus sin llegar a saturarlo si las s
compartidas se dejan en la memoria principal?
En cada segundo hay que transferir 800 106 ciclos × 0,02 instr. (LD/ST) × 8 bytes
= 128 MB por procesador, considerando sólo los datos compartidos.
Por tanto, 8 procesadores generarán un tráfico de 1.024 MB/s para acceder a las
variables compartidas, el máximo que admite el bus.
Ineludiblemente, necesitamos una estrategia que permita disponer de
copias en las caches locales de los procesadores y que éstos las puedan
modificar. Ya sabemos que el uso de las caches (de copias, por tanto) ofrece
dos grandes ventajas: los tiempos de acceso a memoria son menores y se
reduce el tráfico en el bus.
3.1.3 Falsa compartición
El problema de coherencia aparece con las variables compartidas. Las
variables privadas sólo estarán, como mucho, en una cache, y no significan
ningún problema nuevo.
3.1 PRESENTACIÓN DEL PROBLEMA Y REVISIÓN DE CONCEPTOS ▪ 87 ▪
No hay que olvidar, sin embargo, que el control del contenido de la cache,
y el de la coherencia en concreto, se hace por bloques, no palabra a palabra:
se cargan bloques de datos, se borran bloques, se anulan bloque, etc. Por
ello, es posible que un bloque de datos se encuentre en más de un
procesador, aunque todas las variables del bloque sean privadas. Por
ejemplo:
Bloque de datos de 4 palabras
X Y Z T
Aunque las variables son privadas están en el mismo bloque de datos, por
lo que el bloque será compartido y tomará parte en las operaciones de
coherencia. Se dice que hay un problema de falsa compartición (false
sharing). Para evitar este efecto es necesario distribuir los datos en memoria
de manera adecuada y es útil que los bloques de datos no sean muy grandes.
3.1.4 Definición de la coherencia
Decimos que un sistema es coherente si al leer una variable se obtiene
siempre como resultado el último dato que se escribió en dicha variable. En
esta definición no muy formal, se introducen dos conceptos: la propia
coherencia —qué valor se obtiene—, y la consistencia —cuándo se verá en
la variable el valor que ha escrito otro procesador—. Ésta segunda cuestión
la analizaremos un poco más adelante.
Se asegura la coherencia de un sistema de memoria si se cumplen las tres
siguientes condiciones:
1. Desde el punto de vista de un solo procesador, el resultado de una
lectura (LD) debe ser siempre el correspondiente a la última escritura
efectuada por ese procesador en esa variable (siempre que ningún otro
procesador haya modificado dicha variable). Es decir, hay que
respetar el orden entre LD y ST (sobre la misma variable). Se trata de
una condición que también hay que cumplir en el caso de los sistemas
con un solo procesador.
Variables del
procesador Pi
Variables del
procesador Pj
▪ 88 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
2. Considerando todos los procesadores, la operación Pi_rd_A debe
devolver siempre lo escrito por la última operación Pj_wr_A, si es que
ha pasado "suficiente tiempo" desde que se realizó. Ésta es, más o
menos, la definición de coherencia: todos los procesadores tienen que
conocer los cambios producidos en el resto.
3. Las escrituras (cambios) sobre una variable tienen que verse en el
mismo orden en todos los procesadores.
Las estrategias y mecanismos que se han desarrollado para mantener la
coherencia de los datos son diferentes en función de la arquitectura de la
máquina. Como hemos visto en el capítulo anterior, tenemos dos opciones
para los sistemas de memoria compartida: multiprocesadores SMP — de 2 a
16 procesadores conectados en un bus—; o computadores DSM —muchos
procesadores conectados mediante una red de comunicación más
sofisticada—. En el primer tipo de arquitecturas se utilizan protocolos de
coherencia tipo snoopy, mientras que en el segundo se utilizan protocolos
basados en directorios.
En este capítulo vamos a analizar los protocolos de coherencia más
habituales en los sistemas SMP (y en el capítulo 7 analizaremos los
directorios de coherencia).
3.2 PROTOCOLOS DE COHERENCIA SNOOPY
Como hemos comentado en el capítulo anterior, la memoria de los
sistemas SMP está "concentrada" en un solo sitio, y los procesadores utilizan
normalmente un bus16 para acceder a memoria. En este tipo de sistema, la
coherencia de los datos se mantiene por hardware, por medio deun
dispositivo que se conoce como snoopy (fisgón). Puesto que la memoria y
los procesadores se conectan mediante un bus, una red centralizada, todas las
operaciones con la memoria principal son “públicas”, es decir, que cualquier
procesador puede ver lo que otros están haciendo (LD, ST) dado que también
él está conectado al bus. La función del snoopy es justamente ésa: espiar en
todo momento el bus para enterarse de las operaciones que realizan otros
procesadores, y, en su caso, distribuir por el bus información de control. En
16 Vamos a utilizar el modelo más simple de bus, en el que sólo se procesa una petición de uso del bus y
no se admite otra hasta finalizar con la anterior. En general, los buses de los sistemas multiprocesador
son más complejos.
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 89 ▪
función de la información que obtenga, el snoopy decidirá qué hacer con los
bloques de datos que tiene en la cache local.
Cuando se modifica un determinado bloque de datos en la cache, ¿qué hay
que hacer con el resto de posibles copias del mismo en los otros
procesadores? Tenemos dos alternativas:
▪ Invalidar todas las copias de ese bloque que existan en el resto de
memorias cache, y dejar por tanto una única copia, la que se ha
modificado.
▪ Actualizar todas las copias de ese bloque, enviando a través del bus el
nuevo valor de la palabra modificada.
En la siguiente figura aparece un ejemplo de ambas alternativas.
Invalidación Actualización
En el primer caso, el procesador P1 va a modificar la variable A en su
cache, de 4 a 3. Efectúa la escritura y envía una señal de control especial al
bus, INV, para invalidar la copia de P2; como consecuencia de ello, sólo
permanecerá en las caches la copia de P117. En el segundo caso en cambio,
se distribuye a todos los procesadores el nuevo valor de la variable A,
mediante una señal de control especial, BC –broadcast–, para que la
actualicen en su cache. Se mantienen por tanto todas las copias.
Tanto en un caso como en el otro, la memoria principal se actualizará o no
en función de la política de escritura que se utilice: en todas las escrituras si
se usa WT, y sólo en algunas ocasiones si se utiliza WB.
Ya hemos comentado que la coherencia de los datos se mantiene por
bloque, y para ello se añaden algunos bits de control a los bloques de datos
en el directorio de la cache. Mediante esos bits se definen diferentes estados
17 Aunque en el ejemplo sólo aparece una palabra, un bloque contiene siempre varias palabras.
MC1 MC2
wr A,#3
MP
P1
P2
INV A
A = 4→3
A = 4
A = 4→3?
MC1 MC2
wr A,#3
MP
P1
P2
BC A,3
A = 4→3
A = 4→3
A = 4→3?
▪ 90 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
para los bloques. Un autómata finito (el snoopy) se encargará en cada cache
de ir modificando los estados de los bloques en función de las operaciones
que se realicen, tanto desde el procesador local como desde el resto de los
procesadores, sobre los mismos.
3.2.1 Estados de los bloques en la memoria cache y
señales de control
Para mantener la coherencia de los datos en la cache se suelen utilizar
cinco estados. No es necesario utilizarlos todos, y en muchos casos sólo se
usan algunos de ellos, como vamos a ver. Los estados se definen de acuerdo
a dos características: el número de copias de un bloque y si el bloque es o no
coherente (igual) con la copia de memoria principal (los nombres de los
estados pueden variar de máquina a máquina).
I Inválido (invalid)
Un bloque está en estado I si la información que contiene no es válida;
es lo mismo que si no estuviera en la cache (un fallo de cache).
Para indicar que un bloque no está en la cache, utilizaremos también
el símbolo (-). Por ejemplo, cuando se reemplaza un bloque no se
anula, simplemente desaparece. En definitiva, ambos casos, I o (-),
son completamente equivalentes.
E Exclusivo (exclusive, reserved)
Un bloque está en estado E si se trata de la única copia en todas las
caches del multiprocesador y si además su contenido es el mismo que
el del bloque en memoria principal, es decir, es coherente.
M Modificado (modified, dirty, exclusive rd/wr)
Un bloque en estado M es la única copia existente en el
multiprocesador, pero no está actualizado en memoria principal: se ha
escrito en la cache pero no en memoria principal (write-back).
S Compartido (shared, shared rd only)
Existen (o pueden existir) múltiples copias de dicho bloque en el resto
de las caches del multiprocesador, y todas las copias son iguales entre
sí y, normalmente, iguales con la copia de memoria principal
(coherentes).
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 91 ▪
O Propietario (owner, shared dirty)
Existen (o pueden existir) múltiples copias de dicho bloque en el resto
de las caches del multiprocesador, pero, aunque entre ellas son
iguales, el bloque no está actualizado en memoria principal. La copia
en estado O será la encargada, en su momento, de actualizar la
memoria principal y mantener así la coherencia (por ejemplo, al ser
reemplazada). El resto de copias, si existen, se encuentran en estado S
(atención, esas copias no son coherentes con memoria principal).
Para definir los estados del bloque basta con usar tres bits. Por ejemplo:
válido
(valid)
modificado
(dirty)
compartido
(shared)
Estado
0 – – I
1 0 0 E
1 0 1 S
1 1 0 M
1 1 1 O
Como hemos comentado al principio, los dos primeros bits son los
mismos que se utilizan en los sistemas de un solo procesador, por lo que, dfe
momento, sólo se añade un bit más al directorio.
Una máquina de estados finitos en cada procesador, el snoopy, se encarga
de mantener los estados de los bloques de datos en la cache de acuerdo a la
definición anterior, para lo que hay que tomar en consideración las
siguientes acciones:
1. Acciones del procesador local
PR: processor read
Se lee una variable (en un bloque de datos). Si el bloque está en
la cache (acierto), no hay que hacer nada; pero si no está (fallo)
hay que generar una petición de lectura de ese bloque (BR, bus
request).
PW: processor write
Se escribe en una variable (un bloque de cache). En general, hay
que avisar a los otros procesadores, para que actualicen el estado
de dicho bloque en su cache: INV (invalidar) o BC (actualizar),
en función del tipo de protocolo. Además, si ha sido un fallo, hay
que pedir el bloque de datos correspondiente.
▪ 92 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
2 Acciones que se observan en el bus (señales de control enviadas por
los otros snoopy), como resultado de operaciones de otros
procesadores. El número, nombre y tipo de las señales depende de la
implementación del protocolo. En nuestro caso, usaremos las
siguientes:
BR: bus read
Un procesador quiere leer una palabra y se ha producido un fallo
en su cache (no está). Tienen por tanto que conseguir el bloque
correspondiente, y para ello se genera esta petición (BR en el bus
de control, y la dirección en el bus de direcciones). Todos los
snoopy locales tienen que considerar esta señal para adecuar el
estado del bloque (si tienen una copia del mismo).
INV: invalidate [ en los protocolos de invalidación ]
Se escribe una palabra en la cache y, por tanto, hay que eliminar
todas las copias de dicho bloque. Se envía al bus de control la
señal INV y al bus de direcciones la dirección del bloque a
anular. Todos los snoopy tienen que responder adecuadamente a
la señal, anulando, en su caso, la copia del bloque.
BC: broadcast [ en los protocolos de actualización ]
Se escribe una palabra en la cache, por lo que hay que actualizar
todas las copias de dicha variable. Se activa la señal BC en el bus
de control, y se pone la dirección de lavariable en el de
direcciones y nuevo valor en el de datos). Todos los snoopy
tienen que responder adecuadamente a la señal, actualizando, en
su caso, la variable correspondiente.
En algunos casos hay que activar más de una señal de control; por
ejemplo, en un fallo en escritura: hay que solicitar el bloque de datos
(BR) y anular o actualizar el resto de copias (INV o BC). Las señales que
acabamos de definir son simplemente una opción, y las implementaciones
de las mismas pueden ser diferentes; por ejemplo, en lugar de activar dos
señales de control a la vez, puede utilizarse una tercera señal que indique
ambas acciones: RdEx (o BRinv), "lectura exclusiva".
3. Otras señales de control
Las acciones anteriores tienen como consecuencia que se modifique el
estado de los bloques en la cache. Además de ellas, también
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 93 ▪
aparecerán en el bus las siguientes acciones, que no tienen efecto
sobre el estado de los bloques:
BW: bus write
Un procesador va escribir un bloque entero de datos en memoria
principal. Esto va a ocurrir en los casos en los que la política de
escritura sea write-back, cuando es necesario actualizar datos o
en los reemplazos de bloques modificados.
BW*: Un procesador va a escribir una palabra en memoria principal
(estamos usando por tanto WT). Esta señal de control no es
estrictamente necesaria, ya que puede utilizarse para ello la señal
INV (o BC), porque al escribir la memoria principal también hay
que invalidar (o actualizar) el resto de copias (usaremos el * para
indicar una transferencia de sólo una palabra).
Un protocolo de coherencia snoopy es un algoritmo distribuido en el que
colaboran P autómatas finitos distribuidos en P procesadores. Utilizando los
estados que acabamos de describir, permite trabajar con múltiples copias de
un bloque de datos. El snoopy debe controlar las peticiones y avisos que le
lleguen de su procesador local o del resto a través del bus, y, en función de
ellas, decidir el estado de los bloques de datos y generar las señales de
control adecuadas.
Pueden definirse muchos algoritmos de coherencia diferentes, utilizando
algunos o todos los estados anteriores, y diferenciándose entre ellos por la
política de escritura: write-through, write-back, o mezclas de ambos (en
función del número de copias, del número de escrituras, de la jerarquía de
cache, etc.). En muchos textos, el nombre de estos protocolos hace referencia
a los estados que utilizan: MESI, MOSI, etc.
3.2.2 Protocolos de invalidación
Cuando se realiza una escritura en un bloque, y la coherencia se mantiene
mediante un protocolo de invalidación, se eliminan todas las copias de ese
bloque que haya en el sistema. Los protocolos más simples son de sólo dos
estados (I-E o I-S) y utilizan como política de escritura WT, pero no son
demasiado eficientes. Por ello, vamos a analizar protocolos de al menos tres
estados y que utilizan WB como política de escritura siempre que es posible.
▪ 94 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
3.2.2.1 Un protocolo de tres estados, MSI (Silicon Graphics)
Uno de los protocolos más comunes de tres estados es el que se ha
utilizado en algunos de los computadores de Silicon Graphics. Los posibles
estados de los bloques en cache son I, M y S; la política de escritura es, por
tanto, write-back (se utiliza el estado M), aunque no se admite más de una
copia de un bloque que no esté actualizado en la memoria principal.
Para definir un protocolo de coherencia (una autómata de estados finitos),
hay que definir las transiciones entre los estados de los bloques de datos y
las señales de control que se generan en dichas transiciones, en función, por
un lado, de las acciones del procesador local (PR y PW), y, por otro, de las
acciones del resto de procesadores, reflejadas en las señales de control que se
detecten en el bus (BR e INV). Todo ello se refleja en la siguiente tabla:
Estado
presente
Estado siguiente / Señales de control
PR PW BR INV
fa
llo
I, - S BR M BR,INV
ac
ie
rto
S S M INV S I
M M M S BW I BW
Tráfico (datos)
MP → MC: BR // I → S, M
MC→ MP: BW // M → S, I (+reemplazo)
Los protocolos también se pueden representar mediante un grafo, tal como
aparece en la siguiente figura.
PW (BR,INV)
PR (BR)
PW (INV)
INV (BW)
BR (BW)
INV
PR - PW
PR - BR
M
S
I, -
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 95 ▪
Las líneas continuas (letra en negrita) representan las transiciones
generadas por acciones del procesador local, y las flechas discontinuas las
que se producen como consecuencia de las señales de control que aparecen
en el bus, debidas a lecturas y escrituras de otros procesadores. Entre
paréntesis y en cursiva aparecen las señales de control que se envían al bus.
Cuando el procesador lee una variable que está en la cache (PR), el estado
del bloque no se modifica ni se generan señales de control. En cambio, si la
variable no está en la cache (I), hay que pedir el bloque de datos
correspondiente, generando para ello la señal de control BR; una vez que
obtengamos el bloque, se carga en la cache en estado S (no se puede poner
en estado M porque no ha sido modificado).
En caso de escritura (PW), el estado del bloque pasará a ser M: una única
copia y modificada (write-back). Si ya estaba en estado M no hay que hacer
nada; pero si estaba en estado S hay que invalidar todas las posibles copias18
(mediante la señal de control INV). Si la escritura ha sido un fallo (estado I,
la variable no está en la cache), antes de escribir se debe conseguir el bloque,
en modo exclusivo, para lo que se generan las señales de control BR e INV
(BR: leer el bloque + INV: invalidar todas las copias).
Veamos ahora las consecuencias de las operaciones realizadas por otros
procesadores y que se detectan en el bus. Un procesador ha solicitado un
bloque de datos, para lo que activado la señal BR. El bloque solicitado podría
estar en la cache local, en estado S o M. Si está en estado S, no hay que
hacer nada: a las copias que ya había antes, coherentes, se le añade una más.
Pero si está en estado M, es decir, si la copia local es la única y no está
actualizada, hay que modificar su estado. A partir de ahora habrá dos copias
en el sistema, y la única opción en este protocolo es pasar al estado S, es
decir, pasar a ser coherente: el nuevo estado es S y hay que actualizar
(escribir) el bloque en la memoria principal (BW).
Finalmente, si se detecta la señal INV en el bus, la decisión es muy
simple: si el bloque de datos está en la cache, hay que eliminarlo (I). Dado su
efecto, la señal de invalidación INV tiene preferencia frente a la señal BR
cuando ambas se activan a la vez. Como la política de escritura es write-
18 El estado S no implica que necesariamente tenga que haber más copias en el sistema; es decir,
aunque es seguro que en algún momento sí ha habido más de una copia, pueden haber sido
reemplazadas todas ellas, quedando una sola copia, en estado S. Además, en este protocolo la primera
copia también se carga en estado S, ya que no se utiliza el estado E (una sola copia).
▪ 96 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
through, es posible que el bloque que hay que borrar esté en estado M, en
cuyo caso habrá que actualizar su contenido en MP.
▪ Tráfico en el bus compartido
El tráfico que se genera en el bus que conecta los procesadores y la
memoria en un multiprocesador es un aspecto crítico en el rendimiento del
sistema. Dado que es un recurso compartido, el bus puede saturarse; en ese
caso, la latencia de las comunicaciones con memoria crecerá, y la velocidad
de cálculo bajará. Por ello, un protocolo de coherencia adecuado debe
intentar reducir dicho tráfico, para que se pueda conectarel mayor número
de procesadores al bus.
En la parte inferior de la tabla de transiciones de estados del protocolo se
muestra el tráfico en el bus de datos. Hay que transferir un bloque de datos
en estos dos casos: de memoria principal a memoria cache al generarse la
señal BR (es decir, cuando un bloque pasa de estado I a S o M); y de
memoria cache a memoria principal cuando se genera la señal BW (cuando
un bloque en estado M pasa a estado S, se anula o se reemplaza).
▪ ¿De dónde se traen los bloques de datos?
Cuando hay que cargar un bloque en la cache, normalmente se traerá de
MP. Sin embargo, en algunos casos ese bloque se puede traer de alguna otra
cache (porque hay una copia del mismo). Esta posibilidad no disminuye el
tráfico en el bus, pero sí el tiempo de acceso, porque traerlo desde otra cache
va a ser más rápido. En cualquier caso, hacer esto genera una interferencia
en el funcionamiento de otro procesador (mientras se está realizando la copia
de cache a cache, no podrá utilizar su memoria cache), además de necesitar
un arbitraje para escoger una determinada copia, por lo que habitualmente se
trae el bloque de MP.
Si el bloque que se quiere traer está en estado M en otra cache, el snoopy
tiene que conseguir esa copia, ya que la MP no está actualizada. Como en el
caso anterior, tenemos dos opciones: actualizar primero la MP, y luego leer
ahí el bloque; o copiar el bloque en la cache que lo necesita a la vez que se
está actualizando la MP (por tanto, el bloque está en el bus):
(a) MC1 (M) → MP → MC2 o (b) MC1 (M) → MP
→ MC2
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 97 ▪
La segunda opción es bastante más adecuada ya que reduce a la mitad el
tráfico en el bus y la latencia de la operación.
En el caso de que quien solicita el bloque vaya a efectuar una escritura, se
podría eliminar la escritura del bloque en MP, y efectuar únicamente la
transferencia MC1 (M) → MC2 (M), ya que, después de todo, se va a cargar
en la cache y se va a modificar.
3.2.2.2 El protocolo Illinois, MESI (Papamarcos&Patel, 84) (M88100)
Veamos otro conocido protocolo, Illinois, utilizado (con algunas
modificaciones) en los procesadores Pentium, PowerPC y MIPS R4400. Es
una mejora del protocolo anterior, al que se le añade un cuarto estado, E, con
el objetivo de minimizar el número de invalidaciones.
El estado E nos asegura que en todo el sistema sólo hay una copia del
bloque (recordemos que el estado S no distingue entre el número de copias
que hay del bloque) y que, además, es coherente con la información que hay
en la memoria principal. Para distinguir entre los estados E y S se introduce
una nueva señal de control en el bus –sh (shared)–, que indica si un bloque
concreto se encuentra en alguna otra cache o no (es decir, si se está cargando
una copia única o ya había al menos una copia previamente en el sistema).
La tabla de transiciones correspondiente al autómata de coherencia es la
siguiente:
Estado
presente
Estado siguiente / Señales de control
PR PW BR INV
fa
llo
I, - nsh: E sh: S BR M BR,INV
ac
ie
rto
E E M S I
S S M INV S I
M M M S BW I BW
Tráfico (datos)
MP → MC: BR // I → E, S, M
MC → MP: BW // M → S, I (+reemplazo)
▪ 98 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Comparado con el caso anterior, la principal diferencia estriba en que un
bloque que se lee (BR) se carga en la cache en estado E (coherente) si
sabemos que es la única copia de dicho bloque en todo el sistema, es decir, si
no está en ninguna otra cache; si no, se cargará en estado S. Para saber si hay
copias o no, se utiliza la línea de control sh, de manera que cuando aparece
en el bus la señal BR todos los snoopy mirarán en los directorios de sus
caches para comprobar si tienen una copia de ese bloque o no, y, en caso
afirmativo, activarán la señal sh. Por tanto, si sh = 1 existen copias del
bloque (al menos una) en otras caches, y si sh = 0 (nsh, not shared), se está
cargando en el sistema la primera copia de dicho bloque.
Cuando se carga en el sistema la segunda copia, ambas pasarán a estar en
estado S, y a partir de ahí la evolución del bloque será la misma que la que
hemos analizado en el protocolo anterior.
El objetivo del estado E es distinguir los bloques privados (siempre serán
copias únicas) de los compartidos, y, así, reducir el tráfico en el bus. Si se
hace una escritura sobre un bloque que está en estado E, el bloque pasará al
estado M, sin generar tráfico (si hubiera estado en estado S, tendríamos que
haber activado la señal de invalidación junto con la dirección del bloque). Es
decir, no se envía la señal INV cuando no hay copias del bloque que se va a
modificar. No hay que olvidar que la mayoría de los bloques de datos serán
privados, y sólo algunos de ellos serán compartidos.
PW (BR,INV)
INV (BW)
INV
PW (INV)
BR (BW)
PW
PR
PR - PW
BR
PR - BR
I, -
S
M
E
PR (BR)
INV
sh nsh
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 99 ▪
3.2.2.3 El protocolo Berkeley, MOSI
Como último ejemplo de protocolos de invalidación, analicemos el
protocolo Berkeley; utiliza los estados I, M, S y O, y la política de escritura
es write-back "siempre". Recuerda que el estado O (propietario, owner) se
utiliza para poder tener múltiples copias de un bloque no coherente con
memoria principal (en los dos protocolos anteriores sólo se permitía una
copia no coherente, en estado M). Como no se diferencian los estados E y S,
no se usa la señal sh. La tabla de transiciones y el grafo del protocolo son
las siguientes:
Estado
presente
Estado siguiente / Señales de control
PR PW BR INV
fa
llo
I, - S BR M BR,INV
ac
ie
rto
S S M INV S I
M M M O I BW
O O M INV O I BW
Tráfico (datos)
MP / MC → MC: BR // I → S, M
MC → MP: BW // M, O → I (+reemplazo)
Con el nuevo estado O es posible tener múltiples copias de un mismo
bloque no coherentes con MP pero coherentes entre sí (esto permite utilizar
una política de escritura write-back en todos los casos). En el protocolo
anterior había que efectuar la transición M → S cuando era requerida una
PR - BR
I
O
BR
PR - BR
PW (BR,INV)
INV (BW)
INV
PW (INV)
INV (BW)
PR (BR)
PW (INV)
PR - PW
M
S
▪ 100 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
segunda copia de un bloque; ahora, en cambio, el cambio de estado será M
→ O, y no se actualizará la MP. Con esto se consigue no tener que actualizar
un bloque en MP hasta que el bloque sea invalidado o reemplazado.
Cuando un bloque está en estado M en una cache, y se produce una
lectura de ese bloque en otra cache, el primero pasará a estado O
(propietario) y la copia nueva se cargará en estado S. ¡Cuidado! El estado S
no implica que el bloque sea coherente con memoria principal. Si las copias
de ese bloque están en estado S en todas las caches del multiprocesador,
entonces serán coherentes con MP; pero si una de las copias está en estado
O, entonces las copias serán coherentes entre sí, pero no lo serán con MP.
Cuando una copia está en estado O y tiene que ser reemplazada, el snoopy
correspondiente tiene que actualizar la MP; tras ello, el resto de copias de
ese bloque, que estarán en estado S, retomarán la definición inicial de estado
S (coherentes con MP).
En lo que al tráfico se refiere, no hay ningún cambio sustancial respecto al
protocolo anterior. Cuando se tiene que cargar un bloque en estado S en una
cache (fallo en lectura), hay que tener en cuenta dos posibilidades. Si el
bloque no existe en ninguna otra cache, necesariamente habrá que traerlo de
MP; pero si está en alguna otra cache en estado O o M, habrá que traerlo de
esa cache, ya que la MP no está actualizada. El controlador de coherencia de
esa copia deberá de responderadecuadamente a la petición, pasando los
datos de la memoria cache al bus para que se puedan leer y cancelando la
lectura que se había solicitado a la MP.
3.2.2.4 Resumen de los protocolos de invalidación
En los apartados anteriores hemos analizado algunos protocolos de
coherencia de invalidación, en los que las copias de un bloque se invalidan
cuando se modifica una de ellas. Entre ellos se diferencian por los estados
que utilizan, la política de escritura, etc.
No hemos descrito todos los que existen, ni mucho menos. No hay
problema, por ejemplo, para definir un protocolo de invalidación que utilice
los cinco estados. Otro ejemplo bastante conocido es el protocolo write-
once, en el que se utilizan ambas políticas de escritura (WT y WB) según los
casos: WT cuando se escribe por primera vez en el bloque y WB para las
sucesivas escrituras. También se pueden definir protocolos que tienen en
cuenta la jerarquía de memoria a la hora de definir los estados (por ejemplo,
el procesador Alpha). Todos ellos se dejan como ejercicio.
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 101 ▪
3.2.3 Protocolos de actualización
Otros protocolos que se utilizan para gestionar las diferentes copias de un
bloque que puede haber en un multiprocesador de memoria compartida se
engloban dentro del grupo de protocolos de actualización. En los protocolos
de invalidación, cuando una de las copias se va a modificar se elimina el
resto de las copias. Ahora, en cambio, se van a mantener el resto de las
copias, pero actualizadas. El control, snoopy, de la cache que va a hacer la
escritura deberá informar al resto de las caches del cambio realizado, y éstas
actualizarán el bloque con el nuevo valor de la variable.
Para poder actualizar una variable en el resto de las caches, se utiliza una
señal de control denominada BC (broadcast), y junto con ella se pondrá en el
bus la dirección de la variable y el nuevo dato. Los controladores de las
caches procesarán esas señales y, cuando corresponda, realizarán los
cambios asociados a la escritura, tanto en el valor de la variable como en el
estado del bloque.
A pesar de que pudiera parecer que siempre obtendríamos mejores
resultados con este tipo de protocolos, en realidad va a depender de la
aplicación que se esté ejecutando. En los casos en los que un bloque de datos
se reutilice sistemáticamente en los diferentes procesadores, la actualización
será más eficiente, pues se mantiene la copia del bloque en las caches; en
cambio, si el bloque no se va a reutilizar, quizás se esté actualizando ese
bloque sin sacar ningún rendimiento a esas actualizaciones (hubiera sido
mejor invalidar el bloque la primera vez). No hay que olvidar que para
transmitir los datos de la actualización hay que utilizar el bus y, además,
mientras se está actualizando una cache el procesador local no puede
utilizarla y deberá esperar.
Los protocolos de actualización no invalidan los bloques y, por tanto, no
utilizan el estado I. Sin embargo, es necesario utilizar el estado I para otras
cuestiones, tales como, por ejemplo, para invalidar los bloques de datos en
los cambios de contexto o en migraciones de procesos. Por ello, para
representar el caso de que un bloque no esté en la cache utilizaremos (I, -).
Veamos dos protocolos de actualización bastante conocidos.
3.2.3.1 El protocolo Firefly, MSE(I) (Archibald & Baer 85) (DEC)
Este protocolo de actualización utiliza los estados E, M y S. La política de
escritura es write-back con los bloques privados y write-through con los
▪ 102 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
compartidos. Esto es, cuando sólo hay una copia de un bloque, en las
escrituras no se actualiza la memoria principal; en cambio, cuando hay
varias copias del bloque, todas las escrituras actualizan también la memoria
principal. Como el protocolo distingue entre los estados E y S, el bus de
control cuenta con la señal sh (shared): sh = 1 → hay copias de dicho
bloque en alguna otra cache; sh = 0 → no hay copias.
Las transiciones entre estados y las señales de control de este protocolo se
muestran en la siguiente tabla y en el grafo correspondiente:
Estado
presente
Estado siguiente / Señales de control
PR PW BR BC
fa
llo
- nsh: E
sh: S
BR nsh: M
sh: S
BR
BR,BC
ac
ie
rto
E E M S S
S S nsh: E
sh: S
BC S S
M M M S BW S BW
Tráfico (datos)
MP / MC → MC: BR // (I) → E, S, M
MC → MP: BW // M → S (+reemplazo)
MC → MC*MP*: BC // (I) → S(wr); S → E, S(wr)
Cuando se va a cargar un bloque nuevo en una cache, el estado del bloque
va a depender de la señal sh. Si se detecta que no hay copias del bloque en
el sistema (nsh), el estado será E (en las lecturas) o M (en las escrituras); a
M
E PR
BR - BC
BR (BW)
PW (BC)
PR
PW
S
PR - PW
nsh
sh
BR
PR (BR)
PW (BR)
( - )
PW (BC) nsh
nsh
sh
( - )
sh
(BC)
3.2 PROTOCOLOS DE COHERENCIA SNOOPY ▪ 103 ▪
ambos estados, que indican que sólo hay una copia del bloque de datos, se
les aplica la política de escritura write-back. En cambio, si se detecta que hay
una o más copias del bloque en el sistema (sh), el estado del nuevo bloque
será S, y la posterior política de escritura será write-through, que mantiene
coherentes la memoria principal y las memorias cache.
Del mismo modo, si se escribe sobre un bloque que está en estado S, se
elegirá entre E o S en función de la señal sh. Ten en cuenta que aunque el
bloque esté en estado S (compartido), puede ser que en ese momento sea la
única copia si se han reemplazado las demás; aprovechamos así la escritura
para actualizar el estado (aunque sólo quede una copia, el estado será E y no
M, porque la política de escritura con las copias compartidas es siempre la
misma: hay que actualizar la memoria principal).
Desde el punto de vista del tráfico, el caso más interesante es la transición
(I, -) → S. Si es consecuencia de una lectura, entonces hay que traer el
bloque a la cache, bien desde MP o bien desde otra cache. Si el resto de las
copias son coherentes (E, S), normalmente se traerá de MP; si no son
coherentes (M), entonces antes de traer el bloque (o a la vez) habrá que
actualizar la memoria principal. Por otro lado, en las transiciones (I, -) → S,
cuando son consecuencia de una escritura, además de traer el bloque hay que
actualizar la memoria principal y todas las copias del mismo. Por tanto,
cuando se genera la señal BC también hay que actualizar (una palabra) la
memoria principal (es decir, cumple la misma función que la señal BW* que
definimos anteriormente). Por ello, para reducir el tráfico de actualización,
en el caso escritura/fallo antes de generar la señal BC se espera a obtener la
respuesta de la señal sh; si no, podríamos genera la señal BC desde el
comienzo de la operación.
3.2.3.2 El protocolo Dragon, MOES(I) (McCreight 84, Xeroc Parc Dragon)
En este protocolo se utilizan todos los estados: E, M, S y O (una variación
de este protocolo se utiliza en las máquinas Sun Sparc/Server). Al igual que
en el protocolo Berkeley, el estado O permite aplicar la política write-back
en todos los casos. Se mantiene la señal sh (shared), para distinguir entre
los estados E y S y así poder reducir el número de actualizaciones (BC).
En la siguiente tabla y su grafo correspondiente se presenta este protocolo.
▪ 104 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Estado
presente
Estado siguiente / Señales de control
PR PW BR BC
fa
llo
- nsh: E
sh: S BR
nsh: M
sh: O
BR
BR,BC
ac
ie
rto
E E M S S
S S nsh: M
sh: O
-
BC S S
M M M O S
O O nsh: M
sh: O
-
BC O S
Tráfico (datos)
MP / MC → MC: BR // (I) → E, S, M, O
MC → MC*: BC // (I), S, O(wr) → O
[ MC → MP: reempl. // M, O → (I) ]
Tal ycomo se muestra en la tabla, comparado con el caso Firefly hay
pocos cambios: se admiten varias copias de bloques sin actualizar entre
diferentes copias, y por eso aparecen las transiciones M/S → O.
Recuerda que MC → MC* representa la transmisión de una palabra de
una cache a otra; esto es, una operación de broadcast para actualizar las
copias del bloque.
PW (BR)
BR
BC
PR
BR - BC
BC
PW PW sh (BC)
PR - BR
BR
PR - PW
PW
PW
PR
M O
E S
nsh
PR (BR)
( - )
nsh
sh
sh
(BC)
( - )
nsh sh
(BC)
nsh
3.3 IMPLEMENTACIÓN DE LOS PROTOCOLOS SNOOPY ▪ 105 ▪
3.2.4 Resumen de los protocolos de tipo snoopy
En los párrafos anteriores hemos presentado los principales protocolos de
tipo snoopy, tanto los de invalidación como los de actualización. A pesar de
que al principio surgieron muchos protocolos diferentes, hoy en día los
principales son los que hemos comentado (o variantes de los mismos).
Además, debido a ciertos inconvenientes en la implementación, los
protocolos de actualización casi no se utilizan. Por tanto, los más utilizados
son los protocolos de invalidación de 3 o 4 estados, en los que se utiliza la
política de escritura write-back.
3.3 IMPLEMENTACIÓN DE LOS PROTOCOLOS
SNOOPY
3.3.1 Problemas
Para mantener la coherencia de datos en un multiprocesador basado en un
bus es suficiente un sistema de tipo snoopy. Los autómatas que hemos
analizado (o variaciones de los mismos) son los que se utilizan en todos los
multiprocesadores. La “lógica” que tienen que ejecutar los controladores de
coherencia es bastante simple, tanto en el caso de invalidación como en el de
actualización. Pero la implementación distribuida de esa lógica da lugar a
nuevos problemas, que hacen que no sea inmediato conseguir dispositivos
sencillos, eficientes y correctos. Por supuesto, el snoopy debe funcionar
correctamente en cualquier situación, ya que de no ser así no podremos
asegurar la coherencia de los datos y, por tanto, disponer de sistemas
paralelos eficientes de memoria compartida.
Un snoopy es un autómata distribuido que se ejecuta en P procesadores.
Esto es lo que produce problemas, ya que hay que coordinar el
funcionamiento de todos los controladores para, al final, obtener el resultado
correcto. Dentro de los problemas que aparecen, la falta de atomicidad es,
probablemente, el más importante: la necesidad de asegurar que no se
mezclarán, en el tiempo, operaciones de coherencia de dos (o más)
procesadores sobre un mismo bloque, produciendo resultados incorrectos.
Para mostrar los problemas y las soluciones, analicemos cómo se organiza
un controlador de coherencia para un caso “real”. En la figura se muestra el
esquema de un controlador de coherencia (simplificado). Analicemos sus
componentes principales.
▪ 106 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
3.3.1.1 Directorio de la memoria cache
Las operaciones que se realizan en las memorias cache de los
multiprocesadores pueden provenir de dos orígenes diferentes. Por un lado,
de las acciones del propio procesador local; y por otro, de las acciones que
aparecen en el bus compartido. Por tanto, tendremos interferencias entre
ambas fuentes. Por ejemplo, ¿qué se debe hacer cuando se ve una señal INV
en el bus, si en ese instante el procesador está utilizando la memoria cache?
(o viceversa).
Para conseguir un mejor rendimiento, normalmente el controlador de la
cache se divide en dos partes: una analiza lo que está pasando por el bus
(snoopy), y la otra procesa las peticiones del procesador. Ambas partes
tienen que utilizar el directorio de la cache, y hacerlo con el menor número
de interferencias posible: si el procesador está utilizando la cache, el snoopy
se retrasará (y, como consecuencia, todas las transferencias de los demás
procesadores); si es el snoopy el que está utilizando la cache, entonces será
Cache data RAM
tags +
state
snoopy
Bus side
controller
compar.
Processor
side
controller
P
compar.
Cmd
Addr
Addr
Cmd
tags +
state
proc.
Write-back buffer
Data buffer
tag
state
system bus
to controller
data
to controller
addr contr
MC
3.3 IMPLEMENTACIÓN DE LOS PROTOCOLOS SNOOPY ▪ 107 ▪
el procesador el que tendrá que esperar. Por esto, normalmente, el directorio
de la cache suele estar duplicado (o se utiliza una memoria de doble
puerto), y cada parte del controlador utiliza su correspondiente directorio. De
este modo, las operaciones (búsquedas, por ejemplo) se pueden hacer en
paralelo en los dos directorios. Eso sí, los dos se deben mantener coherentes,
es decir, si se realiza un cambio en uno de ellos, se debe de realizar también
en el otro (si existen colisiones al hacer esto, una de las operaciones se
deberá retrasar). Por suerte, las modificaciones —escrituras— de los
directorios son mucho menos frecuentes que las lecturas. Los datos, por
supuesto, no se duplican: ocupan mucho espacio y los controladores los
utilizan con frecuencia mucho más baja.
3.3.1.2 Búferes de escritura
Cuando la política de escritura es write-through, hay momentos en los que
se debe de actualizar un bloque de datos completo en memoria principal,
bien porque se invalide o se reemplace (M → I, -), o bien para mantener la
coherencia (M → S). Por ejemplo, supongamos que se debe reemplazar un
bloque que está en estado M. Antes de traer el nuevo bloque, hay que
guardar el viejo en la memoria, y esto implica un tiempo durante el cual el
procesador está parado. Una mejora bastante común es hacer lo siguiente: en
lugar de efectuar las dos operaciones en este orden <actualizar (BW) / leer el
bloque nuevo (BR)>, se efectúan en el orden contrario: primero traer el
bloque nuevo y, después, actualizar el bloque viejo en MP. Para poder hacer
las operaciones en este orden, primero hay que realizar una copia del bloque
en estado M que se quiere sustituir (si no, se perdería esa información); esta
copia se hace en el búfer de escritura. Una vez que el bloque nuevo ya está
en la cache y el procesador en marcha, se actualizará la MP con el bloque
que está cargado en el búfer de escritura, normalmente aprovechando ciclos
libres del bus.
Esta mejora es común también en los sistemas de un solo procesador, pero
en los multiprocesadores los búferes de escritura se deben de tratar con
cuidado. Cuando un snoopy tiene que efectuar una búsqueda para saber si un
determinado bloque está en la cache, además de en el directorio de la cache
deberá buscar también en el/los búfer/es de escritura. Por tanto, el hardware
de búsqueda, los comparadores, se debe duplicar: uno para el directorio de la
cache y otro para cada búfer de escritura (figura anterior).
▪ 108 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
3.3.1.3 Protocolo de petición de bus
Cuando un procesador pone una petición en el bus, por ejemplo BR, debe
esperar a la respuesta de los demás snoopy, por si existe alguna copia de ese
bloque en otra cache: ¿se debe traer el bloque de memoria, o hay que traerlo
de otra cache, que lo ha modificado? En ese caso, ¿cuánto tiempo hay que
esperar hasta estar seguro de que todos han respondido? Las estrategias más
utilizadas para controlar el tiempo de espera son las siguientes:
• Esperar un tiempo fijo preestablecido, hasta estar seguros de que todos
los snoopy ya han respondido. Por supuesto, es el caso peor, ya que la
decisión se toma en el tiempo máximo (será el hardware del sistema el
que determine ese tiempo), pero, a cambio, es el método más simple
para implementar (Pentium quad/HP/SUN).
• Esperar un tiempo variable (un handshake). Para reducir el tiempo de
espera, se detecta cuándo responde el último snoopy, y la decisión se
toma en ese momento. Así, en la mayoría de los casos la decisión se
toma antes del tiempo máximo,pero es complejo de implementar, ya
que se deben detectar y controlar las respuestas de todos los
dispositivos.
Tanto en el primer caso como en el segundo, una optimización típica
consiste en que, mientras se está esperando, se comienza con la lectura
de memoria; y luego, dependiendo del caso, se aborta el acceso a
memoria (si es que todavía no había terminado) o se bloquea la
respuesta de la memoria hasta que todos los snoopy respondan (SGI
challenge).
• Añadir un bit más a todos los bloques de datos en memoria principal,
para indicar si el bloque está en alguna cache o no. De esta manera, no
hay que esperar a ninguna respuesta, ya que la conoceremos
consultando ese bit. Esta solución es compleja, porque influye en
todos los bloques de memoria principal, por lo que no se usa.
Para poder aplicar estas estrategias necesitamos ayuda del hardware,
normalmente más señales en el bus de control. Por un lado, la señal sh, que
ya hemos utilizado, para saber si existen o no copias de un bloque de datos.
Del mismo modo, es conveniente tener otra señal similar, dirty, para
indicar si el bloque está modificado en alguna cache. Por último, es
interesante tener otra tercera señal, inh (inhibir), para poder abortar los
accesos a memoria principal.
3.3 IMPLEMENTACIÓN DE LOS PROTOCOLOS SNOOPY ▪ 109 ▪
3.3.1.4 Atomicidad: estado del controlador snoopy
Para terminar con el análisis del controlador presentado en la figura
anterior, nos falta un detalle: el estado del controlador. Tal y como hemos
comentado al principio, uno de los principales problemas que se da cuando
tenemos P procesadores ejecutando a la vez es el de la falta de atomicidad de
las operaciones. Se dice que una operación es atómica si se ejecuta toda la
operación, desde el comienzo hasta terminar, sin ningún tipo de interferencia
de ningún otro procesador.
El procedimiento para mantener la coherencia no es atómico por
definición, ya que hay que realizar diversas operaciones y no se puede
asegurar que no vaya a haber “interferencias” (no olvidar que tendremos
muchos procesadores trabajando en paralelo). Dentro de ese conjunto de
operaciones se encuentran las transferencias de datos por el bus. Vamos a
suponer, por simplificar, que las operaciones del bus son atómicas; es decir,
no se procesa otra petición hasta haber terminado con la anterior —no se
segmenta19—. En los sistemas de un procesador, para trabajar con el bus se
utilizan protocolos de comunicación similares a éste (por ejemplo, para una
escritura):
procesador controlador del bus
petición-bus
... concesión-bus
dirección, control
... recibido
datos
De esta manera, el controlador del bus establece orden y prioridades en el
uso del mismo. En los multiprocesadores el control del bus es más
complicado, por un lado porque hay muchos procesadores conectados al bus,
y por otro porque los controladores de las caches son más complicados, para
poder hacer las funciones del snoopy. Además, aunque ayuda, el hecho de
que el bus sea atómico no asegura que el protocolo de coherencia lo sea. Es
por tanto el propio protocolo quien tiene que asegurar la atomicidad de las
operaciones. Analicemos cómo se puede conseguir atomicidad en un caso
concreto, el protocolo Illinois (MESI).
19 En los procesadores actuales esto no es así, ya que el uso de los buses está optimizado.
▪ 110 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
3.3.2 El protocolo Illinois y la atomicidad
3.3.2.1 Carreras: estados transitorios, señales BRQ y BGN
El protocolo Illinois es un protocolo de invalidación de tipo MESI (lo que
vamos a presentar se podría aplicar a los demás protocolos). Este protocolo
utiliza la señal sh, para saber si los bloques están compartidos o no.
Analicemos el siguiente caso. Dos procesadores comparten un bloque de
datos, cuyas copias están en estado S. Los dos hacen una escritura a la vez en
dicho bloque. ¿Cómo se resuelve el problema20? ¿Cómo asegurar que todas
las operaciones que se deben hacer como consecuencia de esas escrituras se
van a realizar del modo adecuado (incluso siendo el bus atómico)?
Por ejemplo, los procesadores P1 y P2 envían la señal INV al bus. Uno de
ellos ganará el uso del bus (supongamos que es P1). Por tanto, el controlador
de coherencia de P2, en lugar de dejar el bloque en estado S (para luego
ponerlo en estado M), lo pondrá en estado I (si no, el bloque estaría en los
estados M y S en dos caches simultáneamente). Pero después de hacer esto,
la señal enviada al bus, INV, no será suficiente, ya que ahora debería enviar
también la señal BR. La consecuencia que podemos extraer está clara: el
controlador del snoopy no se puede quedar esperando, sin hacer nada más, a
la respuesta a su petición; tal vez tenga que cambiar la petición realizada si
entre tanto otro procesador ha querido hacer una operación sobre el mismo
bloque de datos.
A este problema se le denomina "carrera" (race), y para solucionarlo, se
suelen introducir más estados en el protocolo de coherencia, denominados
estados "transitorios". Estos nuevos estados no están asociados a los
bloques de la cache, sino al controlador de coherencia. Por tanto, no se
introducen en el directorio (a nivel de bloque), sino que se guardan en un
registro específico, en el mismo controlador (ver figura del controlador). Es
decir, los posibles estados de un bloque son únicamente I, E, S y M. El
significado de los estados transitorios es claro: algo se está haciendo, pero
todavía no se ha terminado.
Tal y como hemos comentado, vamos a suponer que las operaciones en el
bus son atómicas, y para ello vamos a introducir dos señales de control en el
protocolo:
20 O, por ejemplo, el caso de dos escrituras simultáneas en fallo en dos procesadores. Los dos piden el
bloque y, si en ese momento nadie dice que lo tiene (sh = 0), los dos lo colocarán en estado M.
3.3 IMPLEMENTACIÓN DE LOS PROTOCOLOS SNOOPY ▪ 111 ▪
- petición: BRQ (bus request) petición de utilización del bus
- respuesta: BGR (bus grant) permiso para utilizar el bus
Para secuencializar operaciones que se quieren realizar simultáneamente,
antes de utilizar el bus hay que efectuar una petición de uso (BRQ); cuando
se dé permiso para utilizarlo (BGR), entonces se ejecutará el proceso
correspondiente al protocolo de coherencia.
Analicemos el protocolo Illinois teniendo en cuenta todo lo anterior. Para
implementar un protocolo MESI, son suficientes 3 estados transitorios: ISE,
IM y SM. El grafo del protocolo es el de la figura, y las principales
transiciones entre los estados son las siguientes:
• PR y fallo (I → S, E)
En lugar de ir directamente a E o S, se pasa al estado transitorio ISE.
En la transición I → ISE se pide permiso para utilizar el bus (BRQ), y
el controlador se mantendrá en ese estado hasta que se reciba el
permiso (BGR). Cuando éste llegue, se pedirá el bloque (BR), y se
cargará en la cache en el estado que corresponda, S o E, en función de
la señal sh.
• PW y fallo (I → M)
Antes de traer el bloque y modificarlo, hay que pedir permiso para
usar el bus (BRQ), y mientras tanto se pasa al estado IM. Cuando
llegue el permiso, se pedirá el bloque y se anulará el resto de copias
(BR, INV); finalmente, se cargará el bloque en la cache en estado M.
• PW y acierto (S → M)
Al igual que en los casos anteriores, pasaremos a un estado transitorio,
a SM. Pero cuidado, el bloque estaba en estado S, y podría darse, a la
vez, la misma transición en otra copia. Por tanto, mientras estamos en
el estado transitorio SM, a la espera de poder utilizar el bus (para
poder invalidar el resto de las copias), pueden suceder dos cosas:
- Llega la señal de aceptación BGR; por tanto, el bloque pasará a
estado M, y se generará la señal INV.
- Se detecta la señal INV enel bus, lo que significa que otro snoopy
se nos ha adelantado y quiere hacer una escritura sobre ese bloque.
Debemos invalidar nuestra copia, por lo que el autómata pasará al
▪ 112 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
estado IM, ya que ahora la escritura que queremos hacer partirá del
estado I (es un fallo, por lo que hay que conseguir el bloque de
datos: BR, INV).
• PW y acierto (M, E → M)
En este caso no tendremos ningún problema; como nuestra copia es la
única, se escribe y se modifica el estado, si es que estaba en E.
3.3.2.2 Deadlock, livelock, starvation
Los problemas comentados hasta ahora no son los únicos que se dan
cuando se implementan protocolos de este tipo. El interbloqueo es otro de
los problemas típicos. En el campo de las comunicaciones, el interbloqueo
está relacionado con la ocupación de los buses; en los protocolos de
coherencia, en cambio, puede aparecer otro tipo de interbloqueo: el
denominado fetch deadlock. Veamos un ejemplo.
El controlador de coherencia del procesador P1 está en un estado
transitorio, esperando la respuesta del controlador del bus (y nada más).
INV (BW)
BR
INV
BR (BW) BGR (BR,INV)
INV
PW (BRQ)
INV
PR (BRQ)
BGR (INV)
PW (BRQ)
PR - PW
PR
PW
PR - BR
IM
ISE
SM
M
I, -
S
E
nsh sh
BGR (BR)
▪ 113 ▪
Mientras tanto, el controlador del procesador P2, que ha conseguido el bus,
ha ejecutado la operación BR; por desgracia, el bloque que él quiere lo tiene
P1, y además, en estado M. Consecuencia: el procesador P1 no le enviará el
bloque, porque está esperando la señal BGR, y el procesador P2 no cederá el
bus, porque el bloque que necesita es el de P1. El sistema se ha bloqueado.
Por tanto, para evitar ese tipo de problema los autómatas de los snoopy no
pueden dejar de espiar el bus ni en los estados transitorios. Si estando en un
estado transitorio observa una situación como la descrita en el ejemplo
anterior, deberá dar la respuesta adecuada.
Al igual que sucede en otros contextos (por ejemplo, en la comunicación
entre procesos), además del interbloqueo existen otros problemas, entre ellos
los denominados livelock y starvation. El problema de livelock indica que se
ha llegado a una situación en la que los procesos no están bloqueados
("muertos / dead"), pero, sin embargo, son incapaces de avanzar. Por
ejemplo, dos procesadores escriben a la vez sobre un mismo bloque que no
tienen; los dos traerán el bloque e invalidarán el resto de las copias; en este
caso se producirá livelock si la secuencia de acciones es la siguiente: rd1 –
rd2 – INV1 – INV2 >> rd1 – rd2 – INV1 – INV2... Esto es, la operación
no se va a terminar nunca. Por su parte, el problema de starvation suele
aparecer ligado a cuestiones de prioridades: por ejemplo, un procesador
nunca recibe respuesta a su petición del acceso al bus, porque siempre se le
adelantan los demás. En el ejemplo de protocolo que acabamos de analizar
estos dos problemas están resueltos.
En resumen, los protocolos de coherencia se deben de diseñar con mucho
cuidado, para evitar todo ese tipo de problemas y para que funcionen bien y
de manera eficiente en cualquier situación. Al tratarse de protocolos
distribuidos entre P procesadores, cumplir con esas características puede
resultar complejo.
3.4 SNOOPY JERÁRQUICO
Para llevar a cabo la comunicación entre los procesadores de un
multiprocesador hemos utilizado un bus. Como ya sabemos, el número de
procesadores que se pueden conectar en un bus es limitado, y éste es el
principal inconveniente de la utilización de un bus como red de
comunicación. Pronto analizaremos más formas (redes) de conectar los
procesadores, pero en este momento vamos a analizar otra red de
▪ 114 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
interconexión, que es una evolución natural del bus: la jerarquía de buses.
Un bus jerárquico es un árbol de buses, en el que en las hojas están los
multiprocesadores (unidos mediante un bus) y en los demás "nodos" no hay
más que buses y controladores. Estos últimos se encargan de gestionar la
información por toda la red. Como vemos en la siguiente figura, se organiza
un tipo de cluster en el que los nodos son pequeños sistemas SMP.
Supongamos que tenemos una jerarquía de dos niveles, en la que el bus
del segundo nivel se utiliza para conectar N multiprocesadores (cada uno con
P procesadores en un bus). Aunque la memoria es compartida, lo más
apropiado es distribuir físicamente la memoria, y de esta manera se llega a
un sistema NUMA (non-uniform memory access): el tiempo de acceso es
diferente en función de dónde esté situada la posición de memoria a la que se
quiere acceder; no es por tanto, un sistema SMP. Dentro de cada
multiprocesador SMP, se utiliza un protocolo snoopy para mantener la
coherencia. Pero, ¿cómo mantener la coherencia en todo el sistema?
Tal y como veremos más adelante, la solución que se utiliza en los
sistemas que no utilizan una red de interconexión centralizada (un bus o
similar) son los directorios de coherencia. Cuando se utiliza una jerarquía de
buses, se utilizan unos controladores snoopy especiales que hacen la función
de directorios, espiando y conectando dos niveles de bus, y decidiendo si hay
que pasar la información de un nivel al otro o no.
Estos "monitores" especiales para la coherencia deben de espiar dos tipos
de operaciones: por un lado, las operaciones que se realizan sobre bloques de
su memoria principal local que han sido copiados en una cache remota; y por
otro lado, las que se hacen sobre bloques remotos que han sido traídos a las
caches locales. Por supuesto, la información que se queda dentro de un nodo
concreto (MP y cache) no afecta a los demás nodos, y será el snoopy local el
que se encargue de mantener la coherencia.
P
C
hardware para la
coherencia global
MP MP K K
snoopy local
SMP
B1
B2
3.4 SNOOPY JERÁRQUICO ▪ 115 ▪
Vamos a dividir el monitor de coherencia o directorio en dos partes:
• KL: controlador de coherencia que guarda información referente a los
bloques locales que se encuentran copiados en memorias cache
remotas (solamente los estados, no los datos, ya que el número de
bloques que pueden estar "fuera" puede ser muy grande).
• KR: controlador de coherencia que guarda información de los bloques
remotos que se encuentran en las caches locales (una "cache" que
guarda datos y estados, aunque con los estados sería suficiente; si
están los datos, se reduce el tráfico en el bus, pero se aumenta la
necesidad de memoria).
¿Cómo funciona este hardware para mantener la coherencia? Veamos
algunos ejemplos.
3.4.1 Lecturas (fallo)
En una cache se produce un fallo en lectura. Por tanto, se genera la señal
BR en el bus B1. Existen dos posibilidades:
1. La referencia pertenece al espacio de direccionamiento local
• No hay copias fuera del nodo (por tanto, KL no responde): es una
operación común y se resuelve dentro del mismo nodo (mediante el
snoopy local).
• Existe una copia de ese bloque fuera del nodo (por tanto, KL
responde):
- En estado S: no hay problema, se toma el bloque de su MP (o de
otra MC local).
- En estado E: es una situación similar a la anterior, pero el
controlador KL tiene que avisar al otro nodo (al que tiene una
copia del bloque), utilizando el bus B2, para que ponga el bloque
en estado S (mensajes (a) y (b) de la figura).
- En estado M: ¡cuidado! se debe pedir el dato fuera del nodo. La
petición se pondrá en el bus B2. Cuando el controlador KR del
nodo que tiene la copia del bloque detecte la petición realizará las
siguientes acciones: (i) avisará a la cache local utilizando el bus
B1, para que pase el bloque de estado M a estado S; y (ii) enviará
▪ 116 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
el bloque a quien losolicitó (si tiene los datos en KR, desde ahí
mismo; si no, buscará en qué cache local se encuentra el bloque).
Por último, el controlador KL que ha generado la petición tomará
el bloque de datos del bus B2, y lo pondrá en el bus B1, para
cargarlo en la memoria cache que corresponda y actualizar la
memoria principal (en la figura: 1, 2, 3, 4 y 5).
2. La referencia es del espacio de direccionamiento remoto.
• KR no responde. Por tanto, no está en alguna otra cache del nodo. La
petición se pasa al bus B2. El controlador KL correspondiente a esa
dirección detectará la petición y la pondrá en el bus local. La respuesta
(el bloque) llegará de la MP o de alguna de las caches locales de ese
nodo, y se pasará el bus B2. Junto a ello, se actualizará la información
de los controladores KL y KR.
• KR responde. El bloque está en alguna de las caches locales y se
tomará de ahí (si está en estado S, no hay que hacer nada; si está en
estado E, se debe poner en estado S y hay que avisar al controlador
KL; si está en estado M, además de lo anterior habrá que actualizar la
MP).
3.4.2 Escrituras
Veamos un ejemplo concreto. El procesador P0 del nodo N1 quiere
ejecutar una operación de escritura ST A en un bloque que está en estado S.
MP
MC
MP
KL KR KL
B1 B1
B2
MC MC MC
a
b
M→S
M→S
E→S
E→S
E→S
M→S
rd, fallo
BR @
KR
5
3
2
4 1
I→S
@ →
3.4 SNOOPY JERÁRQUICO ▪ 117 ▪
La variable A pertenece al espacio de direccionamiento del nodo N3, y hay
una copia de dicho bloque en el nodo N2 en estado S. La operación se
desarrollará de la siguiente manera:
1. Se pone el bloque en estado M y se genera una señal de invalidación
(INV) en el bus B1.
2. El controlador KR del nodo N1 ve que es una referencia remota; por
tanto, pasa la señal INV al bus B2.
3. El controlador KR de N2 invalida su copia y pasa la señal INV al bus
B1 (con lo que se invalidarán todas las copias de ese bloque que haya
en ese nodo).
3´. El controlador KL de N3 modifica el estado del bloque, de S a M.
En general, cuando la memoria es compartida pero está físicamente
distribuida no es sencillo mantener la coherencia de los datos. Los
controladores de coherencia son dispositivos complejos y de gran tamaño, y,
lo que es peor, la latencia de las operaciones de coherencia puede llegar a ser
muy elevada, sobre todo si tenemos que acceder a datos fuera del nodo local.
Y no podemos olvidar que hay que mantener la atomicidad de las
operaciones de coherencia.
Lo anterior ha sido simplemente un ejemplo. Normalmente, en lugar de
utilizar jerarquías de buses se utilizan otro tipo de redes (por ejemplo,
mallas), en las que no se pueden utilizar estrategias de tipo snoopy para
mantener la coherencia. Por tanto, deberemos buscar otro tipo de solución al
problema de la coherencia: el directorio, tal como veremos en el capítulo 7.
MP
MC
KL KR
MP
KL KR
MP
KL KR
S→M
1
2
3
3´
MC MC MC MC MC
A
N1 N2 N3
B1 B1
B2
B1
S→I S→I
S→I
S→I S→M
INV A
INV A
INV A INV A
INV A INV A
S→M
wr A
▪ 4 ▪
Sincronización de Procesos
en los Computadores SMP
4.1 INTRODUCCIÓN
En una máquina MIMD, la ejecución de los programas se divide en P
procesos o hilos, que se ejecutan en paralelo en los procesadores del sistema.
En general, la ejecución de esos procesos no es completamente
independiente, sino que se comunican entre ellos, bien sea para pasarse datos
o para sincronizar su ejecución. En este capítulo vamos a analizar los
necesidades de sincronización entre procesos que se ejecutan en paralelo en
una máquina SMP de P procesadores.
▪ 120 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Para presentar el problema de la sincronización entre procesos podemos
utilizar un ejemplo muy sencillo. Supongamos que se va a ejecutar este
código, en paralelo, en dos procesadores, P1 y P2 (inicialmente, A = 0)21:
P1
...
ST A,F1
...
...
P2
...
...
...
LD F4,A
¿Qué valor leerá el procesador P2 en la variable A? Se trata de una
variable compartida, que se utiliza en ambos procesos, y por tanto se debe
mantener la coherencia (mediante un snoopy), lo cual implica que los
cambios que efectúe P1 terminarán apareciendo en P2; sin embargo, no
sabemos cuándo ocurrirá eso.
En todo caso, el significado del programa anterior es confuso. ¿Existe una
dependencia de datos (RAW) entre P1 y P2 en la variable A? Si es así, se
debería indicar de alguna manera que P2 debe leer A después de que la haya
modificado P1, y no antes. Algo similar debería ocurrir si existiera una
antidependencia en A, para que P2 leyera A antes de que la modificara P1.
En otras palabras, se necesita sincronizar el uso de la variable A para que el
programa anterior tenga un sentido “lógico”. En general, en estos casos se
utiliza la sincronización por eventos, para avisar a un proceso (consumidor)
que se ha generado un dato en otro proceso (productor).
La necesidad de sincronización no se reduce a casos como el anterior.
Veamos otro ejemplo. Dos procesos comparten una variable, CONT, que
hace las veces de contador. Ambos procesos incrementan el valor de dicho
contador: CONT := CONT + 1.
P1
...
LD R1,CONT
ADDI R1,R1,#1
ST CONT,R1
...
P2
...
LD R1,CONT
ADDI R1,R1,#1
ST CONT,R1
...
¿Qué valor tendrá la variable CONT tras ejecutar el código anterior en
ambos procesadores? Aunque no existan problemas de coherencia, el
21 Para simplificar el código, en los ejemplos de este capítulo utilizaremos el modo de direccionamiento
absoluto. Como es habitual, el contenido del registro R0 es siempre 0.
4.1 INTRODUCCIÓN ▪ 121 ▪
resultado no está claro. Por ejemplo, ambos procesos ejecutan “a la vez” el
código citado, siendo CONT = 0, pero las instrucciones en cada procesador se
intercalan en el tiempo de la siguiente manera:
LD (P1) - ADDI (P1) - - ST (P1)
LD (P2) - - ADDI (P2) - ST (P2)
El resultado es inesperado: aunque ambos procesadores han incrementado
el valor de CONT, el valor final será CONT = 1. ¿Dónde está el problema? La
variable compartida CONT se ha accedido de manera no adecuada,
habiéndose intercalado en el tiempo las operaciones de P1 y P2 sobre dicha
variable. ¿Cuál sería la solución? También en este caso se necesita
sincronizar el uso de la variable compartida y ordenar su acceso (primero en
un procesador y luego en el otro), para que el resultado de la ejecución en
paralelo sea el esperado. De hecho, aunque el código se ejecute en dos
procesadores, ese trozo de código se debería ejecutar en serie. Dicho de otra
manera, la ejecución de ese código debe ser atómica.
En el ejemplo anterior, los dos procesos sólo comparten una variable,
sobre la que se efectúa una operación muy simple (+1), pero en general se
ejecutan más operaciones sobre las variables compartidas. Por eso, algunos
trozos de código de los procesos paralelos tienen que definirse como
secciones críticas, y hay que controlar de manera adecuada que sólo un
proceso ejecute simultáneamente dicho código, para lo que suelen utilizarse
variables de tipo cerrojo, que funcionan como semáforos a la entrada de las
secciones críticas, regulando el acceso de los procesadores a las mismas.
En resumen, para poder ejecutar un programa en P procesadores, a
menudo es necesario sincronizar el uso de las variables compartidas. La
sincronización entre procesos puede resolverse por software o por hardware.
Si se hace en hardware, suele ser más rápida pero menos flexible; si se hace
por software (bibliotecas), se suelen obtener soluciones más flexibles. Hoy
en día se utiliza una mezcla de ambos tipos; por una parte, se añaden
instrucciones especiales al lenguaje máquina, y, por otra, utilizando esas
instrucciones se escriben diferentes funciones de sincronización.Las estrategias básicas de sincronización son dos: exclusión mutua
(mediante funciones lock/unlock) y sincronización por eventos (punto
a punto, mediante indicadores o flags, o global, mediante barreras). En las
operaciones de sincronización, los procesos esperan hasta que ocurra una
determinada acción (que se abra el cerrojo, que se active un flag...). Como
▪ 122 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
sabemos, los algoritmos de espera pueden ser de dos tipos: espera activa o
bloqueo. En espera activa, el proceso entra en un bucle en el que
continuamente se pregunta si ya se ha producido una determinada acción;
mientras tanto, el procesador no realiza ninguna tarea útil. En los casos de
bloqueo, en cambio, el sistema operativo efectúa un cambio de contexto para
pasar a ejecutar otro proceso. El propio sistema operativo se encargará de
“despertar” al proceso que está en espera cuando se produzca el evento
esperado (o el propio proceso volverá cada cierto tiempo a analizar el estado
de la sincronización). Ambos mecanismos, espera activa y bloqueo, son
adecuados, y escogeremos uno u otro en función de las circunstancias
concretas de la aplicación y de la máquina (tiempo a esperar, latencia del
cambio de contexto, existencia de otros hilos o threads para ejecutar...);
también puede utilizarse un sistema mixto: un tiempo umbral de espera,
seguido de un cambio de contexto. En los ejemplos que vamos a analizar,
utilizaremos un bucle de espera activa.
¿De quién es la responsabilidad de escribir las rutinas de sincronización?
En general, el programador utilizará las rutinas de sincronización de la
librería del sistema (ya optimizadas); en todo caso, hay que analizar con
detenimiento el comportamiento de dichas rutinas, porque no todas ellas son
adecuadas para cualquier situación, situación que puede variar mucho de
programa a programa o dentro del mismo. Por ejemplo, hay que dar solución
eficiente al caso de un único procesador que desea entrar en una sección
crítica o al caso de P peticiones simultáneas de entrada. Una función de
sincronización que dé buen resultado en el primer caso, tal vez no lo dé en el
segundo.
Como hemos comentado, la sincronización no es algo intrínseco al
algoritmo que se va a ejecutar, sino al hecho mismo de que se quiere ejecutar
en paralelo, en P procesadores, lo que va a generar un tráfico de control
específico. Por ello, un mecanismo de sincronización adecuado debe cumplir
algunas condiciones, entre las que cabe destacar:
• Baja latencia: se debe gastar el menor tiempo posible en efectuar la
operación de sincronización, sea cual fuera la situación del programa;
por ejemplo, no se debería perder tiempo en el cerrojo de una sección
crítica cuando ésta está libre y no hay competencia en la entrada.
• Tráfico limitado: el tráfico que se genera en el acceso y uso de las
variables de sincronización debe ser el mínimo posible, para evitar
saturar la red de comunicación.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 123 ▪
• Buena escalabilidad: tanto la latencia como el tráfico no deben crecer
(al menos no demasiado) con el número de procesadores del sistema.
• Poco coste de memoria: no se debe utilizar mucha memoria para la
sincronización.
• Igualdad de oportunidades: todos los procesos deben tener las
mismas oportunidades de resolver sus peticiones de sincronización;
deben evitarse situaciones en las que, por ejemplo, un determinado
proceso no consiga nunca entrar en una sección crítica, mientras que
otros lo hacen una y otra vez (starvation).
Definido el problema, analicemos las principales estrategias de
sincronización.
4.2 EXCLUSIÓN MUTUA (mutual exclusion)
Se utiliza la exclusión mutua para controlar la ejecución de un trozo de
código que, aunque está replicado en P procesos, no puede ser ejecutado por
más de un proceso simultáneamente. Ese trozo de código forma una sección
crítica y nunca debe haber más de un proceso ejecutándolo. Para proteger el
acceso a una sección crítica se utilizan dos funciones específicas, lock y
unlock, que manejan una variable de tipo cerrojo, y que hacen las veces de
un semáforo.
El cerrojo puede tomar dos valores: 0 y 1, Si el cerrojo vale 0 (abierto), no
hay problema alguno para ejecutar la sección crítica; en cambio, si el cerrojo
vale 1 (cerrado) hay que esperar, ya que otro proceso está ejecutando en ese
momento la sección crítica.
Dos funciones se ejecutan con la variable cerrojo. El proceso que entra en
la sección crítica cierra el cerrojo (lock), y, al finalizar la ejecución de la
sección crítica, lo abre (unlock). Antes de entrar en la sección crítica, los
procesos analizan el valor del cerrojo y se quedan a la espera mientras esté
cerrado. Mediante esas dos funciones es posible gestionar el acceso a una
sección crítica para que los procesos la ejecuten siempre de uno a uno:
lock(…)
[ sección crítica ]
unlock(…)
La exclusión mutua puede lograrse también por medio del hardware. Por
ejemplo, se pueden dedicar algunas líneas del bus de control para utilizarse
▪ 124 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
como variables cerrojo hardware (or-wired, como la señal sh). Sin embargo
no se suele utilizar esa solución, sino que las funciones lock y unlock se
implementan en software. Veamos cómo podrían escribirse esas dos
funciones (CER es una variable tipo cerrojo):
función lock(CER)
lock: LD R1,CER
BNZ R1,lock ; saltar si no es 0
ADDI R2,R0,#1 ; R2 := 1
ST CER,R2 ; cerrar cerrojo
RET
función unlock(CER)
unlock: ST CER,R0
RET
Antes de entrar en la sección crítica se lee el cerrojo. Si está cerrado (CER
= 1), los procesos se quedan en el bucle, analizando una y otra vez el valor
del cerrojo; si está abierto (CER = 0), se puede pasar a la sección crítica,
cerrando previamente el cerrojo. Finalmente, al terminar de ejecutar el
código de la sección crítica se ejecuta la función unlock para abrir el
cerrojo (CER = 0).
Sin embargo, aunque las rutinas anteriores podrían ser adecuadas en el
caso de un sistema con un solo procesador (en función de cómo se reparta el
tiempo de ejecución), no funcionan bien en un sistema multiprocesador.
¿Cuál es el problema? El mismo que tiene la sección crítica, la falta de
atomicidad. El uso (lectura / escritura) de la variable CER no es atómico,
por lo que no se puede impedir que dos procesos pasen a la sección crítica.
El problema reside en que no existe una unidad de control centralizada,
puesto que los procesos van en paralelo de manera completamente
independiente.
Para poder gestionar secciones críticas necesitamos disponer de
instrucciones atómicas de tipo RMW (read-modify-write) que permitan
efectuar una operación de lectura y escritura sobre una variable (el cerrojo)
en modo atómico. Mientras se está ejecutando una operación especial de este
tipo, el controlador del sistema de memoria bloquea el acceso del resto de
procesadores a esa variable.
Existen diferentes instrucciones de tipo RMW, y todos los procesadores
actuales disponen de una o varias de ellas en su juego de instrucciones, ya
que todos ellos están pensados para ser utilizados en entornos
multiprocesador de memoria compartida. Veamos las principales
instrucciones atómicas RMW.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 125 ▪
4.2.1 Instrucciones Test&Set y Swap
Como primera opción para gestionar cerrojos, vamos a analizar dos
instrucciones similares. En ambos casos se ejecuta una operación de tipo
RMW: se lee una variable en memoria, se modifica, y se vuelve a escribir en
memoria, sin ninguna interferencia (operación atómica).
4.2.1.1 Instrucción Test&Set
Es una instrucción atómica RMW, la más antigua, que realiza la siguiente
operación:
▪ T&S R1,CER R1 := MEM[CER]; MEM[CER] := 1;
Es decir, se carga una variable en un registro y se escribe un 1 en dicha
variable en memoria.
Utilizandola instrucción T&S, las dos funciones de un cerrojo
(cerrar/abrir) pueden hacerse así:
lock: T&S R1,CER
BNZ R1,lock
RET
unlock: ST CER,R0
RET
La instrucción T&S asegura que sólo un proceso leerá CER = 0, ya que
junto a ello, de manera atómica, se escribe un 1; por tanto, el resto de los
procesos verá un 1 en dicha variable, y continuará en el bucle de espera.
Al salir de la sección crítica hay que abrir el cerrojo, y para ello es
suficiente con escribir un 0 en la variable CER, con una operación "estándar"
de escritura, ya que en la sección crítica sólo hay un proceso.
4.2.1.2 Instrucción Swap
La instrucción Swap es similar a T&S, pero, en lugar de escribir una
constante en memoria, escribe el contenido de un registro. Se trata por tanto
de un intercambio atómico entre el contenido de un registro y una posición
de memoria:
▪ SWAP R1,CER R1 ↔ MEM[CER];
▪ 126 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Para efectuar un cerrojo, basta con cargar previamente un 1 en el registro.
Éstas serían las correspondientes rutinas lock y unlock:
lock: ADDI R1,R0,#1 ; R1 := 1
l1: SWAP R1,CER
BNZ R1,l1
RET
unlock: ST CER,R0
RET
4.2.1.3 Análisis del tráfico
Tal como hemos visto, las instrucciones de tipo RMW permiten controlar
el acceso a una sección crítica, pero tenemos que analizar si se hace de
manera eficiente o no. Como hemos comentado, las funciones de
sincronización deben ser de latencia baja y generar poco tráfico, todo ello, a
ser posible, independiente del número de procesos/procesadores, y con un
reparto equilibrado de los recursos entre los procesos.
Sin embargo, no es eso lo que ocurre. Supongamos que se utiliza un
protocolo tipo MESI (invalidación) para mantener la coherencia de los datos.
Cada vez que un proceso ejecuta la instrucción T&S se produce una escritura
sobre una variable compartida, el cerrojo. La variable cerrojo estará en
estado S (shared) en la cache y, al ser una escritura, habrá que invalidar
todas las copias para mantener la coherencia (snoopy). Esto no es un
problema si somos el único proceso intentando acceder a la sección crítica;
sin embargo, si en ese momento hay muchos procesos efectuando la misma
operación, el próximo intento en todos los procesadores será un fallo en
cache (se ha anulado la variable cerrojo). Todos los procesos, más o menos a
la vez, pedirán el bloque de datos correspondiente, por lo que se generará un
tráfico de datos muy alto en el bus, más alto cuanto mayor sea el número
de procesos esperando entrar en la sección crítica. Como consecuencia, las
latencias (el tiempo de respuesta) de dichas operaciones crecerán mucho.
En la siguiente figura puede observarse una simulación de dicha situación.
Al principio, el procesador P0 está en la sección crítica y otros cuatro
procesadores esperan para entrar. P0 abandona la sección crítica y escribe
CER = 0 (unlock), por lo que invalida todas las copias de dicha variable.
Los otros cuatro procesos, a la vez, pedirán (BRQ) el bloque que contiene
CER, para poder ejecutar T&S. Al ser una instrucción atómica, el controlador
del bus sirve las peticiones de manera "ordenada" (FIFO en la figura). Para
indicar la atomicidad, hemos puesto la ejecución de la instrucción T&S entre
corchetes.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 127 ▪
Simulación de la entrada a una sección crítica
Sincronización: Test&Set (TS)
BRQ = petición de bloque / x = invalidado / transmisión de un bloque de datos
P0 C=0 INV
P1 ? x [TS BRQ TS INV] x SECCIÓN CRÍTICA
P2 ? x [TS BRQ. . . . . . . . . . . . TS INV] [TS. . . . x BRQ. . . . . . . . . TS INV] [TS. . . . x BRQ. . . . . . . . . .
P3 ? x [TS BRQ. . . . . . . . . . . . . . . . . . . . . . . TS INV] [TS . . . x BRQ. . . . . . . . . . . TS INV] [TS. . . . x BRQ. .
P4 ? x [TS BRQ. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . TS INV] [TS. . . . . x BRQ. . . . . . . . . . TS INV] [TS. .
repetir y repetir
Tráfico de datos (bloques)
Para que entre un procesador en la sec. cr. → P + (P – 1) × k veces
Al salir de la sección crítica → 1
La conclusión de la simulación es clara: mientras la sección crítica se
mantiene ocupada, los procesos que intentan entrar están anulando, una y
otra vez, el bloque que contiene la variable cerrojo, lo que implica que hay
que enviar una y otra vez ese bloque, generando un gran tráfico en el bus. No
hay que olvidar que ese tráfico no corresponde al algoritmo que se ejecuta
sino al hecho de ejecutarlo en paralelo.
Así pues, aunque la función lock anterior formalmente funciona bien, y
en situaciones de poca competencia no da problemas, pero cuando la
competencia por entrar en la sección crítica es alta el proceso se degrada
mucho, es decir, no es escalable. Pueden plantearse, sin embargo, algunas
mejoras en el diseño de las rutinas de acceso a la sección crítica, intentando
reducir el tráfico y la latencia.
4.2.1.4 Procedimiento Test&Set with backoff
La fuente del tráfico que se genera en el bus está en la instrucción T&S
(que efectúa siempre una escritura). Por tanto, deberíamos limitar el número
de veces que se ejecuta dicha instrucción.
Una primera alternativa sería esperar un cierto tiempo entre dos
operaciones de T&S:
T&S – t. de espera – T&S – t. de espera – ...
▪ 128 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
es decir, si no es posible entrar en la sección crítica en un momento
determinado, no intentarlo una y otra vez, generando tráfico y sin poder
entrar, sino esperar un cierto tiempo para aumentar la probabilidad de
encontrar libre la sección crítica.
El tiempo de espera entre intentos no debería ser muy alto, para evitar
tener el proceso parado cuando ya se ha liberado la sección crítica, ni muy
bajo, para no intentar entrar en vano. Es decir, hay que tomar un
compromiso entre reducir el tráfico (tiempo alto) y no perder tiempo en
balde (bajo). Diferentes experimentos muestran que suele ser adecuado
utilizar un tiempo que crece de forma exponencial, del tipo ti = k ci (k y c,
dos constantes; i, número de intentos realizados para entrar en la sección
crítica: 0, 1, 2...), lo que genera la siguiente secuencia de tiempos de espera:
t0 = k t1 = k c t2 = k c2 ... (c > 1)
A esta estrategia se le suele denominar Test&Set with backoff. Las rutinas
de control del cerrojo pueden ser las siguientes:
lock: T&S R1,CER
BNZ R1,esp
RET
esp: CALL ESPERA(t1) ; t1 = tiempo de espera
[t1 := ...] ; calcular nuevo valor para t1
JMP lock
unlock: ST CER,R0
RET
4.2.1.5 Procedimiento Test-and-Test&Set
Veamos una segunda alternativa para reducir el tráfico. Cada vez que se
ejecuta T&S se escribe un 1 en memoria... aunque el contenido de la
memoria sea precisamente 1. ¿Por qué escribir en todos los intentos de
acceso a la sección crítica en la variable cerrojo, si no se va a modificar su
contenido?
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 129 ▪
La idea es dividir la operación de sincronización en dos fases. En la
primera parte, simplemente se analiza el contenido del cerrojo, y para ello
basta con utilizar una instrucción de lectura estándar. Repetiremos esa
operación todas las veces que sea necesario hasta encontrar el cerrojo
abierto. En ese momento, se ejecuta una operación de T&S, intentando cerrar
de manera atómica el cerrojo. Sólo un proceso lo logrará, y el resto volverá a
la fase inicial, a leer el valor del cerrojo. Así pues, cuando se abra el cerrojo
cada proceso sólo escribirá una vez.
Los procesos que están intentando acceder a la sección crítica no generan
tráfico en el bus mientras la sección crítica está ocupada. Las invalidaciones
(y, por tanto, la necesidad de tener que traer bloques de datos) sólo ocurrirán
en dos ocasiones: cuandouno de los procesos cierra el cerrojo (escribe un 1)
y cuando el proceso que termina la ejecución en la sección crítica lo abre
(escribe un 0).
A esta estrategia de sincronización se le conoce como Test-and-Test&Set,
y las rutinas de control de la variable cerrojo son las siguientes:
lock: LD R1,CER ; fase de test
BNZ R1,lock
T&S R1,CER ; fase de test-and-set
BNZ R1,lock
RET
unlock: ST CER,R0
RET
En comparación con el uso simple de la instrucción T&S, cuando se utiliza
el procedimiento Test-and-Test&Set el tráfico generado se reduce
notablemente. La siguiente figura muestra una simulación de dicha estrategia
de sincronización. Al inicio, todos los procesos están en la fase de test (LD).
Al abrirse el cerrojo, todos los procesos solicitan el bloque de datos que
contiene la variable cerrojo, ya que ha quedado invalidado en todas las
caches. Todos verán que el cerrojo está abierto (CER = 0), y ejecutarán T&S
(atómico), pero sólo uno logrará pasar a la sección crítica; el resto volverá a
la fase de test, ya que encontrarán el cerrojo cerrado.
▪ 130 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Simulación de la entrada a una sección crítica
Sincronización: Test-and-Test&Set
BRQ = petición de bloque / x = invalidado / transmisión de un bloque de datos
P0 C=0 INV
P1 LD x BRQ LD [TS . . . . . . . . . TS INV] x SECCIÓN CRÍTICA
P2 LD x BRQ. . . . . LD [TS . . . . . . . . . x BRQ TS INV] LD. . . . x BRQ. . . . . . . . . . LD . . . . . . . . .
P3 LD x BRQ. . . . . . . . . . . . LD [TS . . . . x BRQ. . . . . . . . . . . . TS INV] LD . . . . x BRQ. . LD . . . . .
P4 LD x BRQ. . . . . . . . . . . . . . LD [TS x BRQ. . . . . . . . . . . . . . . . . . . . . . . . . TS INV] LD . . . . . . . . . . . . . .
Tráfico de datos (bloques)
Para que entre un procesador en la sec. cr. → P + (P – 1) + (P – 2)
Al salir de la sección crítica → 1
En total → 3P – 2
Para que entren P →
2
3
2
)13()23(
2
1
PPPP
P
p
→
−
=−∑
=
El tráfico que se genera es de orden P2, siendo P el número de procesos
que está intentando acceder simultáneamente a la sección crítica; no es por
tanto muy escalable, por lo que el bus se saturará con facilidad al crecer P.
Además, el tráfico se genera en momentos concretos; todos los procesos
fallan a la vez en la cache, al abrirse el cerrojo, y solicitan a la vez el bloque
de datos (en este segundo caso no sirven las estrategias de esperar un cierto
tiempo, ya que sólo se ejecuta una vez la instrucción T&S).
4.2.1.6 Resumen de características
Como hemos visto, las funciones más simples de tipo T&S para controlar
el acceso a una sección crítica generan mucho tráfico de sincronización
cuando existe alta contención en el acceso a la sección crítica mientras ésta
está ocupada. Pero por otra parte, resultan muy adecuadas en casos de baja
contención: son muy simples, tienen una latencia muy pequeña (pocas
instrucciones) y no generan tráfico.
Se utiliza muy poca memoria, ya que basta con una variable,
independientemente del número de procesos. Desde el punto de vista del
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 131 ▪
equilibrio en el reparto de recursos, no se establece ningún tipo de política de
asignación y, por tanto, el tiempo de espera a recibir respuesta dependerá de
los criterios de prioridad que utilice el controlador del bus (por ejemplo, si es
FIFO, sabemos que el tiempo estará acotado).
En resumen, un T&S simple es una estrategia adecuada únicamente
cuando se sabe que la contención en la entrada a la sección crítica va a ser
muy baja (o cuando el número de procesadores del sistema es muy pequeño).
La estrategia T&S-BO tiene un comportamiento similar, aunque genera
menos tráfico y es, por consiguiente, más escalable.
Test-and-T&S es el mecanismo más adecuado de los tres. En situación de
alta competencia, mantiene el tráfico bastante limitado; cuando la
competencia es baja, presenta una latencia algo superior a la de los casos
anteriores, ya que hay que ejecutar siempre las dos fases: LD [fase de test] y
T&S [fase de test-and-set].
Una última cuestión relacionada con la función lock. ¿Es necesario
llevar a la cache la variable cerrojo de la función Test&Set, o es mejor
mantenerla siempre en memoria principal? Sabemos que es útil llevar a la
cache las variables que vamos a utilizar, pero si fallamos continuamente en
el acceso al cerrojo, porque nos lo invalidan continuamente, y tenemos que
transferir el bloque de datos completo una y otra vez, tal vez sería más
cómodo dejar el cerrojo en la memoria principal y no hacer copias. En todo
caso, si lo dejamos en MP, todos los accesos a dicha variable serían a la
memoria principal, a través del bus, con lo que la latencia en casos de baja
contención sería mucho más alta.
4.2.2 Instrucciones Load Locked / Store Conditional y
Compare&Swap
Acabamos de comentar el problema que presenta una función de lock
basada en la estrategia Test-and-T&S: cuando ejecutan la instrucción T&S
todos los procesos efectúan una escritura en la variable cerrojo, se invalidan
entre todos ellos y se genera un hot spot de tráfico, ya que todos los procesos
solicitan en un intervalo muy corto de tiempo el bloque de datos
correspondiente. Que el tráfico sea muy alto no se puede aceptar cuando el
número de procesadores crece, por lo que hay que intentar algo más para
reducir dicho tráfico.
▪ 132 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
4.2.2.1 Instrucciones LL y SC
Cada vez es más habitual en los procesadores que las operaciones
atómicas necesarias para la sincronización se repartan en dos instrucciones,
que, usadas de manera adecuada, permiten realizar una operación atómica
RMW sobre una variable. Además de las dos instrucciones, se utiliza un flag
hardware para saber que la operación se ha ejecutado de manera atómica.
Las dos instrucciones específicas para sincronización son: LL –Load Locked
(o linked)– y SC –Store Conditional–.
La instrucción LL efectúa una lectura en memoria, pero tiene un efecto
lateral: en un registro (latch) especial que sólo se usa para sincronización (le
llamaremos LSin) se guarda la dirección accedida y un flag, para indicar
que se ha leído dicha posición en un modo especial.
▪ LL R1,CER R1 := MEM[CER];
LSin[dir] := CER; LSin[flag] := 1;
La instrucción SC efectúa una escritura condicional en memoria, para lo
que primero analiza el latch LSin. Si contiene la dirección que se quiere
escribir y el flag está en 1, entonces efectúa la escritura en memoria y envía
una señal especial de invalidación de dicho flag a todos los procesadores,
que al recibirla pondrán a 0 el flag si está asociado a la dirección indicada.
En cambio, si el flag está desactivado, entonces la escritura no se ejecuta. En
ambos casos, se devuelve un código de control, normalmente en el registro
que se quiere escribir, indicando si la escritura se ha ejecutado o no.
▪ SC CER,R1 si (LSin[dir,flag] = CER,1) {
MEM[CER] := R1;
LSin[flag] := 0 (INV, todos)
}
R1 := 1/0 (se ha escrito / o no)
Veamos cómo se utilizan ambas instrucciones para gestionar la entrada a
una sección crítica. La operación se realiza en dos o tres pasos:
1 Se lee la variable de sincronización (cerrojo) mediante la instrucción
LL, con lo que se guarda la dirección accedida y se activa el flag de
sincronización en el latch de sincronización.
2 Si es necesario, se efectúa cálculo o se procesan variables.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 133 ▪
3 Se intenta escribir en la variable cerrojo (normalmente los resultados
del segundo paso), mediante la instrucción SC. Si el flag de
sincronización asociado a la dirección del cerrojo está activado, se
realiza la escritura y se anulan, además de la variablecerrojo, todos
los flags del sistema correspondientes a la dirección de la variable que
se escribe: la operación total [LL — SC] se ha realizado atómicamente,
sin interferencias.
En cambio, si el flag está desactivado, no se realiza la escritura, ya que
otro proceso ha efectuado una escritura en dicha variable (razón por la
cual se ha anulado el flag que se activó con la instrucción LL). No se
ha podido realizar atómicamente el par [LL — SC] y por ello hay que
repetir todo el proceso. Como SC no ha escrito, no se produce ninguna
invalidación, ni se genera, por tanto, tráfico alguno.
En resumen, si SC termina bien, entonces el trozo de código [LL — SC]
se ha ejecutado atómicamente (lo cual no quiere decir que las instrucciones
entre LL y SC formen una sección crítica).
Utilizando esas dos instrucciones, las rutinas lock y unlock quedan de
la siguiente manera:
lock: ADDI R2,R0,#1 ; R2 := 1
l1: LL R1,CER ; examinar cerrojo
BNZ R1,l1
...
SC CER,R2 ; intentar cerrar cerrojo
BZ R2,lock ; SC no ha escrito, repetir
RET
unlock: ST CER,R0
RET
Como ocurre en el caso Test-and-T&S, no se genera tráfico mientras
estamos en el bucle de espera (LL), ya que sólo se hace una lectura. Las
mejoras vienen en la segunda parte. La instrucción T&S siempre escribe en
memoria, independientemente del valor del cerrojo; en cambio, la
instrucción SC sólo escribe cuando el cerrojo está abierto (es decir, cuando
nadie ha escrito en dicha variable desde que se ejecutó LL). Así pues, sólo se
genera tráfico en el bus en dos casos: al entrar en la sección crítica (cuando
SC cierra el cerrojo), y al salir de la misma (al abrir el cerrojo).
▪ 134 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Simulación de la entrada a una sección crítica
Sincronización: LL / SC
BRQ = petición de bloque / x = invalidado / transmisión de un bloque de datos
P0 C=0 INV
P1 LL x BRQ LL(1) [SC . . . . . .SC INV] SECCIÓN CRÍTICA
P2 LL x BRQ. . . . . LL(1) [SC . . . . . . . (0)x BRQ SC] LL. . . . . . . . . . . .
P3 LL x BRQ. . . . . . . . . . . . LL(1) [SC . . . (0)x BRQ. . . . . SC] LL . . . . . . .
P4 LL x BRQ. . . . . . . . . . . . . . LL(1) [SC (0)x BRQ. . . . . . . . SC] LL . . .
Tráfico de datos (bloques)
Para que entre un procesador en la sec. cr. → P + (P – 1)
Al salir de la sección crítica → 0
En total → 2P – 1
Para que entren P →
2
1
)12( PP
P
p
∑
=
=−
En la figura anterior se muestra una simulación de esta estrategia. Como
puede observarse, sólo una instrucción SC logra escribir en memoria, la
primera, ya que las demás encuentran desactivado el flag que activaron con
la instrucción LL. El tráfico de datos, por tanto, es menor. En todo caso, el
tráfico todavía es alto, y además no se aplica ningún tipo de gestión de las
peticiones de entrada. Hay, por tanto, oportunidades para la mejora.
4.2.2.2 Instrucción Compare&Swap
Antes de estudiar posibles mejoras del procedimiento anterior, veamos
otra alternativa en la línea de la anterior. Se trata de la instrucción
Compare&Swap, que realiza la siguiente operación en modo atómico:
▪ C&S R1,R2,CER si (R1 = MEM[CER]) entonces MEM[CER] ←→ R2
En este caso, la escritura en memoria tampoco se realiza en todos los
casos, sino sólo cuando se cumple la comparación. Lo que en la pareja
LL/SC se consigue con la ayuda del hardware (flag), en este caso se logra
mediante el flag estándar resultado de una comparación.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 135 ▪
El código para controlar un cerrojo usando la instrucción C&S es el
siguiente:
lock: ADDI R2,R0,#1 ; R2 := 1
l1: C&S R0,R2,CER ; no escribe siempre
BNZ R2,l1 ; R2 = 1 → no se ha escrito
RET
unlock: ST CER,R0
RET
La instrucción C&S es más "compleja" que las anteriores, puesto que
utiliza dos registros y una posición de memoria (es decir, una operación de
memoria con tres operandos). En muchas arquitecturas RISC no se utiliza
ese formato, por lo que suele ser más habitual que se utilice la pareja LL/SC.
4.2.2.3 Algunos problemas con las instrucciones LL/SC
Las instrucciones LL/SC necesitan la ayuda del hardware para cumplir su
función. Por una parte, cada procesador tiene que disponer de un registro
especial y un flag asociado al mismo, y, por otra, se necesita la colaboración
del controlador del bus. Cuando se ejecuta LL se guarda la dirección
accedida en dicho registro y se activa el flag. A partir de ese momento, el
controlador deberá espiar continuamente el bus para detectar si se produce
una escritura en esa dirección en algún otro procesador, en cuyo caso tiene
que borrar el flag. También hay que borrar el flag cuando se reemplaza el
bloque que contiene la variable de sincronización o en los cambios de
contexto.
Al ir a ejecutar la instrucción SC se mira el flag. Si está activado, no hay
problemas: se escribe en memoria y se envía una señal de control para borrar
todos los flags asociados a dicha dirección en el resto de procesadores. Pero
si está desactivado, no se efectúa la escritura y se devuelve el
correspondiente código de “error”. Hay que implementar el protocolo con
cuidado para evitar problemas de deadlock, livelock, y similares. Por
ejemplo, tendríamos un caso de livelock (repetir continuamente un proceso
sin llegar a completarlo nunca) si ocurriera esto: LL – SC (fallo) – LL – SC
(fallo) - ... (por ejemplo, porque se reemplaza continuamente el bloque que
contiene la variable cerrojo).
▪ 136 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Para evitar estos problemas es conveniente no aceptar reemplazos del
bloque que contiene la variable de sincronización. ¿Cómo? Por un lado, no
efectuar operaciones en memoria entre las instrucciones LL y SC, para no
tener que cargar nuevos bloques de datos en la cache (y evitar así un posible
reemplazo).
Por otro lado, aunque no se acceda a datos en memoria, es muy probable
que haya instrucciones entre LL y SC. Como normalmente la cache de datos
y la de instrucciones estarán separadas, no tendremos problemas. En todo
caso, se recomienda siempre utilizar muy pocas instrucciones entre la pareja
LL/SC, para reducir la posibilidad de que otro procesador acceda a la
variable de sincronización intercalándose con nosotros.
4.2.3 Instrucciones Fetch&Op
En muchos casos, las operaciones que se realizan con las variables
compartidas son muy simples, tal como hemos visto en los ejemplos
anteriores. Por ello, existen instrucciones especiales que realizan esas
operaciones de modo atómico: las instrucciones Fetch&Op. Se trata de un
grupo de instrucciones RMW de uso más general que las anteriores: antes de
volver a escribir la variable en memoria se efectúa algún tipo de operación
(op) con la misma. Según la operación que se realice, tenemos diferentes
instrucciones; por ejemplo:
▪ Fetch&Incr R1,VAR R1 := MEM[VAR];
MEM[VAR] := MEM[VAR] + 1;
▪ Fetch&Dcr R1,VAR R1 := MEM[VAR];
MEM[VAR) := MEM[VAR] – 1;
▪ Fetch&Add R1,R2,VAR R1 := MEM[VAR];
MEM[VAR] := MEM[VAR] + R2;
Por ejemplo, utilizando la instrucción Fetch&Incr podemos
incrementar el valor de la variable CONT de manera atómica:
Fetch&Incr R1,CONT
El valor de la variable CONT se deja en el registro R1, y, a la vez, se
incrementa el contenido de la posición de memoria CONT. Es decir, si CONT
valía 6, tras ejecutar la instrucción tendremos que R1 = 6 y CONT = 7.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 137 ▪
Si el código que hay que ejecutar en exclusión mutua es más largo (algo
más complejo que una simple operación de incremento), entonces habrá que
generar una sección crítica; aunque utilizando este tipo de instrucciones
también se pueden implementar dichas funciones, lo más habitual es utilizar
otro tipo de instrucciones atómicaspara hacerlo.
4.2.4 Alternativas para reducir el tráfico
Tal como hemos comentado, con las instrucciones LL/SC conseguimos
reducir el tráfico, pero aún caben ciertas optimizaciones. Analicemos las más
importantes.
4.2.4.1 Tickets
Un mecanismo basado en tickets puede ser útil para reducir el tráfico en la
entrada a una sección crítica. La idea es sencilla. Un proceso que quiere
entrar en la sección crítica tiene que coger primero un ticket, que le indica el
número de turno de entrada que le corresponde. A continuación, se quedará
esperando a que llegue su turno. En ese momento, solamente él tendrá
permiso para entrar en la sección crítica: es su turno. Al abandonar la
sección crítica incrementará la variable que indica el turno, para dejar paso al
siguiente proceso.
Con el método de los tickets no se produce contención en la entrada de la
sección crítica, ya que todas las entradas se han ordenado, y por tanto se
reduce algo el tráfico. En cambio, hay que utilizar dos variables compartidas:
la que sirve para repartir tickets (TICKET), y la variable que indica el turno
actual (TURNO).
El contador que se utiliza para repartir tickets tiene que accederse en
exclusión mutua, para lo que podemos utilizar, si disponemos de ello, una
instrucción de tipo Fetch&Incr o bien las instrucciones LL y SC. Por
ejemplo:
F&I R1,TICKET ; R1 := MEM[TICKET];
; MEM[TICKET]:= MEM[TICKET]+ 1
o bien:
tick: LL R1,TICKET ; conseguir ticket
ADDI R2,R1,#1 ; incrementar número de ticket para el siguiente
SC TICKET,R2 ; pero de manera atómica
BZ R2,tick ; repetir la operación hasta conseguir atomicidad
▪ 138 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Utilizaremos una u otra solución en función del tipo de instrucciones que
pueda usar el procesador. Finalmente, las rutinas de lock y unlock
quedarán así22:
lock: F&I R1,TICKET ; obtener ticket
esp: LD R2,TURNO
SUB R3,R1,R2
BNZ R3,esp ; esperar turno
RET
unlock: LD R1,TURNO ; actualizar turno
ADDI R1,R1,#1
ST TURNO,R1 ; para dar paso al siguiente
RET
En la siguiente figura (un poco más adelante) se presenta una simulación
de esta estrategia. Los procesos ya han conseguido su ticket y están
esperando su turno. Sólo se genera tráfico una vez, cuando se incrementa el
turno al salir de la sección crítica, aunque tendríamos que contar también el
tráfico generado al obtener el ticket, ya que hay que traer a la cache el bloque
que contiene dicha variable. Sumado todo el tráfico, el nivel del mismo es
similar al que habíamos conseguido antes, aunque ahora está más distribuido
en el tiempo (la obtención de los tickets no tiene por qué ser simultánea), lo
que también es importante.
A pesar de todo, todavía hay momentos en los que se genera bastante
tráfico. Al actualizar la variable TURNO (al salir de la sección crítica) se
producirán fallos en la cache en todos los procesadores que estén esperando
entrar, que pedirán, más o menos a la vez, una copia de dicho bloque: se
genera un “pulso” de tráfico.
Desde el punto de vista de la latencia, cuando no se produce contención
(simultaneidad) en la entrada, esta técnica es de latencia más alta, puesto que
primero hay que conseguir el ticket.
El reparto de peticiones es justo: se aplica una política tipo FIFO. Si se
quiere, en este caso se pueden aplicar técnicas de espera tipo backoff, con un
tiempo de espera “proporcional” a la diferencia entre el ticket obtenido y el
turno actual.
22 Si el número de procesos es P, conviene incrementar las variables TICKET y TURNO módulo P, para
evitar desbordamientos.
4.2 EXCLUSIÓN MUTUA (mutual exclusion) ▪ 139 ▪
4.2.4.2 Vectores de cerrojos
Como hemos comentado, el método anterior genera momentos de tráfico
alto al actualizar la variable TURNO, compartida por todos los procesos. El
problema desaparece si, en lugar de obtener un ticket con el turno
correspondiente, se obtiene la dirección de un elemento de un vector de
cerrojos, un cerrojo particular donde esperar para entrar en la sección
crítica. Así, primero se reparten posiciones del vector de cerrojos —valores
de la variable INDICE—, tal como hemos hecho con los tickets en el
método anterior; luego, cada proceso espera a que se abra su cerrojo
particular: VECT_CER(INDICE).
vector de cerrojos: VECT_CER → ... 0 1 1 1 1 ...
proceso en la sección crítica INDICE: siguiente posición de espera
Las rutinas de lock y unlock serían las siguientes:
lock: F&I R1,INDICE ; obtener posición del vector de cerrojos
; ¡ojo! función módulo
esp: LD R2,VECT_CER(R1) ; esperar turno
BNZ R2,esp
ST MI_INDICE,R1 ; guardar índice para la salida de la S.C.
RET
unlock: ADDI R2,R0,#1 ; R2 := 1
LD R1,MI_INDICE ; recuperar índice del vector de cerrojos
ST VECT_CER(R1),R2 ; cerrar cerrojo propio (1)
ADDI R1,R1,#1 ; ¡ojo! función módulo
ST VECT_CER(R1),R0 ; abrir siguiente cerrojo (0)
RET
En la figura siguiente se muestra una simulación del tráfico generado. El
tráfico es ahora constante, independiente del número de procesos, porque al
salir de la sección crítica sólo se actualiza (y se anula) el cerrojo de un
proceso. El resto de procesos no se entera, y continúa a la espera de su turno.
Estamos suponiendo que no existe un problema de falsa compartición en la
cache, es decir, que los elementos del vector de cerrojos están en bloques
diferentes de memoria.
▪ 140 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
Simulación de la entrada a una sección crítica
Sincronización: Tickets / Vectores de cerrojos
BRQ = petición de bloque / x = invalidado / transmisión de un bloque de datos
Tickets Vectores de cerrojos
P0 TURNO++ INV VC(i+1)= 0 INV
P1 LD x BRQ LD SEC. CRIT. LD x BRQ LD SEC. CRIT.
P2 LD x BRQ. . . . . LD . . . . . . . . . . . . . LD . . . . . . . . .
P3 LD x BRQ. . . . . . . . . . . . LD . . . . . . . . LD . . . . . . . . .
P4 LD x BRQ. . . . . . . . . . . . . . LD . . . LD . . . . . . . . .
Tráfico de datos (bloques) TICK. V.C.
Para conseguir el ticket / turno → 1 1
Para que entre un procesador en la sec. cr. → P 1
Al salir de la sección crítica → 0 1
En total → P + 1 3
Para que entren P → P+3) / 2 3P
El tráfico de datos se reduce considerablemente, pero en cambio se
necesita más memoria para implementar la sincronización (un vector de P
elementos).
Como hemos visto, existen diferentes alternativas para gestionar secciones
críticas (para generar funciones lock); por tanto el programador tendrá que
analizar las características de su aplicación y de la máquina paralela para
optar por la más adecuada.
Como ejemplo final, y a modo de resumen, el tráfico que se generará en el
bus, si tenemos P = 7 procesadores (en una máquina de 8) esperando a entrar
en una sección crítica, será el siguiente en función de la estrategia empleada:
T-T&S: P(3P–1) / 2 → 70 bloques LL/SC: P2 → 49 bloques
Tick.: P(P+3) / 2 → 35 bloques V.C.: 3P → 21 bloques
4.3 SINCRONIZACIÓN "PUNTO A PUNTO" MEDIANTE EVENTOS ▪ 141 ▪
4.3 SINCRONIZACIÓN "PUNTO A PUNTO"
MEDIANTE EVENTOS
Decimos que la sincronización es "punto a punto" si sólo toman parte en
la misma dos procesadores (o grupos): el primero avisa al segundo de que se
ha ejecutado determinada operación. La sincronización se suele ejecutar
mediante un bucle de espera activa sobre una variable común que hace las
veces de flag o indicador.
El flag o indicador es una variable de control que permite sincronizar
ambos procesos. Por ejemplo, en el caso de un productor y un consumidor,
la sincronización puede ser así:
P1 (productor)X = F1(Z);
aviso = 1;
P2 (consumidor)
while (aviso==0) {};
Y = F2(X);
(En algunos casos se puede usar el propio resultado como indicador; por ejemplo, si
sabemos que el resultado va a estar en un rango determinado, el consumidor puede
quedarse esperando mientras el resultado esté fuera de ese rango.)
La idea anterior (un flag de sincronización) puede extenderse y ejecutarse
en hardware (y así se ha intentado en algunas máquinas de tipo experimental
y en situaciones de paralelismo de grano muy fino), añadiendo a cada
posición de memoria un bit de control full/empty que indique si se ha escrito
un nuevo dato desde la última vez que se leyó el anterior o no. La
sincronización productor/consumidor se efectuaría de la siguiente manera: el
productor escribe en la posición de memoria un nuevo dato si el bit de
control asociado está a 0, y en ese caso lo pone a 1; el consumidor lee el
contenido de la posición de memoria si el bit de control está a 1, y en ese
caso lo pone a 0. No es una solución que se haya aplicado comercialmente,
ya que es cara (1 bit por cada posición de memoria), requiere instrucciones
especiales de memoria y presenta problemas en casos como, por ejemplo, un
productor y muchos consumidores.
La sincronización por eventos se efectúa mediante una escritura y un
bucle de espera. En algunos contextos, esas dos operaciones se indican
mediante dos funciones específicas. Por ejemplo:
▪ 142 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
flag = 1 → post(flag) [ signal(flag) ]
while (flag == 0) {} → wait(flag)
Esas funciones pueden generalizarse utilizando vectores de flags (vectores
de eventos):
post(vf,i) → activar el elemento i del vector de flags: vf(i) := 1
wait(vf,i) → esperar a que el elemento idel vector de flags vf sea 1
4.4 SINCRONIZACIÓN MEDIANTE BARRERAS
En la ejecución en paralelo de los programas suele ser muy habitual que
se necesite sincronizar un grupo de procesos entre sí de manera global, todos
a la vez; por ejemplo, para asegurar que todos los procesos han llegado a un
determinado punto en la ejecución del programa. Para ese tipo de
sincronización se utilizan barreras (barrier).
Para construir una barrera de sincronización se utiliza una variable
cerrojo, un contador y un flag. En la barrera se sincronizan P procesos.
Cuando los procesos llegan a la barrera, incrementan el valor de un contador
—en exclusión mutua— y se quedan esperando a que lleguen todos los
procesos. Cuando llega el último, activa el indicador de barrera abierta, y
todos los procesos abandonan la misma. Veamos algunos ejemplos.
4.4.1 Una barrera sencilla
El código siguiente representa una barrera de sincronización sencilla. Se
ha definido un struct, de tipo tipo_barrera, con tres variables: un
cerrojo, un contador y un flag para indicar el estado de la barrera, cerrada (0)
o abierta (1). Además de ello, se utiliza la variable local mi_cont, que
indica cuántos procesos han llegado a la barrera.
struct tipo_barrera
{
int cer; variable para el cerrojo
int cont; núm. proc. que han llegado a la barrera
int estado; estado de la barrera
};
struct tipo_barrera B; declaración de la barrera
4.4 SINCRONIZACIÓN MEDIANTE BARRERAS ▪ 143 ▪
BARRERA (B,P) P = número de procesos
{
LOCK(B.cer); entro en la sección crítica
if (B.cont == 0) B.estado = 0; soy el primero, cierro la barrera
B.cont++;
mi_cont = B.cont; cuántos hemos llegado a la barrera
UNLOCK(B.cer); salgo de la sección crítica
if (mi_cont == P) soy el último
{
B.cont = 0; inicializo el contador
B.estado = 1; abro la barrera
}
else while (B.estado == 0) {}; espero hasta que la barrera se abra
}
Los procesos que ejecutan la barrera incrementan el valor de B.cont,
dentro de una sección crítica. El primer proceso (B.cont = 0) cierra la
barrera, tras lo cual todos los procesos que entran en la barrera pasan a
esperar que la barrera se abra (B.estado = 1). El último proceso que llega
a la barrera (B.cont = P) la abre, y, como consecuencia de ello, todos los
procesos abandonan el bucle de espera.
Tras incrementar el contador dentro de la sección crítica, se utiliza la
variable mi_cont para decidir si hay que abrir la barrera o no. Dicha
variable es necesaria porque, tal y como está escrito el código, no se puede
utilizar, sin más, el contador B.cont, ya que en ese momento puede haber
otro proceso en la sección crítica incrementando dicho contador. Si se quiere
utilizar la variable B.cont, se debe mantener la sección crítica hasta
después de la comparación de la instrucción if, y luego terminar la sección
crítica (unlock) por las dos ramas del if (then y else).
4.4.2 Barreras reutilizables
¿Algún problema con la barrera anterior? Sí, si se utiliza de manera
repetida (por ejemplo, dentro de un bucle): cálculo / barrera / cálculo /
barrera..., lo cual es muy normal, ya que la barrera será normalmente una
función de biblioteca que llamarán los procesos una y otra vez.
Supongamos que se está ejecutando una barrera de sincronización. El
último proceso entra en la barrera y la abre, para que todos los procesos
▪ 144 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
salgan y continúen ejecutando el programa. Si en el código vuelve a aparecer
una llamada a la barrera, es posible que un proceso entre en esa segunda
llamada a la barrera cuando tal vez algún proceso no haya abandonado
todavía la anterior, porque, por ejemplo, no se ha enterado todavía de que la
barrera se ha abierto (no estaba en ejecución).
El primer proceso (B.cont = 0) que vuelva a entrar en la barrera la
cerrará (B.estado = 0) de nuevo. Por tanto, los procesos que se hayan
quedado en la primera barrera ya no podrán salir, y los que entren en la
segunda nunca llegarán a abrirla, porque no están todos. Claramente, hemos
llegado a una situación de deadlock.
¿Cómo evitar ese problema? Por ejemplo,
a. Utilizando un contador que cuente el número de procesos que
abandonan la barrera (de manera similar a como se hace al entrar).
Mientras no la abandonan todos, ningún proceso puede volver a
entrar.
Por una parte, la latencia de la barrera de sincronización puede ser
mayor (en ocasiones hay que esperar, aunque no hay que olvidar que
es el último proceso en llegar el que marca la latencia de la barrera), y,
por otra, puede haber una mayor competencia en la entrada de la
barrera (mientras se espera, se agrupan los procesos).
b. Utilizando valores diferentes, de barrera a barrera, para indicar que la
barrera está abierta (bit alternante, sense reversal). ¿Cuántos valores
diferentes habría que utilizar? Es suficiente con dos, 0 y 1, puesto que
no es posible tener más de dos instanciaciones simultáneas de la
misma barrera. Así pues, el flag que abre la barrera irá alternando de
valor de una a la siguiente.
Cada proceso utiliza una variable privada para saber el valor actual
que indica que la barrera está abierta; no usamos por tanto una
variable compartida como en el caso anterior (B.estado) para
indicar el estado de la barrera.
De acuerdo a esta segunda opción, la barrera quedaría así:
4.4 SINCRONIZACIÓN MEDIANTE BARRERAS ▪ 145 ▪
La variable val_sal es local, una por proceso, e indica el valor actual que permite
salir de la barrera.
BARRERA (B,P)
{
val_sal = !(val_sal); actualizar el valor del bit de apertura
LOCK(B.cer);
B.cont++;
mi_cont = B.cont;
UNLOCK(B.cer);
if (mi_cont == P) soy el último
{
B.cont = 0; inicializo el contador
B.estado = val_sal; abro la barrera
}
else while (B.estado != val_sal) { }; espero a que se abra la barrera
}
4.4.3 Eficiencia
Los criterios de eficiencia de este tipo de sincronización son los mismos
que en el caso anterior: la latencia debe ser baja (no hay que efectuar
muchas operaciones para entrar en la barrera), tiene que generarse poco
tráfico, debe escalar bien conel número de procesos, etc.
En lo que al tráfico que se genera en una barrera de P procesos se refiere,
podemos hacer la siguiente estimación. Supongamos que las variables de la
barrera (cer, cont y estado) se encuentran en bloques diferentes (para
evitar la falsa compartición). En general, el proceso Pi tiene que conseguir
cuatro bloques de datos: el de la variable cer, para entrar en la sección
crítica; el de la variable cont, para incrementar el contador; y el de la
variable estado dos veces, para quedarse en el bucle de espera, y para salir
del mismo, ya que ha sido anulado por el proceso que abre la barrera. Por
tanto, el tráfico generado será del orden de 4P bloques (para ser más
precisos, 4P – 2, ya que el primer y el último proceso necesitan un bloque
menos cada uno).
Analizado en el tiempo, el tráfico se va a repartir, en general, de la
siguiente manera: 2 - 3 - 3... - 3 - P–1; es decir, el tráfico que se genera al
entrar en la barrera suele estar repartido en el tiempo (suponiendo que no hay
▪ 146 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP
contención en la entrada a la barrera; si no, la función lock generará más
tráfico, tal como hemos visto en los apartados anteriores), pero las últimas
P–1 peticiones se generan a la vez, ya que todos los procesos (salvo el
último) están esperando a salir de la barrera; en ese momento, por tanto, la
latencia de servicio de los bloques será más alta.
Como en los casos anteriores, también aquí son posibles algunas
optimizaciones. El objetivo es reducir el número de procesos que acceden a
la misma variable, y para ello puede montarse una estructura en árbol,
binario por ejemplo (en un bus no se gana nada, ya que todas las
trasferencias aparecen en el bus, pero sí cuando la red de comunicación es de
otro tipo, no centralizada). En todo caso, el tipo de barreras que hemos visto
funcionan suficientemente bien en los sistemas SMP, y no es necesario,
salvo casos muy particulares, otro tipo de estructura.
Las barreras pueden implementarse también en hardware, si se dispone de
un bus específico de control. La implementación es similar al caso de la
línea de control sh (AND wired).
4.5 RESUMEN
Es habitual tener que sincronizar la ejecución de procesos que se ejecutan
en paralelo, para que el uso de las variables compartidas sea el adecuado. En
algunos casos, hay que utilizar secciones críticas, para lo que se utilizan unas
instrucciones específicas del lenguaje máquina del procesador que se pueden
ejecutar de manera atómica. De esta manera, se puede leer, modificar y
escribir una variable sin que interfiera ningún otro proceso (atómicamente).
En otros casos, la sincronización hay que implementarla mediante eventos,
punto a punto, o hay que sincronizar un conjunto de procesos, mediante
barreras.
Las diferentes instrucciones atómicas que se utilizan para operaciones de
sincronización son “similares”, y en un procesador concreto sólo tendremos
una o algunas de ellas. De todos modos, se puede “simular” el
comportamiento de unas mediante otras. Por ejemplo, se puede escribir una
rutina que simule el comportamiento de un F&I o de un T&S mediante las
instrucciones LL y SC. Pero no hay que olvidar que algunas pueden ser más
adecuadas que otras, en función del tráfico que generan (los bloques de datos
que se invalidan en las escrituras). Por ejemplo, una función lock
implementada simplemente mediante la instrucción T&S es adecuada si no
4.5 RESUMEN ▪ 147 ▪
hay contención (competencia) para entrar en la sección crítica, pero no es
nada eficiente si se espera una elevada contención.
En los sistemas SMP el tráfico es un problema importante, y, por tanto, se
han desarrollado diferentes estrategias para reducir el tráfico que generan las
funciones de sincronización: backoff, test-and-test&set, tickets, vectores de
cerrojos... Por tanto, es responsabilidad del programador seleccionar entre
las diferentes alternativas la más adecuada para su aplicación, bien sea
utilizando funciones de una biblioteca del sistema, o bien sea escribiendo
funciones específicas.
▪ 5 ▪
Consistencia de la Memoria en
los Computadores Paralelos
5.1 INTRODUCCIÓN
5.1.1 Sistemas de un solo procesador
¿En qué orden se ejecutan las instrucciones en un procesador? La
pregunta tiene más interés del que parece, y tal vez sin pensar mucho
podríamos responder: en el orden en que están en el programa. Sin embargo,
sabemos que eso no es verdad. Aunque el modelo de ejecución sigue siendo
von Neumann, se aplican muchas optimizaciones, tanto hardware como
software, que implican cambios en el orden de las instrucciones del
programa. Por ejemplo, la ejecución de las instrucciones está segmentada y
el inicio y final de las instrucciones no respeta el orden original (modelos
orden/desorden tipo scoreboard o Tomasulo, búferes de instrucciones en los
superescalares, etc.). Por parte del software, sabemos que el compilador
▪ 150 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
puede desordenar las instrucciones para obtener ejecuciones más eficientes
(list scheduling, trace scheduling, software pipelining...).
Lo más preocupante de esas reordenaciones está en las instrucciones de
memoria. La memoria del computador debería mantener en todo momento el
"estado actual" de la aplicación; es decir, debería reflejar en todo momento
hasta qué punto se ha llegado en la ejecución de la misma y los resultados
obtenidos. Pero sabemos que no es eso lo que ocurre. Por ejemplo, al utilizar
la memoria cache no se mantiene actualizada en todo momento la memoria
principal; admitimos que las instrucciones LD adelanten a las ST en
ejecución; se utilizan búferes de escritura para volcar contenidos de cache a
memoria principal y no parar al procesador; el propio compilador puede
eliminar algunos accesos a memoria y usar en su lugar datos de los registros
(por ejemplo, al desenrollar bucles con recurrencias), etc. De hecho, es en
ese tipo de optimizaciones donde se encuentra una de las razones del
aumento de velocidad de los procesadores actuales.
Así pues, la ejecución de un programa sigue su propio camino con el
objeto de lograr la mayor eficiencia posible. En todo caso, la ejecución del
programa debe ofrecer siempre exactamente los mismos resultados que si se
ejecutara en orden estricto. Por ejemplo cualquier lectura de memoria debe
obtener siempre lo escrito en dicha variable la última vez.
En los sistemas con un único procesador, todas las optimizaciones que
hemos comentado están bajo control de la única unidad de control del
sistema, y pueden llevarse así a buen puerto. No ocurre lo mismo, en
cambio, en los sistemas multiprocesador, donde el control de los procesos en
ejecución es esencialmente distribuido.
5.1.2 Sistemas multiprocesador
Lo que está resuelto en los sistemas de un procesador, se convierte en un
problema grave en los multiprocesadores, al estar el control descentralizado
entre todos los procesadores. De hecho, ¿en qué orden se ejecutan las
instrucciones en un sistema paralelo, considerándolo en su totalidad? ¿es el
resultado correcto en todos los casos?
Si la comunicación entre procesos se lleva a cabo en memoria principal,
por ejemplo, las cuestiones anteriores se reducen a esta otra: ¿en qué orden
se ejecutan las instrucciones de memoria en un sistema paralelo?
5.1 INTRODUCCIÓN ▪ 151 ▪
Al problema que hace referencia al orden de ejecución de las
instrucciones, y, en general, a la imagen que tienen los procesadores del
sistema de memoria se le conoce como el problema de la consistencia. El
problema de coherencia de los datos que hemos analizado en el capítulo 3
también hace referencia a estas cuestiones, pero de manera más limitada.
Recordemos que un protocolo de coherencia asegura que:
• los cambios que se efectúan en una variable en una determinada cache
aparecerán en algúnmomento en todas las caches.
• los cambios que se efectúan en una variable aparecen en el mismo
orden en todos los procesadores.
Así pues, al mantener la coherencia de los datos del sistema aseguramos
que todos los procesadores van a observar todos los cambios que se
produzcan en las variables compartidas. Sin embargo, no sabemos nada
sobre el orden en que se verán los cambios producidos en variables
diferentes.
5.1.3 Semántica de los programas y orden de
ejecución de las instrucciones
Antes de nada, es necesario estar seguros de la semántica de los
programas que se ejecutan en paralelo, entendidos como un todo, para evitar
que los resultados obtenidos nos sorprendan, para lo que es necesario
controlar adecuadamente el uso de las variables compartidas. El orden de
ejecución de las instrucciones es especialmente importante para entender el
comportamiento de un programa paralelo. Por ejemplo, ¿cuál será el
resultado en P2 al ejecutar este programa paralelo (inicialmente, A = B = 0)?
¿Tiene un significado claro el programa?
P1 P2
A = 1; (wr1)
B = 2; (wr2)
print B; (rd1)
print A; (rd2)
Tenemos cuatro combinaciones de resultados posibles: BA = 00, 01, 21 y
20. Según el orden en que se intercalen las instrucciones a lo largo del
tiempo, las tres primeras posibilidades pueden interpretarse correctamente.
Por ejemplo, P2 imprimirá BA = 01 si se ejecutan las instrucciones en este
orden en el tiempo:
▪ 152 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
P1 P2
A = 1;
...
B = 2;
...
...
print B;
...
print A;
En cambio, la cuarta combinación, BA = 20, parece “imposible”. Si B = 2,
entonces A debería ser siempre 1. Sin embargo, esa combinación es posible
si el control de P2 decide desordenar la ejecución de sus instrucciones (el
modelo de ejecución habitual es desorden/desorden): dos lecturas en
memoria pero sobre variables diferentes, es decir, completamente
independientes desde su punto de vista. Por tanto, tal como está escrito, el
programa anterior resulta muy ambiguo.
Como hemos analizado en el capítulo anterior, la semántica de los
programas paralelos se asegura normalmente mediante operaciones de
sincronización; de esa manera, en el siguiente ejemplo deberíamos obtener
siempre A = 1 en P2.
P1 P2
A = 1; (wr1)
LISTO = 1; (wr2)
while (LISTO == 0) {}; (rd1)
print A; (rd2)
A pesar de ello, podemos seguir teniendo problemas con el orden de las
instrucciones. Las dependencias de datos del programa deberían asegurar el
resultado correcto, pero, desgraciadamente, esas dependencias se producen
entre programas (procesadores) diferentes, y no dentro del mismo programa.
wr1 (A) rd1 (LISTO)
wr2 (LISTO) rd2 (A)
Si se respeta el orden de las instrucciones en cada procesador, es decir, el
orden entre lecturas y escrituras en memoria (wr1 >> wr2; rd1 >> rd223),
entonces se respetará también la dependencia wr1 → rd2, ya que tenemos
que: wr1 >> wr2 → rd1 >> rd2 ⇒ wr1 → rd2. Pero si no (si el compila-
dor o el hardware desordenan el código), podría ser que obtuviéramos A = 0.
23 Utilizamos el símbolo >> para indicar orden entre operaciones: A >> B indica que A debe ejecutarse
antes que B. El símbolo → indica una dependencia de datos: A → B indica que el dato producido por
A se utiliza en B.
tiempo
5.1 INTRODUCCIÓN ▪ 153 ▪
El problema de la ordenación de las instrucciones se observa también en
este otro ejemplo (inicialmente, F1 = F2 = 0):
P1 P2
F1 = 1;
if (F2 == 0) then
< código >
...
F2 = 1;
if (F1 == 0) then
< código >
...
Las dependencias entre la instrucciones son las siguientes:
wr1 (F1) wr2 (F2)
rd1 (F2) rd1 (F1)
De acuerdo a la lógica secuencial, no es posible que ambos procesadores
ejecuten el código de la rama then, ya que hay que respetar
obligatoriamente la ordenación wr1 >> rd1 y wr2 >> rd2; pero si se
desordena el código en cada procesador (para lo que no hay problema
alguno, ya que no hay dependencias entre las instrucciones), es posible que
ambos procesadores pasen a ejecutar dicho código.
Los cambios de orden que hemos citado son muy habituales en los
sistemas de un solo procesador; más aún, son imprescindibles para lograr un
rendimiento adecuado del sistema.
5.1.4 Atomicidad de las instrucciones
Las operaciones de memoria son atómicas si mientras se efectúan no se
realiza ninguna otra operación en memoria. Además, la finalización de la
operación debe entenderse en su sentido más amplio, incluyendo los efectos
de la operación en el resto de los procesadores (por ejemplo, una escritura no
termina hasta que se han anulado todas las copias de ese bloque en el
sistema). Veamos un ejemplo.
P1 P2
A = 1; (wr1)
LISTO = 1; (wr2)
while (LISTO == 0) {}; (rd1)
print A; (rd2)
▪ 154 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
Aunque se mantenga el orden de las instrucciones, seguimos teniendo un
problema. El protocolo de coherencia nos asegura que los cambios
efectuados en P1 llegarán a P2, pero nada nos dice sobre el orden en que
llegarán, ya que se trata de dos variables diferentes24 Si el nuevo valor de
LISTO llega a P2 antes que el de A, entonces se imprimirá A = 0. Esto puede
ocurrir si las escrituras en memoria de P1 no son atómicas, y se continua con
la ejecución del programa (wr2) antes de que finalice “por completo” (las
consecuencias) la instrucción anterior. En definitiva: necesitamos saber
cuándo finaliza una instrucción de memoria antes de poder empezar con otra.
El problema es incluso más general. En este ejemplo también aparece la
necesidad de atomicidad (A = B = 0):
P1 P2 P3
A = 1;
while (A == 0) {};
B = 1;
while (B == 0) {};
C = A;
Cuando P1 escribe en A, el nuevo valor aparecerá en algún momento en
P2 y P3. Al llegar a P2 se ejecutará B = 1, cuyo nuevo valor también llegará
a P3. ¿Cuál de los dos cambios se hará efectivo en primer lugar en P3? Si el
primero es el cambio producido en P2, entonces es posible que finalmente se
ejecute en P3 C = 0 y no C = 1.
La atomicidad de las operaciones de memoria es un problema global, y
debe mantenerse en cada procesador y en el sistema global.
5.1.5 Modelos de consistencia
El sistema paralelo debe ofrecer exactamente los mismos resultados que el
de un solo procesador al ejecutar un determinado programa, es decir, debe
ser consistente. Como hemos visto en los ejemplos anteriores, el problema
corresponde a las operaciones de memoria (principalmente en el acceso a
variables compartidas), debido a los cambios de orden y a la falta de
atomicidad.
24 Los “mensajes / señales de control” enviados de un procesador a otro pueden llegar al destino en
desorden, en función de la red y de los protocolos de comunicación. Eso es muy claro en los sistemas
de memoria distribuida, pero también puede darse en los sistemas SMP (con bus) en función del tipo
de bus y del protocolo de comunicación.
5.2 CONSISTENCIA SECUENCIAL (SC, sequential consistency) ▪ 155 ▪
Tanto los programadores de software del sistema como los de aplicaciones
necesitan un modelo que especifique el orden y la atomicidad de las
instrucciones que se ejecutan en paralelo, para saber qué optimizaciones
pueden hacerse en el código y para poder interpretar adecuadamente el
comportamiento de los programas. Un modelo de consistencia debe definir
un espacio de memoria “coherente” para todos los procesadores,
especificando las relaciones de orden que van cumplir las operaciones de
memoria. En los próximos apartados vamos a presentar los principales
modelos de consistencia, primeramente el modelo de consistencia
secuencial, y luego los modelos relajados.
5.2 CONSISTENCIA SECUENCIAL (SC, sequential consistency)
Como ya hemos comentado, la consistenciano es un problema grave en
los sistemas de un solo procesador: sólo existe un flujo de instrucciones y el
orden de las instrucciones está bajo control. El compilador puede efectuar
cambios en el orden de las instrucciones (por ejemplo, adelantar lecturas), y
disponemos de hardware para parar el procesador y resolver las
dependencias de datos.
El modelo de consistencia secuencial (SC) consiste en extender al
multiprocesador el modelo de orden estricto de un procesador. Un
multiprocesador es secuencialmente consistente si: (a) se mantiene el orden
local de las instrucciones en cada procesador y (b) el orden de las
instrucciones en todo el sistema (global) corresponde a un determinado
entrelazado de las instrucciones de cada procesador.
El modelo SC es el que normalmente espera un programador, el más
intuitivo. El modelo impone dos condiciones:
1. Hay que mantener el orden local en cada procesador.
Esto implica que no se pueden desordenar las instrucciones LD y ST.
Hay que mantener, por tanto, las cuatro relaciones de orden siguientes,
para cualquier dirección y en todos los procesadores:
wr >> rd; wr >> wr; rd >> rd; rd >> wr.
Por ejemplo, los dos primeros casos del siguiente ejemplo respetan el
modelo SC, mientras que los otros dos no, porque no se mantiene el
orden local (operaciones de memoria).
▪ 156 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
P1 P2 En conjunto SC si SC no
a
b
c
d
a a a c
b c d d
c b c b
d d b a
2. También hay que mantener el orden global, por lo que no puede
ejecutarse ninguna instrucción de memoria hasta que finalice la
anterior de cualquier procesador (y todas sus consecuencias). Para
poder asegurar esta condición es necesario que las operaciones de
memoria sean atómicas (write atomicity = todas las escrituras, en
cualquier posición, deben aparecer en el mismo orden en cualquier
procesador). No se admite, por tanto, esta situación:
En los ejemplos anteriores hemos puesto de manifiesto la necesidad de
estas condiciones. Así pues, con el modelo SC el problema de la consistencia
desaparece de raíz, ya que se impone un orden estricto, tanto local como
global, a todas las operaciones de memoria, junto con la atomicidad de
dichas operaciones. El modelo de memoria es por tanto de orden estricto, y
los programas paralelos se comportarán “tal como se espera”. En la figura
aparece un esquema lógico de la estructura que impone este modelo.
5.2.1 Orden y atomicidad de las instrucciones de
memoria
Imponer orden local en un procesador es relativamente sencillo, al menos
si sabemos cuándo ha terminado la operación anterior. La atomicidad en
cambio es más complicada, dado que cada procesador utiliza una cache
local. Para cumplir con el modelo SC se debe hacer lo siguiente:
tiempo
instrucción a
instrucción b
P P P P
MEM
orden
atomicidad
5.2 CONSISTENCIA SECUENCIAL (SC, sequential consistency) ▪ 157 ▪
1. Hay que mantener el orden de las instrucciones LD y ST en cada
procesador (sencillo de cumplir, ya que el orden de esas instrucciones
corresponde a una sola unidad de control, la de cada procesador).
2. Además del orden, para poder asegurar la atomicidad, hay que esperar
a que finalicen las operaciones de memoria de cada procesador antes
de poder ejecutar la siguiente. El final de una lectura (LD) es simple
de detectar: cuando se reciben los datos. Con las escrituras, en cambio,
el problema es más complejo. Cuando se ejecuta un ST, el procesador
no puede ejecutar otra instrucción de memoria hasta que la escritura y
sus consecuencias (invalidación o actualización de las copias) en
todos los procesadores se hayan ejecutado (write completion).
Para asegurar que se han efectuado todas las invalidaciones o
actualizaciones es necesario complicar el protocolo de coherencia,
añadiendo respuestas a dichas acciones: señales o mensajes de
“confirmación” tipo ACK (acknowledgement). Tras actualizar sus
copias, cada procesador “envía” un mensaje de ese tipo; al recibirse
todos los mensajes, la operación se da por finalizada (problema:
¿cómo saber cuántas copias hay?)
3. Para asegurar la atomicidad hay que cumplir dos condiciones:
(a) Por un lado, los cambios en una variable han de verse en el
mismo orden en todos los procesadores. Por ejemplo,
P1 P2 P3 P4
A = 2;
B = 1;
A = 3;
C = 1;
while (B ≠ 1) {};
while (C ≠ 1) {};
reg1 = A;
while (B ≠ 1) {};
while (C ≠ 1) {};
reg2 = A;
Si los cambios en A (2 y 3) llegan en distinto orden a P3 y P4,
entonces el sistema no será consistente, ya que se asignarán
valores diferentes a reg1 y reg2, con lo que la escritura de A no
habrá sido atómica.
Cuando la red de comunicación del multiprocesador es un bus, el
propio protocolo de coherencia (el snoopy) y una gestión
adecuada del uso del bus permiten asegurar el orden de las
escrituras. Como vamos a ver en un próximo capítulo, cuando se
1. INV
2. ACK 2. ACK
1. INV
▪ 158 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
utilizan otro tipo de redes más generales, hay que utilizar
directorios para mantener la coherencia de los datos y el orden de
las escrituras.
(b) Y por otra parte, antes de ejecutar una operación de lectura hay
que esperar a que finalice totalmente la última operación de
escritura que se ejecutó sobre dicha variable (en general, en otro
procesador), así como todas sus consecuencias.
Si el protocolo de coherencia es de invalidación, esta operación no
es complicada: nuestra copia está invalidada, y por tanto tenemos
que pedir una nueva copia, que recibiremos cuando haya
terminado la operación. En cambio, si las copias se actualizan, el
proceso de coherencia se vuelve más complejo: tras enviar la
señal de actualización de la variable (1), hay que esperar a recibir
todas las confirmaciones (2), tras lo cual hay que enviar una nueva
señal indicando que ya se puede utilizar dicha variable (3), con lo
que finaliza la operación de escritura. La complejidad del
protocolo de actualización es la razón por la que no se suelen
utilizar protocolos de este tipo.
5.2.2 Efectos en el hardware y en el compilador
Las condiciones que hemos impuesto para mantener la consistencia, orden
y atomicidad, son muy “fuertes”: se complica el uso de la memoria cache y
además no se pueden aplicar las optimizaciones más habituales en el caso de
un solo procesador. Recuerda que mientras se efectúa una operación de
memoria en un procesador, nadie puede efectuar otra operación en memoria,
y las instrucciones de memoria vienen a representar un 25% - 35% del total.
Por ejemplo, debido a la necesidad de asegurar la atomicidad, no se
pueden utilizar búferes de escritura, ya que ello supone en definitiva la
posibilidad de adelantar las lecturas. De la misma manera, el compilador no
puede efectuar las reordenaciones de código típicas, si con ello se modifica
el orden de las operaciones de memoria. Y tampoco puede optimizarse el uso
de la memoria mediante la utilización de registros, (para ahorrarnos
operaciones LD/ST). Por ejemplo, esta optimización no funciona en un
multiprocesador:
1. BC 1. BC
2. ACK 2. ACK
3. seguir 3. seguir
5.3 MODELOS RELAJADOS (relaxed) ▪ 159 ▪
P1 P2 P1 P2
A = 1;
B = A;
(2 ST / 1 LD)
A = 0;
r1 = 1;
A = r1;
B = r1;
(2 ST)
A = 0;
La variable B puede tomar los valores 0 o 1 en el programa original; en el
segundo, en cambio, nunca se producirá el caso B = 0, ya que se ha
eliminado la lectura de A (básicamente, un adelanto del LD). No es posible
esa optimización en un modelo de consistencia secuencial.
Dado que el modelo impone condiciones muy restrictivas, podemos
intentar no cumplir con alguna de ellas en determinados casos. Por ejemplo,
podemos intentar ejecutar las instrucciones LD en modo especulativo, antes
de que haya finalizado el anteriorST. Si finalmente todo va bien, seguiremos
adelante sin más cuidados; pero si el bloque se ha anulado o actualizado en
el camino, habrá que echar marcha atrás (roll-back, de manera similar a
como se hace con las apuestas en los saltos). En todo caso, como vamos a
ver, es posible mantener la consistencia del sistema en muchas situaciones
sin utilizar tantas limitaciones.
5.3 MODELOS RELAJADOS (relaxed)
El conjunto de condiciones que impone en el modelo SC es suficiente para
asegurar la consistencia, pero no estrictamente necesario. Además, desde el
punto de vista de la eficiencia, las repercusiones sobre el sistema son
grandes, al impedir muchas optimizaciones y obligar a esperar a la
finalización global de las operaciones de memoria antes de comenzar una
nueva. Por ello, y de cara a mejorar la eficiencia del sistema, se han
propuesto varios modelos de consistencia más flexibles, en los que se
eliminan algunas de las restricciones anteriores.
Analicemos las necesidades de orden de manera más fina. El orden de las
instrucciones de memoria se reduce a estos cuatro casos:
rd >> rd25 rd >> wr 26 wr >> rd wr >> wr
25 Considerando que las caches no se bloquean en los fallos
26 Cuidado con los tres casos siguientes: si es la misma dirección, estamos ante un caso de dependencia
de datos.
▪ 160 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
El modelo de consistencia secuencial impone el orden en los cuatro casos.
Los modelos relajados, en cambio, permiten que no se respete alguno de
ellos. Para definir un modelo de consistencia relajado hay que indicar:
• qué orden se respeta y cuál no entre las instrucciones de memoria.
• si se cumple o no la atomicidad de las escrituras en memoria (ST), lo
que permitiría efectuar una lectura aunque no hayan concluido los
efectos de la escritura anterior en todos los procesadores.
En todo caso, cuando se utiliza un modelo de consistencia relajado, tiene
que existir siempre la posibilidad de dejar en suspenso las optimizaciones e
imponer el orden estricto. Para ello se suelen utilizar nuevas instrucciones
máquina del procesador (normalmente a través de funciones de biblioteca
del sistema). Estas instrucciones se denominan barreras de ordenación
(fence), y se utilizan como puntos de control. Una instrucción de este tipo
impone un determinado orden en las instrucciones de memoria y asegura que
las instrucciones posteriores no comienzan hasta que no hayan finalizado
todas las anteriores.
Las instrucciones concretas tipo fence dependen del procesador en
particular, y pueden llamarse MEMBAR, STBAR, SYNC... En general suelen
ser de alguno de los siguientes tres tipos:
• Write-fence: para asegurar que todas las escrituras (ST) anteriores ha
finalizado en todo el sistema antes de que comience ninguna escritura
posterior (es decir, para imponer el orden wr >> wr).
• Read-fence: misma función que la anterior, pero con las lecturas (se
utilizan normalmente para evitar el adelantamiento de los LD).
• Memory-fence: misma función, pero para ambas operaciones, rd y
wr.
Por definición, si el modelo de consistencia es el secuencial, entonces
todas las operaciones de memoria se tratan como instrucciones tipo fence.
5.3.1 Total Store Ordering (TSO) / Processor
Consistency (PC)
El objetivo de esta primera optimización es “esconder” la latencia de las
escrituras en memoria, y para ello se admite que se ejecute una instrucción
LD aunque no haya finalizado un ST anterior; es decir, se permite el
5.3 MODELOS RELAJADOS (relaxed) ▪ 161 ▪
adelantamiento de los LD: no se asegura el orden wr >> rd. La única
diferencia entre los modelos TSO y PC es que en el caso del modelo
Processor Consistency no se asegura que las operaciones de memoria sean
atómicas.
El esquema de memoria correspondiente sería, esquemáticamente, el
siguiente:
En el modelo TSO se utiliza una cola para las instrucciones ST (y SWAP,
T&S...), es decir, escrituras, donde se asegura el orden de dichas operaciones
(FIFO). Las instrucciones LD, en cambio, pueden adelantar dicha cola (o
cortocircuitar resultados), siempre que no haya una dependencia de datos. En
cambio, un ST no puede adelantar nunca un LD, ni tampoco se pueden
adelantar los LD entre sí. De esta manera, una instrucción LD bloquea el
acceso a memoria de las siguientes instrucciones.
Por ejemplo, el significado de la ejecución de este programa es el
siguiente en función del modelo de consistencia:
P1 P2
X = nuevo_valor;
Y_copia = Y
Y = nuevo_valor;
X_copia = X
SC → por lo menos una de ellas, Y_copia o X_copia, tendrá el valor nuevo.
TSO → podría ocurrir que ni Y_copia ni X_copia tuvieran el nuevo valor.
Por definición, bajo el modelo TSO/PC no se mantiene la consistencia
secuencial y, por tanto, no se asegura que el comportamiento de los
programas sea el “adecuado” en todos los casos. Tal vez sea necesario
búferes ST
(FIFO)
LD ST
P P P P
MEM
ST ST ST
▪ 162 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
imponer el orden estricto (SC) en algunos puntos del programa, para lo que
habrá que utilizar las instrucciones especiales que hemos comentado (fence).
Si el procesador no dispone de instrucciones de ese tipo, entonces pueden
utilizarse instrucciones read-modify-write (por ejemplo, T&S) en lugar de los
ST (LD) habituales, ya que esas instrucciones implican una lectura y una
escritura, y por tanto no pueden desordenarse si el modelo de consistencia es
TSO/PC:
ST ... LD → SWAP ... LD
se pueden desordenar no se pueden desordenar
El modelo TSO es adecuado para aprovechar la latencia de las escrituras,
y bajo el mismo funciona bien la habitual sincronización mediante un flag:
write A; write FLAG // read FLAG; read A. Ha sido utilizado en
numerosas máquinas: Sequent Balance, Encore Multimax, (IBM 370),
SparcCenter2000, SGI Challenge, Pentium Pro (PC), etc.
5.3.2 Partial Store Ordering (PSO)
En este modelo de consistencia, menos restrictivo que el anterior, se
elimina también la restricción de orden entre escrituras; es decir, no se
aseguran las relaciones de orden wr >> rd, wr. La implementación es
similar a la del modelo anterior, pero las colas para las instrucciones ST no
se gestionan en modo FIFO, con lo que no se asegura el orden de las
escrituras.
Hay que tener cuidado, ya que al aplicarse este modelo puede no
funcionar correctamente la típica sincronización productor/consumidor
mediante un flag. Por tanto, hay que analizar con cuidado si merece la pena
su aplicación, evaluando, como siempre, lo que esperamos ganar y lo que
podríamos perder. Como en el caso anterior, en algunos momentos puede
que sea necesario imponer orden a las operaciones de memoria (en este caso
para mantener también el orden wr >> wr), para lo que se utilizan las
instrucciones especiales de ordenación (fence).
Este modelo de consistencia se ha utilizado, por ejemplo, en el Sun Sparc
PSO.
5.3 MODELOS RELAJADOS (relaxed) ▪ 163 ▪
5.3.3 Modelos más relajados
El problema del orden (consistencia) de las instrucciones de memoria sólo
aparece en los accesos a variables compartidas, y no en el resto. Más aún; en
los siguientes dos casos, por ejemplo, no sería necesario asegurar el orden en
todos los accesos a memoria:
P1 P2 P1 / P2 / ... / Pn
X = X + 1;
Y = B + 1;
flag = 1;
...
...
while (flag == 0) {};
A = X / 2;
B = Y;
...
lock(cer);
yo = i;
i = i + N;
j = j - 1;
unlock(cer);
...
En realidad, bastaría con asegurar el orden en relación a las operaciones
de sincronización; asegurado eso, da igual en qué orden se ejecuten el resto
de las operaciones de memoria. (p.e., sólo habrá un procesador en la sección
crítica).
Decimos que se utiliza programación sincronizada si el uso de las
variables compartidas se “protege” mediante operacionesde sincronización,
tal como aparece en los ejemplos anteriores. En caso contrario, es posible
que aparezcan “carreras de datos” (data-races), haciendo que los resultados
obtenidos dependan, por ejemplo, de la velocidad del procesador. Por eso, la
mayoría de los programas paralelos utilizan alguna función de
sincronización para “ordenar” el acceso a las variables compartidas:
funciones lock y unlock, flags, etc.
De ser así, para mantener la consistencia (en su sentido más intuitivo),
bastaría con asegurar el orden de las operaciones de memoria con relación
a las de sincronización, junto con el de las de sincronización entre sí.
Nos interesa, por tanto, distinguir los accesos “estándar” a memoria (rd,
wr) y los accesos a variables de sincronización (s). Así, junto con las
relaciones de orden entre operaciones rd y wr, tendremos que mantener
también estas otras:
rd, wr >> s s >> rd, wr s >> s
Para aplicar un tratamiento específico a las operaciones de sincronización,
habrá que identificarlas convenientemente (por hardware y/o por software).
▪ 164 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
5.3.3.1 Weak Ordering (WO)
En el modelo de consistencia Weak Ordering se admite cualquier orden en
las operaciones de memoria que no sean de sincronización, mientras que se
impone orden estricto a estas últimas (que se van a comportar como si fueran
instrucciones de tipo fence). En resumen, antes de ejecutar una operación de
sincronización hay que esperar a la finalización (global) de todas las
operaciones de memoria anteriores; de igual manera, las operaciones de
memoria posteriores deberán esperar a que finalice por completo la
operación de sincronización.
Estas son pues las relaciones de orden a mantener:
rd / wr >> s; s >> rd / wr; s >> s.
Como en los dos casos anteriores, si se necesita imponer el orden estricto
en una determinada zona del programa, una de dos: o se usan instrucciones
fence o, si no existe esa posibilidad, se identifican como operaciones de
sincronización las instrucciones LD o ST correspondientes.
5.3.3.2 Release Consistency (RC)
Se trata del modelo de consistencia más flexible. Como en el caso
anterior, son las operaciones de sincronización las que van a marcar los
puntos de ordenación del programa; entre ellas, los LD y ST pueden
ejecutarse en cualquier orden. Pero además, las operaciones de
sincronización se dividen en dos tipos: adquisición (acquire, sa) y
liberación (release, sr). Las operaciones sa son lecturas (u operaciones
RMW), y las operaciones sr escrituras (u operaciones RMW). Por ejemplo,
una función de lock es una operación de sincronización de tipo acquire,
mientras que unlock es de tipo release.
Junto con el orden entre operaciones de sincronización (s >> s), se deben
mantener estos otros:
• las operaciones de memoria posteriores a operaciones de
sincronización tipo adquisición (acquire) deben esperar a que
terminen éstas; es decir, hay que mantener el orden sa >> rd / wr.
rd ...wr ...
sinc
rd ...wr ...
sinc
rd ...wr ...
5.3 MODELOS RELAJADOS (relaxed) ▪ 165 ▪
• antes de ejecutar una operación de sincronización tipo liberación
(release), el procesador debe esperar a que finalicen todas la
operaciones de memoria anteriores; es decir, hay que mantener el
orden rd / wr >> sr.
Estos dos últimos modelos de consistencia son adecuados en los casos de
planificación dinámica de las instrucciones (desorden/desorden), puesto que
se acepta la finalización en desorden de las instrucciones LD y el
adelantamiento de los ST. Los procesadores Alpha, IBM PowerPC, MIPS
utilizan un modelo de consistencia de este tipo (en muchos casos no se aplica
ningún modelo concreto, y se deja al usuario que defina el modelo que desea
mediante el uso de instrucciones fence).
En la siguiente tabla se resumen las características principales de los
modelos de consistencia.
Modelo
Orden de las operaciones de memoria Instrucc. para
imponer orden wr>>rd wr>>wr rd>>rd/wr sinc. wr atom.
SC todas
TSO todas MEMBAR, RMW
PC todas MEMBAR, RMW
PSO todas STBAR, RMW
WO todas SYNC
RC
sa >> w/r
w/r >> sr
s >> s
REL, ACQ, RMW
rd ...wr ...
s_acq
rd ...wr ...
s_rel
rd ...wr ...
▪ 166 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
En el ejemplo de la siguiente figura aparecen remarcadas las restricciones
de orden que impone cada modelo.
SC
wr,rd,s >> wr,rd,s
TSO/PC
– wr >> rd
PSO
– wr >> wr
WO RC
– rd >> wr, rd
rd
wr
sinc_a
wr
rd
sinc_r
wr
wr
= A
B =
sinc_acq
C =
= D
sinc_rel
E =
F =
= A
B =
sinc_acq
C =
= D
sinc_rel
E =
F =
= A
B =
sinc_acq
C =
= D
sinc_rel
E =
F =
= A
B =
sinc_acq
C =
= D
sinc_rel
E =
F =
= A
B =
sinc_acq
C =
= D
sinc_rel
E =
F =
5.4 RESUMEN Y PERSPECTIVAS
Para que los programas paralelos tengan una semántica clara, tanto el
hardware como el programador necesitan que el multiprocesador tenga una
“imagen de memoria” bien definida. A la imagen o interfaz de memoria del
multiprocesador se le denomina modelo de consistencia.
Existen dos tipos de modelos de consistencia: el secuencial y los
relajados. El primero, SC, impone el orden local y global de todas las
operaciones de memoria, así como la atomicidad de las mismas. Los
modelos relajados, en cambio, permiten el desorden de algunas de esas
operaciones; por ejemplo, pueden adelantarse los LD (TSO), o los LD y los
ST (PSO), o puede admitirse cualquier orden entre ellas pero respetando el
orden con relación a las operaciones de sincronización (WO). Cuando se
utilizan modelos de consistencia relajados, en algunos casos es necesario
imponer el orden estricto, para lo que se utilizan instrucciones especiales
denominadas fence.
5.4 RESUMEN Y PERSPECTIVAS ▪ 167 ▪
Si consideramos el rendimiento del sistema, los modelos relajados
debieran ser más eficientes que el modelo estricto SC, ya que en este caso no
pueden aplicarse muchas de las optimaciones más habituales, y, en
consecuencia, la eficiencia debiera ser menor. Pero como siempre, debemos
analizar los aspectos positivos y negativos de la aplicación de modelos de
consistencia relajados, ya que para poder aplicar estos modelos se necesita la
colaboración del hardware y del software (nuevas instrucciones, identificar
correctamente los puntos de ordenación dentro del programa, etc.).
Uno de los investigadores principales de estas cuestiones es Mark Hill. En
su opinión, los multiprocesadores deberían utilizar SC como modelo básico,
y, tal vez, ofrecer como alternativa un modelo relajado. ¿Por qué?
En los procesadores actuales es habitual el uso de ejecución especulativa
de las instrucciones. Las instrucciones se ejecutan sin estar seguro de que
hay que hacerlo. Cuando su ejecución se convierte en segura, se escriben los
resultados, las instrucciones se dan por finalizadas y se retiran del
procesador (commit); en caso contrario, si se comprueba que no había que
haberlas ejecutado, entonces hay que deshacer el efecto de esas instrucciones
(en muchos casos, ejecutando procedimientos de roll-back), y volver a un
punto “seguro” en la ejecución del programa.
Siendo eso así, aunque el modelo de consistencia sea SC podrían aplicarse
las optimizaciones habituales, en la medida en que se haga de manera
especulativa; si no resultan adecuadas, tendremos la posibilidad de
deshacerlas. ¿En qué se diferenciarían entonces ambos tipos de consistencia?
Pues en que, en el caso de los modelos relajados, las instrucciones se
retirarían antes del procesador, ya que no habría que esperar a saber si la
reordenación efectuada ha sido correcta o no.
Siempre es necesario medir las hipotéticas ventajas de cualquier tipo de
estrategia utilizando programasreales o bancos de pruebas. Algunos
experimentos realizados muestran que los tiempos de ejecución pueden
llegar a ser un 10% - 20% menores en el caso de los modelos relajados que
en el modelo SC. ¿Merece la pena esa mejora? ¿Aceptan los diseñadores de
middleware (software del sistema, aplicaciones en bajo nivel...) la
complejidad inherente al uso de modelos de consistencia relajados? Por
ejemplo, corresponde a los diseñadores de compiladores introducir las
instrucciones fence (las estrictamente necesarias y no más, para no perder
eficiencia); para facilitar la portabilidad del software hay que implementar
adecuadamente todos los modelos para poder trabajar en plataformas
▪ 168 ▪ Capítulo 5: CONSISTENCIA DE LA MEMORIA EN LOS SISTEMAS PARALELOS
hardware diferentes; etc. Programar en paralelo es difícil en sí mismo, y más
aún si hay que considerar modelos relajados de consistencia.
En resumen: el modelo SC es el estándar en todos los multiprocesadores,
los problemas de consistencia se resuelven en hardware y son transparentes
para el programador. Como segunda alternativa, el modelo TSO parece el
adecuado para poder aplicar las optimizaciones más habituales (adelantar los
LD) y sus efectos sobre el programador son bajos. Los modelos que eliminan
cualquier restricción en el orden de las operaciones de memoria parecen más
difíciles de justificar.
▪ 6 ▪
La Red de Comunicación de
los Computadores Paralelos.
Comunicación mediante
Paso de Mensajes.
6.1 INTRODUCCIÓN
Los principales componentes de un sistema paralelo son los computadores
(procesador, memoria...), la red de comunicación y el interfaz entre ambos.
En los capítulos anteriores hemos analizado los multiprocesadores que
utilizan un bus como red de interconexión (sistemas SMP). En estos sistemas
el espacio de direccionamiento es común para todos los procesadores y el
tiempo de acceso a cualquier posición de memoria es el mismo desde
cualquier procesador. Pero el bus no es una red adecuado para interconectar
los procesadores de un sistema paralelo cuando el número de nodos es
▪ 170 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
elevado, ya que la cantidad de tráfico que puede soportar un bus es limitada.
Dicho tráfico, sin embargo, crece según aumenta el número de procesadores
del sistema, y, además, no de manera lineal, por lo que los sistemas SMP
más habituales suelen ser de 4 – 8 procesadores (o, como mucho, hasta 32).
Para construir sistemas con centenares o miles de procesadores tenemos
que utilizar otras estructuras y otro tipo de redes de comunicación. Sea
privada o sea compartida, es necesario distribuir la memoria entre los
diferentes nodos del sistema, con lo que ya no podremos usar un bus como
red de conexión. En este capítulo vamos a analizar las redes de interconexión
que se utilizan en los sistemas paralelos, tanto de memoria compartida
(DSM) como de memoria privada (MPP).
La necesidad de comunicación entre procesadores no surge con los
sistemas MPP. Las redes de ordenadores son "antiguas" en el mundo de la
informática; por ejemplo, la red ARPANET es de 1969. Sin embargo, a
pesar de compartir ciertos aspectos con las redes denominadas LAN y WAN
(local & wide area network), las redes de interconexión para sistemas MPP
son especiales, ya que la necesidad de comunicación es mucho mayor, y,
además, se debe llevar a cabo en mucho menos tiempo. Aunque en los dos
casos se pasa información entre los procesadores, no se pueden comparar las
necesidades de comunicación asociadas a la resolución de un sistema de
ecuaciones mediante un sistema paralelo y el envío de un mensaje de correo
electrónico entre dos usuarios de Internet. En el primer caso, la
comunicación se deberá completar en microsegundos, siendo la latencia un
aspecto crítico para lograr una ejecución adecuada del problema; en el
segundo caso, en cambio, no. Otro aspecto que diferencia ambos ámbitos es
la distancia entre los procesadores, que en las redes LAN y WAN suele ser
mucho mayor (puede variar desde varios metros a kilómetros) que en los
sistemas MPP (como máximo, de unos pocos metros).
En cualquier caso, no podemos olvidar arquitecturas intermedias de gran
difusión como los clusters o similares, especialmente interesantes desde el
punto de vista del binomio coste/rendimiento. En esos sistemas se están
utilizando tanto redes de banda ancha provenientes del mundo de las redes
de computadores (por ejemplo, Gigabit Ethernet), como redes de diseño
específico, más rápidas y más caras (por ejemplo, InfiniBand o Myrinet).
La función de una red de comunicación en un sistema MPP es clara: en
función del algoritmo que se está ejecutando en el sistema paralelo, llevar la
información de un procesador a otro. Además, la latencia de la
comunicación debe de ser lo más baja posible, se deben admitir muchas
6.2 TOPOLOGÍA DE LA RED ▪ 171 ▪
comunicaciones simultáneamente, el coste de la red debe ser bajo, y, en
cierta medida, el sistema debe mantenerse operativo a pesar de que pueda
haber algunos fallos en la red. Y si es posible, todo lo anterior debería ser
independiente del número de procesadores que estén conectados al sistema.
La red de comunicación de los sistemas paralelos que hemos analizado
hasta el momento ha sido muy simple: un bus. Desde el punto de vista del
coste, un bus es una red adecuada (barata), pero tiene muchos
inconvenientes. Cuando se utiliza un bus como mecanismo de interconexión
los procesadores deben compartir en el tiempo el ancho de banda del bus (no
se pueden enviar dos mensajes a la vez); por este motivo, no se pueden
conectar demasiados procesadores mediante un bus, ya que cada vez se
producirían más problemas en la comunicación (colisiones), y, en
consecuencia, las latencias de las comunicaciones crecerían excesivamente.
Si nos vamos al otro extremo, y conectamos todos los procesadores con
todos, obtenemos un crossbar. Desde el punto de vista del rendimiento de
las comunicaciones es la mejor red posible, ya que cada procesador tiene un
enlace privado con todos y cada uno de los procesadores del sistema; los
componentes de la red no se deben compartir, por lo que la latencia de las
comunicaciones va a ser mínima. Evidentemente, el coste de esta red es muy
alto, y, además, dicho coste crece exponencialmente con el número de
procesadores a interconectar. Por tanto, debemos encontrar otras formas de
conectar un número elevado (miles) de procesadores.
En una red de comunicación podemos distinguir dos aspectos. Por un
lado, el hardware: los conmutadores o encaminadores de mensajes, los
enlaces o links, y el interfaz (conexión del procesador con la red). Por otro
lado, el “software”: protocolos de comunicación, a diferentes niveles. Para
definir una red, se deben concretar diferentes aspectos: la topología, el
algoritmo de encaminamiento, la estrategia de conmutación, el control de
flujo... En los próximos apartados vamos a analizar estos aspectos,
comenzando por la topología de una red, y terminando con los conflictos de
comunicación en este tipo de redes.
6.2 TOPOLOGÍA DE LA RED
La topología de la red de comunicación determina las conexiones
existentes entre los procesadores, y podemos analizarla desde el punto de
vista de la teoría de grafos. Los nodos del grafo representan a los
procesadores (o, más precisamente, a los gestores de la comunicación), y los
▪ 172 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
arcos del grafo se corresponden con los enlaces de la red. El comportamiento
de la red se puede estudiar utilizando diferentes parámetros topológicos. Por
tanto, antes de analizar las redes más utilizadas, definamos algunos de estos
parámetros.
• Distancia media, d: es la media de las distancias entre todos los
posibles pares de nodos. Para calcular la distancia entre dos nodos se
contabiliza el número de nodos que hay que atravesar para ir desdeel
nodo origen al nodo destino utilizando el camino de longitud mínima.
Por tanto:
)1(
1,
,
−
=
∑
=
PP
d
d
P
ji
ji
• Diámetro, D: es la distancia máxima que existe entre cualquier par de
nodos del grafo (tomando la distancia mínima de cada par de nodos).
El diámetro está relacionado con la latencia máxima que podría tener
una comunicación, en ausencia de otros problemas (tráfico, etc.).
Ambos parámetros, distancia media y diámetro, nos dan una primera
aproximación a la latencia de las comunicaciones en la red (en
promedio y en el caso peor) cuando la comunicación es aleatoria.
• Grado: indica el número de enlaces que tiene un nodo (procesador).
Si el grado de todos los nodos es el mismo, se dice que la red es
regular. Interesa que las redes estén compuestas por nodos con grados
relativamente bajos (grado 4, por ejemplo), ya que si el grado es alto
el número de conexiones también lo será, con lo que la
implementación de la red puede llegar a ser muy compleja.
• Simetría: cuando todos los nodos tienen la misma visión de la red se
dice que la red es simétrica. Es una característica deseable, ya que
facilita la elección de los caminos para la comunicación.
• Escalabilidad: una red de comunicación debería ser fácilmente
ampliable, para permitir aumentar, sin grandes problemas, el número
de procesadores.
• Tolerancia a fallos: una red de comunicación debe ser segura; el
sistema debe seguir funcionando aunque algún componente de la red
deje de funcionar. Se trata de una condición imprescindible si el
número de nodos es elevado, porque la probabilidad de que algún
6.3 REDES FORMADAS POR CONMUTADORES ▪ 173 ▪
nodo falle crece con el número de nodos del computador. En la misma
línea, es una condición básica para aquellos sistemas que deben estar
siempre en funcionamiento y cuyo mantenimiento es muy difícil o
imposible.
• Conectividad: la conectividad de una red determina el número
mínimo de enlaces o nodos —arco-conectividad o nodo-
conectividad— que se deben estropear para que la red quede dividida
en dos o más trozos. Es un parámetro relacionado con la tolerancia a
fallos.
• Bisección de la red: es el número mínimo de enlaces que se deben
cortar (eliminar) para dividir la red en dos partes iguales. Como
veremos más adelante, este parámetro da una idea del tráfico máximo
(throughput) que puede gestionar una red (bajo unas determinadas
condiciones).
• Tipo de enlace: los enlaces entre nodos pueden ser unidireccionales
—es decir, A→B—, o bidireccionales —esto es, A↔B—. El tipo de
enlace tiene gran influencia en los parámetros de una red relacionados
con la distancia (es decir, con la comunicación).
En función del tipo de enlaces, las redes se suelen dividir a veces en
dos categorías: directed, si los enlaces tienen una dirección marcada; y
non-directed (o undirected), si los enlaces aceptan comunicación en
ambos sentidos.
En general, vamos a trabajar con enlaces bidireccionales.
Después de haber presentado algunos parámetros topológicos, analicemos
las principales redes de comunicación, tanto estáticas como dinámicas. A
pesar de que en la literatura existe una gran cantidad de propuestas, sólo
vamos a presentar las más utilizadas.
6.3 REDES FORMADAS POR CONMUTADORES
La función de la red de comunicación de un sistema paralelo es permitir la
comunicación entre procesadores (o entre procesadores y memoria), es decir,
transmitir datos. Las redes de transmisión de datos se han utilizado desde
hace mucho tiempo en otras áreas tecnológicas, más en concreto en la
telefonía. Por ello, se ha aprovechado la experiencia acumulada durante años
▪ 174 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
en esa área y se ha adaptado el uso de las redes más habituales en telefonía,
redes construidas a partir de conmutadores, a los sistemas de cómputo
paralelo. A estas redes se les conoce también como redes dinámicas, porque
el camino de comunicación entre origen y destino se construye
dinámicamente (no existen enlaces fijos entre los nodos). Analicemos en qué
consisten y cómo se usan estas redes.
6.3.1 El conmutador (switch)
Comencemos describiendo qué es un conmutador, “dispositivo” que sirve
para construir redes dinámicas. El conmutador más simple, de grado k = 2,
es un dispositivo que conecta dos entradas (E0 y E1) y dos salidas (S0 y S1).
Mediante él, y en función de una señal de control, se pueden establecer las
siguientes cuatro conexiones27:
Por medio de las dos primeras conexiones, la información de una de las
entradas se distribuye a ambas salidas (ese tipo de comunicación se conoce
como broadcast). Sin embargo, las conexiones que más nos interesan son las
otras dos, en las que se conecta cada entrada con una salida, seguidas o
cruzadas, para transmitir información.
Visto desde otro punto de vista, mediante un conmutador se consigue una
“permutación” de las entradas en las salidas. Por ejemplo, con un
conmutador de grado 2 podemos establecer las dos siguientes conexiones:
(0, 1) → (0, 1) o (0, 1) → (1, 0), es decir, las dos permutaciones de las
entradas.
27 En su versión más simple, un conmutador está formado por unos multiplexores y la lógica necesaria
para su control.
E0 → S0, S1 E1 → S0, S1
E0 → S0
E1 → S1
E0 → S1
E1 → S0
0
1
0
1
Conmutador
k = 2
E0
E1
S0
S1
Señales de control
6.3 REDES FORMADAS POR CONMUTADORES ▪ 175 ▪
Las conexiones que se crean en cada conmutador son "dinámicas", ya que
van a ir cambiado en el tiempo en función de las necesidades de
comunicación.
6.3.2 Red crossbar
Tal y como hemos comentado anteriormente, la red de comunicación ideal
sería la que permitiera, en cualquier momento y en un solo "paso", que se
comunicaran cualquier par de procesadores simultáneamente. Según lo
hemos definido, un conmutador de P entradas cumple con esa condición:
permite efectuar P conexiones simultáneas. A esta red se la denomina
también crossbar.
Un crossbar puede construirse de muchas maneras, generalmente
mediante conmutadores de menor grado. En la figura se muestra un ejemplo
de implementación de un crossbar que conecta cuatro procesadores entre sí,
o con cuatro módulos de memoria, construido mediante conmutadores de
grado 2. Cada conmutador permite conectar una fila con una columna, y
ofrece la posibilidad de seguir o girar.
Tal como aparece en la figura, esta estructura de conmutadores permite
efectuar simultáneamente P comunicaciones (cualesquiera, siempre que los
destinos sean todos diferentes). Sin embargo, el coste de esta red es muy alto
si el número de procesadores es elevado, ya que el número de conmutadores
y de conexiones (la complejidad de la red) es del orden de P2; además, el
control tiene que ser centralizado.
Las redes de tipo crossbar se han venido utilizando, normalmente, con un
número pequeño de procesadores, aunque en ocasiones también en sistemas
con un número de procesadores elevado (por ejemplo, en el computador
Earth Simulator), a veces con conmutadores organizados en varias etapas.
Conmutadores
Procesadores o
módulos de memoria
0
1
2
3
0
1
2
3
▪ 176 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
6.3.3 Redes multietapa (multistage)
El coste de los conmutadores crece cuadráticamente con el número de
entradas. Por tanto, para reducir el coste de la red (para usar menos
hardware) es necesario organizar la red de otra manera, aunque con ello no
obtengamos la misma capacidad de comunicación y latencia reducida que
podemos conseguir con un crossbar.
Las redes dinámicas más utilizadas son las denominadas redes multietapa,
en las que las conexiones se establecen mediante conmutadores organizados
por niveles o etapas, tal y como se muestra en la siguiente figura. Las
conexiones entre etapa y etapa se pueden definir de múltiplesmaneras; en
función del esquema o patrón de conexionado se obtienen diferentes tipos de
redes: perfect shuffle, butterfly, cube connection, etc.
La red del dibujo es unidireccional (de izquierda a derecha); si se necesita que
sea bidireccional (p.e., para el caso de conexiones con módulos de memoria)
basta con superponer dos redes unidireccionales.
6.3.3.1 La red Omega
Como ejemplo de las redes multietapa veamos una de las más utilizadas:
la red Omega. Una red Omega con P entradas consta de logk P niveles o
etapas de conmutación, cada una con P/k conmutadores. Por tanto, el
número de conmutadores de una red Omega de grado k es (P/k) logk P,
mucho menor que el de un crossbar. Por ejemplo, un crossbar de 256 nodos
utiliza 2562 = 65.536 conmutadores de grado 2, mientras que la red Omega
correspondiente sólo utiliza 128 × 8 = 1.024; por contra, la latencia de las
Procesadores Procesadores
(o módulos de memoria)
Conexiones entre etapas
de conmutación
Conmutadores
0
P–1
0
P–1
6.3 REDES FORMADAS POR CONMUTADORES ▪ 177 ▪
conexiones es mayor en la red Omega, ya que hay que superar 8 etapas de
conmutación (una sola en el crossbar).
Las conexiones entre las etapas de conmutación siguen un esquema
denominado barajado perfecto (perfect shuffle), tal y como se muestra en la
figura (con conmutadores de grado 2).
El esquema de conexionado denominado barajado perfecto es muy
simple: para el caso de grado 2, las P entradas (de 0 a P–1) se dividen en dos
grupos por la mitad, y se reordenan de la siguiente manera: una de la primera
mitad (0), otra de la segunda mitad (P/2); una de la primera mitad (1), otra
de la segunda mitad (P/2+1); etc. Es decir, se efectúa la siguiente
permutación:
[0, 1, 2... P–1] → [0, P/2, 1, P/2+1, ... P/2–1, P–1].
En el caso general de grado k, se dividen en k grupos y se reordenan de la
misma manera que acabamos de comentar. Se puede comprobar que la
permutación de barajado perfecto se corresponde con una rotación hacia la
izquierda de la dirección binaria del origen. La tabla siguiente muestra dicha
rotación para el caso de 8 procesadores.
Barajado perfecto (perfect shuffle)
posición antigua nueva posición
(0) 000 → (0) 000
(1) 001 → (2) 010
(2) 010 → (4) 100
(3) 011 → (6) 110
(4) 100 → (1) 001
(5) 101 → (3) 011
(6) 110 → (5) 101
(7) 111 → (7) 111
0
2
4
6
1
3
5
7
0
2
4
6
1
3
5
7
0
0
1
2
3
5
6
7
4
0
1
Barajado perfecto
Red Omega
de 8 nodos
or
ig
en
de
st
in
o
▪ 178 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
Analicemos los parámetros topológicos de una red Omega. La distancia
media y el diámetro coinciden: todos los procesadores están a la misma
distancia, logk P, el número de etapas de la red. Por tanto, desde el punto de
vista topológico la latencia de todos los mensajes sería la misma: no se
puede aprovechar la “localidad” de las comunicaciones (la comunicación
suele ser más frecuente con los procesadores que están más "cerca").
La red es simétrica y regular. El grado de los conmutadores es fijo y el
mismo para todos (puede ser 2, como en la figura, pero son más habituales
los conmutadores de grado 4). La red no tiene tolerancia a fallos; tal como
vamos a ver a continuación, sólo existe un camino que comunique el nodo i
con el nodo j, por lo que si algo falla en dicho camino no es posible
establecer esa comunicación. En algunos casos, si no se rompe la red, una
comunicación entre i y j que no se puede establecer puede dividirse en dos
fases: se manda un mensaje de i a k, y se reenvía luego de k a j (aunque,
obviamente, la latencia total será mucho mayor). Si no, la única opción para
hacer frente a fallos en la red consiste en replicar el hardware (p.e. duplicar
los conmutadores, para que si uno de ellos falla podamos usar el otro).
6.3.3.2 Encaminamiento en la red Omega
La finalidad de una red de comunicación es, evidentemente, permitir la
comunicación entre los procesadores. Por ello, es importante que se pueda
conectar el origen y el destino de una manera fácil. Pero, ¿cómo se construye
una conexión (un camino) entre dos nodos de la red? A la construcción (o
elección) de un camino determinado se le denomina encaminamiento
(routing). En las redes Omega el encaminamiento es bastante simple.
Si analizamos el funcionamiento de los conmutadores (k = 2) y de la red
Omega, se puede ver que cuando se elige una salida en cada conmutador se
modifica el último bit de la dirección (posición): a 0, si se elige la salida de
arriba, o a 1, si se elige la de abajo. Además, en cada uno de los barajados
que se hace entre las etapas de conmutación se rota un bit de la dirección.
Por ello, para llegar al destino basta tener en cuenta la dirección de destino:
la salida escogida en cada nivel de conmutación se va correspondiendo con
los bits de la dirección de destino, comenzando por el bit de más peso.
Veamos un ejemplo (el de la figura anterior). Para ir desde el procesador 4
(100) al 2 (010), hay que elegir el siguiente camino en los conmutadores:
arriba (0), abajo (1) y arriba (0).
6.3 REDES FORMADAS POR CONMUTADORES ▪ 179 ▪
barajado barajado barajado
100 → 001 000 → 000 001 → 010 010
salida superior salida inferior salida superior
Otra alternativa para seleccionar el camino es calcular la función xor
entre las direcciones del origen y del destino. El resultado, denominado
routing record o registro de encaminamiento (RE), se debe utilizar de la
siguiente manera: en cada etapa, se procesa un bit del registro de
encaminamiento, comenzando por el de más peso; si es 0, se prosigue sin
cruzar; en cambio, si es 1, se cruza dentro del conmutador, eligiendo la
salida contraria.
Para el ejemplo anterior, 4 → 2: RE = 100 xor 010 = 110. Por tanto, en
las dos primeras etapas cruzar, y en la tercera no cruzar.
Sea utilizando la dirección de destino o el registro de encaminamiento, es
muy sencillo encontrar el camino para ir del nodo i al nodo j en una red
Omega. Sin embargo, existe sólo un camino para ir de un nodo a otro, lo
cual no resulta muy favorable si se quiere poder hacer frente a fallos de
funcionamiento de la red (o a situaciones de tráfico elevado).
6.3.3.3 Conflictos de salida y bloqueos
Uno de los objetivos de una red de interconexión es poder realizar más de
una comunicación a la vez, lo cual no es posible en el caso de un bus. Si
tenemos P procesadores en la red, en el mejor de los casos podríamos tener
P comunicaciones simultáneamente (por supuesto, siendo diferentes los
destinos); tal y como hemos comentado, una crossbar permite efectuar
siempre esas P comunicaciones.
Una red Omega también permite efectuar P comunicaciones
simultáneamente, pero, a pesar de tener toda la conectividad entre entradas y
salidas —ya que no se ponen restricciones al encaminamiento—, no es
posible efectuar esa comunicación en todos los casos, ya que en muchos de
ellos se generan conflictos en el uso de los recursos de la red.
Por ejemplo, en una red Omega no es posible realizar estas dos
comunicaciones a la vez: 0 → 1 y 6 → 0.
▪ 180 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
Como se puede observar, los dos mensajes quieren tomar la misma salida
en el segundo conmutador. Por tanto, surge un conflicto en dicha salida, y se
debe de tomar una decisión. La decisión puede ser una de estas dos: o se
rechaza una de las comunicaciones (y en muchos casos eso no se puede
permitir, ¿se enterará el emisor? ¿cuándo?), o se guarda uno de los
“mensajes” en un búfer hasta que quede libre la salida. En este último caso,
se complica el diseño del conmutador —hay que añadir búferes, autómatas
para gestionarlos...— y, en consecuencia, el tiempo de respuesta del
conmutador será más alto.
Si esos conflictos se producen a menudo, el rendimiento de la redde
comunicación no será muy bueno. Podemos calcular de manera sencilla
cuántas permutaciones (P comunicaciones simultáneas) se pueden realizar
sin conflicto en una red Omega en comparación con el número total de
permutaciones posibles. Para P procesadores, el número total de
permutaciones es P! (por ejemplo, para el caso de P = 3, son 6: 012 → 012,
021, 102, 120, 201, 210). En una red Omega (k = 2), cada conmutador ofrece
dos posibilidades (seguir o cruzar), y tenemos (P/2) × log2 P conmutadores;
por tanto, el número de permutaciones que se pueden realizar es:
2
log
2 22
PPP
P= [ en general, si el grado es k:
P
k
P
k
k
log
)!( ]
A medida que crece la red, el porcentaje de comunicaciones sin conflicto
se va reduciendo (P = 8 → 10%; P = 16 → 0,02%; en todo caso, se trata de
un número muy grande). Sin embargo, en muchas aplicaciones algunas de
esas permutaciones son mucho más comunes que otras (por ejemplo, la
permutación Pi → Pi+1 es muy habitual en muchas aplicaciones). Por eso, a
pesar de que no se puedan realizar todas las permutaciones, una red de
comunicación adecuada debe de poder realizar las más utilizadas.
0
2
4
6
1
3
5
7
0
2
4
6
1
3
5
7
6.3 REDES FORMADAS POR CONMUTADORES ▪ 181 ▪
Si al tener que efectuar una determinada permutación (por ejemplo, en una
máquina SIMD) supiéramos que se van a generar conflictos por los recursos,
podríamos desdoblar la permutación en dos permutaciones que sí se
pudieran realizar. Tal como hemos comentado, esa misma solución se puede
aplicar para hacer frente a los fallos de funcionamiento de la red.
En resumen: una red Omega es una red "bloqueante" (blocking network),
ya que no admite, simultáneamente, implementar cualquier comunicación,
pero, a pesar de ello, es una red adecuada y ampliamente utilizada, porque en
muchos casos (los más utilizados) no presenta problemas para que todos los
nodos puedan comunicarse entre sí.
6.3.3.4 Otro patrón de comunicación: broadcast
Las permutaciones son un tipo de comunicación muy utilizado en
determinadas aplicaciones, pero no el único. Por ejemplo, es habitual tener
que efectuar un tipo de comunicación que se conoce como broadcast: enviar
información desde un procesador a todos los demás i → j, ∀j.
Este tipo de comunicación se puede realizar de un modo sencillo en una
red Omega. Los conmutadores, tal y como hemos visto anteriormente, tienen
la posibilidad de enviar una entrada a las dos salidas. Por tanto, repitiendo
esa operación en todas las etapas se produce un broadcast. Por ejemplo para
efectuar un broadcast desde el nodo 5:
6.3.3.5 Otras redes
Podríamos enumerar muchos otros ejemplos de redes multietapa que se
han usado en multiprocesadores. Entre las más conocidas está la denominada
red butterfly (mariposa). En la figura se presenta un esquema de una red
butterfly de 16 nodos, con conmutadores de grado 2 o de grado 4.
0
2
4
6
1
3
5
7
0
2
4
6
1
3
5
7
BC
BC
BC
BC
BC
BC
BC
▪ 182 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
red butterfly (k = 2) red butterfly (k = 4)
Las redes butterfly y Omega son muy similares; por ejemplo, puede
comprobarse fácilmente que puede usarse en ambas el mismo algoritmo de
encaminamiento, el que utiliza la función xor para generar el registro de
encaminamiento.
Las redes Omega y butterfly, son redes bloqueantes; por su parte, un
crossbar es una red no bloqueante (admite cualquier comunicación
simultánea), pero su coste es muy elevado. En todo caso, es posible construir
redes no bloqueantes con un coste menor que el del crossbar.; ese tipo de
redes se conoce, de manera general, como redes de Clos. Entre las redes no
bloqueantes se encuentran las redes "reorganizables" (rearrangeable), en las
que siempre es posible encontrar un camino para cualquier comunicación,
aunque en algunas ocasiones es necesario reorganizar los caminos existentes
en un momento dado. Un ejemplo de ese tipo de redes son las redes de
Benes, que permiten cualquier permutación si se conoce de antemano cuál es
(para elegir los caminos adecuados), ya que hay muchos caminos para
conectar origen y destino.
La siguiente figura presenta una red de Benes de 16 nodos, que utiliza 7
etapas de conmutación (en las redes de Clos el número de etapas de
conmutación es siempre un número impar). La red permite construir
múltiples caminos para unir dos nodos; por ejemplo, hemos dibujado dos
caminos diferentes para ir del nodo P3 al P11. Como es obvio, el coste de
esta red es mayor que el de una red Omega, y la latencia de los paquetes será
también mayor, ya que tienen que superar más etapas de comunicación.
Si "doblamos" una red de Benes sobre sí misma, la red que se consigue se
denomina árbol (fat tree), en la que los paquetes van hacia adelante (hasta la
raíz) y luego vuelven hacia atrás para llegar al destino (es decir, los enlaces y
puertos son bidireccionales).
6.3 REDES FORMADAS POR CONMUTADORES ▪ 183 ▪
Red de Benes Árbol (fat tree)
6.3.3.6 Resumen
Las redes multietapa intentan superar algunas de las limitaciones que nos
encontramos cuando utilizamos un bus como red de comunicación. Este tipo
de redes tienen larga historia en el área de paralelismo. Al principio se
utilizaron en los sistemas SIMD, en los que la unidad de control distribuye
las instrucciones de un único programa entre los procesadores para
ejecutarlas en modo síncrono. Para algunas aplicaciones —por ejemplo,
tratamiento de imagen, cálculo matricial, aplicaciones numéricas, etc.— este
tipo de máquinas es adecuado, dado que el reparto de trabajo y la
transferencia de datos es muy regular: se aprovecha el paralelismo de datos.
En relación con el reparto de datos, es muy común que los esquemas de
comunicación sean permutaciones (por ejemplo, i → i+1), o tener que hacer
un broadcast... Además de en máquinas tipo SIMD, también se han utilizado
este tipo de redes en multicomputadores MIMD, para sustituir al bus; sin
embargo, hoy en día no se utilizan demasiado (salvo en forma de fat tree y
topologías similares, tal como veremos a continuación).
En la siguiente tabla se presenta una comparación entre un bus, una red
crossbar y una red Omega. Si no hay conflictos, la latencia de las
comunicaciones en un bus es constante; en una red Omega, en cambio, la
latencia es mayor y función de P. En contrapartida, pueden efectuarse más
comunicaciones simultáneamente, ya que el bus hay que compartirlo entre
todos los procesadores (w/P), y la red Omega no (el "ancho de banda" es
mayor). En todo caso, la red Omega es más compleja que un simple bus.
▪ 184 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
P procesadores / canales de w bits
conmutadores de grado k
(O(x) = orden de x)
Bus Red Omega Crossbar
Latencia constante O(log2P) constante
Ancho de banda por procesador O(w/P) → O(w) O(w) → O(w × P) O(w × P)
Complejidad del cableado O(w) O(w × P × logk P) O(w × P2)
Complejidad de la conmutación O(P) O(P/k × logk P) O(P2)
Capacidad de comunicación de una en una algunas permutaciones, broadcast...
todas las
permutaciones
Ejemplos Symmetry S-1 Encore Multimax
BBN TC-2000
IBM RP3
Cray Y-MP/816
Fujitsu VPP500
6.4 REDES FORMADAS POR ENCAMINADORES DE
MENSAJES
En las redes multietapa que acabamos de analizar, los enlaces entre
conmutadores se establecen dinámicamente en función de las necesidades de
comunicación, y los nodos y los dispositivos de la red están "separados".
Tenemos un segundo tipo de redes, en el que cada nodo de la red utiliza un
dispositivo propio para gestionar la comunicación, un encaminador de
mensajes; estas redes se conocen como redes estáticas, Para formar la red, se
conectan entre sí los dispositivos específicos de gestión de mensajes de cada
nodo, los encaminadores de mensajes (routers), de acuerdoa una
determinada topología. Este tipo de redes son las que habitualmente utilizan
los sistemas paralelos actuales, sobre todo si el número de procesadores que
se desea conectar es elevado.
Red de
comunicación
Procesador y
memoria local
router
Gestor de
comunicaciones
Enlaces de la red
6.4 REDES FORMADAS POR ENCAMINADORES DE MENSAJES ▪ 185 ▪
6.4.1 Encaminadores de mensajes
Como ya sabemos, la comunicación entre procesadores en sistemas
paralelos de memoria distribuida se realiza mediante paso de mensajes. Para
enviar un mensaje de un procesador a otro se pasa dicho mensaje al
encaminador de mensajes local, desde donde irá avanzando, encaminador a
encaminador, hasta el nodo destino.
La siguiente figura representa, de manera esquemática, un encaminador de
mensajes típico. Por un lado, tenemos cierto número de puertos de entrada y
salida, mediante los cuales se va a formar la red, más un puerto específico
para la conexión con el procesador local. Por otro, un autómata que decidirá
en cada caso el puerto de salida correspondiente a un mensaje que se ha
recibido en uno de los puertos de entrada, bien para pasarlo a otro
encaminador, bien para pasarlo al procesador local.
Es posible que un mensaje quede bloqueado en algún punto intermedio de
su recorrido, debido a que no esté libre el camino de salida que necesita para
seguir avanzando. Un poco más adelante concretaremos qué hay que hacer
en esos casos, aunque es habitual que el encaminador disponga de búferes
para almacenar mensajes en esas circunstancias.
Un encaminador de mensajes no es sino un tipo de conmutador algo más
complejo. El objetivo de ambos dispositivos es el mismo, conectar entradas
y salidas para abrir un camino a los mensajes; es la organización de la red la
que los hace algo diferentes. El encaminador de mensajes es un elemento
más de los nodos que forman la red, no como los conmutadores de una red
Omega, que son independientes de los nodos de la misma, como
consecuencia de ello, por ejemplo, en el caso de las redes estáticas no todos
los mensajes recorren el mismo número de pasos en la red: unos nodos están
mas cerca que otros.
Puertos
entrada
Puertos
salida
Mensajes del
procesador local
Mensajes para el
procesador local
Enlaces de la red
de comunicación
Función de routing
Enlaces de la red
de comunicación
Búferes
+
Crossbar
▪ 186 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
6.4.2 Topologías de red más utilizadas
Como ya hemos visto, la red ideal es la que conecta todos con todos, un
crossbar, tal como el de la figura.
Es claro que se trata de una topología de difícil aplicación si el número de
procesadores a conectar es elevado: el número de enlaces crece
cuadráticamente y el grado de los nodos (número de conexiones) es grande
(y no es constante).
Tenemos que analizar por tanto las alternativas más viables y que más se
utilizan en los sistemas MPP actuales. En general, cada nodo de las
topologías que vamos a analizar consta de procesador, memoria y
encaminador de mensajes; los enlaces son siempre bidireccionales.
6.4.2.1 Redes de una dimensión: la cadena y el anillo
Aunque no es una topología que se utilice en casos reales, analicemos
como punto de partida dos redes de una sola dimensión: la cadena y el anillo.
cadena anillo
El grado de ambas redes es 2 (2 enlaces por nodo). El anillo es regular y
simétrico, pero la cadena no. La tolerancia a fallos es baja: basta con que
falle un enlace, cualquiera, para romper la cadena, o dos enlaces en el caso
del anillo. Los parámetros de distancia (siendo P, el número de
procesadores, par) son los siguientes (ver apéndice del capítulo):
diámetro distancia media
cadena → P – 1 (P + 1) / 3 → P / 3
anillo → P / 2 P2 / 4(P–1) → P / 4
6.4 REDES FORMADAS POR ENCAMINADORES DE MENSAJES ▪ 187 ▪
Las redes de una dimensión no son adecuadas para conectar un número
alto de procesadores. Pero pueden generalizarse fácilmente a 2, 3 o más
dimensiones.
6.4.2.2 Mallas y Toros (mesh, torus)
Las mallas y los toros son las topologías resultantes de generalizar la
cadena y el anillo respectivamente a n dimensiones. En la figura aparecen
una malla y un toro de 2 dimensiones. Hemos representado redes cuadradas,
pero el número de procesadores por dimensión puede ser cualquiera. Para
obtener un toro a partir de una malla, basta con enlazar entre sí, formando
anillos, los nodos de los bordes de la malla en cada dimensión (con otro tipo
de conexión entre los nodos de los bordes, se obtiene otro tipo de redes).
En general, se utilizan mallas o toros de n dimensiones (n = 2 o 3), con k
nodos por dimensión. Por tanto, el número total de nodos de la red es P = kn.
En el caso de dos dimensiones, P = k × k (si la red no es cuadrada, k1 × k2).
El grado de ambas redes es 2n (hay dos enlaces por dimensión), un valor
bajo. El toro es regular y simétrico28, pero la malla no, ya que el grado de
todos los nodos no es el mismo (por ejemplo, en dos dimensiones, los nodos
de los vértices sólo tienen 2 enlaces, los de los lados 3, y el resto 4), por lo
que los nodos no tienen la misma imagen de la red.
La tolerancia a fallos es alta, ya que hay muchos caminos para ir de un
nodo a otro. En el caso peor, hay que quitar n enlaces en la malla (los
enlaces de un nodo de uno de los vértices) y 2n en el toro (en dos
dimensiones, los cuatro enlaces de cualquiera de los nodos) para romper la
28 Para conseguir la simetría se necesita que todos los enlaces sean de la misma longitud, lo que se
consigue con la red denominada folded torus. Se propone como ejercicio dibujar nuevamente el toro
de 4×4, pero con todos los enlaces de la misma longitud.
0 1 2 3
0 1 2 3
0
1
2
3
▪ 188 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
red; por tanto, la arco-conectividad es n y 2n respectivamente. Sin embargo,
en general, se pueden eliminar (estropear) muchos más enlaces de la red sin
que ésta pierda la conectividad entre todos sus nodos. Respecto al número de
enlaces, una malla tiene n × kn-1 × (k–1) enlaces, y un toro n × kn.
Para dividir la red en dos partes iguales (bisección), es necesario eliminar
k enlaces en la malla y 2k enlaces en el toro (debido a los anillos).
Los parámetros de distancia de ambas redes son los siguientes (k par; ver
apéndice):
diámetro distancia media
malla → n × (k – 1)
13
12
−
−
n
n
k
k
k
kn → n × (k / 3)
toro → n × k / 2
14 −n
n
k
kkn → n × (k / 4)
La distancia máxima de la malla corresponde a ir de un extremo a otro en
cada dimensión; en el toro, en cambio, la distancia máxima por dimensión
nunca es mayor que medio anillo. Simplificando el cálculo para el caso en el
que el número de nodos sea grande, la distancia media en una malla es un
tercio del número de nodos en cada dimensión, y en un anillo un cuarto.
6.4.2.3 Hipercubos (hypercube)
Un hipercubo es una malla de n dimensiones que sólo tiene dos nodos por
dimensión, por lo que también se le denomina n-cubo binario. En las
siguientes figuras tenemos hipercubos de 1, 2, 3 y 4 dimensiones. Para
generar un hipercubo de n dimensiones, hay que construir dos hipercubos de
n–1 dimensiones y enlazar, uno a uno, los nodos de la misma posición en
cada red.
(000)
(110) (111)
(101)
(011)
(100)
(010)
(001)
(0) (1) (0000) (0001)
(0010) (0011)
(0100) (0101)
(0110) (0111)
(1000) (1001)
(1010) (1011)
(1100) (1101)
(1110) (1111)
(01)
(10) (11)
(00)
6.4 REDES FORMADAS POR ENCAMINADORES DE MENSAJES ▪ 189 ▪
Cada nodo de la red tiene una dirección de n bits, (xn–1, xn–2, ..., x1, x0), en
la que cada bit valdrá 1 o 0 en función de la posición que ocupe el nodo en la
correspondiente dimensión. Así, elnodo (xn-1, xn-2, ..., x1, x0) está conectado
con los nodos (/xn-1, xn-2, ..., x1, x0), (xn-1, /xn-2, ..., x1, x0), (xn-1, xn-2, ..., /x1, x0),
(xn-1, xn-2, ..., x1, /x0) [/ = not]; es decir, con aquellos nodos cuya dirección
difiere únicamente en un bit. Por ejemplo, el nodo 0000 está conectado con
los nodos 1000, 0100, 0010 y 0001.
Un hipercubo de n dimensiones tiene 2n nodos. Un hipercubo de P nodos
es de log2 P dimensiones. El grado de la red es n (o sea, log2 P), la dimensión
de la misma, y no es constante, ya que crece con el tamaño de la red. El
hipercubo es simétrico y regular. El número de enlaces es alto, (P/2) × log2 P,
y en la misma medida es alta también la tolerancia a fallos; la arco-
conectividad es n (hay que eliminar los n enlaces de un nodo dado para
romper la red).
La bisección de un hipercubo es de P/2 enlaces (para formar un hipercubo
de P nodos unimos uno a uno los nodos de dos hipercubos de P/2 nodos).
Los parámetros de distancia del hipercubo son los siguientes (ver
apéndice):
diámetro distancia media
hipercubo → n n × 2n–1 / (2n–1) → n / 2
La distancia máxima es el número de dimensiones, ya que sólo se puede
dar un paso en cada dimensión; si el número de nodos es grande, la distancia
media resulta ser la mitad del número de dimensiones.
Los hipercubos presentan dos inconvenientes claros. Por una parte, el
grado no es constante, lo que quiere decir que el número de enlaces del
procesador (mejor, del interfaz de la red) no es constante, sino que varía en
función del tamaño de la red. Por tanto, es complicado ampliar la red, ya que
habría que cambiar todos los elementos de la red (o tener prevista desde el
principio la posible ampliación del sistema). Además la construcción de
hipercubos de muchos nodos no es sencilla, ya que el cableado de la red es
muy denso. Por otra parte, no se puede construir un hipercubo con cualquier
número de procesadores, sino sólo con potencias de 2. Por ejemplo, 512 o
1.024 procesadores, pero no 800.
Topologías tales como mallas, toros e hipercubos pueden englobarse
dentro de una clase más general, denominada k-ary n-cube. Se trata de redes
▪ 190 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
de n dimensiones, con k nodos en cada dimensión. Los nodos de cada
dimensión se unen mediante un anillo (o una cadena) y los enlaces son
unidireccionales (por ejemplo un toro 4×4 sería un 4-ary 2-cube).
6.4.2.4 Árboles y árboles densos (fat tree)
Otra topología muy utilizada en los sistemas paralelos MPP es el árbol.
Un árbol puede tener estructuras diversas; la más utilizada consiste en
disponer los procesadores en las hojas del árbol y los encaminadores de
mensajes en el resto de los nodos, tal como aparece en la siguiente figura.
El grado del árbol es k, el número de nodos que salen de cada nodo (como
excepción, el grado no representa en este caso el número de enlaces). Así, el
número de niveles del árbol resulta ser logk P. En la figura se representa un
árbol binario, con k = 2.
En principio, la red no es regular, ya que el nodo raíz y las hojas sólo tiene
dos enlaces, mientras que los intermedios tienen tres. Sin embargo, si los
procesadores únicamente están en las hojas del árbol, la visión que tiene
cada uno de ellos de la red es la misma, por lo que podríamos decir que, para
los procesadores, es regular.
Considerando el tráfico de paquetes, la red es muy desequilibrada. Por
ejemplo, todo el tráfico que se genere en la mitad derecha del árbol, y que
vaya dirigido a procesadores situados en la mitad izquierda, tiene que utilizar
el encaminador de mensajes de la raíz, zona de la red que va a estar muy
saturada. Además, si se estropeara un enlace de ese nodo o el propio
encaminador, la red quedaría inconexa. Para superar ese problema, las redes
en forma de árbol que se utilizan en la realidad disponen de mayor cantidad
de recursos —enlaces y encaminadores— según se avanza hacia la raíz, con
Encaminadores
Procesadores
fat tree o
árbol denso
6.4 REDES FORMADAS POR ENCAMINADORES DE MENSAJES ▪ 191 ▪
lo que la capacidad de gestionar mensajes es mayor en las zonas en las que el
tráfico va a ser mayor. A este tipo de árbol se le conoce como árbol denso
(fat tree). Por tanto, la bisección de un fat tree es P/2.
Los parámetros de distancia de un árbol son los siguientes (grado k; ver
apéndice):
diámetro distancia media
árbol → 2 logk P
1
2log
1
2
−
−
− k
P
P
P
k
→ 2 log2 P – 2 (k = 2)
En las redes implementadas mediante conmutadores, como las redes
Omega, los diferentes componentes que forman la red están perfectamente
diferenciados —por un lado, los procesadores y, por otro, los
conmutadores— y no hay una relación directa entre ellos. En el caso de las
redes estáticas, en cambio, cada procesador (o, tal vez, un conjunto pequeño
de ellos) utiliza un encaminador de mensajes privado para gestionar la
comunicación. Los árboles que hemos analizado toman la apariencia de
redes dinámicas, porque los procesadores sólo están en los nodos hoja del
árbol; los demás elementos de la red sólo se encargan de gestionar los
mensajes. De hecho, se puede demostrar que los árboles densos y las redes
butterfly son isomorfos (“equivalentes”). La única diferencia es que para
llegar al destino en una red butterfly es necesario atravesar toda la red (la
distancia siempre es la misma), pero en los árboles no siempre es necesario
llegar hasta el nodo raíz, porque se puede volver hacia atrás en cualquiera de
los encaminadores intermedios (y de esta forma hacer el camino más corto).
6.4.2.5 Resumen de topologías
Acabamos de resumir las características principales de las topologías más
utilizadas hoy en día en los sistemas paralelos MPP. La siguiente tabla
resume las principales características topológicas de las redes que hemos
analizado. Por claridad, hemos simplificado algunos parámetros para el caso
de un número grande y par de procesadores.
▪ 192 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
(P = núm. de procesadores, n = núm. de dimensiones, k = grado de los
conmutadores o del árbol, o bien número de procesadores por dimensión).
Proc. Grado Regul./ Simetr.
Enlaces (×w)
[Conm.] d media Diámetro Bisecc.
Arco-
conec.
Crossbar P P–1 si P (P–1) 1 1 P2/4 P–1
Omega P k si P (logk P + 1) [(P/k) logk P]
logk P logk P -- --
Malla P = kn 2n no n kn–1 (k–1) ~ n k/3 n (k–1) kn–1 n
Toro P = kn 2n si n P ~ n k/4 n k/2 2 kn–1 2n
Hipercubo P = 2n n (log P)
si (P/2) log P ~ n/2 n P/2 n
Árbol
(fat tree) P k si P logk P
~ 2 logk P –
2/(k–1) 2 logk P P/2 1
Para analizar con más claridad los parámetros de distancia, la siguiente
tabla muestra el diámetro y la distancia media de mallas, toros e hipercubos.
Tal como veremos a continuación, estos parámetros pueden ser esenciales en
la latencia de la comunicación. Por ejemplo, en el caso de una máquina de
1.024 procesadores, la distancia media del hipercubo es 3 veces menor que
la del toro y 4 veces menor que la de la malla. Pero el principal problema del
hipercubo es su elevado grado (el número de enlaces). Por ejemplo, para el
caso de 1.024 nodos, el grado del hipercubo es 10: cada nodo se conecta con
otros 10; en cambio, en el toro o la malla de dos dimensiones el grado es
solamente 4.
Nodos (número de procesadores)
D / d (med.) 16 64 256 1.024 16.384
Malla 2D 6 / 2,7 14 / 5,3 30 / 10,7 62 / 21,3 254 / 85,3
Toro 2D 4 / 2,13 8 / 4,06 16 / 8,03 32 / 16 128 / 64
Hipercubo 4 / 2,13 6 / 3,05 8 / 4,02 10 / 5 14 / 7
Árbol (binario) 8 / 6,53 12 / 10,19 16 / 14,06 20 / 18 28 / 26
En muchos casos, la topología de la red es de tipo jerárquico: los nodos de
una determinada red con una topología dada, son a su vez sistemas paralelos
con otra topología (o tal vez sistemas SMP). Por ejemplo, un sistema puede
ser un hipercubo hasta por ejemplo 64 procesadores, y a partir deahí un
árbol en el que los nodos son hipercubos. Una de las jerarquías más
utilizadas es aquella en la que los nodos son sistemas SMP de 4-8
procesadores.
6.5 LA COMUNICACIÓN A TRAVÉS DE LA RED EN LOS SISTEMAS PARALELOS ▪ 193 ▪
6.4.2.6 Los enlaces físicos
Además de los conmutadores o de los encaminadores de mensajes, la red
está formada por links o enlaces físicos. Estos enlaces pueden ser de
diferentes tipos en función de las características de la máquina:
• Largos o cortos. Si los cables son largos, es posible tener más de un
dato a la vez en diferentes “zonas” del mismo (por ejemplo, en una red
LAN). Cuando son cortos, sólo admiten un dato. Considerados como
elementos de transmisión digital, el tiempo de transmisión en un cable
corto es básicamente el tiempo de carga, necesario para tener el
mismo valor en ambos extremos del cable, tiempo que crece
logarítmicamente con la longitud del cable. Cuando los cables son
largos, en cambio, el tiempo de transmisión es proporcional a la
longitud.
• "Anchos" o "estrechos”. Los enlaces pueden ser de un bit (serie) o de
varios bits en paralelo: 4, 8, 16 o más. Cuanto más anchos son, más
información pueden transmitir simultáneamente, y, por tanto, menor
será la latencia de un mensaje de tamaño dado. En algunos casos,
algunos de los bits de los enlaces se utilizan para transmitir
información de control, y el resto para datos.
Por otra parte, la comunicación puede ser síncrona (mediante un reloj
global) o asíncrona (mediante un protocolo específico punto a punto).
Para indicar la capacidad de transmisión de los enlaces, se utiliza el ancho
de banda (bandwidth), habitualmente en (Mega) Gigabit/s; por ejemplo,
enlaces de 10 Gb/s.
Por último, los enlaces pueden ser de par trenzado de cobre (típicos en
telefonía), cable coaxial (habitual en redes LAN), o de fibra óptica (cada vez
más utilizados en las redes de comunicación de alta velocidad).
6.5 LA COMUNICACIÓN A TRAVÉS DE LA RED EN
LOS SISTEMAS PARALELOS
La red no es sino el soporte físico de la comunicación entre procesadores;
por encima de ella, es necesario construir la lógica adecuada que permita
llevar los mensajes desde el nodo origen al nodo destino. Para analizar el
comportamiento de una red de comunicación, además de su topología, es
necesario tener en consideración muchas otras cuestiones: ¿cómo se organiza
▪ 194 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
el camino (switching strategy)? ¿por dónde se llega al destino (routing
algorithm)? ¿hay que usar siempre el mismo camino? ¿cómo avanzan los
mensajes por la red? ¿qué hay que hacer si el camino está ocupado (flow
control)? Todas estas decisiones hay que tomarlas teniendo en cuenta que el
objetivo debe ser que la latencia de la comunicación sea baja y el
throughput de la red alto.
6.5.1 Los mensajes
En función de la estructura del sistema paralelo, la información que se
intercambia entre los procesadores puede organizarse de formas diversas. Si
se utilizan variables compartidas, la comunicación se efectuará mediante
operaciones de tipo rd/wr, es decir, pequeños paquetes de control más los
datos. Si la comunicación se efectúa mediante paso de mensajes, éstos serán
más largos y estructurados. Por otra parte, la longitud máxima de los
mensajes que procesa la red suele estar limitada a un valor máximo. Si hay
que enviar un mensaje más largo, entonces habrá que dividirlo en varios
paquetes o unidades de transmisión de tamaño fijo.
En un paquete es habitual distinguir los siguientes campos:
• Cabecera: información de control que identifica al paquete, y que
incluye, junto a la longitud, tipo, prioridad... del paquete, información
sobre la dirección de destino.
• Payload o carga de datos: datos a transmitir (contenido del mensaje).
• Cola: información de control para indicar fin de paquete, códigos de
detección de errores tipo checksum (más común en las redes LAN que
en los MPP), etc.
control datos control.
cola cabecera
Así pues, tenemos que distinguir la información de control y los datos
dentro de un paquete. Si los enlaces entre encaminadores son muy anchos
(de muchos bits), los datos y la información de control pueden ir en paralelo.
Por ejemplo, en el Cray T3D, los enlaces son de 24 bits, 16 bits para datos y
4 para control (y 4 más para control de flujo). Con los 4 bits de control se
indica, por ejemplo, el comienzo y el final de la transmisión de un paquete,
etc. En el Cray T3E, en cambio, la información se envía en paquetes tales
como los que hemos comentado (se dice que se hace framing).
6.5 LA COMUNICACIÓN A TRAVÉS DE LA RED EN LOS SISTEMAS PARALELOS ▪ 195 ▪
Toda la información de control que se añade a los paquetes (cabecera,
checksum...) supone una sobrecarga en la comunicación y, por tanto, una
pérdida de eficiencia, por lo que hay que mantenerla acotada. Por ejemplo, si
para mandar 100 bytes de datos hay que enviar un paquete con 128 bytes,
entonces sólo aprovecharemos 100 / 128 = 78% del ancho de banda del
sistema (para transmitir información útil).
La anchura de los enlaces de red que se utilizan para transmitir la
información suele ir desde un bit (transmisión serie, se ha utilizado poco)
hasta 16 bits (o más); según los casos, por tanto, puede que se necesite más
de un “ciclo de transmisión” para transmitir “la unidad lógica más pequeña”
de un paquete. Desde el punto de vista del control, los paquetes se suelen
dividir en flits. En este contexto, un flit se define como la cantidad mínima
de información con contenido semántico, normalmente la información
mínima que se requiere para poder encaminar el paquete. Por ejemplo, si se
utiliza transmisión en serie, cuando recibimos el primer bit de un paquete no
podemos decir nada sobre dicho paquete; necesitamos más bits para poder
saber a dónde va dicho paquete.
Por tanto, los mensajes/paquetes se miden en flits. Lo más habitual es que
un flit sean 8 o 16 bits, coincidiendo con la anchura de los enlaces que unen
los encaminadores de la red. Normalmente, en uno o dos bytes se puede
codificar la información necesaria para poder encaminar un paquete. De este
modo, un flit se podrá transmitir entre encaminadores en un solo “ciclo”.
6.5.2 Patrones de comunicación: con quién y cuándo
hay que efectuar la comunicación.
No es posible responder de manera precisa a esa cuestión, ya que las
necesidades de comunicación, obviamente, dependen de la aplicación a
ejecutar. Sin embargo, podemos aclarar algunos aspectos. Por una parte, hay
que tener en cuenta que el esquema de comunicación de una aplicación y el
esquema de comunicación en la red son dos cosas diferentes, ya que en
medio se encuentra la asignación física de procesos a procesadores. Es decir,
si los procesos P1 y P2 tienen que intercambiar información, no es lo mismo
que se asignen a procesadores contiguos en la red que asignarlos a
procesadores en dos extremos de la red. Por otra parte, las necesidades de
comunicación no suelen ser siempre de todos con todos, sino entre algunos
de los procesos. Tenemos por tanto que considerar múltiples aspectos: las
▪ 196 ▪ Capítulo 6: LA RED DE COMUNICACIÓN DE LOS SISTEMAS PARALELOS
necesidades de comunicación de la aplicación, el nivel de paralelismo, el
reparto de procesos, etc.
Al esquema de comunicación que hay que ejecutar en la red se le conoce
como patrón de comunicación. El patrón de comunicación puede ser
espacial o temporal, es decir, puede indicar la distribución espacial de los
paquetes (a dónde van) o su distribución en el tiempo (cuándo se
transmiten). Analicemos algunos de los casos más típicos.
1. Aleatorio
Uno de los patrones que más se utiliza para analizar el
comportamiento de las redes es el patrón aleatorio, tanto en el espacio
como en el tiempo. La comunicación es aleatoria si la probabilidad de
enviar un paquete del nodo i al j, PrCij, es la misma para cualquier par
de nodos de