CUDA son las siglas de Compute Unified Device Architecture (Arquitectura Unificada de Dispositivos de Cómputo) que hace referencia a una plataforma de computación en paralelo que incluye un compilador y un conjunto de herramientas de desarrollo creadas por Nvidia que permiten a los programadores usar una variación del lenguaje de programación C (CUDA C) para codificar algoritmos en GPU de Nvidia.

Por medio de wrappers se puede usar Python, Fortran, Julia y Java en vez de C/C++.

Funciona en todas las GPU Nvidia de la serie G8X en adelante, incluyendo GeForce, Quadro, ION y la línea Tesla. Nvidia afirma que los programas desarrollados para la serie GeForce 8 también funcionarán sin modificaciones en todas las futuras tarjetas Nvidia, gracias a la compatibilidad binaria provista por el conjunto de instrucciones PTX (Parallel Thread Execution).

CUDA intenta explotar las ventajas de las GPU frente a las CPU de propósito general utilizando el paralelismo que ofrecen sus múltiples núcleos, que permiten el lanzamiento de un altísimo número de hilos simultáneos. Por ello, si una aplicación está diseñada utilizando numerosos hilos que realizan tareas independientes (que es lo que hacen las GPU al procesar gráficos, su tarea natural), una GPU podrá ofrecer un gran rendimiento en campos que podrían ir desde la biología computacional a la criptografía, por ejemplo.

El primer SDK se publicó en febrero de 2007 en un principio para Windows, Linux, y más adelante en su versión 2.0 para macOS. Actualmente se ofrece para Windows XP/Vista/7/8/10, para Linux 32/64 bits y para macOS.

Historia En 2003, un equipo de investigadores dirigido por Ian Buck presentó Brook, el primer modelo de programación ampliamente adoptado para extender C con construcciones paralelas de datos. Ian Buck luego se unió a NVIDIA y lideró el lanzamiento de CUDA en 2006, la primera solución del mundo para computación general en GPU.

En 2006 Ian Buck se une a NVIDIA y lidera el lanzamiento de CUDA, la primera solución para el cómputo general en GPU.

En 2007 se hace público el SDK 1.0 de CUDA para Microsoft Windows y Linux. Soporta la micro-arquitectura Tesla.

En 2009 se hace público el SDK 1.0 de CUDA para Microsoft Windows y Linux. Soporta la micro-arquitectura Tesla. Desde entonces, el SDK se ha actualizado para soportar las nuevas arquitecturas que se desarrollan para las GPU.

Un aspecto importante en la historia de CUDA fue la relación que mantuvo con Apple, la cual estuvo marcada por varios conflictos y cambios en la dirección estratégica. Inicialmente, CUDA recibió soporte en macOS con su versión 2.0, la cual fue lanzada en el año 2007, pero este soporte terminó con la versión 10.12. La relación entre NVIDIA y Apple se vio afectada por múltiples desafíos, incluyendo problemas técnicos con chipsets gráficos y diferencias en la gestión de la memoria y las comunicaciones a través de la GPU. Estos problemas culminaron en la decisión de Apple de retirar el soporte de los controladores de GPU de NVIDIA en macOS Mojave y su posterior transición hacia su propia tecnología de gráficos, llamada “Metal”. Este cambio no solo mostró las tensiones entre ambas compañías, sino que también tuvo un impacto significativo en la comunidad de desarrolladores, especialmente aquellos que dependían de CUDA para aplicaciones de cálculo computacional intensivo. La falta de soporte de CUDA en los sistemas operativos de Apple marcó un cambio significativo en la industria, evidenciando las diferencias entre las estrategias de hardware y software de NVIDIA y Apple.

Ventajas CUDA presenta ciertas ventajas sobre otros tipos de computación sobre GPU utilizando APIs gráficas.

Lecturas dispersas: se puede consultar cualquier posición de memoria. Memoria compartida: CUDA pone a disposición del programador un área de memoria de 16KB (o 48KB en la serie Fermi) que se compartirá entre hilos del mismo bloque. Dado su tamaño y rapidez puede ser utilizada como caché. Lecturas más rápidas de y hacia la GPU. Soporte para enteros y operadores a nivel de bit.

Desventaja No se pueden utilizar variables estáticas dentro de funciones o funciones con número de parámetros variable. No está soportado el renderizado de texturas. En precisión simple no soporta números desnormalizados o NaNs. Puede existir un cuello de botella entre la CPU y la GPU por los anchos de banda de los buses y sus latencias. Por razones de eficiencia, los threads o hilos de ejecución deben lanzarse en grupos de al menos 32, con miles de hilos en total.

El modelo CUDA CUDA intenta aprovechar el gran paralelismo, y el alto ancho de banda de la memoria en las GPU en aplicaciones con un gran coste aritmético frente a realizar numerosos accesos a memoria principal, lo que podría actuar de cuello de botella.

El modelo de programación de CUDA está diseñado para que se creen aplicaciones que de forma transparente escalen su paralelismo para poder incrementar el número de núcleos computacionales. Este diseño contiene tres puntos claves, que son la jerarquía de grupos de hilos, las memorias compartidas y las barreras de sincronización.

La estructura que se utiliza en este modelo está definido por un grid, dentro del cual hay bloques de hilos que están formados por como máximo 1024 hilos distintos.

Cada hilo en un bloque está identificado con un identificador único, que se accede con la variable threadIdx. Esta variable es muy útil para repartir el trabajo entre distintos hilos. threadIdx tiene 3 componentes (x, y, z), coincidiendo con las dimensiones de bloques de hilos. Así, cada elemento de una matriz, por ejemplo, lo podría tratar su homólogo en un bloque de hilos de dos dimensiones.

