Einfache Hash-Tabelle für GPU


Ich habe auf Github ein neues Projekt namens A Simple GPU Hash Table gepostet .

Dies ist eine einfache Hash-Tabelle für die GPU, die Hunderte Millionen Einfügungen pro Sekunde verarbeiten kann. Auf meinem Laptop mit einer NVIDIA GTX 1060 fügt der Code 64 Millionen zufällig generierte Schlüssel-Wert-Paare in ungefähr 210 ms ein und entfernt 32 Millionen Paare in ungefähr 64 ms.

Das heißt, die Geschwindigkeit auf dem Laptop beträgt ungefähr 300 Millionen Einsätze / Sek. Und 500 Millionen Entfernungen / Sek.

Die Tabelle ist in CUDA geschrieben, obwohl dieselbe Technik auf HLSL oder GLSL angewendet werden kann. Die Implementierung weist mehrere Einschränkungen auf, die eine hohe Leistung der Grafikkarte gewährleisten:

  • Es werden nur 32-Bit-Schlüssel und dieselben Werte verarbeitet.
  • Die Hash-Tabelle hat eine feste Größe.
  • Und diese Größe sollte gleich zwei Grad sein.

Für Schlüssel und Werte müssen Sie eine einfache Begrenzungsmarkierung reservieren (im obigen Code ist sie 0xffffffff).

Hash-Tabelle ohne Sperren


Die Hash-Tabelle verwendet eine offene Adressierung mit linearer Erfassung , dh es handelt sich nur um ein Array von Schlüssel-Wert-Paaren, die im Speicher gespeichert sind und eine hervorragende Cache-Leistung aufweisen. Dies ist bei der Verkettung nicht der Fall, dh es wird nach einem Zeiger in einer verknüpften Liste gesucht. Eine Hash-Tabelle ist ein einfaches Array, in dem Elemente gespeichert werden KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

Die Größe der Tabelle entspricht zwei Potenzen und ist keine Primzahl, da für die Verwendung der pow2 / AND-Maske eine kurze Anweisung ausreicht und der Moduloperator viel langsamer ist. Dies ist im Fall der linearen Erfassung wichtig, da bei einer linearen Suche in der Tabelle der Schlitzindex in jeden Schlitz eingeschlossen werden muss. Infolgedessen werden die Kosten der Operation in jedem Steckplatz modulo addiert.

In der Tabelle werden nur der Schlüssel und der Wert für jedes Element gespeichert, nicht der Schlüssel-Hash. Da in der Tabelle nur 32-Bit-Schlüssel gespeichert sind, wird der Hash sehr schnell berechnet. Der obige Code verwendet den Murmur3-Hash, der nur wenige Verschiebungen, XORs und Multiplikationen ausführt.

Die Hash-Tabelle verwendet eine Sperrschutztechnik, die nicht von der Speicherplatzierungsreihenfolge abhängt. Selbst wenn einige Schreibvorgänge die Reihenfolge anderer solcher Vorgänge verletzen, behält die Hash-Tabelle den korrekten Status bei. Wir werden weiter unten darüber sprechen. Die Technik funktioniert hervorragend mit Grafikkarten, bei denen Tausende von Threads miteinander konkurrieren.

Schlüssel und Werte in der Hash-Tabelle werden so initialisiert, dass sie leer sind.

Der Code kann so geändert werden, dass sowohl 64-Bit-Schlüssel als auch Werte verarbeitet werden können. Schlüssel erfordern atomare Lese-, Schreib- und Austauschoperationen (Vergleichen und Austauschen). Und Werte erfordern atomare Lese- und Schreiboperationen. Glücklicherweise sind in CUDA Lese- / Schreibvorgänge für 32- und 64-Bit-Werte atomar, solange sie natürlich ausgerichtet sind (siehe hier)) und moderne Grafikkarten unterstützen 64-Bit-Atomoperationen im Vergleich zum Austausch. Wenn Sie auf 64 Bit umschalten, nimmt die Leistung natürlich leicht ab.

Hash-Tabellenstatus


