Programación e Implementación de un Clúster de GPU's
0
P
ROGRAMACIÓN E
I
MPLEMENTACIÓN DE UN
C
LÚSTER
DE
GPU
'S
Autores
Regalado Orocio Luis Armando
Bolaños Palacios Julio Cesar
Asesores
M. en C. Quiroz Fabián José Luis
Dr. Castro García Miguel Alfonso
Dr. Aguilar Cornejo Manuel
Octubre 2012
Programación e Implementación de un Clúster de GPU's
1
Índice
Introducción 3
1. GPU: Unidad de Procesamiento de Gráficos. 4
2. Programación para GPU's y el nuevo reto: GPGPU. 5
3. Modelos de programación en GPU’s. 6
4. Requisitos en Hardware de una PC para la instalación de una GPU. 7
5. La Tecnología GPU y CUDA. 8
6. GPGPU aplicado. 9
7. Arquitectura Física de una GPU. 10
8. Desmenuzando CUDA. 11
a. Instalación de CUDA. 12
b. Instalar dependencias. 12
c. Instalar el controlador del dispositivo. 13
d. Instalación de CUDA Toolkit. 13
e. Instalar CUDA SDK. 14
f. Prueba de la instalación. 14
9. CUDA, un modelo de programación de propósito general. 16
a. Host y Device 16
b. Los CUDA Threads y su Jerarquía dentro de la GPU. 16
c. Jerarquía de Memoria 17
d. Kernels. 18
e. Thread Id: Un paso más allá. 19
f. Sincronizando los Threads. 19
g. CUDA: Una programación Heterogénea. 20
h. El proceso de compilación. 21
10. Programación en CUDA. 22
a. Suma de dos Números con CUDA. 22
b. Suma de dos vectores con CUDA. 24
c. Array CudaID. 26
d. Reducción de un Vector con Memoria Compartida. 30
e. Inicialización de un arreglo usando memoria de textura. 32
f. Inicialización de un arreglo usando memoria constante. 34
g. Inicialización de un arreglo usando pthreads. 36
11. Controlando más de un GPU (Programación MultiGPU). 39
a. OpenMP + Cuda. 39
b. Pthreads + Cuda. 48
12. Implementación de un Cluster de GPU’s. 51
a. Clúster de Computadoras. c.
51
b. Requisitos o Componentes de un Clúster. 52
c. Clúster de GPU’s. 53
d. Interfaz de paso de mensajes: OpenMPI. 54
e. Laboratorio de Sistemas Distribuidos UAM – I. 55
f. Paralelismo CUDA + MPI. 56
Programación e Implementación de un Clúster de GPU's
2
h. Ejemplo: simpleCUDAMPI. 59
i. Ejemplo: Cuda + Pthreads + OpenMPI. 62
j. Ejemplo: SendReceiveCUDAMPI. 66
k. Ejemplo: ReduceCudaMPI. 69
13. Conclusiones 72
14. Anexo 72
a. Funciones Open MP. 72
b. Funciones Open MPI. 73
c. Configuración de un Clúster. 73
Programación e Implementación de un Clúster de GPU's
3
Introducción
La programación paralela tradicionalmente se realizaba en máquinas con más de un procesador (máquinas multiprocesador). Debido al costo de las máquinas multiprocesador surgen los clúster (máquinas monoprocesador unidos mediante una red de alta velocidad). Posteriormente la reducción de costos del hardware (entre otras razones) dio surgimiento a las maquinas multicore y clústers de este tipo de arquitecturas.
Las aplicaciones paralelas se han adaptado al hardware multicore buscando aprovechar su diseño (p.e. tener más de un núcleo en un mismo procesador). Haciendo uso de multicore se podría generar un hilo de trabajo por cada núcleo a fin de aumentar el rendimiento de la aplicación paralela más sin embargo estamos limitados al número reducido de núcleos (en promedio 4 por procesador) que ofrecen dichos procesadores. Este limitante se ha resuelto en diferentes aplicaciones haciendo uso de GPU's (graphics processing unit).
Un GPU es un dispositivo dedicado al procesamiento de gráficos, una característica muy importante de un GPU es que puede tener múltiples núcleos (más de 100) trabajando de forma paralela. Buscando explotar los GPU's para aplicaciones en diferentes dominios se ha introducido recientemente el término GPGPU (General Purpose Computing on Graphics Processing Units, Programación de Propósito General en Unidades de Procesamiento de Gráficos), esto es: las GPU's ya no solo son usadas para el procesamiento de gráficos, ahora se busca aprovechar su poder de computó para desarrollar una amplia variedad de aplicaciones de propósito general. Además se puede agrupar un conjunto de nodos con GPU's y formar un clúster de GPU's haciendo uso de interfaces de programación paralela como MPI o PVM (entre otras).
El siguiente reporte pretende mostrar la posibilidad de implementar un clúster de GPU's para aprovechar las prestaciones de estos dispositivos; para ello, se realiza una explicación de lo que es una GPU, los lenguajes creados para su programación, así como su arquitectura; posteriormente se hace énfasis en la configuración y puesta a punto de un clúster integrado por GPU's.
Programación e Implementación de un Clúster de GPU's
4
1. GPU: Unidad de Procesamiento de Gráficos
Una GPU (Graphics Processing Unit1), es un dispositivo de hardware que por sus características, se encarga del procesamiento de gráficos en una computadora, logrando aligerar en este aspecto la carga de trabajo en el CPU (Unidad de Procesamiento Central). Hoy en día se pueden encontrar GPU's en una gran cantidad de dispositivos como lo son: Computadoras de Escritorio, Computadoras Portátiles, Consolas de videojuegos y video proyectores, entre otros.
Una tarjeta de procesamiento de gráficos, GPU; es altamente especializada, pues está pensada para realizar una sola tarea: Procesamiento de Gráficos, que no es otra cosa que realizar una gran cantidad de cálculos aritméticos sobre números reales (representados en una computadora mediante la representación de coma flotante2). Al ser un hardware especializado, todas las operaciones se realizan más rápido pues están implementadas a nivel hardware, es decir, se ocupan circuitos de silicio para realizar dichos cálculos.
Una GPU, además posee un alto grado de paralelismo, pues soporta una gran cantidad de threads3 en ejecución al mismo tiempo, esto comparado con los procesadores más actuales. En la siguiente gráfica se puede apreciar una comparativa entre las CPU's de la marca Intel y las GPU's de la marca Nvidia.
1 GPU: por sus siglas en ingles: Graphics Process Unit, Unidad de Procesamiento de Gráficos.
2 La representación de coma flotante, es una forma de notación científica usada en los CPU, GPU, FPU, etc., con la cual se pueden representar números reales extremadamente grandes y pequeños de una manera muy eficiente y compacta, y con la que se pueden realizar operaciones aritméticas. El estándar para la representación en coma flotante es el IEEE 754.
3 En sistemas operativos, un hilo de ejecución, hebra o subproceso es la unidad de procesamiento más pequeña que puede ser planificada por un sistema operativo.
Figura 1. Gráfica comparativa sobre la cantidad de operaciones en punto flotante que puede realizar un CPU vs GPU.
Programación e Implementación de un Clúster de GPU's
5
En esta imagen podemos destacar varios puntos: Para Enero de 2003 las capacidades de cálculo de los procesadores y las GPU's eran casi las mismas.
En Junio de 2003 comienza un despegue entre las capacidades de computo de los procesadores convencionales y las unidades de procesamiento gráficos, aunque estas capacidades no rebasan los 100 Gigas de operaciones de coma flotante por segundo (GFLOPS).
Entre los años 2006 y 2007 la tecnología de las GPU's, supera en amplio rango el número de operaciones sobre números en coma flotante, aun compitiendo contra los procesadores de última tecnología de la época.
2. Programación para GPU's y el nuevo reto: GPGPU
En un principio la programación en las GPU's se llevaba a cabo mediante interrupciones de hardware4 realizadas en el BIOS5 de las computadoras donde estaban instaladas. Tiempo después, esta programación se realizaba utilizando el lenguaje ensamblador específico para cada modelo de GPU, lo que llevaba a tener que aprender con cada modelo de GPU un nuevo conjunto de instrucciones máquina. Esta forma de programación fue reemplazada al crearse un conjunto de interfaces de programación de aplicaciones, llamadas en el ámbito de la programación como: API's. Las API’s ayudaron a manipular las diferentes características de las tarjetas gráficas, teniendo así un lenguaje de programación más homogéneo; entre las API's más importantes desarrolladas están OpenGL (Open Graphics Languaje) y DirectX.
Posterior al desarrollo de las API's de programación, surgieron lenguajes de programación orientados puramente a la manipulación de gráficos; en este tipo de lenguajes el programador separaba la lógica de la aplicación en dos partes: la que se procesaba fuera de la GPU y la que se ejecutaba directamente en el dispositivo de gráficos; algunos lenguajes de programación destacados son: OpenGL Shading Languaje (GLSL), C for Graphics (Cg), y High Level Shading Languaje (HLSL).
Pero quizás el desarrollo más importante sobre GPU's, se ha dado en los últimos años con el nuevo concepto: Cómputo de propósito general sobre Unidades de procesamiento de gráficos (General- Purpose Computing on Graphics Processing Units, GPGPU); un modelo de programación paralela reciente dentro de la informática, que intenta aprovechar todas las capacidades de cómputo de las GPU's con la finalidad de resolver problemas que por su naturaleza demandan un alto poder de cálculo. Para dicho propósito se han desarrollado
4 Interrupción (también conocida como interrupción de hardware o petición de interrupción): es una señal recibida por el procesador de un ordenador, indicando que debe "interrumpir" el curso de ejecución actual y pasar a ejecutar código específico para tratar esta situación.
5 El BIOS (sigla en inglés de basic input/output system; en español «sistema básico de entrada y salida») es un tipo de firmware que localiza y prepara los componentes electrónicos o periféricos de una máquina, para comunicarlos con algún sistema operativo que la gobernará.
Programación e Implementación de un Clúster de GPU's
6
diferentes lenguajes de programación como son: BrookGPU desarrollado en la universidad de Stanford; Sh un lenguaje de programación derivado de C++ y orientado a objetos; OpenCL (Open Computing Languaje) un ambicioso proyecto que busca unificar tanto el poder de cálculo de las GPU's como el del CPU para resolver problemas de alta demanda y por último el lenguaje de programación desarrollado por la empresa NVIDIA para programar sus propias GPU's: CUDA (Compute Unified Device Architecture), siendo este último, un lenguaje de programación relevante, ya que sus características lo hacen ideal para desarrollar aplicaciones altamente paralelas sobre GPU's nVidia.
3. Modelos de programación en GPU’s
Existen varios modelos de programación como arquitecturas de computadoras, de acuerdo a la taxonomía de Flynn propuesta por Michael J. Flynn en 1972. Se define una clasificación de modelos que se adaptan a distintas arquitecturas:
Taxonomía de Flynn Una
instrucción
Múltiples instrucciones
Un dato SISD MISD
Múltiples datos SIMD MIMD
Single Instruction Single Data (SISD): Maquina monoprocesador que ejecuta instrucciones de manera secuencial.
Multiple Instruction Single Data (MISD): Paralelismo redundante, sistemas de respaldo.
Single Instruction Multiple Data (SIMD): Se explotan varios flujos de datos dentro de un único flujo de instrucciones.
Multiple Instruction Multiple Data (MIMD): Varios procesadores autónomos que ejecutan simultáneamente diferentes instrucciones con diferentes datos.
De esta clasificación se deriva una extensión común a esta taxonomía, que modela de manera intrínseca la programación en una GPU debido a la arquitectura que la conforma. Dicha clasificación se define como Single Program Multiple Data (SPMD), donde múltiples unidades de proceso autónomas e independientes, trabajan simultáneamente sobre el mismo conjunto de instrucciones (aunque en puntos independientes). Es decir, todos los hilos (unidad mínima de proceso) que son lanzados por la GPU, ejecutan el mismo programa con datos diferentes (aunque nada impide que puedan ser los mismos).
Programación e Implementación de un Clúster de GPU's
7
4. Requisitos en Hardware de una PC para la instalación
de un GPU
Para instalar exitosamente un dispositivo de gráficos GPU, será necesario que el equipo de cómputo con el que contemos, específicamente la tarjeta madre, posea una ranura del tipo PCIe x16; se recomienda que la fuente de poder instalada sea de alto rendimiento y que posea los conectores necesarios para alimentar la GPU (aunque no todas las GPU’s requieren alimentación eléctrica). También es deseable que el gabinete donde se desee instalar el dispositivo cuente con la suficiente capacidad de enfriamiento, ya que el trabajo intensivo sobre los dispositivos genera una cantidad de calor considerable.
La siguiente imagen muestra el tipo de ranura PCI-E 16X, la cual es usada mayormente para conectar tarjetas gráficas. PCI Express en 2006 es percibido como un estándar de las placas base para PC, especialmente en tarjetas gráficas. Marcas como ATI Technologies y nVidia entre otras trabajan sobre esta arquitectura.
Programación e Implementación de un Clúster de GPU's
8
5. La Tecnología GPU y CUDA
Los GPU's de la empresa nVidia tienen dos características esenciales: por un lado son Multicore, esto es que en la misma tarjeta GPU tenemos varios (incluso cientos) de procesadores; por otro lado son Multihilo, es decir, se pueden lanzar cientos o miles de hilos, trabajando en un mismo instante para lograr un objetivo común. Dependiendo de las características del GPU, se puede tener hasta un rendimiento de 470 Giga Flops6, es decir 470 billones de operaciones sobre números reales (representados en coma flotante) en un segundo.
En noviembre de 2006, nVIDIA introdujo CUDA, una arquitectura de computación paralela de propósito general que aprovecha el motor de cálculo paralelo de las GPU nVIDIA para resolver muchos problemas complejos de computación de una manera más eficiente que en una CPU convencional.
CUDA viene con un entorno de software que permite a los desarrolladores usar una extensión del lenguaje C y C++ para programar algoritmos que correrán sobre una GPU; además CUDA puede utilizarse también con los lenguajes de programación como: Python, Fortran y Java. La figura 3 muestra algunas características interesantes de la GPU nVIdia GTX 460, el objetivo del gráfico es destacar el número de procesadores en la tarjeta y la cantidad de Threads paralelos que se pueden ejecutar en este dispositivo.
6 En informática, las operaciones de coma flotante por segundo son una medida del rendimiento de una computadora, especialmente en cálculos científicos que requieren un gran uso de operaciones de coma flotante. Es más conocido su acrónimo, FLOPS, por el inglés floating point operations per second.
Programación e Implementación de un Clúster de GPU's
9
La razón principal detrás de la discrepancia en la capacidad cálculo sobre números representados en punto flotante entre la CPU y la GPU es que la GPU está especializado para realizar cálculo intensivo y paralelo sobre datos de este tipo, que es exactamente lo que está detrás del procesamiento de gráficos y por lo tanto está diseñado de tal forma que un mayor número de transistores se dedican al procesamiento de datos en lugar de caché de datos y control de flujo, como se ilustra esquemáticamente en la figura 4.
Más específicamente, la GPU es especialmente adecuada para abordar los problemas que se pueden expresar como cálculos paralelos sobre muchos datos (el mismo programa se ejecuta en muchos elementos de datos en paralelo, con una intensidad aritmética alta). Muchas aplicaciones que procesan grandes volúmenes de datos pueden utilizar un modelo de programación de datos en paralelo para acelerar los cálculos. En 3D, grandes conjuntos de píxeles y vértices se asignan a los hilos en paralelo, del mismo modo, las imágenes son renderizadas, la codificación y decodificación de video se realiza de esta misma manera, también la ampliación de imágenes, la visión estéreo, y el reconocimiento de patrones trabajan con este modelo paralelo; de hecho, muchos algoritmos fuera del campo del procesamiento de imágenes, son acelerados por el procesamiento paralelo de datos, como el procesamiento de señales en general o simulación de la física, finanzas o biología computacional.
6. GPGPU aplicado
Hemos venido destacando las prestaciones de las GPU's, pero ¿cuál es su campo de acción?, a continuación presentamos ejemplos del impacto que ha tenido en diferentes áreas esta nueva forma de hacer cómputo paralelo:
Bioinformática y Ciencias de la Vida: La secuenciación y el acoplamiento de proteínas son tareas muy intensivas en cómputo, que ven una mejora del rendimiento general mediante el uso de una GPU y el paradigma GPGPU.
Programación e Implementación de un Clúster de GPU's
10
Dinámica de Fluidos Computacional: Varios proyectos en curso sobre los modelos de Navier-Stokes y métodos Lattice Boltzman han mostrado aceleraciones muy grandes que utilizan GPU’s habilitadas con CUDA.
Minería de Datos, Análisis y Bases de Datos: Las Bases de datos son el caballo de batalla de las empresas hoy en día. Buscar a través de bases de datos y encontrar información útil se ha convertido en un reto computacional grande. Los investigadores del mundo académico y de Microsoft, Oracle, SAP, y muchas otras corporaciones están buscando en las prestaciones de las GPU's la solución a la búsqueda y recuperación de datos.
Imágenes Médicas: El tratamiento de imágenes médicas es una de las primeras aplicaciones para tomar ventaja del poder de cálculo de la GPU para obtener una mayor la aceleración en el procesamiento de información.
Dinámica Molecular: Las aplicaciones de dinámica molecular son extremadamente susceptibles a la arquitectura masivamente paralela de las GPU. Se destaca el trabajo realizado en paquetes de software como VMD, NAMD y HOOMD.
Criptografía: Se han desarrollado programas paralelos para mejorar la encriptación de datos usando algoritmos como RSA, además mediante el uso de una GPU ha sido posible romper passwords cifrados con MD5 e incluso RAR.
7. Arquitectura Física de una GPU
Tiene que ver directamente con el hardware del dispositivo, la placa de la GPU está constituida por circuitos, transistores y otros componentes que forman varias capas denominadas Multiprocessors, cada capa contiene a su vez varios procesadores denominados CUDA Cores.
Las capas Multiprocessors están conectadas mediante buses de datos con la memoria global del dispositivo, con una serie de registros y un bloque de memoria compartida por capa Multiprocessors.
Es importante mencionar que el número de capas Multiprocessors y CUDA Cores contenidos en cada capa, varía en dependencia del modelo de la GPU. A continuación se muestra un diagrama, que muestra la forma física de una GPU.
Programación e Implementación de un Clúster de GPU's
11
8. Desmenuzando CUDA
CUDA significa: “Compute Unified Device Architecture”, y es un modelo de programación de propósito general, donde el programador podrá manipular lotes de hilos concurrentes que se generan dentro de la GPU, con dichos hilos se podrá realizar calculo intensivo, con lo cual se puede ver a una GPU como un coprocesador masivamente paralelo.
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 Mac OS. Actualmente se ofrece para Windows XP/Vista/7, para Linux 32/64 bits y para Mac OS en su versión 4.0 lanzada el 5 de Junio de 201.
Programación e Implementación de un Clúster de GPU's
12
a. Instalación de CUDA
Realizaremos una instalación guiada de CUDA, para ello deberemos tener los siguientes requerimientos:
Hardware:
o GPU nVidia compatible con CUDA7.
Software:
o Sistema Operativo GNU/Linux (Esta guía basada en Centos 5.0) o Driver GPU8
(NVIDIA-Linux-x86_64-285.05.09.run) o CUDA ToolKit (cudatoolkit_4.0.17_linux_64_rhel5.5.run)9 o CUDA SDK + Examples (gpucomputingsdk_4.0.17_linux.run)
b. Instalar dependencias
Para que la instalación de todo el entorno CUDA se realizara de forma exitosa es necesario instalar una serie de librerías y aplicaciones a fin de resolver cualquier dependencia, para ello echaremos mano de una terminal:
7 Consultar la sección de Anexo para conocer la lista de GPU's nVidia compatibles con la tecnología CUDA. 8 Para la descarga del driver dirigirse a la página: http://www.nvidia.com/Download/index.aspx?lang=en-us 9 Para la descarga del SDK y del ToolKit dirigirse a la página: http://developer.nvidia.com/cuda-downloads
# CUDA necesita una serie de aplicaciones y bibliotecas previamente instaladas $ sudo yum install kernel-devel gcc-c++ freeglut freeglut-devel
Programación e Implementación de un Clúster de GPU's
13
c. Instalar el controlador del dispositivo
Iniciaremos con la instalación del controlador de la tarjeta GPU, para ello necesitamos realizarlo con el entorno X deshabilitado por completo y posteriormente instalar el driver, para ello realizaremos el siguiente procedimiento desde una terminal:
d. Instalación de CUDA Toolkit
Ahora se instalara CUDA ToolKit, se modificaran algunos archivos y se exportaran algunas variables de entorno.
# Cambiar el runlevel del entorno X al nivel 3. $ sudo vi /etc/inittab
# Cambiar: id:5:initdefault a id:3:initdefault # Guardar los cambios, salir y reiniciar el equipo.
# Cuando el equipo se reinicie, aparecerá el símbolo del sistema. # Logearse como root e instalar el driver.
$ sh NVIDIA-Linux-x86_64-285.05.09.run
# El sistema realizara la copia del controlador en el sistema.
# Instalar CUDA. Usando las configuraciones por default. $ sh cudatoolkit_4.0.17_linux_64_rhel5.5.run
# Crear el archivo: cuda.conf en el directorio: /etc/ld.so.conf.d $ vi /etc/ld.so.conf.d/cuda.conf
# Poner en dicho archivo: /usr/local/cuda/lib
# Crear un script cuda.sh in the folder /etc/profile.d con: # export LD_LIBRARY_PATH=/usr/local/cuda/lib
# export PATH=/usr/local/cuda/bin:${PATH} # Guardar los cambios en dicho archivo.
# Cambiar el nivel de ejecución del entorno X a 5 y reiniciar.
Programación e Implementación de un Clúster de GPU's
14
e. Instalar CUDA SDK
Ahora tendremos que instalar el kit de desarrollo, compilar los ejemplos y comprobar que el compilador de CUDA (nvcc), funciona.
f. Prueba de la instalación
Si todo fue bien estamos listos para ejecutar alguno de los ejemplos incluidos con CUDA, para ello deberemos dirigirnos a la carpeta donde se encuentran, si se realizó la instalación con los valores por defecto dicho directorio se encuentra en la siguiente ruta:
# Instalar CUDA SDK
$ sh gpucomputingsdk_4.0.17_linux.run # Compilar.
$ cd ~/NVIDIA_GPU_Computing_SDK/C $ make
# Al terminar los binarios se encontraran en:
~/NVIDIA_GPU_Computing_SDK/C/bin/linux/release # Verificar la instalación:
$ nvcc –version
# La salida en pantalla deberá ser algo similar a: $ nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2011 NVIDIA Corporation Built on Thu_May_12_11:09:45_PDT_2011
Cuda compilation tools, release 4.0, V0.2.1221
Programación e Implementación de un Clúster de GPU's
15
Aquí proponemos ejecutar al menos dos aplicaciones ejemplo, el primero llamado: “DeviceQuery” (Figura 6), el cual realiza la consulta de las GPU's instaladas en el sistema y despliega las características de cada GPU; el segundo programa recomendado es “Ocean”, el cual muestra en pantalla la simulación del océano, este último programa es impresionante ya que la simulación del agua es bastante realista (Figura 7).
Figura 6. Salida en pantalla de la aplicación de ejemplo: Device Query
Programación e Implementación de un Clúster de GPU's
16
9. CUDA, un modelo de programación de propósito
general
A continuación se presenta los conceptos principales detrás del modelo de programación CUDA, los cuales ayudaran a entender en profundidad dicha tecnología.
a. Host y Device
En el ámbito de CUDA, llamaremos HOST a la unidad de procesamiento central o CPU alojada en nuestro sistema, mientras que llamaremos DEVICE a nuestro dispositivo GPU instalado en el mismo.
b. Los CUDA Threads y su Jerarquía dentro de la GPU
La unidad mínima de ejecución en CUDA es el Thread (al cual le llamaremos CUDA Thread para hacer distinción del Thread generado por una CPU.), Los Cuda Thread son agrupados por eficiencia en un lotes denominados Block; cuando tenemos un conjunto de Bloques a este se le denomina Grid. La figura 8 muestra esta jerarquía:
Programación e Implementación de un Clúster de GPU's
17
c. Jerarquía de Memoria
Un CUDA Thread puede acceder a distintos espacios de memoria durante su ejecución; como se muestra en la figura 9, cada CUDA Thread tiene una memoria local que es privada. Cada Block tiene a su vez una porción de memoria compartida visible para todos los CUDA Threads del mismo Block y con el mismo tiempo de vida que el Block. Todos los hilos además, tienen acceso a la memoria global del Device. Existen además dos espacios de memoria que son de solo lectura, accesibles por todos los hilos: La memoria constante y la memoria de textura las cuales son usadas para propósitos específicos en la generación de gráficos. La memoria global, constante y de textura es persistentes a lo largo de la ejecución de un programa en CUDA.
Figura 9. Organización de las diferentes capas de memoria que posee una GPU nVidia y el nivel de acceso que cada Thread posee.
Programación e Implementación de un Clúster de GPU's
18
La siguiente tabla muestra una breve descripción de cada tipo de memoria:
Memoria local del Thread Esta memoria solo es accedida por el propio thread
en ejecución, su ámbito de vida dura lo que dura el propio thread en ejecución, además de ser de rápido acceso.
Memoria Compartida La memoria compartida es usada por los
CUDA Threads dentro de un block, y su ámbito de vida dura lo que dura la ejecución
del block.
Memoria Constante Esta memoria es de solo lectura y puede ser
accedida por todos los hilos en ejecución además de ser es de rápido acceso.
Memoria de textura Esta memoria es de solo lectura y puede ser
accedida por todos los hilos en ejecución además de ser es de rápido acceso.
Memoria Global del GPU La memoria global es el agente de comunicación
intermediario entre la CPU y la GPU, esto significa que un hilo o proceso de la CPU pude escribir en la memoria de la GPU, además de que todos los hilos de la GPU pueden acceder a ella, es una memoria de lectura y escritura tipo RAM, por lo que su acceso es costoso ya que es más lenta que los otros tipos de memoria ya mencionados por ser de tipo chache.
d. Kernels
CUDA extiende del lenguaje de programación C, lo cual permite al programador definir funciones especiales que tendrán algún comportamiento en particular; en CUDA se le denomina:
Kernel a una función que se ejecuta N veces en paralelo por N diferentes CUDA Threads
exclusivamente dentro del GPU (o Device en el ámbito CUDA).
Un Kernel se define usando el especificador: __global__ en la declaración de la función; cuando se realiza el llamado a dicha función dentro del cuerpo del programa, esta llamada se realiza junto con una configuración de ejecución o arranque: <<<...>>>, esta nueva sintaxis indica el número de CUDA Threads, que, de forma paralela ejecutaran la función kernel. A cada CUDA Thread en el instante mismo de su creación y ejecución se le es asignado un identificador único de hilo, almacenado en una variable especial llamada threadIdx mediante la cual es posible acceder al thread en todo momento durante su periodo de ejecución.
Programación e Implementación de un Clúster de GPU's
19
e. Thread Id: Un paso más allá
Por conveniencia threadIdx es un vector de 3 dimensiones, por lo que un CUDA Thread se puede identificar de forma unidimensional, bidimensional o tridimensional; formando de tal manera un Block unidimensional, bidimensional o tridimensional, esto proporciona una forma natural a la hora de invocar los CUDA Threads en un problema cuyo dominio sea un vector, una matriz o un volumen.
El índice de un hilo dentro de un bloque y su ID se relacionan entre si de una manera sencilla: para un bloque unidimensional son iguales; para un bloque de dos dimensiones de tamaño (Dx, Dy), el ID de un CUDA Thread de índice (x, y) es (x + y Dx); para un bloque tridimensional de tamaño (Dx, Dy, Dz), el ID del CUDA Thread de índice (x, y, z) es (x + y Dx + z Dx Dy).
Existe un límite para el número de CUDA Threads por Block, ya que se espera que todos los hilos de un bloque residan en el núcleo del procesador mismo por lo que deberán compartir los recursos de memoria. En las GPU's actuales, un Block de CUDA Threads puede contener hasta 1024 de estos.
Sin embargo, un Kernel puede ser ejecutado por múltiples bloques de CUDA Threads de forma que, por lo que el número total de CUDA Threads en un instante de ejecución es igual al número de CUDA Threads por Bloque, por el número de bloques lanzados.
Los bloques también se organizan de manera unidimensional, en dos y hasta en tres dimensiones. Por lo tanto el número de bloques de CUDA Threads en un GRID está generalmente determinado por el tamaño de los datos que están siendo procesados.
El número de Hilos (CUDA Threads) por Bloque y el número de Bloques por Grid son especificados en la llamada a la función Kernel dentro de la sintaxis: <<< >>>, estos números puede ser de tipo int o dim3 (tres dimensiones).
Cada bloque dentro del GRID pueden ser identificados dentro del Kernel lanzado mediante un índice unidimensional, bidimensional, o tridimensional, este índice se obtiene a través de la variable: blockIdx. La dimensión del bloque es accesible dentro del Kernel a través de otra variable llamada: blockDim.
f. Sincronizando los Threads
Los CUDA Threads dentro de un bloque pueden cooperar entre sí mediante el intercambio de datos a través de la memoria compartida y mediante la sincronización de su ejecución para coordinar los accesos a esta memoria. Para ser más precisos, se puede especificar dentro de un programa puntos de sincronización con el llamando a la función: __syncthreads(); dicha función actúa como una barrera, es decir: todos y cada uno de los CUDA Thread que ejecuten esta función dentro de algún programa, deberán detenerse en ese punto (el punto de invocación) sin poder ejecutar las siguientes líneas de código hasta que todos los restantes CUDA Thread's hayan alcanzado esa barrera.
Programación e Implementación de un Clúster de GPU's
20
g. CUDA: Una programación Heterogénea
El modelo de programación CUDA asume que los CUDA Threads se ejecutan en un Device separado físicamente, dicho dispositivo funciona como un coprocesador para el Host, el cual se encarga de ejecutar un programa escrito en lenguaje C. Por ejemplo, cuando los Kernel's se ejecutan en una GPU y el resto del programa C se ejecuta en una CPU.
El modelo de programación CUDA también asume que tanto el Host y el Device administran sus propios espacios de memoria. Por lo tanto, un programa gestiona los espacios de memoria global, constante, y la textura visible para los Kernel's a través de llamadas en tiempo de ejecución de funciones CUDA. Esto incluye la asignación de memoria del Device y su liberación, así como la transferencia de datos entre el Host y la memoria del Device.
La siguiente figura muestra el flujo de ejecución de un programa escrito en CUDA, en dicha aplicación se encuentra código que es ejecutado por el Host (CPU) y código paralelo que es ejecutado por el Device (GPU).
Programación e Implementación de un Clúster de GPU's
21
h. El proceso de compilación
El proceso de compilación de un programa en CUDA se lleva a cabo en dos etapas diferentes; la primera de ellas es independiente de la GPU, en esta etapa se separan las secciones de código que se ejecutaran tanto en el CPU como en la tarjeta gráfica, se genera un código llamado PTX (Parallel Thread eXecution) el cual es el que contiene el conjunto de instrucciones a ejecutar en el dispositivo GPU. La segunda etapa del proceso de compilación está enfocado directamente a la generación de un código objeto para una GPU concreta.
NVCC es e driver de compilación que se encarga de monitorizar la llamada a todos los compiladores y herramientas CUDA: cudac, g++, etc. El ejecutable Cuda usa dos bibliotecas dinámicas: Cudart, el cual es la biblioteca de tiempo de ejecución de CUDA y Cuda, el cual es la biblioteca de control de los núcleos del dispositivo GPU. La siguiente imagen muestra las dos fases del proceso de compilación: La etapa Virtual y la etapa Física.
Figura 11. Etapas de compilación de un programa en CUDA y su separación en diferentes códigos objeto.
Programación e Implementación de un Clúster de GPU's
22
10. Programación en CUDA
Estamos listos para comenzar a realizar algunos programas en CUDA, y así, aprovechar la capacidad de computo de los dispositivos GPU's.
a. Suma de dos Números con CUDA
Realicemos un programa sencillo: Necesitamos sumar dos números enteros y almacenarlos en una variable y luego mostrar el resultado, para ello es necesario llevar dos números enteros a y
b a la memoria de la GPU y luego realizar ahí la suma almacenándola en una variable: dev_c que
deberá vivir en la memoria del GPU, por lo cual será necesario reservar memoria en nuestro dispositivo de gráficos, realizar la suma y posteriormente transferir dicho resultado a la memoria del CPU para mostrarlo. Dicho procedimiento necesita de operaciones para reservar memoria, y para realizar la transferencia de datos entre el CPU y el GPU en ambos sentidos.
Analicemos el siguiente código.
// Programa: Suma de dos números en una GPU. #include <stdio.h>
// Función Kernel, la cual se ejecutara en el GPU
__global__ void sumaNumeros( int a, int b, int *c )
{
// Se realiza la suma y se almacena en una variable. *c = a + b;
}
int main( void ) {
int c; // Resultado en el CPU int *dev_c; // Resultado en el GPU
// Se reserva memoria en el GPU para almacenar el resultado. cudaMalloc ((void**)&dev_c, sizeof(int));
// Se invoca a la función Kernel, pasándole como parámetros: // Los valores a sumar y la variable resultado.
sumaNumeros<<<1,1>>>( 2, 7, dev_c ); // Transferimos dicho resultado
cudaMemcpy ( &c, dev_c, sizeof(int), cudaMemcpyDeviceToHost ); // Se imprime el resultado en pantalla
printf( "2 + 7 = %d\n", c );
// Se libera la memoria reservada en el GPU. cudaFree( dev_c );
return 0; }
Programación e Implementación de un Clúster de GPU's
23
Notemos que este código en CUDA no distingue mucho de algún código desarrollado en lenguaje C, sin embargo existen algunas funciones nuevas que describiremos a continuación:
cudaMalloc(): Reserva memoria en el dispositivo GPU.
cudaMemcpy(): Realiza la copia o transferencia de datos entre el CPU y el GPU, existen cuatro flujos posibles de transferencia de datos, los cuales son: cudaMemcpyHostToHost, cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost y cudaMemcpyDeviceToDevice.
cudaFree(): Libera la memoria reservada en el GPU.
Al compilar y ejecutar el código, el resultado es el esperado:
Programación e Implementación de un Clúster de GPU's
24
b. Suma de dos vectores con CUDA
Retomemos el ejemplo anterior, y pensemos en dos arreglos de igual tamaño, cada uno con N elementos del mismo tipo; suponga además que se desea sumar cada elemento del arreglo A con su correspondiente elemento del arreglo B y dejar el resultado en un tercer arreglo C, obviamente del mismo tamaño que los anteriores; todo esto de tal forma que las operaciones de suma elemento a elemento se realicen de forma independiente y concurrentemente.
Para realizar esta tarea es necesario diseñar una función que pueda ejecutarse de forma independiente, es decir, aislada de todo el flujo del programa; Esta función se ejecutara de forma paralela tomando las direcciones de memoria donde se encuentran alojados los vectores A y B, a continuación se generan tantos Cuda Threads como el tamaño de los vectores. Estos hilos “especiales” entran en contexto y mediante un índice único dentro de todo del dispositivo GPU, se puede controlar que cada Cuda Thread, acceda de forma independiente a una localidad de memoria del vector A, posteriormente sume el elemento alojado ahí con el elemento que encuentre en la misma posición del vector B, para finalmente dejar la suma de ambos dentro del arreglo C. Cuestiones de sincronización y posible corrupción de datos al manipular información de forma paralela se descartan, ya que se garantiza que cada hilo accede inequívocamente a una y solo una localidad de memoria; el tema de la sincronización se toca más adelante.
Programación e Implementación de un Clúster de GPU's
25
A continuación se muestra el código completo del programa. // Programa: Suma de Dos Vectores en CUDA
#include <stdio.h> #define N 100
int main(void){
int a[N], b[N], c[N]; int *dev_a, *dev_b, *dev_c;
cudaMalloc((void**)&dev_a, N * sizeof(int)); cudaMalloc((void**)&dev_b, N * sizeof(int)); cudaMalloc((void**)&dev_c, N * sizeof(int)); // Llenar los array: 'a' y 'b' en el CPU. for (int i=0; i<N; i++) {
a[i] = i; b[i] = i; }
// Copiar los array: 'a' y 'b' a la memoria del GPU
cudaMemcpy( dev_a, a, N * sizeof(int), cudaMemcpyHostToDevice ); cudaMemcpy( dev_b, b, N * sizeof(int), cudaMemcpyHostToDevice ); // EJECUTAR EL KERNEL CON 1 BLOQUE Y N HILOS
add<<<1,N>>>( dev_a, dev_b, dev_c);
// Después de ejecutado el Kernel. Regresar el Resultado. // Copiar el array: 'c' desde el GPU al CPU.
cudaMemcpy( c, dev_c, N * sizeof (int), cudaMemcpyDeviceToHost); // Imprimir los resultados.
for (int i=0; i<N; i++){
printf( " [ %d + %d = %d ] \n",a[i], b[i], c[i] ); } // Liberamos la memoria. cudaFree( dev_a ); cudaFree( dev_b ); cudaFree( dev_c ); return 0; }
// La función principal Kernel.
__global__ void add( int *a, int *b, int *c ){ int tid = threadIdx.x;
if (tid < N)
c[tid]= a[tid] + b[tid]; }
Programación e Implementación de un Clúster de GPU's
26
El programa anterior reserva en memoria del CPU tres arreglos de tamaño N, referidos mediante las variables: a, b y c. Posteriormente genera apuntadores a memoria mediante las variables: dev_a, dev_b y dev_c; dichos apuntadores refieren a una porción de memoria de tamaño N * int, Esta porción de memoria se reserva mediante la instrucción cudaMalloc. Posteriormente se realiza el llenado de los vectores a, b, c que se encuentran almacenados en la memoria del CPU, una vez realizado esto, se procede al copiado de información del CPU al GPU mediante la instrucción: cudaMemcpy. Cuando los datos se encuentran en la memoria del device GPU, es entonces cuando se lanza el Kernel paralelo, la configuración de este Kernel es de 1 Bloque con N Hilos. Cuando el Kernel haya terminado, será necesario copiar el vector resultado en la dirección contraria a la primera transferencia de información, es decir: del GPU hacia el CPU, nuevamente con la instrucción cudaMemcpy. Una vez realizado esto, bastara con recorrer cada elemento del arreglo c e imprimirlo en pantalla para corroborar que la suma de los elementos fue correcta.
c. Array CudaID
En términos simples, el siguiente programa crea e inicializa en ceros un arreglo en la memoria del Host (CPU), transfiere una copia de dicho arreglo a la memoria global del Device (GPU) donde es indexado nuevamente por CudaThreads, es decir: se generan tantos CudaThreads como tamaño del arreglo, cada CudaThread deposita el valor de su identificador en la misma localidad del arreglo. Al final se transfiere una copia de este arreglo hacia la memoria del Host para desplegarlo pantalla y verificar el resultado.
Programación e Implementación de un Clúster de GPU's
27
El programa ilustra una forma de obtener para cada CudaThread de diferente bloque, un identificador unidimensional único. Para poder comprender esta idea se necesita saber que los CudaThreads y Blocks pueden ser identificados en una dimensión. Ver Figura15 y Figura16.
Figura 15. Identificación en una dimensión del los Blocks dentro de un Grid
Programación e Implementación de un Clúster de GPU's
28
De esta forma es muy sencillo sincronizar CudaThreads de diferentes Blocks, veamos el siguiente ejemplo:
Supongamos que tenemos 2 Blocks con 15 Cuda Threads en cada uno.
Para saber el identificador en una dimensión de cada Block se una la propiedad blockIdx.x , análogamente para los Cuda Threads se una threadIdx.x
Tomando en cuenta que para saber el número de CudaThreads que hay dentro de un Block se conoce la propiedad blockDim
La siguiente formula demuestra la forma en que se obtiene un identificador unidimensional para cada Cuda Thread:
id = BlockIdx.x * blockDim + threadIdx.x
De esta forma utilizaremos todos los CudaThreads de forma lineal; comencemos identificando los CudaThreads del primer Block según la fórmula, en este caso blockDim = 15
Para el CudaThread 0: id = ( 0 ) * ( 15 ) + ( 0 ) = 0 Para el CudaThread 1: id = ( 0 ) * ( 15 ) + ( 1 ) = 1 Para el CudaThread 14: id = ( 0 ) * ( 15 ) + ( 14 ) = 14
Para el segundo Block tenemos:
Para el CudaThread 0: id = ( 1 ) * ( 15 ) + ( 0 ) = 15 Para el CudaThread 1: id = ( 1 ) * ( 15 ) + ( 1 ) = 16 Para el CudaThread 14: id = ( 1 ) * ( 15 ) + ( 14 ) = 29
Así tenemos una serie lineal de identificadores para cada Cuda Thread independientemente del bloque en el que se encuentren.
Programación e Implementación de un Clúster de GPU's
29
// Programa: Array CudaId#include<stdio.h> #define N 1000 //Función Principal int main(void) { int arreglo[N]; int *dev_arreglo; int numBloques; int tamBloques; // Inicializamos el arreglo. for (int i=0 ; i<N ; i++) arreglo[i]=0;
// Reservamos memoria en el Device(GPU) para almacenar el arreglo. cudaMalloc( (void**) &dev_arreglo, N * sizeof(int) );
// Copiamos el arreglo al Device(GPU)
cudaMemcpy( dev_arreglo, arreglo, N*sizeof(int), cudaMemcpyHostToDevice); // Deseamos tener 20 hilos por bloque
tamBloques = 20;
numBloques = N/tamBloques;
//Invocamos al kernel.
kernel<<<numBloques, tamBloques>>>(dev_arreglo);
// Copiamos el arreglo indexado por los Threads al Host(CPU)
cudaMemcpy( &arreglo, dev_arreglo, N * sizeof(int), cudaMemcpyDeviceToHost); // Desplegamos el arreglo
printf("El Arreglo es: \n\n"); for (int i=0 ; i<N ; i++)
printf(" [ %i ] -> %d \n",i, arreglo[i]); printf("\n");
//Liberamos memoria
cudaFree(dev_arreglo); return 0;
}
// Definición de la función Kernel que se ejecuta en el Device(GPU) __global__ void kernel(int *array)
{
int id = blockIdx.x * blockDim.x + threadIdx.x; array[id]=id;
Programación e Implementación de un Clúster de GPU's
30
d. Reducción de un Vector con Memoria Compartida
Suponga que se tiene un vector lineal de tamaño N y que se desea sumar todos y cada uno de los elementos almacenados en dicho arreglo; la primera idea para resolver el problema usando CUDA seria generar tantos hilos como el tamaño del arreglo y que cada hilo sumara el elemento del arreglo que corresponda con su id a una variable, si nos detenemos a pensar un momento esta situación, es probable que exista corrupción de datos al no existir una sincronización correcta en la manipulación de la variable que almacenara el resultado final. Aquí se propone el siguiente método para realizar la tarea de forma concurrente, tomando en cuenta la situación de la probable corrupción de datos.
El siguiente programa hace uso de la memoria compartida, a la cual podrán acceder todos los hilos que pertenezcan al mismo bloque; en primera instancia, se genera un arreglo en memoria compartida de tamaño N, posteriormente se generan N hilos, los cuales depositaran el valor de su identificador en la misma localidad de memoria del vector. Es necesario esperar a que todos los hilos depositen el valor de su identificador antes de comenzar a realizar la suma de los elementos, para garantizar esto, se hace uso de la instrucción: __syncthreads(). Una vez garantizada la correcta inicialización del vector, nuestro algoritmo propone hacer uso solamente de la mitad de los cuda threads creados, para ello cada cuda thread tendrá que sumar dos posiciones de memoria del arreglo, el que corresponde a su identificador (idThread) y el que corresponde a la posición [ (N/2) + idThread ], dejando el resultado de esta suma parcial en la localidad correspondiente a su identificador. Con este algoritmo en cada vuelta se van prescindiendo de mas cuda Threads, al final se tendrá en la posición 0 del vector el resultado final, solo habrá que trasladarla a la memoria del CPU para mostrarla en pantalla; también en cada vuelta será necesario utilizar la sincronización para garantizar que todos los hilos hayan sumado sus correspondientes localidades.
La siguiente imagen muestra el desarrollo del algoritmo, indicando que en cada paso habrá que hacer uso del llamado a __syncthreads(), el cual como ya se ha mencionado, actúa como una barrera para los Cuda Threads, impidiendo que se ejecute un bloque de instrucciones hasta que todos los hilos activos lleguen al punto de invocación de la barrera.
Programación e Implementación de un Clúster de GPU's
31
El código del programa se muestra a continuación.#include<iostream> #define N 256
////////////////////////////////////////////////// // Función que se ejecutara en el Device (GPU) // ////////////////////////////////////////////////// __global__ void reduce(float* total)
{
// Arreglo de tamaño N en Memoria Compartida. __shared__ float array[N];
// Obtenemos el Identificador del Hilo. int id = threadIdx.x;
// Inicializamos el arreglo. if(id < N)
array[id]=id; __syncthreads();
// Esperamos a que todos los hilos lleguen hasta este punto. // Se suman los elementos del arreglo.
int i = N/2; // Solo usaremos la mitad de los hilos. while (i != 0)
{
if (id < i)
array[id] += array[id + i];
__syncthreads(); // Esperamos a todos los threads hasta este punto. i /= 2
}
if (id == 0) // El hilo cero se encarga de almacenar el resultado final. *total=array[0];
}
int main( void ) {
// Resultado de la suma en el Host (CPU) float host_total=0;
// Resultado de la suma en el Device (GPU) float *dev_total;
// Reservamos memoria en el Device (GPU) para la suma total. cudaMalloc((void**)&dev_total, sizeof(float));
// Invocamos a la función Kernel, con 1 Bloque y N Hilos. reduce<<<1,N>>>(dev_total);
// Copiamos la suma total del Device al Host.
cudaMemcpy( &host_total, dev_total, sizeof(float),cudaMemcpyDeviceToHost ); // Mostramos el resultado en Pantalla.
printf("La suma es: %f \n",host_total); // Liberamos la memoria.
cudaFree(dev_total); return 0;
Programación e Implementación de un Clúster de GPU's
32
e. Inicialización de un arreglo usando memoria de
textura
A continuación se muestra el uso de la memoria de textura la cual era utilizada originalmente antes de la programación de propósito general, para la declaración de estructuras denominadas texturas que son vectores cuyos datos representan color, profundidad, ancho de línea o tamaño de punto y son utilizados en la programación y manipulación de imágenes que pueden ser referenciados en una, dos o tres dimensiones, mejorando el rendimiento y la velocidad de acceso por los CudaThreads en aplicaciones graficas puesto que como ya hemos mencionado esta memoria de tipo cache.
Cuda implementa una serie de funciones con las cuales podemos hacer uso de la memoria de textura:
texture<float> : Declaración de una variable que residirá en la memoria de textura.
cudaBindTexture () : Función que mapea datos en un espacio de memoria de textura.
cudaBindTexture2D() : Mapea datos en la memoria de textura, son referenciados en espacio de memoria en dos dimensiones.
cudaUnbindTexture() : Función que des referencia los datos, liberándolo de la memoria.
tex1Dfetch() : Realiza una búsqueda sin filtros en textura usando coordenadas . El nivel de detalle es proporcionado por el último componente del vector de coordenadas.
La siguiente aplicación hace uso de la memoria de textura para realizar una tarea específica; la idea general de este programa es crear e inicializar un arreglo en la memoria del CPU, transferir dicho arreglo a la memoria global de GPU. Crear una variable en la memoria de textura y copiar el valor de la variable en cada casilla del arreglo. En la GPU son lanzados tantos CudaThreads como tamaño del arreglo, cada CudaThread copia el valor de la variable de textura e inicializa cada casilla correspondiente del arreglo en la memoria global de la GPU.
Programación e Implementación de un Clúster de GPU's
33
A continuación se muestra el código del programa// Programa: Inicialización de un arreglo usando memoria de textura #include<stdio.h> #define N 10 texture<float> textVar; int main(){ float arreglo[N]; float *dev_arreglo; float *dev_textVar; float valor=111; // Inicializamos el Arreglo. for (int i=0 ; i<N ; i++) arreglo[i]=1;
// Reservamos memoria en el GPU para almacenar el arreglo. cudaMalloc( (void**) &dev_arreglo, N * sizeof(float) ); // Copiamos el arreglo al device.
cudaMemcpy(dev_arreglo,arreglo,N*sizeof(float),cudaMemcpyHostToDevice); //Reservamos memoria para la variable de textura
cudaMalloc( (void**) &dev_textVar, sizeof(float) ); //copiamos el valor de la variable de text
cudaMemcpy(dev_textVar,&valor,sizeof(float),cudaMemcpyHostToDevice); //Indicamos q nuestra variable es de textura
cudaBindTexture( NULL, textVar, dev_textVar, sizeof(float)); //Llamada al kernel
textura<<<1,N>>>(dev_arreglo);
//Copiamos el arreglo, de regreso al CPU
cudaMemcpy(arreglo,dev_arreglo,N*sizeof(float),cudaMemcpyDeviceToHost); //Desplegamos el arreglo for (int j=0 ; j<N ; j++) printf("\n[%d] --> %f",j,arreglo[j]); printf("\n"); //Liberamos memoria cudaFree(dev_arreglo); cudaFree(dev_textVar); cudaUnbindTexture( textVar ); } //Kernel
__global__ void textura(float *array){ int id = threadIdx.x;
array[id] = tex1Dfetch(textVar,0); __syncthreads();
Programación e Implementación de un Clúster de GPU's
34
f. Inicialización de un arreglo usando memoria
constante
Existe otra memoria dentro de la GPU llamada memoria constante, esta memoria es accedida por todos los CudaThreads en ejecución, sirve como memoria auxiliar en la implementación de técnicas de renderización y aplicaciones gráficas.
De igual forma que para la memoria de textura, existen funciones para manipular el uso de la memoria constante.
__constant__ : Calificador de tipo de variable, la variable definida reside en la memoria constante, su tiempo de vida es el de la aplicación y es accedida por todos los CudaThreads.
cudaMemcpyToSymbol() : Copia un área de memoria del Host a un espacio de memoria global o constante.
El siguiente programa ilustra un posible uso de esta memoria; se crea e inicializa un arreglo de tamaño N en la memoria del CPU que es transferido a la memoria global de la GPU. De igual forma es creado otro arreglo y transferido a la memoria constante de la GPU. En la GPU son lanzados tantos CudaThreads como tamaño del arreglo que se encargan de mapear el valor de las casillas del arreglo en memoria constante al arreglo en memoria global.
Programación e Implementación de un Clúster de GPU's
35
A continuación se muestra el código del programa// Programa: Inicialización de un arreglo usando memoria constante #include<cuda_runtime.h> #include<cuda.h> #include<stdio.h> #include<stdlib.h> const int N=10;
__constant__ float const[N]; int main(){
int arreglo[N]; int *dev_arreglo;
float valor[N];
// Inicializamos el Arreglo. for (int i=0 ; i<N ; i++) arreglo[i]=1;
// Reservamos memoria en el GPU para almacenar el arreglo. cudaMalloc( (void**) &dev_arreglo, N * sizeof(int) ); // Copiamos el arreglo al device.
cudaMemcpy(dev_arreglo,arreglo,N*sizeof(int),cudaMemcpyHostToDevice); //Reservamos memoria en el GPU para el valor de la memoria constante cudaMalloc((void**)&dev_valor, sizeof(int));
//Copiamos a la memoria global de la GPU el valor
cudaMemcpy(dev_valor,&valor,sizeof(int),cudaMemcpyHostToDevice); //Copiamos a memoria constante
cudaMemcpyToSymbol(const,valor, N * sizeof(float)); //Llamada al kernel
constante<<<1,N>>>(dev_arreglo);
//Copiamos el arreglo, de regreso al CPU
cudaMemcpy(arreglo,dev_arreglo,N*sizeof(int),cudaMemcpyDeviceToHost); //Desplegamos el arreglo for (int j=0 ; j<N ; j++) printf("\n[%d] --> %d",j,arreglo[j]); printf("\n"); //Liberamos memoria cudaFree(dev_arreglo); cudaFree(const); } //Kernel
__global__ void constante(int *array){ int id = threadIdx.x;
array[id] = const[id]; __syncthreads(); }
Programación e Implementación de un Clúster de GPU's
36
g. Inicialización de un arreglo usando pthreads
Los Pthreads son definidos por el estándar POSIX, es un thread (hilo o proceso ligero) que fue normalizado por los estándares y tiene la propiedad de ser dinámico, ya que se puede crear en cualquier instante de tiempo. Su funcionalidad es ejecutar una tarea o sección de código independiente de la aplicación, es decir que lleva un nivel de paralelismo donde con múltiples pthreads se puede hacer más de una tarea a la vez.
Pthread es una estructura transparente al usuario y su particularidad puede ser manipulada por un conjunto de funciones; a continuación se muestran varias de ellas.
Pthread_create : Crea un pthread con una tarea determinada.
Pthread_exit: Causa la finalización del pthread que lo invoca.
Pthread_join: Hace que el pthread que invoca esta función espere a que termine un pthread determinado.
Pthread_cancel: Solicita la terminación de otro pthread.
Haciendo uso de algunas funciones que manipulan la funcionalidad de los pthreads y de las herramientas que nos proporciona Cuda para controlar la GPU, se desarrolla este programa que lleva a cabo el lanzamiento de 2 Pthreads por el flujo (proceso) principal de la aplicación. Cada Pthread ejecuta un segmento de código independiente e invoca a la GPU.
Los Pthreads crean e inicializan un arreglo. El Pthread 1 lo inicializa en 1’s , es transferida una copia del arreglo a la memoria global del Device donde varios CudaThreads lo reinicializan en 0’s , finalmente es transferida una copia de regreso a la memoria del Host. Análogamente se realiza el mismo proceso para el Pthread 2 pero con valores inversos.
Programación e Implementación de un Clúster de GPU's
37
A continuación mostramos el código del programa// Programa: Inicialización de un arreglo usando Pthreads
#include <stdio.h> #include <cuda.h> #include <pthreads.h> #define N 10
//KERNEL 1 (esta función se ejecuta en la GPU) __global__ void Gpu1(int *array){
int id = threadIdx.x;
array[id] = id * 0;
__syncthreads(); }
//KERNEL 2 (esta función se ejecuta en la GPU) __global__ void Gpu2(int *array){
int id = threadIdx.x;
array[id] = id * 0 + 1;
__syncthreads(); }
//Procedimiento que ejecutara el primer pthread void Hilo1Gpu1(){
int arreglo[N]; int *dev_arreglo;
// Inicializamos el Arreglo. for (int i=0 ; i<N ; i++)
arreglo[i]=1;
// Reservamos memoria en el GPU para almacenar el arreglo. cudaMalloc( (void**) &dev_arreglo, N * sizeof(int) ); // Copiamos el arreglo al device
cudaMemcpy( dev_arreglo, arreglo, N*sizeof(int), cudaMemcpyHostToDevice); // Deseamos tener N CudaThread por bloque
tamBloques = N; numBloques = 1;
//Invocamos al kernel.
Gpu1<<<numBloques, tamBloques>>>(dev_arreglo); // Copiamos el resultado
cudaMemcpy( &arreglo, dev_arreglo, N * sizeof(int), cudaMemcpyDeviceToHost);
// Desplegamos el arreglo
printf("El Arreglo es: \n\n"); for (int i=0 ; i<N ; i++)
printf(" [ %i ] -> %d \n",i, arreglo[i]); printf("\n");
pthread_exit(0); }
Programación e Implementación de un Clúster de GPU's
38
Continuación …//Procedimiento que ejecutara el segundo pthread void Hilo2Gpu2(){
int arreglo[N]; int *dev_arreglo;
// Inicializamos el Arreglo. for (int i=0 ; i<N ; i++) arreglo[i]=0;
// Reservamos memoria en el GPU para almacenar el arreglo. cudaMalloc( (void**) &dev_arreglo, N * sizeof(int) ); // Copiamos el arreglo al device
cudaMemcpy( dev_arreglo, arreglo, N*sizeof(int), cudaMemcpyHostToDevice);
// Deseamos tener N CudaThread por bloque tamBloques = N;
numBloques = 1;
//Invocamos al kernel.
Gpu2<<<numBloques, tamBloques>>>(dev_arreglo); // Copiamos el resultado
cudaMemcpy( &arreglo, dev_arreglo, N * sizeof(int), cudaMemcpyDeviceToHost);
// Desplegamos el arreglo
printf("El Arreglo es: \n\n"); for (int i=0 ; i<N ; i++)
printf(" [ %i ] -> %d \n",i, arreglo[i]); printf("\n"); pthread_exit(0); } //Funcion principal int main(){
//definición de variables de tipo pthreads pthread_t hilo1,hilo2;
//creación de los pthread's
pthread_create(&hilo1, NULL, Hilo1Gpu1, NULL); pthread_create(&hilo2, NULL, Hilo2Gpu2, NULL); //espera de los hilos
pthread_join(hilo1, NULL); pthread_join(hilo2, NULL); return 0;
Programación e Implementación de un Clúster de GPU's
39
Compilación:11. Controlando
más
de
un
GPU
(Programación
MultiGPU)
Hasta este momento, todos los ejemplos presentados muestran una característica en particular: son lineales y bloqueantes, es decir, una vez que se ejecuta el programa y la función Kernel es llamada, la aplicación se suspende hasta que dicha función termina, regresando el control a la aplicación que la invoco y ejecutando las siguientes líneas de código.
Supongamos que se cuenta con más de un dispositivo GPU instalado en el mismo ordenador, es deseable poder sumar las capacidades de computo de cada uno de estos dispositivos para realizar computo masivamente paralelo y poder resolver problemas que requieran realizar cálculos intensivos sobre una gran cantidad de información. Pero no solo es deseable poder utilizar los dispositivos GPU’s, sino también poder incrementar la capacidad de cálculo haciendo uso de los CPU’s del sistema.
Presentamos a continuación dos posibles formas de lograr esto; la primera opción es utilizar las prestaciones de los llamados Pthreads y la segunda es usando las características de OpenMp.
a. OpenMP + Cuda
OpenMp es una interfaz de programación de aplicaciones y el estándar actual de la programación paralela; permite añadir concurrencia a programas escritos en lenguaje C, C++ y Cuda trabajando sobre un modelo de ejecución conocido como: Fork-Join, este modelo es un paradigma de programación en el cual una tarea extensa se divide en N hilos (Fork), cada hilo realizara una porción de tarea de menor peso, para luego unificar el resultado final en uno solo (Join); esto se logra mediante pragmas, directivas, llamadas a funciones y variables, que le indican al compilador que porción debe paralelizar en hilos.
# Compilación
Programación e Implementación de un Clúster de GPU's
40
El esquema general de un programa en OpenMP + Cuda, sería el siguiente:
Figura 21. El modelo de programación: Fork-Join.
Programación e Implementación de un Clúster de GPU's
41
En primera instancia, es necesario incluir la librería que aporta los pragmas de openMp, dichas funciones se encuentran en: omp.h. Una vez iniciado el cuerpo del programa, será necesario detectar el número de GPU’s instalados en el sistema así como el número de núcleos de procesador, posteriormente se tendrían que inicializar los datos a tratar dentro de la memoria del CPU para luego establecer el número de threads de la sección paralela de openMp. Una vez realizado todo esto la aplicación llega a su bloque paralelo (pragma_openMp), en esta sección se generan tantos threads como se haya establecido previamente, cierta cantidad de threads (el mismo número que GPU’s) toman control de los dispositivos gráficos, copiando una porción de los datos a tratar y llevándola hacia la memoria del dispositivo para lanzar su respectivo Kernel, los threads restantes toman una porción de datos y trabajan sobre ellos mediante el uso de los núcleos del CPU. Al finalizar todas las subtareas, se busca unificar el resultado a fin de completar la tarea principal en su totalidad.
Existen un conjunto de funciones en CUDA para la administración de los GPU’s, con estas funciones es posible determinar el número de dispositivos instalados en el sistema, así como tomar el control de cualquiera de estas tarjetas gráficas.
cudaGetDeviceCount(int numDevice): Devuelve en numDevice el numero de GPU's instalados.
cudaSetDevice(int Device): Selecciona el GPU referido por Device [0-x].
cudaGetDevice(int gpuId): Devuelve en gpuId el número de dispositivo usado actualmente.
cudaGetDeviceProperties(cudaDeviceProp dev_prop, int gpu_id): Almacena en dev_prop, toda la información del gpu con id: gpu_id.
cudaDeviceReset(): Destruye y limpia todos los recursos asociados con el dispositivo actual en el proceso actual.
También tenemos un conjunto de pragmas para OpenMp, mediante el cual podemos administrar los núcleos del CPU y el número de Hilos de la sección paralela:
omp_get_num_procs(): Devuelve el número de procesadores en el sistema.
omp_set_num_threads(num_threads): Establece el número de threads en la sección paralela.
omp_get_thread_num(): Devuelve el identificador del hilo [0 , num_threads-1]
La compilación de un programa con OpenMP y CUDA es un poco distinta ha como se venía manejando, ya que habrá que indicarle al compilador de CUDA: nvcc, que se incluirá la librería de open MP:
# Compilación Hibrida OpenMp + Cuda
$ nvcc -Xcompiler –fopenmp origen.cu -o destino
# Xcompiler: Especifica directivas de compilación para nvcc.
Programación e Implementación de un Clúster de GPU's
42
A continuación se muestra un ejemplo de un programa hibrido con OpenMP y CUDA; Se verifican mediante funciones de CUDA y Open MP el número de GPU’s y CPU’s Instalados, posteriormente se genera un número determinado de hilos; al entrar en la sección paralela el hilo K toma control del GPU K; como los gpu’s pueden ejecutar funciones kernel diferentes o la misma (en nuestro ejemplo la función kernel no efectúa ninguna tarea.), trabajando sobre una sección de datos, los demás hilos ejecutan funciones diferentes. Un diagrama que ejemplifica el flujo de la aplicación es el siguiente, se destaca la sección paralela que existe mediante la implementación del estándar openMP.