Lihtne hajutustabel GPUle

Lihtne hajutustabel GPUle
Olen lukenud Githubiin uutta projektia A Simple GPU Hash Table.

TÀmÀ on yksinkertainen GPU-hajautustaulu, joka pystyy kÀsittelemÀÀn satoja miljoonia lisÀyksiÀ sekunnissa. Omalla kannettavalla tietokoneellani, jossa on NVIDIA GTX 1060, koodi lisÀÀ noin 64 miljoonaa satunnaisesti luotua avain-arvo-paria noin 210 ms:ssa ja poistaa 32 miljoonaa paria noin 64 ms:ssa.

Eli kannettavalla tietokoneella nopeus on noin 300 miljonaa lisÀystÀ/sek ja 500 miljonaa poistoa/sek.

Taulukko on kirjoitettu CUDAlla, vaikka samaa menetelmÀÀ voidaan soveltaa HLSL:ÀÀn tai GLSL:ÀÀn. Toteutuksessa on useita rajoituksia, jotka varmistavat korkean suorituskyvyn nÀytönohjaimella:

  • KĂ€sitellÀÀn vain 32-bittisiĂ€ avaimia ja samankaltaisia arvoja.
  • Hajautustaululla on kiinteĂ€ koko.
  • Ja tĂ€mĂ€n koon tulisi olla kahden potenssi.

Avaimille ja arvoille on varattava yksinkertainen erottava merkki (esimerkkikoodissa tÀmÀ on 0xffffffff).

Ilman lukitusta oleva hajautustaulu

Hajautustaulussa kÀytetÀÀn avointa osoitusta lineaarisella tutkimisella, eli se on vain avain-arvo-parien taulukko, joka sÀilytetÀÀn muistissa ja jolla on erinomainen vÀlimuistisuorituskyky. TÀmÀ ei pÀde ketjutasoitukseen (chaining), joka tarkoittaa osoittimen etsimistÀ sidotussa listassa. Hajautustaulu on yksinkertainen taulukko, joka tallentaa elementit KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

Taulukon koko on kahden potenssi, ei alkuluku, koska pow2/AND-maskeja voidaan kÀyttÀÀ yhdellÀ nopealla komennolla, kun taas moduulioperaattori toimii paljon hitaammin. TÀmÀ on tÀrkeÀÀ lineaarisen tutkimisen yhteydessÀ, koska lineaarisessa haussa taulukon slotin indeksi on oltava kierretty jokaisessa slotissa. TÀmÀn seurauksena moduulioperaation kustannus lisÀtÀÀn jokaiseen slotin.

Taulukko tallentaa vain avaimen ja arvon jokaiselle elementille, ei avaimen hajautusta. Koska taulukko tallentaa vain 32-bittisiÀ avaimia, hajautus lasketaan erittÀin nopeasti. Esimerkkikoodissa kÀytetÀÀn Murmur3-hajautusta, joka suorittaa vain muutamia siirtoja, XOR:ia ja kertolaskuja.

HÀkk-tabelis kasutatakse blokeerimise kaitse meetodit, mis ei sÔltu mÀlu paigutuse jÀrjekorrast. Isegi kui mÔned kirjutusoperatsioonid rikuvad teiste selliste operatsioonide jÀrjekorda, sÀilitab hÀkk-tabel siiski Ôige oleku. Sellest rÀÀgime allpool. Meetod töötab suurepÀraselt graafikakaartidega, kus tuhanded voolud töötavad samal ajal.

HĂ€kk-tabelis initsialiseeritakse vĂ”tmed ja vÀÀrtused tĂŒhjaks.

