J'ai une bonne compréhension de l'allocation et de la copie de la mémoire linéaire avec cudaMalloc()
et cudaMemcpy()
. Cependant, lorsque je souhaite utiliser les fonctions CUDA pour allouer et copier des matrices 2D ou 3D, je suis souvent déconcerté par les différents arguments, en particulier concernant les pointeurs pitchés qui sont toujours présents dans les tableaux 2D/3D. La documentation est bonne pour fournir quelques exemples sur la façon de les utiliser, mais elle suppose que je connais la notion de rembourrage et de hauteur, ce que je ne suis pas.
Je finis généralement par peaufiner les divers exemples que je trouve dans la documentation ou ailleurs sur le Web, mais le débogage aveugle qui suit est assez douloureux, donc ma question est:
Qu'est-ce qu'un pitch? Comment est-ce que je l'utilise? Comment allouer et copier des tableaux 2D et 3D dans CUDA?
Voici une explication sur le pointeur et le rembourrage dans CUDA.
Tout d'abord, commençons par la raison de l'existence d'une mémoire non linéaire. Lors de l'allocation de mémoire avec cudaMalloc, le résultat est comme une allocation avec malloc, nous avons un morceau de mémoire contigu de la taille spécifiée et nous pouvons y mettre tout ce que nous voulons. Si nous voulons allouer un vecteur de 10000 float, nous faisons simplement:
float* myVector;
cudaMalloc(&myVector, 10000*sizeof(float));
puis accéder au ième élément de myVector par indexation classique:
float element = myVector[i];
et si nous voulons accéder à l'élément suivant, nous faisons juste:
float next_element = myvector[i+1];
Cela fonctionne très bien car accéder à un élément juste à côté du premier est (pour des raisons que je ne connais pas et je ne souhaite pas l'être pour l'instant) bon marché.
Les choses deviennent un peu différentes lorsque nous utilisons notre mémoire comme un tableau 2D. Disons que notre vecteur flottant 10000 est en fait un tableau 100x100. Nous pouvons l'allouer en utilisant la même fonction cudaMalloc, et si nous voulons lire la i-ème ligne, nous faisons:
float* myArray;
cudaMalloc(&myArray, 10000*sizeof(float));
int row[100]; // number of columns
for (int j=0; j<100; ++j)
row[j] = myArray[i*100+j];
Nous devons donc lire la mémoire de myArray + 100 * i à myArray + 101 * i-1. Le nombre d'opérations d'accès à la mémoire qu'il faudra dépend du nombre de mots de mémoire que cette ligne prend. Le nombre d'octets dans un mot mémoire dépend de l'implémentation. Pour minimiser le nombre d'accès à la mémoire lors de la lecture d'une seule ligne, nous devons nous assurer que nous commençons la ligne au début d'un mot, donc nous devons remplir la mémoire pour chaque ligne jusqu'au début d'une nouvelle.
Une autre raison pour le remplissage des tableaux est le mécanisme de banque dans CUDA, concernant l'accès à la mémoire partagée. Lorsque la baie est dans la mémoire partagée, elle est divisée en plusieurs banques de mémoire. Deux threads CUDA peuvent y accéder simultanément, à condition qu'ils n'accèdent pas à la mémoire appartenant à la même banque de mémoire. Étant donné que nous souhaitons généralement traiter chaque ligne en parallèle, nous pouvons nous assurer que nous pouvons y accéder de manière simulée en remplissant chaque ligne au début d'une nouvelle banque.
Maintenant, au lieu d'allouer le tableau 2D avec cudaMalloc, nous allons utiliser cudaMallocPitch:
size_t pitch;
float* myArray;
cudaMallocPitch(&myArray, &pitch, 100*sizeof(float), 100); // width in bytes by height
Notez que la hauteur ici est la valeur de retour de la fonction: cudaMallocPitch vérifie ce qu'elle devrait être sur votre système et renvoie la valeur appropriée. Ce que fait cudaMallocPitch est le suivant:
À la fin, nous avons généralement alloué plus de mémoire que nécessaire, car chaque ligne correspond désormais à la taille de la hauteur, et non à la taille de w*sizeof(float)
.
Mais maintenant, quand nous voulons accéder à un élément dans une colonne, nous devons faire:
float* row_start = (float*)((char*)myArray + row * pitch);
float column_element = row_start[column];
Le décalage en octets entre deux colonnes successives ne peut plus être déduit de la taille de notre tableau, c'est pourquoi nous souhaitons conserver la hauteur renvoyée par cudaMallocPitch. Et comme la hauteur est un multiple de la taille de remplissage (généralement, la plus grande de la taille Word et de la taille de la banque), cela fonctionne très bien. Yay.
Maintenant que nous savons comment créer et accéder à un seul élément dans un tableau créé par cudaMallocPitch, nous pourrions vouloir en copier une partie entière vers et depuis une autre mémoire, linéaire ou non.
Disons que nous voulons copier notre tableau dans un tableau 100x100 alloué sur notre hôte avec malloc:
float* Host_memory = (float*)malloc(100*100*sizeof(float));
Si nous utilisons cudaMemcpy, nous copierons toute la mémoire allouée avec cudaMallocPitch, y compris les octets remplis entre chaque ligne. Ce que nous devons faire pour éviter de remplir la mémoire, c'est de copier chaque ligne une par une. Nous pouvons le faire manuellement:
for (size_t i=0; i<100; ++i) {
cudaMemcpy(Host_memory[i*100], myArray[pitch*i],
100*sizeof(float), cudaMemcpyDeviceToHost);
}
Ou nous pouvons dire à l'API CUDA que nous voulons uniquement la mémoire utile de la mémoire que nous avons allouée avec des octets de remplissage pour sa commodité , donc si elle pouvait gérer son propre gâchis automatiquement ce serait très sympa en effet, merci. Et ici entre cudaMemcpy2D:
cudaMemcpy2D(Host_memory, 100*sizeof(float)/*no pitch on Host*/,
myArray, pitch/*CUDA pitch*/,
100*sizeof(float)/*width in bytes*/, 100/*heigth*/,
cudaMemcpyDeviceToHost);
Maintenant, la copie se fera automatiquement. Il copiera le nombre d'octets spécifié en largeur (ici: 100xsizeof (float)), le temps en hauteur (ici: 100), en sautant le pas octets à chaque fois il saute à une rangée suivante. Notez que nous devons toujours fournir la hauteur de la mémoire de destination car elle pourrait également être complétée. Ici, ce n'est pas le cas, donc la hauteur est égale à la hauteur d'un tableau non rembourré: c'est la taille d'une ligne. Notez également que le paramètre width dans la fonction memcpy est exprimé en octets, mais le paramètre height est exprimé en nombre d'éléments. C'est à cause de la façon dont la copie est effectuée, d'une certaine manière, comme j'ai écrit la copie manuelle ci-dessus: la largeur est la taille de chaque copie le long d'une ligne (éléments contigus en mémoire) et la hauteur est le nombre de fois que cette opération doit être accompli. (Ces incohérences dans les unités, en tant que physicien, m'énervent beaucoup.)
Les tableaux 3D ne sont pas différents des tableaux 2D en fait, aucun rembourrage supplémentaire n'est inclus. Un tableau 3D est juste un tableau 2D classique de lignes rembourrées. C'est pourquoi lors de l'allocation d'un tableau 3D, vous n'obtenez qu'un seul pas qui est la différence de décompte d'octets entre les points successifs d'une rangée. Si vous souhaitez accéder à des points successifs le long de la dimension de profondeur, vous pouvez multiplier le pas en toute sécurité par le nombre de colonnes, ce qui vous donne le slicePitch.
L'API CUDA pour accéder à la mémoire 3D est légèrement différente de celle pour la mémoire 2D, mais l'idée est la même: