Table de hachage simple pour GPU


J'ai posté sur Github un nouveau projet appelé A Simple GPU Hash Table .

Il s'agit d'une simple table de hachage pour le GPU, capable de traiter des centaines de millions d'inserts par seconde. Sur mon ordinateur portable équipé d'une NVIDIA GTX 1060, le code insère 64 millions de paires clé-valeur générées aléatoirement en 210 ms environ et supprime 32 millions de paires en 64 ms environ.

Autrement dit, la vitesse sur l'ordinateur portable est d'environ 300 millions d'insertions / sec et 500 millions de suppressions / sec.

Le tableau est écrit en CUDA, bien que la même technique puisse être appliquée à HLSL ou GLSL. L'implémentation présente plusieurs limitations qui garantissent des performances élevées sur la carte vidéo:

  • Seules les clés 32 bits et les mêmes valeurs sont traitées.
  • La table de hachage a une taille fixe.
  • Et cette taille doit être égale à deux degrés.

Pour les clés et les valeurs, vous devez réserver un marqueur de délimitation simple (dans le code ci-dessus, il s'agit de 0xffffffff).

Table de hachage sans serrures


La table de hachage utilise l'adressage ouvert avec détection linéaire , c'est-à-dire qu'il s'agit simplement d'un tableau de paires clé-valeur qui est stocké en mémoire et qui présente d'excellentes performances de cache. Ce n'est pas le cas avec le chaînage, ce qui signifie rechercher un pointeur dans une liste chaînée. Une table de hachage est un simple tableau stockant des éléments KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

La taille de la table est égale à deux en puissance et non à un nombre premier, car pour utiliser pow2 / AND-mask, une instruction rapide suffit et l'opérateur du module est beaucoup plus lent. Ceci est important dans le cas de la détection linéaire, car dans une recherche linéaire dans le tableau, l'indice de créneau doit être enveloppé dans chaque créneau. Et par conséquent, le coût de l'opération est ajouté modulo dans chaque emplacement.

La table stocke uniquement la clé et la valeur de chaque élément, pas le hachage de clé. Étant donné que la table ne stocke que des clés 32 bits, le hachage est calculé très rapidement. Le code ci-dessus utilise le hachage Murmur3, qui n'effectue que quelques décalages, XOR et multiplications.

La table de hachage utilise une technique de protection de verrouillage qui ne dépend pas de l'ordre de placement de la mémoire. Même si certaines opérations d'écriture violent l'ordre d'autres opérations de ce type, la table de hachage conservera toujours l'état correct. Nous en parlerons ci-dessous. La technique fonctionne très bien avec les cartes vidéo dans lesquelles des milliers de threads rivalisent.

Les clés et les valeurs de la table de hachage sont initialisées pour être vides.

Le code peut être modifié afin de pouvoir traiter à la fois les clés et les valeurs 64 bits. Les clés nécessitent des opérations atomiques de lecture, d'écriture et d'échange (comparaison et échange). Et les valeurs nécessitent des opérations de lecture et d'écriture atomiques. Heureusement, dans CUDA, les opérations de lecture-écriture pour les valeurs 32 bits et 64 bits sont atomiques tant qu'elles sont naturellement alignées (voir ici), et les cartes vidéo modernes prennent en charge les opérations atomiques 64 bits de comparaison avec l'échange. Bien sûr, lors du passage à 64 bits, les performances diminueront légèrement.

État de la table de hachage


Chaque paire clé-valeur dans une table de hachage peut avoir l'un des quatre états suivants:

  • La clé et le sens sont vides. Dans cet état, la table de hachage est initialisée.
  • La clé a été enregistrée, mais la valeur n'est pas encore. Si un autre thread d'exécution lit des données à ce moment, il renvoie une valeur vide. C'est normal, la même chose se produirait si un autre fil d'exécution fonctionnait un peu plus tôt, et nous parlons d'une structure de données compétitive.
  • La clé et la valeur sont enregistrées.
  • La valeur est disponible pour d'autres threads d'exécution, mais la clé ne l'est pas encore. Cela peut se produire car le modèle de programmation CUDA implique un modèle de mémoire mal ordonné. C'est normal, en tout état de cause, la clé est toujours vide, même si la valeur ne l'est pas.

Une nuance importante est que dès que la clé a été enregistrée dans la fente, elle ne bouge plus - même si la clé est supprimée, nous en parlerons ci-dessous.

Le code de table de hachage fonctionne même avec des modèles de mémoire mal ordonnés qui ne connaissent pas l'ordre de lecture et d'écriture dans la mémoire. Lorsque nous analysons l'insertion, la recherche et la suppression dans la table de hachage, n'oubliez pas que chaque paire clé-valeur est dans l'un des quatre états décrits ci-dessus.

Insérer dans une table de hachage


Une fonction CUDA qui insère des paires clé-valeur dans une table de hachage ressemble à ceci:

void gpu_hashtable_insert(KeyValue* hashtable, uint32_t key, uint32_t value)
{
    uint32_t slot = hash(key);

    while (true)
    {
        uint32_t prev = atomicCAS(&hashtable[slot].key, kEmpty, key);
        if (prev == kEmpty || prev == key)
        {
            hashtable[slot].value = value;
            break;
        }
        slot = (slot + 1) & (kHashTableCapacity-1);
    }
}

Pour insérer une clé, le code parcourt le tableau de table de hachage en commençant par le hachage de la clé insérée. Dans chaque emplacement de la matrice, une opération de comparaison atomique est effectuée avec l'échange, dans laquelle la clé de cet emplacement est comparée à une clé vide. Si une incompatibilité est détectée, la clé de l'emplacement est mise à jour vers la clé insérée, puis la clé d'origine de l'emplacement est renvoyée. Si cette clé d'origine était vide ou correspondait à la clé insérée, le code a trouvé un emplacement approprié pour l'insertion et apporte la valeur insérée dans l'emplacement.

Si dans un seul appel du noyaugpu_hashtable_insert()Il y a plusieurs éléments avec la même clé, alors n'importe laquelle de leurs valeurs peut être écrite dans l'emplacement de clé. Ceci est considéré comme normal: l'une des opérations d'écriture de valeur-clé au cours de l'appel réussira, mais comme tout cela se produit en parallèle dans plusieurs threads d'exécution, nous ne pouvons pas prédire quelle opération d'écriture dans la mémoire sera la dernière.

Recherche de table de hachage


Code de recherche de clé:

uint32_t gpu_hashtable_lookup(KeyValue* hashtable, uint32_t key)
{
        uint32_t slot = hash(key);

        while (true)
        {
            if (hashtable[slot].key == key)
            {
                return hashtable[slot].value;
            }
            if (hashtable[slot].key == kEmpty)
            {
                return kEmpty;
            }
            slot = (slot + 1) & (kHashTableCapacity - 1);
        }
}

Pour trouver la valeur de la clé stockée dans la table, nous parcourons le tableau en commençant par le hachage de la clé souhaitée. Dans chaque emplacement, nous vérifions si la clé est celle que nous recherchons, et si c'est le cas, retournons ensuite sa valeur. Nous vérifions également si la clé est vide, et si oui, nous interrompons la recherche.

Si nous ne pouvons pas trouver la clé, le code renvoie une valeur vide.

Toutes ces opérations de recherche peuvent être effectuées de manière compétitive lors des insertions et des suppressions. Chaque paire du tableau aura l'un des quatre états décrits ci-dessus pour le flux.

Suppression de table de hachage


Code de suppression de clé:

void gpu_hashtable_delete(KeyValue* hashtable, uint32_t key, uint32_t value)
{
    uint32_t slot = hash(key);

    while (true)
    {
        if (hashtable[slot].key == key)
        {
            hashtable[slot].value = kEmpty;
            return;
        }
        if (hashtable[slot].key == kEmpty)
        {
            return;
        }
        slot = (slot + 1) & (kHashTableCapacity - 1);
    }
}

La suppression d'une clé est inhabituelle: nous laissons la clé dans le tableau et marquons sa valeur (pas la clé elle-même) vide. Ce code est très similaire à lookup(), sauf que lorsqu'une correspondance est trouvée pour la clé, il rend sa valeur vide.

Comme mentionné ci-dessus, une fois que la clé est écrite dans l'emplacement, elle ne bouge plus. Même lorsque vous supprimez un élément du tableau, la clé reste en place, seule sa valeur devient vide. Cela signifie que nous n'avons pas besoin d'utiliser l'opération atomique d'écriture de la valeur de l'emplacement, car peu importe si la valeur actuelle est vide ou non - elle deviendra toujours vide.

Redimensionner une table de hachage


Vous pouvez redimensionner la table de hachage en créant une table plus grande et en y insérant des éléments non vides de l'ancienne table. Je n'ai pas implémenté cette fonctionnalité car je voulais garder l'exemple de code simple. De plus, dans les programmes CUDA, l'allocation de mémoire se fait souvent dans le code hôte, et non dans le noyau CUDA.

L'article A A Lock-Free Wait-Free Hash Table décrit comment modifier une telle structure de données protégée par un verrou.

Compétitivité


Dans les extraits de code ci - dessus, les fonctions gpu_hashtable_insert(), _lookup()et _delete()procédé à une seule paire de valeurs de clé à la fois. Et ci gpu_hashtable_insert()- dessous , _lookup()ils _delete()traitent un tableau de paires en parallèle, chaque paire dans un thread d'exécution GPU distinct:

// CPU code to invoke the CUDA kernel on the GPU
uint32_t threadblocksize = 1024;
uint32_t gridsize = (numkvs + threadblocksize - 1) / threadblocksize;
gpu_hashtable_insert_kernel<<<gridsize, threadblocksize>>>(hashtable, kvs, numkvs);

// GPU code to process numkvs key/values in parallel
void gpu_hashtable_insert_kernel(KeyValue* hashtable, const KeyValue* kvs, unsigned int numkvs)
{
    unsigned int threadid = blockIdx.x*blockDim.x + threadIdx.x;
    if (threadid < numkvs)
    {
        gpu_hashtable_insert(hashtable, kvs[threadid].key, kvs[threadid].value);
    }
}

Une table de hachage verrouillable prend en charge les insertions, recherches et suppressions simultanées. Étant donné que les paires clé-valeur sont toujours dans l'un des quatre états et que les clés ne bougent pas, la table garantit l'exactitude même lorsque vous utilisez différents types d'opérations.

Cependant, si nous traitons un paquet d'insertions et de suppressions en parallèle, et si des clés en double sont contenues dans le tableau d'entrée de paires, nous ne pourrons pas prédire quelles paires gagneront - elles seront écrites dans la table de hachage en dernier. Supposons que nous ayons appelé un code d'insertion avec un tableau d'entrée de paires A/0 B/1 A/2 C/3 A/4. Lorsque le code est terminé, les paires B/1et sont C/3garanties d'être présentes dans le tableau, mais en même temps l'une des paires y apparaîtra A/0, A/2ouA/4. Cela peut ou non être un problème - tout dépend de l'application. Vous savez peut-être à l'avance qu'il n'y a pas de clés en double dans le tableau d'entrée, ou peu importe pour vous la dernière valeur écrite.

Si cela vous pose problème, vous devez diviser les paires en double en différents appels système CUDA. Dans CUDA, toute opération d'appel de noyau se termine toujours avant le prochain appel de noyau (au moins dans le même thread. Dans différents threads, le noyau s'exécute en parallèle). Si dans l'exemple ci-dessus, appelez un noyau avec A/0 B/1 A/2 C/3et l'autre avec A/4, la clé Aobtiendra une valeur 4.