Koodi saab modifitseerida, et see saaks töödelda ka 64-bitiseid vĂ”tmeid ja vÀÀrtusi. VĂ”tmete jaoks on vajalikud aatomilised lugemise, kirjutamise ja vĂ”rgu operatsioonid (compare-and-swap). VÀÀrtuste jaoks on vajalikud aatomilised lugemise ja kirjutamise operatsioonid. Õnneks on CUDA-s 32- ja 64-bitiste vÀÀrtuste lugemis-kirjutamisoperatsioonid aatomilised, kuni need on Ă”igesti joondatud (vt. siin), ja kaasaegsed graafikakaardid toetavad 64-bitiseid aatomilisi vĂ”rgu operatsioone. Loomulikult vĂ€heneb jĂ”udlus 64-bitisele ĂŒleminekul veidi.

HĂ€kk-tabeli olek

Igal vĂ”tme-vÀÀrtuse paaril hĂ€kk-tabelis vĂ”ib olla ĂŒks neljast olekust:

  • VĂ”ti ja vÀÀrtus on tĂŒhjad. Sellises olekus initsialiseeritakse hĂ€kk-tabel.
  • VĂ”ti on kirjutatud, kuid vÀÀrtus ei ole veel. Kui sel ajal loeb andmeid teine tĂ€itmisvoog, tagastab see tĂŒhja vÀÀrtuse. See on normaalne, sama oleks juhtunud, kui teine tĂ€itmisvoog oleks töötanud natuke varem, ja rÀÀgime konkurentsivĂ”imelisest andmestruktuurist.
  • Nii vĂ”ti kui ka vÀÀrtus on kirjutatud.
  • VÀÀrtus on teistele tĂ€itmisvoogudele saadaval, kuid vĂ”ti ei ole veel. See vĂ”ib juhtuda, kuna CUDA programmeerimisreduksioon eeldab nĂ”rgalt korraldatud mĂ€lumudelit. See on normaalne, ĂŒhelgi juhul on vĂ”ti endiselt tĂŒhi, isegi kui vÀÀrtus ei ole.

Oluline nĂŒanss on see, et alates hetkest, mil vĂ”ti kirjutatakse slotile, ei liigu see enam — isegi kui vĂ”ti kustutatakse, rÀÀgime sellest allpool.

HĂ€kk-tabeli kood töötab isegi nĂ”rgalt korraldatud mĂ€lumudelites, kus lugemise ja kirjutamise jĂ€rjekord mĂ€lu sees ei ole teada. Kui me arutame hĂ€kk-tabli lisamist, otsimist ja kustutamist, pidage meeles, et iga vĂ”tme-vÀÀrtuse paar on ĂŒhes neljast eespool kirjeldatud olekust.

HĂ€kk-tabelisse lisamine

CUDA-funktsioon, mis lisab hargnitud tabelisse vÔtme-vÀÀrtuse paare, nÀeb vÀlja selline:

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

VĂ”tme sisestamiseks iteratiivne kood hargnitud tabeli massiivi, alustades sisestatava vĂ”tme hash'ist. Igas massiivi slotis teostatakse atomaarne vĂ”rdlemise ja vahetamise operatsioon, kus antud sloti vĂ”tme vĂ”rdletakse tĂŒhjaga. Kui leitakse mittesobivus, uuendatakse slotis olev vĂ”tme sisestatava vĂ”tmega ning seejĂ€rel tagastatakse algne sloti vĂ”ti. Kui see algne vĂ”ti oli tĂŒhi vĂ”i vastas sisestatavale vĂ”tmele, leidis kood sobiva sloti ja sisestab sloti vastava vÀÀrtuse.

Kui ĂŒhes tuumas kutsumises gpu_hashtable_insert() on mitu elementi sama vĂ”tmega, siis vĂ”ib ĂŒkskĂ”ik milline nende vÀÀrtustest olla salvestatud vĂ”tme sloti. See on normaalne: ĂŒks vĂ”tme-vÀÀrtuse salvestamise operatsioon tuumakutsumise kĂ€igus Ă”nnestub, kuid kuna see toimub paralleelselt mitme tĂ€itmisvoo raames, ei saa me ennustada, milline mĂ€lu salvestamise operatsioon on viimane.

Otsingu hargnitud tabelis

Kood vÔtmete otsimiseks:

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

VĂ”tme leidmiseks, mis on tabelis, iteratiivne kood massiivi, alustades otsitava vĂ”tme hash'ist. Igas slotis kontrollime, kas vĂ”tme on see, mida me otsime, ja kui jah, siis tagastame selle vÀÀrtuse. Samuti kontrollime, kas vĂ”ti on tĂŒhi ja kui jah, katkestame otsingu.

Kui me ei suuda vĂ”tme leida, tagastab kood tĂŒhja vÀÀrtuse.

KĂ”iki neid otsingu operatsioone saab teostada ĂŒheaegselt sisestuste ja kustutuste kĂ€igus. Igal tabeli paaril on voos ĂŒks neljast ĂŒlaltoodud olekust.

Kustutamine hargnitud tabelis

Kood vÔtmete kustutamiseks:

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

Klahvi kustutamine toimub ebatavaliselt: jĂ€tame vĂ”tme tabelisse ja mĂ€rgistame selle vÀÀrtuse (mitte vĂ”tme endi) tĂŒhjaks. See kood sarnaneb vĂ€ga lookup(), vĂ€lja arvatud, et vĂ”tme vastavuse tuvastamisel teeb see selle vÀÀrtuse tĂŒhjaks.

Nagu eespool mainitud, kui vĂ”tme slotti kirjutatakse, ei liigu see enam. Isegi kui tabelist element kustutatakse, jÀÀb vĂ”tme koht muutumatuks, lihtsalt selle vÀÀrtus muutub tĂŒhjaks. See tĂ€hendab, et me ei pea kasutama slotivÀÀrtuse kirjutamiseks aatomilist operatsiooni, kuna pole vahet, kas praegune vÀÀrtus on tĂŒhi vĂ”i mitte — see muutub ikkagi tĂŒhjaks.

HĂ€storinti suurendamine

HĂ€storinti saab suurendada, luues suurema tabeli ja sisestades sellesse mitte-tĂŒhjad elemendid vanast tabelist. Ma ei rakendanud seda funktsionaalsust, kuna tahtsin sĂ€ilitada koodi lihtsana. Veelgi enam, CUDA programmides toimub mĂ€lu eraldamine sageli host-koodis, mitte CUDA ydas.

Artiklis Bloqueeringu vaba ootevaba hash-tabel on kirjeldatud, kuidas sellist blokeerimise vastu kaitstud andmestruktuuri muuta.

Konkurents

Ülaltoodud koodilĂ”ikudes funktsioonid gpu_hashtable_insert(), _lookup() ja _delete() töötlevad ĂŒhe vĂ”tme-vÀÀrtuse paari korraga. Allpool gpu_hashtable_insert(), _lookup() ja _delete() töötlevad paaride massiivi paralleelselt, iga paari eraldi GPU töövoos:

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

Bloqueeringu vastu kaitstud hash-tabel toetab konkurentsivĂ”imelisi sisestusi, otsinguid ja kustutamisi. Kuna vĂ”tme-vÀÀrtuse paarid on alati ĂŒhes neljast olekust ja vĂ”tmed ei liigu, tagab tabel Ă”igsuse isegi erinevate operatsioonide samaaegsel kasutamisel.

Kuid kui töötleme paralleelselt sisestuste ja kustutuste paketti ning kui sisendmassiivis on korduvad vĂ”tmed, ei suuda me ennustada, millised paarid "vĂ”idavad" — mis kirjutatakse hash-tabelisse viimaseid. Oletame, et kutsusime sisestuskoodi, mille sisendmassiiviks on paarid A/0 B/1 A/2 C/3 A/4. Kui kood on lĂ”pule viidud, on paarid B/1 ja C/3 garanteeritult tabelis, kuid samas vĂ”ivad seal olla ka mistahes paarid A/0, A/2 vĂ”i A/4See, see, see. This could be a problem or it may not—everything depends on the application. You might already know there's no duplicate keys in the input array, or it might not matter which value was recorded last.

