web-dev-qa-db-fra.com

Comment et quand dois-je utiliser le pointeur avec l'API cuda?

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?

51
Ernest_Galbrun

Voici une explication sur le pointeur et le rembourrage dans CUDA.

Mémoire linéaire vs mémoire rembourrée

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];

Alignement des mots

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.

Conflits bancaires

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:

  1. Attribuez la première ligne.
  2. Vérifiez si le nombre d'octets alloués le rend correctement aligné. Par exemple, il s'agit d'un multiple de 128.
  3. Sinon, allouez des octets supplémentaires pour atteindre le prochain multiple de 128. le pas est alors le nombre d'octets alloués pour une seule ligne, y compris les octets supplémentaires (octets de remplissage).
  4. Réitérez pour chaque ligne.

À 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.

Copie de données vers/depuis la mémoire pitchée

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.)

Gérer les tableaux 3D

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:

  • Lorsque vous utilisez cudaMalloc3D, vous recevez une valeur de hauteur que vous devez soigneusement conserver pour un accès ultérieur à la mémoire.
  • Lors de la copie d'un morceau de mémoire 3D, vous ne pouvez pas utiliser cudaMemcpy à moins que vous ne copiiez une seule ligne. Vous devez utiliser tout autre type d'utilitaire de copie fourni par l'utilitaire CUDA qui prend en compte la hauteur.
  • Lorsque vous copiez vos données vers/depuis la mémoire linéaire, vous devez fournir un pas à votre pointeur même s'il n'est pas pertinent: ce pas est la taille d'une ligne, exprimée en octets.
  • Les paramètres de taille sont exprimés en octets pour la taille de ligne et en nombre d'éléments pour la dimension de colonne et de profondeur.
84
Ernest_Galbrun