En enkel hash-tabell för GPU

En enkel hash-tabell för GPU
Jag har lagt upp pÄ Github ett nytt projekt A Simple GPU Hash Table.

Detta Àr en enkel hash-tabell för GPU som kan hantera hundratals miljoner insÀttningar per sekund. PÄ min laptop med NVIDIA GTX 1060 infogar koden 64 miljoner slumpmÀssigt genererade nyckel-vÀrde-par pÄ cirka 210 ms och tar bort 32 miljoner par pÄ cirka 64 ms.

Det vill sÀga, hastigheten pÄ laptopen Àr cirka 300 miljoner insÀttningar/s och 500 miljoner borttagningar/s.

Tabellen Àr skriven i CUDA, men samma metod kan anvÀndas för HLSL eller GLSL. Implementeringen har nÄgra begrÀnsningar som sÀkerstÀller hög prestanda pÄ grafikkortet:

  • Endast 32-bitars nycklar och likadana vĂ€rden bearbetas.
  • Hash-tabellen har en fast storlek.
  • Och denna storlek mĂ„ste vara en kraft av tvĂ„.

För nycklar och vÀrden behöver man reservera en enkel avgrÀnsande markör (i den angivna koden Àr detta 0xffffffff).

Hash-tabell utan lÄs

I hash-tabellen anvÀnds öppen adressering med linjÀr sonding, vilket innebÀr att det helt enkelt Àr en array av nyckel-vÀrde-par som lagras i minnet och har utmÀrkt cache-prestanda. Detta kan inte sÀgas om kedjebinding (chaining), vilket innebÀr att man letar efter en pekare i en lÀnkad lista. Hash-tabellen Àr en enkel array som lagrar element KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

Tabellens storlek Àr en kraft av tvÄ, inte ett primtal, eftersom det rÀcker med en snabb instruktion för att anvÀnda pow2/AND-mask, medan modulus-operatören fungerar mycket lÄngsammare. Detta Àr viktigt i fallet med linjÀr sonding, eftersom indexet för slotten mÄste omslutas i varje slot. Och som ett resultat lÀggs kostnaden för modulus-operationen till i varje slot.

Tabellen lagrar endast nyckel och vÀrde för varje element, inte hash av nyckeln. Eftersom tabellen bara lagrar 32-bitars nycklar berÀknas hash mycket snabbt. Den angivna koden anvÀnder hash Murmur3, som utför bara nÄgra skiftningar, XOR och multiplikation.

I hash-tabellen anvĂ€nds en metod för att skydda sig mot lĂ„sningar, som inte beror pĂ„ ordningen för placering i minnet. Även om vissa skrivoperationer bryter mot ordningen för andra sĂ„dana operationer, kommer hash-tabellen Ă€ndĂ„ att behĂ„lla ett korrekt tillstĂ„nd. Detta kommer vi att diskutera nedan. Metoden fungerar utmĂ€rkt med grafikprocessorer dĂ€r tusentals trĂ„dar körs samtidigt.

Nycklar och vÀrden i hash-tabellen initieras som tomma.

Koden kan modifieras sÄ att den kan hantera bÄde 64-bitars nycklar och vÀrden. För nycklarna krÀvs atomÀra operationer för lÀsning, skrivning och jÀmförelse med byte (compare-and-swap). För vÀrdena behövs atomÀra lÀs- och skrivoperationer. Lyckligtvis Àr lÀs- och skrivoperationerna för 32- och 64-bitars vÀrden atomÀra sÄ lÀnge de Àr korrekt justerade (se. hÀr), och moderna grafikprocessorer stöder 64-bitars atomÀra jÀmförelse med byteoperationer. SjÀlvklart kan prestandan minska nÄgot nÀr man gÄr över till 64 bitar.

TillstÄndet för hash-tabellen

