1. Tarea01: primefact_serial

Tarea estrictamente individual. Fecha límite de entrega: viernes 03 de setiembre a las 23:59. Se entrega en su repositorio personal. Se recomienda adicionalmente subir una copia comprimida del código fuente a este enunciado a modo de respaldo.

1.1. Descripción del problema

Factorizar números enteros en sus componentes primos es una tarea repetitiva y tediosa, en especial para números grandes. Sin embargo, también lo es para las computadoras tradicionales. La seguridad de sus comunicaciones privadas, datos sensibles y su dinero en el banco depende de esta dificultad, ya que están protegidos por números difíciles de factorizar.

Todo número entero positivo se clasifica en tres categorías: uno, primo, o compuesto. Un número primo es un número entero divisible solamente por 1 y por él mismo, como es el caso de 17. Los números compuestos son aquellos que tienen más de dos divisores, se pueden descomponer y representar como una multiplicación de factores primos. Por ejemplo, el número 10 se puede representar como 2 x 5, el número 100 como 2 x 2 x 5 x 5 o como 2^2 x 5^2 usando notación de potencias. El número 1 es especial y no se considera en ninguna de las dos categorías anteriores.

Para esta entrega se necesita un programa en C que reciba una lista de números enteros en la entrada estándar y calcule su factorización prima de manera serial. Su programa producirá como resultado una lista en la salida estándar con los números en el mismo orden en que fueron ingresados. Cada línea de la salida contiene un número ingresado seguido de su factorización prima. Los exponentes se deben anteceder por el carácter ^. Note que los exponentes 1 no se deben escribir. A continuación se muestra un ejemplo de la salida esperada para la entrada dada.

Ejemplo de entrada:

1
7
25
87
378
1400
-40

Salida esperada:

1: NA
7: 7
25: 5^2
87: 3 29
378: 2 3^3 7
1400: 2^3 5^2 7
-40: invalid number

Su programa debe poder factorizar números positivos de 64 bits con signo, es decir, entre 0 y 2^63-1 (int64_t de la biblioteca <stdint.h>). Si en una línea se provee un número fuera de este rango o un texto que no es un número, el programa debe imprimir el texto "invalid number" en la salida estándar, y continuar procesando el resto de líneas.

1.2. Entregables

En su repositorio personal cree una carpeta tareas/primefact_serial/ para esta tarea. Debe mantenerse la estructura de archivos y directorios característica de un proyecto de Unix, resumido en la siguiente tabla. En las secciones siguientes encontrará detalles de cada archivo o carpeta.

Recurso Descripción Versionado

bin/

Ejecutables

No

build/

Código objeto (.o) temporal

No

design/

Diseño de la solución en UML, redes de Petri, pseudocódigo, u otros

doc/

Documentación generada por doxygen

No

src/

Código fuente (.h y .c)

test/

Casos de prueba

Makefile

Compila, prueba, genera documentación

Doxyfile

Configura Doxygen para extraer documentación del código fuente

README.md

Describe el problema resuelto y el manual de usuario

.gitignore

Ignora los recursos que NO deben estar en control de versiones (columna Versionado)

Haga buen uso del repositorio de control de versiones. Cada commit debe tener un cambio con un significado, un mensaje significativo, y no romper el build''. No haga megacommits'' que implementan muchas funcionalidades distintas y acumuladas en varios días de desarrollo.

Sea severamente cuidadoso(a) al agregar archivos a control de versiones. No agregue archivos ejecutables, código binario, o carpetas que surgieron de la salida de un programa automatizado. Además de los archivos que tienen No'' en la columna Versionado'', haga que su .gitignore ignore archivos generados por el sistema operativo, como .DS_Store en macOS o thumbs.db en Windows.

Su solución debe ser una obra intelectual propia por la naturaleza individual de la tarea. Si reutiliza código de alguna fuente, dé el respectivo crédito. Por ejemplo, si son unas pocas líneas, basta un comentario en el código fuente que diga: // Adaptado de <URL>. Si reutiliza varios archivos, la referencia debe estar en su README.md, en una sección de ``Créditos'', donde liste el nombre de la biblioteca, el creador, la dirección electrónica, la licencia, si la tiene. Nota: no es necesario dar crédito a los ejemplos y materiales provistos por los docentes en este curso.

1.3. Análisis

En un archivo README.md en notación Markdown, o si lo prefiere readme.adoc en notación AsciiDoc (es indiferente el uso de mayúsculas o minúsculas), plasme el resultado de la fase de análisis. Si no conoce el formato Markdown o AsciiDoc, asegúrese de seguir un manual o buscar un tutorial, ya que la buena calidad del código también será evaluada. Por ejemplo, un párrafo en estas notaciones se forma al separar por dos cambios de línea y no por uno. Su documento de análisis debe incluir:

  1. Una descripción del problema a resolver. Puede adaptar partes de este enunciado pero no copiarlas exactamente igual, ya que el enunciado va dirigido al estudiantado, mientras que el README va dirigido a cualquier persona que visite el proyecto y quiera entender el problema que la solución resuelve.

  2. Un manual de uso, que indica cómo construir (compilar) la solución, y cómo correrla. Conviene proveer ejemplos de cómo correr el programa, sea de forma interactiva, o en lote redireccionando la entrada estándar. También contra números primos, compuestos, e inválidos. No es necesario hacer capturas de pantalla de la terminal, se puede copiar y pegar la salida en un bloque de código de Markdown o AsciiDoc.

  3. Una sección de créditos, donde indica su nombre e información de contacto (correo institucional). Si utiliza recursos de terceros, como imágenes o archivos de código fuente, dé el respectivo crédito.

1.4. Diseño

Para que su código sea fácil de paralelizar en tareas posteriores, su diseño de esta tarea no debe entrelazar el cálculo de los factores con las impresiones, sino que las subrutinas de lectura, cálculo de factores (potencias), e impresión deben estar separadas e invocarse en secuencia en ese orden. Por consiguiente, necesitará diseñar una estructura de datos que almacene en memoria dinámica los números ingresados, las factorizaciones encontradas, y que serán impresas.

Para este diseño elabore un diagrama de la estructura de datos para un ejemplo de entrada con al menos tres líneas (un número compuesto de al menos dos potencias, un primo, y un número inválido). Puede pensar en este diagrama como un rastreo de memoria o como los diagramas típicos de estructuras de datos que realizó en cursos previos de programación. El modelo resultante, es decir, los tipos de datos de la estructura, represéntelo en UML.

Exporte sus diagramas a archivos .svg o .png y almacénelos en la carpeta design/. En esta misma carpeta cree un archivo design/README.md o design/readme.adoc. En este archivo incruste las imágenes de diseño. Además provea una descripción que ayude a una persona ajena al proyecto a comprender su diseño general (`comprender el bosque''). Tip: Puede poner un hiperenlace desde el `README.md de la tarea hacia su design/readme.md, lo cual permitirá navegabilidad entre los documentos del proyecto.

Su solución debe ser modular. Debe al menos tener las subrutinas de lectura, cálculo de factores primos (potencias), impresión, y subrutina principal. La del cálculo de factores probablemente necesitará subdividirla. Para cada subrutina diseñe una solución en pseudocódigo en uno o varios archivos con extensión .pseudo en la carpeta design/. Si usa notación AsciiDoc, incruste estos archivos en su documento de diseño (design/readme.adoc), sino enlácelos con hiperenlaces desde su archivo de Markdown.

1.5. Documentación

Las declaraciones de subrutinas y tipos de datos (e.j struct) públicos deben estar en archivos .h, y deben estar documentados con Doxygen en inglés o español. Doxygen requiere un archivo de configuración (por defecto llamado Doxyfile), que sí debe estar en control de versiones. Se puede generar un archivo de configuración con el comando doxygen -s -g y se debe configurar para el lenguaje de programación C. Estos son algunos cambios sugeridos:

PROJECT_NAME           = "Prime Factorization"
PROJECT_NUMBER         = 1.0.0
PROJECT_BRIEF          = "Find factorization of integers"
OUTPUT_DIRECTORY       = doc
CREATE_SUBDIRS         = YES
OPTIMIZE_OUTPUT_FOR_C  = YES
EXTRACT_ALL            = YES
EXTRACT_PRIVATE        = YES
EXTRACT_PRIV_VIRTUAL   = YES
EXTRACT_PACKAGE        = YES
EXTRACT_STATIC         = YES
EXTRACT_LOCAL_METHODS  = YES
QUIET                  = YES
INPUT                  = src
RECURSIVE              = YES
HAVE_DOT               = YES

Los archivos de salida de Doxygen deben generarse en una carpeta doc/ y ésta NO se debe agregar a control de versiones. Asegúrese de que el reporte que genera Doxygen en la salida estándar, no incluya diagnósticos (warnings). Además de documentar las interfaces, documente el código no trivial en los cuerpos de las subrutinas.

Nótese que la documentación de subrutinas tiene dos partes. La primera es la interfaz de la subrutina, que debe hacerse en Doxygen. Aquí importa el qué hace la subrutina: Una oración que resume qué hace la subrutina, y puede tener una sección de detalles; qué parámetros recibe (valores esperados), qué valor retorna, qué pasa cuando se envían valores no válidos (ej. fuera de rango), y cualquier otro detalle que alguien que invoque su subrutina necesita saber, por ejemplo, si el cálculo de números primos es por fuerza bruta o algún otro algoritmo. Un ejemplo corto de documentación de una subrutina podría ser:

/**
 @brief Read @a value_count double from stdin and store them in @a values array
 @param value_count Number of elements to be read from stdin
 @param values Array of elements. It must not be NULL
 @return An error code:
   0 for success.
   1 if EOF is found before @a value_count values are read
   2 if a valid double value cannot be read from stdin
*/
int read_values(const size_t value_count, double values[]);

La segunda parte es la implementación de la subrutina. Esto se documenta como comentarios normales (NO de Doxygen) dentro del cuerpo de la subrutina. Aquí importa el cómo, de tal forma que si alguien lee los comentarios dentro del cuerpo, obtiene un algoritmo en alto nivel (lenguaje natural) de la solución implementada. Por lo tanto, tome el texto de sus archivos de diseño (.pseudo), péguelo en el archivo fuente (.c) correspondiente, y convierta el texto en comentarios. En la fase de implementación escribirá código C cada instrucción del pseudocódigo. Un ejemplo de este proceso puede verse en video Punto de entrada, declaraciones de variables'' que es parte de una secuencia de videos titulados Diseño procedimental #''.

1.6. Implementación

Implemente su diseño (diagramas UML y pseudocódigo) en el lenguaje de programación C. Recuerde aplicar buenas prácticas de programación. Por ejemplo, las variables globales están prohibidas en esta y todas las evaluaciones del curso. Corra el linter con frecuencia. Los siguientes son algunos detalles técnicos que encontrará.

1.6.1. Validación de entrada

Aplique la buena práctica de programación defensiva. Su programa debe defenderse de entradas inválidas o mal intencionadas, como números incorrectos, muy grandes, o textos que no son números. Para probar si hubo un error de lectura deben hacerse las verificaciones de la subrutina que están usando para entrada/salida, por ejemplo el valor de retorno de scanf(). Además conviene revisar la variable global errno declarada en el encabezado <errno.h> disponible en ambientes Unix, ya que algunas subrutinas de entrada y salida escriben en ella códigos de error. Nota: Después de procesar una entrada inválida, conviene limpiar la variable errno para la siguiente lectura.

1.6.2. Enteros de tamaño fijo

El linter sugiere que use tipos de datos enteros cuyo tamaño no dependa de la arquitectura. Es decir, en lugar de usar int que en algunas arquitecturas es de 2 bytes y en otras es de 4 bytes, use un tipo que siempre es de 2 o 4 bytes indiferentemente de la arquitectura, como int16_t o int32_t respectivamente. La biblioteca estándar C define estos tipos en el encabezado <stdint.h>.

El paso seguido es hacer que la entrada y salida también se adapte a los tamaños fijos de los enteros. En C++ no hay que hacer cambio alguno, ya que la E/S es genérica, pero en C se usan unas constantes de formato definidas en el encabezado <inttypes.h>. Esas constantes tienen la forma SCNxN para lectura (scanf) y PRIxN para impresión (printf) Por ejemplo, el siguiente programa de C usa un tipo de datos dependiente de la arquitectura (4 o 8 bytes):

unsigned long value = 0;
if ( scanf("%lu", &value) == 1 ) {
   printf("value = %lu\n", value);
}

Si se quiere que el entero sea de 4 bytes sin signo, el mismo programa sería:

#include <stdint.h>
#include <inttypes.h>

uint32_t value = 0;
if ( scanf("%" SCNu32, &value) == 1 ) {
   printf("value = %" PRIu32 "\n", value);
}

1.6.3. Estructuras de datos

La biblioteca estándar de C no tiene contenedores genéricos. Uno los implementa de forma casera, o reutiliza código de terceros. Por ejemplo, un arreglo dinámico se puede implementar con realloc(), y colas simplemente enlazadas usando registros (struct) y punteros. En C estas estructuras no tienden a ser genéricas, sino estar ``mezcladas'' con los tipos de datos que almacenan. En los vídeos del taller de C++ a C se provee ejemplos de estos dos contenedores..

1.6.4. Optimización

No optimice antes de tiempo, sin necesidad, y no lo haga de forma artesanal. Enfóquese primero en resolver el problema por corrección (correctitud''), y por tanto, pasar los casos de prueba. Una vez que el problema esté resuelto, se determina que la solución implementada es lenta'' y hay recursos para mejorarla (tiempo, dinero…), es el momento de optimizar.

Una optimización debe hacerse de forma sistemática, y más adelante en el curso se proveerá un método que emplea herramientas (profiling) para detectar las regiones que más consumen recursos (procesamiento, memoria, comunicación), y se hacen mediciones antes y después de los cambios para determinar empíricamente si hubo en efecto una mejora en el rendimiento o no. Mientras tanto, enfóquese en resolver el problema por corrección.

1.7. Pruebas

Asegúrese de verificar el funcionamiento de su programa con los casos de prueba que se adjuntan. Los casos están clasificados en tres grupos de acuerdo a la cantidad de procesamiento que requieren. Puede agregar los casos pequeños y medianos a control de versiones si gusta, pero no los casos de prueba grandes. Se recomienda enfáticamente crear casos de prueba propios y agregarlos a control de versiones.

El Makefile disponible en la sección `Archivos'' del aula virtual, permite probar su solución contra todos los casos de prueba con la orden `make test. Conviene instalar el programa icdiff para obtener una comparación con colores (sudo apt icdiff).

Su programa debe hacer buen uso de los recursos. Por ejemplo, no debe generar fugas de memoria, ni accesos inválidos, ni usar memoria no inicializada. Estas pruebas puede realizarlas herramientas de análisis dinámico de código como Valgrind y Google Sanitizers.

1.8. Evaluación

  1. [10%] Buen uso de los commits, estructura de archivos y directorios, ignorar archivos generados.

  2. [10%] Análisis (README.md): descripción del problema, el manual de usuario, créditos.

  3. [20%] Diseño de la solución: diagrama de memoria, diagrama de UML, pseudocódigo.

  4. [10%] Documentación de subrutinas y registros (doxygen) y algoritmos en los cuerpos de subrutinas.

  5. [30%] Implementación y corrección de los algoritmos y estructuras de datos (pasar los casos de prueba).

  6. [10%] Buen uso de la memoria (memcheck, asan, msan, ubsan).

  7. [10%] Modularización en subrutinas y archivos (.h, .c). Apego a una convención de estilos (cpplint).

2. Tarea02: primefact_pthread

Tarea estrictamente individual. Fecha límite de entrega: jueves 30 de setiembre a las 23:59. Se entrega en su repositorio personal.

Esta tarea es una secuencia de la tarea01 y debe apegarse a las reglas de entrega de la tarea01, a excepción de los cambios solicitados en este enunciado. En particular, se mantienen las siguientes secciones de la tarea01:

  • Descripción del problema

  • Entregables

  • Análisis

  • Documentación

Para esta tarea paralilice su programa en C usando Pthreads para que el cálculo de los factores primos se haga de forma concurrente.

2.1. Correcciones a la Tarea01

Si no lo ha hecho, primero corrija su solución a la tarea01 en la carpeta tareas/primefact_serial/ (no copie ni cree una carpeta nueva) para aplicar las observaciones que obtuvo durante la revisión de la misma, de manera que no se propaguen las deficiencias identificadas de la tarea01 en la tarea02. Recuerde utilizar issues de su sistema de control de versiones de la siguiente forma.

  1. Por cada observación que recibió durante la revisión (sea que las haya anotado o que visualice la grabación de la revisión), cree un issue en el sistema de alojamiento de control de versiones (git.ucr o github). Inicie el título del issue con el número de la tarea, por ejemplo "Tarea01: cambiar arreglo estático por dinámico". Describa en el issue el problema identificado puntualmente. Note que cada issue recibe un número que lo identifica.

  2. Corrija un issue a la vez en su repositorio. Modifique los archivos que necesite directamente (no cree una copia de carpeta). Para cada corrección cree un commit y en el mensaje refiera el número del issue. Si es el último commit que resuelve el issue, ciérrelo desde el mensaje de commit. Por ejemplo, el mensaje:

    git commit -m 'Store sums in dynamic array instead of a static one. Close #13'

    cerraría el issue identificado con 13. De esta forma el issue y el commit quedan ligados en el sistema de control de versiones y facilita la rastreabilidad.

Una vez que haya corregido la tarea01, cree un tag tarea01fix asociado al último commit de las correcciones, y asegúrese enviarlo al repositorio remoto (git push --tags). Luego pase a la versión concurrente (tarea02).

2.2. Descripción del problema (Tarea02)

En su repositorio personal copie la carpeta tareas/primefact_serial/ y su contenido como tareas/primefact_pthread. Haga un commit con este cambio.

El programa concurrente recibirá la lista de números enteros en la entrada estándar e imprimirá la descomposición prima de cada uno de ellos, en el mismo orden y de la misma forma que la versión serial lo hace. Es decir, el programa concurrente debe pasar los mismos casos de prueba que la versión serial. La diferencia con la versión serial, es que la solución a esta tarea debe calcular las factorizaciones primas de manera concurrente utilizando hilos que se reparten el trabajo, y por lo tanto, debe tener un incremento en el desempeño corroborable.

Su programa concurrente debe permitir al usuario invocarlo con un número provisto como argumento de línea de comandos, el cual indica la cantidad de hilos de ejecución que realizarán el cálculo de los factores primos. Si este número no se provee, se debe asumir la cantidad de núcleos (CPUs) disponibles en la máquina. Recuerde actualizar su README.md para indicar que su solución usa hilos, y que el usuario puede proveer este parámetro opcional.

2.3. Diseño concurrente

Importante. Al disponer de varios hilos de ejecución, debe repartir el trabajo lo más equitativamente posible entre ellos. Haga que los hilos trabajen de forma lo más paralela posible, y por consiguiente, evitar el control de concurrencia innecesario. Recuerde que además su solución debe imprimir los resultados en el orden esperado. Se recomienda evitar la serialización de los hilos y usar una estrategia de seguridad condicional (conditionally safe).

Su solución debe tener un buen diseño concurrente expresado en diagramas y pseudocódigo:

  1. Resalte en su diagrama de la estructura de datos qué datos le corresponden a cada hilo de ejecución. Puede suponer que dispone de dos hilos de ejecución los cuales se reparten los tres números dibujados en la estructura.

  2. El diseño en pseudocódigo debe centrarse en la lógica concurrente (lo nuevo en esta tarea) y no los detalles algorítmicos utilizados para calcular la factorización prima de un número (lo ya resuelto en la tarea01). Debe usar la misma notación de pseudocódigo que se ha usado en las lecciones del curso. Su pseudocódigo debe considerar la creación de estructuras de datos, lectura de números, creación de hilos, e impresión de resultados. Los hilos secundarios se encargan de repartirse el trabajo en la estructura de datos. Los hilos invocarán subrutinas de factorización de primos que preferiblemente deben estar definidas en otros archivos de pseudocódigo.

2.4. Implementación concurrente

Implemente su diseño concurrente en el lenguaje de programación C con la tecnología Pthreads. Recuerde aplicar buenas prácticas de programación, estilo de código (linter), y documentar las subrutinas tanto su interfaz (Doxygen) como su implementación (pseudocódigo).

En esta versión NO haga optimizaciones a los algoritmos de factorización (ej.: usar una criba), la tarea03 se encargará de ello. La versión concurrente debe implementar el mismo algoritmo de factorización que la versión serial. Simplemente debe repartir el trabajo lo más equitativamente posible entre los hilos de ejecución.

La versión concurrente debe registrar un incremento de velocidad. Es decir, la duración de la versión concurrente con un caso de prueba mediano o grande debe ser menor o igual que la duración de la versión serial. Importante: no agregue los casos de prueba grandes a control de versiones (por el contrario, puede incluirlos en su archivo .gitignore).

2.5. Aritmética de precisión arbitraria [Opcional]

Los sistemas que aprovechan la factorización prima, por ejemplo, para proveer seguridad en las telecomunicaciones, no usan números de 64 bits, sino de 1024, 2048 o más bits. Haga que su solución pueda encontrar la factorización prima de números de cualquier longitud. Puede utilizar una biblioteca de aritmética de precisión arbitraria, como The GNU Multiple Precision Arithmetic Library (GMP), para la cual hay disponibles tutoriales en línea (ej.: University de Colorado Boulder). También puede consultar este ejemplo de uso en C.

Asegúrse de instalar la biblioteca y su código fuente en su distribución de Linux (ej.: sudo apt install libgmp-dev). Actualice el Makefile para que el preprocesador pueda incluir los encabezados (si es necesario) y el linker pueda enlazar los binarios de la biblioteca (LIBS=-lgmp).

Su programa debe tratar los números usando la aritmética más eficiente. Por ejemplo, al leer los números de la entrada estándar, si estos son pequeños use la aritmética de precisión fija. Si el número en la entrada desborda esta aritmética, trate de usar aritmética de precisión arbitraria. Si tampoco cumple el formato de estos números, debe considerarse como una línea inválida.

De igual forma, los hilos deben poder calcular la factorización prima usando la aritmética correspondiente. Si el número es relativamente pequeño usar aritmética de precisión fija dado que es más eficiente porque es provista por el hardware. De lo contrario, usar aritmética de precisión arbitraria, que es más lenta dado que es provista por el software (la biblioteca de terceros).

2.6. Pruebas

Asegúrese de verificar el funcionamiento de su programa con los casos de prueba. Antes de implementar precisión arbitraria, use los mismos casos de prueba de la tarea01. Para probar la precisión arbitraria, use los que se adjuntan. Se recomienda enfáticamente crear más casos de prueba.

Además de probar el buen uso de memoria (asan, msan, ubsan, memcheck), asegúrese de hacer un buen uso de la concurrencia (tsan, helgrind).

Recuerde que puede probar los ejecutables instrumentalizados contra los casos de prueba. Para generar un ejecutable con un sanitizer hay que compilar todos los fuentes con el sanitizer. Si ya se tiene un ejecutable construido sin sanitizer o un sanitizer diferente, es mejor borrar (make clean) todos los archivos objeto viejos (.o) y recompilar todo. Por ejemplo, para construir un ejecutable con asan sanitizer y probarlo, y luego otro con thread sanitizer y probarlo sería algo como

make clean && make asan && make test
make clean && make tsan && make test

2.7. Evaluación

  1. [5%] Buen uso del repositorio (commits, ignores, tags) y directorios.

  2. [5%] Análisis: agregar concurrencia al README.md.

  3. [15%] Diseño de la solución concurrente: diagrama de memoria y pseudocódigo.

  4. [5%] Documentación de interfaces (doxygen) e implementaciones (algoritmos).

  5. [30%] Implementación y corrección de la concurrencia (pasar los casos de prueba).

  6. [10%] Buen uso de la memoria (memcheck, asan, msan, ubsan).

  7. [10%] Buen uso de la concurrencia (helgrind, tsan).

  8. [10%] Incremento del desempeño con los casos de prueba medianos o grandes.

  9. [10%] Modularización en subrutinas y archivos (.h, .c). Apego a una convención de estilos (cpplint).

  10. [+15%] Opcional: Implementa arimética de precisión arbitraria.

Recuerde que en todos los rubros se evalúan las buenas prácticas de programación.

3. Tarea03: primefact_optimization

Tarea estrictamente individual. Fecha límite de entrega: el lunes 25 de octubre a las 23:59. Se entrega en su repositorio personal.

Realice al menos dos optimizaciones de su solución para la factorización de números que incrementen el desempeño respecto a la versión anterior (primefact_pthread). Es requerido proveer evidencia de este incremento con datos, gráficas, y su respectiva discusión textual, en un documento de reporte. Además compare el comportamiento de su solución ante diferentes niveles de concurrencia.

En todas las optimizaciones es obligatorio seguir el método sugerido para optimizar visto durante las lecciones del curso. Este método es cíclico, y se deberá recorrer al menos dos veces con las optimizaciones que logren incrementar el desempeño de la solución respecto a la versión previa.

3.1. Aspectos generales

Esta tarea es una secuencia de la tarea02 y debe apegarse a las reglas de entrega de la tarea02, a excepción de los cambios solicitados en este enunciado. También se mantienen los lineamientos de las siguientes secciones de la tarea01:

  • Descripción del problema

  • Entregables

  • Análisis

  • Documentación

3.1.1. Correcciones a la Tarea02

Si no lo ha hecho, primero corrija su solución a la tarea02 en la carpeta tareas/primefact_pthread/ (no copie ni cree una carpeta nueva) para aplicar las observaciones que obtuvo durante la revisión de la misma, de manera que no se propaguen las deficiencias identificadas de la tarea02 en la tarea03. Recuerde utilizar issues de su sistema de control de versiones de la siguiente forma.

  1. Por cada observación que recibió durante la revisión (sea que las haya anotado o que visualice la grabación de la revisión), cree un issue en el sistema de alojamiento de control de versiones (git.ucr o github). Inicie el título del issue con el número de la tarea, por ejemplo "Tarea02: cambiar arreglo estático por dinámico". Describa en el issue el problema identificado puntualmente. Note que cada issue recibe un número que lo identifica.

  2. Corrija un issue a la vez en su repositorio. Modifique los archivos que necesite directamente (no cree una copia de carpeta). Para cada corrección cree un commit y en el mensaje refiera el número del issue. Si es el último commit que resuelve el issue, ciérrelo desde el mensaje de commit. Por ejemplo, el mensaje:

    git commit -m 'Store sums in dynamic array instead of a static one. Close #13'

    cerraría el issue identificado con 13. De esta forma el issue y el commit quedan ligados en el sistema de control de versiones y facilita la rastreabilidad.

Una vez que haya corregido la tarea02, cree un tag tarea02fix asociado al último commit de las correcciones, y asegúrese enviarlo al repositorio remoto (git push --tags). Luego pase a las optimizaciones (tarea03).

3.1.2. Documento de reporte

Una vez concluidas las correcciones a la tarea02, copie su solución concurrente tareas/primefact_pthread a la carpeta tareas/primefact_optimization en su repositorio de control de versiones. Cree una carpeta tareas/primefact_optimization/report/ y dentro un archivo readme.ext cuya estensión varía si es en formato de Markdown, AsciiDoc, o LaTeX. A este archivo se le referirá como documento de reporte. Conviene crear un enlace desde tareas/primefact_optimization/README.md a su documento de reporte para que facilite la navegación.

Importante: El documento de reporte tendrá una sección por cada iteración del método sugerido para optimizar. En cada sección, documente el diseño de la modificación que piensa incrementará el desempeño, el rendimiento (speedup y eficiencia) del código antes y después de modificarlo, y una discusión de las lecciones aprendidas.

3.1.3. ¿Cómo medir el tiempo de ejecución?

Para todas las mediciones de rendimiento use todos los casos de prueba de la carpeta tests_large/ en una máquina con al menos 8 núcleos de CPU. Si no dispone de un equipo con estas características, use un nodo esclavo (y no la máquina máster) del clúster Arenal. Anote los resultados en una hoja de cálculo que le facilite realizar las comparaciones. Se adjunta una hoja de cálculo modelo que puede usar para este fin. Para poder medir el rendimiento puede (1) modificar su programa para imprimir esta medición en el error estándar, o (2) usar una herramienta como perf.

  1. Si modifica su programa, registre el tiempo de CPU wall time con precisión de nanosegundos que tarda en ejecutar cada uno de los casos de prueba que utiliza para las mediciones. Utilice para ello la subrutina clock_gettime que se demostró durante las lecciones del curso. Para que el reporte de la duración no interfiera con la salida estándar (que se compara con los casos de prueba), haga que el programa imprima la duración en el error estándar.

  2. Si prefiere usar perf instale el paquete correspondiente (linux-perf en Debian o perf en RedHat). Si en Debian requiere permisos de administración para permitir a perf inspeccionar a otros programas, puede requerir configuración adicional para correrlo como usuario normal (como crear el archivo sudo echo 1 >/proc/sys/kernel/perf_event_paranoid y reiniciar). Una vez instalado, puede obtener la duración con nohup perf stat make test ARG=8 TST_DIR=tests_large.

Para toda medición deben realizarse tres corridas y tomar la menor de ellas. Algunas mediciones dependen de la cantidad de CPUs disponibles en la máquina, la cual la puede consultar con el comando lscpu de Linux. Todas las ejecuciones deben realizarse en la misma máquina. Si usa Arenal, debe ser en un nodo esclavo (no la máquina máster), identificado con el número N entre 0 y 3 en los siguientes comandos como guía:

ssh CARNET@arenal.ecci.ucr.ac.cr
git clone url_mi_repositorio                # Solo la primera vez
git pull --rebase                           # Si hizo cambios
cd mi_repositorio/tareas/programa_a_medir
make clean release

ssh compute-0-N
cd mi_repositorio/tareas/programa_a_medir
nohup perf stat make test ARG=8 TST_DIR=tests_large
nohup perf stat make test ARG=8 TST_DIR=tests_large
nohup perf stat make test ARG=8 TST_DIR=tests_large
less nohup.out  # Tomar la menor de las tres duraciones

Si por error corre un proceso en el nodo máster, debe detenerlo de inmediato. Si un programa tarda más de 1 hora en un nodo esclavo también debe detenerlo. Para esto puede usar el comando ps -eu | grep make que le listará los procesos que tiene corriendo en la máquina que tienen make en su nombre. El primer número que vea de resultado es el process ID, usualmente referido como PID. Para matarlo puede usar el comando kill PID reemplazando PID por el número de proceso. Si eso no lo detiene, agregue kill -9 PID.

3.1.4. Pruebas

Asegúrese de verificar el funcionamiento de su programa con los casos de prueba. Se recomienda enfáticamente crear más casos de prueba. Además de probar el buen uso de memoria (asan, msan, ubsan, memcheck), asegúrese de hacer un buen uso de la concurrencia (tsan, helgrind).

Recuerde que puede probar los ejecutables instrumentalizados contra los casos de prueba. Recuerde que para generar un ejecutable con un sanitizer hay que (re)compilar todos los fuentes con el sanitizer. Si ya se tiene un ejecutable construido sin sanitizer o un sanitizer diferente, es mejor borrar (make clean) todos los archivos objeto viejos (.o) y recompilar de nuevo. Por ejemplo, para construir un ejecutable con asan sanitizer, probarlo, y luego otro con thread sanitizer y probarlo se puede con:

make clean asan test
make clean tsan test

3.2. Optimización01: Mapeo dinámico

La primera optimización es implementar mapeo dinámico de los números. Es decir, los hilos secundarios se reparten los números que se leyeron de la entrada estándar de forma dinámica. Su implementación del mapeo dinámico debe seguir el patrón productor-consumidor, donde el productor puede ser el hilo principal, y los consumidores son los hilos secundarios. La cantidad de hilos secundarios debe poderse ajustar mediante un argumento de línea de comandos. Si éste se omite, se debe asumir la cantidad de núcleos de procesador disponibles en el sistema.

De acuerdo al patrón, la comunicación entre los productores y consumidores se realiza mediante un buffer acotado o no, que puede ser una cola thread-safe. Sin embargo, si implementa además seguridad condicional (un arreglo de resultados), puede simplificar el buffer, por ejemplo, mediante un contador entero protegido por mutex.

Siga todos los pasos del método sugerido para optimizar. Como paso 1, haga una medición en la máquina de pruebas de la tarea02 con tantos hilos secundarios como núcleos dispone tomando la menor duración de tres corridas. Nota: Para esta optimización no es necesario realizar el paso 2 de profiling. El paso 3 corresponde a este mapeo dinámico. Las mediciones en el paso 5 debe realizarlas en la misma máquina, con los mismos casos de prueba, y siempre tomando la menor de tres corridas.

3.3. Optimización02: Propuesta libre

La segunda optimización es propuesta libremente por la persona estudiante, siempre y cuando demuestre empíricamente que incrementa el desempeño. Esta optimización se debe realizar sobre el código de la primera optimización. Realice todos los pasos del método sugerido para optimizar. En el paso 1 tome como punto de partida los resultados de la optimización 1.

En el paso 2 realice un análisis dinámico de rendimiento (profiling) para asegurarse de que su propuesta de optimización impacta directa o indirectamente las líneas de código más críticas en consumo de CPU. Para realizar el análisis dinámico de código, ejecute su solución actual con la herramienta callgrind con un caso de prueba mediano. Visualice la salida con KCachegrind y determine las regiones de código que más demandan procesamiento o aquellas que las invocan. Haga una captura de pantalla y agréguela a su documento de reporte y explique resumidamente cómo el diseño de su optimización piensa disminuir este consumo.

En el paso 3 y 4 documente rápidamente (un párrafo o menos) en qué consiste la optimización en su documento de reporte, luego implemente la potencial mejora. En el paso 5 recuerde siempre hacer tres corridas en las mismas condiciones que la optimización 1, es decir: en la misma máquina, con los mismos casos de prueba, con tantos hilos secundarios como CPUs hay disponibles en la máquina, y tomar la menor duración de tres corridas. Anote los resultados en su hoja de cálculo y estime el incremento de velocidad y eficiencia. Escriba en su documento de reporte estos valores. Si no logró incrementar el rendimiento, conjeture en su discusión a qué se podría deber ese resultado.

3.4. Comparación01: de optimizaciones

Ya que ha realizado varias optimizaciones, compárelas entre ellas para determinar cuál aportó más al incremento del desempeño o genera mejor eficiencia. Todas las mediciones han de ser comparables, por tanto, deben realizarse en la misma máquina, con los mismos casos de prueba, y tomar la menor duración de tres corridas. Las versiones de sus programas primefact a medir son:

Versión Descripción

tarea01

La versión serial

tarea02

La versión concurrente

optim01

La versión con mapeo dinámico

optim02

La versión con su segunda optimización

Nota: A la tabla anterior puede agregar optim03, optim04, y subsiguientes, si realizó varias iteraciones por el método sugerido para optimizar que lograran incrementar el desempeño.

Todas las mediciones deben realizarse con tantos hilos como la cantidad de núcleos disponibles en el sistema (ocho para el nodo esclavo del clúster Arenal), a excepción de la versión serial. Si no tiene alguna de las mediciones anteriores realícela y agréguela a la hoja de cálculo. Para todas las mediciones calcule el incremento de velocidad respecto a la versión serial, y la eficiencia.

Cree un gráfico en la hoja de cálculo. En el eje X muestre la versión del programa, correspondiente a la columna “Versión” de la tabla (tarea01, tarea02, optim01, optim02). El gráfico debe ser combinado con dos ejes Y. El eje Y primario, ubicado a la izquierda del gráfico, indica la duración de las mediciones en segundos. Este debe ser el eje de referencia para serie de mediciones de tiempo. El eje Y secundario, a la derecha del gráfico, indica el incremento de velocidad (speedup) y aunque no tiene unidades puede indicar la palabra “veces”. Este debe ser el eje de referencia para la serie de incrementos de velocidad.

Puede, y se recomienda, crear un segundo gráfico que permite comparar el speedup en el eje Y primario contra la eficiencia en el eje Y secundario.

Incluya una sección “Comparación de optimizaciones” en su documento de reporte. Haga una discusión de máximo 500 palabras, en la que compare las versiones de su solución e indique qué produjo el mayor incremento de desempeño. Exporte el gráfico a formato SVG o PNG e incrústelo en la discusión.

3.5. Comparación02: grado de concurrencia

Ahora que dispone de programas medibles, uno de los aprendizajes más enriquecedores es poder compararlos en diversas circunstancias, como diferente hardware, casos de prueba, y grado de concurrencia, entre otros. En esta sección podrá comparar sus soluciones respecto a este último rubro.

Todas las mediciones han de ser comparables, por tanto, deben realizarse en la misma máquina, con los mismos casos de prueba, y tomar la menor duración de tres corridas. Lo que varía en cada medición es la cantidad de hilos de ejecución, obtenidas en función de la constante C definida como la cantidad de núcleos de procesador disponibles en un sistema. Por ejemplo, para un nodo esclavo del clúster Arenal, C equivale a 8 núcleos.

Para realizar las comparaciones, se usarán 7 niveles de concurrencia en función de C, es decir, usted registrará en su hoja de cálculo 7 mediciones de la duración de sus programas, listadas en la tabla a continuación. Dado que para lograr una medición más fiable, debe ejecutar su programa al menos tres veces y tomar la duración de menor duración, en total realizará al menos 21 ejecuciones de sus programas. En la última columna de la siguiente tabla se muestra la cantidad de hilos para el nodo esclavo del clúster Arenal.

# Leyenda Descripción Hilos

1

S

Serial

 1

2

1

Un solo hilo

 1

3

hC

Tantos hilos como la mitad de CPUs hay en la computadora que ejecuta el programa

 4

4

1C

Tantos hilos como CPUs hay en la computadora que ejecuta el programa

 8

5

2C

Dos hilos por cada CPU que hay en la computadora que ejecuta el programa

 16

6

4C

Cuatro hilos por cada CPU que hay en la computadora que ejecuta el programa

 32

7

D

Tantos hilos como números recibe el programa en la entrada estándar

 D

La medición 1 en la tabla (S) corresponde a la menor de tres (o más) ejecuciones de su programa serial (primefact_serial) con los casos de prueba. Las restantes seis mediciones deben realizarse con el programa que obtuvo tras la Optimización02 con tres ejecuciones de los casos de prueba. Lo que varía en cada medición es la cantidad de hilos a crear, el cual es el argumento con que se invoca su programa en línea de comandos o make test ARG=#. Anote la menor de las tres (o más) ejecuciones en su hoja de cálculo. Note que la Optimización02 ya obtuvo la medición para 1C.

Una vez que tenga todas las mediciones calcule el incremento de velocidad (speedup) de cada medición respecto a la serial (incluso la serial misma). Calcule también la eficiencia de cada medición.

Cree un gráfico en la hoja de cálculo. En el eje X muestre la cantidad de hilos que realizaron los cálculos durante la ejecución del programa, correspondiente a la columna “Leyenda” de la tabla (S, 1, hC, 1C, 2C, 4C y D). El gráfico debe ser combinado con dos ejes Y. El eje Y primario, ubicado a la izquierda del gráfico, indica el incremento de velocidad (speedup) y aunque no tiene unidades puede indicar la palabra “veces”. Éste debe ser el eje de referencia para la serie de incrementos de velocidad. El eje Y secundario, a la derecha del gráfico, indica la eficiencia obtenida por cada nivel de concurrencia. De esta forma permitirá comparar el incremento de desempeño (speedup) contra la eficiencia. Distinga visualmente las series de speedup de las de eficiencia. Por ejemplo, puede usar líneas continuas para las primeras y punteadas para las segundas.

Finalmente, realice un análisis de los resultados obtenidos en la parte final de su documento de reporte. Exporte los gráficos a formato SVG o PNG e incrústelos como imágenes. Esta discusión debe ser concisa, de máximo 500 palabras, con su interpretación de los gráficos. A partir de sus resultados responda a la pregunta ¿cuál es la cantidad de hilos óptima para conseguir el mejor rendimiento?. Considere tanto el incremento de velocidad como la eficiencia en su respuesta.

3.6. Evaluación

  1. [20%] Optimización01 (mapeo dinámico): Implementación y corrección. Pasar los casos de prueba y obtener incremento de desempeño.

  2. [20%] Optimización02 (propuesta libre): Implementación y corrección. Pasar los casos de prueba y obtener incremento de desempeño.

  3. [10%] Documentación en el reporte de las iteraciones realizadas siguiendo el método sugerido para optimizar (al menos Optimización01 y Optimización02) que incluye: diseño de la optimización, speedup/eficiencia antes y después, y lecciones aprendidas.

  4. [15%] Comparación01 (de optimizaciones): Hoja de cálculo con las mediciones, incrementos de velocidad, eficiencia, y gráficos combinados. Discusión de resultados y gráficos en el documento de reporte.

  5. [15%] Comparación02 (grado de concurrencia): Hoja de cálculo con las mediciones, incrementos de velocidad, eficiencia, y gráficos combinados. Discusión de resultados y gráficos en el documento de reporte.

  6. [10%] Buen uso de la memoria (memcheck, asan, msan, ubsan) y la concurrencia (helgrind, tsan).

  7. [5%] Documentación de interfaces (doxygen) e implementaciones (algoritmos).

  8. [5%] Apego a una convención de estilos (cpplint). Modularización en subrutinas y archivos (.h, .c). Buen uso del repositorio (commits, ignores, tags) y directorios, como se ha indicado en tareas anteriores.

Recuerde que en todos los rubros se evalúan las buenas prácticas de programación. En todos los rubros que generan documentación se evalúa el buen uso de la comunicación escrita, tal como lo estipula la carta al estudiante.

4. Tarea04: primefact_omp_mpi

Tarea estrictamente individual. Fecha límite de entrega: lunes 15 de noviembre a las 23:59. Se entrega en su repositorio personal de control de versiones.

En las tareas anteriores usted escribió programas que reciben números enteros en la entrada estándar y calculan su factorización prima de forma serial (tarea01), concurrente con Pthreads (tarea02), y comparó su rendimiento (tarea03). Estas soluciones aprovechan los recursos concurrentes de una máquina, pero no escalan, ni aprovechan varias computadoras disponibles en una organización o un clúster de computadoras.

En esta tarea su objetivo es evaluar una tecnología declarativa y distribuir el trabajo de la factorización entre varias máquinas usando dos tecnologías: OpenMP y MPI. Su programa recibirá los números en la entrada estándar en el mismo formato, y deberá producir los resultados en el mismo orden, de tal forma que pase los casos de prueba.

4.1. Correcciones a la Tarea03

Si no lo ha hecho, primero corrija su solución a la tarea03 en la carpeta tareas/primefact_optimization/ (no copie ni cree una carpeta nueva) para aplicar las observaciones que obtuvo durante la revisión de la misma, de manera que no se propaguen las deficiencias identificadas de la tarea03 en la tarea04. Recuerde utilizar issues de su sistema de control de versiones de la siguiente forma.

  1. Por cada observación que recibió durante la revisión (sea que las haya anotado o que visualice la grabación de la revisión), cree un issue en el sistema de alojamiento de control de versiones (git.ucr o github). Inicie el título del issue con el número de la tarea, por ejemplo "Tarea03: cambiar arreglo estático por dinámico". Describa en el issue el problema identificado puntualmente. Note que cada issue recibe un número que lo identifica.

  2. Corrija un issue a la vez en su repositorio. Modifique los archivos que necesite directamente (no cree una copia de carpeta). Para cada corrección cree un commit y en el mensaje refiera el número del issue. Si es el último commit que resuelve el issue, ciérrelo desde el mensaje de commit. Por ejemplo, el mensaje:

    git commit -m 'Store sums in dynamic array instead of a static one. Close #13'

    cerraría el issue identificado con 13. De esta forma el issue y el commit quedan ligados en el sistema de control de versiones y facilita la rastreabilidad.

Una vez que haya corregido la tarea03, cree un tag tarea03fix asociado al último commit de las correcciones, y asegúrese enviarlo al repositorio remoto (git push --tags). Luego pase a las optimizaciones (tarea03).

4.2. Concurrencia declarativa

Una vez concluidas las correcciones a la tarea03, copie su solución concurrente tareas/primefact_optimization a la carpeta tareas/primefact_omp_mpi en su repositorio de control de versiones. Recuerde que gracias a la tarea03, esta carpeta ya incluye el documento de reporte al cual se le harán modificaciones en la tarea04.

Utilice la tecnología de paralelismo de datos OpenMP para paralelizar el trabajo de la factorización prima. Asegúrese de que su implementación pasa los casos de prueba, y no genera diagnósticos de las herramientas de validez como el linter.

Al igual que la tarea02, haga que los hilos trabajen de forma lo más paralela posible, y por consiguiente, evitar control de concurrencia innecesario. Recuerde que además su solución debe imprimir los resultados en el orden esperado. Se recomienda evitar la serialización de los hilos y usar una estrategia de seguridad condicional (conditionally safe).

La cantidad de hilos es controlada por argumento de línea de comandos. En caso de omitirse se debe suponer la cantidad de CPU disponibles en la máquina donde corre el programa.

Nota: Si toma como código base la tarea03, deberá reemplazar el código de Pthreads por OpenMP. Si usa como código base su solución serial (tarea01), asegúrese de aplicarle las optimizaciones que realizó en tareas posteriores y de que éstas pasen los casos de prueba.

4.3. Comparación Pthreads-OpenMP

Como hubo un cambio en la implementación de la concurrencia, compare el rendimiento de su implementación con OpenMP contra la de Pthreads siguiendo el mismo procedimiento para obtener mediciones que aplicó en la tarea03, utilizando tantos hilos como núcleos de CPU hay en el sistema con el caso de prueba que usó para la tarea03.

Calcule el incremento de velocidad (speedup) y la eficiencia. Cree un gráfico combinado con esas dos secuencias (siguiendo las mismas indicaciones de la tarea03). Agréguelo a un documento de reporte en una sección de "Concurrencia declarativa (OpenMP)" donde incluya una discusión no mayor a 500 palabras explicando los resultados obtenidos en las mediciones.

4.4. Distribución

Para hacer su solución escalable y aprovechar un clúster de computadoras, distribuya el trabajo de factorización utilizando la tecnología MPI. Tome en cuenta que su programa debe leer los números a factorizar de la entrada estándar y no de archivos ni argumentos de línea de comandos. Esto impone restricciones que afectan el diseño de su solución.

Descomponga la unidad de trabajo a distribuir, ésta puede tener la misma o mayor granularidad que la unidad usada para OpenMP. Implemente un mapeo dinámico usando paso de mensajes para repartir las unidades de trabajo entre los procesos o hilos, de tal forma que los recursos del clúster se aprovechen lo más equitativamente posible.

Asegúrese de que su solución distribuida con MPI pase los casos de prueba usando distintas cantidades de procesos e hilos. Además su implementación no debe generar diagnósticos del linter.

4.5. Comparación OpenMP-MPI

Compare el rendimiento de su solución distribuida con el caso de prueba que usó en la tarea03. En este caso, es necesario usar un ambiente distribuido, y por tanto el clúster de Arenal. Cree tantos procesos o hilos como CPUs hayan disponibles en los nodos secundarios del clúster. Dado que uno de los procesos se encargará del mapeo exclusivamente, éste puede correr en el nodo máster.

Mida la duración de la versión distribuida, calcule el incremento de velocidad y la eficiencia respecto a la versión OpenMP (la cual también debe correr en un nodo esclavo del clúster). Agregue los resultados a su gráfico, de tal forma que en el eje-x se pueda comparar la versión distribuida de MPI con las versiones previas (OpenMP, Pthreads, serial) que sean comparables, y por lo tanto, que hayan corrido en el mismo hardware. Agregue a su discusión un análisis de los resultados que obtuvo con la versión distribuida.

4.6. Evaluación

  1. [20%] Concurrencia declarativa: Implementación y corrección. Pasar los casos de prueba y obtener incremento de desempeño respecto de su versión serial.

  2. [20%] Distribución: Implementación y corrección. Pasar los casos de prueba y obtener incremento de desempeño respecto de la versión no distribuida.

  3. [20%] Mapeo dinámico de la carga de trabajo usando paso de mensajes.

  4. [15%] Comparación Pthreads-OpenMP: Hoja de cálculo con las mediciones, incrementos de velocidad, eficiencia, y gráfico combinado. Coherencia de la discusión de resultados y el correspondiene gráfico.

  5. [15%] Comparación OpenMP-MPI: Hoja de cálculo con las mediciones, incrementos de velocidad, eficiencia, y gráfico combinado. Coherencia de la discusión de resultados y el correspondiene gráfico.

  6. [5%] Documentación de interfaces (doxygen) e implementaciones (algoritmos).

  7. [5%] Apego a una convención de estilos (cpplint). Modularización en subrutinas y archivos (.h, .c). Buen uso del repositorio (commits, ignores, tags) y directorios, como se ha indicado en tareas anteriores.

Recuerde que en todos los rubros se evalúan las buenas prácticas de programación. En los rubros que generan documentación se evalúa el buen uso de la comunicación escrita, tal como lo estipula la carta al estudiante.

5. Tarea05: Problema de sincronización

Resolver con diseño e implementar con alguna tecnología concurrente un problema no clásico de sincronización. El Pequeño libro de los semáforos ofrece problemas afines.