Usar SIMD/AVX/SSE para cruzar árboles

Actualmente estoy investigando si sería posible acelerar un recorrido de árboles de van Emde Boas (o cualquier árbol). Dada una única consulta de búsqueda como entrada, que ya tiene varios nodos de árbol en la línea de caché (diseño de van emde Boas), el cruce de árbol parece tener un cuello de botella de instrucciones.

Siendo un poco nuevo en las instrucciones de SIMD/AVX/SSE, me gustaría saber de los expertos en ese tema si sería posible comparar múltiples nodos a la vez con un valor y luego descubrir qué ruta de árbol seguir. Mi investigación llevó a la siguiente pregunta:

¿Cuántos ciclos/instrucciones de CPU se desperdician en la construcción del registro SIMD/AVX/SSE, etc. Esto haría su uso para el wayne, si la construcción lleva más tiempo que atravesar manualmente todo el subárbol (2 + 4 + 8 nodos en 1 caché de tamaño 64 bytes).

¿Cuántos ciclos/instrucciones de la CPU se desperdicia para encontrar el registro SIMD/AVX/SSE adecuado con la respuesta de qué camino seguir? ¿Podría alguien encontrar una forma inteligente para que esas instrucciones AVX "findMinimumInteger" se puedan usar para decidir en un ciclo de 1 (??) cpu?

¿Cuál es tu suposición?

Another, more tricky approach to speed up tree traversal would be to have multiple search querys run down at once, when there is high probability to land in nodes closely together in the last tree level. Any guesses on this ? Ofc it would have to put those querys aside that do not belong to the same sub-tree any longer and then recursively find them after finishing the first "parallel traversal" of the tree.. The tree querys have sequential, though not constant access patterns (query[i] always < than query[i+1]).

Importante: esto es sobre árbol entero, por lo que se usa el árbol de van Emde Boas (quizás x-fast/y-fast intente más adelante)

Tengo curiosidad sobre cuáles son sus 50 centavos en este tema, dado que uno podría estar interesado en el rendimiento más alto posible en árboles de gran escala. Gracias de antemano por su tiempo gastando en esto :-)

0
Utilizaremos el enhebrado masivo de todos modos. Esta es solo la implementación más eficiente de un árbol en el hardware AVX512.
agregado el autor user1610743, fuente
Si tiene muchos árboles, me sentiría tentado a hacer que cada búsqueda de árboles sea un hilo paralelo. (Hacemos esto en la herramienta de transformación/análisis de programa que construimos, parece funcionar razonablemente). ¿Por qué no es esa una de tus opciones consideradas? Otra idea: si tiene varias consultas y sabe con anticipación, puede compilarlas en una FSA utilizada para guiar las búsquedas. La parte de la FSA generada por consultas comunes se procesa una sola vez, con un ahorro considerable. (Mira los analizadores de LR para ver un truco similar de patrón de producto).
agregado el autor Ira Baxter, fuente

2 Respuestas

De acuerdo con su código, me adelanté y comparé 3 opciones: AVX2, ramificación anidada (4 saltos) y una variante sin sucursales. Estos son los resultados:

// Tabla de rendimiento ... // Todos usan el tamaño de la línea de caché 64byteAligned chunks (van Emde-Boas Layout); loop desenrollado por cacheline; // todas las optimizaciones activadas. Cada Elemento es de 4 bytes. Intel i7 4770k Haswell a 3.50 GHz

Type        ElementAmount       LoopCount       Avg. Cycles/Query
===================================================================
AVX2        210485750           100000000       610 cycles    
AVX2        21048575            100000000       427 cycles           
AVX2        2104857             100000000       288 cycles 
AVX2        210485              100000000       157 cycles   
AVX2        21048               100000000       95 cycles  
AVX2        2104                100000000       49 cycles    
AVX2        210                 100000000       17 cycles 
AVX2        100                 100000000       16 cycles   