Varje nyckel-vÀrde-par i hash-tabellen kan ha ett av fyra tillstÄnd:

  • Nyckeln och vĂ€rdet Ă€r tomma. I detta tillstĂ„nd initieras hash-tabellen.
  • Nyckeln har skrivits, men vĂ€rdet har Ă€nnu inte det. Om en annan exekveringstrĂ„d lĂ€ser data vid den tidpunkten returnerar den ett tomt vĂ€rde. Detta Ă€r normalt, samma skulle hĂ€nda om en annan exekveringstrĂ„d hade kört lite tidigare, och vi talar om en konkurrensutsatt datakonstruktion.
  • BĂ„de nyckeln och vĂ€rdet har skrivits.
  • VĂ€rdet Ă€r tillgĂ€ngligt för andra exekveringstrĂ„dar, medan nyckeln inte Ă€r det Ă€nnu. Detta kan intrĂ€ffa eftersom programmeringsmodellen i CUDA förutsĂ€tter en svagt ordnad minnesmodell. Detta Ă€r normalt; i vilket fall som helst Ă€r nyckeln fortfarande tom, Ă€ven om vĂ€rdet inte lĂ€ngre Ă€r det.

En viktig aspekt Ă€r att sĂ„ snart nyckeln har skrivits in i ett fack, flyttas den inte lĂ€ngre — Ă€ven om nyckeln tas bort, vilket vi kommer att diskutera nedan.

Koden för hash-tabellen fungerar Àven med svagt ordnade minnesmodeller, dÀr ordningen för lÀsningar och skrivningar i minnet inte Àr kÀnd. NÀr vi behandlar insÀttning, sökning och borttagning i hash-tabellen, kom ihÄg att varje nyckel-vÀrde-par finns i ett av de fyra ovanstÄende tillstÄnden.

InsÀttning i hash-tabellen

CUDA-funktionen som sÀtter in nyckel-vÀrde-par i hash-tabellen ser ut sÄ hÀr:

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

För att sÀtta in nyckeln itererar koden genom hash-tabellens array, började frÄn hashen för den inlagda nyckeln. I varje fack av arrayen utförs en atomÀr jÀmför-och-byt-operation dÀr nyckeln i detta fack jÀmförs med det tomma vÀrdet. Om en skillnad upptÀckts uppdateras nyckeln i facket med den inlagda nyckeln och den ursprungliga nyckeln i facket Äterförs. Om denna ursprungliga nyckel var tom eller matchade den inlagda nyckeln innebÀr det att koden har hittat en lÀmplig plats för inmatning och lÀgger in vÀrdet i facket.

Om i ett anrop av kÀrnan gpu_hashtable_insert() finns flera element med samma nyckel, dÄ kan vilket som helst av deras vÀrden skrivas in i nyckelfacket. Detta anses vara normalt: en av skrivoperationerna för nyckel-vÀrde-paret under anropet kommer att vara framgÄngsrik, men eftersom allt detta sker parallellt inom flera exekveringstrÄdar, kan vi inte förutspÄ vilken skrivoperation till minnet som kommer att vara den sista.

Sökning i hash-tabellen

Koden för att söka efter nycklar:

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

För att hitta vÀrdet av en nyckel som lagras i tabellen itererar vi genom arrayen, började frÄn hashen för den nyckel vi letar efter. I varje fack kontrollerar vi om nyckeln Àr den vi söker, och om sÄ Àr fallet returnerar vi dess vÀrde. Vi kontrollerar ocksÄ om nyckeln Àr tom, och om sÄ Àr fallet avbryter vi sökningen.

Om vi inte lyckas hitta nyckeln returnerar koden ett tomt vÀrde.

Alla dessa sökningsoperationer kan utföras samtidigt under insÀttningar och borttagningar. Varje par i tabellen kommer att ha ett av de fyra ovanstÄende tillstÄnden för trÄden.

Borttagning i hash-tabellen

Koden för att ta bort nycklar:

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

Att ta bort en nyckel utförs pÄ ett ovanligt sÀtt: vi lÀmnar nyckeln i tabellen och markerar dess vÀrde (inte sjÀlva nyckeln) som tomt. Denna kod Àr mycket lik lookup(), förutom att den vid upptÀckten av en matchning med nyckeln gör dess vÀrde tomt.

Som nĂ€mnts ovan, sĂ„ fort nyckeln har skrivits till en slot flyttas den inte lĂ€ngre. Även vid borttagning av ett element frĂ„n tabellen förblir nyckeln pĂ„ plats; dess vĂ€rde blir bara tomt. Detta innebĂ€r att vi inte behöver anvĂ€nda en atomĂ€r skrivoperation för slotens vĂ€rde, eftersom det inte spelar nĂ„gon roll om det aktuella vĂ€rdet Ă€r tomt eller inte — det kommer Ă€ndĂ„ att bli tomt.

Ändra storleken pĂ„ hash-tabellen

