lunes, 25 de abril de 2011

Microprocesador DLX y simulador (I - punto de vista del hardware)

Recientemente he terminado una asignatura de la titulación Ingeniero Electrónico: Arquitectura de computadores. El objetivo de la asignatura es saber diseñar, a nivel de bloques funcionales, toda la arquitectura de un microprocesador (segmentación, renombramiento dinámico de registros, ejecución fuera de orden, predicción de saltos, etc.).

Teníamos que hacer un "trabajito" relacionado con la asignatura, que sería la nota mas importante de la asignatura. Como "trabajito" elegí diseñar una implementación propia del DLX a nivel de bloques. Una vez diseñado, simularía su funcionamiento mediante un programa escrito en c++.

Se que tal y como he tratado el tema aquí, exige unos conocimientos previos básicos sobre el "mundillo" de los microprocesadores. Es cierto que debería haberme "currado" un poco más el artículo, para poder llegar a un público más general. Pero sé que los que tienen nociones básicas de microprocesadores agradecerán este artículo, porque toca algorítmos muy interesantes que son la esencia del descomunal rendimiento que ofrecen los microprocesadores hoy día (al margen de los multi-núcleos que no ofrecen nada interesante, salvo la solución por fuerza bruta de la capacidad de cómputo, mediante el "copy-paste" de cores). Añadir también, que aquí se presentan los "trucos" para ganar rendmiento a nivel de bloques. La otra gran lucha para conseguir rendmiento en los microprocesadores se realiza en la simplificación del álgebra booleana inmersa en ests algoritmos, para conseguir diseños con un menor número de puertas lógicas. Además, estaría el último frente y el de más bajo nivel: conseguir cada vez transistores más rápidos. En sucesivas entradas iré tocando éstos aspectos, al menos a un nivel básico.

Los componentes y "features" principales del microprocesador son las siguientes:

- Bus de direcciones y de datos de 32 bits.
- Aritmética de 32 bits.
- Cauce segmentado (8 etapas).
- Arquitectura Harvard (en previsión de incluir una cache L1 independiente para datos e instrucciones).
- Renombrado dinámico de registros mediante el algoritmo de Tomasulo (tres estaciones de reservas, una para cada unidad funcional: enteros, sumas/restas en coma flotante, multiplicaciones/divisiones en coma flotante, cada una con 5 entradas).
- Ejecución fuera de orden mediante un buffer de reordenamiento.
- Una unidad de ejecución para enteros.
- Una unidad de ejecución para sumas/restas en coma flotante.
- Una unidad de ejecución para multiplicaciones/divisiones en coma flotante.
- Un banco de 32 registros para enteros.
- Un banco de 32 registros de coma flotante en simple precisión, ó 16 registros de coma flotante en doble precisión.

Algunas cosas que se deberían haber incluido, y no se hicieron por falta de tiempo:

- Una segunda unidad de enteros para sumas: de esta forma se pueden realizar cálculos de direcciones en paralelo con operaciones aritméticas "normales". De esta forma se puede tener la dirección de un salto sin tener que esperar a que la unidad de enteros esté disponible. Siempre es muy importante tener las direcciones de saltos calculadas con mucha antelación, en previsión de que haya que alojar una nueva línea de caché, es decir, se produzca un fallo de caché al buscar la instrucción siguiente al salto.
- Predicción de saltos. En este caso le propuse al profesor de la asignatura la inclusión de una BHR de 2 bits y una BHT de 2 bits también. Ya hablaré más extendidamente sobre las técnicas de predicción de saltos utilizada en los microprocesadores reales (con algún ejemplo real), y como se puede implementar una versión mas "doméstica" que utilice menos recursos (y menos área de silicio).
- 'Superescalabilidad'. Es decir, duplicar algunos de los elementos del cauce, para permitir que se ejecuten dos o más instrucciones consecutivas simultáneamente (si se quiere un microprocesador puramente 'superescalar', no queda otra que duplicar cada elemento de cada etapa del cauce). De esta forma se ejecutan dos instrucciones de un mismo proceso o hilo del sistema (es decir, el contador de programa es único).
- 'HyperThreading'. Tal y como hizo Intel en sus microprocesadores, se puede mejorar el rendimiento permitiendo que un mismo cauce ejecute dos instrucciones simultáneamente. En este caso se trata de duplicar sólo algunas partes del cauce, habiendo otras compartidas. Consigue menos rendimiento que con técnicas superescalares, pero consume muchísima menos área de silicio. Y además, a diferencia de la técnica 'superescalar', ambas instrucciones pueden pertenecer a procesos o hilos del sistema diferentes. Dicho de otra forma, es como tener el doble de microprocesadores (en caso de contar con 'HyperThreading' x2), al menos de forma virtual.
- Caché L1 y L2. El microprocesador diseñado tiene una arquitectura Harvard, pensada para incluir una cache L1. En el simulador esto se simula mediante dos archivos/instancias en RAM distintas. En cualquier caso, no se ha simulado un comportamiento de cache real, ya que en el simulador, la cache L1 tiene un tamaño desmesurado, para garantizar que cabe perfectamente todo el programa. Es más, se ha omitido todo el proceso de comparación de etiquetas de líneas de cache, carga de líneas de cache nuevas, etc.

