Tabel hash sederhana untuk GPU


Saya memposting di Github sebuah proyek baru bernama A Simple GPU Hash Table .

Ini adalah tabel hash sederhana untuk GPU, yang mampu memproses ratusan juta insert per detik. Pada laptop saya dengan NVIDIA GTX 1060, kode tersebut menyisipkan 64 juta pasangan nilai kunci yang dihasilkan secara acak dalam sekitar 210 ms dan menghilangkan 32 juta pasangan dalam sekitar 64 ms.

Artinya, kecepatan pada laptop adalah sekitar 300 juta sisipan / detik dan 500 juta kepindahan / detik.

Tabel ini ditulis dalam CUDA, meskipun teknik yang sama dapat diterapkan ke HLSL atau GLSL. Implementasinya memiliki beberapa batasan yang memastikan kinerja tinggi pada kartu video:

  • Hanya kunci 32-bit dan nilai yang sama diproses.
  • Tabel hash memiliki ukuran tetap.
  • Dan ukuran ini harus sama dengan dua derajat.

Untuk kunci dan nilai, Anda perlu memesan penanda pembatas sederhana (dalam kode di atasnya 0xffffffff).

Meja hash tanpa kunci


Tabel hash menggunakan pengalamatan terbuka dengan penginderaan linier , yaitu, hanya array pasangan nilai kunci yang disimpan dalam memori dan memiliki kinerja cache yang sangat baik. Ini tidak terjadi dengan chaining, yang berarti mencari pointer di daftar tertaut. Tabel hash adalah elemen penyimpanan array sederhana KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

Ukuran tabel sama dengan dua daya, dan bukan bilangan prima, karena untuk menggunakan pow2 / AND-mask, satu instruksi cepat sudah cukup, dan operator modul jauh lebih lambat. Ini penting dalam kasus penginderaan linier, karena dalam pencarian linear dalam tabel, indeks slot harus dibungkus di setiap slot. Dan sebagai hasilnya, biaya operasi ditambahkan modulo di setiap slot.

Tabel hanya menyimpan kunci dan nilai untuk setiap item, bukan hash kunci. Karena tabel hanya menyimpan kunci 32-bit, hash dihitung dengan sangat cepat. Kode di atas menggunakan hash Murmur3, yang hanya melakukan beberapa shift, XOR, dan multiplikasi.

Tabel hash menggunakan teknik perlindungan kunci yang tidak tergantung pada urutan penempatan memori. Bahkan jika beberapa operasi penulisan melanggar urutan operasi lainnya, tabel hash akan tetap mempertahankan status yang benar. Kami akan membicarakan ini di bawah ini. Teknik ini bekerja sangat baik dengan kartu video di mana ribuan utas bersaing.

Kunci dan nilai dalam tabel hash diinisialisasi ke kosong.

Kode dapat dimodifikasi sehingga dapat memproses kunci dan nilai 64-bit. Kunci memerlukan operasi membaca, menulis, dan bertukar atom (bandingkan-dan-tukar). Dan nilai membutuhkan operasi baca dan tulis atom. Untungnya, dalam CUDA, operasi baca-tulis untuk nilai 32-bit dan 64-bit adalah atom selama mereka secara alami selaras (lihat di sini), dan kartu video modern mendukung operasi atom 64-bit perbandingan dengan pertukaran. Tentu saja, ketika beralih ke 64 bit, kinerja akan sedikit menurun.

Status Tabel Hash


Setiap pasangan kunci-nilai dalam tabel hash dapat memiliki satu dari empat status:

  • Kuncinya dan artinya kosong. Dalam keadaan ini, tabel hash diinisialisasi.
  • Kunci telah direkam, tetapi nilainya belum. Jika utas eksekusi lainnya sedang membaca data pada saat itu, maka itu mengembalikan nilai kosong. Ini normal, hal yang sama akan terjadi jika utas eksekusi lainnya bekerja sedikit lebih awal, dan kita berbicara tentang struktur data kompetitif.
  • Baik kunci maupun nilainya dicatat.
  • Nilai tersedia untuk utas eksekusi lainnya, tetapi kuncinya belum. Ini bisa terjadi karena model pemrograman CUDA menyiratkan model memori yang tidak tertata dengan baik. Ini normal, dalam hal apapun, kuncinya masih kosong, bahkan jika nilainya tidak lagi seperti itu.

Nuansa yang penting adalah bahwa begitu kunci telah ditulis ke slot, itu tidak lagi bergerak - bahkan jika kunci dihapus, kita akan membicarakan hal ini di bawah ini.

Kode tabel hash bahkan bekerja dengan model memori yang dipesan dengan buruk yang tidak tahu urutan membaca dan menulis ke memori. Saat kami menganalisis sisipan, mencari dan menghapus dalam tabel hash, ingatlah bahwa setiap pasangan nilai kunci ada di salah satu dari empat negara yang dijelaskan di atas.

Masukkan ke dalam tabel hash


Fungsi CUDA yang memasukkan pasangan nilai kunci ke dalam tabel hash terlihat seperti ini:

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

Untuk menyisipkan kunci, kode iterates di atas array tabel hash dimulai dengan hash dari kunci yang dimasukkan. Di setiap slot array, operasi perbandingan atom dilakukan dengan pertukaran, di mana kunci dalam slot ini dibandingkan dengan yang kosong. Jika ketidakcocokan terdeteksi, kunci dalam slot diperbarui ke kunci yang dimasukkan, dan kemudian kunci asli dari slot dikembalikan. Jika kunci asli ini kosong atau terkait dengan kunci yang dimasukkan, maka kode menemukan slot yang cocok untuk dimasukkan dan membawa nilai yang dimasukkan ke dalam slot.

Jika dalam satu panggilan kernelgpu_hashtable_insert()Ada beberapa elemen dengan kunci yang sama, maka salah satu nilainya dapat ditulis ke slot kunci. Ini dianggap normal: salah satu operasi penulisan nilai kunci selama panggilan akan berhasil, tetapi karena semua ini terjadi secara paralel dalam beberapa utas eksekusi, kami tidak dapat memperkirakan operasi penulisan ke memori mana yang akan menjadi yang terakhir.

Pencarian Tabel Hash


Kode Pencari Kunci:

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

Untuk menemukan nilai kunci yang disimpan dalam tabel, kita beralih pada array yang dimulai dengan hash kunci yang diinginkan. Di setiap slot, kami memeriksa apakah kuncinya adalah yang kami cari, dan jika demikian, kembalikan nilainya. Kami juga memeriksa apakah kunci kosong, dan jika demikian, kami menghentikan pencarian.

Jika kami tidak dapat menemukan kunci, maka kode mengembalikan nilai kosong.

Semua operasi pencarian ini dapat dilakukan secara kompetitif selama penyisipan dan penghapusan. Setiap pasangan dalam tabel akan memiliki satu dari empat negara yang dijelaskan di atas untuk streaming.

Penghapusan tabel hash


Kode Penghapusan Kunci:

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

Menghapus kunci adalah tidak biasa: kita membiarkan kunci di dalam tabel dan menandai nilainya (bukan kunci itu sendiri) kosong. Kode ini sangat mirip dengan lookup(), kecuali bahwa ketika kecocokan ditemukan untuk kunci, itu membuat nilainya kosong.

Seperti disebutkan di atas, begitu kunci ditulis ke slot, itu tidak lagi bergerak. Bahkan ketika Anda menghapus item dari tabel, kuncinya tetap di tempatnya, hanya nilainya menjadi kosong. Ini berarti bahwa kita tidak perlu menggunakan operasi atom untuk menulis nilai slot, karena tidak masalah apakah nilai saat ini kosong atau tidak - masih akan menjadi kosong.

Ubah ukuran tabel hash


Anda bisa mengubah ukuran tabel hash dengan membuat tabel yang lebih besar dan memasukkan elemen yang tidak kosong dari tabel lama ke dalamnya. Saya tidak menerapkan fungsi ini karena saya ingin menjaga kode sampel tetap sederhana. Selain itu, dalam program CUDA, alokasi memori sering dilakukan dalam kode host, dan bukan pada inti CUDA.

Artikel A Hash-Free Tunggu-Gratis Tabel Hash menggambarkan bagaimana mengubah struktur data yang dilindungi kunci.

Daya saing


Dalam cuplikan kode di atas, fungsi gpu_hashtable_insert(), _lookup()dan _delete()proses satu pasangan nilai kunci sekaligus. Dan di bawah ini gpu_hashtable_insert(), _lookup()mereka _delete()memproses berbagai pasangan secara paralel, masing-masing pasangan dalam alur eksekusi GPU terpisah:

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

Tabel hash yang dapat dikunci mendukung sisipan, pencarian, dan penghapusan secara bersamaan. Karena pasangan nilai kunci selalu di salah satu dari empat negara, dan kunci tidak bergerak, tabel menjamin kebenaran bahkan ketika menggunakan berbagai jenis operasi.

Namun, jika kita memproses paket penyisipan dan penghapusan secara paralel, dan jika kunci duplikat terkandung dalam array input berpasangan, maka kita tidak akan dapat memprediksi pasangan mana yang akan "menang" - mereka akan ditulis ke tabel hash terakhir. Misalkan kita memanggil kode insert dengan array input berpasangan A/0 B/1 A/2 C/3 A/4. Ketika kode selesai, pasangan B/1dan C/3dijamin akan hadir dalam tabel, tetapi pada saat yang sama salah satu pasangan akan muncul di dalamnya A/0, A/2atauA/4. Ini mungkin atau mungkin tidak menjadi masalah - itu semua tergantung pada aplikasi. Anda mungkin tahu sebelumnya bahwa tidak ada kunci duplikat di larik input, atau mungkin tidak masalah bagi Anda nilai apa yang terakhir ditulis.

Jika ini masalah bagi Anda, maka Anda perlu membagi pasangan duplikat menjadi panggilan sistem CUDA yang berbeda. Dalam CUDA, setiap operasi panggilan kernel selalu berakhir sebelum panggilan kernel berikutnya (setidaknya dalam utas yang sama. Dalam utas yang berbeda, kernel mengeksekusi secara paralel). Jika dalam contoh di atas, panggil satu inti dengan A/0 B/1 A/2 C/3, dan yang lainnya dengan A/4, maka kunci Aakan mendapatkan nilai 4.

Sekarang mari kita bicara tentang apakah fungsi lookup()dan delete()menggunakan sederhana (polos) atau variabel (mudah menguap) pointer ke array pasangan dalam tabel hash.Dokumentasi CUDA menyatakan bahwa:

Kompiler dapat, atas kebijakannya sendiri, mengoptimalkan operasi baca dan tulis ke memori global atau bersama ... Optimalisasi ini dapat dinonaktifkan menggunakan kata kunci volatile: ... tautan apa pun ke variabel ini dikompilasi menjadi instruksi baca atau tulis nyata dalam memori.

Pertimbangan kebenaran tidak membutuhkan aplikasi volatile. Jika utas eksekusi menggunakan nilai cache dari operasi baca sebelumnya, maka ini berarti akan menggunakan informasi yang sedikit ketinggalan zaman. Tapi tetap saja, ini adalah informasi dari status tabel hash yang benar pada titik tertentu dalam panggilan kernel. Jika Anda perlu menggunakan informasi terbaru, Anda dapat menggunakan pointer volatile, tetapi kemudian kinerja akan sedikit menurun: menurut pengujian saya, ketika Anda menghapus 32 juta item, kecepatan berkurang dari 500 juta delete / s menjadi 450 juta delete / s.

Performa


Dalam tes untuk memasukkan 64 juta elemen dan menghapus 32 juta elemen, std::unordered_mappraktis tidak ada persaingan antara dan tabel hash untuk GPU:


std::unordered_mapMenghabiskan 70.691 ms pada penyisipan dan penghapusan elemen dengan rilis berikutnya unordered_map(rilis dari jutaan elemen membutuhkan banyak waktu, karena unordered_mapbanyak alokasi memori dilakukan di dalam ). Jujur saja, ada std:unordered_mapbatasan yang sangat berbeda. Ini adalah eksekusi CPU-thread tunggal, mendukung nilai-nilai kunci dari berbagai ukuran, bekerja dengan baik pada tingkat pemanfaatan yang tinggi dan menunjukkan kinerja yang stabil setelah banyak penghapusan.

Durasi tabel hash untuk GPU dan komunikasi antar-program adalah 984 ms. Ini termasuk waktu yang diperlukan untuk menempatkan tabel dalam memori dan menghapusnya (alokasi satu kali memori 1 GB, yang dalam CUDA membutuhkan waktu), penyisipan dan penghapusan elemen, dan juga iterasi di atasnya. Juga memperhitungkan semua salinan ke dan dari memori kartu video.

Tabel hash itu sendiri mengambil 271 ms. Ini termasuk waktu yang dihabiskan oleh kartu video untuk memasukkan dan menghapus item, dan tidak memperhitungkan waktu yang diperlukan untuk menyalin ke memori dan beralih ke tabel yang dihasilkan. Jika tabel GPU hidup untuk waktu yang lama, atau jika tabel hash seluruhnya terdapat dalam memori kartu video (misalnya, untuk membuat tabel hash yang akan digunakan oleh kode GPU lain dan bukan prosesor pusat), maka hasil pengujian tersebut relevan.

Tabel hash untuk kartu video menunjukkan kinerja tinggi karena bandwidth tinggi dan paralelisasi aktif.

kerugian


Arsitektur tabel hash memiliki beberapa masalah yang perlu diingat:

  • Clustering mengganggu linear probing, karena itu kunci dalam tabel jauh dari ideal.
  • Tombol tidak dihapus menggunakan fungsi deletedan seiring waktu mengacaukan tabel.

