Recursos avanzados de CUDA
Manuel UjaldónCatedrático de Arquitectura de Computadores @ Universidad de MálagaCUDA Fellow @ Nvidia Corporation
Curso de Extensión UniversitariaTitulaciones Propias. Universidad de Málaga. Curso 2016/17
Agradecimientos
2
Al personal de Nvidia, por compartir conmigo ideas, material, diagramas, presentaciones, ...
Stephen Jones [Kepler].Mark Ebersole [Kepler, Maxwell]. Mark Harris [Maxwell, Volta].
Contenidos [60 diapositivas]
1. La caché de sólo lectura (Kepler+) [4 diapositivas]2. Paralelismo dinámico (Kepler+). [17]
1. Ejecución dependiente de los datos. [2]2. Algoritmos paralelos recursivos. [4]3. Llamadas a librerías desde los kernels. [3] 4. Simplificar la división CPU/GPU. [2]
3. Hyper-Q (Kepler+). [6]4. GPU Boost (Kepler+). [6]5. Memoria unificada (Maxwell+). [19]
1. Ejemplos de programación. [9]2. Resumen. [1]3. La hoja de ruta. [1]
6. Planificación independiente de hilos (Volta+). [7]
3
1. La caché de sólo lectura (Kepler+)
Diferencias en la jerarquía de memoria:Fermi vs. Kepler
5
Motivación para usar la nueva caché de datos
48 Kbytes extra para expandir el tamaño de la caché L1. Posee el mayor ancho de banda en caso de fallo a caché.Usa la caché de texturas, pero de forma transparente al
programador, y elimina el tiempo de configuración de ésta.Permite que una dirección global pueda buscarse y
ubicarse en esta caché, utilizando para ello un camino separado del que accede a caché L1 y memoria compartida.
Es flexible, no requiere que los accesos estén alineados.Gestionada automáticamente por el compilador.
6
Declarar los punteros con el prefijo "const __restrict__".El compilador automáticamente mapeará la carga de esos
valores en la caché para usar el nuevo camino de datos a través de la memoria de texturas.
__global__ void saxpy(float x, float y, const float * __restrict__ input, float * output){ size_t offset = threadIdx.x + (blockIdx.x * blockDim.x);
// El compilador utilizará la nueva caché para "input" output[offset] = (input[offset] * x) + y;}
Cómo utilizar la nueva caché de datos
7
Comparativa con la memoria de constantes
8
A comparar Memoria de constantes Caché de datos de sólo lectura
Disponibilidad
Tamaño
Implementación hardware
Acceso
Mejor rasgo
Peor rasgo
Mejor escenario de uso
Desde CUDA Compute Capability 1.0A partir de CCC 3.5
(aunque desde CCC 1.0 se podía usar la memoria de texturas manualmente)
64 Kbytes 48 Kbytes
Una partición de la memoria global (DRAM)
Caché de texturas que expande la L1 (SRAM)
A través de una caché de 8 Kbytesque posee cada multiprocesador
Mediante un camino aparte en el cauce de segmentación gráfico
Latencia muy baja Gran ancho de banda
Menor ancho de banda Mayor latencia
Acceso con el mismo coeficiente (sin usar threadIdx) a un pequeño
conjunto de datos de sólo lectura
Cuando el kernel es memory-bound, aún después de haber saturado el ancho
de banda con memoria compartida
2. Paralelismo dinámico (Kepler+)
La habilidad para lanzar nuevos procesos (mallas de bloques de hilos) desde la GPU de forma:
Dinámica: Basándonos en datos obtenidos en tiempo de ejecución.Simultánea: Desde múltiples hilos a la vez.Independiente: Cada hilo puede lanzar una malla diferente.
¿Qué es el paralelismo dinámico?
10
Fermi: Sólo la CPU puede generar trabajo en GPU.
Kepler: La GPU puede generar trabajo por sí sola.
CPU GPU CPU GPU
Así se hacían las cosas en la era pre-Kepler:La GPU era un mero esclavo del host o CPU
Gran ancho de banda en las comunicaciones:Externas: Superior a 10 GB/s (PCI-express 3).Internas: Superior a 100 GB/s (memoria de vídeo GDDR5 y anchura
de bus en torno a 384 bits, que es como un séxtuple canal en CPU).
11
Operación 1 Operación 2 Operación 3
Init
Alloc
Función Lib Lib Función Función
CPU
GPU
12
CPU GPU CPU GPU
Antes la GPU era un co-procesador
Ahora los programas van más rápido y
Y así se pueden hacer con Kepler:Las GPUs lanzan sus propios kernels
Con Kepler, la GPU es más autónoma: Entra en escena el paralelismo dinámico
se expresan de una forma más natural.
Ejemplo 1: Generación dinámica de la carga
Asigna los recursos dinámicamente según se vaya requiriendo precisión en tiempo real, lo que facilita la computación de aplicaciones irregulares en GPU.
Amplía el ámbito de aplicación donde puede ser útil.
13
Malla gruesa Malla fina Malla dinámica
Rendimiento elevado,precisión baja
Sacrifica rendimiento sólodonde se requiere precisión
Rendimiento bajo,precisión elevada
Ejemplo 2: Desplegando paralelismo según el nivel de detalle
14
CUDA hasta 2012:• La CPU lanza kernels de forma regular.• Todos los píxeles se procesan igual.
CUDA sobre Kepler:• La GPU lanza un número diferente de kernels/bloques para cada región computacional.
La potencia computacional se asocia a las regiones
según su interés
A vigilar cuando se use el paralelismo dinámico
Es un mecanismo mucho más potente de lo que aparenta su simplicidad en el código. Sin embargo...
Lo que escribimos dentro de un kernel CUDA se fotocopia para todos los hilos. Por lo tanto, la llamada a un kernel provocará millones de lanzamientos si no va precedida de algún IF que la delimite (por ejemplo, sólo el hilo 0 lanza).
Si un bloque CUDA lanza kernels hijos, ¿pueden éstos utilizar la memoria compartida del padre?
No. Sería fácil de implementar en hardware, pero muy complejo para el programador garantizar que no se producen riesgos por dependencias (condiciones de carrera).
15
2. 1. Ejecución dependiente de los datos
El programa paralelo más elemental:Los bucles son paralelizables.Conocemos a priori la carga de trabajo.
Paralelismo dependiente del volumen de datos
17
for (i=0; i<N; i++) for (j=0; j<ElementsOnRow[i]; j++) convolution (i, j);
for (i=0; i<N; i++) for (j=0; j<M; j++) convolution (i, j);
Una solución mala: Supercómputo.Una solución peor: Serialización.
max(ElementsOnRow[i])
N
El programa imposible más elemental:Desconocemos la carga de trabajo.El reto es su distribución (partición de datos).
M
N
Lo que hace posible el paralelismo dinámico:Los dos lazos se ejecutan en paralelo
El programa CUDA para Kepler:
18
__global__ void convolution(int ElementosPorFila[]){ for (j=0; j<ElementosPorFila[blockIdx]; j++) // cada bloque lanza kernel <<< ... >>> (blockIdx, j) // ElementosPorFila[.] kernels}
convolution <<< N, 1 >>> (ElementosPorFila); // Lanza N bloques // de un solo hilo (las filas comienzan en paralelo)
N b
loqu
es
ElementosPorFila[blockIdx] llamadas a kernelsIntercambiando estos dos parámetros(y cambiando en el bucle blockIdx por threadIdx), el programa es más rápido, pero no vale para más de 1024 filas (el máximo tamaño del bloque).
2. 2. Algoritmos paralelos recursivos
Algoritmos paralelos recursivos antes de Kepler
Los primeros modelos de programación CUDA no soportaban recursividad de ningún tipo.
CUDA comenzó a soportar funciones recursivas en la versión 3.1, pero podían fallar perfectamente si el tamaño de los argumentos era considerable.
En su lugar, puede utilizarse una pila definida por el usuario en memoria global, pero a costa de una considerable merma en rendimiento.
Gracias al paralelismo dinámico, podemos aspirar a una solución eficiente para GPU.
20
Un ejemplo sencillo de recursividad paralela:Quicksort
Es el típico algoritmo divide y vencerás que cuesta a Fermi La ejecución depende de los datos.Los datos se particionan y ordenan recursivamente.
21
El código CUDA para quicksort
22
Versión ineficiente Versión más eficiente en Kepler_global_ void qsort(int *data, int l, int r){ int pivot = data[0]; int *lptr = data+l, *rptr = data+r; // Particiona datos en torno al pivote partition(data, l, r, lptr, rptr, pivot);
// Lanza la siguiente etapa recursivamente int rx = rptr-data; lx = lptr-data; if (l < rx) qsort<<<...>>>(data,l,rx); if (r > lx) qsort<<<...>>>(data,lx,r);}
_global_ void qsort(int *data, int l, int r){ int pivot = data[0]; int *lptr = data+l, *rptr = data+r; // Particiona datos en torno al pivote partition(data, l, r, lptr, rptr, pivot);
// Utiliza streams para la recursividad cudaStream_t s1, s2; cudaStreamCreateWithFlags(&s1, ...); cudaStreamCreateWithFlags(&s2, ...); int rx = rptr-data; lx = lptr-data; if (l < rx) qsort<<<...,0,s1>>>(data,l,rx); if (r > lx) qsort<<<...,0,s2>>>(data,lx,r);}
Las ordenaciones de la parte derecha e izquierda se serializan
Utiliza "streams" separados para lograr concurrencia
Resultados experimentales para Quicksort
El número de líneas de código se reduce a la mitad.El rendimiento se mejora en un factor 2x.
23
2. 3. Llamadas a librerías desde los kernels
Conceptos básicos del modelo CUDA:Sintaxis y semántica en tiempo de ejecución
25
__device__ float buf[1024];__global__ void dynamic(float *data){ int tid = threadIdx.x; if (tid % 2) buf[tid/2] = data[tid]+data[tid+1]; __syncthreads();
if (tid == 0) { launchkernel<<<128,256>>>(buf); cudaDeviceSynchronize(); } __syncthreads();
if (tid == 0) { cudaMemCpyAsync(data, buf, 1024); cudaDeviceSynchronize(); }}
Este lanzamiento se produce para cada hiloCUDA 5.0+: Espera a que concluyan todos los lanzamientos y llamadas que el bloque hayaefectuado anteriormente.Los hilos sin trabajo esperan al resto aquíCUDA 5.0+: Sólo se permiten lanzamientos asíncronos para la recogida de datos
Un ejemplo de llamada sencilla a una librería utilizando cuBLAS (disponible a partir de CUDA 5.0)
26
__global__ void libraryCall(float *a, float *b, float *c){ // Todos los hilos generan datos createData(a, b); __syncthreads();
// El primer hilo llama a librería if (threadIdx.x == 0) { cublasDgemm(a, b, c); cudaDeviceSynchronize(); }
// Todos los hilos esperan los resultados __syncthreads();
consumeData(c);}
La CPU lanza el kernel
Generación de datos por cada bloque
Llamadasa la librería
externa
Se ejecutala función externa
Uso del resultado
en paralelo
__global__ void libraryCall(float *a, float *b, float *c){ // Todos los hilos generan datos createData(a, b); __syncthreads();
// El primer hilo llama a la librería if (threadIdx.x == 0) { cublasDgemm(a, b, c); cudaDeviceSynchronize(); }
// Todos los hilos esperan los resultados __syncthreads();
consumeData(c);}
La relación padre-hijo en bloques CUDA
27
Ejecución por cada hilo
Una solo llamada a la función de librería externa:- La librería generará el bloque hijo...- ... pero sincronizamos en el bloque padre.
Sincroniza los hilos que han lanzado kernels:- Si no, pueden producirse condiciones de carrera entre el padre y el hijo.
Todos los hilos deben esperar antes de poder usar los datos en paralelo
Los bloques padre e hijo son distintos, así que:- La memoria local y compartida del padre no puede ser utilizada por el hijo.- Hay que copiar valores en memoria global para pasarlos al hijo como argumentos del kernel.
2. 4. Simplificar la división CPU/GPU
Versión para Fermi Versión para Kepler CPU GPUdgetrf(N, N)} { for j=1 to N { for i=1 to 64 { idamax<<<...>>> idamax(); memcpy dswap<<<...>>> dswap(); memcpy dscal<<<...>>> dscal(); dger<<<...>>> dger(); } memcpy dlaswap<<<...>>> dlaswap(); dtrsm<<<...>>> dtrsm(); dgemm<<<...>>> dgemm(); }}
CPU GPUdgetrf(N, N) { dgetrf<<<...>>> dgetrf(N, N) { for j=1 to N { for i=1 to 64 { idamax<<<...>>> dswap<<<...>>> dscal<<<...>>> dger<<<...>>> } dlaswap<<<...>>> dtrsm<<<...>>> dgemm<<<...>>> } } synchronize();}
La CPU está completamente ocupada controlando los lanzamientos a GPU
Una LU por lotes permite liberar a la CPU, que ahora puede acometer otras tareas
Un método directo para la resolución de ecuaciones lineales: La descomposición LU
29
Los beneficios son muy superiores cuando hay que realizar la LU sobre muchas matrices
Trabajo por lotes controlado por la CPU: Serializar las llamadas. Padecer las limitaciones de P-threads (10s).
30
dgetf2 dgetf2 dgetf2
Control de hilos en CPU
Control de hilos en CPU
Control de hilos en CPU
dswap dswap dswap
Control de hilos en CPU
dtrsm dtrsm dtrsm
Control de hilos en CPU
dgemm dgemm dgemm
Trabajo por lotes a través del paralelismo dinámico: Migrar los lazos superiores a la GPU para computar miles de LUs en paralelo.
Control de hilos en CPU
Control de hilos en CPU
dgetf2
dswap
dtrsm
dgemm
Control de hilos en GPU
dgetf2
dswap
dtrsm
dgemm
Control de hilos en GPU
dgetf2
dswap
dtrsm
dgemm
Control de hilos en GPU
3. Hyper-Q (Kepler+)
Hyper-Q
En Fermi, diversos procesos de CPU ya podían enviar sus mallas de bloques de hilos sobre una misma GPU, pero su ejecución concurrente se encontraba severamente limitada por hardware.
En Kepler, pueden ejecutarse simultáneamente hasta 32 kernels procedentes de:
Varios procesos de MPI, hilos de CPU o streams de CUDA.
Esto incrementa el porcentaje de ocupación temporal de la GPU.
32
FERMI1 sola tarea MPI activa
KEPLER32 tareas MPI simultáneas
Un ejemplo: 3 streams, cada uno compuesto de 3 kernels
33
__global__ kernel_A(pars) {body} // Same for B...ZcudaStream_t stream_1, stream_2, stream_3;...cudaStreamCreatewithFlags(&stream_1, ...);cudaStreamCreatewithFlags(&stream_2, ...);cudaStreamCreatewithFlags(&stream_3, ...);...kernel_A <<< dimgridA, dimblockA, 0, stream_1 >>> (pars);kernel_B <<< dimgridB, dimblockB, 0, stream_1 >>> (pars);kernel_C <<< dimgridC, dimblockC, 0, stream_1 >>> (pars);...kernel_P <<< dimgridP, dimblockP, 0, stream_2 >>> (pars);kernel_Q <<< dimgridQ, dimblockQ, 0, stream_2 >>> (pars);kernel_R <<< dimgridR, dimblockR, 0, stream_2 >>> (pars);...kernel_X <<< dimgridX, dimblockX, 0, stream_3 >>> (pars);kernel_Y <<< dimgridY, dimblockY, 0, stream_3 >>> (pars);kernel_Z <<< dimgridZ, dimblockZ, 0, stream_3 >>> (pars);
stre
am
1
stream_1
kernel_A
kernel_B
kernel_C
stream_2
kernel_P
kernel_Q
kernel_R
stream_3
kernel_X
kernel_Y
kernel_Z
stre
am
2st
ream
3
Distribuidor de cargapara los bloques lanzados desde las mallas
16 mallas activas
Colas de streams(colas ordenadas de mallas)
Kernel C
Kernel B
Kernel A
Kernel Z
Kernel Y
Kernel X
Kernel R
Kernel Q
Kernel P
Stream 1 Stream 2 Stream 3
El gestor de kernels/mallas: Fermi vs. Kepler
34
Distribuidor de cargaSe encarga de las mallas activas
32 mallas activas
Cola de streamsC
B
A
R
Q
P
Z
Y
X
Gestor de kernels/mallasMallas pendientes y suspendidas
Miles de mallas pendientes
SMX SMX SMX SMXSM SM SM SM
Fermi Kepler GK110
Carg
a de
tra
bajo
ge
nera
da p
or C
UD
A
Una sola cola hardwaremultiplexa los streams
Hardware paralelo de streams
Permite suspender mallas
Relación entre las colas software y hardware
35
P -- Q -- R
A -- B -- C
X -- Y -- Z
Stream 1
Stream 2
Stream 3
Oportunidad para solapar: Sólo en las fronteras entre streams
A--B--C P--Q--R X--Y--ZEl hardware de la GPU puede albergar hasta 16 mallas en ejecución...
...pero los streams se multiplexan en una cola únicaFermi:
Relación entre las colas software y hardware
36
P -- Q -- R
A -- B -- C
X -- Y -- Z
Stream 1
Stream 2
Stream 3
A--B--C P--Q--R X--Y--Z
Fermi:
P -- Q -- R
A -- B -- C
X -- Y -- Z
Stream 1
Stream 2
Stream 3Concurrencia plena entre streams
P--Q--REl número de mallas
en ejecución crece hasta 32
Desaparecen las dependencias entre streamsKepler:
A--B--C
X--Y--Z
Oportunidad para solapar: Sólo en las fronteras entre streams
El hardware de la GPU puede albergar hasta 16 mallas en ejecución...
...pero los streams se multiplexan en una cola única
...mapeados sobre GPU 37
E
F
D
C
B
A
Procesos en CPU...
Sin Hyper-Q: Multiproceso por división temporal
A B C D E F
100
50
% u
tiliz
ació
n de
la G
PU
0Tiempo
Tiempo ganado0
A
AA
B
B B
C
CC
D
D
D
E
E
E
F
F
F
Con Hyper-Q: Multiproceso simultáneo 100
50
% u
tiliz
ació
n de
la G
PU
0
4. GPU Boost (Kepler+)
¿En qué consiste?
Permite acelerar hasta un 17% el reloj de la GPU si el consumo de una aplicación es bajo.
Se retornará al reloj base si se sobrepasan los 235 W.Se puede configurar un modo “persistente” de vigencia
permanente de un reloj, u otro para ejecuciones puntuales.
39
Consumo sin apurar
Rendimiento
Reloj a máxima frecuenciaReloj base
Maximiza los relojes gráficos sin salirsede los márgenes de consumo nominales
745 MHz 810 MHz 875 MHz
Cada aplicación tiene un comportamiento distinto en relación al consumo
Aquí vemos el consumo medio (vatios) en la Tesla K20X de aplicaciones muy populares en el ámbito HPC:
40
0
40
80
120
160
AMBER ANSYS Black ScholesChroma GROMACS GTC LAMMPS LSMS NAMD Nbody QMCPACK RTM SPECFEM3D
Boa
rd P
ower
(Wat
ts)
En el caso de la K40, se definen tres saltos de frecuencia con incrementos del 8.7%.
Aquellas aplicaciones que menos consumen pueden beneficiarse de una frecuencia mayor
41
Reloj base
Consumo máximo. Referencia (peor caso).
235W
Reloj acelerado
#1
Consumomoderado. Ej: AMBER
235W
Reloj acelerado
#2
Consumo bajo. Ej: ANSYS Fluent
235W
875 MHz
810 MHz
745 MHz
A 875 MHz, la K40 mejora el rendimiento hasta en un 40% respecto a la K20X.
Y no sólo mejoran los GFLOPS, también lo hace el ancho de banda efectivo con memoria.
GPU Boost frente a otras implementaciones
Resulta mejor un régimen estacionario para la frecuencia desde el punto de vista del estrés térmico y la fiabilidad.
42
Reloj de la GPU
Conmutación automática de reloj
Boost Clock # 1
Boost Clock # 2
Tesla K40
Relojes deterministas
Base Clock # 1
Otros fabricantes
Otros fabricantes Tesla K40
Valor por defecto
Opciones predefinidas
Interfaz con el usuario
Duración del reloj acelerado
Reloj acelerado Reloj base
Bloquear a la frecuencia base 3 niveles: Base, Boost1 o Boost2
Panel de control Comandos en el shell: nv-smi
Aprox. 50% del tiempo de ejec. 100% del tiempo de ejecución
Lista de comandos GPU Boost
43
Comando Efecto
nvidia-smi -q -d SUPPORTED_CLOCKS
nvidia-smi -ac <MEM clock, Graphics clock>
nvidia-smi -pm 1
nvidia-smi -pm 0
nvidia-smi -q -d CLOCK
nvidia-smi -rac
nvidia-smi -acp 0
Muestra los relojes que soporta nuestra GPU
Activa uno de los relojes soportados
Habilita el modo persistente (el reloj sigue vigente tras el apagado)
Modo no persistente: El reloj vuelve a su configuración base tras apagar la máquina
Consulta el reloj en uso
Inicializa los relojes en su configuración base
Permite cambiar los relojes a los usuarios que no son root
Ejemplo: Consultando el reloj en uso
nvidia-smi -q -d CLOCK —id=0000:86:00.0
44
5. Memoria unificada (Maxwell+)
Ahora
46
GPU CPU
DDR4Memoria 2.5D
NVLINK80 GB/s
DDR4
100 GB/s Memoria apilada en
4 capas: 1 TB/s
En pocos años: Todas las comunicaciones internas al chip 3D
47
GPUCPU
Límitesdel áreade silicio
SRAM
3D-DRAM
La idea: Tenemos que acostumbrar al programador a ver así a la memoria
48
GPUCPU
DDR3 GDDR5
Memoria principal Memoria de video
PCI-express
Maxwell GPUCPU
DDR3 GDDR5Memoriaunificada
El viejo modelo software y hardware:Differentes memorias, prestaciones y espacio de direcciones.
El nuevo API:Misma memoria, un solo espacio de direcciones.
Rendimiento sensible a la proximidad de los datos.
CUDA 2007-2014 CUDA en lo sucesivo
Requerimientos del sistema
49
Requerido Limitaciones
Versión de CUDA
GPU
Sistema Operativo
Windows
Linux
Linux on ARM
Mac OSX
A partir de la 6.0
Kepler (GK10x+) oMaxwell (GM10x+)
Rendimiento limitado en CCC 3.0 y CCC 3.5
64 bits
7 u 8 WDDM & TCC no XP/Vista
Kernel 2.6.18+Todos los distros soportados por CUDA,
sin ARM en las primeras versiones
ARM64
Soportado a partir de CUDA 7 Soportado a partir de CUDA 7
Aportaciones de la memoria unificada
Un modelo de programación y de memoria más simple:Un puntero único a los datos, para acceder conjuntamente desde
CPU y GPU.Ya no hace falta utilizar cudaMemcpy().Simplifica enormemente la portabilidad del código.
Mayor rendimiento a través de la localidad de los datos:Migra los datos a la memoria del procesador que accede a ellos.Garantiza la coherencia global.Aún permite la optimización manual con cudaMemcpyAsync().
50
Los tipos de memoria en CUDA
51
Zero-Copy(pinned memory)
Unified Virtual Addressing Unified Memory
Llamada CUDA
Alojada en
Acceso local
Acceso por PCI-e
Otros rasgos
Coherencia
Disponibilidad
cudaMallocHost(&A, 4); cudaMalloc(&A, 4); cudaMallocManaged(&A, 4);
Mem. principal (DDR3) Mem. video (GDDR5) Ambas
CPU La GPU de su tarjeta La CPU y la GPU de su tarjeta
Todas las GPUs El resto de GPUs El resto de GPUs
Evita paginación a disco Prohibida desde la CPU Migra al acceder desde CPU o GPU
En todo momento Entre GPUs Sólo con lanzar + sincronizar
CUDA 2.2 CUDA 1.0 CUDA 6.0
Novedades en el API de CUDA
cudaMallocManaged(puntero,tamaño,flag)Sustituto de cudaMalloc(puntero,tamaño) para alojar memoria.El flag indica quién comparte el puntero con la GPU.cudaMemAttachHost: Sólo la CPU.
cudaMemAttachGlobal: Adicionalmente, cualquier otra GPU.
Todas las operaciones válidas sobre la memoria de la GPU también son válidas sobre la memoria unificada.
Nueva palabra clave: __managed__Anotación de variable global que se combina con __device__.Declara una variable de GPU migrable y de ámbito global.Símbolo accesible tanto desde la CPU como desde la GPU.
Nueva llamada: cudaStreamAttachMemAsync()Gestiona concurrentemente las aplicaciones multi-hilo de la CPU. 52
Detalles técnicos
La máxima cantidad de memoria unificada que puede alojarse es la menor de las memorias que tienen las GPUs.
Aquella memoria unificada que sea tocada por la CPU debe migrar de regreso a la GPU antes de lanzar el kernel.
La CPU no puede acceder a la memoria unificada mientras la GPU esté ejecutando, esto es, debemos llamar a cudaDeviceSynchronize() antes de permitir a la CPU que pueda acceder a la memoria unificada.
La GPU tiene acceso exclusivo a la memoria unificada mientras se esté ejecutando un kernel, aunque éste no toque la memoria unificada (ver el primer ejemplo de la serie).
53
5.1. Ejemplos de programación
Ejemplo 1:Restricciones de acceso (2)
55
__device__ __managed__ int x, y = 2; // Memoria unificada
__global__ void mykernel() // Territorio GPU{ x = 10;}
int main() // Territorio CPU{ mykernel <<<1,1>>> ();
y = 20; // ERROR: Acceso desde CPU concurrente con GPU return 0;}
Ejemplo 1:Restricciones de acceso (2)
56
__device__ __managed__ int x, y = 2; // Memoria unificada
__global__ void mykernel() // Territorio GPU{ x = 10;}
int main() // Territorio CPU{ mykernel <<<1,1>>> (); cudaDeviceSynchronize(); // Solución // Ahora, la GPU está parada, el acceso a “y” no tiene riesgo y = 20; return 0;}
57
Código CUDA pre-versión 6.0SIN memoria unificada
Código CUDA post-versión 6.0CON memoria unificada
__global__ void incr (float *a, float b, int N){ int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < N) a[idx] = a[idx] + b;}void main(){ unsigned int numBytes = N*sizeof(float); float* h_A = (float* ) malloc(numBytes); float* d_A; cudaMalloc(&d_A, numBytes); cudaMemcpy(d_A,h_A,numBytes,cudaMemcpyHostToDevice); incr<<<N/blocksize,blocksize>>>(d_A,b,N); cudaMemcpy(h_A,d_A,numBytes,cudaMemcpyDeviceToHost); cudaFree(d_A); free(h_A);}
__global__ void incr (float *a, float b, int N){ int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < N) a[idx] = a[idx] + b;}void main(){
float* m_A; cudaMallocManaged(&m_A, numBytes);
incr<<<N/blocksize,blocksize>>>(m_A,b,N); cudaDeviceSynchronize(); cudaFree(m_A);}
Ejemplo 2: Incrementar un valor “b” a los N elementos de un vector “a”
Ejemplo 3: Ordenar un fichero de datos.Comparemos frente a las CPUs que usan C
58
Código para la CPU (en C) Código para la GPU (a partir de CUDA 6.0)
void sortfile (FILE *fp, int N) { char *data; data = (char *) malloc(N); fread(data, 1, N, fp);
qsort(data, N, 1, compare);
use_data(data);
free(data);}
void sortfile (FILE *fp, int N) { char *data; cudaMallocManaged(&data, N); fread(data, 1, N, fp);
qsort<<<...>>>(data, N, 1, compare); cudaDeviceSynchronize(); use_data(data);
cudaFree(data);}
Ejemplo 4: Clonando estructuras dinámicas SIN memoria unificada
Realizar copias sucesivas:Debemos copiar la estructura
y todos los contenidos a los que direcciona. Por eso C++ inventó el “copy constructor”.
CPU y GPU no pueden compartir una copia de sus datos (coherencia). Esto impide comparaciones tipo memcpy, checksums y demás.
59
dataElem
prop1
prop2
*text “Hola, mundo”
Memoria principal (CPU)
dataElem
prop1
prop2
*text “Hola, mundo”
Memoria de vídeo (GPU)
struct dataElem { int prop1; int prop2; char *text;}
Dos direccionesy dos copias diferentes delos datos
Clonando estructuras dinámicasSIN memoria unificada (2)
60
dataElem
prop1
prop2
*text “Hola, mundo”
Memoria principal (CPU)
dataElem
prop1
prop2
*text “Hola, mundo”
Memoria de vídeo (GPU)
void launch(dataElem *elem) { dataElem *g_elem; char *g_text;
int textlen = strlen(elem->text);
// Aloja almacenamiento para struct y text cudaMalloc(&g_elem, sizeof(dataElem)); cudaMalloc(&g_text, textlen);
// Copia cada pieza por separado, incluyendo un nuevo puntero en *text para la GPU cudaMemcpy(g_elem, elem, sizeof(dataElem)); cudaMemcpy(g_text, elem->text, textlen); cudaMemcpy(&(g_elem->text), &g_text, sizeof(g_text)); // Finalmente lanzamos el kernel, pero CPU y GPU usan diferentes copias de “elem” kernel<<< ... >>>(g_elem);}
Dos direccionesy dos copias diferentes delos datos
Clonando estructuras dinámicasCON memoria unificada
Lo que queda invariable:El movimiento de datos.GPU usa una copia local de text.
Lo que cambia:El programador ve un solo puntero.CPU y GPU acceden y referencian
al mismo objeto.Existe coherencia en memoria.
Para pasar por referencia y por valor se necesita usar C++.
61
void launch(dataElem *elem) { kernel<<< ... >>>(elem);}
dataElem
prop1
prop2
*text “Hola, mundo”
Memoria de vídeo (GPU)
Memoria unificada
Memoria principal (CPU)
Ejemplo 5: Listas enlazadas
Casi imposible de implementar con el API original de CUDA.La solución menos mala es utilizar memoria pinned:
Los punteros son globales, al igual que con memoria unificada.Pero el rendimiento es bajo: La GPU padece los accesos por PCI-e.La latencia de la GPU es muy alta, lo que resulta crítico para las listas
enlazadas debido al usual recorrido encadenado de punteros. 62
key
value
next
key
value
next
key
value
next
key
value
next
key
value
next
key
value
next
Todos los accesos por PCI-express
Memoria principal
Memoria de vídeo
Listas enlazadas con memoria unificada
Se pueden pasar elementos entre la CPU y la GPU.No hace falta mover datos entre la CPU y la GPU.
Se pueden insertar y borrar elementos desde CPU o GPU.Pero el programa debe prevenir condiciones de carrera (los datos
son coherentes entre CPU y GPU solo si se lanza y sincroniza). 63
key
value
next
key
value
next
key
value
next
Memoria principal (CPU)
Memoria de vídeo (GPU)
Memoria unificada: Resumen
cudaMallocManaged() reemplaza a cudaMalloc().cudaMemcpy() es ahora opcional.
Simplifica enormemente la portabilidad de código.Menos gestión de la memoria en el lado del host.
Permite compartir estructuras de datos entre CPU y GPUUn mismo puntero al dato. No hay que clonar su estructura.
Potente mecanismo en lenguajes de alto nivel como C++.
64
Memoria unificada: La hoja de ruta.Contribuciones según el nivel de abstracción
65
Nivel de abstracción
Consolidadoen 2014
Realizadodurante 2015-16
Lo más reciente (2017-2018)
Alto
Medio
Bajo
Un único puntero a los datos. cudaMemcpy()
ya no es necesaria
Mecanismos de prebúsqueda para anticipar la llegada de
datos en las copias
El alojamiento de la memoria
se unifica
Coherencia garantizada si se lanza y sincroniza
Directivas del programador para ayudar a una migración
de datos más eficiente
El uso de la memoria en la pila
también se unifica
Estructuras de datos compartidas en C y C++
Soporte adicional enlos sistemas operativos
Los mecanismos para aceleración de la coherencia se implementan en hardware
6. Planificación independiente de hilos
Nuevo modelo de sincronización y comunicación
En CUDA 9 se pueden definir sincronizaciones a 3 niveles:Intra-warp: Grupos de hilos dentro del warp.
Lanzar con el típico mikernel<<< , >>> o usando cudaLaunchKernel();
Inter-bloques: Múltiples bloques dentro de la malla.Lanzar usando cudaLaunchCooperativeKernel(mikernel);
Inter-GPUs: Múltiples GPUs dentro del sistema.Lanzar usando cudaLaunchCooperativeKernelMultiDevice(mikernel);
67
Intra-warp: Grupos cooperativos
Permite definir, sincronizar y particionar grupos de hilos cooperativos dentro del warp.
El programa puede ejecutarse en GPUs a partir de Kepler, aunque dispone de infraestructura hardware rápida en Volta:
Programación de algoritmos y estructuras de datos de forma natural.Agrupamiento y sincronización flexible de hilos.
La ejecución es escalable (desde unos pocos hilos a todos).Permite una sincronización explícita (a partir de CUDA 9).
Debemos adaptar el código antiguo al nuevo modelo de ejecución, eliminando la programación síncrona implícita de warps. Por ejemplo:
CUDA 9 depreca __shfl(), __ballot(), __any(), __all() asíncronos como transición a __shfl_sync(), __ballot_sync(), __any_sync(), ...
68
Grupos cooperativos:Sincronización explícita y flexible
Los grupos de hilos son objetos explícitos en tu programa:thread_group block = this_thread_block();
Se pueden sincronizar hilos dentro del grupo:block.sync();
Se pueden crear grupos particionando los ya existentes:thread_group tile32 = tiled_partition(block, 32);thread_group tile4 = tiled_partition(tile32, 4);
Los grupos particionados pueden sincronizarse también:tile4.sync();
69Nota: En verde las llamadas del nuevo API (espacio de nombres de grupos cooperativos). 70
Para cada bloque Para cada warp
g = my_thread_block();reduce(g, ptr, myVal);
g = tiled_partition<32>(my_thread_block());reduce(g, ptr, myVal);
Ejemplo 1: Suma por reducción en paralelo.Compuesta, robusta y eficiente
__device__ int reduce(thread_group g, int *x, int val) { int lane = g.thread_rank(); for (int i = g.size()/2; i > 0; i /= 2) { x[lane] = val; g.sync(); val += x[lane + i]; g.sync(); } return val;}
Ejemplo: Simulación de partículas
Sin emplear grupos cooperativos:
71
// Los hilos actualizan las partículas en paralelo// (posición, velocidad) integrate<<<blocks, threads, 0, s>>>(particles);
// Nota: sincron. implícita entre lanzam. de kernels
// Construye una malla para acelerar el proceso // de encontrar las colisiones entre partículascollide<<<blocks, threads, 0, s>>>(particles);
Notar cómo tras la creación de la malla, el orden de las partículas en memoria y su mapeo sobre los hilos cambia, requiriendo una sincronización entre fases y el lanzamiento de múltiples kernels. Utilizando grupos cooperativos, todas las sincronizaciones pueden hacerse dentro de un único kernel.
Cooperación para toda la malla (grid)
La actualización se hace en un solo kernel:
Lanzar usando cudaLaunchCooperativeKernel();72
__global__ void particleSim(Particle *p, int N) {
grid_group g = this_grid(); for (i = g.thread_rank(); i < N; i += g.size()) integrate(p[i]);
g.sync(); // Sync whole grid!
for (i = g.thread_rank(); i < N; i += g.size()) collide(p[i], p, N);}
Cooperación multi-GPU
Large-scale multi-GPU simulation in a single kernel:
Lanzar usando cudaLaunchCooperativeKernelMultiDevice();73
__global__ void particleSim(Particle *p, int N) {
multi_grid_group g = this_multi_grid(); for (i = g.thread_rank(); i < N; i += g.size()) integrate(p[i]);
g.sync(); // Sync all GPUs!
for (i = g.thread_rank(); i < N; i += g.size()) collide(p[i], p, N);}