Storleken pÄ hash-tabellen kan Àndras genom att skapa en större tabell och sÀtta in icke-tomma element frÄn den gamla tabellen i den. Jag har inte implementerat denna funktionalitet eftersom jag ville hÄlla exempel-koden enkel. Dessutom utförs minnesallokering ofta i vÀrdkoden, snarare Àn i CUDA-kÀrnan.

I artikeln En lÄsfri vÀntan-fri hash-tabell beskriver hur man Àndrar en sÄdan dat struktur skyddad mot lÄs.

Konkurens

I de ovanstÄende fragmenten av koden hanterar funktionerna gpu_hashtable_insert(), _lookup() och _delete() en nyckel-vÀrde-par i taget. Men nedan gpu_hashtable_insert(), _lookup() och _delete() hanterar en array av par parallellt, varje par i en separat GPU-trÄd:

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

En hash-tabell med lÄs skyddar stödjer samtidiga insÀttningar, sökningar och borttagningar. Eftersom nyckel-vÀrde par alltid Àr i ett av fyra tillstÄnd, och nycklarna inte flyttas, garanterar tabellen korrekthet Àven vid samtidig utförande av operationer av olika slag.

Men om vi parallellt behandlar ett paket av insĂ€ttningar och borttagningar, och om inmatningsarrayen av par innehĂ„ller duplicerade nycklar, kan vi inte förutsĂ€ga vilka par som "vinner" — som kommer att skrivas till hash-tabellen sist. Anta att vi kallade insĂ€ttningskoden med inmatningsarrayen av par A/0 B/1 A/2 C/3 A/4. NĂ€r koden Ă€r klar kommer paren B/1 och C/3 garanterat att finnas i tabellen, men samtidigt kommer nĂ„got av paren A/0, A/2 eller A/4Det hĂ€r kan vara ett problem, eller inte — det beror pĂ„ anvĂ€ndningen. Du kan veta i förvĂ€g att det inte finns nĂ„gra duplicerade nycklar i den inkommande arrayen, eller sĂ„ kanske det inte spelar nĂ„gon roll vilket vĂ€rde som sist skrevs.

Om detta Àr ett problem för dig mÄste du dela upp duplicerade par mellan olika systemanrop i CUDA. I CUDA avslutas alltid varje operation med en kÀrnÄtgÀrd innan nÀsta kÀrnÄtgÀrd anropas (Ätminstone inom en trÄd. I olika trÄdar körs kÀrnorna parallellt). Om vi i det ovanstÄende exemplet anropar en kÀrna med A/0 B/1 A/2 C/3, och en annan med A/4, dÄ kommer nyckeln A att fÄ vÀrdet 4.

Nu fÄr vi diskutera huruvida funktionerna lookup() och delete() ska anvÀnda en enkel (plain) eller variabel (volatile) pekare pÄ array av par i hash-tabellen. CUDA-dokumentationen hÀvdar att:

Compilern kan efter eget gottfinnande optimera lÀs- och skrivoperationer till global eller shared minne... Dessa optimeringar kan inaktiveras med hjÀlp av nyckelordet volatile: ... varje referens till denna variabel kompileras till en faktisk lÀs- eller skrivinstruktion till minnet.

KorrekthetshÀnsyn krÀver inte tillÀmpning volatile. Om exekveringsströmmen anvÀnder ett cachat vÀrde frÄn en tidigare lÀsoperation, innebÀr det att den kommer att anvÀnda nÄgot förÄldrad information. Men detta Àr ÀndÄ information frÄn ett korrekt tillstÄnd i hash-tabellen vid ett visst ögonblick av kÀrnan. Om du behöver anvÀnda den senaste informationen kan du anvÀnda en pekare volatile, men dÄ minskar prestandan nÄgot: enligt mina tester minskade hastigheten vid borttagning av 32 miljoner element frÄn 500 miljoner borttagningar/sek till 450 miljoner borttagningar/sek.

Performance

I testet för att införa 64 miljoner element och ta bort 32 miljoner av dem var konkurrensen mellan std::unordered_map och hash-tabellen för GPU praktiskt taget obefintlig:

En enkel hash-tabell för GPU
std::unordered_map det tog 70 691 ms att införa och ta bort element med efterföljande frigöring av unordered_map (frigöring av miljoner element tar en hel del tid eftersom mĂ„nga minnesallokeringar görs inuti unordered_map ). Ärligt talat hade std::unordered_map helt andra begrĂ€nsningar. Det Ă€r en enhetlig CPU-trĂ„d för exekvering, den stöder nyckel-vĂ€rden av vilken storlek som helst, fungerar bra vid höga belastningar och visar stabil prestanda efter upprepade borttagningar.

Körningstiden för hash-tabellen för GPU och mellanprogram interaktion var 984 ms. Detta inkluderar tiden som spenderades pĂ„ att placera tabellen i minnet och dess borttagning (ett engĂ„ngsanrop för att allokera 1 GB minne, vilket tar en viss tid i CUDA), insĂ€ttning och borttagning av element, samt iteration genom dem. Även alla kopieringar in i och ur GPU-minnet beaktas.

Hash-tabellens egna arbete tog 271 ms. Detta inkluderar tiden som spenderades av GPU:n för insÀttning och borttagning av element, och inkluderar inte tiden för minneskopiering och iteration genom den resulterande tabellen. Om GPU-tabellen lever lÀnge, eller om hash-tabellen helt hÄlls i GPU-minnet (till exempel för att skapa en hash-tabell som kommer att anvÀndas av annan GPU-kod, och inte av CPU:n), Àr testresultatet relevant.

Hash-tabellen för GPU:n visar hög prestanda tack vare stor bandbredd och aktiv parallellisering.

Nackdelar

Det finns flera problem med arkitekturen för hash-tabellen som bör beaktas:

  • Klustering hindrar linjĂ€r sondning, vilket gör att nycklarna i tabellen inte placeras optimalt.
  • Nycklar tas inte bort med funktionen ta bort och över tid blockerar de tabellen.

Som ett resultat kan prestandan för hash-tabellen gradvis minska, sÀrskilt om den existerar lÀnge och mÄnga insÀttningar och borttagningar görs. Ett av sÀtten att mildra dessa nackdelar Àr att rehashera till en ny tabell med en tillrÀckligt lÄg utnyttjandegrad och filtrera bort borttagna nycklar vid rehashing.

För att illustrera de beskrivna problemen anvÀnder jag koden ovan för att skapa en tabell med 128 miljoner element, och cykliskt kommer jag att sÀtta in 4 miljoner element tills jag fyller 124 miljoner platser (uthyrningsgrad pÄ cirka 0,96). HÀr Àr resultattabellen, varje rad Àr ett anrop av CUDA-kÀrnan med insÀttning av 4 miljoner nya element i en hash-tabell:

Utnyttjandegrad
Varaktighet för isÀttning av 4 194 304 element

0,00
11,608448 ms (361,314798 miljoner nycklar/sek.)

0,03
11,751424 ms (356,918799 miljoner nycklar/sek.)

0,06
11,942592 ms (351,205515 miljoner nycklar/sek.)

0,09
12,081120 ms (347,178429 miljoner nycklar/sek.)

0,12
12,242560 ms (342,600233 miljoner nycklar/sek.)

0,16
12,396448 ms (338,347235 miljoner nycklar/sek.)

0,19
12,533024 ms (334,660176 miljoner nycklar/sek.)

0,22
12,703328 ms (330,173626 miljoner nycklar/sek.)

0,25
12,884512 ms (325,530693 miljoner nycklar/sek.)

0,28
13,033472 ms (321,810182 miljoner nycklar/sek.)

0,31
13,239296 ms (316,807174 miljoner nycklar/sek.)

0,34
13,392448 ms (313,184256 miljoner nycklar/sek.)

0,37
13,624000 ms (307,861434 miljoner nycklar/sek.)

0,41
13,875520 ms (302,280855 miljoner nycklar/sek.)

0,44
14,126528 ms (296,909756 miljoner nycklar/sek.)

0,47
14,399328 ms (291,284699 miljoner nycklar/sek.)

0,50
14,690304 ms (285,515123 miljoner nycklar/sek.)

0,53
15,039136 ms (278,892623 miljoner nycklar/sek.)

0,56
15,478656 ms (270,973402 miljoner nycklar/sek.)

0,59
15,985664 ms (262,379092 miljoner nycklar/sek.)

0,62
16,668673 ms (251,627968 miljoner nycklar/sek.)

0,66
17,587200 ms (238,486174 miljoner nycklar/sek.)

0,69
18,690048 ms (224,413765 miljoner nycklar/sek.)

0,72
20,278816 ms (206,831789 miljoner nycklar/sek.)

0,75
22,545408 ms (186,038058 miljoner nycklar/sek.)

0,78
26,053312 ms (160,989275 miljoner nycklar/sek.)

0,81
31,895008 ms (131,503463 miljoner nycklar/sek.)

0,84
42,103294 ms (99,619378 miljoner nycklar/sek.)

0,87
61,849056 ms (67,815164 miljoner nycklar/sek.)

0,90
105,695999 ms (39,682713 miljoner nycklar/sek.)

0,94
240,204636 ms (17,461378 miljoner nycklar/sek.)

NÀr anvÀndningsgraden ökar minskar prestandan. Detta Àr oönskat i de flesta fall. Om applikationen sÀtter in element i tabellen och sedan kastar bort dem (t.ex. vid ordantal i en bok) Àr det inget problem. Men om applikationen anvÀnder en lÄnglivad hash-tabell (t.ex. i en grafisk redigerare för att lagra icke-tomma delar av bilder nÀr anvÀndaren ofta klipper och klistrar) kan detta beteende orsaka problem.

Och jag mÀtte djupet pÄ sondningen av hash-tabellen efter 64 miljoner insÀttningar (anvÀndningsgrad 0,5). Det genomsnittliga djupet var 0,4774, sÄ de flesta nycklar lÄg antingen i den bÀsta möjliga slotten eller en slot frÄn den bÀsta positionen. Det maximala djupet pÄ sondningen var 60.

Sedan mÀtte jag djupet pÄ sondningen i tabellen med 124 miljoner insÀttningar (anvÀndningsgrad 0,97). Det genomsnittliga djupet var nu 10,1757, och det maximala var 6474 (!!). Prestandan för linjÀr sondning faller kraftigt vid höga anvÀndningsgrader.

Det Ă€r bĂ€st att hĂ„lla denna hash-tabell med en lĂ„g anvĂ€ndningsgrad. Men dĂ„ ökar vi prestandan pĂ„ bekostnad av minnesanvĂ€ndning. Lyckligtvis kan detta vara berĂ€ttigat för nycklar och vĂ€rden av 32 bitar. Om vi i exemplet ovan har en tabell med 128 miljoner element och behĂ„ller en anvĂ€ndningsgrad pĂ„ 0,25 kan vi bara placera 32 miljoner element i den, medan de Ă„terstĂ„ende 96 miljoner platserna gĂ„r förlorade — 8 byte för varje par, 768 MB förlorat minne.

Observera att detta handlar om förlust av grafikkortsminne, vilket Ă€r en mer vĂ€rdefull resurs Ă€n systemminnet. Även om de flesta moderna skrivbords-GPU:er som stöder CUDA har minst 4 GB minne (vid tidpunkten för denna artikel har NVIDIA 2080 Ti 11 GB), Ă€r det fortfarande inte sĂ€rskilt klokt att förlora sĂ„dana mĂ€ngder.

Jag kommer senare att skriva mer om att skapa hash-tabeller för grafikkort som inte har problem med probingdjup samt om metoder för att ÄteranvÀnda borttagna platser.

ProbingdjupsmÀtning

För att bestÀmma probingdjupet för en nyckel kan vi extrahera hashningen av nyckeln (dess ideala index i tabellen) frÄn dess faktiska tabellindex:

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

PĂ„ grund av magin med tvĂ„ binĂ€ra tal i komplement och det faktum att kapaciteten i en hash-tabell Ă€r en tvĂ„a upphöjt till, kommer denna metod att fungera Ă€ven nĂ€r nyckelns index flyttas till början av tabellen. LĂ„t oss ta en nyckel som hashats till 1 men som satts in i plats 3. DĂ„ fĂ„r vi för en tabell med kapacitet 4 (3 — 1) & 3, vilket Ă€r ekvivalent med 2.

Slutsats

Om du har frÄgor eller kommentarer kan du skriva till mig pÄ Twitter eller öppna ett nytt Àmne i förrÄdet.

Den hÀr koden Àr inspirerad av fantastiska artiklar:

I framtiden kommer jag att fortsÀtta skriva om implementationer av hash-tabeller för grafikkort och analysera deras prestanda. Planerna inkluderar kedjebindning, Robin Hood-hashing och cuckoo-hashing med atomÀra operationer i datastrukturer som Àr bekvÀma för grafikkort.

KĂ€lla: habr.com

Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar đŸ”„ Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster