1. Conjetura de Goldbach
Aplicación gráfica que encuentra las sumas de primos que equivalen a un número dado, de acuerdo a la Conjetura de Goldbach.
1.1. Versión 0: interfaz congelada
En esta versión la interfaz gráfica se queda congelada si se ingresan números enteros grandes.
El cálculo se hace en la interfaz (clase MainWindow
), lo cual mezcla lógica del dominio (modelo) con la interfaz gráfica (vista). Más adelante se pide corregir el problema de la interfaz que se congela. Los siguientes son los archivos que conforman el proyecto:
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Encabezado de la ventana principal. |
||
Implementación de la ventana principal. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.2. Versión 1: interfaz reactiva ineficiente
Para hacer que la interfaz gráfica reaccione a los eventos, existe una solución rápida en el paradigma de programación orientada a eventos, que consiste en procesar los eventos pendientes de la cola cada vez que se realiza el cálculo de las sumas. Por ejemplo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 long long MainWindow::calculateEvenGoldbach(long long number)
{
long long results = 0;
for ( long long a = 2; a < number; ++a )
{
if ( ! isPrime(a) ) continue;
long long b = number - a;
if ( b >= a && isPrime(b) )
this->appendResult( tr("%1: %2 + %3").arg(++results).arg(a).arg(b) );
// Update the progress bar
this->updateProgressBar((a + 1) * 100 / number);
// If user cancelled, stop calculations
if ( this->isStopped() )
return results;
// Process all pending events in the queue
QApplication::processEvents(); (1)
}
return results;
}
-
Procesa los eventos pendientes en la cola y retorna cuando la cola se vacía
Sólo la implementación del MainWindow.cpp
cambió respecto a la versión anterior:
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Encabezado de la ventana principal. |
||
Implementación de la ventana principal. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.3. Versión 2: separación de asuntos
La versión anterior mezcla la lógica del dominio (modelo) con la interfaz gráfica. Esta separación de asuntos debe hacerse y no sólo en dos clases distintas, sino que también la deben realizar distintos trabajadores (workers) de acuerdo al paradigma de programación concurrente, lo cual se hace en la versión 2 de este proyecto.
La vista se libera de la lógica del dominio, que pasa a la clase GoldbachWorker
. Cuando el usuario ingresa un número y presiona el botón Start, la vista crea un hilo de ejecución (execution thread) el cual busca sumas de primos que coincidan con el número. La vista le solicita al trabajador que cada vez que encuentra una suma, le avise. Es decir, cada vez que el trabajador encuentra una suma, éste emite el evento sumFound()
el cual invoca al manejador de eventos (slot) appendResult()
en la vista. Este encadenamiento se hace con una conexión de signals-and-slots.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Encabezado de la ventana principal. Instancia un hilo de ejecución |
||
Implementación de la ventana principal. Delega el cálculo de las sumas de Goldbach al hilo de ejecución. |
||
Encabezado del hilo de ejecución. |
||
Implementación del hilo de ejecución. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
El hilo de ejecución GoldbachWorker
es un objeto porque el API de Qt lo implementa de esta forma. El hilo debe sobreescribir el método run()
el cual es similar a un main()
. A partir del momento en que a un QThread
se le invoca el método start()
, el thread inicia la ejecución de su run()
mientras que la vista no espera a que termine, sino que continúa ejecutando las instrucciones posteriores al start()
.
1.4. Versión 3: interfaz no se satura
La tercera versión utiliza un QListView
que no almacena las sumas encontradas (los datos), sino que el modelo las guarda en un arreglo. La vista solicita al modelo sólo los datos que el usuario le pide mostrar. De esta forma, la vista no se satura con cantidades gigantescas de datos que el o los threads produzcan.
Los cambios que se hicieron con respecto a la versión 2 fueron:
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Encabezado de la ventana principal. La ventana principal no administra los workers, sino una clase controladora de modelos llamada |
||
Implementación de la ventana principal. Delega el cálculo de las sumas de Goldbach al controlador de workers ( |
||
Controla los hilos de ejecución. Almacena las estructuras de datos donde los hilos escriben los resultados. |
||
Delega el cálculo de las sumas de Goldbach a los hilos de ejecución, y responde a las solicitudes de resultados hechas por la interfaz gráfica. |
||
Para incrementar la eficiencia, el hilo de ejecución disminuye la comunicación. Cada vez que encuentra una suma de Goldbach, la agrega a un contenedor que recibe del controlador de modelos |
||
Implementación del hilo de ejecución. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.5. Versión 4: testing
De nada sirve hacer un programa más rápido o consumir mucha menos memoria, si produce resultados incorrectos.
Ante la falta de un framework para pruebas de software concurrente, se creó un un programa casero de pruebas de caja blanca.
Esta versión es la misma que la versión 3, pero incluye el programa casero (GoldbachTester
) y algunos casos de prueba pequeños.
El programa de pruebas usa Qt pero en lugar de usar interfaz gráfica, tiene una interfaz de línea de comandos, por conveniencia y para probar que las clases del modelo (GoldbachCalculator
y GoldbachWorker
) son independientes de la interfaz. El programa recibe por parámetro carpetas que contienen casos de prueba:
./GoldbachTester test_folder1 test_folder2 ... test_folderN
Se tomó como convención que un caso de prueba es un archivo de texto cuyo nombre es el número a probar, y cuyo contenido son las sumas de Goldbach usando el mismo formato de salida de la Versión 0: interfaz congelada.
Archivo | Descripción | Cambios |
---|---|---|
Contiene el |
||
Encabezado de la clase controladora que representa al programa de pruebas como un todo. |
||
El controlador de pruebas busca casos de prueba en las carpetas dadas por argumentos de línea de comandos y por cada caso instancia un objeto |
||
Controla los hilos de ejecución. Se agregó un método |
||
La implementación del método |
||
Proyecto de Qt que indica generar una aplicación de línea de comandos en lugar de una aplicación gráfica. |
||
Ejemplo de un caso de prueba par. Generado con la versión 0 de Goldbach. |
||
Ejemplo de un caso de prueba impar. Generado con la versión 0 de Goldbach. |
1.6. Versión 5: profiling optimizations
Para incrementar el rendimiento de una aplicación se optimizan las secciones críticas de consumo de recursos. El paradigma de programación concurrente ofrece un mecanismo para optimizar, que consiste en la división de tareas o datos entre varios trabajadores (workers). Sin embargo, existe una regla valiosa que indica que no se debe sobre-optimizar. La sobre-optimización puede ser más dañina que no optimizar del todo, ya que el código se torna de difícil lectura y mantenimiento. Por tanto se debe hacer un balance.
Resulta especialmente útil para los profesionales en computación disponer de herramientas que ayuden a localizar los puntos críticos de código que consumen abundantes recursos, en especial cuando la cantidad de código fuente es abrumadora (miles o millones de líneas de código). Un tipo de herramientas de análisis dinámico de código, llamadas profiling tools pueden ayudar en esta necesidad. Son herramientas que mientras un ejecutable corre, guardan estadísticas de eventos o consumo de un recurso particular, como procesamiento, memoria dinámica, fallos de caché, uso de red, etc. Estas estadísticas ayudan a los programadores a comprender el comportamiento de los programas y ubicar las áreas crítica de código que se deben optimizar.
En clase se presentó Valgrind, la herramienta de profiling de facto para Linux, ya que este sistema operativo es el más utilizado en clústers de super-computación del mundo. Valgrind consta de un conjunto de herramientas, cada una recaba distintos datos sobre un programa en ejecución. La herramienta callgrind
registra la cantidad de instrucciones de procesador que ejecuta cada trozo del programa. Si se incluye el código fuente en el ejecutable, calgrind
reporta las líneas que consumen más instrucciones de procesador. Por eso conviene compilar en "modo debug":
# Ir al directorio donde esta el codigo fuente
cd ~/Documents/repositorio/actividades/Goldbach
# Crear una carpeta para archivos binarios
mkdir build-goldbach-debug
cd build-goldbach-debug/
# Compilar en "debug mode" con 4 procesos al mismo tiempo
/opt/Qt5.11.1/5.11.1/gcc_64/bin/qmake ../Goldbach.pro CONFIG+=debug
make -j4
# Pide al valgrind correr el ejecutable y hacer contabilidad de las
# instrucciones ejecutadas en el procesador
valgrind --tool=callgrind --separate-threads=yes ./Goldbach
# callgrind genera archivos de texto con la contabilidad de instrucciones
# kcachegrind visualiza el código fuente y su consumo de instrucciones
kcachegrind &
La herramienta KCachegrind permite al desarrollador encontrar rápida y visualmente las líneas de código fuente que consumen más instrucciones de procesador, y por tanto, las líneas críticas que convienen optimizar, incluso en una base de código extensa. Para el ejemplo de Goldbach, no es sorpresa que sea el método GoldbachWorker::isPrime()
, dado que es una base de código muy pequeña y con algoritmos fáciles de comprender. La siguiente figura muestra una captura de pantalla de KCachegrind tras analizar la ejecución de la Calculadora de Goldbach con el número 11111
.
En la esquina superior izquierda muestra un gráfico de consumo por cada thread. En la esquina inferior izquierda muestra las subrutinas que han ejecutado más instrucciones de procesador en porcentajes, isPrime()
está seleccionada. En la esquina superior derecha se muestra las líneas de código fuente de isPrime()
que consumieron más instrucciones. Se puede ver la invocación a qSqrt()
y el operador módulo (%
) son los que causan casi la totalidad del procesamiento. Estos son los puntos críticos a optimizar.
El desarrollador estará tentado a hacer modificaciones en el código para optimizar el consumo de recursos. Sin embargo, por cada cambio debe asegurarse de que se mantenga la correctitud y que realmente se haya alcanzado un mayor rendimiento. Conviene seguir un método:
Para determinar si hubo una ganancia de rendimiento, se puede utilizar una medida relativa llamada speedup \$S\$ (incremento de velocidad), que se calcula como la relación entre el tiempo que tarda una computación serial (\$T_\text{serial}\$) contra el tiempo que tarda la misma computación en paralelo (\$T_\text{parallel}\$):
\$S = \frac{T_\text{serial}}{T_\text{parallel}}\$
El speedup indica la cantidad de veces que la computación paralela es más rápida que la computación serial. Un valor mayor a 1 indica un incremento de velocidad, 1 que la velocidad se mantiene igual, y un valor menor a 1 un decremento en la velocidad.
2. Concurrencia de memoria compartida
Cree una carpeta 2-memcompartida/
en su repositorio de control de versiones.
2.1. Phtreads
El siguiente es un "Hola mundo" en Pthreads:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde el hilo principal y un hilo secundario. |
Cree una carpeta 2-memcompartida/pthreads
en su repositorio de control de versiones. Para cada una de las siguientes actividades, cree una subcarpeta que utilice el nombre dado entre corchetes.
2.2. OpenMP
El siguiente es un "Hola mundo" en OpenMP:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde el hilo principal e hilos secundarios en OpenMP. |
Cree una carpeta 2-memcompartida/openmp
en su repositorio de control de versiones. Para cada una de las siguientes actividades, cree una subcarpeta que utilice el nombre dado entre corchetes.
Los siguientes son ejemplos de las directivas parallel for
y for
en OpenMP:
Archivo | Descripción | Cambios |
---|---|---|
La directiva |
||
La directiva |
Archivo | Descripción | Cambios |
---|---|---|
Programa que cuenta la cantidad de números primos entre 2 y un número dado por primer argumento en línea de comandos. |
||
Permite compilar el programa anterior |
3. Concurrencia de memoria distribuida
Cree una carpeta 3-memdistribuida/
en su repositorio de control de versiones. La tecnología que se usará en esta etapa será Message Passing Interface (MPI), la cual se considera de bajo nivel. Exiten otras tecnologías como Charm++ de más alto nivel, pero sólo se cubrirá en este curso MPI.
3.1. MPI
El siguiente es un "Hola mundo" en MPI:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde uno o varios procesos en una o varias máquinas. |
||
Para compilar programas con MPI se requiere instalar una implementación de MPI como |
||
Si se quiere que el programa corra en varios nodos de un clúster, se requiere un archivo que los liste. Este archivo está diseñado para mpich y el clúster arenal de la ECCI. Después de generado el ejecutable se puede invocar con |
Dentro de su carpeta 3-memdistribuida/
cree una carpeta mpi
y para cada una de las siguientes actividades cree una subcarpeta con el mismo nombre que la actividad.
Archivo | Descripción | Cambios |
---|---|---|
Todos los procesos envían mensajes de hola al proceso 0 quien los recibe e imprime en orden de rank en la salida estándar, y no en el orden en que fueron recibidos. |
||
Todos los procesos envían mensajes de hola al proceso 0 quien los recibe e imprime en el orden en que los recibió, y no en el orden de _rank. Los cambios a la derecha son respecto a la versión que imprime los mensajes en orden. |
||
Un makefile genérico para compilar los dos programas anteriores. |
Archivo | Descripción | Cambios |
---|---|---|
El proceso 0 lee un entero de la entrada estándar (puesto que es el único en MPI que puede hacerlo). Luego envía una copia del entero a los demás procesos con la subrutina MPI_Bcast. Esta subrutina es típicamente más eficiente que ejecutar un ciclo de envíos (MPI_Send) en el proceso emisor, y una invocación a MPI_Recv() en los procesos receptores. Internamente MPI_Bcast() propaga un conjunto de envíos en forma de árbol desde el emisor (raíz) hasta los receptores (hojas). |
||
Ejemplo que utiliza comunicación punto a punto y barreras para reportar al usuario el tiempo aproximado en que corrió la tarea que se quiere medir, la cual corre en varios nodos de un clúster. |
||
El mismo ejemplo anterior que utiliza comunicación colectiva. MPI_Reduce provoca que todas los procesos hoja se van enviando sus resultados y agregándolos hasta que llegue al proceso raíz. |
||
El mismo ejemplo anterior de comunicación colectiva pero con MPI_Allreduce(). Esta subrutina hace la reducción normal desde los datos de los procesos hojas hasta el resultado en el proceso raíz, y luego el resultado se distribuye hacia los procesos hoja. De esta forma, todos los procesos quedan con el valor resultado de la reducción. |
4. Ejemplos extra
Ejemplos de temas que no corresponden al curso pero que son útiles para la "Programación paralela y concurrente".
Archivo | Descripción | Cambios |
---|---|---|
Carga una matriz de un archivo y lo imprime en otro archivo. Si no se proveen nombres de archivo, se usa la entrada o salida estándar. |
||
(Incompleto) Implementa una subrutina |