Introducción

En esta guida, vamos a explorar el poder de una GPU programada en C++. Los desarrolladores podran esperar un rendimiento increible con C++, y acceder al poder fenomenal de la GPU con una lenguage de bajo nivel puede generar algunas de los computos mas rápidos actualmente disponibles.

Requerimientos.

Si bien cualquier computadora capaz de correr una version moderna de Linux puede soportar un compilador de C++, vas a necesitar una placa de video baada en NVIDIA. Si no tienes una GPU, puedes activar una instancia con Amazon Web Services o cualquier otro proveedor de servicios en la nube que prefieras.

Si te decides por una máquina física, aseguraté de tener instalados los drivers propietarios de NVDIA instalados. Puedes encontrar la información para esto en: https://linuxhint.com/install-nvidia-drivers-linux/

Además del driver, necesitaras el toolkit de CUDA. En este ejemplo vamos a usar Ubuntu 16.04 LTS, pero hay versiones disponibles para descargar para la mayoria de las principales distribuciones de Linux que se pueden encontrar en el siguiente link: https://developer.nvidia.com/cuda-downloads

Para Ubuntu, deberias elejir descargar el archivo .deb. El archivo descargado no tendra la extensión .deb, pero puedes renombarlo facilmente. Luego puedes instalarlo con:

sudo dpkg -i package-name.deb

Probablemente se te preguntara si quieres instalar una clave GPG, y si es asi, sigué las instrucciones provistas para hacerlo.

Una vez que terminado, actualiza tus repositorios:

sudo apt-get update
sudo apt-get install cuda -y

Una vez hecho, recomiendo reiniciar el sistema para asegurarnos de que todo esta correctamente cargado.

Los beneficios del desarrollo sobre GPU

Las CPUs manejan muchas entradas y salidas diferentes y contienen un gran variedad de funciones no solo para tratar con una gran variedad de programas necesario, sino tambien para administrar varientes de configuraciones de hardware. Tambien manejan la memoria, cacheo, el bus del sistema, segmentación, y funcionalidades de E/S, haciendolos un servidor de todas las tareas.

Las GPUs son lo opuesto, contienen muchos procesadores individuales que estan enfocados en funciones matematicas muy simples. Debido a esto, procesan las tareas muchas veces más rápido que las CPUs. Al especializarse en funciones escalares(funciones que toman una o mas entradas y devuelven una unica salida), alcanzan un rendimiento extremo a costa de una especialización extrema.

Código de ejemplo

En este ejemplo, vamos a sumar dos vectores. Agregue una versión con CPU y una con GPU para hacer una comparación de velocidad. El archivo gpu-example.cpp contiene el siguiente código:

#include "cuda_runtime.h"
#include <stdio.h>
#include <stdlib.h>
#include <iostream>
#include <cstdio>
#include <chrono>
 
typedef std::chrono::high_resolution_clock Clock;
 
#define ITER 65535
 
// Version CPU de la función suma de vectores
void vector_add_cpu(int *a, int *b, int *c, int n) {
    int i;
 
    // Add the vector elements a and b to the vector c
    for (i = 0; i < n; ++i) {
    c[i] = a[i] + b[i];
    }
}
 
// Versión GPU de la función suma de vectores
__global__ void vector_add_gpu(int *gpu_a, int *gpu_b, int *gpu_c, int n) {
    int i = threadIdx.x;
    // No es necesario el loop for por que el runtime de CUDA
    // maneja estos hilos ITER veces
    gpu_c[i] = gpu_a[i] + gpu_b[i];
}
 
int main() {
 
    int *a, *b, *c;
    int *gpu_a, *gpu_b, *gpu_c;
 
    a = (int *)malloc(ITER * sizeof(int));
    b = (int *)malloc(ITER * sizeof(int));
    c = (int *)malloc(ITER * sizeof(int));
 
    // Necesitamos variables accesibles en CUDA,
    // para eso cudaMallocManaged nos las provee
    cudaMallocManaged(&gpu_a, ITER * sizeof(int));
    cudaMallocManaged(&gpu_b, ITER * sizeof(int));
    cudaMallocManaged(&gpu_c, ITER * sizeof(int));
 
    for (int i = 0; i < ITER; ++i) {
        a[i] = i;
        b[i] = i;
        c[i] = i;
    }
 
    // Llama a la versión CPU y la temporiza
    auto cpu_start = Clock::now();
    vector_add_cpu(a, b, c, ITER);
    auto cpu_end = Clock::now();
    std::cout << "vector_add_cpu: "
    << std::chrono::duration_cast<std::chrono::nanoseconds>(cpu_end - cpu_start).count()
    << " nanoseconds.\n";
 
    // Llama a la versión GPU y la temporiza
    // Los triples <> es una extensión del runtime CUDA que permite
    // que los parametros de una llamada al kernel CUDA sean pasados
    // En este ejemplo estamos pasando un thread block con ITER threads
    auto gpu_start = Clock::now();
    vector_add_gpu <<<1, ITER>>> (gpu_a, gpu_b, gpu_c, ITER);
    cudaDeviceSynchronize();
    auto gpu_end = Clock::now();
    std::cout << "vector_add_gpu: "
    << std::chrono::duration_cast<std::chrono::nanoseconds>(gpu_end - gpu_start).count()
    << " nanoseconds.\n";
 
    // Libere la memoria basada en la función GPU allocations
    cudaFree(a);
    cudaFree(b);
    cudaFree(c);
 
    // Libere la memoria basada en la función CPU allocations
    free(a);
    free(b);
    free(c);
 
    return 0;
}

El Makefile contiene: ~~~ INC=-I/usr/local/cuda/include NVCC=/usr/local/cuda/bin/nvcc NVCC_OPT=-std=c++11

all: $(NVCC) $(NVCC_OPT) gpu-example.cpp -o gpu-example

clean: -rm -f gpu-example


Para correr el ejemplo, primero compilar:

make ~~~

Y luego correr el programa:

./gpu-example

Como puedes ver, la version con CPU corre considerablemente mas lento que la version GPU.

Si no es así, debes ajustar el ITER definido en gpu-example.cpp a un numero mas alto. Esto se debe a que el tiempo de configuración de la GPU es más largo que algunos bucles más pequeños con uso intensivo de la CPU. Encontré 65535 para que funcione bien en mi máquina, pero su kilometraje puede variar. Sin embargo, una vez que borre este umbral, la GPU es mucho más rápida que la CPU.

Conclusíon

Espero que hayas aprendido mucho de nuestra introducción en la programación de GPU con C ++. El ejemplo anterior no logra mucho, pero los conceptos demostrados proporcionan un marco que puede utilizar para incorporar sus ideas para liberar el poder de su GPU.

Post traducido de: LinuxHint: GPU Programming with C++