Está en la página 1de 182

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 2012
Í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
1.5.3.2 Eliminación de las variables de inducción......................................... 51
1.5.3.3 Antidependencias (DR, WAR) ...................................................................... 52
▪ vi ▪ ÍNDICE

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 protocolo Berkeley, MOSI ................................................................ 99
3.2.2.4 Resumen de los protocolos de invalidación................................... 100
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
ÍNDICE ▪ vii ▪

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

Capítulo 5. Consistencia de la Memoria en los Computa-


dores Paralelos ................................................................ 149
5.1 Introducción ....................................................................................................... 149
▪ viii ▪ ÍNDICE

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
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
ÍNDICE ▪ ix ▪

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
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
▪ x ▪ ÍNDICE

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 de Bucles y Planificación de


Tareas ................................................................................. 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
Alineación de las dependencias (peeling) .................................................... 307
8.4.4
Extracción de threads independientes (switching) ..................................... 309
8.4.5
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
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
ÍNDICE ▪ xi ▪

Capítulo 9. Computadores Paralelos de Alto Rendimiento.


Programación Paralela: OpenMP y MPI
(introducción). ..................................................................... 327
9.1 Computadores paralelos de alto rendimiento............................................ 328
9.2 Programación Paralela: OpenMP y MPI (introducción) .......................... 332
9.2.1 OpenMP ................................................................................................................ 334
9.2.2 MPI ......................................................................................................................... 337
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 permitido
▪ 2 ▪ INTRODUCCIÓN

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 1000
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 múltiples núcleos (core). Del mismo modo, la frecuencia del reloj
del procesador 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,
1000, 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

uno SISD SIMD


flujos de
instrucciones
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
caso de 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).
▪ 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 ciclo 1, 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 muestra 2):
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ón anterior, 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:
T V = 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 control 3 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
que su capacidad sería de 64 (o 128) × 64 = 4 (u 8) kilobits. Si nos fijamos
▪ 12 ▪ Capítulo 1: COMPUTADORES VECTORIALES

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.

registros vectoriales U.F.

¿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).
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).

Unidades
Registros funcionales
Memoria

Procesador
escalar (op.)
(completo)

Unidad de Control del


direcciones procesador
(datos) vectorial

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.
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:
LV LV
ADDVI ADDVI
SV SV

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 de orden 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
▪ 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:
LV

ADDVI T ~ 2N
SV

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áquina cuenta 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
▪ 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:

LV LV
LV LV
ADDV ADDV
SV SV

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
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:

nm ≥ t m
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:
nm
MCD(nm, s ) (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:
nm
≥ tm
MCD(nm, s )

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 diagonales 6).

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 se pueda 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:

MOVI VS,#1
MOVI R1,#N
do i = 0, N-1 mas: MOV VL,R1
A(i) = A(i) + 1 → LV V1,A(R2)
enddo 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 = 1000, en la última iteración sólo se procesarán 104
elementos (7 × 128 + 104 = 1000).
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:

 N 
TV =   (ti + tbuc ) + tv N
 Lmax 

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 + 1500 = 1530 ciclos (idea)


3,06 ciclos/elemento
pero
<

Lmax = 64 tbuc = 10 ciclos → TV = 8 × (30+10) + 1500 = 1820 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 vectorial 7
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.
300

250
TV
200
pendiente = tb
150
2ti
100
TV = 30 + 2N
5
ti N1/2
0
0 25 50 75 100 125 150

N (longitud de los vectores)

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):
N N
RN = =
TV ti + tv N
La velocidad de cálculo se suele dar en Mflop/s (Mega FLoat OPeration /
second), es decir, cuántos millones de operaciones de coma flotante se
realizan por segundo, para lo que tenemos que considerar el número de

7 Sin considerar el tamaño de los registros vectoriales.


▪ 32 ▪ Capítulo 1: COMPUTADORES VECTORIALES

operaciones que se realizan con los vectores, OpCF 8, 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:

N N
RN = = × OpCF × F Mflop/s (TV en ciclos, F en MHz)
TV ti + tv N

Analicemos gráficamente el comportamiento de la expresión anterior.

R∞
(rendimiento)

R∞/2
R

