Tabla hash simple para GPU


Publiqué en Github un nuevo proyecto llamado A Simple GPU Hash Table .

Esta es una tabla hash simple para la GPU, capaz de procesar cientos de millones de insertos por segundo. En mi computadora portátil con una NVIDIA GTX 1060, el código inserta 64 millones de pares clave-valor generados aleatoriamente en aproximadamente 210 ms y elimina 32 millones de pares en aproximadamente 64 ms.

Es decir, la velocidad en la computadora portátil es de aproximadamente 300 millones de insertos / segundo y 500 millones de extracciones / segundo.

La tabla está escrita en CUDA, aunque la misma técnica se puede aplicar a HLSL o GLSL. La implementación tiene varias limitaciones que aseguran un alto rendimiento en la tarjeta de video:

  • Solo se procesan claves de 32 bits y los mismos valores.
  • La tabla hash tiene un tamaño fijo.
  • Y este tamaño debe ser igual a dos en grado.

Para claves y valores, debe reservar un marcador de delimitación simple (en el código anterior es 0xffffffff).

Mesa hash sin cerraduras


La tabla hash utiliza direccionamiento abierto con detección lineal , es decir, es solo una matriz de pares clave-valor que se almacena en la memoria y tiene un excelente rendimiento de caché. Este no es el caso con el encadenamiento, lo que significa buscar un puntero en una lista vinculada. Una tabla hash es una matriz simple que almacena elementos KeyValue:

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

El tamaño de la tabla es igual a dos en potencia, y no un número primo, porque para usar la máscara pow2 / AND, una instrucción rápida es suficiente y el operador del módulo es mucho más lento. Esto es importante en el caso de la detección lineal, ya que en una búsqueda lineal en la tabla, el índice de la ranura debe estar envuelto en cada ranura. Y como resultado, el costo de la operación se agrega módulo en cada ranura.

La tabla almacena solo la clave y el valor de cada elemento, no el hash de la clave. Dado que la tabla solo almacena claves de 32 bits, el hash se calcula muy rápidamente. El código anterior usa el hash Murmur3, que realiza solo unos pocos cambios, XOR y multiplicaciones.

La tabla hash utiliza una técnica de protección de bloqueo que no depende del orden de colocación de la memoria. Incluso si algunas operaciones de escritura violan el orden de otras operaciones, la tabla hash seguirá manteniendo el estado correcto. Hablaremos de esto a continuación. La técnica funciona muy bien con tarjetas de video en las que compiten miles de hilos.

Las claves y los valores en la tabla hash se inicializan para vaciarse.

El código puede modificarse para que pueda procesar claves y valores de 64 bits. Las claves requieren operaciones de lectura, escritura e intercambio atómicas (comparar e intercambiar). Y los valores requieren operaciones de lectura y escritura atómicas. Afortunadamente, en CUDA, las operaciones de lectura y escritura para valores de 32 y 64 bits son atómicas siempre que estén alineadas de forma natural (ver aquí), y las tarjetas de video modernas admiten operaciones atómicas de 64 bits de comparación con intercambio. Por supuesto, al cambiar a 64 bits, el rendimiento disminuirá ligeramente.

Estado de la tabla hash


Cada par clave-valor en una tabla hash puede tener uno de cuatro estados:

  • La clave y el significado están vacíos. En este estado, la tabla hash se inicializa.
  • La clave ha sido registrada, pero el valor aún no. Si otro hilo de ejecución está leyendo datos en ese momento, entonces devuelve un valor vacío. Esto es normal, lo mismo sucedería si otro hilo de ejecución funcionara un poco antes, y estamos hablando de una estructura de datos competitiva.
  • Se registran tanto la clave como el valor.
  • El valor está disponible para otros hilos de ejecución, pero la clave aún no lo está. Esto puede suceder porque el modelo de programación CUDA implica un modelo de memoria mal ordenado. Esto es normal; en cualquier caso, la clave todavía está vacía, incluso si el valor ya no es tal.

Un matiz importante es que tan pronto como la clave se ha escrito en la ranura, ya no se mueve, incluso si se elimina la clave, hablaremos de esto a continuación.

El código de tabla hash incluso funciona con modelos de memoria mal ordenados que no conocen el orden de lectura y escritura en la memoria. Cuando analizamos la inserción, la búsqueda y la eliminación en la tabla hash, recuerde que cada par clave-valor se encuentra en uno de los cuatro estados descritos anteriormente.

Insertar en una tabla hash


Una función CUDA que inserta pares clave-valor en una tabla hash se ve así:

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

Para insertar una clave, el código itera sobre la matriz de la tabla hash comenzando con el hash de la clave insertada. En cada ranura de la matriz, se realiza una operación de comparación atómica con el intercambio, en el que la clave en esta ranura se compara con una vacía. Si se detecta una falta de coincidencia, la clave en la ranura se actualiza a la clave insertada y luego se devuelve la clave original de la ranura. Si esta clave original estaba vacía o correspondía a la clave insertada, entonces el código encontró una ranura adecuada para la inserción y trae el valor insertado a la ranura.

Si en una llamada del núcleogpu_hashtable_insert()Hay varios elementos con la misma clave, entonces cualquiera de sus valores se puede escribir en la ranura de la clave. Esto se considera normal: una de las operaciones de escritura de valores clave durante la llamada será exitosa, pero dado que todo esto sucede en paralelo dentro de varios hilos de ejecución, no podemos predecir qué operación de escritura en la memoria será la última.

Búsqueda de tabla hash


Código del buscador clave:

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

Para encontrar el valor de la clave almacenada en la tabla, iteramos sobre la matriz comenzando con el hash de la clave deseada. En cada ranura, verificamos si la clave es la que estamos buscando y, de ser así, devolvemos su valor. También verificamos si la clave está vacía, y si es así, interrumpimos la búsqueda.

Si no podemos encontrar la clave, entonces el código devuelve un valor vacío.

Todas estas operaciones de búsqueda se pueden realizar de manera competitiva durante las inserciones y eliminaciones. Cada par de la tabla tendrá uno de los cuatro estados descritos anteriormente para la secuencia.

Eliminación de tabla hash


Código de eliminación de clave:

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

Eliminar una clave es inusual: dejamos la clave en la tabla y marcamos su valor (no la clave en sí) vacía. Este código es muy similar lookup(), excepto que cuando se encuentra una coincidencia para la clave, hace que su valor esté vacío.

Como se mencionó anteriormente, una vez que la clave se escribe en la ranura, ya no se mueve. Incluso cuando elimina un elemento de la tabla, la clave permanece en su lugar, solo su valor se vacía. Esto significa que no necesitamos usar la operación atómica de escribir el valor de la ranura, porque no importa si el valor actual está vacío o no, seguirá estando vacío.

Cambiar el tamaño de una tabla hash


Puede cambiar el tamaño de la tabla hash creando una tabla más grande e insertando elementos no vacíos de la tabla anterior. No implementé esta funcionalidad porque quería mantener el código de muestra simple. Además, en los programas CUDA, la asignación de memoria a menudo se realiza en el código del host y no en el núcleo CUDA.

El artículo A A-Lock-Wait Wait-Free Hash Table describe cómo cambiar dicha estructura de datos protegida con bloqueo.

Competitividad


En los fragmentos anteriores de código, las funciones gpu_hashtable_insert(), _lookup()y _delete()proceso de un solo par clave-valor a la vez. Y a continuación gpu_hashtable_insert(), _lookup()se _delete()procesan una serie de pares en paralelo, cada par en un hilo GPU separada de ejecución:

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

Una tabla hash bloqueable admite inserciones, búsquedas y eliminaciones concurrentes. Dado que los pares clave-valor siempre están en uno de los cuatro estados, y las claves no se mueven, la tabla garantiza la corrección incluso cuando se utilizan diferentes tipos de operaciones.

Sin embargo, si procesamos un paquete de inserciones y eliminaciones en paralelo, y si hay claves duplicadas en la matriz de entrada de pares, entonces no podremos predecir qué pares "ganarán": se escribirán en la tabla hash en último lugar. Supongamos que llamamos un código de inserción con una matriz de entrada de pares A/0 B/1 A/2 C/3 A/4. Cuando se completa el código, los pares B/1y están C/3garantizados para estar presentes en la tabla, pero al mismo tiempo cualquiera de los pares aparecerá en ella A/0, A/2oA/4. Esto puede o no ser un problema, todo depende de la aplicación. Puede saber de antemano que no hay claves duplicadas en la matriz de entrada, o puede que no le importe qué valor se escribió por última vez.

Si esto es un problema para usted, entonces necesita dividir los pares duplicados en diferentes llamadas CUDA del sistema. En CUDA, cualquier operación de llamada al núcleo siempre finaliza antes de la siguiente llamada del núcleo (al menos dentro del mismo hilo. En diferentes hilos, el núcleo se ejecuta en paralelo). Si en el ejemplo anterior, llame a un núcleo con A/0 B/1 A/2 C/3, y al otro con A/4, entonces la clave Aobtendrá un valor 4.

Ahora hablemos sobre si la función lookup()y el delete()uso de un puntero simple (simple) o variable (volátil) a una matriz de pares en una tabla hash.La documentación de CUDA establece que:

El compilador puede, a su discreción, optimizar las operaciones de lectura y escritura en la memoria global o compartida ... Estas optimizaciones pueden deshabilitarse usando la palabra clave volatile: ... cualquier enlace a esta variable se compila en una instrucción real de lectura o escritura en la memoria.

Las consideraciones de corrección no requieren aplicación volatile. Si el hilo de ejecución usa el valor almacenado en caché de una operación de lectura anterior, entonces esto significa que usará información un poco desactualizada. Pero aún así, esta es información del estado correcto de la tabla hash en un cierto punto de la llamada del kernel. Si necesita usar la información más reciente, puede usar el puntero volatile, pero luego el rendimiento disminuirá ligeramente: según mis pruebas, cuando elimina 32 millones de elementos, la velocidad disminuye de 500 millones de eliminación / seg a 450 millones de eliminación / seg.

Actuación


En la prueba para insertar 64 millones de elementos y eliminar 32 millones de ellos, std::unordered_mapprácticamente no hay competencia entre y la tabla hash para la GPU:


std::unordered_mapPasó 70 691 ms en la inserción y eliminación de elementos con la liberación posterior unordered_map(la liberación de millones de elementos lleva mucho tiempo, ya que unordered_mapse realizan numerosas asignaciones de memoria en el interior ). Honestamente, hay std:unordered_maplimitaciones completamente diferentes. Este es un único subproceso de ejecución de la CPU, admite valores clave de cualquier tamaño, funciona bien a altas tasas de utilización y muestra un rendimiento estable después de numerosas eliminaciones.

La duración de la tabla hash para la GPU y la comunicación entre programas fue de 984 ms. Esto incluye el tiempo necesario para colocar la tabla en la memoria y eliminarla (asignación única de 1 GB de memoria, que lleva algo de tiempo en CUDA), insertar y eliminar elementos, y también iterar sobre ellos. También se tiene en cuenta toda la copia desde y hacia la memoria de la tarjeta de video.

La propia tabla hash tomó 271 ms. Esto incluye el tiempo empleado por la tarjeta de video para insertar y eliminar elementos, y no tiene en cuenta el tiempo que lleva copiar en la memoria e iterar sobre la tabla resultante. Si la tabla GPU dura mucho tiempo, o si la tabla hash está contenida completamente en la memoria de la tarjeta de video (por ejemplo, para crear una tabla hash que será utilizada por otro código GPU y no por el procesador central), el resultado de la prueba es relevante.

La tabla hash para la tarjeta de video muestra un alto rendimiento debido a su gran ancho de banda y paralelización activa.

desventajas


La arquitectura de la tabla hash tiene varios problemas a tener en cuenta:

  • El agrupamiento interfiere con el sondeo lineal, por lo que las claves en la tabla están lejos de ser ideales.
  • Las teclas no se eliminan mediante la función deletey con el tiempo desordenan la tabla.