FUNCIONAMIENTO DEL MICROPROCESADOR

- Segmentación. Imaginemos por un instante que todo el procesamiento completo, es decir, fetch de la instrucción, decodificación de la misma, ejecución, operaciones de memoria (si es una instrucción de memoria) y escritura en registros de destino, se hiciera en un mismo ciclo de reloj. Está claro que es un proceso muy complejo, y que por tanto consumiría una gran cantidad de tiempo. Como consecuencia de ésto, la frecuencia de funcionamiento del microprocesador sería muy pequeña. Pongamos por caso, a modo de ejemplo, que el proceso completo consume 100 microsegundos. La frecuencia por tanto sería de 10 kHz. Ahora, vamos a colocar unos registros entre cada una de las etapas principales del microprocesador: fetching, decodificacion, ejecución, memoria y "write back" o escritura de registros de destino. De esta forma, podemos hacer que una instrucción entre en fetch y se guarde en el registro intermedio, pasa otro ciclo y la instrucción ahora se decodifica y se guarda en el registro intermedio, pasa otro ciclo y se ejecuta, y así hasta llegar a la última etapa. En definitiva, gracias a esa memoria intermedia, llamada "registros de segmentación", podemos hacer que la tarea se realice en "n" ciclos de reloj, más pequeños que el anterior. Cualquiera podría argumentar que en realidad la instrucción tarda lo mismo en ejecutarse, porque aunque cada ciclo de reloj es menor, en vez de hacerse en uno solo, se hace en tantos ciclos como en etapas hayamos descompuesto la instrucción. Yo les diría aun más: en realidad, cada instrucción tarda más que antes, dado que hemos introducido retardos adicionales al tener que manejar un registro intermedio. Además, la frecuencia de reloj no se divide exactamente por el número de etapas. Dicho de otra manera, si tenemos 5 etapas, la frecuencia no pasa automáticamente a ser de 50 kHz porque la frecuencia pasa a depender de la etapa más lenta, y este tiempo no tiene por qué ser una división perfecta entre el periodo antes de segmentar y el número de etapas. De modo, que en realidad, las instrucciones son más lentas. ¿Que sentido tiene entonces segmentar? Tened en cuenta que si todos los microprocesadores modernos -y muchos de los antiguos- son segmentados, es porque se gana tiempo. El truco está en que gracias a los registros intermedios, una instrucción que ya ha pasado por, pongamos por caso, decodificación, en el ciclo siguiente deja totalmente libre todo este hardware para cualquier otra instrucción. Por lo que en realidad, la mayor parte del tiempo hay tantas instrucciones procesándose al mismo tiempo, como etapas de segmentación tenga el microprocesador. En nuestro ejemplo, si hay 5 etapas de segmentación, hay 5 instrucciones simultáneamente procesándose. Es cierto que la primera instrucción tarda -en nuestro ejemplo- cinco ciclos en procesarse, pero a partir de ese momento, en cada ciclo de reloj aparece una nueva instrucción procesada, y además, este ciclo de reloj es menor que cuando teníamos un microprocesador sin segmentación. Si se piensa bien, este mismo principio es el que se ha usado en las cadenas de montaje de todas las fábricas del mundo. Un operario no se encarga de todo el proceso de fabricación de un coche, sino que se especializa en, por ejemplo, montar el volante en la columna de dirección y alinearlo.


Primera parte del cauce segmentado.

Segunda parte del cauce de segmentado.

