Você pode superar a busca binária
(lemire.me)- A verificação de pertencimento em arrays ordenados pode ser feita de forma mais rápida do que a busca binária clássica de livro-texto, e o SIMD Quad foi mais rápido que
std::binary_searchem todas as condições medidas - O SIMD Quad divide um array ordenado de inteiros sem sinal de 16 bits em blocos de 16 elementos e faz busca por interpolação quaternária nos limites dos blocos, com comparações paralelas via SIMD dentro de cada bloco
- ARM 64 bits moderno e x64 (Intel/AMD) conseguem comparar 8 inteiros de 16 bits com uma única instrução, reduzindo a necessidade de seguir literalmente a estrutura da busca binária que examina um valor por vez
- O benchmark foi executado com 100.000 arrays ordenados de tamanho 2 a 4096 e 10 milhões de consultas de pertencimento; o modo cold simula falhas de cache e o modo warm simula acertos de cache
- Na Intel, com cache warm, o SIMD Quad foi mais de 2x mais rápido que a busca binária; na Apple, com cache cold, foi mais de 2x mais rápido; e, em arrays grandes com cache cold na Intel, a abordagem quaternária aproveitou melhor o paralelismo em nível de memória
O gargalo da verificação de pertencimento em arrays ordenados
- A forma mais simples de verificar se um valor existe em um array ordenado é a busca linear, percorrendo os valores um a um; em C++, o mesmo efeito pode ser obtido com
std::find - Em arrays grandes, a busca binária é mais rápida e repete o processo de descartar a metade superior ou inferior com base no valor do meio da faixa de busca até encontrar o valor ou chegar a uma faixa vazia
- Em C++,
std::binary_searchretorna um booleano indicando se o valor existe - O formato Roaring Bitmap usa arrays de inteiros de 16 bits com tamanho de 1 a 4096 e usa busca binária para verificar a existência de valores
O ponto de partida do SIMD Quad
- A maioria dos processadores modernos possui instruções de paralelismo de dados, ou SIMD, e ARM 64 bits e x64 (Intel/AMD) conseguem comparar 8 inteiros de 16 bits com o valor alvo em uma única instrução
- Por causa dessa característica, não é necessário continuar descendo com a busca binária até blocos menores que 8, e também é barato comparar 16 elementos ou mais
- A busca binária examina um valor por vez, mas processadores recentes conseguem carregar e testar vários valores de uma só vez e também têm bom paralelismo em nível de memória
- Em vez de dividir o array em duas partes, é possível tentar uma busca quaternária, dividindo em quatro; mesmo que o número de instruções aumente um pouco, o gargalo pode não estar na quantidade de instruções
Estrutura do algoritmo SIMD Quad
- O SIMD Quad é um algoritmo de busca para arrays ordenados de inteiros sem sinal de 16 bits, combinando busca por interpolação quaternária com SIMD
- O array é dividido em blocos de tamanho fixo com 16 elementos, e o último bloco pode ser uma exceção
- O último elemento de cada bloco é usado como chave de interpolação para restringir a busca a um único bloco com maior probabilidade de conter o valor alvo; em seguida, os 16 elementos desse bloco são testados simultaneamente com SIMD
- O fluxo central é uma busca em camadas: menos comparações com busca por interpolação nos limites dos blocos e comparações paralelas via SIMD dentro do bloco
- As etapas de funcionamento são as seguintes
- Se o número de elementos for menor que 16, faz uma busca linear simples no array inteiro
- O array de tamanho
cardinalityé dividido em blocos de 16 elementos consecutivos, e o número total de blocos énum_blocks = cardinality / 16 - Os últimos elementos dos blocos nas posições
16-1,32-1etc. são usados como chaves, comparando os pontos de 1/4 da faixa de busca atual com o alvopose ajustandobase - Com base no resultado da interpolação, escolhe-se o índice de bloco apropriado
lo - Se o bloco for válido, no ARM usa-se NEON e no x64 usa-se SSE2 para carregar 16 elementos e fazer comparações de igualdade em paralelo com o valor alvo; se houver correspondência, retorna
true - Os elementos restantes que não entram em um bloco completo de 16 são verificados com busca linear
Metodologia do benchmark
- Para cada tamanho de array entre 2 e 4096, o benchmark gerou 100.000 arrays ordenados de inteiros sem sinal de 16 bits
- Para cada tamanho, foram executadas 10 milhões de consultas de pertencimento em dois modos
- Modo cold: cada consulta pesquisa um array diferente, simulando falhas de cache
- Modo warm: as consultas são agrupadas por array e o mesmo array é pesquisado 100 vezes seguidas, simulando acertos de cache
- A métrica medida foi o tempo médio por consulta, e os algoritmos comparados foram busca linear (
std::find), busca binária (std::binary_search) e SIMD Quad - Os sistemas de medição foram Apple M4 com Apple LLVM e processador Intel Emerald Rapids com GCC
Resultados das medições
- Na comparação entre busca linear e busca binária, a busca binária vence conforme o array cresce
- Com cache cold, a busca linear é relativamente mais prejudicada, porque mais acessos a dados aumentam as falhas de cache
- Depois de a busca binária se mostrar superior à busca linear de forma geral, ela foi comparada com o SIMD Quad
- Os resultados nas plataformas Intel e Apple foram claramente diferentes
- Na plataforma Intel, com cache warm, o SIMD Quad foi mais de 2x mais rápido que a busca binária
- Na plataforma Intel, com cache cold, o ganho foi menor
- Na plataforma Apple, ao contrário, o SIMD Quad foi mais de 2x mais rápido com cache cold, e o ganho foi mais limitado com cache warm
- Em todos os casos, o SIMD Quad foi mais rápido que a busca binária
- A parte SIMD reduz o trabalho com instruções especiais e usa menos instruções e desvios, o que torna o ganho de velocidade fácil de entender
O efeito da busca quaternária
- Também foi comparada uma versão SIMD binary que mantém a otimização com SIMD, mas troca a busca por interpolação quaternária pela busca binária padrão
- Na plataforma Apple, o efeito da abordagem quaternária foi pequeno
- Na plataforma Intel, em arrays grandes com cache cold, a abordagem quaternária foi uma otimização significativa
- Nos servidores Intel, a busca quaternária aproveitou melhor o paralelismo em nível de memória
Pontos centrais da implementação
- A função
simd_quadrecebe um arrayuint16_t, o número de elementoscardinalitye o valor a procurarpos, retornando um booleano gapé fixado em 16, e secardinality < gap, a busca é feita com um loop simples- O loop de busca por blocos, enquanto
n > 3, lê os valores finais dos blocos nos pontos de 1/4, 2/4 e 3/4, compara compose movebasepela soma dos três resultados de comparação - Depois disso, enquanto
n > 1, são feitas comparações com base na metade para reduzir a faixa restante e, por fim, seleciona-se o blocolo - O bloco selecionado é comparado em duas metades de 16 elementos usando
vceqq_u16no ARM NEON ou_mm_cmpeq_epi16no x64 SSE2 - Na faixa de elementos restantes, retorna-se se
v == posno ponto em quev >= pos; se não aparecer até o fim, retornafalse
Conclusão e materiais
- A busca binária clássica de livro-texto é um algoritmo razoável, mas pode ser tornada significativamente mais rápida na prática
- Muitos algoritmos padrão não foram projetados assumindo o alto grau de paralelismo dos computadores atuais
- O SIMD Quad é uma abordagem que tenta explorar tanto o paralelismo em nível de memória quanto o paralelismo de dados
- Algoritmos ainda melhores podem ser possíveis, e abordagens mais criativas são necessárias
- Código-fonte
- Interseções mais rápidas entre arrays ordenados com shotgun
1 comentários
Comentários do Hacker News
Se houver conhecimento prévio sobre a distribuição dos dados, dá para usar essa informação e projetar algoritmos muito mais rápidos. Por exemplo, em um dicionário de papel, uma pessoa consegue pular para um conjunto de páginas apropriado mais rápido do que com uma binary search puramente ideal
Matematicamente, buscar em uma lista ordenada é quase como inverter uma função monotônica com um algoritmo de controle em malha fechada, e também seria possível usar uma função de custo adequada com gradient descent ou variantes aceleradas
De forma mais geral, para resolver um problema de maneira mais eficiente, o melhor costuma ser usar mais informações sobre o problema específico que você quer resolver, em vez de recorrer a uma solução excessivamente abstrata. Isso pode dar ganhos escaláveis de ordens de grandeza, maiores do que melhorias por fator constante obtidas ao usar melhor o hardware
O número fixo de iterações é bom para o branch predictor, e o loop principal também é otimizado de forma bem agressiva para o hardware-alvo, evitando multiplicações. Todas as tentativas de deixá-lo mais esperto acabaram piorando o loop e não compensaram o custo. É um formato array-of-structs de tamanho 12, e o N também costuma ser pequeno, o que dificulta ainda mais
Não salvei nos favoritos, então acabo procurando de novo umas duas vezes por ano
É por isso que B-tree é o padrão em bancos de dados. Os dados podem ser qualquer coisa, e se você importar muitas linhas novas de uma vez, as propriedades podem mudar bastante a qualquer momento
Você até pode tentar projetar algo para acelerar lookup com algoritmos como gradient descent, mas a B-tree já é muito rápida, tem desempenho de pior caso e exigências de I/O previsíveis, além de muitas vantagens como range scan, ordered traversal e condições de prefixo
Dá para criar algoritmos de lookup mais eficientes para distribuições específicas de dados, mas muitas vezes eles perdem propriedades importantes. Mesmo que outro algoritmo dê uma estimativa inicial mais próxima, ele pode acabar sendo mais lento para encontrar o item final, e pode ter média melhor mas pior caso terrível, o que dificulta o uso na prática
Mesmo num dicionário de papel, depois da primeira estimativa quase sempre se usa binary search, e isso só reduz alguns movimentos. Quando você chega às páginas mais ou menos corretas, acaba fazendo uma busca linear além do necessário; uma binary search estrita talvez fosse mais rápida, mas é preciso equilibrar isso com o tempo de virar páginas
Ed Kmett refinou a ideia na biblioteca discrimination[2] para Haskell, e uma apresentação técnica relacionada[3] também foi muito interessante
[1]: https://dl.acm.org/doi/epdf/10.1145/1411203.1411220
[2]: https://hackage.haskell.org/package/discrimination
[3]: https://www.youtube.com/watch?v=cB8DapKQz-I
Se erram, descartam esse trabalho especulativo, pagam alguns ciclos de penalidade e recomeçam de outro ponto
Isso significa que, do ponto de vista do processador, todo loop já é em certa medida desenrolado, e o principal custo da binary search é buscar dados da memória ou do cache. Então o verdadeiro jogo é emitir o quanto antes os pedidos dos dados que serão necessários depois, para reduzir a latência
Quad search e buscas de ordem ainda maior fazem prefetch raso de todos os casos possíveis, em vez de fazer prefetch profundo só de um lado de cada branch. Assim, garantem que o prefetch que realmente será necessário seja emitido, e também reduzem um pouco a bandwidth gasta com dados que não serão usados no caminho de execução real
“Número de comparações” quase não serve como métrica para prever desempenho real em algoritmos de busca. O gargalo não é a quantidade de comparações, mas sim usar ao máximo a bandwidth de memória e de cache. Levando em conta o comportamento de branch em processadores modernos, até dá para enxergar como loop unrolling
De todo modo, as duas buscas são O(log N), mudando só a constante. Em aulas de algoritmos, a constante importa menos, mas no mundo real ela pode importar bastante
No nosso workload, ele trouxe ganho de 8x em velocidade
[1] https://lalitm.com/post/exponential-search/
[2] https://en.wikipedia.org/wiki/Exponential_search
HEAD /users/1 -> 200 OK
HEAD /users/2 -> 200 OK
HEAD /users/4 -> 200 OK
...
HEAD /users/2048 -> 200 OK
HEAD /users/4096 -> 404 Not Found
Depois disso, basta fazer binary search entre 2048 e 4096 para achar o usuário mais recente e, de quebra, o número de usuários. É uma informação bastante útil ao investigar empresas concorrentes de SaaS
Por isso, eu queria tentar uma busca “binária” em que se verifica todos os valores da “cache line do meio” e, se não estiver ali, vai para a esquerda/direita. A busca na cache line poderia ser feita com uma única instrução SIMD de 512bit
Uma cache line tem 64 bytes, ou seja, 32 inteiros de 16 bits, então esse tipo de busca poderia ser quase 32 vezes mais rápida que uma binary search simples. Pelo menos os acessos à memória cairiam em 32x, e na maioria dos programas reais isso é o fator dominante
Com chave de 4 bytes e child pointer ou índice de array de 4 bytes, um internal node ocupa exatamente uma cache line de 64 bytes com 7 chaves, 8 child pointers e 1 next pointer. Em uma árvore com 1 milhão de entradas, a profundidade cai de algo como 20 para algo como 7, e os níveis superiores provavelmente ficam no cache
Também dá para aplicar SIMD no nó da B-tree para acelerar a busca interna do nó, mas a dependência de dados é muito alta
No geral, gostei muito do artigo por explorar perfeitamente uma dúvida que eu tinha com um estudo de ablação bastante útil
O mais rápido em arrays pequenos foi linear branchless search. Ou seja, em vez de sair do loop no meio, ela percorre todos os elementos. Por exemplo, se você só quer saber se um número está no array, pode fazer OR lógico dos resultados das verificações de todos os itens
Não usei SIMD, mas em arrays pequenos o custo de branch é tão alto que costuma ser mais rápido simplesmente verificar todos os elementos sem branch
A parte de SIMD só aparece no estágio final, na busca dos últimos 16 elementos
A parte “quad” consiste em verificar 3 pontos para criar 4 caminhos, mas não está procurando apenas a chave exata; está procurando o bloco correto
Os detalhes são interessantes: o autor usa o último elemento de cada bloco para a quad search. Fiquei curioso sobre como o algoritmo mudaria se usasse o primeiro elemento de cada bloco, ou um elemento arbitrário
A ideia geral seria: 1) usar gather para buscar vários elementos em 16 posições igualmente espaçadas, 2) comparar em paralelo com instruções SIMD, 3) focar no bloco correto, 4) se o bloco ficar pequeno, trocar para busca linear; caso contrário, repetir o ciclo de gather/compare
A instrução gather lê memória não contígua e lê mais do que uma binary search comum, mas permite comparação multiway e elimina quatro etapas de binary search, então pode valer a pena em tabelas grandes
Porém, nem todo tipo de dado permite comparação 16-way completa. Busca em float64 fica limitada a 8-way mesmo com AVX-512, enquanto int32 e float32 permitem comparação 16-way
Aposto que binary search acaba sendo mais rápida na prática do que uma abordagem baseada em gather
Se qualquer uma dessas suposições deixa de valer, aparecem vários ajustes para obter desempenho melhor
A vantagem dos algoritmos clássicos é que eles oferecem um excelente ponto de partida para desenvolver soluções mais eficientes e otimizadas quando você conhece melhor uma determinada forma de dados ou características/quirks de uma CPU específica
Na fronteira da otimização, normalmente você passa a olhar como os dados são armazenados e acessados na memória, e se mudanças para melhorar isso não prejudicam etapas posteriores. Num emprego antigo, alguém passou muito tempo otimizando uma parte específica do código, mas essa otimização expulsava mais informação útil do cache depois, e a aplicação como um todo acabou ficando mais lenta
Isso provavelmente ecoa a quinta regra de programação do Rob Pike, ou indo mais para trás, algo que Fred Brooks disse em The Mythical Man Month. Referência: https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.htm...
Em vez de comparar só com o valor do meio, compara com o valor no ponto de 1/3 e, se for baixo demais, compara com o valor no ponto de 2/3
Mas achei que, em cada etapa, em vez de reduzir o espaço de busca para 2/3 em comparação com 1/2 da binary search, você reduz para 1/3 em vez de 1/2, só que faz em média 3/2 vezes mais comparações, então no fim seria equivalente
Correção: como respondeu zamadatix, na prática são 5/3 comparações. No caso dos 2/3, é preciso comparar duas vezes
O primeiro terço exige 1 comparação, o segundo terço exige 2, e o terceiro terço também exige 2, então a média é (1+2+2)/3 = 5/3. É fácil se enganar achando que é “1 ou 2 comparações, então 50% para cada”, mas na verdade a chance de parar na primeira é 1/3 e a de fazer 2 comparações é 2/3
Por isso, ternary search é um pouquinho pior em número médio total de comparações. 5/3 * Log_3[n] = 1.052... * Log_2[n]
Ou seja, o número de níveis cai, mas em média você faz mais comparações para chegar ao fim. Isso vale de modo geral para buscas com número de divisões maior que 2, sob algumas hipóteses padrão como distribuição uniforme do valor buscado e custo de operação idealizado. As diferenças do hardware real discutidas no texto entram justamente aqui
Só não como versão ternária do algoritmo de binary search. Isso não seria uma ternary search de verdade, mas algo mais próximo de uma binary search enviesada. Comparações são intrinsecamente binárias, então algoritmos de busca baseados em comparação são, de certo modo, um tipo de binary search, e escolher algo diferente do elemento central é menos eficiente em termos de complexidade algorítmica. Claro que no hardware real pode ser melhor em certas condições. Para fazer uma ternary search de verdade, a operação básica precisaria ser uma 3-way comparison
A parte interessante aparece ao pensar em radix efficiency[1], onde a melhor escolha é o número natural mais próximo de e, isto é, 3. Isso também se relaciona com busca em árvores, e uma ternary tree pode ser melhor que uma binary tree
[1] https://en.wikipedia.org/wiki/Optimal_radix_choice
Aquela ideia talvez fosse impraticável nas CPUs da época, mas pode ser mais realista nas CPUs atuais