Como resultado, el rendimiento de la tabla hash puede disminuir gradualmente, especialmente si existe durante mucho tiempo y se realizan numerosas inserciones y eliminaciones. Una forma de mitigar estas deficiencias es volver a mostrar una nueva tabla con una tasa de utilización bastante baja y filtrar las claves remotas al volver a mostrar.

Para ilustrar los problemas descritos, utilizo el código anterior para crear una tabla para 128 millones de elementos, insertaré cíclicamente 4 millones de elementos hasta llenar 124 millones de espacios (la utilización es de aproximadamente 0,96). Aquí está la tabla de resultados, cada fila es una llamada al núcleo de CUDA con la inserción de 4 millones de elementos nuevos en una tabla hash:

Tasa de usoDuración de inserción 4 194 304 elementos
0.0011.608448 ms (361.314798 millones de claves / seg.)
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 /.)

A medida que aumenta la utilización, disminuye la productividad. Esto no es deseable en la mayoría de los casos. Si una aplicación inserta elementos en una tabla y luego los descarta (por ejemplo, al contar palabras en un libro), entonces esto no es un problema. Pero si la aplicación utiliza una tabla hash de larga duración (por ejemplo, en un editor de gráficos para almacenar partes de imágenes no vacías cuando el usuario a menudo inserta y elimina información), este comportamiento puede ser problemático.

Y midió la profundidad de sondear la tabla hash después de 64 millones de inserciones (factor de utilización 0.5). La profundidad promedio fue de 0.4774, por lo que la mayoría de las teclas se ubicaron en la mejor ranura posible o en una ranura desde la mejor posición. La profundidad máxima de sondeo fue de 60.

Luego medí la profundidad de sondeo en la tabla con 124 millones de insertos (tasa de utilización 0.97). La profundidad promedio ya era 10.1757, y la máxima - 6474 (!!). El rendimiento del sonido lineal cae drásticamente a altas tasas de utilización.

Es mejor mantener baja esta tabla hash. Pero luego aumentamos la productividad al consumir memoria. Afortunadamente, en el caso de claves y valores de 32 bits, esto puede justificarse. Si en el ejemplo anterior en la tabla para 128 millones de elementos se guarda el coeficiente de utilización de 0.25, entonces no podemos colocar más de 32 millones de elementos en él, y se perderán los 96 millones de ranuras restantes: 8 bytes por cada par, 768 MB de memoria perdida.

Tenga en cuenta que estamos hablando de la pérdida de memoria de la tarjeta de video, que es un recurso más valioso que la memoria del sistema. Aunque la mayoría de las tarjetas gráficas de escritorio modernas que admiten CUDA tienen al menos 4 GB de memoria (al momento de escribir, NVIDIA 2080 Ti tiene 11 GB), perder esos volúmenes no será la mejor decisión.

Más adelante, escribiré más sobre la creación de tablas hash para tarjetas de video que no tengan problemas con la profundidad del sonido, así como las formas de reutilizar las ranuras remotas.

Medición de profundidad de detección


Para determinar la profundidad del sonido de la clave, podemos extraer el hash de la clave (su índice ideal en la tabla) de su índice de tabla real:

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

Debido a la magia de dos números binarios en el código adicional y al hecho de que la capacidad de la tabla hash es igual a dos en potencia, este enfoque funcionará incluso cuando el índice clave se mueva al comienzo de la tabla. Tome una clave que está dividida en 1 pero insertada en la ranura 3. Luego, para una tabla con capacidad 4 obtenemos (3 — 1) & 3lo que es equivalente a 2.

Conclusión


Si tiene preguntas o comentarios, escríbame en Twitter o abra un nuevo tema en el repositorio .

Este código está inspirado en algunos excelentes artículos:


En el futuro, continuaré escribiendo sobre implementaciones de tablas hash para tarjetas de video y analizaré su rendimiento. Tengo planes de encadenar, hash Robin Hood y cuckoo hash utilizando operaciones atómicas en estructuras de datos que sean convenientes para tarjetas de video.

All Articles