A busca binária branchless mais rápida
(mhdm.dev)sb_lower_boundmantém a mesma interface destd::lower_bounde mostrou ser até 2x mais rápida que a busca binária comum quando o ramo de comparação é compilado como movimentação condicional (cmov)- Como o resultado da comparação na busca binária não permite saber antecipadamente a posição procurada, há muitas falhas de predição de desvio; no x86, a opção
clang -mllvm -x86-cmov-converter=falseajuda a reduzir isso - Esta implementação reduz
lengthpela metade a cada loop e atualiza apenasfirstconforme o resultado da comparação, diminuindo o número de instruções, e sempre fazk+1comparações no intervalo2^k <= n < 2^(k+1) - No benchmark com
clang -cmov, o tempo médio de execução foi de 61.30ns parastd::lower_bound, 33.24ns parasb_lower_bounde 32.73ns parabb_lower_bound; a média geométrica também mostrou grande diferença, com 39.17ns, 19.81ns e 21.33ns, respectivamente - Em buscas com strings de 8 bytes, nas quais a função de comparação é lenta,
std::lower_boundàs vezes ficou ligeiramente à frente; em arrays grandes, uma variante com prefetching foi em média cerca de 2.3x mais rápida questd::lower_bound
Estrutura básica de sb_lower_bound
sb_lower_boundé uma função C++ com a mesma forma destd::lower_bound- As entradas são
first,last,value,comp - O valor retornado é o iterador para a primeira posição em que a comparação falha; se todos os elementos satisfizerem a condição, retorna
last
- As entradas são
- O loop principal reduz
lengthpela metade e movefirstpara frente apenas quandocomp(first[length], value)é verdadeiro - Aqui, “branchless” não significa que o
ifdesaparece, mas sim que esseifé compilado como uma instrução de movimentação condicional, comocmov, em vez de um salto condicional - No
clang, usando a opção-mllvm -x86-cmov-converter=false, essa forma pode ser compilada como movimentação condicional
Onde std::lower_bound fica mais lento
- A busca binária tradicional compara o elemento do meio com
valuee depois escolhe o intervalo da esquerda ou da direita - Quando a posição do alvo é desconhecida,
if (comp(first[half], value))tende a se tornar um desvio difícil de prever - A CPU executa antecipadamente as próximas instruções com predição de desvio, mas, se errar a previsão, precisa descartar o trabalho já feito
- Com movimentação condicional, é possível escolher valores com base no resultado da comparação e reduzir saltos condicionais
clang -cmovconseguiu transformar parte dosif/elsedestd::lower_boundem movimentação condicional, deixando-o cerca de 25% mais rápido- O
gccnão tem uma boa opção para forçar movimentação condicional no mesmo cenário, esb_lower_boundtambém não gera hoje código branchless independentemente do nível de otimização
Busca “ótima” do ponto de vista do número de comparações
- Aqui, “ótima” significa uma busca binária com o menor número de comparações possível
- Em uma lista de tamanho
n, os resultados possíveis destd::lower_boundsãon+1:nposições de elementos mais a posição final - Se o tamanho da lista é
2^k - 1, então há2^kresultados possíveis; como cada comparação fornece 1 bit de informação verdadeiro/falso, o número ótimo de comparações ék - No caso “bonito”, em que o comprimento é
2^k - 1, é possível fazer uma busca ótima com um loop muito curto - Se o comprimento não encaixa nesse formato, pode ocorrer acesso fora dos limites, como no caso de
[0, 1, 2, 3, 4, 5]quandovalueé 4
Características de desempenho e limitações de sb_lower_bound
- Ao dividir um intervalo de tamanho par,
sb_lower_boundnem sempre pula elementos suficientes, mesmo quando o resultado da comparação é verdadeiro - No intervalo
2^k <= n < 2^(k+1), ele sempre fazk+1comparações - No mesmo intervalo,
std::lower_boundfazkouk+1comparações e, em média, cerca delog2(n+1)comparações - O número de comparações pode ser maior, mas a quantidade de instruções dentro do loop é bem menor, então o tempo total de execução acaba sendo mais rápido
- Quando a função de comparação é muito lenta, a diferença entre
k+1elog2(n+1)comparações pode impactar o desempenho - Para forçar movimentação condicional no
gcc, há a opção de usarcmovcom assembly inline específico para x86, mas a abordagem simples aumenta o número de instruções e a alternativa exige assembly separado por tipo
A variante mais rápida bb_lower_bound
bb_lower_bounddivide o intervalo de outra forma até que o comprimento vire2^k - 1, e então faz a busca em um segundo loop mais rápidolength & (length + 1)é usado para verificar se o comprimento tem a forma11..1, isto é,2^k - 1- Em comprimentos irregulares, usa-se o valor MAGIC
auto step = length / 8 * 6 + 1para aproximar rapidamente o intervalo de uma faixa “bonita” - Em geral,
stepprecisa ser pelo menoslength / 2para entrar com frequência no loop rápido, mas, se ficar próximo demais delength, perde-se a vantagem da busca binária - Por causa do
break,bb_lower_boundpassa a ter um formato com ramificação - Um método que usaria uma tabela pré-calculada com o
stepmais rápido para todos os comprimentos ainda não foi explorado
A implementação totalmente branchless não foi mais rápida
- Em uma máquina de 64 bits, o loop de
sb_lower_boundroda no máximo 64 vezes, então é possível criar uma versão “totalmente branchless” que elimina até a checagem delengthusandoswitche fall-through intencional - A ideia é saltar para a posição de código correspondente ao número de comparações necessário usando
std::bit_width(length) - Na prática, o desempenho não foi melhor
- CPUs x86 modernas lidam muito bem com desvios previsíveis, como a condição de loop, então não houve ganho em remover a checagem de
length - Também se concluiu que o loop comum é melhor por evitar templates, macros e a necessidade de copiar e ajustar 64 casos
Resultados de benchmark
- No tempo médio de execução (ns), os resultados com
clang -cmovforam os seguintesstd::lower_: 61.30branchless_lower_: 43.43asm_lower_: 54.32sb_lower_: 33.24sbm_lower_: 35.54bb_lower_: 32.73
- A média geométrica do tempo de execução (ns) também foi a menor para
sb_lower_std::lower_: 39.17branchless_lower_: 25.14asm_lower_: 31.21sb_lower_: 19.81sbm_lower_: 20.91bb_lower_: 21.33
sbm_lower_boundé uma variante que usa a formaifem vez defirst += comp(first[length], value) * (length + rem)para induzir ogcca gerar movimentação condicional- Essa otimização pode desaparecer em uma próxima versão do
gcc, então requer comentários e cuidado - Os comandos de benchmark usaram
g++-10,clang++-10,clang++-10 -mllvm -x86-cmov-converter=falsee também-march=haswell -march=nativeou não especificar-marchnão afetou muito o ranking, e os testes foram executados em um Intel i7 Kaby Lake
Medição de falhas de predição de desvio
- Uma execução comum com
clang, medida comperf, registrou cerca de 6,94 bilhões debranchese cerca de 1,20 bilhão debranch-misses, com taxa de 17,34% - A execução com
clang -cmovregistrou cerca de 4,07 bilhões debranchese cerca de 35,95 milhões debranch-misses, reduzindo a taxa para 0,88% -cmoveliminou cerca de 2,9 bilhões de desvios e cerca de 1,2 bilhão de falhas de desvio- Os desvios removidos eram daqueles que falhavam na predição com probabilidade de cerca de 41%
- Isso fica próximo dos 50% esperados para um desvio totalmente imprevisível
Com função de comparação lenta, o resultado muda
- Para observar um cenário com comparação mais lenta, foi testada a busca em strings de 8 bytes
- No tempo médio de execução (ns),
std::lower_boundfoi ligeiramente mais rápido ou muito próximo desb_lower_boundgcc:std::lower_160.01,sb_lower_165.66clang:std::lower_157.71,sb_lower_162.68,bb_lower_157.22clang -cmov:std::lower_156.06,sb_lower_164.71,bb_lower_157.48
- Nesse caso,
std::lower_boundfoi de forma consistente um pouco mais rápido quesb_lower_bound - Uma biblioteca pode buscar o melhor desempenho usando
sb_lower_boundpara tipos primitivos estd::lower_boundnos demais casos
Diferenças visíveis no assembly
- O hot loop de
std::lower_boundcomclang -cmovinclui movimentações condicionais comocmovaecmovbe, mas usa várias instruções para atualizar comprimento e posição - O hot loop de
sb_lower_boundcalcula metade do comprimento, o resto e o ponteiro a mover, e então atualizafirstcomcmova - O assembly de
branchless_lower_boundé muito curto e limpo, mas, nos testes de desempenho,sb_lower_boundentregou resultado melhor com menor overhead
Atualização: sb_lower_bound ficou ainda mais curto
- Após um comentário do autor de orlp.net,
sb_lower_boundpôde ser refatorado para reduzir as instruções do hot loop em assembly de 9 para 8 - O ponto central é que
length - halfé igual ahalf + length % 2 - A forma refatorada calcula
half = length / 2, fazfirst += length - halfquando a comparação é verdadeira, e então atualizalength = half - Com
clang -cmov, o tempo médio de execução melhorou levemente, de cerca de 33ns para cerca de 32ns
Em arrays grandes, prefetching funciona bem
- O prefetching, sugerido nos comentários, é uma técnica para trazer antecipadamente para o cache L1/L2 os dados que serão necessários, reduzindo a latência no acesso real
- Exemplos de latência: L1 cerca de 4 ciclos, L2 cerca de 12 ciclos, L3 cerca de 40 ciclos e memória cerca de 200 ciclos
- Tanto
gccquantoclangsuportam__builtin_prefetch() - Fazer prefetch da posição
length / 4desperdiça 1 em cada 2 acessos; adicionar tambémlength / 8faz com que 5 em cada 6 sejam desperdiçados - O cálculo das posições de prefetch e a própria chamada também têm overhead, e esse custo importa em um hot loop encurtado
- Várias estratégias de prefetch não ajudaram em arrays menores que 256KB
- Acima de 256KB, a versão
sbp_lower_boundcom prefetch melhorou o tempo médio de cerca de 32ns para cerca de 26ns em testes de até aproximadamente 4 milhões de entradas, ou 16MB - Em testes depois ampliados até cerca de 128 milhões de entradas, ou 512MB, a versão com prefetch ficou cerca de 2.3x mais rápida que
std::lower_boundpelo tempo médio- A comparação foi de cerca de 161ns para
std::lower_boundcontra cerca de 71ns para a versão com prefetch
- A comparação foi de cerca de 161ns para
Observações em conjuntos de dados grandes e alternativas
- Em tamanhos muito grandes, o
std::lower_boundbranchless gerado porclang -cmovficou mais lento que a versão com ramificações - CPUs modernas podem seguir desvios previstos enquanto fazem carregamentos de memória e execução especulativa, o que pode funcionar na prática como uma forma de prefetch
sbpm_lower_boundé uma versão que adiciona prefetch asbm_lower_bounde usa multiplicação booleana para induzir ogcca gerar código branchless- Há saltos no gráfico de desempenho entre 1 milhão e 10 milhões de elementos, sugerindo que ainda existe espaço para uma implementação teoricamente mais rápida
- Porém, o código com prefetch vai ficando cada vez mais complexo e cheio de constantes mágicas, e a chance de isso virar contribuição para
gcc/libstdc++oullvm/libc++diminui conforme a complexidade cresce - Uma alternativa que quebra as restrições de
std::lower_boundé a Eytzinger Binary Search, que reorganiza o array de entrada em forma de heap binário por medianas para melhorar a localidade de cache - No teste de árvore 16-ária com inteiros apresentado por Sergey Slotin na CppCon 2022, os resultados foram de 7x a 15x mais rápidos que
std::lower_bound
Código e condições de uso
- Se a busca ou a comparação for a parte mais lenta do programa e o processador tiver dificuldade para prever o resultado das comparações, vale tentar no x86 a opção
clang-mllvm -x86-cmov-converter=false - Se precisar de uma busca binária mais rápida, você pode testar
sb_lower_bound; nogcc,sbm_lower_boundtambém é uma opção - O código é distribuído sob licença MIT
- O código e os benchmarks estão disponíveis em github.com/mh-dm/sb_lower_bound/
1 comentários
Opiniões no Hacker News
Sempre que vejo pessoas tentando eliminar desvios, fico me perguntando se elas sabem que a estrutura em que uma falha de previsão de desvio faz uma pipeline longa parar não é um elemento essencial da arquitetura de CPU
A razão de a pipeline ser longa é que muita análise e transformação é feita logo antes da execução, mas, como não se trata de um algoritmo com grande dependência de estado, a maior parte disso poderia ser feita de antemão
O CPU Transmeta Crusoe funcionava desse jeito, e dá para imaginar um mundo em que não fosse preciso se preocupar com desvios
Olhando mais a fundo, toda operação é um desvio que olha para o estado dos bits e altera o resultado, mas esses desvios locais dentro da ALU não ficam na pipeline principal, então não prejudicam muito o desempenho
Lembro também de ter dito ao srk, na época, que escolher entre IPC e throughput como métrica influencia o que se considera bom ou ruim
O lado do IPC vê que, se você criar um IPC mais alto, o processo de fabricação aumentará o clock e todo mundo ganha; já o lado do throughput adota uma abordagem mais realista, vendo que a Lei de Moore morreu, que fazer o silício rodar mais rápido o derrete, e que quem vence é quem projeta a ISA de forma inteligente
Nos últimos 20 anos, ambos os lados tiveram sucessos e frustrações, e é interessante que hoje o RISC-V esteja voltando a essas questões em arquitetura de CPU
Também é um bom lugar para acompanhar como ideias superscalares modernas são adicionadas com base na flexibilidade do conjunto de instruções, e, no longo prazo, acho que esse lado vai vencer
A tradução da Transmeta não eliminava o custo dos desvios
Fiquei com a lembrança de Linus, que trabalhava na Transmeta, dizendo em uma thread da comp.arch algo como “o trabalho da CPU é produzir cache misses o mais rápido possível”
Cache misses obrigatórios existem, e nenhum JIT consegue eliminá-los
No mundo real, mesmo com caches enormes como os de hoje, também não dá para evitar misses de capacidade
O Itanium também acreditava que poderia eliminar o custo dos desvios com análise estática; basta lembrar qual foi o resultado
Eu gostaria que programadores lessem alguns livros de arquitetura de computadores antes de concluir com tanta confiança que conseguem facilmente criar algo melhor que processadores modernos
Acho que estão subestimando a escala do esforço intelectual colocado nos processadores atuais em pelo menos 7 dígitos
Um deles são os dados de entrada que serão processados
A busca binária é exatamente esse caso: o compilador não sabe em qual posição o resultado será encontrado
Outro é a microarquitetura, especialmente a hierarquia de cache e a configuração das unidades de execução
Se a ISA for trocada por uma com instruções semelhantes às micro-operações das CPUs atuais, será preciso recompilar para cada microarquitetura
Ainda assim, isso é tecnicamente solucionável com um JIT no sistema operacional, como nas GPUs atuais, em que programas são distribuídos em formato de bytecode (DXBC, SPIR-V, NVPTX) e o driver de GPU em modo de usuário recompila para as instruções reais do hardware
A variável maior é que outros threads da CPU executam código que não se pode conhecer
Mesmo eliminando o hyper-threading para tornar os núcleos independentes, ainda restam recursos compartilhados por todo o chip, como cache L3, memória externa, largura de banda de I/O, energia e dissipação de calor
Se você redefinir tudo como Branch™, então algumas Branch™, inclusive coisas que não são desvios de verdade, podem ser pré-computadas
Mas a eliminação de desvios, no sentido comum, não trata dos casos em que o caminho de computação realmente se divide em código como if/else?
Mesmo nesse mundo, otimizações úteis seriam possíveis, mas ficariam limitadas a Branch™ que tentam calcular simultaneamente os resultados de vários futuros
Sempre que existe uma operação que pode ser executada de forma independente, surge a possibilidade de executá-la ao mesmo tempo
Não estou falando só de decodificar, buscar e executar
Se há uma ALU e um shifter independentes, dá para fazer um shift enquanto se faz uma soma; se há um somador e um multiplicador dedicados, não há motivo para não tentar os dois ao mesmo tempo
Isso logo leva à vontade de manter várias instruções em andamento ao mesmo tempo, o que significa que é preciso conseguir buscar e decodificar instruções mais rápido do que a velocidade de processamento
Também leva naturalmente à situação de querer reordenar as coisas para que N instruções Add não impeçam a visualização de um Shift independente
Você pode achar que a estrutura atual é mais complexa do que o necessário, e pode não estar errado
Ainda assim, há uma engenharia enorme envolvida na criação da estrutura atual; então, se você acha que seria possível torná-la muito mais rápida de outro modo, vale a pena investigar a fundo quão correta é essa afirmação
Na parte em que diz “quem dera houvesse uma linguagem bare-metal limpa e rápida para escrever tudo isso...”, o autor incluiu notas de rodapé “BUT RUST..” e “BUT ZIG..”, mas fico curioso sobre como seria com Nim
Parece haver uma implementação de
lowerBoundna biblioteca nativa: https://github.com/nim-lang/Nim/blob/version-2-0/lib/pure/al...Estritamente falando, não é uma linguagem “bare-metal”, mas como compila para C ou C++, seria interessante ver para que código ela compila aqui
E também fico curioso sobre qual é o problema com C
No TigerBeetle, eles usam uma implementação sem ramificações própria: https://github.com/tigerbeetle/tigerbeetle/blob/e996abcf7154...
Esse é exatamente o tipo de uso que justifica a necessidade de templates em C++
C não é limpo
Não sei bem se isso ainda é
lower_boundTalvez eu tenha lido o código errado, mas parece retornar qualquer item correspondente quando há duplicatas, não a primeira correspondência
Se a função de comparação estiver procurando um prefixo específico de string para autocompletar, então mesmo em uma lista única vários itens podem corresponder, e nesse caso você quer o primeiro item da lista
Fico curioso para saber por que você acha que não
Queria que todos os posts de blog começassem como este: “Vocês devem estar ocupados, então vou direto ao ponto. Aqui está a implementação de busca binária em C++ mais rápida, geral e simples”
A biblioteca padrão do Zig não chama C++ para fazer busca binária
A busca binária atual está aqui: https://github.com/ziglang/zig/blob/b835fd90cef1447904d3b009...
Não estou entendendo bem
O problema de busca binária e ramificações não é a ramificação em si, mas o fato de que, até terminar a comparação, você não sabe qual posição de memória buscar em seguida no array
Não importa se você usa uma ramificação ou alguma outra coisa; no fim, a questão é o que você quer que o processador faça
Há uma dependência de dados
Antes de ler o índice do meio, você não sabe se vai pesquisar a metade superior ou a inferior
Você pode especular e emitir leituras para os dois lados, e isso resolve a dependência, mas aumenta o tráfego de memória
O ponto central é saber se esse é o trade-off certo; simplesmente remover a ramificação não é a resposta
Isso é abordado no fim do artigo: https://mhdm.dev/posts/sb_lower_bound/#prefetching
Por isso, uma busca binária corretamente mais rápida usa layout de array Eytzinger: https://algorithmica.org/en/eytzinger
No meu processador Cascade Lake,
-mllvm -x86-cmov-converter=falsereduz o desempenho da busca binária para quase metadeOs números são nanossegundos por bsearch em um array de 100 MB de
uint32O clang 15.0.7 parece ser muito pior que o gcc 13.2.1 nesta otimização específica de código
O assembly pode ser visto aqui: https://godbolt.org/z/cbx5Kdjs6
O assembly do gcc parece bem mais limpo
100 MB é grande o suficiente para a versão com ramificações sair um pouco favorecida, não por ser melhor, mas por causa das características da execução especulativa do x86
Alguém sabe para onde o link “BUT RUST” deveria apontar originalmente?
Como ele não estava fixado em uma versão, parece que já quebrou, e talvez a intenção fosse apontar para o meio do comentário de documentação de
starts_withlet mid = left + size / 2;[1] https://web.archive.org/web/20230602210213/https://doc.rust-...
A intenção era linkar para a implementação de busca binária do Rust
Foi atualizado para https://doc.rust-lang.org/1.71.1/src/core/slice/mod.rs.html#...
É interessante que, com uma função de comparação
compmais complexa, o resultado não se mantémO artigo considerou um cenário de busca binária um tanto realista em que a função de comparação é lenta, como ao comparar ID, telefone, conta ou palavra-chave, e por isso testou a busca em strings de 8 bytes
Nesse caso,
std::lower_boundé só um pouco, mas consistentemente, mais rápido quesb_lower_bound; para obter sempre o melhor desempenho, a biblioteca poderia usarsb_lower_boundquando lida diretamente com tipos primitivos estd::lower_boundnos demais casosGostaria de ver a análise aqui
Com dados e entradas realmente aleatórios, a predição erraria cerca de metade das vezes
A abordagem com CMOV fica bloqueada depois da função de comparação por causa da dependência de dados
Em média, a abordagem com desvio executa duas comparações por vez, enquanto a com CMOV executa uma; portanto, quando o tempo de comparação fica maior que a penalidade de uma falha de predição de desvio, é de se esperar que surja um ponto de inversão
Algo que fiz de forma aproximada com SIMD algum tempo atrás é 3 vezes mais rápido que
std::lower_boundaté esbarrar na largura de banda de memória: https://github.com/matthewkolbe/ThinkingInSimd/tree/main/alg...Presumo que sejam puramente aleatórios, mas, se essas strings de 8 bytes não forem informação pura, um preditor de desvios moderno pode facilmente ter desempenho melhor que
cmovO atributo
unpredictableagora parece afetar o passe de conversão para cmovComo é de 1º de junho, provavelmente deve entrar no clang 17/18: https://reviews.llvm.org/D118118