Jedes Schlüssel-Wert-Paar in einer Hash-Tabelle kann einen von vier Zuständen haben:

  • Der Schlüssel und die Bedeutung sind leer. In diesem Zustand wird die Hash-Tabelle initialisiert.
  • Der Schlüssel wurde aufgezeichnet, aber der Wert ist noch nicht. Wenn in diesem Moment ein anderer Ausführungsthread Daten liest, wird ein leerer Wert zurückgegeben. Dies ist normal, dasselbe würde passieren, wenn ein anderer Ausführungsthread etwas früher funktioniert und wir über eine wettbewerbsfähige Datenstruktur sprechen.
  • Sowohl der Schlüssel als auch der Wert werden aufgezeichnet.
  • Der Wert ist für andere Ausführungsthreads verfügbar, der Schlüssel jedoch noch nicht. Dies kann passieren, weil das CUDA-Programmiermodell ein schlecht geordnetes Speichermodell impliziert. Dies ist normal, auf jeden Fall ist der Schlüssel noch leer, auch wenn der Wert nicht mehr so ​​ist.

Eine wichtige Nuance ist, dass sich der Schlüssel, sobald er in den Steckplatz geschrieben wurde, nicht mehr bewegt - selbst wenn der Schlüssel gelöscht wird, werden wir weiter unten darauf eingehen.

Der Hash-Tabellencode funktioniert sogar mit schlecht geordneten Speichermodellen, die die Reihenfolge des Lesens und Schreibens in den Speicher nicht kennen. Denken Sie beim Analysieren des Einfügens, Suchens und Löschens in der Hash-Tabelle daran, dass sich jedes Schlüssel-Wert-Paar in einem der vier oben beschriebenen Zustände befindet.

In eine Hash-Tabelle einfügen


Eine CUDA-Funktion, die Schlüssel-Wert-Paare in eine Hash-Tabelle einfügt, sieht folgendermaßen aus:

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);
    }
}

Um einen Schlüssel einzufügen, durchläuft der Code das Hash-Tabellen-Array, beginnend mit dem Hash des eingefügten Schlüssels. In jedem Schlitz des Arrays wird eine Atomvergleichsoperation mit dem Austausch durchgeführt, bei der der Schlüssel in diesem Schlitz mit einem leeren verglichen wird. Wenn eine Nichtübereinstimmung festgestellt wird, wird der Schlüssel im Steckplatz auf den eingefügten Schlüssel aktualisiert, und dann wird der ursprüngliche Schlüssel des Steckplatzes zurückgegeben. Wenn dieser ursprüngliche Schlüssel leer war oder dem eingefügten Schlüssel entsprach, fand der Code einen zum Einfügen geeigneten Steckplatz und brachte den eingefügten Wert in den Steckplatz.

Wenn in einem Kernel-Aufrufgpu_hashtable_insert()Es gibt mehrere Elemente mit demselben Schlüssel, dann kann jeder ihrer Werte in den Schlüsselsteckplatz geschrieben werden. Dies wird als normal angesehen: Eine der Schlüsselwert-Schreiboperationen während des Aufrufs ist erfolgreich, aber da dies alles parallel innerhalb mehrerer Ausführungsthreads geschieht, können wir nicht vorhersagen, welche Schreiboperation in den Speicher die letzte sein wird.

Hash-Tabellensuche


Key Finder Code:

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);
        }
}

Um den Wert des in der Tabelle gespeicherten Schlüssels zu ermitteln, durchlaufen wir das Array beginnend mit dem Hash des gewünschten Schlüssels. In jedem Slot prüfen wir, ob der Schlüssel der gesuchte ist, und geben in diesem Fall seinen Wert zurück. Wir prüfen auch, ob der Schlüssel leer ist, und unterbrechen in diesem Fall die Suche.

Wenn wir den Schlüssel nicht finden können, gibt der Code einen leeren Wert zurück.

Alle diese Suchvorgänge können beim Einfügen und Löschen wettbewerbsfähig ausgeführt werden. Jedes Paar in der Tabelle hat einen der vier oben für den Stream beschriebenen Zustände.

Löschen der Hash-Tabelle


Code zum Entfernen von Schlüsseln:

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);
    }
}

Das Entfernen eines Schlüssels ist ungewöhnlich: Wir lassen den Schlüssel in der Tabelle und markieren seinen Wert (nicht den Schlüssel selbst) leer. Dieser Code ist sehr ähnlich lookup(), außer dass, wenn eine Übereinstimmung für den Schlüssel gefunden wird, sein Wert leer wird.

Wie oben erwähnt, bewegt sich der Schlüssel nicht mehr, sobald er in den Steckplatz geschrieben wurde. Selbst wenn Sie ein Element aus der Tabelle löschen, bleibt der Schlüssel an Ort und Stelle, nur sein Wert wird leer. Dies bedeutet, dass wir die atomare Operation zum Schreiben des Werts des Slots nicht verwenden müssen, da es keine Rolle spielt, ob der aktuelle Wert leer ist oder nicht - er wird immer noch leer.

Ändern Sie die Größe einer Hash-Tabelle


Sie können die Größe der Hash-Tabelle ändern, indem Sie eine größere Tabelle erstellen und nicht leere Elemente aus der alten Tabelle einfügen. Ich habe diese Funktionalität nicht implementiert, weil ich den Beispielcode einfach halten wollte. Darüber hinaus erfolgt in CUDA-Programmen die Speicherzuweisung häufig im Host-Code und nicht im CUDA-Kern.

Der Artikel A Lock-Free Wait-Free Hash Table beschreibt, wie eine solche sperrengeschützte Datenstruktur geändert wird.

Wettbewerbsfähigkeit


In dem obigen Code - Schnipsel, die Funktionen gpu_hashtable_insert(), _lookup()und _delete()Prozess eines Schlüssel-Wert - Paar zu einem Zeitpunkt. Und darunter gpu_hashtable_insert(), _lookup()sie _delete()eine Reihe von paarweise parallel, wobei jedes Paar in einem gesonderten GPU Ausführungs - Thread verarbeitet werden :

// 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);
    }
}

Eine abschließbare Hash-Tabelle unterstützt das gleichzeitige Einfügen, Suchen und Löschen. Da sich die Schlüssel-Wert-Paare immer in einem von vier Zuständen befinden und sich die Schlüssel nicht bewegen, garantiert die Tabelle die Richtigkeit, selbst wenn verschiedene Arten von Operationen verwendet werden.

Wenn wir jedoch ein Paket von Einfügungen und Löschungen parallel verarbeiten und das Eingabearray von Paaren doppelte Schlüssel enthält, können wir nicht vorhersagen, welche Paare "gewinnen" werden - sie werden zuletzt in die Hash-Tabelle geschrieben. Angenommen, wir haben einen Einfügecode mit einem Eingabearray von Paaren aufgerufen A/0 B/1 A/2 C/3 A/4. Wenn der Code fertig ist, sind die Paare B/1und C/3garantiert in der Tabelle vorhanden, aber gleichzeitig wird jedes der Paare darin angezeigt A/0, A/2oderA/4. Dies kann ein Problem sein oder auch nicht - alles hängt von der Anwendung ab. Möglicherweise wissen Sie im Voraus, dass das Eingabearray keine doppelten Schlüssel enthält, oder es spielt für Sie keine Rolle, welcher Wert zuletzt geschrieben wurde.

Wenn dies ein Problem für Sie ist, müssen Sie die doppelten Paare in verschiedene System-CUDA-Aufrufe aufteilen. In CUDA endet jede Kernelaufrufoperation immer vor dem nächsten Kernelaufruf (zumindest innerhalb desselben Threads. In verschiedenen Threads wird der Kernel parallel ausgeführt). Wenn Sie im obigen Beispiel einen Kern mit A/0 B/1 A/2 C/3und den anderen mit aufrufen , erhält A/4der Schlüssel Aeinen Wert 4.

Lassen Sie uns nun darüber sprechen, ob die Funktion lookup()und delete()ein einfacher (einfacher) oder variabler (flüchtiger) Zeiger auf ein Array von Paaren in einer Hash-Tabelle verwendet werden.In der CUDA-Dokumentation heißt es:

Der Compiler kann nach eigenem Ermessen die Lese- und Schreibvorgänge im globalen oder gemeinsam genutzten Speicher optimieren. Diese Optimierungen können mit dem Schlüsselwort deaktiviert werden volatile: ... Jeder Link zu dieser Variablen wird zu einer echten Lese- oder Schreibanweisung im Speicher kompiliert.

Überlegungen zur Korrektheit erfordern keine Anwendung volatile. Wenn der Ausführungsthread den zwischengespeicherten Wert einer früheren Leseoperation verwendet, bedeutet dies, dass er etwas veraltete Informationen verwendet. Dies sind jedoch Informationen aus dem korrekten Status der Hash-Tabelle an einem bestimmten Punkt im Kernel-Aufruf. Wenn Sie die neuesten Informationen verwenden müssen, können Sie den Zeiger verwenden volatile, aber dann nimmt die Leistung leicht ab: Nach meinen Tests verringert sich die Geschwindigkeit beim Löschen von 32 Millionen Elementen von 500 Millionen Löschen / Sek. Auf 450 Millionen Löschen / Sek.

Performance


Beim Test zum Einfügen und Löschen von 64 Millionen Elementen gibt es std::unordered_mappraktisch keine Konkurrenz zwischen und der Hash-Tabelle für die GPU:


std::unordered_map70 691 ms für das Einfügen und Entfernen von Elementen mit anschließender Freigabe aufgewendet unordered_map(die Freigabe von Millionen von Elementen nimmt viel Zeit in Anspruch, da unordered_mapim Inneren zahlreiche Speicherzuweisungen durchgeführt werden). Ehrlich gesagt gibt es std:unordered_mapganz andere Einschränkungen. Dies ist ein einzelner CPU-Thread der Ausführung, er unterstützt Schlüsselwerte jeder Größe, funktioniert gut bei hohen Auslastungsraten und zeigt nach zahlreichen Löschungen eine stabile Leistung.

Die Dauer der Hash-Tabelle für die GPU- und programmübergreifende Kommunikation betrug 984 ms. Dies umfasst die Zeit, die benötigt wird, um die Tabelle im Speicher abzulegen und zu löschen (einmalige Zuweisung von 1 GB Speicher, was in CUDA einige Zeit in Anspruch nimmt), das Einfügen und Löschen von Elementen sowie das Durchlaufen dieser Elemente. Berücksichtigt auch das gesamte Kopieren zum und vom Speicher der Grafikkarte.

Die Hash-Tabelle selbst dauerte 271 ms. Dies schließt die Zeit ein, die die Grafikkarte zum Einsetzen und Entfernen von Elementen benötigt, und berücksichtigt nicht die Zeit, die zum Kopieren in den Speicher und zum Durchlaufen der resultierenden Tabelle benötigt wird. Wenn die GPU-Tabelle lange lebt oder wenn die Hash-Tabelle vollständig im Speicher der Grafikkarte enthalten ist (z. B. um eine Hash-Tabelle zu erstellen, die von einem anderen GPU-Code und nicht vom Zentralprozessor verwendet wird), ist das Testergebnis relevant.

Die Hash-Tabelle für die Grafikkarte weist aufgrund ihrer hohen Bandbreite und aktiven Parallelisierung eine hohe Leistung auf.

Nachteile


Bei der Hash-Tabellenarchitektur sind verschiedene Aspekte zu beachten:

  • Clustering stört die lineare Abtastung, weshalb die Schlüssel in der Tabelle alles andere als ideal sind.
  • Schlüssel werden nicht mit der Funktion gelöscht deleteund überladen mit der Zeit die Tabelle.

Infolgedessen kann die Leistung der Hash-Tabelle allmählich abnehmen, insbesondere wenn sie für eine lange Zeit vorhanden ist und zahlreiche Einfügungen und Löschungen darin durchgeführt werden. Eine Möglichkeit, diese Mängel zu beheben, besteht darin, eine neue Tabelle mit einer relativ geringen Auslastungsrate erneut aufzuwärmen und beim Aufwärmen entfernte Schlüssel zu filtern.

Um die beschriebenen Probleme zu veranschaulichen, verwende ich den obigen Code, um eine Tabelle für 128 Millionen Elemente zu erstellen. Ich füge zyklisch 4 Millionen Elemente ein, bis ich 124 Millionen Slots fülle (die Auslastung beträgt ungefähr 0,96). Hier ist die Ergebnistabelle. Jede Zeile ist ein Aufruf des CUDA-Kerns mit dem Einfügen von 4 Millionen neuen Elementen in eine Hash-Tabelle:

