Главная Статьи Ссылки Скачать Скриншоты Юмор Почитать Tools Проекты Обо мне Гостевая Форум |
Одной из широко распространенных структур для поиска данных являются хэш-таблицы. И можно довольно легко написать ее реализацию для однонитевого приложения. Но в случае кода, выполняемого на GPU, когда у вас многие тысячи нитей, это становится гораздо сложнее. При этом нити могут одновременно модифицировать эту таблицу.
Здесь я хочу рассмотреть реализацию подобной таблицы, с которой можно работать на GPU, т.е. из многих тысяч и десятков тысяч нитей. При этом мы будем поддерживать и добавление и удаление элементов. И, что также очень важно, это будет lock-free таблица.
Естественно, чтобы это можно было достаточно просто реализовать, необходимо ввести
некоторые ограничения.
Мы будем строить хэш-таблицу, переводящую int
в int
. При таком выборе гарантируется оптимальное выравнивание
данных в памяти GPU и то, что запись ключей и значений будет происходить атомарно, т.е. ни одна нить
не прочтет не до конца записанное значение.
Второе ограничение - мы изначально выделить таблицу фиксированного размера и не будет проводить ее
переаллокацию и/или перестройку (вообще выделять память на стороне GPU довольно сложно даже в CUDA).
Кроме того, мы будем использовать таблицы с размером равным степени двух, что позволит вместо отнюдь
не самой дешевой функции mod
использовать побитовое AND.
Мы рассмотрим реализацию хэш-таблицы и на CUDA и в шейдере на GLSL. Каждая запись в таблице будет представлена в виде следующей структуры:
struct KeyValue
{
uint32_t key;
uint32_t value;
};
Кроме того, мы введем следующие константы:
const uint32_t HashTableCapacity = 128 * 1024 * 1024;
const uint32_t Empty = 0xffffffff;
Здесь HashTableCapacity
обозначает размер хэш-таблицы (и является степень двух).
Второе значение - Empty
- обозначает отсутствующее значнение, причем оно может использоваться как
в качестве ключа, так и в качестве значения.
Вначале мы проинициализируем всю таблицу (и key
и value
) значением Empty
.
В качестве хэш-функции в этом примере мы будем использовать достаточно простую и быструю Murmur3. Для разрешения коллизий в нашей таблице мы будем использовать linear probing, т.е. в поисках значения будем перебирать записи, идущие после той, на которую указывает хэш.
// 32 bit Murmur3 hash
__device__ uint32_t hash(uint32_t k)
{
k ^= k >> 16;
k *= 0x85ebca6b;
k ^= k >> 13;
k *= 0xc2b2ae35;
k ^= k >> 16;
return k & (HashTableCapacity-1);
}
Давайте сначала рассмотрим каким образом можно найти в таблице нужный элемент или убедиться в его отсутствии.
Для этого мы для ключа находим значение хэш функции, берем его по модулю размера таблицы минус один.
После этого мы начинаем перебирать значения в таблице, начиная с найденной позиции до тех пор, пока не найдем
наш ключ или Empty
.
__device__ bool hashtableFind ( KeyValue * hashtable, uint32_t key )
{
uint32_t slot = hash ( key );
while ( true )
{
if ( hashtable[slot].key == key ) // found key
return true;
if (hashtable[slot].key == Empty)
return false;
slot = (slot + 1) & (HashTableCapacity - 1);
}
}
Вставка значений в таблицу является гораздо более сложной операцией, поскольку не исключено, что сразу несколько нитей попытаются обратиться к одной и той же записи в таблице (будет хэш-коллизия при записи). Поэтому для чтения ключа из таблицы мы воспользуемся atomic-compare-and--swap(code>atomicCAS). Данная функция читает значение по переданному адресу и сравнивает его со своим вторым аргументом. В случае совпадения происходит запись на этом место значения третьего аргумента.
Эта функция всегда возвращает прочитанное значение (не зависимо от того, была ли запись или нет).
Таким образом atomicCAS(&hashtable[slot].key, Empty, key)
в случае если в hastable[slot].key
было значение Empty
(т.е. этот слот свободен), производит запись туда нашего ключа.
В противном случае возможны два варианта - на этом месте находился другой ключ или же на этом месте находится наш ключ key
.
Что именно там было мы можем узнать по возвращенному значению - если Empty
или же наш ключ, то мы
производим запись значения в этом место. Иначе мы просто переходим к следующему элементу таблицы.
__device__ void hashtableInsert ( KeyValue * hashtable, uint32_t key, uint32_t value )
{
uint32_t slot = hash(key);
while ( true )
{
uint32_t prev = atomicCAS (&hashtable [slot].key, Empty, key );
if ( prev == Empty || prev == key )
{
hashtable [slot].value = value;
return;
}
slot = (slot + 1) & (kHashTableCapacity-1);
}
}
Последняя операция, которую нам осталось рассмотреть, это удаления значения (по ключу)
из таблицы.
Здесь мы не будем использовать атомарные операции, но при удалении найденной записи мы
просто в ее поле value
запишем значение Empty
.
Так как ключ на самом деле остается в таблице, то при длительном применении возможно
накопление мусора и таблицу лучше почистить.
Мы будем считать, что это будет делать отдельное ядро CUDA.
__device__ void hashtableDelete ( KeyValue * hashtable, uint32_t key )
{
uint32_t slot = hash(key);
while ( true )
{
if (hashtable [slot].key == key ) // found key
{
hashtable[slot].value = Empty;
return;
}
if ( hashtable[slot].key == Empty ) // key not found, return
return;
slot = (slot + 1) & (kHashTableCapacity - 1);
}
}
Этот код можно легко переписать на GLSL.
При этом в качестве таблицы мы будем использовать SSBO, а вместо функции atomicCAS
мы используем функцию atomicCompSwap
.
#version 450
//
// Simple GLSL hashtable implementation
// uint -> uint
// No GPU rehashing supported
struct KeyValue // entry in table
{
uint key;
uint value;
};
layout ( std430, binding = 0 ) buffer Hashtable
{
KeyValue hashtable [];
};
const uint HashTableCapacity = 128 * 1024 * 1024;
const uint Empty = 0xffffffff;
// 32 bit Murmur3 hash
uint hash ( uint k )
{
k ^= k >> 16;
k *= 0x85ebca6b;
k ^= k >> 13;
k *= 0xc2b2ae35;
k ^= k >> 16;
return k & ( HashTableCapacity - 1 );
}
bool hashtableFind ( uint key )
{
uint slot = hash ( key );
while ( true )
{
if ( hashtable [slot].key == key ) // found key
return true;
if ( hashtable [slot].key == Empty )
return false;
slot = (slot + 1) & (HashTableCapacity - 1);
}
}
void hashtableInsert ( uint key, uint value )
{
uint slot = hash ( key );
while ( true )
{
uint prev = atomicCompSwap ( hashtable [slot].key, Empty, key );
if ( prev == Empty || prev == key )
{
hashtable [slot].value = value;
return;
}
slot = (slot + 1) & (HashTableCapacity-1);
}
}
void hashtableDelete ( uint key )
{
uint slot = hash ( key );
while ( true )
{
if (hashtable [slot].key == key ) // found key
{
hashtable [slot].value = Empty;
return;
}
if ( hashtable [slot].key == Empty ) // key not found, return
return;
slot = (slot + 1) & (HashTableCapacity - 1);
}
}
Ссылки по теме:
WarpCore: A Library for fast Hash Tables on GPUs