Table de hachage simple pour GPU

Table de hachage simple pour GPU
J'ai mis en ligne sur Github un nouveau projet A Simple GPU Hash Table.

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

Cela signifie que la vitesse sur l'ordinateur portable est d'environ 300 millions d'inserts/sec et 500 millions de suppressions/sec.

La table est Ă©crite en CUDA, bien que la mĂȘme mĂ©thode puisse ĂȘtre appliquĂ©e Ă  HLSL ou GLSL. La mise en Ɠuvre a plusieurs limitations qui assurent de hautes performances sur la carte graphique :

  • Seules des clĂ©s et des valeurs de 32 bits sont traitĂ©es.
  • La table de hachage a une taille fixe.
  • Et cette taille doit ĂȘtre Ă©gale Ă  une puissance de deux.

Il faut réserver un marqueur de délimitation simple pour les clés et les valeurs (dans le code fourni, c'est 0xffffffff).

Table de hachage sans verrouillage

La table de hachage utilise l'adressage ouvert avec sondage linéaire, cela signifie que c'est simplement un tableau de paires clé-valeur, qui est stocké en mémoire et a d'excellentes performances de cache. On ne peut pas en dire autant du chaßnage, qui implique la recherche d'un pointeur dans une liste chaßnée. La table de hachage est un simple tableau qui stocke les éléments KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

La taille de la table est une puissance de deux, et non un nombre premier, car pour appliquer un masque pow2/AND, il suffit d'une seule instruction rapide, tandis que l'opĂ©rateur modulo est beaucoup plus lent. C'est important dans le cas du sondage linĂ©aire, car lors de la recherche linĂ©aire dans la table, l'indice de la case doit ĂȘtre enveloppĂ© dans chaque case. En consĂ©quence, le coĂ»t de l'opĂ©ration modulo s'ajoute Ă  chaque case.

La table ne stocke que la clé et la valeur pour chaque élément, et non le hachage de la clé. Puisque la table ne stocke que des clés de 32 bits, le hachage est calculé trÚs rapidement. Le code fourni utilise le hachage Murmur3, qui ne nécessite que quelques décalages, XOR et multiplications.

Dans une table de hachage, une mĂ©thode de protection contre les blocages est appliquĂ©e, qui ne dĂ©pend pas de l'ordre d'emplacement en mĂ©moire. MĂȘme si certaines opĂ©rations d'Ă©criture perturbent l'ordre d'autres opĂ©rations similaires, la table de hachage conservera quand mĂȘme un Ă©tat correct. Nous en parlerons plus bas. Cette mĂ©thode fonctionne trĂšs bien avec les cartes graphiques, oĂč des milliers de threads s'exĂ©cutent en concurrence.

Les clés et les valeurs dans la table de hachage sont initialisées comme vides.

Le code peut ĂȘtre modifiĂ© pour qu'il puisse traiter des clĂ©s et des valeurs de 64 bits. Des opĂ©rations atomiques de lecture, d'Ă©criture et de comparaison avec Ă©change (compare-and-swap) sont nĂ©cessaires pour les clĂ©s. Pour les valeurs, des opĂ©rations atomiques de lecture et d'Ă©criture sont nĂ©cessaires. Heureusement, dans CUDA, les opĂ©rations de lecture-Ă©criture pour des valeurs de 32 et 64 bits sont atomiques tant qu'elles sont correctement alignĂ©es (voir ici), et les cartes graphiques modernes prennent en charge les opĂ©rations atomiques de comparaison avec Ă©change de 64 bits. Bien sĂ»r, avec le passage Ă  64 bits, les performances diminuent lĂ©gĂšrement.

État de la table de hachage

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

  • La clĂ© et la valeur sont vides. Dans cet Ă©tat, la table de hachage est initialisĂ©e.
  • La clĂ© a Ă©tĂ© Ă©crite, mais la valeur ne l'est pas encore. Si un autre thread lit des donnĂ©es Ă  ce moment, il renverra ensuite une valeur vide. Cela est normal, la mĂȘme chose se produirait si un autre thread venait de s'exĂ©cuter juste auparavant, et nous parlons ici d'une structure de donnĂ©es concurrente.
  • La clĂ© et la valeur sont toutes deux Ă©crites.
  • La valeur est accessible pour d'autres threads, mais la clĂ© ne l'est pas encore. Cela peut se produire car le modĂšle de programmation dans CUDA suppose un modĂšle de mĂ©moire faiblement ordonnĂ©. C'est normal, dans tous les cas, la clĂ© est toujours vide mĂȘme si la valeur ne l'est pas.

Un point important est que dĂšs qu'une clĂ© a Ă©tĂ© Ă©crite dans un slot, elle ne bouge plus — mĂȘme si la clĂ© est supprimĂ©e, nous en parlerons plus bas.

Le code de la table de hachage fonctionne mĂȘme avec des modĂšles de mĂ©moire faiblement ordonnĂ©s, oĂč l'ordre des lectures et des Ă©critures en mĂ©moire n'est pas connu. Lorsque nous examinerons l'insertion, la recherche et la suppression dans la table de hachage, gardez Ă  l'esprit que chaque paire clĂ©-valeur se trouve dans l'un des quatre Ă©tats dĂ©crits ci-dessus.

Insertion dans la table de hachage

La fonction CUDA qui insÚre des paires clé-valeur dans une table de hachage se présente comme suit :

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 itĂšre Ă  travers le tableau de la table de hachage en commençant par le hachage de la clĂ© Ă  insĂ©rer. Dans chaque emplacement du tableau, une opĂ©ration atomique de comparaison et d'Ă©change est effectuĂ©e, oĂč la clĂ© dans cet emplacement est comparĂ©e Ă  vide. Si un non-correspondance est dĂ©tectĂ©e, la clĂ© dans l'emplacement est mise Ă  jour avec la clĂ© Ă  insĂ©rer, puis la clĂ© d'origine de l'emplacement est retournĂ©e. Si cette clĂ© d'origine Ă©tait vide ou correspondait Ă  la clĂ© insĂ©rĂ©e, cela signifie que le code a trouvĂ© un emplacement appropriĂ© pour l'insertion et insĂšre la valeur dans cet emplacement.

Si dans un seul appel de noyau gpu_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 la clĂ©. Cela est considĂ©rĂ© comme normal : une des opĂ©rations d'Ă©criture de la clĂ©-valeur durant l'appel sera rĂ©ussie, mais comme tout cela se produit parallĂšlement au sein de plusieurs fils d'exĂ©cution, nous ne pouvons pas prĂ©dire quelle opĂ©ration d'Ă©criture en mĂ©moire sera la derniĂšre.

Recherche dans la table de hachage

Code pour rechercher des clés :

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 d'une clé stockée dans la table, nous itérons à travers le tableau à partir du hachage de la clé recherchée. Dans chaque emplacement, nous vérifions si la clé est celle que nous cherchons, et si oui, nous renvoyons sa valeur. Nous vérifions également si la clé est vide, et si c'est le cas, nous interrompons la recherche.

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

Toutes ces opĂ©rations de recherche peuvent ĂȘtre effectuĂ©es de maniĂšre concurrente lors des insertions et des suppressions. Chaque paire dans la table aura pour le fil l'un des quatre Ă©tats dĂ©crits ci-dessus.

Suppression dans la table de hachage

Code pour supprimer des clés :

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Ă© s'effectue de maniĂšre inhabituelle : nous laissons la clĂ© dans la table et marquons sa valeur (pas la clĂ© elle-mĂȘme) comme vide. Ce code ressemble beaucoup Ă  lookup(), sauf que lorsqu'une correspondance est trouvĂ©e pour la clĂ©, sa valeur est dĂ©finie sur vide.

Comme mentionnĂ© prĂ©cĂ©demment, une fois qu'une clĂ© est Ă©crite dans un slot, elle ne bouge plus. MĂȘme lorsque l'Ă©lĂ©ment est supprimĂ© de la table, la clĂ© reste Ă  sa place, sa valeur devenant simplement vide. Cela signifie qu'il n'est pas nĂ©cessaire d'utiliser une opĂ©ration d'Ă©criture atomique pour la valeur du slot, car il n'importe pas si la valeur actuelle est vide ou non — elle deviendra de toute façon vide.

Redimensionnement de la table de hachage

Le redimensionnement de la table de hachage peut ĂȘtre effectuĂ© en crĂ©ant une table plus grande et en insĂ©rant les Ă©lĂ©ments non vides de l'ancienne table. Je n'ai pas rĂ©alisĂ© cette fonctionnalitĂ© car je voulais garder le code simple. De plus, dans les programmes CUDA, l'allocation de mĂ©moire est souvent effectuĂ©e dans le code hĂŽte, et non dans le noyau CUDA.

Dans l'article Une table de hachage sans verrou et sans attente décrit comment modifier une telle structure de données protégée contre les blocages.

Concurrence

Dans les extraits de code ci-dessus, les fonctions gpu_hashtable_insert(), _lookup() et _delete() traitent une paire clé-valeur à la fois. En revanche, ci-dessous gpu_hashtable_insert(), _lookup() et _delete() traite un tableau de paires en parallÚle, chaque paire dans un thread d'exécution GPU séparé :

// 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 protĂ©gĂ©e contre les blocages supporte les insertions, recherches et suppressions concurrentes. Comme les paires clĂ©-valeur se trouvent toujours dans un des quatre Ă©tats, et que les clĂ©s ne bougent pas, la table garantit la cohĂ©rence mĂȘme lors de l'utilisation simultanĂ©e d'opĂ©rations de diffĂ©rents types.

Cependant, si nous traitons parallĂšlement un lot d'inserts et de suppressions, et si le tableau d'entrĂ©e contient des clĂ©s dupliquĂ©es, nous ne pourrons pas prĂ©dire quelles paires "vont gagner" — c'est-Ă -dire celles qui seront Ă©crites en dernier dans la table de hachage. Supposons que nous avons appelĂ© le code d'insertion avec le tableau d'entrĂ©e de paires A/0 B/1 A/2 C/3 A/4. Lorsque le code sera terminĂ©, les paires B/1 et C/3 seront garanties d'ĂȘtre prĂ©sentes dans la table, mais en mĂȘme temps, l'une des paires A/0, A/2 ou A/4Cela peut ĂȘtre un problĂšme ou non, tout dĂ©pend de l'application. Vous pouvez savoir Ă  l'avance qu'il n'y a pas de clĂ©s dupliquĂ©es dans le tableau d'entrĂ©e, ou il se peut que cela ne vous importe pas quel est la valeur enregistrĂ©e en dernier.

Si c'est un problĂšme pour vous, il faut diviser les paires dupliquĂ©es 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 (du moins Ă  l'intĂ©rieur d'un mĂȘme flux. Dans diffĂ©rents flux, les noyaux s'exĂ©cutent en parallĂšle). Si dans l'exemple ci-dessus, un noyau est appelĂ© avec A/0 B/1 A/2 C/3, et un autre avec A/4, alors la clĂ© A obtiendra la valeur 4.