If this is a problem for you, then you need to separate the duplicate pairs into different system CUDA calls. In CUDA, any operation with a kernel call always completes before the next kernel call (at least within a single thread. In different threads, kernels execute in parallel). If in the example above, one kernel is called with A/0 B/1 A/2 C/3, and another with A/4, then the key A will receive the value 4.

Now let's discuss whether the functions lookup() ja delete() should use a plain or volatile pointer to the array of pairs in the hash table. The CUDA documentation states that:

The compiler may optimize read and write operations to global or shared memory at its discretion
 These optimizations can be disabled using the keyword volatile: 
 any reference to this variable compiles to an actual read or write instruction in memory.

Correctness considerations do not require application of volatile. If the execution thread uses a cached value from an earlier read operation, it means it will use slightly outdated information. However, it is still information from a valid state of the hash table at a specific kernel call moment. If you need to use the latest information, you can use a pointer volatile, but then performance may slightly decrease: according to my tests—when deleting 32 million elements, the speed dropped from 500 million deletes/sec to 450 million deletes/sec.

TÔhusus

In the test for inserting 64 million elements and deleting 32 million of them, competition between std::unordered_map and the hash table for the GPU is virtually nonexistent:

Lihtne hajutustabel GPUle
std::unordered_map it took 70,691 ms to insert and delete elements followed by the freeing up of unordered_map (freeing up millions of elements takes quite some time because numerous memory allocations happen inside). Honestly, regarding unordered_map std:unordered_map std:unordered_map tÀiesti teised piirangud. See on ainus CPU-tegevusvoog, mis toetab vÔtme-vÀÀrtuse paare mistahes suuruses, töötab hÀsti kÔrgete kasutuskoefitsientide korral ning nÀitab pÀrast rohkete kustutamiste jÀrel stabiilset jÔudlust.

Hash-tabeli tööaeg GPU ja protsessidevahelistes suhtlustes oli 984 ms. See hĂ”lmab aega, mis kulus tabeli mĂ€lu paigutamiseks ja selle kustutamiseks (ĂŒhekordne 1 GB mĂ€lu eraldamine, mis vĂ”tab CUDA-s aega), elementide sisestamine ja kustutamine, samuti nende vaheliteriseerimine. Samuti on arvesse vĂ”etud kĂ”ik koopiad mĂ€lust ja graafikakaardi mĂ€lu.

Hash-tabeli töötamine vÔttis 271 ms. See hÔlmab aega, mille graafikakaart kulutab elementide sisestamiseks ja kustutamiseks, ja ei arvestata aega, mis kulub mÀlu kopeerimiseks ja saadud tabeli vaheliteriseerimiseks. Kui GPU-tabel elab kaua, vÔi kui hash-tabel asub tÀielikult graafikakaardi mÀlus (nÀiteks hash-tabeli loomiseks, mida kasutatakse teise GPU-koodi jaoks, mitte keskpÀeva protsessori jaoks), on testi tulemus asjakohane.

Hash-tabel graafikakaardi jaoks nÀitab kÔrget jÔudlust, tÀnu suurele lÀbilaskvusele ja aktiivsele paralleliseerimisele.

Puudused

Hash-tabeli arhitektuuril on mitmeid probleeme, mida tasub meeles pidada:

  • Lineaarne sondimine on hĂ€iritud klasterdamise tĂ”ttu, mille tĂ”ttu paigutatakse vĂ”tmed tabelisse kaugele mitte ideaalselt.
  • VĂ”tmed ei kustutata funktsiooniga delete ja aja jooksul ummistavad tabelit.

