Buscar este blog

viernes, 23 de marzo de 2012

Un ejemplo básico, parte II: Implementación del Kernel

Como os prometí en el anterior post, hoy veremos la implementación del kernel para la reducción de vectores:

Nuestro kernel básicamente consiste en realizar la suma de una serie de elementos y guardarla en el primero de ellos. Cada ejecución se encargará de una posición de memoria diferente, con lo que no es necesario preocuparnos de implementar ningún mecanismo de sincronización. El número de elementos a sumar viene dado por el factor de desenrollado (UF). 

Los parámetros de entrada del Algoritmo son: Datos: puntero a la estructura que contiene los datos a sumar, Elementos: número de elementos que se suman, Impar: indica si el número de elementos es impar (para evitar la realización del cálculo del módulo, dentro del kernel, ya que es computacionalmente costoso), y finalmente UF, que se utilizará para indicar el número de elementos a sumar dentro de cada hilo.

Algoritmo 3: kernel de reducción de vectores
1
tid ← threadIdx.x + blockIdx.x * blockDim.x
2
t (ene/UF)
3
si tid < t hacer
4
   para i1 hasta UF hacer
5
            a[tid] a[tid]  + a[(tid+(t*i))]
6
Si tid = 0 y impar > 0 hacer
7
  para i1 hasta impar+1 hacer
8

a[tid] ← a[tid]  + a[(ene-i)]


Algoritmo 1 Kernel de reducción de vectores

1.-La primera línea del algoritmo, “tid ← threadIdx.x + blockIdx.x * blockDim.x”, es la típica asignación para el cálculo de la posición global de ejecución del kernel. Así se identifica exactamente al hilo que se está referenciando en cada uno de los kernels y es la forma que tenemos para organizar el proceso en paralelo. Como en nuestro caso estamos trabajando únicamente con una dimensión, sólo se referencia al valor del campo “x” de cada variable (threadIdx, blockIdx y blockDim), pero podrían ser hasta tridimensionales, para ello se utilizan los campos “y” y “z” para referirnos a la segunda y tercera dimensión respectivamente.

2.- Dado que el número real de cálculos que tenemos que ejecutar no siempre coincide con el total de hilos ejecutados en los Kernels, debemos controlar esta situación para que el dispositivo no haga trabajo inútil (o redundante). Por este motivo la segunda línea del algoritmo calcula el número total de threads a emplear y en la tercera sólo se permite la ejecución del proceso de reducción a los hilos que están dentro del rango necesario.

4.-El siguiente paso define al bucle que empleamos para la suma de elementos del vector, que viene definido por el factor de desenrollado que empleamos en la llamada.

5.-La siguiente línea realiza la suma de elementos y la acumula en el primer elemento.

6.-Finalmente, tenemos una condición especial: sólo si se trata del primer hilo y el número de elementos a procesar es impar, adicionamos esos elementos impares también al primero.

Las operaciones realizadas en el siguiente ejemplo son: (3+6=9), (8+5=13), (4+2=6), y en la siguiente iteración (9+13+6=28).
En la siguiente Figura se puede observar gráficamente el proceso, para un factor UF de 2:


Figura 1. Reducción con UF = 2

Las propiedades fundamentales de este método de reducción que acabamos de proponer son:

·         No hay relación entre bloques ni hilos del mismo stream, con lo que no es necesario establecer ningún tipo de mecanismo para sincronizar el algoritmo en ningún punto. Es un algoritmo orientado al hilo y no al bloque.
·         Adecúa la petición de recursos del sistema en función al estado de su ejecución.
·         Sólo se realiza un desplazamiento de datos de memoria central a memoria de dispositivo por el total de elementos a sumar, y además solo se necesita desplazar el primer elemento a memoria principal para obtener el resultado de la reducción.
·         Con el control de UF se reduce el efecto de la latencia de memoria global del dispositivo, no siendo por tanto necesario realizar las lecturas alineadas con el orden de los hilos.


Ya hemos presentado un algoritmo para la reducción de vectores, que como sabréis, es una de las operaciones más típicas en multitud de escenarios, tanto en análisis de datos, como en optimización. El próximo post presentará los datos obtenidos en las pruebas. 


viernes, 9 de marzo de 2012

Un ejemplo básico, parte I: la llamada al kernel


Vamos a diseñar un algoritmo que permita realizar llamadas a nuestro kernel de tal forma que conociendo N número de elementos a procesar o tamaño del bucle en versión serie, utilice los mínimos recursos (de procesador gráfico) minimizando también las transferencias de memoria. Nos centraremos en un típico caso que en el que se emplea la programación paralela: Reducción de vectores. En la literatura hay multitud de ejemplos y diversos enfoques, nVidia también realiza varias implementaciones hasta 7 diferentes, van ilustrando los diferentes factores para mejorar el rendimiento. Yo implementé mi propio método, que ha resultado ser más rápido que las implementaciones de nVidia y de la librería Trust en varios escenarios. En el presente post y el siguiente lo presento:

Algoritmo para la llamada al kernel

Primero necesitamos obtener las características del dispositivo donde se van a ejecutar los programas, eso lo podemos obtener en tiempo de ejecución interrogando las capacidades de cada dispositivo. Las tres variables cuyo valor necesitamos saber son: N  número de elementos a procesar, UF factor de desenrollado (Unroll factor), que determinará la velocidad de segmentación de los datos y el número de instrucciones serie que ejecuta el kernel en cada hilo, y datos que es el puntero al bloque de datos a procesar.

