Cuando realizamos optimizaciones en nuestro código, debemos ser conscientes de las limitaciones que impone la arquitectura, en muchos casos lo que puede parecer una mejora acaba siendo un cuello de botella.
Aunque la técnica de “unroll”
o desenrollado de bucles descrita en el post anterior parezca la “Panacea[1]”, hay que ser cautelosos,
porque no siempre será así. Es necesario hacer un estudio detallado de cada
caso para aplicar factores de desenrollado apropiados a cada uno de los
algoritmos que se pretenden computar, ya que un desenrollado de bucles
agresivo, puede aumentar demasiado las tareas de planificación (scheduling)
y aumentar demasiado el uso de registro en el cuerpo del bucle, que en las CPU
puede implicar una migración de los valores de los registros a memoria y esto
puede desembocar en una degradación del rendimiento del programa. Por otro
lado, si se utilizan factores de desenrollado muy altos, el tamaño del cuerpo
del bucle resultante podría desbordar la caché de instrucciones, seguido de
pérdidas de caché, y en consecuencia reduciéndose el rendimiento.
En programación GPGPU nos
encontramos además con una serie de restricciones que limitan el rendimiento de
los programas. Antes de proceder a la implementación deberemos estudiar el
impacto que supone el desenrollado de bucles para los programas GPGPU en el
contexto de las restricciones de esos recursos y hallar el factor de “unroll”
óptimo para ese programa y dispositivo.
ILP y Factor de Ocupación (OF):
como ya se ha visto, el nivel de paralelismo por instrucción juega un papel
importante a la hora de aumentar el rendimiento, ya que el tamaño de la caché
de instrucciones “I-cache” es limitado. Es importante no sobrecargar el
cuerpo de la función con demasiadas instrucciones que provoquen un fallo de
caché y la consiguiente penalización en el rendimiento. Por otro lado en las GPU el factor de
ocupación puede ser igual o más importante que el anterior a la hora de ocultar
las latencias de acceso a memoria y de flujo de instrucciones. Debido a la
interrelación que hay entre ILP y OF, se hace muy complicado estimar los
valores óptimos. A continuación se muestra un caso concreto que sirve de
ejemplo.
En el apartado B de esta sección
se expuso brevemente el modelo de programación de CUDA compuesto por Mallas (Grids),
Bloques (Blocks) e Hilos (Threads), ahora vamos a compararlo con
la arquitectura de las GPUs para comprender mejor el problema del factor de
ocupación. Una GPU está compuesta por x SM “stream multiprocessor”,
cada uno dispone de c SP “stream processor”, en cada uno hay w
Warps[2],
que a su vez pueden ejecutar t Threads. La clave para
comprender el problema del factor de ocupación reside en la diferencia real que
existe entre la capacidad computacional de la arquitectura que utilicemos y la
capacidad que solicitamos en la llamada al kernel. La Figura 1 muestra las
capacidades para distintos dispositivos con arquitectura CUDA.
Device Name
|
GeForce GTX 480
|
Tesla C2050
|
GeForce GTX 295
|
Compute capability
|
2.0
|
2.0
|
1.3
|
Memory Informat.
|
|||
Total global mem
|
1.536 MB
|
2.687 MB
|
896 MB
|
Total constant Mem
|
64 Kb
|
64 Kb
|
64 Kb
|
Max mem pitch
|
2048 MB
|
2048 MB
|
2048 MB
|
MP Information
|
|||
Multiprocessor count
|
15
|
14
|
30
|
Shared mem per mp
|
48
|
48
|
16
|
Registers per mp
|
32768
|
32768
|
16384
|
Threads in warp
|
32
|
32
|
32
|
Warps/MP
|
48
|
48
|
32
|
Blocks/MP
|
8
|
8
|
8
|
Max threads/block
|
1024
|
1024
|
512
|
Max thread dim
|
(1024,1024, 64)
|
(1024,1024, 64)
|
(512, 512, 64)
|
Max grid dim
|
(65535, 65535, 65535)
|
(65535, 65535, 65535)
|
(65535, 65535, 1)
|
Warps/block in SP
|
6
|
6
|
4
|
threads in block
|
192
|
192
|
128
|
total pararell threads
|
2880
|
2688
|
3840
|
Figura 1. Capacidades de
algunos dispositivos
De todos los valores de la
tabla ahora mismo nos interesan: “Multiprocessor count”, que es el número de
multiprocesadores que tiene el dispositivo, “Blocks/MP” bloques por multiprocesador, “”Warps/MP” que son agrupaciones de hilos dentro de cada
bloque y “Threads in warp” que son el
número de hilos que ejecutan en cada warp. Sin tener nada más en cuenta,
podemos calcular el número total de hilos que cada dispositivo es capaz de
ejecutar de forma concurrente: por ejemplo para el dispositivo GeForce GTX 480
tenemos 15 procesadores x 8 bloques x 6 warps x 48 hilos, en total 34.560 hilos
concurrentes ejecutando el mismo kernel. Evidentemente la arquitectura nos está
imponiendo además ciertas restricciones: “Max threads per block” está indicando
un número (1024) mayor al teórico. Si en un bloque hay 6 warps y 32 hilos (192
hilos en total) ¿cómo es posible alcanzar esa cifra? CUDA puede utilizar hasta
8 bloques por procesador, es decir, que si utilizamos 192 hilos por bloque,
dispondremos de 8 bloques por procesador, pero si necesitamos 256 hilos,
dispondremos de 6 bloques, para 512 hilos solo 3 bloques, para 1024 solo un
bloque por procesador. Cuando se solicitan los recursos en las llamadas a los
kernels, CUDA realiza una segmentación y planificación de ejecución de todos
los bloques solicitados, ahí es donde se pueden producir holguras si no hemos
diseñado bien nuestra llamada, reduciéndose el factor de ocupación.
Otra cuestión a considerar, también muy importante es el uso de los
registros que necesita nuestro Kernel, a mayor uso de registros, menor
disponibilidad de bloques por procesador. Estas dos variables son las que van a
modificar el factor de ocupación que, como hemos podido inferir, es el
aprovechamiento físico de la arquitectura para cada tanda de instrucciones. El primero se
maneja afinando los parámetros de llamada al núcleo y el segundo, mediante la
creación de kernels lo más sencillos que se pueda (sin perder de vista el
tiempo de latencia). Podemos saber el uso de registros que hace nuestro kernel
añadiendo el parámetro (31) – Xptxas –v en la
línea de compilación.
nvcc -Xptxas –v ejemplo.cu
ptxas info : Compiling entry function 'acos_main'
ptxas info : Used 4 registers, 60+56 bytes lmem,
44+40 bytes smem,
20 bytes cmem[1],
12 bytes cmem[14] |
Figura 2. Obteniendo
información de nuestro kernel
[1] De panacea universal: Remedio que buscaban los antiguos alquimistas para
curar todas las enfermedades
[2] El término “Warp” puede asimilarse al de “tanda” ya que hace referencia al
conjunto de hilos de un procesador que se ejecutan en el mismo instante por
pertenecer a la misma programación del scheduler.