{n} vezes mais rápido que C
(owen.cafe)- Mesmo em um loop pequeno em C, a saída do compilador nem sempre é a melhor possível; ao ajustar manualmente o assembly x86_64, a versão sem desvios condicionais ficou 6,73 vezes mais rápida que a saída do clang
- A função alvo trata
's'como +1,'p'como -1 e'\0'como término ao percorrer uma string, mas a saída do clang 16 divide esse fluxo em 3 desvios condicionais - Após trocar a ordem dos desvios, reorganizar os blocos básicos e substituir saltos por aritmética, o tempo de execução caiu de 3,23 s para 2,87 s, igualando a velocidade do GCC 12 nessa etapa
- A versão mais rápida usa
cmovepara escolher entre 0, 1 e -1 como valor a somar para cada caractere e então sempre executaadd, registrando 0,48 s e vazão de 1,94 GiB/s - O benchmark foi feito em um AMD Ryzen 5 5625U com Linux 6.1.33, processando 1 milhão de caracteres aleatórios
'p'/'s'1000 vezes e usando o melhor resultado entre várias execuções
Função testada e saída do compilador
- A função alvo incrementa um ponteiro de string caractere por caractere e atualiza o inteiro
resconforme o caractere's':res += 1'p':res -= 1'\0': retornares- qualquer outro caractere: sem mudança
- Como a função é pequena, a expectativa inicial era que gcc ou clang conseguiriam otimizá-la muito bem, talvez até de forma ótima
- O assembly inicial gerado pelo clang divide os quatro casos em três desvios condicionais (
je,je,jne)- começa com
res = 0 - lê um caractere e primeiro verifica se é
'\0' - depois compara com
'p'e's'
- começa com
- Resultado inicial do clang
- Tempo de execução: 3,23 s
- Vazão: 295,26 MiB/s
- O GCC gerou um pouco mais de código, mas foi levemente mais rápido
Verificar os caracteres comuns antes da condição rara de término
- O loop só termina ao encontrar o caractere nulo
'\0', e nesse contexto ele pode aparecer no máximo uma vez - A saída do clang verifica
'\0'primeiro, fazendo com que cada caractere'p'e's'passe antes pela condição de término - A primeira mudança manual foi inverter a ordem das comparações para verificar
'p'e's'primeiro - Resultado
- Tempo de execução: 3,10 s
- Ganho de velocidade: 1,04x
- Vazão: 307,64 MiB/s
Reorganização de blocos básicos e redução de saltos
- Como os dois casos comuns,
'p'e's', ambos saltam de volta para o início do loop, dá para reduzir desvios posicionando um dos blocos acima do loop - Colocando o bloco de
's'logo antes do loop, a execução cai de volta no loop após tratar's', sem salto extra - Em troca, no início da função é preciso um salto único para pular o bloco de
's'e entrar no loop- o salto no início da função acontece só uma vez
- como
's'pode aparecer muitas vezes, isso foi tratado como um trade-off aceitável
- Resultado
- Tempo de execução: 2,98 s
- Ganho total de velocidade: 1,08x
- Vazão: 320,02 MiB/s
Eliminar um salto incondicional com aritmética
- Para remover o
jmpincondicional que volta ao loop no blocop:, foi usada aritmética - Como uma redução de 1 pode ser obtida com
sub eax, 2seguido deinc eax, isso permite que, após tratar'p', o fluxo caia no bloco de's' - Com isso, mais uma instrução de desvio foi eliminada
- Resultado
- Tempo de execução: 2,87 s
- Ganho total de velocidade: 1,12x
- Vazão: 332,29 MiB/s
- Nesse ponto, o desempenho ficou igual ao do código gerado pelo GCC 12
- o código do GCC 12 também roda em 2,87 s
- a versão escrita manualmente tem 13 instruções
- a saída do GCC tem 19 instruções
- o código do GCC aparentemente desenrola o loop e reutiliza parcialmente os blocos de case
Substituir desvios condicionais por cmove
- Se os desvios condicionais são o gargalo, é possível eliminá-los sem depender do preditor de desvios
- A versão mais rápida usa
cmove, ou seja, movimentação condicional se igual - A regra de funcionamento é simples
- o valor padrão é 0
- se o caractere atual é
's', usa 1 - se o caractere atual é
'p', usa -1 - em cada iteração, o valor escolhido é sempre somado a
res
- Essa abordagem remove muitas setas do grafo de fluxo de controle
- Resultado
- Tempo de execução: 0,48 s
- Ganho total de velocidade: 6,73x
- Vazão: 1,94 GiB/s
- Nesse loop compacto em C com assembly escrito à mão, foi possível obter um ganho de mais de 6x com uma otimização que o compilador não automatizou
Tentativa de economizar registradores e outros experimentos que falharam
- Também foi testada uma versão com
setedo x86_64, que define condicionalmente um registrador de 1 byte como 0 ou 1 - Essa versão elimina o uso de
r8d, mas foi mais lenta que a versão baseada apenas emcmov - Resultado
- Tempo de execução: 0,51 s
- Ganho total de velocidade: 6,33x
- Vazão: 1,83 GiB/s
- Usar menos registradores ou operações de 8 bits no lugar de operações de 32 bits não trouxe mais velocidade
- Outras tentativas também pioraram o desempenho
- desenrolar o loop da melhor versão: ficou mais lento
- alinhar o início do loop a um limite de 16 bytes: ficou mais lento
- no GNU assembler, colocar
.align <bytes>antes de um rótulo pode inserirnop
Ambiente de benchmark e código
- A lista de códigos está no GitHub
- Ambiente de benchmark
- OS: Linux 6.1.33
- CPU: AMD Ryzen 5 5625U with Radeon Graphics
- família de CPU 25, 6 núcleos, 2 threads por núcleo, 1 soquete
- clang: 16.0.1
- gcc: 12.2.0
- A versão em C foi compilada com
-march=nativepara permitir a geração de código ajustado à CPU específica - O benchmark usa uma lista de 1 milhão de caracteres aleatórios
'p'e's'- cada versão da função processa essa lista 1000 vezes
- cada versão é executada várias vezes e o melhor resultado é escolhido
- O texto continua em um artigo seguinte, part two
1 comentários
Comentários do Hacker News
A conclusão correta parece ser menos assembly escrito à mão é 6 vezes mais rápido que C e mais saltos podem ser muito mais lentos que aritmética condicional
Mesmo em C, se você não usar
switche tratar com um ou doisif, dá para obter facilmente o mesmo efeito. Ao mudar a função em C para algo como incrementar se fors, decrementar se forpe encerrar se for\0, ficou 5,5 vezes mais rápida, caindo de 3,58 segundos para 0,65 segundo no exemplo de execuçãoComo outros disseram, também dá para vetorizar o algoritmo depois de ajustar a entrada. Encarei isso como um exercício educacional e sinceramente espero que ninguém desça para assembly sem um motivo realmente forte
O Linus já escreveu longamente no passado que
cmovnão é útil em desvios previsíveis: https://yarchive.net/comp/linux/cmov.htmlgcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0tantolonequantoltwoficaram em cerca de 3,58 segundosswitchpor váriosifé sempre mais rápido. Também queria saber a partir de quantos casos oswitchpassa a ser melhor e, se isso for consistente, parece algo que deveria entrar em otimização de compiladorAcho que o código original não foi escrito de forma muito amigável ao compilador. Se escrever como
result += *s == 's'; result -= *s == 'p';, o compilador gera o código sem desvios comsete/cmovapropriado, e a velocidade fica quase igual à do assembly otimizado do textoAinda assim, ele não faz unrolling do loop nem vetorização. Se você passar também o tamanho da string e iterar sabendo o
size, o compilador conhece o tamanho do loop, faz unrolling e, se possível, usa instruções AVX-512. Em entradas grandes fica muito mais rápido, mas estou com preguiça de benchmarkar isso manualmente. Se você é um programador C que não acompanha o comprimento das strings, faça como quiser, mas na minha opinião realmente não deveria: https://godbolt.org/z/rde51zMd8Essa versão alcança 3,88GiB/s. De propósito não fui até a vetorização; queria manter o escopo do problema pequeno e mostrar as dicas e truques de assembly do texto. Ainda dá para escrever depois um artigo sobre fazer padding na string de entrada e vetorizar o algoritmo
/* DON’T REFACTOR THIS FOR READABILITY IT WILL SLOW DOWN */{.overflowChecks:off.}e percorrerinput, incrementando quando's' == ce decrementando quando'p' == cNo Apple M1 houve cerca de 5 vezes de ganho de desempenho, e com a checagem de overflow ligada ficou só em algo como 2 vezes sobre a versão C básica. É sempre bom conhecer padrões que induzem otimizações SIMD
Falando como alguém mais próximo de especialista em otimização, eu resolveria esse problema de forma totalmente diferente. No meu computador, a versão C inicial fazia 389MB/s, e se o assembly do texto entregar a mesma melhoria de 6,2 vezes, isso dá algo em torno de 2,4GB/s
Em buffers longos, esta versão em C++ passa de 24GB/s no meu computador: https://gist.github.com/Const-me/3ade77faad47f0fbb0538965ae7...
Sem assembly, usando intrinsics de AVX2, ela fica 61 vezes mais rápida que a versão original
ymm, parece que dá para vetorizar o prólogo usandomovemaskepopcntAinda não testei o código, então precisaria de benchmark, mas parece possível seguir por esse caminho criando máscaras para
s,pe\0, e usandotzcntebzhipara contar os bits até o fim da stringstd::experimental::simd: https://en.cppreference.com/w/cpp/experimental/simdEste código parece combinar muito bem com SIMD. Se der para mudar o protótipo para receber um comprimento explícito, fica fácil ler e processar 16 bytes por vez
Dá para somar e subtrair diretamente os resultados das comparações, e provavelmente já vale a pena só chamar
strlen()no começo da função para obter um comprimento explícitoFiz rapidamente uma implementação vetorizada em RISC-V. Ela usa
rvvpara ler a string, encontrar a posição do\0e então contar comvcpopquantossepexistemNum Mangopi MQ Pro (C906,
rv64gc+rvv 0.7.1, comprimento de vetor de 128 bits), oswitchficou em 0,19 Bytes/Cycle, a implementação em C com tabela ficou em 0,17 Bytes/Cycle, ervvficou em 1,57 Bytes/Cycle, caindo para 1,35 depois de cerca de 30KiB. Se alinhar o ponteiro à página e fizervlnão ultrapassar o tamanho da página, dá para chegar a 2/1,7 Bytes/Cyclervvtem esse recurso; caso contrário, pode falhar quando o byte nulo estiver logo antes do fim da memória alocadaIsso parece ser uma característica específica da arquitetura x86. Como o custo de não fazer desvio é muito baixo, o desvio acaba parecendo relativamente caro: https://wordsandbuttons.online/challenge_your_performance_in...
Mas em outros processadores isso pode não ser verdade: https://wordsandbuttons.online/using_logical_operators_for_l...
A pergunta maior é por que o C é necessário em geral. Se a ideia é ajustar tudo manualmente para rodar da melhor forma em um hardware específico, então C é a ferramenta errada, e o que você precisa é assembly e um bom sistema de macros. O objetivo original de C era facilitar a portabilidade de código de nível de sistema de uma plataforma para outra, e a perda de eficiência nesse processo era esperada. É parecido com escrever um poema em esperanto e depois traduzi-lo automaticamente para o idioma desejado, em vez de traduzir poesia em hindi para urdu. Você não vai conseguir dois grandes poemas, mas consegue rapidamente duas traduções ruins, e esse é o papel do C
Se você compilar com FDO/PGO, então reordenação de desvios e de blocos certamente pode acontecer. Sem FDO, o compilador não tem como saber com que frequência cada desvio será escolhido. Em alguns casos, FDO também pode ativar
cmovMas se
cmové mais eficaz do que umtest/jumpcomum depende muito de quão previsível é o desvio; em geral,cmovfunciona melhor quando o desvio é muito imprevisível. Secmovficou 6 vezes mais rápido, então imagino que a entrada de teste seja uma string aleatória composta quase toda desep. Não está errado, mas o texto parece um pouco enganoso porque explora no benchmark uma propriedade não mencionada dos dadosEscolhe aleatoriamente
's'ou'p', e nenhum caractere além de's','p'e o nulo terminador pode aparecer. Sabendo dessa característica da entrada, dá até para fazer uma otimização esperta demais comoresult += (1 | *s++) - 'r';. É um código inteligente até demais, mas ilustra perfeitamente o ponto de explorar propriedades dos dados'\0'só pode aparecer no máximo uma vez, porque a função retorna ao encontrá-lo, enquanto os outros caracteres podem aparecer várias vezes. Isso parece ser informação à qual o compilador poderia ter acesso mesmo sem PGOClaro que PGO ajuda, e no meu computador deu 2,80 s, melhor que o código no fim da seção
Rearranging blocks. A entrada está descrita emBenchmarking setupe também está no repositório: https://github.com/414owen/blog-code/blob/master/01-six-time...Na parte 2, linkada no fim do texto, eles deixam o código C o mais rápido possível e vencem todo o assembly deste artigo. Nunca disse que usar assembly é necessariamente uma boa ideia; só acho que otimização e interpretação da saída do compilador são um desafio interessante e uma boa oportunidade de aprendizado
Acho que fiz uma versão mais rápida do que a do artigo e a do texto seguinte. Mas isso tem o custo de ser especializada para o caso em que a string é composta apenas de
's'e'p'Como o benchmark também testa apenas strings formadas por
's'e'p', acho justo. O ponto central é que queremos aumentarresem 1 quando o próximo caractere fors, masres += c - 'r'falha porque vale 1 paras, mas -2 parap. Só que, se você tratar'p' - 'r'como inteiro sem sinal, acontece underflow e a carry flag é ativada, e oadcno x64 soma dois registradores junto com a carry flag. Assim, dá para trocar doiscmp, cmovpor um únicosub, adc. Esta versão foi 1,08 vez mais rápida que a versão em C do texto seguinte e 1,66 vez mais rápida que a x64-7 original. Claro, ainda daria para melhorar mais com SWAR/SIMD02-the-same-speed-as-c/loop-5.x64.sera apenas a versão mais rápida que eu tinhaNo meu computador,
loop-5.x64.sfaz 0,244 s, e a implementação acima faz 0,422 s. Não sei exatamente por que essa diferença aparece; olhando, a implementação acima parece mais rápida. Por isso é importante sempre medir no hardware real em que o código vai rodar'p' * len, depois dividir por('s' - 'p')para obter a contagem des. A contagem depserialen - s_countA soma inicial também é facilmente vetorizável. Se eu não estiver cometendo nenhum erro, isso deve funcionar; o único problema seria a possibilidade de overflow no acumulador. Não estou com vontade de fazer benchmark disso. Edit: deixei passar a parte em que ao ver
so valor diminui, então o resultado final ép_count - s_countstrlen()provavelmente já é implementado de forma bem rápida e, se o tamanho do buffer for conhecido, o compilador pode vetorizar automaticamente o loop internoDe fato, o código
len = strlen(buf)seguido de um loopforsomando(buf[i] == 's') - (buf[i] == 'p')é autovetorizado: https://gcc.godbolt.org/z/qYfadPYoqNo passado, escrevi um decodificador UTF-8 em Common Lisp para SBCL. Já existia um decodificador embutido, então era só por prática
Tirando as otimizações óbvias e fáceis, quase todo o ganho de desempenho veio de estruturar o código de forma que o compilador gerasse instruções
cmov*em vez de desviosDecodificadores UTF-8 normalmente rodam bastante em entradas compostas inteiramente por ASCII. Fiquei curioso sobre que tipo de entrada foi usada no benchmark