Kuna selle tulemusena vĂ”ib hash-tabeli jĂ”udlus jĂ€rk-jĂ€rgult vĂ€heneda, eriti kui see eksisteerib kaua ja selles viiakse lĂ€bi rohkelt sisestusi ja kustutamisi. Üks viis nende puuduste leevendamiseks on uut tabelit alamhĂ”ivega, piisavalt madala kasutuskoefitsiendiga ja kustutatud vĂ”tmete filtreerimisega alamhĂ”ive ajal.

Ülaltoodud probleemide illustreerimiseks kasutan eespool esitatud koodi, et luua 128 miljoni elemendi tabel, sisestan tsĂŒkliliselt 4 miljonit elementi, kuni olen tĂ€itnud 124 miljoni pesa (kasutuskoefitsient umbes 0,96). Siin on tulemuste tabel, iga rida on CUDA tuuma kutse 4 miljoni uue elemendi sisestamiseks ĂŒhte hash-tabelisse:

Kasutuskoefitsient
Sisestamisaeg 4 194 304 elementi jaoks

0,00
11,608448 ms (361,314798 miljonit vÔtit/s)

0,03
11,751424 ms (356,918799 miljonit vÔtit/s)

0,06
11,942592 ms (351,205515 miljonit vÔtit/s)

0,09
12,081120 ms (347,178429 miljonit vÔtit/s)

0,12
12,242560 ms (342,600233 miljonit vÔtit/s)

0,16
12,396448 ms (338,347235 miljonit vÔtit/s)

0,19
12,533024 ms (334,660176 miljonit vÔtit/s)

0,22
12,703328 ms (330,173626 miljonit vÔtit/s)

0,25
12,884512 ms (325,530693 miljonit vÔtit/s)

0,28
13,033472 ms (321,810182 miljonit vÔtit/s)

0,31
13,239296 ms (316,807174 miljonit vÔtit/s)

0,34
13,392448 ms (313,184256 miljonit vÔtit/s)

0,37
13,624000 ms (307,861434 miljonit vÔtit/s)

0,41
13,875520 ms (302,280855 miljonit vÔtit/s)

0,44
14,126528 ms (296,909756 miljonit vÔtit/s)

0,47
14,399328 ms (291,284699 miljonit vÔtit/s)

0,50
14,690304 ms (285,515123 miljonit vÔtit/s)

0,53
15,039136 ms (278,892623 miljonit vÔtit/s)

0,56
15,478656 ms (270,973402 miljonit vÔtit/s)

0,59
15,985664 ms (262,379092 miljonit vÔtit/s)

0,62
16,668673 ms (251,627968 miljonit vÔtit/s)

0,66
17,587200 ms (238,486174 miljonit vÔtit/s)

0,69
18,690048 ms (224,413765 miljonit vÔtit/s)

0,72
20,278816 ms (206,831789 miljonit vÔtit/s)

0,75
22,545408 ms (186,038058 miljonit vÔtit/s)

0,78
26,053312 ms (160,989275 miljonit vÔtit/s)

0,81
31,895008 ms (131,503463 miljonit vÔtit/s)

0,84
42,103294 ms (99,619378 miljonit vÔtit/s)

0,87
61,849056 ms (67,815164 miljonit vÔtit/s)

0,90
105,695999 ms (39,682713 miljonit vÔtit/s)

0,94
240,204636 ms (17,461378 miljonit vÔtit/s)

Kuna kasutuse koefitsient tĂ”useb, vĂ€heneb tulemuslikkus. See pole enamikul juhtudel soovitav. Kui rakendus sisestab tabelisse elemente ja seejĂ€rel need kĂ”rvaldada (nĂ€iteks raamatu sĂ”nade loendamisel), siis see ei ole probleem. Kuid kui rakendus kasutab pikaajalist rippmenahtla (nt graafikutoimetajas mitte tĂŒhi osa piltide hoidmiseks, kui kasutaja sageli sisestab ja eemaldab teavet), vĂ”ib selline kĂ€itumine tekitada ebamugavusi.