Parlons maintenant de savoir si les fonctions lookup() et delete() doivent utiliser un pointeur simple (plain) ou un pointeur variable (volatile) sur un tableau de paires dans une table de hachage. La documentation CUDA déclare que :

Le compilateur peut optimiser Ă  sa discrĂ©tion les opĂ©rations de lecture et d'Ă©criture en mĂ©moire globale ou partagĂ©e 
 Ces optimisations peuvent ĂȘtre dĂ©sactivĂ©es Ă  l'aide du mot-clĂ© volatile: 
 toute rĂ©fĂ©rence Ă  cette variable est compilĂ©e en une vĂ©ritable instruction de lecture ou d'Ă©criture en mĂ©moire.

Des considérations d'exactitude ne nécessitent pas d'application. volatileSi le flux d'exécution utilise une valeur mise en cache provenant d'une opération de lecture antérieure, cela signifie qu'il utilisera des informations légÚrement obsolÚtes. Mais cela reste des informations provenant d'un état valide de la table de hachage à un moment donné de l'appel de noyau. Si vous devez utiliser les informations les plus récentes, alors vous pouvez utiliser un pointeur volatile, mais cela peut légÚrement réduire les performances : selon mes tests, lors de la suppression de 32 millions d'éléments, la vitesse est passée de 500 millions de suppressions/seconde à 450 millions de suppressions/seconde.

Performance

Dans le test d'insertion de 64 millions d'éléments et la suppression de 32 millions d'entre eux, la concurrence entre std::unordered_map et la table de hachage pour GPU est en fait inexistante :

Table de hachage simple pour GPU
std::unordered_map cela a pris 70 691 ms pour insĂ©rer et supprimer des Ă©lĂ©ments avec le libĂ©ration subsĂ©quente de unordered_map ((libĂ©rer des millions d'Ă©lĂ©ments prend beaucoup de temps, car Ă  l'intĂ©rieur unordered_map de nombreuses allocations de mĂ©moire sont effectuĂ©es). HonnĂȘtement, pour std::unordered_map des limitations complĂštement diffĂ©rentes. Cela reprĂ©sente un seul flux d'exĂ©cution CPU, capable de gĂ©rer des clĂ©s-valeurs de n'importe quelle taille, fonctionnant bien sous des taux d'utilisation Ă©levĂ©s et affichant des performances stables aprĂšs de nombreuses suppressions.

La durée de fonctionnement de la table de hachage pour le GPU et l'interaction inter-programmes a été de 984 ms. Cela inclut le temps nécessaire pour allouer la table en mémoire et la supprimer (une seule allocation de 1 Go de mémoire, ce qui prend un certain temps dans CUDA), l'insertion et la suppression d'éléments, ainsi que l'itération à travers. Toutes les copies en mémoire et depuis la mémoire de la carte graphique ont également été prises en compte.

Le fonctionnement de la table de hachage elle-mĂȘme a pris 271 ms. Cela inclut le temps que la carte graphique a passĂ© Ă  insĂ©rer et Ă  supprimer des Ă©lĂ©ments, et n'inclut pas le temps nĂ©cessaire pour copier en mĂ©moire et itĂ©rer Ă  travers la table rĂ©sultante. Si la table GPU reste active longtemps, ou si la table de hachage est entiĂšrement stockĂ©e dans la mĂ©moire de la carte graphique (par exemple, pour crĂ©er une table de hachage qui sera utilisĂ©e par un autre code GPU plutĂŽt que par le processeur central), alors le rĂ©sultat du test est pertinent.

La table de hachage pour la carte graphique démontre de hautes performances grùce à une grande bande passante et à un parallélisme actif.

Inconvénients

L'architecture de la table de hachage présente plusieurs problÚmes à garder à l'esprit :

  • Le sondage linĂ©aire est affectĂ© par la fragmentation, ce qui fait que les clĂ©s dans la table ne sont pas placĂ©es de maniĂšre optimale.
  • Les clĂ©s ne sont pas supprimĂ©es par la fonction supprimer et encombrent la table avec le temps.

En conséquence, les performances de la table de hachage peuvent progressivement diminuer, surtout si elle existe longtemps et que de nombreuses insertions et suppressions y sont effectuées. L'une des façons d'atténuer ces inconvénients est de re-hachager dans une nouvelle table avec un coefficient d'utilisation suffisamment bas et de filtrer les clés supprimées lors du re-hachage.

Pour illustrer les problÚmes décrits, j'utilise le code ci-dessus pour créer une table de 128 millions d'éléments, et j'insérerai cycliquement 4 millions d'éléments jusqu'à ce que 124 millions de cases soient remplies (coefficient d'utilisation d'environ 0,96). Voici le tableau des résultats, chaque ligne représentant un appel au noyau CUDA avec l'insertion de 4 millions de nouveaux éléments dans une table de hachage :

Coefficient d'utilisation
Durée d'insertion de 4 194 304 éléments

0,00
11,608448 ms (361,314798 millions de clés/seconde)

0,03
11,751424 ms (356,918799 millions de clés/seconde)

0,06
11,942592 ms (351,205515 millions de clés/seconde)

0,09
12,081120 ms (347,178429 millions de clés/seconde)

0,12
12,242560 ms (342,600233 millions de clés/seconde)

0,16
12,396448 ms (338,347235 millions de clés/seconde)

0,19
12,533024 ms (334,660176 millions de clés/seconde)

0,22
12,703328 ms (330,173626 millions de clés/seconde)

0,25
12,884512 ms (325,530693 millions de clés/seconde)

0,28
13,033472 ms (321,810182 millions de clés/seconde)

0,31
13,239296 ms (316,807174 millions de clés/seconde)

0,34
13,392448 ms (313,184256 millions de clés/seconde)

0,37
13,624000 ms (307,861434 millions de clés/seconde)

0,41
13,875520 ms (302,280855 millions de clés/seconde)

0,44
14,126528 ms (296,909756 millions de clés/seconde)

0,47
14,399328 ms (291,284699 millions de clés/seconde)

0,50
14,690304 ms (285,515123 millions de clés/seconde)

0,53
15,039136 ms (278,892623 millions de clés/seconde)

0,56
15,478656 ms (270,973402 millions de clés/seconde)

0,59
15,985664 ms (262,379092 millions de clés/seconde)

0,62
16,668673 ms (251,627968 millions de clés/seconde)

0,66
17,587200 ms (238,486174 millions de clés/seconde)

0,69
18,690048 ms (224,413765 millions de clés/seconde)

0,72
20,278816 ms (206,831789 millions de clés/seconde)

0,75
22,545408 ms (186,038058 millions de clés/seconde)

0,78
26,053312 ms (160,989275 millions de clés/seconde)

0,81
31,895008 ms (131,503463 millions de clés/seconde)

0,84
42,103294 ms (99,619378 millions de clés/seconde)

0,87
61,849056 ms (67,815164 millions de clés/seconde)

0,90
105,695999 ms (39,682713 millions de clés/seconde)

0,94
240,204636 ms (17,461378 millions de clés/seconde)

À mesure que le coefficient d'utilisation augmente, la performance diminue. Cela est indĂ©sirable dans la plupart des cas. Si une application insĂšre des Ă©lĂ©ments dans une table et les supprime ensuite (par exemple, lors du comptage des mots dans un livre), ce n'est pas un problĂšme. Mais si une application utilise une table de hachage Ă  long terme (par exemple, dans un Ă©diteur graphique pour stocker les parties non vides des images lorsque l'utilisateur insĂšre et supprime souvent des informations), ce comportement peut ĂȘtre gĂȘnant.

J'ai également mesuré la profondeur de sondage de la table de hachage aprÚs 64 millions d'inserts (coefficient d'utilisation de 0,5). La profondeur moyenne était de 0,4774, donc la plupart des clés étaient soit dans les meilleurs emplacements possibles, soit à un emplacement du meilleur emplacement. La profondeur maximale de sondage était de 60.

Ensuite, j'ai mesuré la profondeur de sondage dans une table avec 124 millions d'inserts (coefficient d'utilisation de 0,97). La profondeur moyenne était déjà de 10,1757, et la profondeur maximale était de 6474 (!!). La performance du sondage linéaire chute fortement à des coefficients d'utilisation élevés.

Il est prĂ©fĂ©rable de maintenir un faible taux d'utilisation de cette table de hachage. Mais cela augmente la performance au prix de la consommation de mĂ©moire. Heureusement, avec des clĂ©s et des valeurs de 32 bits, cela peut ĂȘtre justifiĂ©. Si dans l'exemple ci-dessus, la table de 128 millions d'Ă©lĂ©ments maintient un taux d'utilisation de 0,25, nous pourrons y placer au maximum 32 millions d'Ă©lĂ©ments, tandis que les 96 millions de places restantes seront perdues — soit 8 octets pour chaque paire, 768 Mo de mĂ©moire perdue.

Notez qu'il s'agit de la perte de mémoire de la carte graphique, qui est une ressource plus précieuse que la mémoire systÚme. Bien que la plupart des cartes graphiques de bureau modernes prenant en charge CUDA aient au moins 4 Go de mémoire (au moment de la rédaction, la NVIDIA 2080 Ti dispose de 11 Go), perdre de tels volumes ne sera pas la décision la plus judicieuse.

Je parlerai plus en détail de la création de tables de hachage pour les cartes graphiques qui n'ont pas de problÚmes de profondeur de sonde, ainsi que des moyens de réutiliser les emplacements supprimés.

Mesure de la profondeur de sonde

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

// 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 en complĂ©ment Ă  deux et du fait que la capacitĂ© d'une table de hachage est Ă©gale Ă  une puissance de deux, cette approche fonctionnera mĂȘme lorsque l'index de la clĂ© dĂ©borde au dĂ©but de la table. Prenons une clĂ© qui est hachĂ©e en 1, mais insĂ©rĂ©e dans le slot 3. Ainsi, pour une table de capacitĂ© 4, nous obtenons (3 — 1) & 3, ce qui Ă©quivaut Ă  2.

Conclusion

Si vous avez des questions ou des commentaires, écrivez-moi à Twitter ou ouvrez un nouveau sujet dans dépÎts.

Ce code est inspiré par d'excellents articles :

À l'avenir, je continuerai Ă  Ă©crire sur les implĂ©mentations de tables de hachage pour les cartes graphiques et j'analyserai leur performance. Je prĂ©vois d'aborder les chaĂźnes de hachage, le hachage de Robin des Bois et le hachage par coucou en utilisant des opĂ©rations atomiques dans des structures de donnĂ©es adaptĂ©es aux cartes graphiques.

Source : habr.com

Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS đŸ”„ Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster