
Olen veebis GitHubis .
See on lihtne hash-tabel GPU jaoks, mis suudab töödelda sajandeid miljoneid sisendeid sekundis. Minu sĂŒlearvuti NVIDIA GTX 1060-ga sisestab kood umbes 64 miljonit juhuslikult genereeritud vĂ”tme-vÀÀrtuse paari umbes 210 ms jooksul ja eemaldab 32 miljonit paari umbes 64 ms jooksul.
Seega on sĂŒlearvuti kiirus umbes 300 miljonit sisestust/sek ja 500 miljonit eemaldamist/sek.
Tabel on kirjutatud CUDA-s, kuigi sama meetodit saab rakendada HLSL-i vÔi GLSL-i. Teostusel on mitmeid piiranguid, mis tagavad kÔrge jÔudluse graafikakaardil:
- Töödeldakse ainult 32-bitiseid vÔtmeid ja sama suurusega vÀÀrtusi.
- Hash-tabelil on fikseeritud suurus.
- Ja see suurus peab olema vÔrdne kahe astmega.
VÔtmete ja vÀÀrtuste jaoks peab olema reserveeritud lihtne eraldusmarker (antud koodis on see 0xffffffff).
Blokeerimata hash-tabel
Hash-tabelis kasutatakse avatud adresseerimist koos , st, see, this is just an array of key-value pairs that is stored in memory and has excellent cache performance. The same cannot be said for chaining, which implies searching for a pointer in a linked list. A hash table is a simple array that stores elements. KeyValue:
struct KeyValue
{
uint32_t key;
uint32_t value;
};
The size of the table is a power of two rather than a prime number because a single quick instruction is enough for the pow2/AND mask, while the modulus operator works much slower. This is important in the case of linear probing, as the index of the slot must be wrapped in every slot. As a result, the cost of the modulus operation is added in each slot.
The table stores only the key and value for each element, rather than the hash of the key. Since the table only stores 32-bit keys, the hash is computed very quickly. The provided code uses the Murmur3 hash, which performs just a few shifts, XORs, and multiplications.
Hash-tabelis kasutatakse blokeerimistest sÔltumatut kaitsemeetodit, mis ei sÔltu mÀlu paigutamise jÀrjekorrast. Isegi kui mÔned kirjutamisoperatsioonid hÀirivad teiste selliste operatsioonide jÀrjekorda, sÀilitab hash-tabel ikkagi Ôige seisundi. RÀÀgime sellest hiljem. Meetod töötab suurepÀraselt graafikakaartides, kus tuhanded lÔimed töötavad samaaegselt.
Hash-tabeli vĂ”tmed ja vÀÀrtused initsialiseeritakse tĂŒhjadeks.
Koodi saab muuta, et see suudaks töödelda ka 64-bitiseid vĂ”tmeid ja vÀÀrtusi. VĂ”tmete jaoks on vajalikud aatomilised lugemise, kirjutamise ja vahetamise (compare-and-swap) operatsioonid. VÀÀrtuste jaoks on vajalikud aatomilised lugemise ja kirjutamise operatsioonid. Ănneks on CUDA-s 32- ja 64-bitiste vÀÀrtuste lugemise kirjutamise operatsioonid aatomilised, kui need on Ă”igesti joondatud (vt. ), ja kaasaegsed graafikakaardid toetavad 64-bitiseid aatomilisi vahetusoperatsioone. Loomulikult vĂ€heneb jĂ”udlus mĂ”nevĂ”rra, kui liikuda 64 bitini.
Hash-tabeli seisund
Iga vĂ”tme-vÀÀrtuse paar hash-tabelis vĂ”ib olla ĂŒhes neljast olekust:
- Kl Keys ja vÀÀrtus on tĂŒhjad. Sellises seisundis initsialiseeritakse hash-tabel.
- Key on salvestatud, kuid vÀÀrtus mitte veel. Kui teine tĂ€itmisprotsess loeb andmeid sel ajal, siis tagastab ta seejĂ€rel tĂŒhja vÀÀrtuse. See on normaalne, sama juhtuks, kui teine tĂ€itmisprotsess oleks lĂ”petanud veidi varem, ja rÀÀgime konkurentsivĂ”imelisest andmestruktuurist.
- Nii vÔtme kui vÀÀrtuse kirjed on salvestatud.
- VÀÀrtus on teiste tĂ€itmisprotsesside jaoks kĂ€ttesaadav, kuid vĂ”ti veel mitte. See vĂ”ib juhtuda, kuna CUDA programmodel eeldab nĂ”rgalt jĂ€rjestatud mĂ€lumudelit. See on normaalne, mingil hetkel on vĂ”ti endiselt tĂŒhi, isegi kui vÀÀrtus ei ole.
Oluline nĂŒanss on see, et kui vĂ”ti on salvestatud pesasse, ei liigu see enam â isegi kui vĂ”ti eemaldatakse, rÀÀgime sellest hiljem.
Hash-tabeli kood töötab isegi nĂ”rgalt jĂ€rjestatud mĂ€lumudelitega, kus ei ole teada lugemise ja kirjutamise jĂ€rjekord mĂ€lu. Kui me uurime insertimist, otsimist ja kustutamist hash-tabelis, pidage meeles, et iga vĂ”tme-vÀÀrtuse paar on ĂŒhes neljast eespool kirjeldatud olekust.
Sisestamine hash-tabelisse
CUDA-funktsioon, mis sisestab hash-tabelisse vÔtme-vÀÀrtuse paare, nÀeb vÀlja jÀrgmine:
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 lĂ€bib hash-tabeli massiivi, alustades sisestatava vĂ”tme hash'ist. Igas massiivi slotis teostatakse aatomiline vĂ”rreldes vahetus, kus selle sloti vĂ”ti vĂ”rreldakse tĂŒhjaga. Kui leitakse vastuolu, uuendatakse slotis olev vĂ”ti sisestatava vĂ”tmega ning seejĂ€rel tagastatakse sloti algne vĂ”ti. Kui see algne vĂ”ti oli tĂŒhi vĂ”i vastas sisestatavale vĂ”tmele, siis tĂ€hendab see, et kood on leidnud sobiva sloti ja sisestab sloti sisestatud vÀÀrtuse.
Kui ĂŒhes tuumas gpu_hashtable_insert() on mitmeid elemente sama vĂ”tmega, seega vĂ”ib ĂŒkskĂ”ik milline nende vÀÀrtustest olla salvestatud vĂ”tme sloti. Seda peetakse normiks: ĂŒhe vĂ”tme-vÀÀrtuse salvestamise operatsiooni jooksul tuleb olema edukas, kuid kuna see toimub paralleelselt mitmes tĂ€itmisvoos, ei saa me prognoosida, milline salvestamise operatsioon mĂ€lus viimane on.
Otsimine hajutustabelis
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 vÀÀrtuse leidmiseks, mis on tabelis, itereerime massiivi alates otsitava vĂ”tme hĂ€sist. Igas slotis kontrollime, kas vĂ”tme on see, mida otsime, ja kui jah, siis tagastame selle vÀÀrtuse. Kontrollime ka, kas vĂ”tme on tĂŒhi, ja kui jah, siis katkestame otsimise.
Kui me ei suuda vĂ”tmeid leida, tagastab kood tĂŒhja vÀÀrtuse.
KĂ”iki neid otsinguoperatsioone saab teostada samaaegselt lisamise ja kustutamise kĂ€igus. Iga paari jaoks tabelis on ĂŒks neljast ĂŒlaltoodud olekust, mis kehtib voogude kohta.
Kustutamine hÀÀletustabelis
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);
}
}
VĂ”tme kustutamine toimub ebatavaliselt: me jĂ€tame vĂ”tme tabelisse ja mĂ€rgime selle vÀÀrtuse (mitte vĂ”tme ennast) tĂŒhjaks. See kood sarnaneb vĂ€ga lookup(), vĂ€lja arvatud see, et vĂ”tme vaste leidmisel muudab see selle vÀÀrtuse tĂŒhjaks.
Kuidas eelnevalt mainitud, niipea kui vĂ”ti on slotis, ei liigu see enam. Isegi kui element tabelist eemaldatakse, jÀÀb vĂ”ti oma kohale, lihtsalt selle vÀÀrtus muutub tĂŒhjaks. See tĂ€hendab, et me ei pea kasutama sloti vÀÀrtuse kirjutamise aatomset operatsiooni, kuna pole tĂ€htis, kas praegune vÀÀrtus on tĂŒhi vĂ”i mitte â see jÀÀb ikkagi tĂŒhjaks.
HÀÀletustabeli suuruse muutmine
HĂ€kkide suurust saab muuta, luues suurema tabeli ja sisestades sinna mitte-tĂŒhjad elemendid vanast tabelist. Ma ei rakendanud seda funktsionaalsust, kuna soovisisin koodi lihtsana hoida. Veelgi enam, CUDA-programmides toimub mĂ€lu eraldamine sageli host-koodis, mitte CUDA tuumas.
Artiklis kuidas muudab sellist lukustusvastast andmestruktuuri.
Konkurentsus
Ălaltoodud koodilĂ”ikudes funktsioonid gpu_hashtable_insert(), _lookup() ja _delete() töötlevad ĂŒhte vĂ”tme-vÀÀrtuse paari korraga. Allpool gpu_hashtable_insert(), _lookup() ja _delete() töötlevad massiivi paare paralleelselt, iga paari jaoks eraldi GPU tĂ€itmisvoos:
// 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);
}
}
Lukustusvastane hĂ€kk tabel toetab konkurentsiat, otsingut ja kustutamist. Kuna vĂ”tme-vÀÀrtuse paarid on alati ĂŒhes neljast olekust ja vĂ”tmed ei liigu, tagab tabel Ă”igsuse isegi erinevat tĂŒĂŒpi operatsioonide samaaegse kasutamise korral.
Kuid kui me töötleme samal ajal massiivi sisendeid ja eemaldamisi ning kui sisendmassiivis on korduvad vĂ”tmed, siis ei saa me ennustada, millised paarid "vĂ”idavad" â need, mis kirjutatakse viimati hĂ€shtabellisse. Oletame, et kutsume esile sisestuskoodi paaride massiiviga. A/0 B/1 A/2 C/3 A/4. Kui kood lĂ”petatakse, siis paarid B/1 ja C/3 on garanteeritult tabelis, kuid seal vĂ”ib olla ĂŒkskĂ”ik milline paar A/0, A/2 vĂ”i A/4. See vĂ”ib olla probleem vĂ”i mitte â kĂ”ik sĂ”ltub kasutamisest. Te vĂ”ite eelnevalt teada, et sisendmassiivis ei ole korduvaid vĂ”tmeid, vĂ”i te vĂ”ite mitte hoolida, milline vÀÀrtus viimati salvestati.
Kui see on teie jaoks probleem, tuleb korduvad paarid jagada erinevatesse sĂŒsteemsetesse CUDA-kutsetesse. CUDA-s lĂ”ppeb iga tuuma kutse operatsioon alati enne jĂ€rgmist tuuma kutset (vĂ€hemalt ĂŒhe voolu sees. Erinevates voogudes tuumad tĂ€idetakse paralleelselt). Kui ĂŒlaltoodud nĂ€ites kutsuda ĂŒks tuum vĂ€lja A/0 B/1 A/2 C/3, ja teine A/4, siis saab vĂ”tme A vÀÀrtuse 4.
NĂŒĂŒd rÀÀgime sellest, kas funktsioonid lookup() ja delete() kasutada lihtsat (plain) vĂ”i muutuva (volatile) punkti massiivi paaride hulgast rĂ€sitabelis. kinni pidama, et:
Kompilaator vĂ”ib oma Ă€ranĂ€gemise jĂ€rgi optimeerida lugemis- ja kirjutamisoperatsioone globaalsetes vĂ”i ĂŒhistes mĂ€ludes ... Need optimeerimised saab vĂ€lja lĂŒlitada vĂ”tmesĂ”na
volatile: ... iga viide sellele muutuvale kompileeritakse tegelikuks mÀlu lugemise vÔi kirjutamise kÀsuks.
Korrektsuse kaalutlused ei nÔua rakendamist volatile. Kui tÀitmisvoog kasutab varasemat lugemistehingust vahemÀllu salvestatud vÀÀrtust, tÀhendab see, et ta kasutab natuke aegunud teavet. Kuid see on siiski teave rÀsitabeli Ôigest olekust teatud hetkel kernekutses. Kui vajate kÔige vÀrskemat teavet, saate kasutada punkti volatile, kuid see vÀhendab pisut jÔudlust: minu testide jÀrgi vÀhenes 32 miljoni elemendi kustutamisel kiirus 500 miljonilt kustutamiselt/sÀkki 450 miljonini kustutamiselt/sÀkki.
Tootlikkus
64 miljoni elemendi lisamise ja 32 miljoni kustutamise testis oli konkurents std::unordered_map ja rÀsitabeli vahel GPU jaoks praktiliselt puuduv.