Parlons maintenant de la fonction lookup()et de l' delete()utilisation d'un pointeur simple (simple) ou variable (volatile) vers un tableau de paires dans une table de hachage.La documentation de CUDA indique que:

Le compilateur peut, à sa discrétion, optimiser les opérations de lecture et d'écriture dans la mémoire globale ou partagée ... Ces optimisations peuvent être désactivées à l'aide du mot-clé volatile: ... tout lien vers cette variable est compilé en une véritable instruction de lecture ou d'écriture en mémoire.

Les considérations d'exactitude ne nécessitent pas d'application volatile. Si le thread d'exécution utilise la valeur mise en cache d'une opération de lecture antérieure, cela signifie qu'il utilisera des informations un peu obsolètes. Mais encore, ce sont des informations sur l'état correct de la table de hachage à un certain moment de l'appel du noyau. Si vous devez utiliser les dernières informations, vous pouvez utiliser le pointeur volatile, mais les performances diminueront légèrement: selon mes tests, lorsque vous supprimez 32 millions d'éléments, la vitesse passe de 500 millions de suppressions / s à 450 millions de suppressions / s.

Performance


Dans le test d'insertion de 64 millions d'éléments et de suppression de 32 millions d'entre eux, il n'y a std::unordered_mappratiquement pas de concurrence entre et la table de hachage pour le GPU:


std::unordered_map70 691 ms ont été consacrées à l'insertion et à la suppression d'éléments lors de la publication ultérieure unordered_map(la libération de millions d'éléments prend beaucoup de temps, car de unordered_mapnombreuses allocations de mémoire sont effectuées à l' intérieur ). Honnêtement, il y a std:unordered_mapdes limitations complètement différentes. Il s'agit d'un seul thread d'exécution du processeur, il prend en charge des valeurs-clés de toute taille, fonctionne bien à des taux d'utilisation élevés et affiche des performances stables après de nombreuses suppressions.

La durée de la table de hachage pour le GPU et la communication inter-programme était de 984 ms. Cela inclut le temps nécessaire pour placer la table en mémoire et la supprimer (allocation unique de 1 Go de mémoire, ce qui prend du temps dans CUDA), l'insertion et la suppression d'éléments, ainsi que leur itération. Également pris en compte toutes les copies vers et depuis la mémoire de la carte vidéo.

La table de hachage elle-même a pris 271 ms. Cela inclut le temps passé par la carte vidéo pour insérer et supprimer des éléments, et ne prend pas en compte le temps nécessaire pour copier dans la mémoire et parcourir le tableau résultant. Si la table GPU vit longtemps ou si la table de hachage est entièrement contenue dans la mémoire de la carte vidéo (par exemple, pour créer une table de hachage qui sera utilisée par un autre code GPU et non par le processeur central), le résultat du test est pertinent.

La table de hachage de la carte vidéo présente des performances élevées en raison de sa bande passante élevée et de sa parallélisation active.

désavantages


L'architecture de table de hachage a plusieurs problèmes à garder à l'esprit:

  • Le clustering interfère avec le palpage linéaire, à cause de quoi les clés du tableau sont loin d'être idéales.
  • Les clés ne sont pas supprimées à l'aide de la fonction deleteet au fil du temps encombrent la table.

En conséquence, les performances de la table de hachage peuvent diminuer progressivement, surtout si elle existe depuis longtemps et que de nombreuses insertions et suppressions y sont effectuées. Une façon d'atténuer ces lacunes est de ressasser une nouvelle table avec un taux d'utilisation assez bas et de filtrer les clés distantes lors du resshachage.

Pour illustrer les problèmes décrits, j'utilise le code ci-dessus pour créer une table pour 128 millions d'éléments, j'insérerai cycliquement 4 millions d'éléments jusqu'à remplir 124 millions d'emplacements (l'utilisation est d'environ 0,96). Voici le tableau des résultats, chaque ligne est un appel au cœur CUDA avec l'insertion de 4 millions de nouveaux éléments dans une table de hachage:

Taux d'utilisationDurée d'insertion 4 194 304 éléments
0,0011,608448 ms (361,314798 millions de clés / sec.)
0,0311,751424 (356,918799 /.)
0,0611,942592 (351,205515 /.)
0,0912,081120 (347,178429 /.)
0,1212,242560 (342,600233 /.)
0,1612,396448 (338,347235 /.)
0,1912,533024 (334,660176 /.)
0,2212,703328 (330,173626 /.)
0,2512,884512 (325,530693 /.)
0,2813,033472 (321,810182 /.)
0,3113,239296 (316,807174 /.)
0,3413,392448 (313,184256 /.)
0,3713,624000 (307,861434 /.)
0,4113,875520 (302,280855 /.)
0,4414,126528 (296,909756 /.)
0,4714,399328 (291,284699 /.)
0,5014,690304 (285,515123 /.)
0,5315,039136 (278,892623 /.)
0,5615,478656 (270,973402 /.)
0,5915,985664 (262,379092 /.)
0,6216,668673 (251,627968 /.)
0,6617,587200 (238,486174 /.)
0,6918,690048 (224,413765 /.)
0,7220,278816 (206,831789 /.)
0,7522,545408 (186,038058 /.)
0,7826,053312 (160,989275 /.)
0,8131,895008 (131,503463 /.)
0,8442,103294 (99,619378 /.)
0,8761,849056 (67,815164 /.)
0,90105,695999 (39,682713 /.)
0,94240,204636 (17,461378 /.)

À mesure que l'utilisation augmente, la productivité diminue. Ceci n'est pas souhaitable dans la plupart des cas. Si une application insère des éléments dans un tableau puis les supprime (par exemple, lors du comptage des mots dans un livre), ce n'est pas un problème. Mais si l'application utilise une table de hachage longue durée (par exemple, dans un éditeur graphique pour stocker des parties d'images non vides lorsque l'utilisateur insère et supprime souvent des informations), ce comportement peut être gênant.

Et il a mesuré la profondeur de sondage de la table de hachage après 64 millions d'insertions (facteur d'utilisation 0,5). La profondeur moyenne était de 0,4774, de sorte que la plupart des clés étaient situées soit dans le meilleur emplacement possible, soit dans un emplacement à partir de la meilleure position. La profondeur de sondage maximale était de 60.

J'ai ensuite mesuré la profondeur de sondage dans le tableau avec 124 millions d'inserts (taux d'utilisation de 0,97). La profondeur moyenne était déjà de 10,1757 et le maximum - 6474 (!!). Les performances de sondage linéaire chutent considérablement à des taux d'utilisation élevés.

Il est préférable de garder cette table de hachage basse. Mais ensuite, nous augmentons la productivité en consommant de la mémoire. Heureusement, dans le cas de clés et de valeurs 32 bits, cela peut être justifié. Si dans l'exemple ci-dessus dans le tableau pour 128 millions d'éléments le coefficient d'utilisation de 0,25 est stocké, alors nous ne pouvons pas y placer plus de 32 millions d'éléments, et les 96 millions d'emplacements restants seront perdus - 8 octets pour chaque paire, 768 Mo de mémoire perdue.

Veuillez noter que nous parlons de la perte de mémoire de la carte vidéo, qui est une ressource plus précieuse que la mémoire système. Bien que la plupart des cartes graphiques de bureau modernes qui prennent en charge CUDA disposent d'au moins 4 Go de mémoire (au moment de la rédaction, NVIDIA 2080 Ti en a 11 Go), la perte de tels volumes ne sera pas la décision la plus sage.

Plus tard, j'écrirai plus sur la création de tables de hachage pour les cartes vidéo qui n'ont aucun problème avec la profondeur du sondage, ainsi que sur les façons de réutiliser les emplacements distants.

Mesure de la profondeur de détection


Pour déterminer la profondeur du sondage de clé, nous pouvons extraire le hachage de clé (son index idéal dans le tableau) de son index de table réel:

// get_key_index() -> index of key in hash table
uint32_t probelength = (get_key_index(key) - hash(key)) & (hashtablecapacity-1);

En raison de la magie de deux nombres binaires dans le code supplémentaire et du fait que la capacité de la table de hachage est égale à deux en puissance, cette approche fonctionnera même lorsque l'index de clé est déplacé au début de la table. Prenez une clé hachée à 1 mais insérée dans l'emplacement 3. Ensuite, pour une table de capacité 4, nous obtenons (3 — 1) & 3ce qui équivaut à 2.

Conclusion


Si vous avez des questions ou des commentaires, écrivez-moi sur Twitter ou ouvrez un nouveau sujet dans le référentiel .

Ce code est inspiré de quelques grands articles:


À l'avenir, je continuerai d'écrire sur les implémentations de table de hachage pour les cartes vidéo et analyserai leurs performances. J'ai l'intention de chaîner, de hacher Robin Hood et de coucou en utilisant des opérations atomiques dans des structures de données qui conviennent aux cartes vidéo.

All Articles