NutzungsrateEinfügedauer 4 194 304 Elemente
0,0011.608448 ms (361.314798 Millionen Schlüssel / Sek.)
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 /.)

Mit zunehmender Auslastung nimmt die Produktivität ab. Dies ist in den meisten Fällen unerwünscht. Wenn eine Anwendung Elemente in eine Tabelle einfügt und diese dann verwirft (z. B. beim Zählen von Wörtern in einem Buch), ist dies kein Problem. Wenn die Anwendung jedoch eine langlebige Hash-Tabelle verwendet (z. B. in einem Grafikeditor, um nicht leere Teile von Bildern zu speichern, wenn der Benutzer häufig Informationen einfügt und löscht), kann dieses Verhalten problematisch sein.

Und er maß die Tiefe der Prüfung der Hash-Tabelle nach 64 Millionen Inserts (Auslastungsfaktor 0,5). Die durchschnittliche Tiefe betrug 0,4774, sodass sich die meisten Schlüssel entweder im bestmöglichen Steckplatz oder in einem Steckplatz von der besten Position befanden. Die maximale Sondiertiefe betrug 60.

Dann habe ich die Sondiertiefe in der Tabelle mit 124 Millionen Einsätzen gemessen (Nutzungsrate 0,97). Die durchschnittliche Tiefe betrug bereits 10.1757 und das Maximum - 6474 (!!). Die linear klingende Leistung nimmt bei hohen Auslastungsraten dramatisch ab.

Es ist am besten, diese Hash-Tabelle niedrig zu halten. Aber dann steigern wir die Produktivität, indem wir Speicher verbrauchen. Glücklicherweise kann dies bei 32-Bit-Schlüsseln und -Werten gerechtfertigt sein. Wenn im obigen Beispiel in der Tabelle für 128 Millionen Elemente der Auslastungskoeffizient von 0,25 gespeichert wird, können wir nicht mehr als 32 Millionen Elemente darin platzieren, und die verbleibenden 96 Millionen Steckplätze gehen verloren - 8 Bytes für jedes Paar, 768 MB verlorener Speicher.

Bitte beachten Sie, dass es sich um den Verlust des Grafikkartenspeichers handelt, der eine wertvollere Ressource als der Systemspeicher darstellt. Obwohl die meisten modernen Desktop-Grafikkarten, die CUDA unterstützen, über mindestens 4 GB Arbeitsspeicher verfügen (zum Zeitpunkt des Schreibens verfügt NVIDIA 2080 Ti über 11 GB), ist der Verlust solcher Volumes nicht die klügste Entscheidung.

Später werde ich mehr über das Erstellen von Hash-Tabellen für Grafikkarten schreiben, die keine Probleme mit der Klangtiefe haben, sowie über Möglichkeiten, Remote-Slots wiederzuverwenden.

Tiefenmessung erfassen


Um die Tiefe des Key Sounding zu bestimmen, können wir den Key Hash (seinen idealen Index in der Tabelle) aus seinem tatsächlichen Tabellenindex extrahieren:

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

Aufgrund der Magie von zwei Binärzahlen im zusätzlichen Code und der Tatsache, dass die Kapazität der Hash-Tabelle gleich zwei ist, funktioniert dieser Ansatz auch dann, wenn der Schlüsselindex an den Anfang der Tabelle verschoben wird. Nehmen Sie einen Schlüssel, der bei 1 gehasht, aber in Steckplatz 3 eingefügt wurde. Für eine Tabelle mit Kapazität 4 erhalten wir dann das, (3 — 1) & 3was 2 entspricht.

Fazit


Wenn Sie Fragen oder Kommentare haben, schreiben Sie mir auf Twitter oder öffnen Sie ein neues Thema im Repository .

Dieser Code ist von einigen großartigen Artikeln inspiriert:


In Zukunft werde ich weiterhin über Hash-Tabellen-Implementierungen für Grafikkarten schreiben und deren Leistung analysieren. Ich habe vor, Robin Hood und Kuckuck-Hash mit atomaren Operationen in Datenstrukturen zu verketten, zu hashen, die für Grafikkarten geeignet sind.

All Articles