Una vez determinados los valores de estas variables podremos calcular el factor de ocupación. El consumo máximo de tiempo se emplea en la migración de los datos desde memoria principal a la memoria del dispositivo, por lo que realizaremos sólo un proceso de carga y otro de descarga. El tamaño de bloques necesarios para la ejecución se irá reduciendo de forma logarítmica en cada iteración, ya que este algoritmo está orientado a las operaciones de reducción de vectores.

Para acumular los valores obtenidos se sobrescribe el vector de entrada con los resultados que se van obteniendo en cada cálculo, evitando así crear dos estructuras de datos, una de entrada y otra de salida. Además para cada llamada se aprovecha la información residente en la memoria del dispositivo, reduciendo la cantidad de datos transferida entre la memoria central y la del dispositivo.

Algoritmo 2: Algoritmo de llamada a un kernel
1
Obtener_caracteristicasGPU (max_bloques, max_hilos)
2
Determinar_factor_ocupacion(N,max_bloques, max_hilos, registros_kernel, nhilos)
3
UF ← 7
4
auxN  N
5
huerfanos  ←  (auxN modulo UF)
6
Datos_a_dispositivo(datos_dispositivo, datos)
7
Para i ← 1 hasta (log(N) / Log(UF))
8

Ejecutar en paralelo Reduce<<<(auxN+nhilos-1)/nhilos,nhilos> >>(datos_dispositivo, auxN, huerfanos, UF)
9

auxNauxN / UF
10

huerfanos  ←  (auxN modulo UF)
11
Datos_a_memoria_principal(datos, datos_dispositivo)

Algoritmo 1.Llamada al Kernel



Pasaremos a comentar cada una de las líneas de l algoritmo:

1.- Obtener_caracteristicas GPU (max_bloques, max_hilos): Indica los límites de la arquitectura seleccionada, que se utilizarán para aplicar las reglas que obtendrán el mejor factor de ocupación. Adicionalmente se obtienen otras cualidades de los dispositivos como la capacidad computacional (Compute capability) que indicará cuales son los dispositivos que permiten aprovechar las nuevas capacidades de CUDA 4, caracterizados por tener una capacidad igual o superior a 2.0.

2.- Si se puede estimar el tamaño N de nuestro proceso, y esto se puede traducir en el número de hilos necesarios para invocar al kernel, se podrá determinar el mejor factor de ocupación. Este cálculo óptimo de la relación hilos/bloques se puede realizar fácilmente porque al compilar el kernel se puede conocer el tamaño de de los registros necesarios para cada hilo,   y consecuentemente, se puede determinar el número máximo de hilos por bloque y de bloques por procesador.

3.- En la línea 3 hay que proporcionar un valor de Unroll Factor (UF). Para ello se deberán de realizar varias pruebas para obtener el mejor valor para este parámetro, ya que dependerá mucho de la estructura de datos en memoria que estemos utilizando, el tipo de memoria que utilicemos para su gestión y la coalescencia de los accesos que realicemos. En nuestras pruebas hemos utilizado la memoria global del dispositivo, y los accesos no han sido alineados con la ejecución de los kernels, Como veremos más adelante para nuestro algoritmo el mejor valor ha sido 7.

4.- En la línea 4 utilizamos una variable para guardar el tamaño de procesamiento remanente. El valor de esta variable se va reduciendo logarítmicamente conforme avanza el proceso.

5.- La variable "huérfanos" se utiliza para determinar cuándo se produce un elemento huérfano, el deberá de tratarse de forma especial.

6.- La línea 6 copia los datos desde memoria principal a la memoria global del dispositivo para ser procesados por el kernel.

7.- El bucle realiza una serie de llamadas al kernel en las que se ajustan los bloques que demandamos al dispositivo en función del remanente de ejecuciones. El número de iteraciones se corresponde con el cociente entre el logaritmo  de N y el logaritmo de UF-.

11.- Una vez concluidas las ejecuciones se descargará el dato deseado a memoria principal para obtener el resultado final.  La utilización del las llamadas descendentes, la vamos a entender a continuación en cuanto se exponga el Kernel.

Os dejo analizando el algoritmo, en el próximo post, veremos la implementación del Kernel. Hasta pronto


viernes, 2 de marzo de 2012

En busca del Kernel perdido


Con todo lo expuesto en los post anteriores debemos confeccionar nuestra lista de prioridades a la hora de diseñar los kernel que ejecuten las GPU. Resumiendo, debemos tener en cuenta los siguientes factores:

·         Factor de “unroll”

·         ILP: Nivel de Paralelización por instrucción

·         Factor de ocupación de la GPU, que se compone de:
o    Nº de bloques que se necesitan
o    Nº de hilos por cada bloque
o    Registros utilizados en el kernel

·         Transferencias de memoria CPU <-> GPU

·         Tipos de memoria a utilizar, Global, compartida,…


Teniendo todo esto en cuenta, nuestro kernel debe tener un número de instrucciones  mínimo que ocupe la latencia de la arquitectura, y se intentará invocar al kernel con el tamaño justo de bloques e hilos por bloques que maximicen la ocupación del tamaño  requerido. Se minimizará el número de transferencias de memoria entre CPU y GPU. Se intentará evitar las latencias de acceso a memoria global mediante accesos ordenados en memoria compartida.


En el próximo post, ilustraré con un ejemplo todo esto que hemos comentado