Al igual que los hilos, los bloques se identifican mediante blockIdx (en sus componentes x, y, z). Algunas otras funciones útiles son blockDim, para acceder al tamaño de bloque y gridDim, para acceder a la forma de la grid.

Kernel Un kernel es el código que se ejecuta en el dispositivo, la función que ejecutan los diferentes flujos durante la fase paralela. En CUDA un kernel se ejecuta mediante un conjunto de flujos, es decir, es una función la cual al ejecutarse lo hará en N distintos hilos en lugar de en secuencial. Se define incluyendo __global__ en la declaración. Por ejemplo:

//Definición del kernel __global__ void f(int a, int b, int c)

Si nuestra función f queremos que calcule la diferencia entre dos vectores A y B y lo almacene en un tercero C: __global__ void f(int* A, int* B, int* C) { int i = threadIdx.x; C[i] = A[i] - B[i]; }

Esta función se ejecutaría una vez en cada hilo, reduciendo el tiempo total de ejecución en gran medida, y dividiendo su complejidad, O(n), por una constante directamente relacionada con el número de procesadores disponibles.

El mismo ejemplo con matrices sería: __global__ void f(int** A, int** B, int** C) { int i = threadIdx.x; //Columna del bloque que ocupa este determinado hilo int j= threadIdx.y; //Fila C[i][j] = A[i][j] - B[i][j]; }Cuando se lanza un kernel se crea una malla de hilos donde todos ellos ejecutan el mismo programa. El kernel especifica las instrucciones que van a ser ejecutadas por cada hilo individual y se pueden lanzar todos los hilos en un único bloque, lanzar varios bloques con un solo hilo cada uno, o lanzar varios bloques con varios hilos en cada bloque. De esta forma, el GPU se encarga de ejecutar simultáneamente tantas copias del kernel como hilos se tenga en ejecución. A cada hilo se le asignan sus propios datos de forma que cada copia realice la misma operación pero con datos diferentes, aprovechando así el paralelismo que ofrecen las GPUs.

Ejecución de un kernel La memoria del procesador principal y del dispositivo son espacios de memoria completamente separados, lo que permite la computación simultánea tanto en la CPU como en la GPU sin competir por los recursos de memoria. Para ejecutar un kernel en el dispositivo GPU se siguen los siguientes pasos:

Reservar memoria en el dispositivo Transferir los datos necesarios del procesador principal al espacio de memoria asignado al dispositivo. Invocar la ejecución del kernel en cuestión. Transferir los resultados del dispositivo al procesador principal y liberar la memoria del dispositivo una vez finalizada la ejecución del kernel.

Invocaciones a un kernel En una llamada a un kernel, se le ha de pasar el tamaño de grid y de bloque, por ejemplo, en el main del ejemplo anterior podríamos añadir: dim3 bloque(N,N); //Definimos un bloque de hilos de N*N dim3 grid(M,M) //Grid de tamaño M*M

f<<<grid, bloque>>>(A, B, C); En el momento que se invoque esta función, los bloques de un grid se enumerarán y distribuirán por los distintos multiprocesadores libres.

Sincronización Como los distintos hilos colaboran entre ellos y pueden compartir datos, se requieren unas directivas de sincronización. En un kernel, se puede explicitar una barrera incluyendo una llamada a __syncthreads(), en la que todos los hilos se esperarán a que los demás lleguen a ese mismo punto.

Compilación Las funciones kernel se separan del código principal y son compiladas por los ensambladores y compiladores patentados por NVIDIA, mientras que el resto del código se compila con un compilador de C++ compatible con el Compilador Nvidia CUDA (NVCC), por ejemplo gcc, g++ o cl.exe. Al final, se incorporan las funciones de GPU compiladas al archivo principal del código.

Jerarquía de memoria Los hilos en CUDA pueden acceder a distintas memorias, unas compartidas y otras no.

En primer lugar, está la memoria privada de cada hilo, solamente accesible desde él mismo. Cada bloque de hilos posee también un espacio de memoria, compartida en este caso por los hilos del bloque y con un ámbito de vida igual que el del propio bloque. Todos los hilos pueden acceder a una memoria global.

Además, existen otros dos espacios de memoria más, que son de solo lectura y accesibles por todos los hilos. Son la memoria constante y la de texturas. Todas las memorias de acceso global persisten mientras esté el kernel en ejecución.

Arquitectura CUDA Un multiprocesador contiene ocho procesadores escalares, dos unidades especiales para funciones trascendentales, una unidad multihilo de instrucciones y una memoria compartida. El multiprocesador crea y maneja los hilos sin ningún tipo de overhead por la planificación, lo cual unido a una rápida sincronización por barreras y una creación de hilos muy ligera, consigue que se pueda utilizar CUDA en problemas de muy baja granularidad, incluso asignando un hilo a un elemento por ejemplo de una imagen (un píxel).

Diferencia entre una tarjeta gráfica y CUDA En la arquitectura clásica de una tarjeta gráfica podemos encontrar la presencia de dos tipos de procesadores, los procesadores de vértices y los procesadores de fragmentos, dedicados a tareas distintas e independientes dentro del entorno gráfico, y con repertorios de instrucciones diferentes.

Esto presenta dos problemas importantes, por un lado el desequilibrio de carga que aparece entre ambos procesadores y por otro la diferencia entre sus respectivos repertorios de instrucciones. De este modo, la evolución natural en la arquitectura de una GPU ha sido la búsqueda de una arquitectura unificada donde no se distingue entre ambos tipos de procesadores. Así se llegó a la arquitectura CUDA, donde todos los núcleos de ejecución necesitan el mismo repertorio de instrucciones.