Figura 4.2 Accesos a la jerarquía de memoria
4.2. Memoria Global
La memoria global es una memoria off-chip, por lo tanto su acceso es lento. Esta memoria se divide en tres espacios de memoria separada: la Memoria Global, la Memoria de Textura y la Memoria de Constantes. El primer espacio es de acceso tanto de lectura como de escritura, no así la memoria de constante y de textura, a la cual todos
los threads de un grid pueden acceder sólo para lecturas. Tanto la
memoria de textura y constante poseen caché on-chip. En esta sección se hace referencia a la memoria global, las otras se explican en las secciones siguientes.
Para declarar una variable en memoria global se antepone, opcionalmente, a la declaración de la misma la palabra clave
__device__. Una variable en memoria global es declarada en el host. Todos los threads de todos los bloques del grid pueden acceder a la variable global, ésta puede verse como una variable compartida por todos los threads del grid. El ciclo de vida de una variable global es todo la aplicación, esto significa que una vez finalizado un kernel los valores de las variables globales modificadas persisten y pueden ser recuperados y accedidos por los siguientes kernels. Por lo tanto, las variables globales pueden utilizarse como medio de comunicación de
threads de distintos bloques. Generalmente se utilizan para transmitir
información entre distintos kernels.
El acceso a una variable global es más lento y como todos los threads de un grid pueden acceder a ella, son necesarios mecanismos de sincronización para asegurar la consistencia de los datos, una de ellas son las funciones atómicas (Apéndice A y C).
CUDA tiene limitaciones respecto al uso de variables punteros a la memoria del dispositivo. En general, se usan punteros a datos de la memoria global. Los punteros se pueden usar de dos maneras, una cuando un objeto es alojado en la memoria del dispositivo desde el host a través de la función cudaMalloc() y pasado como parámetro en la invocación del kernel. El segundo uso es para asignarle la dirección de una variable global para trabajar sobre ella. En la figura 4.3 se muestra el código del kernel de la suma de vectores detallada en el capítulo anterior. Las variables dev_a, dev_b y dev_c pasadas como parámetro
del kernel son ejemplo de variables punteros a objetos residente en la
1. #define N 100
2. int main( void ) {
3. int a[N], b[N], c[N];
4. __device__ int *dev_a, *dev_b, *dev_c;
5. inicializa(a); //Inicializa los vectores de entrada a y b
6. inicializa(b);
7. cudaMalloc( (void**)&dev_a, N * sizeof(int)); // reserva
memoria en la GPU
8. cudaMalloc( (void**)&dev_b, N * sizeof(int)); 9. cudaMalloc( (void**)&dev_c, N * sizeof(int));
10. // copia los vectores A y B a la GPU
11. cudaMemcpy( dev_a, a, N * sizeof(int),
cudaMemcpyHostToDevice);
12. cudaMemcpy( dev_b, b, N * sizeof(int),
cudaMemcpyHostToDevice );
13. Suma_vec<<<1,N>>>( dev_a, dev_b, dev_c );
14. // copia el resultado desde la GPU en el vector C de la CPU
15. cudaMemcpy( c, dev_c, N * sizeof(int),
cudaMemcpyDeviceToHost);
16. cudaFree( dev_a ); // libera la memoria reservada de la GPU
17. cudaFree( dev_b );
18. cudaFree( dev_c );
19. mostrar_resultados ( C); // Muestra los resultados
20. return 0;
21. }
Figura 4.3. Variables globales en la suma de vectores de la GPU
Como se mencionó antes, el acceso a la memoria global es más lento, dependiendo del patrón de acceso puede variar la velocidad. Los accesos a la memoria global se resuelven en medio warp. Cuando un acceso a memoria global es ejecutada por un warp, se realizan dos peticiones: una para la primera mitad del warp y otra para la segunda mitad. Para aumentar la eficiencia de la memoria global, el hardware puede unificar las transacciones dependiendo del patrón de acceso. Las restricciones para la unificación depende de la arquitectura, en las más modernas GPU basta con que todos los threads de medio warp accedan al mismo segmento de memoria (las restricciones de las arquitecturas más antiguas pueden encontrarse en (NVIDIA., 2011). El patrón de acceso dentro del segmento no importa, varios threads pueden acceder a un dato, puede haber permutaciones, etc. Sin embargo, si los threads acceden a n segmentos distintos de memoria, entonces se producen n transacciones. El tamaño del segmento puede ser:
32 bytes si todos los threads acceden a palabras de 1 byte,
64 bytes si todos los threads acceden a palabras de 2 bytes,
128 bytes si todos los threads acceden a palabras de 4 bytes. Si los threads no acceden a todos los datos del segmento, entonces se leen datos que no serán usados y se desperdicia ancho de banda. Por ello, el hardware facilita un sistema para acotar la cantidad de datos a traer dentro del segmento, pudiendo traer subsegmentos de 32 bytes o 64 bytes. La figura 4.4 muestra algunos ejemplos de acceso a la memoria global.
Figura 4.4. Patrones de acceso a la memoria global
En los tres casos los threads acceden a palabras de 4B, esto implica un tamaño de segmento de 128B. En el caso de la figura 4.4. (a), los threads acceden a 16 posiciones consecutivas alineadas con el segmento de 128B, por lo que el hardware reduce el tamaño a 64B para evitar leer datos inútiles. De esta forma, realiza una única transacción de 64B. En la figura 4.4. (b) ocurre todo lo contrario, también se accede a 16 posiciones, pero al no estar alineadas, el hardware no puede reducir la cantidad de datos a leer y genera una
acceden a palabras alojadas en distintos segmentos de 128B implicando la generación de dos transacciones, las cuales son una de 32B y la otra de 64B.
La memoria global es una de las memorias más utilizadas, es el medio de comunicación entre el host y la GPU. Además es la memoria de mayor tamaño, su buen uso implica tener en cuenta todas las consideraciones aquí mencionadas para obtener buena performance.