Ahora explicaremos con detenimiento cada una de las etapas supuestas en el diseño de este microprocesador. A efectos de comprender mejor los entresijos de los algoritmos empleados, consideraremos siempre el siguiente fragmento de código:

ld     r5,r2+5
add  r6,r5,r4
ori   r7,r3,45
andi r5, r3,65

- Etapa de 'Fetch'. En esta primera etapa se busca en la cache L1 la instrucción apuntada por el contador de programa, que es un registro de 32 bits que contiene la instrucción que hay que procesar en cada instante. Una vez obtiene la instrucción desde la memoria cache L1, incrementa en 4 el valor del contador de programa (desde ahora PC) para que la siguiente vez apunte a la siguiente instrucción. En caso de que la unidad de control del microprocesador decrete la introducción de una burbuja (porque exista algún riesgo estructural, es decir, hay instrucciones delante esperando algún recurso, y no puede entrar ninguna nueva), en vez de buscar una nueva instrucción en la cache L1 e incrementar el PC, deja intacto el PC, e introduce una operación NOP en el cauce. Dado que NOP no hace nada sobre ningún registro, a efectos prácticos es como introducir una "burbuja", es decir, un ciclo de reloj en el que el microprocesador no debe hacer nada. De esta forma, el cauce gana tiempo para conseguir que las instrucciones atascadas consigan su recurso esperado.

- Etapa de Decodificación1. En esta etapa se hace una decodificación básica de la instrucción. Dado que se implementó en forma de simulador software, en realidad en esta etapa descompongo la instrucción (un entero de 32 bits) en una estructura c con todos los campos que necesito. En la realidad, en esta etapa se calculan los bits de control de cada unidad funcional, en base a la instrucción. Estos bits gobiernan el 'camino de datos', es decir, decide que operandos entran en la ALU, si es una operación que se guarda en un registro del banco de registros o en el PC (es decir, una instrucción de salto), habilitando las escrituras en el registro correspondiente, decide que operación de ALU hay que realizar (suma, resta, AND, XOR, etc). El número de señales y su complejidad depende del 'camino de datos' concreto del microprocesador.

- Etapa de Decodificación 2. En el microprocesador que he diseñado, en esta etapa se solicita una plaza en el buffer de reordenamiento. Como ya he indicado anteriormente, el microprocesador ejecuta las instrucciones fuera de orden. ¿Que quiere decir esto? En un microprocesador normal, las instrucciones entran por la primera etapa, y salen por la última etapa por orden. Si un programador escribe una suma, una resta con otros operandos, y después un movimento a memoria del resultado de la suma, espera que se haga en ese mismo orden, y no en otro orden arbitrario. La secuencialidad es implícita e inherente a la naturaleza de los programas software. Pero por otro lado ejecutar en orden tiene ciertos inconvenientes, de cara a conseguir más rendimiento. ¿Que ocurre si la instrucción que entró en segundo lugar, en este caso la resta, tiene sus operandos disponibles ( es decir, el banco de registros se los puede suministrar), pero la suma no? Pues que la resta, aun teniendo sus operando listos, tendrá que esperar hasta que el banco de registros pueda suministrarle sus operandos a la suma, que entró en primer lugar. Pues bien, hagamos una cosa. Permitamos que la resta progrese en el cauce de segmentación, y que sea solo la suma la que espere a sus operandos. Hecho esto, la resta progresará, hasta que llegue a la última etapa. ¿Que ocurre ahora, permitimos que la resta acabe antes que la suma? Esta claro que el programador espera que el microprocesador acabe las tareas en orden. Así que no podemos cambiar eso, porque los programas tendrían un comportamiento aleatorio, y eso no nos sirve para nada. ¿En que punto paramos la resta, para que espere a la suma? He aquí la clave del algoritmo. Se diseña un conjunto de registros de n entradas, donde una instrucción volcará su resultado, en el orden de llegada. Me explico. Justo en esta etapa, en Decodificación 2, se solicita una posición en el registro, es decir, en el buffer de reordenamiento. Este hueco estará justo debajo de la instrucción anterior, y estará justo encima de la instrucción siguiente. Una vez la instrucción haya progresado a través del cauce y llegue a un punto apropiado, volcará su resultado sobre el buffer de reordenamiento, en la posición que le dieron en esta etapa. Se ve claramente, que los resultados se irán colocando cada uno donde corresponde. En nuestro escenario, la resta volcará su resultado en el buffer de reordenamiento, antes que la suma, pero lo hará en su posición que es uno justo debajo de la suma. La suma siempre estará por encima. ¿Ocurre algo porque haya escrito antes la resta que la suma? Evidentemente no. La lógica del microprocesador se encarga de esperar a la suma, que tiene más prioridad que la resta. Pero hemos conseguido aprovechar el cauce con la resta, que de otra forma habría permanecido sin uso, a la espera de los operandos de la suma. Para cuando la suma acabe, el resultado de la resta se usará inmediatamente en el ciclo siguiente, porque ya estaba calculado de antes.

