web-dev-qa-db-fra.com

Dans un noyau CUDA, comment puis-je stocker un tableau dans la "mémoire de thread local"?

J'essaie de développer un petit programme avec CUDA, mais comme c'était LENT, j'ai fait quelques tests et googlé un peu. J'ai découvert que, bien que les variables uniques soient par défaut stockées dans la mémoire du thread local, les tableaux ne le sont généralement pas. Je suppose que c'est pourquoi cela prend autant de temps à exécuter. Maintenant, je me demande: puisque la mémoire des threads locaux doit être d'au moins 16 Ko et que mes tableaux sont comme 52 caractères, existe-t-il un moyen (syntaxe s'il vous plaît :)) de les stocker dans la mémoire locale?

Cela ne devrait-il pas être quelque chose comme:

__global__ my_kernel(int a)
{
  __local__ unsigned char p[50];
}
24
Matteo Monti

Matrices, mémoire locale et registres

Il y a une idée fausse ici concernant la définition de "mémoire locale". La "mémoire locale" dans CUDA est en fait de la mémoire globale (et devrait vraiment être appelée "mémoire globale locale du thread") avec un adressage entrelacé (ce qui rend l'itération sur un tableau en parallèle un peu plus rapide que d'avoir les données de chaque thread bloquées ensemble). Si vous voulez que les choses soient vraiment rapides, vous devez utiliser la mémoire partagée ou, mieux encore, les registres (en particulier sur les derniers appareils où vous obtenez jusqu'à 255 registres par thread). Expliquer l'intégralité hiérarchie de la mémoire CUDA sort du cadre de cet article. Concentrons-nous plutôt sur la rapidité des calculs de petits tableaux.

Les petits tableaux, tout comme les variables, peuvent être stockés entièrement dans des registres. Cependant, sur le matériel NVIDIA actuel, il est difficile de mettre des tableaux en registres. Pourquoi? Parce que les registres nécessitent un traitement très soigné. Si vous ne le faites pas correctement, vos données se retrouveront dans la mémoire locale (qui, encore une fois, est vraiment de la mémoire globale, qui est la mémoire la plus lente que vous ayez). Le Guide de programmation CUDA, section 5.3.2 vous indique quand la mémoire locale est utilisée:

Mémoire locale

Les accès à la mémoire locale ne se produisent que pour certaines variables automatiques, comme mentionné dans Qualificateurs de type de variable. Les variables automatiques que le compilateur est susceptible de placer dans la mémoire locale sont:

  1. Tableaux pour lesquels il ne peut pas déterminer qu'ils sont indexés avec des quantités constantes,
  2. Grandes structures ou tableaux qui consommeraient trop d'espace de registre,
  3. N'importe quelle variable si le noyau utilise plus de registres que disponible (cela est également connu sous le nom de déversement de registres).

Comment fonctionne l'allocation des registres?

Notez que l'allocation des registres est un processus extrêmement compliqué, c'est pourquoi vous ne pouvez pas (et ne devriez pas) y interférer. Au lieu de cela, le compilateur convertira le code CUDA en code PTX (une sorte de bytecode) qui suppose une machine avec une infinité de registres. Vous pouvez écrire PTX en ligne mais cela ne fera pas trop pour enregistrer l'allocation. Le code PTX est un code indépendant de l'appareil et ce n'est que la première étape. Dans un deuxième temps, PTX sera compilé en code d'assemblage d'appareil, appelé SASS. Le code SASS a les allocations de registre réelles. Le compilateur SASS et son optimiseur seront également l'autorité ultime pour savoir si une variable sera dans les registres ou dans la mémoire locale. Tout ce que vous pouvez faire est d'essayer de comprendre ce que fait le compilateur SASS dans certains cas et de l'utiliser à votre avantage. La vue de corrélation de code dans Nsight peut vous aider avec cela (voir ci-dessous). Cependant, comme le compilateur et l'optimiseur continuent de changer, il n'y a aucune garantie quant à ce qui sera ou ne sera pas dans les registres.

Registres insuffisants

L'annexe G, section 1 vous indique le nombre de registres qu'un thread peut avoir. Recherchez "Nombre maximum de registres 32 bits par thread". Pour interpréter ce tableau, vous devez connaître votre capacité de calcul (voir ci-dessous). N'oubliez pas que les registres sont utilisés pour toutes sortes de choses et ne sont pas seulement en corrélation avec des variables uniques. Les registres sur tous les appareils jusqu'à CC 3.5 sont 32 bits chacun. Si le compilateur est suffisamment intelligent (et que le compilateur CUDA continue de changer), il peut par exemple regrouper plusieurs octets dans le même registre. La vue de corrélation de code Nsight (voir "Analyse des accès à la mémoire" ci-dessous) le révèle également.

Indexation constante ou dynamique

Bien que la contrainte d'espace soit un obstacle évident aux tableaux en registre, la chose qui est facilement supervisée est le fait que, sur le matériel actuel (capacité de calcul 3.x et inférieure), le compilateur place tout tableau dans la mémoire locale accessible avec indexation dynamique. Un index dynamique est un index que le compilateur ne peut pas comprendre. Les tableaux accédés avec des indices dynamiques ne peuvent pas être placés dans des registres car les registres doivent être déterminés par le compilateur, et donc le registre réel utilisé ne doit pas dépendre d'une valeur déterminée au moment de l'exécution. Par exemple, étant donné un tableau arr, arr[k] est une indexation constante si et seulement si k est une constante, ou ne dépend que de constantes. Si k, de quelque manière que ce soit, dépend d'une valeur non constante, le compilateur ne peut pas calculer la valeur de k et vous obtenez l'indexation dynamique . Dans les boucles où k commence et se termine à un (petit) nombre constant, le compilateur (très probablement) peut dérouler votre boucle, et peut toujours obtenir une indexation constante.

Exemple

Par exemple, le tri d'un petit tableau peut être effectué dans des registres mais vous devez utiliser réseaux de tri ou des approches "câblées" similaires, et ne pouvez pas simplement utiliser un algorithme standard car la plupart des algorithmes utilisent l'indexation dynamique.

Avec une probabilité assez élevée, dans l'exemple de code suivant, le compilateur conserve l'intégralité du tableau aBytes dans les registres car il n'est pas trop grand et les boucles peuvent être entièrement déroulées (car la boucle itère sur une plage constante). Le compilateur (très probablement) sait quel registre est accédé à chaque étape et peut donc le garder entièrement dans les registres. Gardez à l'esprit qu'il n'y a aucune garantie. Le mieux que vous puissiez faire est de le vérifier au cas par cas à l'aide des outils de développement CUDA, comme décrit ci-dessous.

__global__
void
testSortingNetwork4(const char * aInput, char * aResult)
{
    const int NBytes = 4;

    char aBytes[NBytes];

    // copy input to local array
    for (int i = 0; i < NBytes; ++i)
    {
        aBytes[i] = aInput[i];
    }

    // sort using sorting network
    CompareAndSwap(aBytes, 0, 2); CompareAndSwap(aBytes, 1, 3); 
    CompareAndSwap(aBytes, 0, 1); CompareAndSwap(aBytes, 2, 3); 
    CompareAndSwap(aBytes, 1, 2); 


    // copy back to result array
    for (int i = 0; i < NBytes; ++i)
    {
        aResult[i] = aBytes[i];
    }
}

Analyse des accès à la mémoire

Une fois que vous avez terminé, vous voulez généralement vérifier si les données sont réellement stockées dans des registres ou si elles sont allées dans la mémoire locale. La première chose que vous pouvez faire est de dites à votre compilateur de vous donner des statistiques de mémoire en utilisant le --ptxas-options=-v flag . Une manière plus détaillée d'analyser les accès à la mémoire utilise Nsight .

Nsight a de nombreuses fonctionnalités intéressantes. Nsight pour Visual Studio a un profileur intégré et une vue de corrélation de code CUDA <-> SASS. La fonctionnalité est expliquée ici . Notez que les versions de Nsight pour différents IDE sont probablement développées indépendamment, et donc leurs fonctionnalités peuvent varier entre les différentes implémentations.

Si vous suivez les instructions du lien ci-dessus (assurez-vous d'ajouter les drapeaux correspondants lors de la compilation!), Vous pouvez trouver le bouton "CUDA Memory Transactions" tout en bas du menu inférieur. Dans cette vue, vous voulez trouver qu'il n'y a pas de transaction de mémoire provenant des lignes qui ne fonctionnent que sur le tableau correspondant (par exemple les lignes CompareAndSwap dans mon exemple de code). Parce que s'il ne signale aucun accès à la mémoire pour ces lignes, vous avez (très probablement) été en mesure de conserver l'intégralité du calcul dans les registres et vous auriez pu gagner une vitesse de plusieurs milliers, sinon des dizaines de milliers, de pour cent (vous pourriez également vouloir vérifiez le gain de vitesse réel, vous en sortez!).

Déterminer la capacité de calcul

Afin de déterminer le nombre de registres dont vous disposez, vous devez connaître la capacité de calcul de votre appareil. La manière standard d'obtenir ces informations sur le périphérique est d'exécuter l'exemple de requête de périphérique. Pour CUDA 5.5 sur Windows 64 bits, qui se trouve par défaut dans C:\ProgramData\NVIDIA Corporation\CUDA Samples\v5.5\Bin\win64\Release\deviceQuery.exe (Sous Windows, la fenêtre de la console se fermera immédiatement, vous pouvez d'abord ouvrir cmd et l'exécuter à partir de là). Il a un emplacement similaire sur Linux et MAC.

Si vous avez Nsight pour Visual Studio, accédez simplement à Nsight -> Windows -> Informations système.

N'optimisez pas tôt

Je partage cela aujourd'hui parce que j'ai rencontré ce problème très récemment. Cependant, comme mentionné dans ce fil , forcer les données à être dans les registres n'est certainement pas la première étape que vous souhaitez prendre. Tout d'abord, assurez-vous de bien comprendre ce qui se passe, puis abordez le problème étape par étape. La consultation du code d'assemblage est certainement une bonne étape, mais elle ne devrait généralement pas être la première. Si vous débutez avec CUDA, le Guide des meilleures pratiques CUDA vous aidera à comprendre certaines de ces étapes.

66
Domi

Tout ce dont vous avez besoin est le suivant:

__global__ my_kernel(int a)
{
    unsigned char p[50];
    ........
}

Le compilateur renversera automatiquement cela pour enfiler la mémoire locale si nécessaire. Mais sachez que la mémoire locale est stockée en SDRAM hors du GPU, et elle est aussi lente que la mémoire globale. Donc, si vous espérez que cela entraînera une amélioration des performances, il se peut que vous soyez déçu .....

11
talonmies

~ Pour quelqu'un qui traverse cela à l'avenir ~

En un mot, pour créer un tableau pour chaque thread, vous souhaitez les créer dans la mémoire de l'appareil. Pour ce faire, un peu de mémoire partagée peut être créée par thread. Une attention particulière doit être portée pour éviter les conflits ou les performances chuteront.

Voici un exemple tiré d'un article de blog nvidia de Maxim Milakov en 2015:

// Should be multiple of 32
#define THREADBLOCK_SIZE 64 
// Could be any number, but the whole array should fit into shared memory 
#define ARRAY_SIZE 32 

__device__ __forceinline__ int no_bank_conflict_index(int thread_id, int logical_index)
{
    return logical_index * THREADBLOCK_SIZE + thread_id;
}

__global__ void kernel5(float * buf, int * index_buf)
{
    // Declare shared memory array A which will hold virtual 
    // private arrays of size ARRAY_SIZE elements for all 
    // THREADBLOCK_SIZE threads of a threadblock
    __shared__ float A[ARRAY_SIZE * THREADBLOCK_SIZE]; 
    ...
    int index = index_buf[threadIdx.x + blockIdx.x * blockDim.x];

    // Here we assume thread block is 1D so threadIdx.x 
    // enumerates all threads in the thread block
    float val = A[no_bank_conflict_index(threadIdx.x, index)];
    ...
}
1
Sunsetquest