Akibatnya, kinerja tabel hash dapat secara bertahap menurun, terutama jika ada untuk waktu yang lama dan banyak penyisipan dan penghapusan dilakukan di dalamnya. Salah satu cara untuk mengurangi kekurangan ini adalah dengan mengulangi tabel baru dengan tingkat pemanfaatan yang cukup rendah dan memfilter kunci jarak jauh saat pengulangan.

Untuk menggambarkan masalah yang dijelaskan, saya menggunakan kode di atas untuk membuat tabel untuk 128 juta elemen, saya akan secara siklik memasukkan 4 juta elemen hingga saya mengisi 124 juta slot (utilisasi sekitar 0,96). Berikut adalah tabel hasil, setiap baris adalah panggilan ke inti CUDA dengan penyisipan 4 juta elemen baru ke dalam satu tabel hash:

Tingkat penggunaanDurasi penyisipan 4 194 304 elemen
0,0011.608448 ms (361.314798 juta kunci / detik)
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 /.)

Ketika pemanfaatan meningkat, produktivitas menurun. Ini tidak diinginkan dalam banyak kasus. Jika aplikasi memasukkan elemen ke dalam tabel dan kemudian membuangnya (misalnya, saat menghitung kata dalam buku), maka ini bukan masalah. Tetapi jika aplikasi menggunakan tabel hash yang berumur panjang (misalnya, dalam editor grafis untuk menyimpan bagian gambar yang tidak kosong ketika pengguna sering memasukkan dan menghapus informasi), maka perilaku ini dapat menyusahkan.

Dan dia mengukur kedalaman menggali tabel hash setelah 64 juta sisipan (faktor pemanfaatan 0,5). Kedalaman rata-rata adalah 0,4774, sehingga sebagian besar tombol berada di slot terbaik atau dalam satu slot dari posisi terbaik. Kedalaman suara maksimal adalah 60.

Kemudian saya mengukur kedalaman bunyi dalam tabel dengan 124 juta sisipan (tingkat pemanfaatan 0,97). Kedalaman rata-rata sudah 10,1757, dan maksimum - 6474 (!!). Kinerja suara linier turun drastis pada tingkat pemanfaatan yang tinggi.

Yang terbaik adalah menjaga tabel hash ini tetap rendah. Tetapi kemudian kami meningkatkan produktivitas dengan mengonsumsi memori. Untungnya, dalam kasus kunci dan nilai 32-bit, ini dapat dibenarkan. Jika dalam contoh di atas dalam tabel untuk 128 juta elemen, koefisien pemanfaatan 0,25 disimpan, maka kita dapat menempatkan tidak lebih dari 32 juta elemen di dalamnya, dan sisa 96 juta slot akan hilang - 8 byte untuk setiap pasangan, 768 MB memori yang hilang.

Harap dicatat bahwa kita berbicara tentang hilangnya memori kartu video, yang merupakan sumber daya yang lebih berharga daripada memori sistem. Meskipun sebagian besar kartu grafis desktop modern yang mendukung CUDA memiliki setidaknya 4 GB memori (pada saat penulisan, NVIDIA 2080 Ti memiliki 11 GB), kehilangan volume seperti itu tidak akan menjadi keputusan paling bijaksana.

Nantinya, saya akan menulis lebih banyak tentang membuat tabel hash untuk kartu video yang tidak memiliki masalah dengan kedalaman bunyi, serta cara untuk menggunakan kembali slot jarak jauh.

Pengukuran Kedalaman Sensing


Untuk menentukan kedalaman bunyi kunci, kita dapat mengekstrak hash kunci (indeks ideal dalam tabel) dari indeks tabel aktualnya:

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

Karena keajaiban dua angka biner dalam kode tambahan dan fakta bahwa kapasitas tabel hash sama dengan dua dalam kekuasaan, pendekatan ini akan bekerja bahkan ketika indeks kunci dipindahkan ke awal tabel. Ambil kunci yang hash pada 1 tetapi dimasukkan dalam slot 3. Kemudian untuk tabel dengan kapasitas 4 kita mendapatkan (3 — 1) & 3apa yang setara dengan 2.

Kesimpulan


Jika Anda memiliki pertanyaan atau komentar, tulis saya di Twitter atau buka topik baru di repositori .

Kode ini terinspirasi oleh beberapa artikel hebat:


Di masa depan, saya akan terus menulis tentang implementasi tabel hash untuk kartu video dan akan menganalisis kinerjanya. Saya punya rencana untuk rantai, hash Robin Hood, dan hash cuckoo menggunakan operasi atom dalam struktur data yang sesuai untuk kartu video.

All Articles