Además, en esta etapa se realizan otras tareas en paralelo. Por un lado, se busca en el buffer de reordenamiento si la tarea que corresponde por orden de llegada ha finalizado. Volviendo a nuestro ejemplo de siempre, preguntaría si la suma ha terminado, y obviaría la resta, incluso si ésta esta ya lista. De encontrarse terminada la suma, efectuaría la etapa final de "write back", escribiendo en el registro correspondiente del banco de registro, o en el PC, según corresponda a la instrucción (en nuestro ejemplo, en el registro del banco de registro).

Por otro lado, si no hay hueco disponible en el buffer de reordenamiento, no queda más remedio que introducir "burbujas" en el cauce, introduciendo instrucciones NOP, y parando las etapas anteriores a ésta.

- Etapa de Decodificación 3. En esta etapa, se solicita plaza en la estación de reserva correspondiente. ¿Que es eso de la estación de reserva? Miremos con detenimiento el fragmento de código antes mencionado.

Se ve claramente que la suma no puede comenzar hasta que la operación de memoria termine. Esto, en un cauce sin renombramiento dinámico de registros, paralizaría todo el cauce. Pero al contar con un algoritmo de Tomasulo, esto se soluciona de la siguiente manera.

Ya en esta etapa de la segmentación, la instrucción anterior, es decir, la carga en memoria, ha solicitado y ha obtenido una plaza en el buffer de reordenamiento. Esto significa, a todos los efectos, que la posición dentro del buffer de reordenamiento es un identificador de la instrucción, durante toda su vida dentro del cauce segmentado. Dicho de otro modo: la posición ocupada en el buffer de reordenamiento es un 'DNI' de la instrucción. Es decir, a partir de la etapa de Decodificación 2, la instrucción de carga desde memoria tiene, pongamos por caso, el identificador "ROB1", es decir, ocupa la posición 1 en el buffer de reordenamiento (ROB = Re-Ordering Buffer). Dicho esto, también podemos decir que la instrucción "ROB1" escribirá en el registro 5 del banco de registros, una vez acabe su ejecución. La instrucción siguiente, que ahora mismo se encuentra en la etapa de Decodificación 2, está pidiendo su propia posición dentro del buffer de reordenamiento, y dado que las instrucciones entran por orden, y han de salir por orden, obtendrá el identificador "ROB2", es decir, ocupará la segunda posición en el buffer de reordenamiento, justo debajo de la instrucción de carga desde memoria. El problema, es que la instrucción "ROB2" tiene que esperar a que termine la instrucción "ROB1", debido a que "ROB1" escribe sobre uno de los operandos que necesita "ROB2". Pues bien, la estrategia es la siguiente. Disponemos de un conjunto de registros, llamados "estaciones de reserva", donde alojaremos provisionalmente las instrucciones, según van llegando. Y dentro de este registro guardamos, o bien el valor que contiene el registro del banco de registros (correspondiente a sus operandos), o bien apuntamos el "DNI" de la instrucción que generará dicho operando. De esta forma, cada instrucción se queda a la espera de sus propios operandos. Si una instrucción posterior, en nuestro ejemplo la 'ORI' (a la cual se le habrá asignado el identificador "ROB3") tiene sus operandos disponibles, no tiene por qué quedarse a la espera de que instrucciones más prioritarias tengan sus operandos. Obviamente solo podrán progresar hasta escribir el resultado en el buffer de reordenamiento, y no podrán volcar sus resultados sobre sus registros de destino, pero a todos los efectos, la instrucción se ha computado completamente (ya ha utilizado la unidad de ejecución, que a fin de cuentas es el cuello de botella del cauce). Es trabajo adelantado.

