Rendimiento CPU vs. GPU / Open CL
Todas las aplicaciones de tratamiento masivo de datos se pueden beneficiar de la capacidad de proceso cada vez mayor que tienen los modernos equipos informáticos que ya están al alcance del bolsillo de cualquiera. En este artículo voy a presentar una comparativa básica de rendimiento entre varias plataformas CPU / GPU utilizando como base el archiconocido Conjunto de Mandelbrot y su asombrosa representación gráfica.
La prueba consiste en dibujar los puntos de dicho conjunto en un determinado intervalo del plano complejo utilizando distintos procedimientos: una sola tarea, una tarea por punto o un atarea por línea de la imagen, utilizando la CPU, o bien una tarea por punto de la imagen utilizando la GPU. Para ello, he escrito una aplicación utilizando los lenguajes C# y C++ con Visual Studio 2022, que podéis descargar utilizando este enlace. El archivo contiene el ejecutable de la aplicación (en el subdirectorio MandelbrotMP\bin\Release), el código fuente, y un archivo CSV de datos con los resultados para diferentes plataformas (Performance-data.csv).
Para la ejecución del algoritmo utilizando la CPU, he utilizado la clase Parallel de la Plataforma .NET, que permite realizar bucles utilizando los diferentes núcleos del procesador en paralelo. Para utilizar la GPU, he escrito una dll en C++ que hace uso del API de Open CL para lanzar una tarea por cada punto de la imagen. La comparativa la he realizado con los siguientes equipos y configuraciones, todos ellos con 16 GB de RAM o más:
- Equipo 1: Sobremesa, CPU i9 9960X (16 núcleos / 32 hilos), AMD Radeon RX 6900 XT, DDR4 2666 MHz, PCIe 3.0.
- Equipo 2: Sobremesa, CPU i9 11980K (8 núcleos / 16 hilos), AMD Radeon RX 5700 XT, DDR4 3200 MHz, PCIe 4.0.
- Equipo 3: Portátil, CPU i7 11800H (8 núcleos / 16 hilos), Nvidia RTX 3060, DDR4 3200 MHz, PCIe 4.0.
- Equipo 4: Portátil, CPU i7 11800H (8 núcleos / 16 hilos), Nvidia RTX 3050, DDR4 3200 MHz, PCIe 4.0.
- Equipo 5: Sobremesa, CPU i7 9700 (8 núcleos / 8 hilos), Nvidia RTX 2060, DDR4 2666 MHz, PCIe 3.0.
- Equipo 6: Sobremesa, CPU i5 8400 (6 núcleos / 6 hilos), AMD Radeon RX 5500 XT, DDR4 2400 MHz, PCIe 3.0.
- Equipo 7: Sobremesa, CPU i7 10700K (8 núcleos / 16 hilos), Nvidia RTX 3080, DDR4 2666 MHz, PCIe 4.0.
Uso de la aplicación
El programa permite realizar dos tipos de pruebas: dibujar una sola imagen del conjunto, que podemos ampliar utilizando la rueda del ratón, o dibujar 100 imágenes, realizando un zoom entre una imagen y la siguiente, obteniendo un efecto como este:
Esta es la opción que he utilizado para realizar la comparativa, ya que permite guardar un archivo CSV con los tiempos en milisegundos que tarda en calcular cada una de las imágenes.
Al ejecutar el programa, aparece el siguiente cuadro de diálogo que permite seleccionar el tipo de prueba que queremos realizar:
Se pueden seleccionar 4 tipos de algoritmo:
- Ejecución con un solo hilo: Se dibujan todos los puntos de la imagen usando una única tarea.
- Ejecución en paralelo por punto: Se dibuja la imagen utilizando una tarea separada por cada punto.
- Ejecución en paralelo por línea: Se dibuja la imagen usando una tarea separada por cada línea.
- Ejecución usando la GPU con Open CL: Se dibuja la imagen con la GPU con el API de Open CL.
El resto de parámetros tienen el siguiente uso:
- Log: si se marca, se graba un archivo de texto con el resultado de las distintas operaciones realizadas (solo para la opción GPU / Open CL).
- Reset: Las coordenadas y el zoom de la imagen se restauran a los valores iniciales.
- Travelling: Si está marcado, se dibujan 100 imágenes consecutivas, centradas en el punto correspondiente a las coordenadas Xc e Yc indicadas.
- Save Time Data: después de dibujar las 100 imágenes, se graba un archivo csv con el tiempo empleado en cada una de ellas, en milisegundos.
- Radius e Iteration Multiplier: Parámetro para configurar el peso de los cálculos, como se indica más adelante.
Este cuadro de diálogo se puede volver a mostrar en cualquier momento haciendo doble clic sobre la ventana del programa. Con la rueda del ratón podemos realizar zoom sobre el punto en el que se encuentra el puntero, aumentando hacia delante y disminuyendo hacia atrás. Si pulsamos con el botón derecho sobre un punto cualquiera de la imagen, sus coordenadas se guardan en el portapapeles y se utilizan para dar valores a Xc e Yc al abrir de nuevo el cuadro de diálogo de configuración.
Las pruebas
Tenemos una imagen con W puntos de ancho y H de alto, lo que nos da un total de W x H puntos. En resolución Full HD el total es de 1920 x 1080 = 2.073.600 puntos aproximadamente (hay que descontar el espacio que ocupan la barra de título y el borde de la ventana y la barra de tareas, si no está oculta). Si la resolución es 4k, la cantidad de puntos es aproximadamente de 4 veces más, es decir, 8.294.400 puntos.
Para dibujar el conjunto de Mandelbrot, en cada punto se realizan las operaciones siguientes:
- El número complejo que corresponde al punto es Z0 = X + iY.
- Se calcula la sucesión Zn + 1 = Zn2 + Z0, siendo Zn el resultado del cálculo anterior.
- Si el módulo del resultado es mayor que un cierto número (el parámetro Radius del cuadro de diálogo de configuración), se considera que el número Z0 no pertenece al conjunto de Mandelbrot, y se dibuja con un determinado color en función del valor n (número de términos de la sucesión calculados).
- Si una vez realizadas todas las iteraciones no hemos superado el valor de Radius, el punto pertenece al conjunto y se dibuja con el color 0.
Los colores están definidos en una paleta de 1786 colores:
El parámetro Iteration Multiplier del cuadro de diálogo de configuración indica cuantas Términos de la serie vamos a intentar calcular, en múltiplos del número de colores, para comprobar si el punto pertenece al conjunto (color negro).
Para todas las pruebas, he realizado el cálculo de las 100 imágenes utilizando valores de 1 (1786 iteraciones) y de 4 (7144 iteraciones). Todas las pruebas están realizadas en resolución Full HD. En los equipos 1 y 2 también las he realizado en resolución 4k. Solo he realizado las pruebas de CPU con los equipos 1 y 2, que son los más potentes.
Los resultados
Para no aburrir a los que no estén interesados en los detalles técnicos, voy a presentar primero los resultados. Solo he realizado la ejecución en mono hilo con el procesador más potente (equipo 1), ya que está claro que será claramente inferior a cualquiera de las otras opciones. Este es el gráfico con los resultados (utilizando el llamado “diagrama de cajas y bigotes”):
Si eliminamos la prueba con una sola tarea, la comparativa queda así:
En este gráfico, tenemos los datos de las CPU en azul (Intel), y los de las GPU en tonos rojizos (AMD), o verdes (Nvidia). El texto de cada gráfica contiene el modelo de la CPU / GPU, el multiplicador de iteraciones (x1 y x4) y también se indican los casos en que la resolución utilizada es 4k. Me ha llamado la atención que las GPU RX 5700 XT y RX 6900 XT de AMD parecen tener un rendimiento en esta prueba bastante superior a las CPU, lo cual es lo esperable, pero también a las GPU Nvidia, incluso a la RTX 3080
En este otro gráfico podemos ver la comparativa de las GPU, sin los datos de CPU:
En cuanto a los tiempos totales para calcular las 100 imágenes, esta es la gráfica para cada una de las CPU / GPU (los tiempos están en milisegundos, en el eje vertical. No se muestra el tiempo de la ejecución en mono hilo):
Las GPU Radeon RX 6900 XT y RX 5700 XT son las claras ganadoras, incluso en 4k, dejando atrás a la RTX 3080, que en teoría es superior. Lo que queda claro es que, si el problema que estamos intentando resolver requiere un número suficiente de tareas, cualquier GPU supera con creces a la mejor de las CPUs. Esto es debido a que disponen de un número muy superior de tareas. En los casos en los que el problema requiera de un número de tareas no muy grande, las CPUs pueden resultar superiores a las GPUs, ya que, en general, tienen una velocidad de reloj mayor.
El código fuente
Para los interesados en el código fuente del programa, que quieran realizar modificaciones para ejecutar pruebas diferentes, estos son los puntos que considero más relevantes:
El algoritmo para calcular si un punto Z0 = xn + iyn pertenece al conjunto es el siguiente:
double xx = 0;
double yy = 0;
pt[cp] = _colors[0];
for (int i = 0; i < it; i++)
{
double x = ((xx * xx) - (yy * yy)) + xn;
double y = (2 * xx * yy) + yn;
xx = x;
yy = y;
if ((x * x) + (y * y) > r)
{
pt[cp] = colors[i / rcol];
break;
}
}
Los datos se guardan en un array cp de w x h enteros(w = ancho de la imagen, h = alto de la imagen, en píxeles), cada uno de los cuales representa un color de la paleta. Inicializamos el punto actual con el color 0, como si perteneciera al conjunto.
Durante it iteraciones (el número de colores por el multiplicador rcol), realizamos el cálculo Zn+1 = Zn2 + Z0, si el módulo del resultado es mayor que la constante r, le damos al punto el color de la paleta que le corresponde y pasamos al siguiente.
Para dibujar la imagen usando la CPU, utilizamos el método For de la clase Parallel de System.Threading.Tasks. Este procedimiento utiliza de manera óptima los distintos núcleos de la CPU, de manera completamente transparente.
Para utilizar la GPU, he utilizado Open CL, lo que implica escribir una librería en C++ a la que debe llamar el programa para dibujar la imagen. El kernel que dibuja cada punto es el siguiente, y se encuentra en el archivo de texto MandelbrotKernel2.src:
__kernel void MandelbrotKernel(int w, int h, double x0, double y0, double dx, double dy,
int it, int r, int colorscnt, __constant int* colors, __global int* image)
{
int gsz = get_global_size(0);
int cp = get_global_id(0) +
(get_global_id(1) * gsz) +
(get_global_id(2) * gsz * gsz);
if (cp < (h * w))
{
int rcol = it / colorscnt;
double xn = x0 + ((double)(cp % w) * dx);
double yn = y0 - ((double)(cp / w) * dy);
double xx = 0;
double yy = 0;
for (int i = 0; i < it; i++)
{
double x = ((xx * xx) - (yy * yy)) + xn;
double y = (2 * xx * yy) + yn;
xx = x;
yy = y;
if (((x * x) + (y * y)) > r)
{
image[cp] = colors[i / rcol];
break;
}
}
}
}
Los parámetros son los siguientes: w y h son el ancho y el alto en píxeles de la imagen; x0 e y0 son las coordenadas del punto del plano complejo de la esquina inferior izquierda de la imagen; dx y dy corresponden al incremento de las coordenadas X e Y por cada píxel de la imagen; it es el número de iteraciones; r es el radio de divergencia máximo; colorscnt es el número de colores; colors es un puntero al array de colores e image es un puntero a los datos de la imagen.
Para calcular la posición de cada píxel en el array de datos de la imagen, utilizamos los identificadores globales de las tres dimensiones del espacio de trabajo de la GPU. Estos valores los habremos calculado de la siguiente manera, a partir de la cantidad total de píxeles de la imagen:
cl_uint ndim = 3;
int nbits = (int)ceil(log2(imgsz));
nbits = (nbits / ndim) + ((nbits % ndim) ? 1 : 0);
size_t globalWorkSize[3];
globalWorkSize[0] = (size_t)1 << nbits;
globalWorkSize[1] = (size_t)1 << nbits;
globalWorkSize[2] = 1 + (imgsz / (globalWorkSize[0] * globalWorkSize[1]));
Primero obtenemos el número de bits por dimensión (nbits) calculando el logaritmo en base 2 del tamaño de la imagen (imgsz) y dividiéndolo por el número de dimensiones, que es 3. Esto es equivalente a expresar el tamaño total con tres dígitos en base nbits.
Este cálculo no está optimizado, por lo que pueden existir elementos sobrantes por encima del número de píxeles de la imagen. Por esta razón es necesaria la comprobación if (cp < (h * w))
antes de realizar ninguna operación.
En la librería C++ MandelbrotMPOpenCLGPU.dll, se exportan cuatro funciones:
- MSGPUImage: Realiza todas las operaciones necesarias para dibujar una imagen, reservando y liberando los recursos en una sola llamada.
- MSGPUPrepare: Reserva los recursos para utilizar la GPU, pero no dibuja ninguna imagen. Se utiliza cuando realizamos la prueba de dibujo de 100 imágenes, para inicializar.
- MSGPUFrame: Dibuja una imagen con los recursos reservados por la función anterior.
- MSGPURelease: Libera los recursos Open CL reservados.
Para almacenar los identificadores de los recursos Open CL para la GPU se utiliza la estructura ContextData:
public struct ContextData
{
IntPtr kernel;
IntPtr program;
IntPtr cmdQueue;
IntPtr imgBuffer;
IntPtr colorBuffer;
IntPtr gpuDevice;
IntPtr context;
};
En cuanto a la elección de la GPU, de entre todas las que se encuentren disponibles en todas las plataformas, se selecciona aquella para la que sea mayor el producto de los parámetros CL_DEVICE_MAX_COMPUTE_UNITS y CL_DEVICE_MAX_WORK_GROUP_SIZE. Esto nos debería garantizar el uso del dispositivo de mayor potencia, que normalmente será la tarjeta gráfica dedicada.
Y esto es todo por el momento. Espero que disfrutéis del código realizando modificaciones para realizar vuestras propias comparativas.