Ja ma mÔÔtsin rippmenahtla sondimis sĂŒgavust pĂ€rast 64 miljoni sisestamist (kasutuse koefitsient 0,5). Keskmine sĂŒgavus oli 0,4774, nii et enamik vĂ”tmeid asus kas parimas vĂ”imalikus slotis vĂ”i ĂŒhe sloti kaugusel parimast asukohast. Maksimaalne sondimissĂŒgavus oli 60.

Siis mÔÔtsin sondimissĂŒgavust tabelis, kus oli 124 miljonit sisestamist (kasutuse koefitsient 0,97). Keskmine sĂŒgavus oli juba 10,1757, maksimaalne — 6474 (!!). Lineaarse sondimise tulemuslikkus langeb mĂ€rkimisvÀÀrselt, kui kasutuse koefitsient on kĂ”rge.

Parima on selle hash-tabeli madal kasutussuhte sĂ€ilitamine. Kuid see tĂ€hendaks, et me suurendame jĂ”udlust mĂ€lutarbimise arvelt. Õnneks on see 32-bitiste vĂ”tmete ja vÀÀrtuste korral pĂ”hjendatud. Kui ĂŒlaltoodud nĂ€ites, kus tabelis on 128 miljonit elementi, hoida kasutussuhteks 0,25, saame me tabelisse mahutada mitte rohkem kui 32 miljonit elementi, samas kui ĂŒlejÀÀnud 96 miljonit slotti lĂ€hevad kaotsi — iga paari jaoks 8 baiti, kokku 768 MB kaotatud mĂ€lu.

Pange tĂ€hele, et jutt on videokaardi mĂ€lu kaotusest, mis on vÀÀrtuslikum ressurss kui sĂŒsteemimĂ€lu. Kuigi enamikul kaasaegsetest lauaarvutite videokaardidest, mis toetavad CUDA-d, on vĂ€hemalt 4 GB mĂ€lu (artikli kirjutamise ajal on NVIDIA 2080 Ti-l 11 GB), suurte mahtude kaotamine ei ole siiski kĂ”ige mĂ”istlikum lahendus.

Hiljem kirjutan ma lĂ€hemalt videokaartide jaoks hash-tabelite loomisest, millel ei esine sĂŒgavusprobleeme, samuti uuesti kasutatavate eemaldatud slotide meetoditest.

SĂŒgavuse mÔÔtmine

Selleks, et mÀÀrata vĂ”tme sĂŒgavus, saame vĂ€lja vĂ”tta vĂ”tme hash'i (selle ideaalne indeks tabelis) tegelikust tabeli indeksist:

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

Kuna kaht kahendnumbrite maagia ja see, et hash-tabeli maht on kahe astmest, toimib see lĂ€henemine isegi siis, kui vĂ”tme indeks kantakse tabeli algusesse. VĂ”tame vĂ”tme, mis hashitakse 1, kuid on sisestatud slotti 3. Siis tabeli maht 4 puhul saame (3 — 1) & 3, mis on vĂ”rreldav 2.

KokkuvÔte

Kui teil on kĂŒsimusi vĂ”i kommentaare, kirjutage mulle Twitteris vĂ”i avage uus teema hoidlad.

See kood on inspireeritud suurepÀrasest artiklist:

Tulevikus jĂ€tkan ma videokaartidele mĂ”eldud hash-tabelite rakenduste kirjutamist ja analĂŒĂŒsin nende jĂ”udlust. Plaanide hulgas on ahelal pĂ”hinev hashimine, Robin Hoodi hashimine ning kukeharu hashimine, kasutades aatomoperatsioone andmestruktuurides, mis sobivad videokaartidele.

Allikas: habr.com

Osta usaldusvÀÀrne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid đŸ”„ Osta usaldusvÀÀrne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid | ProHoster