Pues bien, en esta etapa de la segmentación, justo se busca un hueco disponible donde alojar provisionalmente la instrucción. A diferencia del buffer de reordenamiento, que es un buffer FIFO, en este el orden no es importante, puesto que la primera instrucción en salir del mismo, no es la primera en llegar, o la última, sino la primera que tiene sus operandos disponibles.

Nuevamente, de no existir hueco disponible, habría que introducir burbujas -instrucciones NOP- y parar el cauce en las etapas anteriores.

- Etapa de Decodificación 4. Ya tenemos todos los buffers reservados, ahora solo nos queda llenarlos con los datos adecuados. 
De un lado, la instrucción "reserva" el registro de destino. Es decir, en nuestro ejemplo, la instrucción de carga desde memoria "ROB1" guarda el resultado en el registro r5. Es decir, una vez la instrucción "ROB1" acabe, el resultado deberá guardarse en el registro r5 del banco de registros. Pues bien, en esta etapa se le dice al banco de registros, que apunte que es la instrucción "ROB1" la que deberá guardarse en esta posición. ¿Que ocurre si otra instrucción quiere guardar también su resultado en el mismo registro? En nuestro ejemplo la instrucción 'andi' también quiere guardar su resultado en el registro r5 del banco de registros (y por consiguiente, con 'DNI' "ROB4"). Bueno, si es una instrucción "más tardía" que "ROB1", es decir, en la secuencialidad del programa, primero va "ROB1" y luego va "ROB4", está claro que el resultado que debe prevalecer en el banco de registros, es el resultado de la segunda instrucción. De no ser así, para cuando acabe la instrucción "ROB4" el resultado que se habrá guardado en el banco de registros es el de la instrucción "ROB1". Luego el algoritmo es sencillo: esté o no reservado por una instrucción, la instrucción que llega a esta etapa reserva su registro de destino en el banco de registros.

En paralelo, se ha realizado otra tarea necesaria: la recopilación de operandos. Hemos estado muy atareados reservando espacios en múltiples registros y búfferes, pero aun no tenemos lo necesario para ejecutar la instrucción: los operandos. En esta fase, se pregunta al banco de registros si los operandos estan disponibles, o han sido reservados por alguna instrucción. Supongamos que la instrucción "ROB2" ha llegado a esta etapa (la instrucción add), y quiere usar el registro r5. La instrucción no quiere el contenido actual del registro r5, sino el que tendrá cuando la instrucción "ROB1" (la instrucción ld) acabe. Por otro lado también necesita el registro r4, que en este caso, no necesita esperar a ninguna otra instrucción, puesto que ninguna instrucción previa va a escribir sobre este registro. Por tanto, cuando "ROB2" pregunte al banco de registro por ambos registros, le dirá que puede darle el valor del registro r4, y el 'DNI' de la instrucción que le proporcionará el registro r5 (porque "ROB1" ya fijó en el ciclo anterior que él escribiría sobre este registro cuando acabara). Es decir, la instrucción "ROB2" se quedará en la estación de reserva esperando a que termine la instrucción "ROB1". Por otro lado, cuando la instrucción "ROB3" llegue a esta etapa, querrá el registro r3, que se encuentra totalmente libre, y su otro operando es un inmediato. Por tanto, en esta etapa, la instrucción "ROB3" completará su lista de operandos, pudiendo entrar en ejecución en el ciclo siguiente (gracias a que la ejecución es fuera de orden mediante un buffer de reordenamiento, como ya indique antes).

Aquí, en esta etapa, las instrucciones se quedan esperando en las estaciones de reserva, a que los operandos estén disponibles. Se puede decir, que en cierto sentido las estaciones de reserva hacen las veces de registros de segmentación. En cierto sentido, a nivel conceptual, el micro se puede dividir en dos partes: preparación de las instrucciones y ejecución de las mismas. Hasta aquí, todos los procesos que se han realizado han preparado las instrucciones para su ejecución. Ahora podemos ejecutarlas.

- Etapa de Ejecución 1. Hemos dejado las instrucciones en la estación de reserva, recopilando sus operandos. Una vez tenga todos los operandos disponibles, la instrucción puede pasar a ejecución (a usar la ALU). Pero, ¿quién decide que instrucción esta lista para pasar a ejecución? Necesitamos un agente que compruebe que instrucción tiene todos sus operandos disponibles. En esta etapa, un agente busca instrucciones listas y las pasa a ejecución, que es la siguiente etapa. Hay tres agentes comprobando cada una de las tres estaciones de reserva, alimentando las tres unidades funcionales: ALU, sumador/restador en coma flotante y multiplicador/divisor en coma flotante.

- Etapa de Ejecución 2. Aquí es donde se realiza la ejecución de la instrucción en sí misma. Está compuesta por tres unidades funcionales diferentes: la ALU para operaciones aritméticas y cálculo de direcciones, la unidad de sumas y restas en punto flotante y la unidad de multiplicaciones y divisiones en punto flotante. Cada unidad toma un número de ciclos para completar las operaciones. Debemos recordar que este microprocesador lo implementé en forma de simulador software. Por tanto, podía modular completamente el comportamiento de estas unidades. Por ejemplo, la unidad ALU tomaba un ciclo para sus operaciones (incluida la multiplicación...), la unidad de sumas/restas en coma flotante tomaba tres ciclos y finalmente, la unidad de multiplicaciones/divisiones en coma flotante tomaba cinco ciclos. Además, en el simulador podemos indicar, para cada unidad, si está segmentada o no. Esto permite que en cada ciclo entre una nueva instrucción en la unidad de punto flotante. En los microprocesadores reales, implementados sobre silicio, el número de ciclos tomados son bastantes más, y además -y debido precisamente al número de ciclos que usan-, las unidades son casi siempre segmentadas.

- Etapa de Memoria. Las instrucciones de memoria usan la ALU de enteros para calcular la dirección. En la etapa anterior, ésta ha sido ya calculada. En esta etapa es cuando se pide a la cache L1 el dato alojado en la dirección dada. Es una etapa que puede durar más de un ciclo, dependiendo de si el dato está o no en la cache L1. Dado que esta operación puede tomar mas de un ciclo, existe el riesgo de que dos instrucciones de memoria quieran acceder al mismo tiempo. Esto detendría todo el cauce de segmentación. La solución adoptada en este diseño, es disponer de un buffer que almacena las direcciones de memoria y el tipo de acceso (escritura o lectura) de todas las instrucciones de acceso a memoria. En la implementación software, se cuenta con un buffer para lecturas de cinco entradas, y un buffer para escrituras de cinco entradas.

En la implementación software, la etapa de Ejecución 2 y la etapa de Memoria escriben su resultado en el buffer de reordenamiento en el mismo ciclo. Está claro que eso al menos consumiría otro ciclo más, por lo que es una operación que habría que segmentar en dos. Podríamos llamar a esta etapa "ROB". En la implementación software se simplificó, haciendo que la propia etapa de Ejecución 2 y la etapa de Memoria realicen la tarea.

- Unidades funcionales. Como ya he indicado, el microprocesador cuenta con tres unidades funcionales: ALU, sumas/restas en coma flotante y multiplicaciones/divisiones en coma flotante. ¿Por qué esta separación? Existen diferentes estrategias a la hora de definir el "camino de datos", es decir, el flujo que sigue la información dentro del microprocesador. Entre ellas quizás las más interesantes son las siguientes:

  1.     ALU y FPU en una misma unidad. Esto simplifica enormemente la lógica de control. Para empezar solo hace falta una estación de reserva. Pero esto tiene grandes inconvenientes. Por un lado, la diferencia de velocidad entre la unidad de enteros y la de coma flotante es abismal. En el simulador software la diferencia es mínima, pero en la realidad la cosa difiere bastante. Además, imaginemos que la unidad de ejecución se encuentra ocupada procesando una división en coma flotante (la más larga con diferencia). Y esperando, tenemos una suma de enteros para calcular un salto. El salto, no se podría calcular hasta que terminara la operación de coma flotante. Eso retrasaría no solo la instrucción de salto en sí, sino además la comprobación de la instrucción en la cache L1. ¿Y si la instrucción no está en la línea de cache y hay que traerla de memoria? Pues no se podrá hacer hasta que la operación de coma flotante termine, y no se podrá tener ese trabajo adelantado. Así que parece lógico que la primera división es separar la unidad de enteros de la unidad de coma flotante, no ya solo por este ejemplo en concreto, sino por la abismal diferencia de rendimiento que ofrecen ambas unidades.
  2.     ALU y FPU separadas. Ya hemos visto que esta es una solución absolutamente necesaria. Pero en su día, me pareció demasiado básica. Me explico. La mayor parte del tiempo realizamos sumas, muchas veces restas, y poquísimas veces multiplicaciones y divisiones (hablamos de programas de carácter general, y no de técnicas especiales de manipulación de datos). de modo que, una sola multiplicación o división en coma flotante, paralizaría todas las operaciones de sumas y restas, que son las que más se utilizan. Entonces me pareció buena idea, separar ambas operaciones en dos unidades de coma flotante: una para sumas/restas y otra para multiplicaciones/divisiones.
  3.     ALU ADDF y MULF separadas. ¿Tanto impacto tiene el tener unidas la unidad de multiplicación y la de sumas? Ya se dijo que apenas se utilizan las operaciones de multiplicación, de modo que no parece que tenga un gran impacto sobre el rendimiento, en términos estadísticos. Pero, aun así, ¿que costo adicional tiene el tener separadas ambas unidades funcionales? Únicamente la inclusión de una nueva estación de reserva. En principio, esto es un costo mínimo, y se ha supuesto que fácilmente asumible. A cambio, se consigue una leve mejora del rendimiento cuando se utilicen muchas operaciones de sumas y multiplicaciones. En nuestra implementación, se ha supuesto además que la unidad de suma no se utiliza para implementar la unidad de multiplicación, por lo que pueden vivir separadas.
No hay que olvidar que todo este diseño se ha supuesto para simplemente realizar un simulador. Sobretodo, hay que tener en cuanta que su finalidad era más didáctica que práctica. En posteriores entregas iré describiendo como se implementó finalmente en c++, y posteriormente, que adaptación del microprocesador he implementado en silicio, diseñando "transistor a transistor". Creo que será interesante ver como se llevan todas estas ideas al terreno físico y real. En el mundo del software casi todo es realizable. Y si te falta capacidad de cálculo, siempre puedes tener más máquinas corriendo. Pero el mundo del hardware es muy hostil. Para que un diseño de altas prestaciones tenga sentido, hay que pensar siempre en integrarlo todo en una misma pastilla (nada de conectar dos integrados a través de pistas en un circuito integrado). Pero obviamente, el espacio es finito, y no todo lo que se nos ocurre se puede llevar a cabo. Dicho esto, añadiré que todos estos algoritmos que he comentado los implementan los microprocesadores actuales (y muchísimas cosas más que no he contado). Es más, incluso hay cabida para tener varios de estos microprocesadores en la misma 'die' (véanse los core i3, i5 e i7 de intel), y no solo eso, sino que incluso caben cantidades ingentes de memoria caché. Por otro lado, aun siendo material de otro artículo, adelantaré que el microprocesador lo he implementado con tecnología de 0,35 micrómetros (o 'micras' en el argot), misma tecnología que se usaron en las últimas revisiones del pentium, o incluso mejor que la tecnología usada en los primeros Pentium Pro (0,50 micrómetros).

Espero que os haya parecido interesante el artículo, y que comentéis cualquier error que haya podido cometer.

lunes, 4 de abril de 2011

La luna llena, más llena

    Hoy, 19 de marzo, después de 15 años, la órbita de la luna se coloca de nuevo en su punto más cercano a la Tierra (en el perigeo), coincidiendo con la luna llena. Gracias a este fenómeno astronómico, la luna se mostrará más grande y más brillante. Eso es lo que hemos oído en televisión y demás medio de comunicación, y me gustaría precisar un poco más esta noticia.

    Como sabemos, la órbita de la luna alrededor del planeta Tierra es elíptica, estando el planeta Tierra en un foco de la misma, y la Luna en el opuesto. De tal suerte que en ocasiones la luna está en el punto más alejado, que se conoce como apogeo, y otras veces en el punto más cercano de la Tierra, que es conocido como perigeo. Alrededor del mes de Mazo, los perigeos y apogeos son los siguientes:


  • Apogeo    6 Marzo 2011   7:50     406.583'5 km
  • Perigeo    19 Marzo 2011 19:07   356.578'2 km
  • Apogeo    2 Abril 2011     9:02    406.658'8 km


    En concreto, la luna estará a 356.578'2 kilómetros de nuestro planeta. Pero, ¿siempre es el mismo Perigeo y el mismo apogeo?, si cogemos unos cuantos valores más, se podrá ver fácilmente que esto no es así:


  • Perigeo     17 Abril 2011  5:58    358.090'5 km
  • Apogeo    29 Abril 2011  18:01  406.038'1 km
  • Perigeo     15 Mayo 2011 11:24  362.133'4 km
  • Apogeo     27 Mayo 2011 9:56   405.003'0 km
  • Perigeo     12 Junio 2011  1:41   367.188,7 km
  • Apogeo    24 Junio 2011   4:11  402.274'2 km
  • Perigeo     7 Julio 2011     13:51 369.567,3 km
  • Apogeo    21 Julio 2011   22:47 404.358'1 km
  • Perigeo    2 Agosto 2011  21:05 367.757'7 km
  • Apogeo   18 Agosto 2011 16:23 405.162'5 km
  • Perigeo    30 Agosto 2011 17:38 360.856'0 km

    En realidad, los apogeos y perigeos van variando cíclicamente a lo largo de los años, debido fundamentalmente a la influencia del sol. Y además, no siempre coincide con la fase lunar de Luna llena. Por tanto, ¿por qué es tan especial este perigeo del 19 de Marzo? La razón es una doble coincidencia: es uno de los perigeos más cercanos a la Tierra, y además, coincide exactamente (con horas de diferencia) con la Luna llena. Y este hecho insólito, esta doble coincidencia, es lo que no se daba desde hacía muchos años. Además, y debido a la ilusión óptica que ocurre cuando la luna esta cerca del horizonte, teníamos asegurado un espectáculo impresionante, para deleite y disfrute de nuestros ojos.


    Como aficionado a la fotografía, no podía dejar escapar esta oportunidad. Así que, armado con mi cámara réflex Sony, mi juego de ópticas, mi trípode y un block para tomar notas, me decidí a aventurarme en la astrofotografía en su vertiente más simple: con una simple cámara de fotos. Además, y para colmo de males, mi cámara no es precisamente buena con el ruido, y de noche la necesidad de ISOs altos es apremiante. Además, no dispongo de un gran zoom, por lo que la escena era demasiado abierta (nada que no se arregle con un recorte en photoshop, aunque para ello sacrifique la resolución).  Con todo y con eso, a base de exposiciones largas y un poco de photoshop conseguí algunas fotos que se salvaban "de la quema".

    Lo primero era elegir la escena que deseaba fotografiar: la luna emergiendo del mar. Lo segundo era buscar un emplazamiento adecuado para la astrofotografía: con poca contaminación lumínica. Dado que no dispongo de tiempo como para desplazarme fuera de Málaga, tenía que buscar un buen emplazamiento aquí mismo. Y la solución fue la siguiente:

Emplazamiento de las fotos

    Una vez elegido el sitio, monte mi cámara en el trípode, y tiré algunas fotos de prueba, por ir tanteando. Primero hice una tirada con el diafragma ligeramente cerrado, en concreto, en la posición de mayor calidad óptica (menor aberración cromática, mayor definición, mejor profundidad de campo, etc): f :7.0 aproximadamente.
Montaje de mi cámara réflex con el trípode.
    Con esta apertura, necesitaba tiempos de exposición largos, eso estaba seguro. La cuestión estaba en elegir un buen valor de ISO. Cuanta menor cantidad de ISO se use, menor ruido se captará, y la falta de luminosidad se podrá corregir con exposiciones aun más largas. En cualquier caso hice algunas tomas a ISOSs altos, para contrastar la calidad.

  • Luna saliendo por el horizonte
20s f/7.1 ISO200 70mm (35mm eq:105mm) 
20s f/7.1 ISO200 70mm (35mm eq:105mm) 
20s f/7.1 ISO200 18mm (35mm eq:27mm) 

  • Luna a 5-10 grados del horizonte aprox.
10s f/6.3 ISO3200 50mm (35mm eq:75mm) 
10s f/6.3 ISO3200 70mm (35mm eq:105mm)


  • Luna a 20 grados del horizonte aprox.
20s f/7.1 ISO200 18mm (35mm eq:27mm) 



  • Luna a 30 grados del horizonte aprox.
15s f/16.0 ISO800 70mm (35mm eq:105mm) 


0.8s f/1.4 ISO100 50mm (35mm eq:75mm)



Referencias:
http://www.astronomo.org
http://www.nasa.gov