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). Se llama versión 0 porque no puede entregarse a un cliente. 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. |
||
Diseño de la interfaz 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
1.2.1. Versión 1.0
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 con invocar a QApplication::processEvents()
. 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. Invoca a |
||
Diseño de la interfaz de la ventana principal. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.2.2. Versión 1.1
La versión 1.1 implementa la barra de progreso. El progreso se calcula considerando el avance sobre el componente a
de la suma par a + b = n
o de la impar en a + b + c = n
, donde a
, b
, c
son naturales primos y n
es el número natural ingresado por el usuario.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Encabezado de la ventana principal. Declara la barra de progreso. |
||
Implementación de la ventana principal. Implementa barra de progreso. |
||
Diseño de la interfaz 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
1.3.1. Versión 2.0
Aunque en la versión 1 la interfaz gráfica responde a eventos de usuario, el rendimiento decayó significativamente. 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 (signal) 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.
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()
.
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. |
||
Diseño de la interfaz de la ventana principal. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.3.2. Versión 2.1
La versión 2.1 re-implementa el botón de Stop. Detener un worker no es usual. A modo de metáfora, es como contratar formalmente un trabajador y asignarle un trabajo. Lo natural es esperar a que termine su trabajo. Si una parte le pide que se detenga, implica romper el contrato. En la computadora hay dos formas de terminar un thread: matándolo, lo cual es una medida extrema que debe evitarse. La segunda es enviándole el mensaje al thread que debe parar. Éste entonces chequea periódicamente si se le ha pedido o no terminar. La clase QThread
implementa el método requestInterruption()
para indicarle a los threads que deben terminar. Las clases que hereden de QThread
pueden preguntar periódicamente si se ha cancelado el trabajo con isInterruptionRequested()
.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
Guarda un puntero al |
||
Envía el mensaje |
||
Encabezado del hilo de ejecución. |
||
Chequea en cada iteración de los ciclos "pesados" si el usuario ha cancelado el cálculo para romperlos. |
||
Diseño de la interfaz de la ventana principal. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.4. Versión 3: interfaz no se satura
1.4.1. Versión 3.0
La versión 2 cumple el objetivo de separar los asuntos en dos trabajadores. La actualización de la interfaz la realiza el hilo principal y la búsqueda de sumas de Goldbach la realiza el hilo trabajador. Sin embargo, el worker encuentra sumas más rápido de lo que la interfaz tarda en pintarlas en la pantalla. Por tanto, el worker satura la cola de eventos del hilo principal y la interfaz reacciona lento de nuevo.
La tercera versión cambia el control a uno que implemente el patrón modelo-vista. Siguiendo el ejemplo de Qt llamado "Fetch more", se utiliza un QListView
que no almacena las sumas encontradas (los datos), sino que un modelo las guarda en un arreglo. Por tanto se crea la clase GoldbachModel
que actúa como la fuente de datos (sumas) para el QListView
y además controla al worker. La vista solicita al modelo sólo los datos que el usuario le pide mostrar. De esta forma, la vista "va a la velocidad del usuario humano" y no se satura con cantidades gigantescas de datos que el thread produzca.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
La ventana principal ya no administra los workers, sino que lo delega a una clase llamada |
||
Delega el cálculo de las sumas de Goldbach al modelo |
||
Fuente de datos (sumas) para la interfaz gráfica. Internamente delega el cálculo de las sumas a un hilo de ejecución. Almacena las estructuras de datos en arreglos en memoria, donde el hilos escribe los resultados directamente sin usar eventos. |
||
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 |
||
Implementación del hilo de ejecución. |
||
Diseño de la interfaz de la ventana principal. Cambia el |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.4.2. Versión 3.1
La versión 3.1 implementa dos funcionalidades:
-
Re-implementa el botón Stop. Cuando el usuario presiona Stop, la interfaz gráfica envía un evento (signal) al modelo y éste lo retransmite al hilo de ejecución.
-
Actualiza la barra de progreso. El trabajador guarda un atributo entero para poder determinar si ha incrementado un 1% en la búsqueda de las sumas. Sólo en tal caso, emite un evento (señal) que el modelo recibe. El modelo lo retransmite a la interfaz gráfica. Este método de detectar un 1% de progreso no es eficiente.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
La ventana principal. |
||
Cuando se presiona el botón Stop avisa al objeto |
||
Declara métodos para detener al |
||
Implementa métodos para detener al |
||
Declara un atributo para saber cuándo ha incrementado un 1% de progreso |
||
Avisa cuando ha incrementado en al menos un 1% de progreso |
||
Cambia el título de la ventana de |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.5. Versión 4: múltiples trabajadores
1.5.1. Versión 4.0
La versión 3 cumple el objetivo de separar los asuntos en dos trabajadores. La actualización de la interfaz la realiza el hilo principal y la búsqueda de sumas de Goldbach la realiza el hilo trabajador. Si una computadora tiene varios núcleos (CPU), por ejemplo 16 núcleos, el programa anterior sólo usa dos núcleos y desaprovecha los restantes (14 núcleos ociosos en el ejemplo).
La versión 4 incrementa el rendimiento de la calculadora de Golbach aprovechando todos los núcleos de la computadora donde se corre el programa. En lugar de crear un hilo trabajador, se crean W
hilos, donde W
es la cantidad de núcleos disponibles en la máquina, indicada por QThread::idealThreadCount()
. Si se quiere, se puede restar uno para dejar un núcleo disponible para el hilo principal que debe actualizar la interfaz gráfica. El siguiente código muestra la idea general de cómo lanzar varios hilos.
1
2
3
4
5
6
7 int ideal = QThread::idealThreadCount() - 1;
for ( int current = 0; current < ideal; ++current )
{
GoldbachWorker* worker = new GoldbachWorker(number, current, ideal, this);
// Hacer las conexiones para reaccionar cuando el worker:
// (1) encuentra una suma, (2) incrementa el porcentaje, (3) termina
}
Cuando se crea, cada hilo recibe el número ingresado por el usuario (number
), el número de trabajador (workerNumber
), la cantidad de hilos que están buscando sumas para el mismo número (workerCount
) y el objeto padre (parent
):
1
2
3
4 class GoldbachWorker : public QThread
{
public:
explicit GoldbachWorker(long long number, int workerNumber, int workerCount, QObject *parent = nullptr);
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
La ventana principal. |
||
Implementa la ventana principal. |
||
En lugar de un |
||
Crea W workers. Busca resultados que pide la interfaz dentro de los arreglos para resultados. |
||
Declara atributos para saber el número de trabajador y cuántos más con él están calculando sumas del mismo número. |
||
Inicializa los atributos para saber el número de trabajador y cuántos más con él están calculando sumas del mismo número. |
||
Diseño de la interfaz con el usuario. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.5.2. Versión 4.1
En la versión 4.0 la barra de progreso crece y decrece debido a que los hilos de ejecución reportan sus porcentajes de incremento, y estos no necesariamente lo hacen a la misma velocidad. La versión 4.1 corrige este problema. Cada vez que un worker informa su progreso al GoldbachModel
, éste estima el porcentaje de progreso de todos los workers en conjunto como el mínimo de todos. El mínimo es una medida más realista porque sigue el principio de que "un equipo de trabajadores es tan rápido como el más lento de todos".
Para poder calcular el mínimo porcentaje de progreso, el GoldbachModel
almacena un arreglo de porcentajes, uno por cada trabajador. Los trabajadores además de indicar su porcentaje de progreso, indica su número de trabajador para que el GoldbachModel
sepa cuál porcentaje actualizar en el arreglo.
Cuando el GoldbachModel
recibe la primera señal de algún GoldbachWorker
que ha tenido un avance de al menos un 1%, invoca a fetchMore( QModelIndex() )
. Esto provoca que la vista tenga que actualizarse con algunos datos. De esta forma el usuario puede iniciar la exploración de los datos usando la barra de desplazamiento (scroll) o las flechas en el teclado.
Algo similar ocurre con terminar los cálculos de suma. En la versión 4.o, cuando un trabajador termina, se informa a la interfaz gráfica que rehabilite los campos para iniciar otro cálculo, lo cual es incorrecto. Otros trabajadores no han terminado. El GoldbachModel
corrige este problema contando cuántos trabajadores han terminado. Cuando este conteo alcanza la cantidad total de trabajadores, se informa a la interfaz que el cálculo ha finalizado.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
La ventana principal. |
||
Implementa la ventana principal. |
||
Declara un arreglo de porcentajes de progreso y un contador de trabajadores que han terminado de realizar cálculos. |
||
Calcula el mínimo de los porcentajes de progreso y si ese mínimo incrementa en 1% o más avisa a la interfaz. Avisa cuando todos los hilos de ejecución han terminado de hacer cálculos. |
||
Avisa su número de trabajador cuando tiene un 1% o más de progreso |
||
Reporta su número de trabajador cuando incrementa su progreso. |
||
Diseño de la interfaz con el usuario. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.5.3. Versión 4.2
En las versiones 4.0 y 4.1 se crean W hilos de ejecución, tantos como el número de CPU disponibles en el sistema donde corre el programa. Sin embargo, todos los hilos de ejecución hacen el mismo trabajo. Es decir, todos encuentran todas las sumas de primos del número N. Por tanto, los resultados aparecen en la interfaz W veces, lo cual es incorrecto, el programa es igual de lento que las versiones anteriores, y se hace un consumo injustificado de recursos.
No tiene sentido que todos los trabajadores busquen las mismas sumas. Este problema se resuelve con paralelismo de datos, de tal forma que cada hilo pruebe distintos primos tratando de encontrar sumas. El paralelismo requiere diseñar dos pasos: descomposición y mapeo de los datos. La descomposición divide los datos en trozos que pueden ser trabajados en paralelo. El mapeo asocia los trozos con los trabajadores.
Se sugiere que los hilos de ejecución se repartan los valores de a
en los que buscan sumas, donde a
proviene de las fórmulas a + b = n
y a + b + c = n
. Los tres números que recibe cada GoldbachWorker
por parámetro en el constructor le servirán para hacer la división.
Por ejemplo, si el número ingresado por el usuario es 10
, se tienen que encontrar todos los primos a
y b
tal que 10 = a + b
. Para hacer la descomposición de datos, puede tomar la siguiente sugerencia. El ciclo más externo en GoldbachWorker::calculateEvenGoldbach()
buscaría valores primos para a
entre:
2 3 4 5 6 7 8 9
Es decir, 10 - 2 = 8
valores de a
para repartir. Supóngase que se lanzan w = 3
trabajadores. Si se repartiera el trabajo equitativo, cada trabajador recibiría div(8, 3) = 2
unidades de trabajo. Pero quedarían mod(8, 3) = 2
unidades por repartir. Este residuo se puede repartir a los dos primeros trabajadores, los cuales tendrían 2 + 1 = 3
unidades de trabajo y el último trabajador tendría 2 unidades. De esta forma, las a
se repartirían al trabajador con índice i
de la siguiente forma:
a | i |
---|---|
2 |
0 |
3 |
0 |
4 |
0 |
5 |
1 |
6 |
1 |
7 |
1 |
8 |
2 |
9 |
2 |
Al hacer que las primeras a
sean calculadas por el primer trabajador, las subsecuentes a
por el segundo trabajador, y así sucesivamente, se garantiza que los resultados se mantengan en orden en el arreglo de arreglos de sumas.
Dado que los GoldbachWorker
deben calcular en qué a
deben iniciar y finalizar su trabajo, necesitan una función matemática. La siguiente tabla obtiene el inicio s
(de start) y el final f
(de finish) a partir del ejemplo de la tabla anterior:
i | s | f |
---|---|---|
0 |
2 |
5 |
1 |
5 |
8 |
2 |
8 |
10 |
De esta forma un hilo de ejecución debe buscar sumas en el rango entero [s, f[
. Se necesitan dos funciones matemáticas s
y f
que puedan obtener estos dos valores, en función de la cantidad de trabajo/datos D
, la cantidad de trabajadores W
, el número de trabajador i
, y el número del trabajo donde todos deben iniciar B
. Estas relaciones se pueden deducir como:
\$ s(D, W, i, B) = B + i \cdot \text{div}(D - B, W) + \min( i, \mod(D - B, W) ) \$
\$ f(D, W, i, B) = s(D, W, i + 1, B) \$
La versión 4.2 implementa:
-
La descomposición y mapeo de trabajo entre los hilos de ejecución, con las funciones reutilizables
calculateStart()
ycalculateFinish()
. -
Corrige el error por cero en el porcentaje de progreso. Aunque este cálculo sigue siendo ineficiente.
-
Corrige el número de resultado reportado al usuario. Este número se escribe antes de dos puntos en cada suma resultado, por ejemplo el
1
en1: 3 + 7
. -
Evita desde la interfaz gráfica que se inicie el cálculo de números inválidos.
Archivo | Descripción | Cambios |
---|---|---|
Instancia el controlador, la ventana principal, e inicia la ejecución del programa. |
||
La ventana principal. |
||
Evita iniciar los cálculos para números inválidos, pues haría que los hilos de ejecución se caigan. |
||
Administra los hilos de ejecución y provee resultados a la interfaz gráfica. |
||
Reporta la cantidad correcta de sumas cuando terminan los cálculos. Reporta el número correcto de cada suma resultado. |
||
Declara funciones estáticas reutilizables para calcular el inicio y final de los datos que se debe distribuir entre trabajadores. |
||
Declara funciones estáticas reutilizables para calcular el inicio y final de los datos que se debe distribuir entre trabajadores. Usa esas funciones para calcular el inicio y final del trabajador en los dos ciclos. Corrige el error por cero calculando el porcentaje de progreso. No reporta el número de resultado de cada suma encontrada, esto lo hará el |
||
Diseño de la interfaz con el usuario. |
||
Proyecto de Qt que lista los archivos que forman parte de la aplicación y cómo compilarlos. |
1.6. Versión 5: testing
1.6.1. Versión 5.0
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 4, 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 (GoldbachModel
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. Utiliza un atributo para contar cuántos modelos han terminado de hacer cálculos y por lo tanto, saber cuándo terminar el ciclo de eventos. |
||
Entra en el ciclo de eventos y se sale de éste hasta que hayan terminado todos los modelos. Cada vez que un modelo termina, compara las sumas que encontró con las del caso de prueba. |
||
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 manualmente con un ejemplo de Wikipedia. |
||
Ejemplo de un caso de prueba impar. Generado con la versión 0 de Goldbach. |
1.6.2. Potenciales mejoras
-
Haga que cada hilo cuente su duración mientras está encontrando las sumas en el rango de valores que le toca. Imprima en la salida o error estándar (o algún control en la interfaz gráfica) su duración.
-
Implemente una forma alterna de calcular el porcentaje de incremento. Recuerde que las divisiones o módulos son operaciones muy caras en tiempo de ejecución.
-
Modifique el programa de pruebas para que presente un mensaje de ayuda cuando se invoca sin parámetros, por ejemplo:
./GoldbachTester Usage: GoldbachTester test_folder1 test_folder2 ... test_folderN
-
Haga que el programa de pruebas no entre en el ciclo de eventos si no se encuentran casos de prueba en las carpetas dadas en línea de comandos.
-
El programa de pruebas hecho en clase reporta en la salida estándar un resultado para cada archivo probado. El programador debe recorrer toda la salida en busca de errores, lo cual es tedioso cuando la cantidad de casos de prueba es considerable. Modifique el programa de pruebas para que produzca una salida más ejecutiva para el programador. Por ejemplo, si el programa pasa todos los casos de prueba, podría indicar:
./GoldbachTester test 87 test cases: 87 passed (100%), 0 failed (0%).
-
Si el programa falla uno o más casos de prueba, imprima detalles que ayuden al programador a encontrar el fallo, por ejemplo:
./GoldbachTester test test case 870 failed at line 31: expected: "31: 263 + 607" produced: "31: 269 + 601" test case 5431 failed at line 15379: expected: "15378: 1789 + 1811 + 1831" produced: "" 27 test cases: 25 passed (93%), 2 failed (7%).
1.7. Versión 6: 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íticas 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 compartida
La concurrencia de memoria compartida, o simplemente concurrencia compartida ocurre cuando varios trabajadores (hilos de ejecución) realizan una tarea concurrentemente y pueden acceder a regiones compartidas de memoria. Los hilos de ejecución siguen este esquema, dado que comparten todos los segmentos del proceso (código, datos, y memoria dinámica), excepto el segmento de pila. Por ejemplo, un dato que un hilo escriba en la memoria compartida, otro hilo puede leerlo inmediatamente, sin hacer ninguna preparación especial. De esta forma, la memoria compartida se convierte en un mecanismo directo de comunicación entre los hilos de ejecución.
Se estudiarán en el curso tres tecnologías para implementar concurrencia compartida: Pthreads, OpenMP, y OpenACC. Éste último es de carácter opcional.
2.1. Phtreads
Los POSIX Threads es un estándar que permite a los desarrolladores crear y controlar hilos de ejecución en los sistemas operativos basados en Unix. Cree en una carpeta ejemplos/pthreads
para los siguientes ejemplos en su repositorio personal de control de versiones para el curso. Para cada ejemplo cree una subcarpeta que utilice el nombre dado entre corchetes.
2.1.1. Hola mundo (thread)
El siguiente es un "Hola mundo" en Pthreads:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde el hilo principal y desde un hilo secundario. |
Para compilar con GCC –o un compilador compatible– código fuente que use Pthreads, se debe agregar el parámetro -pthread
:
cc -g -Wall -Wextra hello.c -o hello -pthread
echo hello > .gitignore
git add hello.c .gitignore
Recuerde siempre ignorar los ejecutables en su repositorio de control de versiones. Para ello, cree o agregue a su archivo .gitignore
el nombre del ejecutable. Luego agregue el código fuente y el .gitignore
a control de versiones. Los dos últimos comandos de arriba hacen este trabajo.
2.1.2. Hola mundo múltiple (indeterminismo)
En la siguiente solución se incluye además un archivo de reglas Makefile
que permite a un desarrollador compilar el código fuente sin tener que recordar los argumentos con los que se debe invocar al compilador. Bastará que el programador emita el comando make
en la carpeta, y éste invocará al compilador siguiendo la única regla estipulada en el Makefile
de abajo.
Archivo | Descripción | Cambios |
---|---|---|
Varios threads dicen "hola mundo" junto con el hilo principal. |
||
Un archivo para compilar el código fuente sin tener que recordar los argumentos de invocación al compilador. |
2.1.3. Hola mundo numerado (memoria privada)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads saludan indicando su identificador en la salida estándar. |
||
El Makefile cambia el nombre del ejecutable y del archivo fuente. |
2.1.4. Hola mundo ordenado (memoria compartida) con espera activa
La espera activa es un ciclo que hace a un hilo de ejecución esperar repetitivamente hasta que una condición se haga falsa. Por ejemplo, si la variable next_thread
indica el número del próximo thread que puede realizar una tarea que debe ser serializada en orden, el código
// Wait until it is my turn
while ( next_thread < my_thread_id )
; // busy-wait
// Do the ordered-task here
task();
// Allow subsequent thread to do the task
++next_thread;
Archivo | Descripción | Cambios |
---|---|---|
Varios threads saludan en orden gracias a la espera activa. |
||
Se agregaron dos reglas típicas: una por defecto que genera todos los productos ( |
2.1.5. Posición en la carrera (mutex)
Archivo | Descripción | Cambios |
---|---|---|
Varios threads compiten en una carrera, y reportan el orden en que llegan a la meta. Utilizan exclusión mutua cuando llegan a la meta para incrementar la variable contadora correctamente y para evitar la condición de carrera en la impresión en la salida estándar. |
||
Cada vez que se cambia de proyecto, hay que cambiar el nombre de los archivos fuente y del ejecutable. Esta versión usa variables automáticas para hacer las reglas reutilizables entre proyectos, además de una variable definida ( |
2.1.6. Productor-consumidor (semáforo)
Archivo | Descripción | Cambios |
---|---|---|
Simula el problema del productor-consumidor. |
||
Asume que el nombre de la carpeta es el nombre del ejecutable y de que existe un archivo |
La versión anterior utiliza semáforos no nombrados. Son regiones de la memoria alojadas con sem_init()
y liberadas con sem_destroy()
. En macOS, estos semáforos están desaprobados (deprecated). La siguiente versión modifica la anterior para usar semáforos nombrados, los cuales son archivos, abiertos con sem_open()
, cerrados con sem_close()
y eliminados del sistema de archivos con sem_unlink()
. Si no se eliminan, quedan en el sistema de archivos y próximas ejecuciones del programa continuarán usándolos o fallarán.
Archivo | Descripción | Cambios |
---|---|---|
Usa semáforos nombrados para resolver el problema del productor-consumidor. |
||
Idéntico a la versión anterior. |
2.1.7. Carrera de relevos (barrera)
Archivo | Descripción | Cambios |
---|---|---|
Simula una carrera de relevos con threads. Esta solución se compara contra el ejemplo de posición en la carrera ( |
||
Agrega la regla |
2.1.8. Misterio (variable de condición)
2.1.9. Arreglo reentrante y thread-safe (candado de lectura-escritura)
Archivo | Descripción | Cambios |
---|---|---|
Respecto al código dado, se cambió |
||
El registro que contiene los atributos (campos) de un arreglo se hace opaco (sus campos son privados para los usuarios, por lo que no se declaran en la interfaz |
||
Las variables estáticas/globales se reemplazan por campos en registros. El usuario tiene que colocar los registros en un lugar seguro de la memoria de tal forma que una "instancia" de un arreglo no interfiera con otra. Esto lo hace reentrante. |
||
Un proyecto de Qt para quien guste usar este IDE. |
||
Este |
2.2. OpenMP
Tecnología desarrollada por varias empresas para implementar concurrencia de manera más declarativa y menos imperativa. Es una especificación para C y Fortran que varios compiladores implementan. Conviene tener a mano la guía de referencia al iniciar con esta tecnología.
2.2.1. Hola mundo (región paralela, directivas)
El siguiente es un "Hola mundo" en OpenMP:
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde hilos secundarios en OpenMP. En esta tecnología el hilo principal sólo crea y espera por los hilos secundarios por tanto, no puede realizar otra acción mientras los hilos secundarios están en ejecución. |
||
Agrega la bandera |
Para compilar con GCC o un compilador compatible debe habilitarse OpenMP con la opción -fopenmp
. El Makefile
del ejemplo incluye esta bandera:
cc -g -Wall -Wextra -fopenmp source.c -o executable
2.2.2. Hola mundo múltiple (funciones de biblioteca)
Archivo | Descripción | Cambios |
---|---|---|
Cada hilo secundario saluda diciendo su número en el equipo (team) y la cantidad de miembros en el equipo. |
||
Igual al ejemplo anterior. |
2.2.3. Repartir las iteraciones (omp parallel for
)
La directiva omp parallel
crea siempre una región paralela, que implica la creación y destrucción (join) de threads. La instrucción o bloque paralelizado es ejecutado por todos los _threads del equipo. La directiva omp parallel for
siempre debe estar seguida por un ciclo por contador (for
) estructurado (sin terminaciones abruptas como break
, continue
, y return
). Por ser una región paralela (por la palabra parallel
) también crea y destruye threads, pero a diferencia de omp parallel
, omp parallel for
reparte las iteraciones del ciclo entre los threads creados:
Archivo | Descripción | Cambios |
---|---|---|
La directiva |
2.2.4. Reutilizar hilos (omp for
)
Si se tienen varias regiones parallel for
una tras otra que utilizan la misma cantidad de threads, es ineficiente crearlos y destruirlos cada vez que se ingresa y sale de estas secciones, de ahí la utilidad de la directiva for
.
Archivo | Descripción | Cambios |
---|---|---|
La directiva |
2.2.5. Ordenamiento par-impar
Archivo | Descripción | Cambios |
---|---|---|
Fragmento de código discutido en clase que utiliza las directivas de OpenMP para paralelizar el ordenamiento par-impar. |
2.2.6. Medición de duraciones con gprof
y perf
Los siguientes comandos resumen el uso de gprof
:
# Compile a modified executable that measures function call durations
gcc -g -Wall -Wextra -pg -no-pie source.c -o executable
# Run the executable as usual, it will generate binary file gmon.out
./executable args
# Generate a report from the gmon.out binary log
gprof ./executable gmon.out > report.txt
La herramienta perf
provee información menos detallada que gprof
, pero tiene la ventanja de que no es necesario modificar el ejecutable. Para obtener la duración se puede anteceder el comando con perf stat
de la forma:
perf stat ./executable args
3. Concurrencia distribuida
El paradigma de programación distribuido permite crear programas escalables que aprovechan máquinas que pueden correr procesos y pueden comunicarse a través de redes de computadoras. Se distingue de la distribución compartida en que los hilos pueden acceder a la misma memoria y comparten el mismo reloj. En la concurrencia distribuida, los procesos tienen cada uno su propia memoria exclusiva y los relojes pueden ser distintos, por lo que tienen que comunicarse a través de paso de mensajes.
Existen dos variantes de la distribución. En la distribución heterogénea, los ambientes en el que corre el programa son distintos: diferente hardware, sistema operativo, huso horario, etc., lo que forma una malla de computadoras. En la distribución homogénea, el ambiente (tanto el hardware como el software) debe ser idéntico para todos los procesos del programa, lo que se llama un clúster de computadoras. La distribución homogénea logra conseguir los mayores niveles de paralelismo.
3.1. Distribución homogénea: MPI
Message Passing Interface (MPI) es una tecnología de distribución homogénea creado por un grupo de académicos con el fin de facilitar el parelismo de aplicaciones científicas, que se convirtió en un estándar de facto. Existen otras tecnologías como Charm++ de más alto nivel.
Cree una carpeta ejemplos/mpi/
en su repositorio de control de versiones.
3.1.1. Hola mundo (proceso)
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 |
3.1.2. Distribución híbrida (proceso-hilo)
Archivo | Descripción | Cambios |
---|---|---|
Dice "hola mundo" desde el hilo principal e hilos secundarios en varios procesos. |
||
Agrega las banderas para habilitar OpenMP. La bandera |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
Archivo | Descripción | Cambios |
---|---|---|
Reparte un rango de valores dado por argumento de línea de comandos entre procesos e hilos. |
||
Makefile para compilar el código fuente |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
3.1.3. Comunicación punto a punto (send-receive)
La comunicación punto a punto envía mensajes de un proceso a otro. MPI provee una familia de funciones para enviar y recibir mensajes. Las dos más básicas son: MPI_Send y MPI_Recv.
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. |
||
Un makefile genérico para compilar con MPI. |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
Archivo | Descripción | Cambios |
---|---|---|
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 con MPI. |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
3.1.4. Lectura distribuida y tiempo de pared
MPI permite que sólo el proceso 0 lea de la entrada estándar. Más específicamente, la entrada la lee el comando mpiexec
, la captura y la envía al proceso 0. Los demás procesos no reciben la entrada, por tanto si intentan leer, se quedarán bloqueados perennemente. Si un proceso debe leer de la entrada estándar, el proceso 0 tendrá que usar comunicación para enviarla a los demás procesos.
Archivo | Descripción | Cambios |
---|---|---|
Reparte un rango de valores dado leído de la entrada estándar entre procesos e hilos. |
||
Makefile para compilar el código fuente |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
Para calcular la duración en segundos del tiempo en la pared use la función MPI_Wtime.
(Ejemplo pendiente)
3.1.5. Comunicación colectiva: broadcast
Archivo | Descripción | Cambios |
---|---|---|
El proceso 0 lee el rango de la entrada estándar (puesto que es el único en MPI que puede hacerlo), si no se provee en los argumentos de línea de comandos. Luego envía copias del rango 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 ( |
||
Makefile para compilar el código fuente |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
3.1.6. Comunicación colectiva: reduce
Archivo | Descripción | Cambios |
---|---|---|
Todos los procesos escogen un número de la suerte al azar. Note que se usa el número de proceso en la semilla para generar números pseudoaletorios, de lo contrario, es probable que todos los procesos escojan el mismo número de la suerte. Las diferencias se comparan contra Hola mundo (proceso). |
||
Un makefile genérico para compilar con MPI. |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |
3.1.7. Comunicación colectiva: all-reduce
Archivo | Descripción | Cambios |
---|---|---|
Cada proceso escoge un número de la suerte y lo envía a todos los demás. Cada proceso recibe el mínimo, la suma y el máximo de los números escogidos. Cada proceso calcula el promedio y compara su número de la suerte contra los estadísticos anteriores. |
||
Un makefile genérico para compilar con MPI. |
||
Nodos mpich del clúster arenal de la ECCI, para poder invocar con |