N1/2
N (número de elementos)

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∞.
1
R∞ = lim N →∞ RN = × OpCF × F
tv
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.
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áxima 9.
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 programa que 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 = 3000
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?

TE t N te
KV = = e K∞ =
T V ti + t v N tv
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.
ti N1/ 2
t e N v = ti + t v N v → Nv = =
te − tv K ∞ − 1
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á:

TE TE TE K
KVE = = = =
TVE fTV + (1 − f )TE TE f (1 − K ) + K
f + (1 − f )TE
K
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.

16
KV = ∞

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).
KV KV 1
= → f1 / 2 = 1 −
2 f1 / 2 (1 − KV ) + KV KV − 1
▪ 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:

N N N N
RVE = = = =
TVE fTV + (1 − f )TE f (ti + tv N ) + (1 − f )te N f (ti + tv N ) + (1 − f ) K ∞ tv N

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.
16
Factor de aceleración (normalizado)

14 Ley de Amdahl

12
tv = 5 ns
te = 66,6 ns
10

tv = 10 ns
4
te = 33,3 ns
CRAY X-MP
2
tv = 10 ns
te = 66,6ns
0
0 0.2 0.4 0.6 0.8 1

f (factor de vectorización)

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
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 puede ejecutarse 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 1
2: D = A
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
1: A = B + C
B
2: B = D
2

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.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 39 ▪

• Dependencias de salida (WAW write-after-write, RR)


1
1: A = B + C
A
2: A = D
2

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:
i
1
do i = 2, N-2
A, 2
1: A(i) = B(i) + 2
2: C(i) = A(i-2) + A(i+1) A, 2 A, 1
i=0 i=1 i=2 …
enddo
2 A, 1

j
A, (0, 1)
A, (0, 1)
do i = 2, N-1 1
do j = 1, N-2 i A, (2, –1)

1: A(i,j) = A(i,j-1) * 2 A, (2, –1)


2: C(i,j) = A(i-2,j+1) + 1
enddo
enddo 2
grafo de dependencias espacio de iteraciones
▪ 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
ejemplos 10.

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

i
1
do i = 0, N-1 A, 0
1: A(i) = B(i) + C(i) A, 0
2: D(i) = A(i)
enddo 2

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


▪ 42 ▪ Capítulo 1: COMPUTADORES VECTORIALES

1.5.2.1.3 Tercer ejemplo

1 i
do i = 1, N-1
1: A(i) = B(i) + C(i)
A, 1
2: D(i) = A(i-1)
enddo A, 1
2
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

1
do i = 0, N-2 i
1: A(i) = B(i) + C(i)
A, 1
2: D(i) = A(i+1)
enddo 2 A, 1

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.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


i
1
do i = 1, N-1 A, 0
1: A(i) = B(i-1) + 1 A, 0
B, 1
2: B(i) = A(i) B, 1
enddo 2

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 A, 3 1
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?
▪ 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 j
s=1
MOVI VS,#1 ; paso

buc: LV V1,B(R1) A 0,0 0,1 … 0,M-1


LV V2,C(R1)
ADDV V3,V1,V2 1,0 1,1 … 1,M-1
SV A(R1),V3 i
… … … …
ADDI R1,R1,#M ; siguiente fila
SUBI R2,R2,#1 ; una fila menos N-1,0 N-1,1 … N-1,M-1
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.
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 A j
MOVI VS,#M ; paso

buc: LV V1,B(R1) s=M 0,0 0,1 … 0,M-1


LV V2,C(R1)
ADDV V3,V1,V2 1,0 1,1 … 1,M-1
SV A(R1),V3 i
… … … …
ADDI R1,R1,#1 ; siguiente columna
SUBI R2,R2,#1 ; una columna menos N-1,0 N-1,1 … N-1,M-1
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 comienzo 11.
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.
▪ 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 grafo de 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:

MOVI VL,#N-1
1 MOVI VS,#1
do i = 1, N-1 B, 0
LV V1,B+1(R1)
A(i) = B(i) SV A+1(R1),V1
B(i) = B(i-1) 2
MOVI R3,#N-1
enddo B, 1
buc: FLD F1,B(R2)
FST B+1(R2),F1
(puede vectorizarse la primera instrucción, ADDI R2,R2,#1
pero no la segunda, debido a la dependencia, una SUBI R3,R3,#1
recurrencia) 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.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.