Type        ElementAmount       LoopCount       Avg. Cycles/Query
===================================================================  
Branching   210485750           100000000       819 cycles 
Branching   21048575            100000000       594 cycles 
Branching   2104857             100000000       358 cycles 
Branching   210485              100000000       165 cycles 
Branching   21048               100000000       82 cycles
Branching   2104                100000000       49 cycles 
Branching   210                 100000000       21 cycles 
Branching   100                 100000000       16 cycles   


Type        ElementAmount       LoopCount       Avg. Cycles/Query
=================================================================== 
BranchLESS  210485750           100000000       675 cycles 
BranchLESS  21048575            100000000       602 cycles 
BranchLESS  2104857             100000000       417 cycles
BranchLESS  210485              100000000       273 cycles 
BranchLESS  21048               100000000       130 cycles 
BranchLESS  2104                100000000       72 cycles 
BranchLESS  210                 100000000       27 cycles 
BranchLESS  100                 100000000       18 cycles

Así que mi conclusión es similar: cuando el acceso a la memoria es un poco óptimo, AVX puede ayudar con Tree's más grande que 200k Elements. Debajo, apenas hay una multa que pagar (si no usa AVX para nada más). Ha valido la noche de la evaluación comparativa de esto. Gracias a todos los involucrados :-)

0
agregado

He usado SSE2/AVX2 para ayudar a realizar una búsqueda de árbol B +. Aquí hay un código para realizar una búsqueda binaria en una línea de caché completa de 16 DWORD en AVX2:

// perf-critical: ensure this is 64-byte aligned. (a full cache line)
union bnode
{
    int32_t i32[16];
    __m256i m256[2];
};

// returns from 0 (if value < i32[0]) to 16 (if value >= i32[15]) 
unsigned bsearch_avx2(bnode const* const node, __m256i const value)
{
    __m256i const perm_mask = _mm256_set_epi32(7, 6, 3, 2, 5, 4, 1, 0);

   //compare the two halves of the cache line.

    __m256i cmp1 = _mm256_load_si256(&node->m256[0]);
    __m256i cmp2 = _mm256_load_si256(&node->m256[1]);

    cmp1 = _mm256_cmpgt_epi32(cmp1, value);//PCMPGTD
    cmp2 = _mm256_cmpgt_epi32(cmp2, value);//PCMPGTD

   //merge the comparisons back together.
    //
   //a permute is required to get the pack results back into order
   //because AVX-256 introduced that unfortunate two-lane interleave.
    //
   //alternately, you could pre-process your data to remove the need
   //for the permute.

    __m256i cmp = _mm256_packs_epi32(cmp1, cmp2);//PACKSSDW
    cmp = _mm256_permutevar8x32_epi32(cmp, perm_mask);//PERMD

   //finally create a move mask and count trailing
   //zeroes to get an index to the next node.

    unsigned mask = _mm256_movemask_epi8(cmp);//PMOVMSKB
    return _tzcnt_u32(mask)/2;//TZCNT
}

Usted terminará con una única rama altamente predecible por bnode , para probar si se ha alcanzado el final del árbol.

Esto debería ser trivialmente escalable para AVX-512.

Para preprocesar y deshacerse de esa instrucción lenta de PERMD , esto se usaría:

void preprocess_avx2(bnode* const node)
{
    __m256i const perm_mask = _mm256_set_epi32(3, 2, 1, 0, 7, 6, 5, 4);
    __m256i *const middle = (__m256i*)&node->i32[4];

    __m256i x = _mm256_loadu_si256(middle);
    x = _mm256_permutevar8x32_epi32(x, perm_mask);
    _mm256_storeu_si256(middle, x);
}
0
agregado
Estoy ansioso por probar esto, ya que actuaremos en dispositivos compatibles con AVX512. Estaba pensando en poner todos los datos en el último nivel del árbol y usar los primeros niveles de log2 (n) -1 como un rápido acelerador de consultas; instalar más nodos en una línea de caché (sin necesidad de punteros de datos si el árbol es estático); también eliminaría el requisito de verificar la igualdad en cada iteración de verificación/iteración de nodos: solo se necesita uno == después de finalizar todas las iteraciones.
agregado el autor user1610743, fuente
Por cierto, ¿alguna razón especial para almacenar indicadores de bifurcación? Creo firmemente que es un desperdicio de espacio en el caché. Desplazar hacia abajo 4 bytes en lugar de 1 para árboles binarios debería funcionar bien.
agregado el autor user1610743, fuente
Sí, me gustaría poner las cosas al lado del otro. El espacio ahorrado al no usar punteros puede utilizarse para sobredimensionar la asignación de memoria para árboles dinámicos. Con este proyecto, estamos bien con árboles de tamaño estático. Es por eso que estoy pensando en usar solo el último nivel del árbol, que no funcionaría tan bien si necesitas insertarlo.
agregado el autor user1610743, fuente
¡He añadido un punto de referencia sobre cómo funcionan varios métodos en el cruce de árboles! Gracias a todos por ayudarme mucho con la parte de AVX.
agregado el autor user1610743, fuente
Otra pregunta interesante de esto sería; si borras la penalización de las ramas mal predichas, siendo más eficiente en el lado de la instrucción pero un poco obligado por la carga de nuevos datos; uno podría hacer operaciones adicionales sobre los datos mientras espera que lleguen los próximos datos. Podría imaginarlo para tener algún uso en los motores de los juegos. También me preguntaba cuándo se emitirá la captación previa de "load cacheline +1". Hasta ahora, el diseño de la memoria de mi árbol no ofrece ninguna ruta de caché +1 como DFS en los trozos de caché (vEB) podría ofrecer. Mejora
agregado el autor user1610743, fuente
Sus nodos B-tree caben en una sola línea de caché. No me puedo imaginar que el SSE (etc.) Proporcionaría mucho beneficio en el rendimiento, incluso si el árbol B encajaba completamente en el caché (lo que parece una bonita caja de strings). He construido B-trees en memoria en ensamblador que tienen estas mismas restricciones; más o menos, solo obtiene una "rama única" real por nodo porque el predictor de rama prácticamente lo hace bien. En el peor de los casos, puede hacer una búsqueda binaria en las claves en el nodo; solo hay 6 promedio. ¿Puedes citar con SSE y sin números para comparar?
agregado el autor Ira Baxter, fuente
Si sus nodos B-tree pueden estar en cualquier lugar, ¿cómo puede evitar los punteros? ¿Estás asumiendo que el árbol está contiguo en la memoria?
agregado el autor Ira Baxter, fuente
Eso no es en realidad una búsqueda binaria dentro del nodo B-tree; es un O (N * log2 (N)) búsqueda paralela de fuerza bruta, que es realmente bueno para pequeños N. (N = 2 mm vectores en este caso). (La parte log2 (N) es la agrupación en un único mapa de bits escalar. Aunque para una N grande, aún así solo fusionaríamos elementos de bytes, luego realizamos un paso de fusión final después de vpmovmskb , y usa múltiples _tzcnt_u64 . Así que supongo que es realmente O (N) ). De todos modos, me parece óptimo para este tamaño d
agregado el autor Peter Cordes, fuente
Estoy trabajando ahora mismo, así que no puedo buscar el código. El SIMD es básicamente una forma rápida de realizar una búsqueda binaria en un número fijo de enteros, y reduce esas ramas. Eso es todo lo que hace.
agregado el autor Cory Nelson, fuente
Se necesitaron punteros de rama en mi caso, pero es fácil ver casos que se pueden optimizar a su alrededor.
agregado el autor Cory Nelson, fuente
E Ira, un asignador personalizado se puede hacer para proporcionar nodos contiguos y un puntero base.
agregado el autor Cory Nelson, fuente
¡Encontré mi código! Actualizó la publicación.
agregado el autor Cory Nelson, fuente
Me imagino que podría sacar ventaja de la captación previa si hace que su algoritmo procese en pasos y haga algo más en el medio. Dudo que sea útil de lo contrario, pero me gustaría saber qué se te ocurre.
agregado el autor Cory Nelson, fuente