Arquitectura e Ingenierı́a de Computadores Examen final de junio Jueves, 19 de junio de 2008 ¡Acuérdate de poner tu nombre en todas las hojas que utilices! ¡Justifica claramente todas tus contestaciones! Utiliza grupos de folios separados para responder a las cuestiones de cada parte SOLUCIONES PARTE ARQUITECTURAS MONOPROCESADOR 1. (2 puntos) Responder verdadero o falso a las siguientes cuestiones justificando adecuadamente las respuestas. a) (0,4 puntos) La planificación estática de instrucciones ayudada por una optimización basada en la búsqueda de paralelismo por parte del compilador, es una técnica común empleada en los procesadores superescalares actuales. b) (0,4 puntos) En una máquina superescalar capaz de lanzar dos instrucciones por ciclo (1 de punto fijo y 1 de punto flotante), con planificación dinámica mediante Tomasulo y especulación, la presencia de riesgos WAW puede impedir el lanzamiento de dos instrucciones en un ciclo. c) (0,4 puntos) Tanto los procesadores superescalares como los procesadores VLIW dependen mucho de una adecuada predicción de los saltos de la aplicación. d) (0,4 puntos) En las arquitecturas VLIW el procesador recibe el código libre de dependencias y optimizado para la ejecución paralela. e) (0,4 puntos) En una arquitectura de tipo VLIW, las caracterı́sticas de la arquitectura hacen que la complejidad del banco de registros sea muy baja. Solución a) Falso. Los procesadores superescalares actuales se basan en la planificación dinámica de código, la especulación y tener implementado en hardware un algoritmo tipo “Tomasulo” para conseguir una gran eficiencia. b) Falso. Una máquina superescalar con especulación no presenta detenciones por riesgos de datos en el lanzamiento de instrucciones. Concretamente, los riesgos WAW son resueltos por medio del renombramiento implı́cito de registros que se realiza gracias a Tomasulo. c) Verdadero. La predicción de los saltos es la base para un buen funcionamiento de los procesadores actuales, ya sean VLIW o superescalares. Lo que cambia en ellos es la manera de realizar dicha predicción: de forma dinámica, en los superescalares, de forma estática en los procesadores VLIW. 1 d) Verdadero. Los procesadores VLIW no incorporan chequeo de dependencias, por lo que es misión del compilador preparar el código objeto para que esté libre de dependencias y optimizado para la ejecución en una determinada arquitectura VLIW. e) Falso. Todo lo contrario. Por las caracterı́sticas del diseño VLIW, donde se trata de que en el mismo ciclo de reloj se acceda al banco de registros por varias instrucciones, el diseño del banco de registros es más complejo, siendo éste habitualmente un banco de registros partido en varios sub-bancos. ¤ 2. (2 puntos) Tenemos un procesador superescalar homogéneo de grado 2 (en todas las etapas se pueden manejar hasta 2 instrucciones por ciclo), con búsqueda alineada y emisión alineada y en orden. Para mantener la consistencia secuencial, ası́ como para renombrar los registros y gestionar las interrupciones se utiliza un ROB. Existe una estación de reserva para cada unidad de ejecución y el predictor de saltos es estático y predice que no va a saltar en los saltos hacia adelante, y que si va a saltar en los saltos hacia atrás. Se dispone de las siguientes unidades de ejecución: dos unidades de carga/almacenamiento (segmentadas) de latencia 2 (calcula la dirección en el primer ciclo y accede a la cache en el segundo ciclo), dos ALUs enteras de latencia 1, un sumador en coma flotante (segmentado) de latencia 3, un divisor en coma flotante (segmentado) de latencia 4, y una unidad de saltos que resuelve los saltos en la etapa de ejecución. Suponemos que en el cauce de enteros están implementados los cortocircuitos y reenvı́os habituales, pero no ası́ en el cauce de coma flotante. Supón para este problema un tamaño ilimitado del ROB y de cada una de las estaciones de reserva. En dicho ordenador se va a ejecutar la siguiente secuencia de instrucciones que calcula la división de dos vectores x e y componente a componente: lw r3, n slli r4, r3, #2 inicio: lf f0, x(r2) lf f1, y(r2) divf f2, f0, f1 sf f2, z(r2) addui r2, r2, #4 sub r5, r4, r2 bnez r5, inicio trap #0 <sgte.> ; ; ; ; ; ; ; ; ; ; ; ; el valor inicial de n es bastante grande r3 = n r4 = n * 4 (final del vector) f0 = x(i) f1 = y(i) f2 = x(i) / y(i) z(i) = x(i) / y(i) r2 = r2 + 4 (incremento el desplazamiento) compruebo si he llegado al final saltar a inicio si r5 es distinto de 0 fin del programa suponemos las instrucciones siguientes enteras Nota: La instrucción trap no causa la terminación del programa hasta que se retira del ROB. Considera que la primera vez que se accede a memoria (para obtener el valor de n y para obtener el valor de los vectores a partir de r2 ) se produce un fallo de cache L1 que se resuelve en la cache L2 con una latencia total de 3 ciclos en el acceso a memoria. El resto de accesos a memoria son servidos por la cache L1 gracias a la técnica del prefetching. a) (1 punto) ¿En qué ciclo se confirma la primera instrucción de salto? ¿En que ciclo se confirma la segunda instrucción de salto? Dibuja el diagrama correspondiente para justificar tu respuesta. En dicho diagrama indica, para cada instrucción y ciclo de reloj, qué fase de la instrucción se está ejecutando. b) (0,5 puntos) ¿Cuántas instrucciones de las dos primeras iteraciones tendrá el ROB al acabar el ciclo en que se confirma la instrucción (addui r2, r2, #4) de la primera iteración? Para justificar la respuesta, muestra el contenido del ROB en dicho ciclo. c) (0,5 puntos) Considera ahora que el procesador tiene un reloj de 1 GHz. y que el régimen estacionario de ejecución es igual al de la segunda iteración. ¿En cuanto tiempo (en segundos) 2 se ejecutarı́a el código anterior para un tamaño del vector de 1000 elementos? No hace falta que tengas en cuenta el tiempo necesario para el llenado/vaciado del pipeline. ¿Cuantos MFLOPs obtendrı́amos para el procesador descrito ejecutando el código anterior? Solución a) Para obtener las dos preguntas de este apartado tenemos que simular la ejecución del programa durante las dos primeras iteraciones. Obtenemos el siguiente diagrama instrucciones–tiempo: 1 lw r3, n slli r4, r3, #2 lf f0, x(r2) lf f1, y(r2) divf f2, f0, f1 sf f2, z(r2) addui r2, r2, #4 sub r5, r4, r2 bnez r5, inicio trap #0 lf f0, x(r2) lf f1, y(r2) divf f2, f0, f1 sf f2, z(r2) addui r2, r2, #4 sub r5, r4, r2 bnez r5, inicio trap #0 lf f0, x(r2) lf f1, y(r2) 2 3 IF IS EX IF IS IF IS IF IS IF IF 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 L2 EX EX IS IS IF IF L2 L2 EX IS IS IF IF L2 L2 EX IS XX IF IF WB EX L2 L2 WB EX - C WB WB L2 WB EX C C L2 - WB - C EX - EX - EX - EX - WB - C C(S1) - C - C - - C IS IS IF IF EX EX IS IS IF IF WB L1 EX IS XX IF IF WB WB EX - EX WB EX EX - EX - EX - WB - L1 EX IS IS IF IF - C - C C - C(S1) C - C - C En el diagrama denotamos con L1 un acceso a memoria resuelto en la cache L1 y con L2 un acceso a memoria satisfecho por la cache L2. Como podemos apreciar, los tres primeros accesos a cache fallan en la L1, siendo resueltos en tres ciclos por la cache L2. A partir del diagrama anterior vemos que la primera instrucción de salto se confirma en el ciclo 18, mientras que la segunda instrucción de salto lo hace en el ciclo 21. b) La instrucción addui r2, r2, #4 de la primera iteración ejecutada se confirma en el ciclo 17. Al final de dicho ciclo el contenido del ROB (sólo instrucciones de las 2 primeras iteraciones) es el siguiente: Entrada 1 2 3 4 5 6 7 8 ocupado Si Si Si Si Si Si Si Si instr. bnez r5, inicio lf f0, x(r2) lf f1, y(r2) divf f2, f0, f1 sf f2, z(r2) addui r2, r2, #4 sub r5, r4, r2 bnez r5, inicio estado EX WB WB WB EX WB WB EX dest. valor f0 f1 f2 Mem[z(r2)] r2 r5 Mem[x(r2)] Mem[y(r2)] #2 / #3 r2+4 r4 - #6 El número total de entradas del ROB ocupadas es de 8 al final del ciclo 17. Como se puede ver, hemos rellenado el campo valor para todas aquellas entradas que ya han pasado por su etapa WB. 3 c) Del diagrama del apartado a) podemos ver que cada iteración del bucle en el régimen estacionario tarda en ejecutarse 4 ciclos de reloj (medido como la diferencia entre el comienzo de las instrucciones de carga en el régimen normal). Por lo tanto, el tiempo que tardan en ejecutarse 1000 iteraciones, sin tener en cuenta los ciclos del llenado/vaciado del pipeline, es el siguiente: T iempo = 4 ciclos ∗ 1000 = 4000 ciclos = 4 µseg Para el cálculo de los MFLOPs, tenemos que en cada iteración sólo realizamos una operación en coma flotante (la división), por lo que obtendrı́amos: MFLOPs = 1 op / 4 ciclos @ 1 Ghz = 250 MFLOPs. ¤ PARTE ARQUITECTURAS MULTIPROCESADOR 3. (3 puntos) Responder brevemente a cada una de las cuestiones teórico/prácticas que a continuación se plantean (se valorará la capacidad de concreción del alumno). a) (0,25 puntos) Dada una aplicación cientı́fica que requiere la realización de 2 × 1015 operaciones de punto flotante, y dado que disponemos de procesadores con 25 GFLOPS, ¿cuál es el número mı́nimo teórico de procesadores que necesitarı́amos en nuestra máquina paralela para poder ejecutar la aplicación en 1 hora?¿Crees que dicho número de procesadores coincidirá con el que se necesitarán en la práctica? b) (0,25 puntos) ¿Por qué podemos argumentar que la Ley de Amdahl da una visión un tanto pesimista de las ventajas del paralelismo? c) (0,25 puntos) ¿Cuál de las dos organizaciones de directorio plano (basado en memoria o basado en cache) es más escalable desde el punto de vista del rendimiento? d) (0,25 puntos) ¿En qué consiste la compartición falsa (false sharing)? Desde el punto de vista del programador, ¿cómo crees que se podrı́a evitar este fenómeno? e) (0,25 puntos) Explica en qué caso serı́a preferible realizar la transición de estado M − → I frente a la M − → S ante la ocurrencia de un fallo de lectura en un procesador remoto. Pon un ejemplo de código (preferiblemente que aparezca en la práctica 3) en el que se dé dicha situación. f) (0,50 puntos) Supón un CMP con 4 núcleos de ejecución. Cada núcleo tiene caches separadas de primer nivel para datos (16 KB) e instrucciones (8 KB), mientras que la cache de segundo nivel de 2 MB es compartida entre los 4 núcleos. La cache L2 contiene todos los datos que se almacenan en las caches L1 (se mantiene la inclusividad). La red de interconexión dentro del CMP es un bus común y para el mantenimiento de la coherencia de las caches L1 se emplea un protocolo basado en fisgoneo con los estados MOSI. Para la siguiente secuencia de eventos sobre el mismo bloque de datos, indica en cada caso las transacciones de bus que se generan, el vector de estados para el bloque de datos y quién proporciona el bloque de datos (memoria, L2 o L1 remota). Supón que inicialmente las caches están vacı́as. Lect(core 1) → Escr(core 1) → Lect(core 2) → Lect(core 3) → Escr(core 4) → Lect(core 1) → reemplazo(core 4) → Lect(core 2) g) (0,25 puntos) ¿Qué diferencias hay entre el modelo de consistencia secuencial y el modelo de consistencia débil estudiados en clase? Dado el siguiente fragmento de código, ¿qué resultados son posibles bajo cada uno de los dos modelos? Suponer que inicialmente el valor de A es 0. P1 P2 A = 1; BARRIER(b); BARRIER(b); { print A; 4 h) (0,25 puntos) Explicar la principal diferencia entre la versión de los cerrojos basada en las instrucciones LL-SC y la basada en tickets estudiadas en clase. i) (0,25 puntos) Explicar la implementación software centralizada de las barreras con cambio de sentido estudiada en clase. Con respecto a la implementación software centralizada original, ¿qué problema trata de resolver? j) (0,25 puntos) ¿Qué diferencia hay entre las técnicas de conmutación virtual cut-through y wormhole? k) (0,25 puntos) Pon un ejemplo de algoritmo de encaminamiento determinista para mallas ndimensionales y explica su funcionamiento. Solución a) El número mı́nimo de procesadores que necesitamos (N ) lo calculamos a partir de la siguiente 15 = 3600, con lo que N = 23 procesadores. En la práctica el número de expresión: N 2×10 ×25×109 procesadores necesarios probablemente será mayor, dado que estamos despreciando la sobrecarga introducida por la parelización y suponemos que la aplicación escala perfectamente con el número de procesadores y todo el tiempo de ejecución de la aplicación está dedicado a cálculos en punto flotante que se reparten de manera perfecta entre todos los procesadores. b) La ley de Amdahl nos dice que tenemos limitada la escalabilidad, y que este lı́mite depende de la fracción de código no paralelizable. Más concretamente, en el razonamiento de Amdahl se supone constante el tiempo de ejecución de una aplicación en un sistema uniprocesador y con ello se considera también constante la fracción de código paralelizable. Sin embargo, en muchas ocasiones se puede incrementar la fracción de código paralelizable aumentando el tamaño del problema que resuelve la aplicación. c) Desde el punto de vista del rendimiento ambas implementaciones difieren en el rendimiento que obtienen las escrituras, más concretamente en la latencia de las mismas. En el primer caso, la latencia de las escrituras permanece casi constante conforme aumentamos el número de procesadores (y por tanto, compartidores potenciales). Por el contrario, en el segundo esquema la latencia de las escrituras se incrementa con el número de compartidores. De esta forma, diremos que el primer esquema es más escalable. d) La compartición falsa surge, por ejemplo, cuando dos procesos están accediendo a partes distintas de un mismo bloque de datos y al menos uno de ellos está escribiendo. El programador podrı́a eliminar este fenómeno evitando que variables que no se comparten entre dos procesadores caigan en la misma lı́nea de memoria. Para ello se podrı́an declarar variables “vacı́as” (padding) entre las declaraciones de las dos variables no compartidas. e) La transición M − → I serı́a preferible para bloques de datos que contienen variables que están siendo accedidas según un patrón migrario por varios procesadores. Como ejemplo, la variable global->diff en la práctica 3 se accede según ese patrón migratorio (primero se lee y después se modifica su contenido). f) Inicialmente, el vector de estados para el bloque de datos será V(c1, c2, c3, c4) = (I, I, I, I). Las transacciones de bus que se generarán son: Lect(core 1) : BusRd. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (S, I, I, I). El bloque de datos es proporcionado por la memoria principal y cargado también el la L2 compartida. 5 Escr(core 1) : BusRdX. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (M, I, I, I). El bloque de datos es proporcionado por la L2. Lect(core 2) : BusRd. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (O, S, I, I). El bloque de datos es proporcionado por la L1 del core 1. Lect(core 3) : BusRd. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (O, S, S, I). El bloque de datos es proporcionado por la L1 del core 1. Escr(core 4) : BusRdX. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (I, I, I, M). El bloque de datos es proporcionado por la L1 del core 1. Lect(core 1) : BusRd. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (S, I, I, O). El bloque de datos es proporcionado por la L1 del core 4. reemplazo(core 4) : BusWB. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (S, I, I, I). El bloque de datos es actualizado en la L2 compartida. Lect(core 2) : BusRd. Una vez completada la transacción de bus, el vector de estados quedarı́a de la forma V(c1, c2, c3, c4) = (S, S, I, I). El bloque de datos es proporcionado por la L2 compartida. g) El modelo de consistencia débil permite cualquier reordenación entre escrituras y lecturas a posiciones de memoria distintas por parte del mismo procesador (relaja W− →R, W− →W, R− →R, R− →W), manteniendo el orden entre accesos sólo en los puntos de sincronización del código. Para el ejemplo que se nos da, el resultado de la ejecución bajo ambos modelos serı́a la impresión de un valor 1 por pantalla. h) La principal diferencia es que la versión basada en tickets es justa, es decir, otorga el cerrojo en el mismo orden en el que los procesos van tratando de adquirirlo, mientras que la versión basada en LL-SC no. i) El problema de la versión original es que se puede producir un interbloqueo si se hace uso de la misma variable barrera en dos puntos del programa. En concreto cuando se libera la barrera en el primer punto, todos los procesos deben salir antes de que un proceso vuelva a entrar en la misma variable barrera en el segundo punto. Como ejemplo, podrı́amos tener que uno de los procesos que alcanzó la barrera en el primer punto permanece expulsado de la CPU cuando el último proceso en llegar a la barrera la libera. Si uno de los procesos alcanza el segundo punto del programa antes de que el proceso anterior haya visto la liberación de la barrera se produce el interbloqueo. Para evitar este problema, la versión con cambio de sentido hace que la espera ocupada se haga sobre valores distintos en llamadas consecutivas a la misma variable barrera, de forma que no hay que inicializar la variable que empleamos para hacer la espera ocupada. j) Ambas técnicas de conmutación segmentan el envı́o de los mensajes, la diferencia es que la primera realiza el control de flujo sobre paquetes completos, lo que hace que los buffers del router tengan capacidad para almacenar al menos todo un paquete, mientras que la segunda ejerce el control de flujo a nivel de flit (unidades más pequeñas) de forma que el tamaño de los buffers puede ser menor. k) El ejemplo más claro es el encaminamiento en orden de dimensión (dimension order ). En este caso se recorren las dimensiones en un orden fijo, agotando los desplazamientos en cada una de ellas antes de pasar a la siguiente. ¤ 6 4. (3 puntos) Dado el siguiente programa secuencial escrito en C: #include ... if (max == 0) break; key index[pos] = 0; value = key[max]; for (i=0;i<8000;i++) { for (j=0;j<8000;j++) { A[i][j] = A[i][j] * value; } } float key[256]; unsigned char key index[8000]; /* valores entre 0 y 255 */ float A[8000][8000]; void initialize(void) { /* Inicializa los arrays key y key index, y la matriz A */ } } void solve(void) { int max; int i,j,pos; float value; int main() { initialize(); solve(); return 0; } while (1) { max = 0; for (i=0;i<8000;i++) { if (key index[i]>max) { max = key index[i]; pos = i; } } muestra una versión paralela del código1 que utilice 4 hilos y que por tanto pueda ser ejecutado en un CMP con 4 núcleos. Supón que nos interesa paralelizar los accesos tanto a la matriz A como a key index. Para ello, discute las fases en las que habrı́a que organizar el código paralelo, las variables que habrı́a que declarar como compartidas, etc. Solución El programa paralelo lo organizarı́amos en tres fases: (1) fase de inicialización, en la que entre otras cosas se crearán los 3 hilos adicionales para el cálculo, (2) fase paralela, que consistirı́a en la ejecución del procedimiento solve por parte de los cuatro hilos, y (3) fase de finalización en la empleamos la llamada wait for end(). A su vez, el procedimiento solve se organizará en dos fases: (1) cálculo del máximo elemento en la porción del array key index que cada hilo tiene asignada y posteriormente cálculo del máximo global de entre los máximos encontrados por cada hilo y (2) actualización de la matriz A. Los arrays key index y key, y la matriz A habrı́a que declararlos como memoria compartida entre todos los hilos. Además necesitamos un par de variables globales para almacenar el máximo global (global max ) y su posición en el array key index (global pos). Finalmente necesitamos también una variable global pid que vamos a utilizar para obtener el identificador único que cada hilo va a tener asignado. En cuanto a las primitivas de sincronización vamos a hacer uso de un cerrojo (l1 ) y una barrera (b1 ). El código pedido quedarı́a como sigue: ¤ 1 Utiliza las macros parmacs vistas en clase: lockdec(lock), lock(lock) y unlock(lock) bardec(bar) y barrier(bar,num threads) g malloc(num bytes), create(num threads, proc, arguments) y wait for end(num threads) 7 #include ... if (max != 0) { LOCK(l1); if (max > var->global max) { var->global max = max; var->global pos = pos; } UNLOCK(l1); } BARRIER(b1); if (var->global max == 0) break; if (pid == 0) key index[var->global pos] = 0; value = key[var->global max]; for (i=0;i<2000;i++) { for (j=0;i<8000;j++) { A[min i+i][j] = A[min i+i][j] * value; } } BARRIER(b1); float *key; unsigned char *key index; float **A; struct { int global pid; int global max; int global pos; } *var; LOCKDEC(l1); BARDEC(b1); void initialize(void) { /* Inicializa los arrays key y key index, y la matriz A */ } void solve(void) { int max,pid; int i,j,pos; float value; int min i; LOCK(l1); pid = var->global pid; var->global pid++; UNLOCK(l1); min i = pid*2000; while (1) { max = 0; if (pid==0) var->global max = 0; for (i=0;i<2000;i++) { if (key index[min i+i]>max) { max = key index[min i+i]; pos = min i+i; } } } } int main() { key = G MALLOC(array 1-d de 256 elementos float); key index = G MALLOC(array 1-d de 8000 elementos char); A = G MALLOC(array 2-d de 8000 por 8000 elementos float); var = G MALLOC(struct con 3 elementos enteros); initialize(); CREATE(3, solve); solve(); WAIT FOR END(3); return 0; } 8