ai+b
ci+d

L1 i1 i2 L2

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.
▪ 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
detallado 12. Se pueden diferenciar tres casos:

i i i
L1 L2 L1 L2 L1 L2
(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.
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 se realiza, 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=5i+2 y k=3i–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
1: A(i) = B(i) + C(i)
2: D(i) = A(i) + A(i+1) A, 0 A, 1
enddo
2

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:
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 53 ▪

0
do i = 0, N-2
A, 1
0: [T(i)] = A(i+1)
1: A(i) = B(i) + C(i) 1 T, 0
2: D(i) = A(i) + [T(i)]
A, 0
enddo
2

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:

1
do i = 0, N-3
1: A(i) = B(i) + C(i) A, 0 A,2
2: A(i+2) = A(i) * D(i)
enddo 2

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:
▪ 54 ▪ Capítulo 1: COMPUTADORES VECTORIALES

1
do i = 0, N-3 T, 0
1: [T(i)] = B(i) + C(i)
2 T, 0
2: A(i+2) = [T(i)] * D(i)
3: A(i) = [T(i)] A, 2
enddo 3

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 i
do j = 1, N-1 1
A(i,j) = A(i,j-1) + 1
A, (0, 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.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: j

do j = 1, N-1 i
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.
j
do i = 1, N-1
1 i
do j = 1, N-2
(1) A(i,j) = B(i-1,j+1) + 1
A, (0, 1)
(2) B(i,j) = A(i,j-1)
enddo 2
enddo B, (1, -1)

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
intercambio es 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.
▪ 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 i
1 A, (1, 0)
do j = 1, N-1 B
(1) A(i,j) = A(i-1,j) + 1
A
(2) B(i,j) = B(i,j-1) * 2
2
enddo
B, (0, 1)
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:
do i = 0, N-1
1.5 TÉCNICAS DE COMPILACIÓN PARA GENERAR CÓDIGO VECTORIAL ▪ 57 ▪

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 do i = 0, N-1
Z(i) = X(i) + Y(i) Z(i) = X(i) + Y(i)
enddo R(i) = Z(i) + 1
enddo
do i = 0, N-1
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 do i = 1, L
Z(i) = X(i) + Y(i) Z(i) = X(i) + Y(i)
enddo ≠ R(i) = Z(i+1) + 1
do i = 1, L enddo
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, vector mask) 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.

s=3

14

1 8

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
▪ 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 continúa 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 una 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
5120 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 8192 procesadores). En la lista citada (junio 2002) había 41
computadores vectoriales.

En 2009 disponemos de una nueva versión de dicha máquina (1280


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 unidades funcionales 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 fue 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.
Red de comunicación

Computador Procesador Pr + M + I/O


front-end de control

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, 1024 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 si las 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.
Procesadores (+ MC)

P0 P1 Pp–1

Red de comunicación

sistema
M0 Mm–1 E/S

Memoria principal
▪ 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.

P0 Pp–1

E/S E/S
M M
K K

Red de comunicación
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 sistemas
SMP 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.

Tp

Tcom
Tej

Núm. procesadores

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.
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 lograr 13. 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í:

∑f
Ts
Tsp = f × Tp + (1–f) × Ts (en general, Tsp = i )
i =1
i

Si no consideramos el tiempo de 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 = 1024 y f = 0,98, entonces fa = 47,7, muy lejos del


hipotético 1024. 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 1024 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:

trozo que hay que trozo que se puede


ejecutar en serie ejecutar en paralelo

(1–f) Ts f Ts
1 procesador

problema de mayor tamaño

(1–f) Ts f Ts × P

en paralelo en paralelo

(1–f) Ts f Ts / P (1–f) Ts f Ts
P procesadores

tamaño del problema constante tiempo de ejecución constante


▪ 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 = 1024 y f = 0,98, el factor de aceleración que se consigue
resulta ser fa = 1003,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
bloques 14 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 principal 15. 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 1024 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

Variables del Variables del


procesador Pi procesador Pj

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.
▪ 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 bus 16 para acceder a memoria. En este tipo de sistema, la
coherencia de los datos se mantiene por hardware, por medio de un
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.

P1 P2 P1 P2
wr A,#3 wr A,#3

MC1 A = 4→3 A=4 MC2 MC1 A = 4→3 A = 4→3 MC2


INV A BC A,3

A = 4→3? A = 4→3?
MP MP

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.
▪ 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 modificado compartido Estado


(valid) (dirty) (shared)
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 la variable 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 siguiente / Señales de control


Estado
presente PR PW BR INV
fallo

I, - S BR M BR,INV

S S M INV S I
acierto

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.
PR - PW

M
BR (BW)

PR - BR INV (BW)
PW (INV)

PW (BR,INV) INV

PR (BR)

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 conectar el 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 siguiente / Señales de control


Estado
presente PR PW BR INV
nsh: E
fallo

I, - BR M BR,INV
sh: S

E E M S I
acierto

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

PR - PW

BR (BW) INV (BW)

PW PW (INV)

PR PR - BR
E BR S
nsh sh
INV INV

PW (BR,INV) PR (BR)

I, -

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.
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 siguiente / Señales de control


Estado
presente PR PW BR INV
fallo

I, - S BR M BR,INV

S S M INV S I
acierto

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)

PW (INV)
PR - PW PR - BR
M O
BR

INV (BW) INV (BW)


PW (INV)
PR - BR
S
INV

PW (BR,INV) PR (BR)

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
▪ 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 responder adecuadamente 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 siguiente / Señales de control


Estado
presente PR PW BR BC
nsh: E nsh: M BR
fallo

- BR
sh: S sh: S BR,BC

E E M S S
E
acierto

nsh:
S S BC S S
sh: 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)

PR - PW

M nsh

PW (BR) (-)
BR (BW)
sh
PW (BC)
PW (BC)
PW (BC) nsh
sh
PR E S PR
BR BR - BC
nsh sh
PR (BR)
(-)

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
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 siguiente / Señales de control


Estado
presente PR PW BR BC
nsh: E nsh: M BR
fallo

- sh: S
BR
sh: O BR,BC

E E M S S
nsh: M -
S S BC S S
sh: O
acierto

M M M O S
nsh: M -
O O BC O S
sh: O

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) ]

(-)
PW (BR)
nsh sh
(BC)
nsh
PW
PW sh (BC)
PR - PW M O
BR PR - BR

BC (BC) BC
nsh sh
PW
PW

PR
PR E BR
S BR - BC

nsh sh

PR (BR)
(-)

Tal y como 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.
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

data addr contr


MC

tags + tags + Processor


Bus side state state side
Cache data RAM
controller controller
snoopy proc.

to controller
compar.

tag Write-back buffer

to controller
compar.

state Cmd Addr Data buffer Addr Cmd

system bus

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á
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
segmenta 19—. 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 problema 20? ¿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 en el 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.

PR - PW

M
BGR (BR,INV) BR (BW)
INV (BW)
BGR (INV)

IM
INV SM
PW
PW (BRQ)

PR E BR S PR - BR

nsh sh
BGR (BR)

INV INV
ISE

PW (BRQ) PR (BRQ)

I, -

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).
Mientras tanto, el controlador del procesador P2, que ha conseguido el bus,
▪ 113 ▪

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
interconexión, que es una evolución natural del bus: la jerarquía de buses.
▪ 114 ▪ Capítulo 4: SINCRONIZACIÓN DE PROCESOS EN LOS COMPUTADORES SMP

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.

SMP
P

C snoopy local
B1

MP K K MP

B2
hardware para la
coherencia global

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.
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 lo solicitó (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).

MC MC MC MC

I→S rd, fallo


M→S E→S
MP MP
BR @
@ → 3 b
B1 B1
5
KL KR KL 2 KR

M→S E→S

M→S
E→S

B2 1 a 4

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.
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.

N1 N2 N3
MC MC MC MC MC MC
wr A
S→M S→I
S→I S→I
MP MP MP
INV A INV A INV A A
B1 B1 B1
1 3
KL KR KL KR KL KR

S→M S→I S→M



2 INV A B2 INV A INV A

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.
▪ 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 las
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 P2
... ...
ST A,F1 ...
... ...
... 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 P2
... ...
LD R1,CONT LD R1,CONT
ADDI R1,R1,#1 ADDI R1,R1,#1
ST CONT,R1 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) función unlock(CER)

lock: LD R1,CER unlock: ST CER,R0


BNZ R1,lock ; saltar si no es 0 RET
ADDI R2,R0,#1 ; R2 := 1
ST CER,R2 ; cerrar cerrojo
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.
Utilizando la instrucción T&S, las dos funciones de un cerrojo
(cerrar/abrir) pueden hacerse así:

lock: T&S R1,CER unlock: ST CER,R0


BNZ R1,lock RET
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 unlock: ST CER,R0


l1: SWAP R1,CER RET
BNZ R1,l1
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 t 2 = 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 la variable cerrojo en
todos los intentos de acceso a la sección crítica 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: cuando uno 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 → P


P(3P − 1) 3P 2
∑ (3P − 2) =
p =1 2

2

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 variable cerrojo, 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
P

Para que entren P → ∑ ( 2P − 1) =P


p =1
2

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ómicas para 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) P2 (consumidor)
X = F1(Z); while (aviso==0) {};
aviso = 1; 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 con el 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ún momento 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) print B; (rd1)
B = 2; (wr2) 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; ...
tiempo
... print B;
B = 2; ...
... 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) while (LISTO == 0) {}; (rd1)
LISTO = 1; (wr2) 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 >> rd2 23),
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.
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; F2 = 1;
if (F2 == 0) then if (F1 == 0) then
< código > < 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) while (LISTO == 0) {}; (rd1)
LISTO = 1; (wr2) 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 diferentes 24 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 consistencia no 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

En conjunto
P1 P2
SC si SC no
a c a a a c
tiempo b d 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:
instrucción a

instrucción b

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.

P P P P
orden

atomicidad

MEM

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:
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?)
1. INV 1. INV

2. ACK 2. ACK

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; A = 3; while (B ≠ 1) {}; while (B ≠ 1) {};
B = 1; C = 1; while (C ≠ 1) {}; while (C ≠ 1) {};
reg1 = A; 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
▪ 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.
1. BC 1. BC

2. ACK 2. ACK

3. seguir 3. seguir

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:
5.3 MODELOS RELAJADOS (relaxed) ▪ 159 ▪

P1 P2 P1 P2
A = 1; A = 0; r1 = 1; A = 0;
B = A; A = r1;
B = r1;
(2 ST / 1 LD) (2 ST)

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 anterior ST. 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 >> rd 25 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:

P P P P

LD ST ST ST ST
búferes ST
(FIFO)

MEM

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 = nuevo_valor;
Y_copia = Y 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
▪ 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; ... lock(cer);
Y = B + 1; while (flag == 0) {}; yo = i;
flag = 1; A = X / 2; i = i + N;
... B = Y; 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 operaciones de 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.

rd ...wr ... sinc rd ...wr ... sinc rd ...wr ...

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.
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.

rd ...wr ... s_acq rd ...wr ... s_rel rd ...wr ...

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.

Orden de las operaciones de memoria Instrucc. para


Modelo wr>>rd wr>>wr rd>>rd/wr sinc. wr atom. imponer orden

SC    todas 

TSO   todas  MEMBAR, RMW

PC   todas MEMBAR, RMW

PSO  todas  STBAR, RMW

WO todas  SYNC

sa >> w/r
REL, ACQ,
RC w/r >> sr RMW
s >> s
▪ 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 TSO/PC PSO WO RC
wr,rd,s >> wr,rd,s – wr >> rd – wr >> wr – rd >> wr, rd

rd = A = A = A = A = A

wr B = B = B = B = B =

sinc_a sinc_acq sinc_acq sinc_acq sinc_acq sinc_acq

wr C = C = C = C = C =

rd = D = D = D = D = D

sinc_r sinc_rel sinc_rel sinc_rel sinc_rel sinc_rel

wr E = E = E = E = E =

wr F = F = F = F = 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 programas reales 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.

También podría gustarte