std::unordered_map kasutas 70 691 ms elementide sisestamiseks ja kustutamiseks ning seejĂ€rel vabastamiseks unordered_map (miljonite elementide vabastamine vĂ”tab aega, kuna sees toimub unordered_map palju mĂ€lu eraldamist). Ausalt öeldes, std:unordered_map on hoopis teised piirangud. See on ĂŒhe CPU-tĂ€itevoo tĂ€itmine, mis toetab mis tahes suurusega vĂ”tme-vÀÀrtuse paare, töötab hĂ€sti kĂ”rgete kasutuskohtadega ja nĂ€itab stabiilset jĂ”udlust pĂ€rast arvukaid kustutusi.
Hash-tabeli tööaeg GPU ja programmid vahel oli 984 ms. Siia kuulub aeg, mis kulus tabeli mĂ€lu paigutamiseks ja selle eemaldamiseks (ĂŒhekordne 1 GB mĂ€lu eraldamine, mis CUDA-s vĂ”tab aega), elementide sisestamine ja kustutamine ning nende ĂŒle iteratsioon. Samuti on arvesse vĂ”etud kĂ”iki kopeerimisi videokaardi mĂ€lu ja sealt.
HÀkkedabeli töö kestis 271 ms. See hÔlmab aega, mis kulus graafikakaardile elementide sisestamiseks ja kustutamiseks, kuid ei arvestata mÀlestusse kopeerimise ja saadud tabeli itereerimise aega. Kui GPU-tabel elab kaua vÔi kui hÀlbedabel on tÀielikult graafikakaardi mÀlus (nÀiteks hÀlbedabeli loomiseks, mida kasutab muu GPU-kood, mitte keskprotsessor), on testimise tulemus asjakohane.
Graafikakaardi hÀlbedabel demonstreerib kÔrget jÔudlust tÀnu suurele lÀbilaskevÔimele ja aktiivsele paralleelsele töötlemisele.
Puudused
HĂ€kkedabeli arhitektuuril on mitmeid probleeme, millega tuleb arvestada:
- Klastri vormimise tÔttu takistab lineaarne uurimine, mistÔttu on tabelis klahvid kaugeltki mitte ideaalsed.
- Klahve ei kustutata funktsiooni
kustutaja need ummistavad aja jooksul tabelit.
Aja tĂ”ttu vĂ”ib hash-tabeli jĂ”udlus jĂ€rk-jĂ€rgult kahaneda, eriti kui see on pikka aega olemas ning selles tehakse arvukaid sisestusi ja kustutusi. Ăks viis nende puuduste leevendamiseks on ĂŒmberhashimine uude tabelisse, millel on piisavalt madal kasutuskordaja ja kustutatud vĂ”tmete filtreerimine ĂŒmberhashimise ajal.
Probleemide illustreerimiseks kasutan ĂŒlaltoodud koodi, et luua 128 miljoni elemendi tabel, sisestan tsĂŒkliliselt 4 miljonit elementi, kuni tĂ€idan 124 miljonit pesa (kasutuskordaja umbes 0,96). Siin on tulemuste tabel, iga rida on CUDA tuuma kĂ”ne, mille ajal sisestatakse 4 miljonit uut elementi ĂŒhte hash-tabelisse:
Kasutuskordaja
4 194 304 elemendi sisestamise kestus
0,00
11,608448 ms (361,314798 miljonit vÔtit/sekundis)
0,03
11,751424 ms (356,918799 miljonit vÔtit/sekundis)
0,06
11,942592 ms (351,205515 miljonit vÔtit/sekundis)
0,09
12,081120 ms (347,178429 miljonit vÔtit/sekundis)
0,12
12,242560 ms (342,600233 miljonit vÔtit/sekundis)
0,16
12,396448 ms (338,347235 miljonit vÔtit/sekundis)
0,19
12,533024 ms (334,660176 miljonit vÔtit/sekundis)
0,22
12,703328 ms (330,173626 miljonit vÔtit/sekundis)
0,25
12,884512 ms (325,530693 miljonit vÔtit/sekundis)
0,28
13,033472 ms (321,810182 miljonit vÔtit/sekundis)
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)
Kasutusse mÀÀramise kasvu korral vĂ€heneb jĂ”udlus. See on enamasti soovimatu. Kui rakendus lisab tabelisse elemente ja viskab need seejĂ€rel Ă€ra (nt raamatu sĂ”nade arvestamisel), pole see probleem. Kuid kui rakendus kasutab pikaajalist hash-tabelit (nt graafikuredaktor, et salvestada mittetĂŒhje osa piltidest, kui kasutaja sisestab ja eemaldab teavet sageli), vĂ”ib selline kĂ€itumine tekitada probleeme.
Ja ma mÔÔtsin hash-tabeli sondimise sĂŒgavust pĂ€rast 64 miljoni sisestuse (kasutuse mÀÀr 0,5). Keskmine sĂŒgavus oli 0,4774, nii et enamik vĂ”tmeid asus kas parimates vĂ”imalikes slotides vĂ”i ĂŒhe slotiga parimast positsioonist. Maksimaalne sondimise sĂŒgavus oli 60.
Siis mÔÔtsin sondimise sĂŒgavust tabelis, kus oli 124 miljonit sisestust (kasutuse mÀÀr 0,97). Keskmine sĂŒgavus oli juba 10,1757, maksimaalne sĂŒgavus â 6474 (!!). Lineaarse sondimise jĂ”udlus langeb tugevalt suurte kasutuse mÀÀrade korral.
Selle hĂ€shtabeli kasutusefektiivsuse hoidmine madalal tasemel on parim praktika. Kuid see suurendab jĂ”udlust mĂ€lutarbimise arvelt. Ănneks on see 32-bitiste vĂ”tmete ja vÀÀrtuste puhul mĂ”istetav. Kui ĂŒlaltoodud nĂ€ites hoiame 128 miljoni elemendiga tabelis kasutusefektiivsuse tasemeks 0,25, saame neis hoidmiseks mahutada maksimaalselt 32 miljonit elementi, samas kui ĂŒlejÀÀnud 96 miljonit pesa jÀÀb kasutamata â iga paari kohta 8 baiti, 768 MB kadunud mĂ€lu.
Pange tĂ€hele, et see kĂ€sitleb graafikakaardi mĂ€lu kaotust, mis on vÀÀrtuslikum ressurss kui sĂŒsteemimĂ€lu. Kuigi enamik tĂ€napĂ€evaseid lauagraafikakaarte, mis toetavad CUDA-d, omavad vĂ€hemalt 4 GB mĂ€lu (artikli kirjutamise ajal oli NVIDIA 2080 Ti-l 11 GB), ei ole selliste suuruste kaotamine siiski kĂ”ige mĂ”istlikum lahendus.
Hiljem kirjutan tÀpsemalt graafikakaartide jaoks mÔeldud hÀshtabelite loomise kohta, mis ei puutu kokku uurimisaste probleemidega, samuti meetoditest, kuidas kasutamata pesa uuesti kasutada.
Uurimisaste
Klahvi sĂŒvendi mÀÀramiseks saame vĂ€lja tĂ”mmata klahvi rĂ€sikoodi (selle ideaalne indeks tabelis) selle tegelikust tabeli indeksist:
// get_key_index() -> index of key in hash table
uint32_t probelength = (get_key_index(key) - hash(key)) & (hashtablecapacity-1);
Kuna kahte binaarnumbrit tĂ€iendavates koodides ja asjaolu, et rĂ€sitabeli maht on kahe astmes, töötab see lĂ€henemine isegi siis, kui klahvi indeks liigutatakse tabeli algusesse. Oletame, et klahvi rĂ€sitakse 1, kuid see on sisestatud 3. sloti. Siis saame tabeli jaoks, mille maht on 4, (3 â 1) & 3, mis on vĂ”rreldav 2-ga.
KokkuvÔte
Kui teil on kĂŒsimusi vĂ”i mĂ€rkusi, kirjutage mulle vĂ”i avage uus teema.
See kood on kirjutatud inspiratsiooni saades kaunilt artiklitest:
Tulevikus jĂ€tkan ma rĂ€sitabelite rakendustega videokaartide jaoks kirjutamist ja analĂŒĂŒsin nende jĂ”udlust. Mul on plaanis ahelalastamine, Robin Hoode rĂ€simine ja kukeseene rĂ€simine, kasutades aatomoperatsioone, millel on videokaartidele sobivad andmestruktuurid.
Allikas: habr.com
