FizzBuzz a 55 GiB/s (2021)
(codegolf.stackexchange.com)- High throughput Fizz Buzz, do Code Golf Stack Exchange, é um experimento de desempenho que compete para ver quão rápido é possível gerar a saída do FizzBuzz e enviá-la por um pipe, pontuando pela taxa de transferência de
<program> | pv > /dev/null - A saída válida deve ser ASCII simples, usar quebras de linha
\n, ter Fizz/Buzz/FizzBuzz corretos, continuar até pelo menos2^58ou ser praticamente infinita, e o truque de bytes nulos detectado apenas pelopvé proibido - As principais implementações abandonam loops com
% 3,% 5eprintf, reduzindo gargalos com desenrolamento do padrão de 15 linhas, processamento de números de largura fixa, buffers grandes, paralelização e saída zero-copy - A resposta em assembly x86-64+AVX2 destacada pressupõe Linux e AVX2 e combina
vmsplice, cache L2, huge pages e um interpretador de bytecode de FizzBuzz para registrar cerca de 61 GiB/s - Os resultados variam muito conforme CPU, pipe buffer, versão do
pv, CPU affinity e configurações de mitigation; na máquina do autor da pergunta, a maior pontuação ficou com a implementação em C++ de David Frank, cerca de 1,7 Tb/s
Limites de throughput levados ao extremo com FizzBuzz
- O ponto principal não é o problema FizzBuzz, mas descobrir o que vira gargalo primeiro em uma geração de texto muito simples: cálculo de CPU, cópia de memória, I/O por pipe ou fronteira com o kernel
- A implementação ingênua em C usada como referência utiliza
% 3,% 5eprintf, e entrega cerca de 170 MiB/s em uma máquina comum - O autor da pergunta já tinha visto implementações acima de 3 GiB/s na mesma máquina e queria que a comunidade explorasse limites de throughput ainda maiores
- A pontuação foi medida no desktop do autor da pergunta
- AMD 5950x, 16C/32T
- 64 GB de RAM a 3200 MHz
- CPU mitigations desativadas
- A tabela por linguagem inclui asm 60,8 GiB/s, C 20,9 GiB/s, Julia 15,5 GiB/s, Go 6,8 GiB/s, Java 5,8 GiB/s, Rust 3,4 GiB/s, Ruby 1,7 GiB/s, Python 0,5 GiB/s, entre outras
- A resposta em assembly x86-64+AVX2 de ais523 mira desempenho máximo em thread única e registrou cerca de 31 GiB/s na máquina do autor, e cerca de 61 GiB/s no agregado do autor da pergunta
- A implementação em C++ de David Frank registrou, segundo o corpo da pergunta, a maior pontuação atual, cerca de 1,7 Terabit/s; outra implementação separada em C++20 afirma ter alcançado 283 GB/s em um AMD Ryzen 9 7700X
Regras de saída e condições do benchmark
- As condições para uma saída válida são exigentes
- A saída deve ser exatamente FizzBuzz
- Deve ter 1 byte por caractere ASCII
- Apenas
\né permitido como quebra de linha \r\nnão é permitido- A saída deve continuar até um número muito grande, pelo menos
2^58
- O próprio método de benchmark também afeta o throughput
- Discute-se que
pve o Linux pipe buffer usam, por padrão, buffers de 64 K - Dependendo da alocação em CPU sibling cores, o caminho pelo cache L2 entre o programa gerador e o programa consumidor pode mudar
- É possível forçar a alocação de CPU com
tasksetpara fazer comparações
- Discute-se que
Como a implementação em assembly otimiza
- O ponto central da implementação em assembly é reduzir o custo de copiar a saída, mais do que o custo de cálculo
- Usar
writeaumenta o custo de copiar do espaço de usuário para o espaço do kernel - O autor escreve que trocar para uma versão baseada em
writereduz o desempenho para um quinto - Com
vmsplice, o pipe passa a referenciar o buffer do programa, reduzindo cópias
- Usar
- O cálculo do FizzBuzz é dividido em 3 etapas
- A primeira etapa codifica a string inicial diretamente
- A segunda etapa processa números de 2 a 5 dígitos com rotinas AVX2 relativamente diretas
- A terceira etapa processa a faixa de 6 a 18 dígitos com um interpretador de bytecode de FizzBuzz
- O loop principal da terceira etapa mira 64 bytes de saída a cada 4 ciclos de clock
- 1 byte de bytecode gera 1 byte de saída
- Ele carrega 32 bytes de bytecode e produz 32 bytes de saída com
vpshufb,vpsubbe outras instruções - Os números das linhas são tratados com aproximações e correções no bytecode, evitando fazer a conversão numérica comum a cada linha
- Essa implementação é altamente dependente da plataforma
- Requer uma versão não muito antiga do Linux
- Requer um processador x86-64 com suporte a AVX2
- Emite erro na inicialização se a saída padrão não for um pipe
- O autor afirma que ela pode produzir saída incorreta em algumas configurações de pipe que envolvem
splice
Estratégias comuns adotadas por implementações em outras linguagens
- Implementações em C, Go, Java, Rust, Python, Julia, Ruby e C# repetem direções de otimização parecidas
- Desenrolam o padrão de 15 linhas do FizzBuzz
- Reduzem a quantidade de conversões para strings numéricas
- Acumulam a saída em buffers grandes
- Paralelizam o trabalho de geração com threads ou goroutines
- Usam barrier, channel, queue ou mutex para preservar a ordem da saída
- É difícil tratar esse resultado como uma simples comparação de “desempenho de linguagem”
- Versão do
pv, tamanho do pipe, disponibilidade devmsplice, CPU affinity, huge pages, otimizações do compilador e inlining dememcpyalteram muito o throughput - Algumas respostas têm comentários dizendo que são rápidas em determinadas máquinas, mas que os mesmos números não foram reproduzidos em outros ambientes
- Versão do
Escopo não coberto
- As notas de entrada indicam que alguns source chunks foram omitidos por limites de tamanho e custo, portanto o texto não cobre integralmente todas as 46 respostas nem todo o código e comentários
- O escopo omitido pode incluir algumas submissões por linguagem, corpos longos de código, logs detalhados de tuning e discussões nos comentários
1 comentários
Comentários do Hacker News
O mais impressionante é que o Linux consegue manter os dados enviados por pipe de um programa para outro apenas no cache L2, sem tocar a memória principal
É uma arquitetura de sistemas impressionante ver várias partes comuns do kernel Linux se encaixando para tornar esse caminho rápido possível
Fico curioso se algo assim também seria possível com as portas Mach do Mac OSX ou com os Named Pipes do Windows
Fazendo jus ao nome de usuário “ais523 - high effort answers”, ele deixou um comentário extremamente caprichado mesmo para quem não conseguiu executar o programa, e a conclusão foi esta
“Provavelmente o programa acabou sendo compilado com ASLR ativado. Nesse caso, o linker dinâmico não respeita o alinhamento de 4MiB do segmento BSS, o que na prática ignora meu
.align, e isso parece ser a causa do bug”Sempre rio ao ver este comentário cada vez que esse post reaparece
“@chx: Eu já tenho uma dissertação de mestrado. Isso foi mais difícil. – ais523 - high effort answers Oct 29, 2021 at 1:17”
Fiz implementações simples sem otimização em Rust, Python3 e C. Usei apenas if/else/while simples e saída padrão
Rust -> 23.2MiB/s
Python3 -> 28.6MiB/s
C -> 238MiB/s
Queria saber se alguém entende por que o desempenho do Rust fica numa faixa parecida com a do Python3. Eu esperava algo mais próximo de C
printdo Rust pega um lock por padrão. Isso é por segurança; C não faz isso. Mais detalhes na documentação do Rust: https://doc.rust-lang.org/std/macro.print.htmlPara obter desempenho parecido com C, provavelmente será preciso lidar com esse lock manualmente
let mut lock = stdout().lock();write!(lock, "hello world").unwrap();E também ajustar o tamanho do buffer da saída padrão para combinar com o de C
https://ismailmaj.github.io/tinkering-with-fizz-buzz-and-con...
Em um programa em C que conta números com 1ms de latência, a segunda coluna é o tempo decorrido desde o
read()anterior$ ./out | rtss4.7ms 4.7ms | 14.7ms | 24.7ms | 34.7ms | 44.8ms exit status: 0Dá para ver que tudo foi gravado de uma vez. Se você alocar um terminal, sai linha por linha
$ rtss --pty ./out0.8ms 0.8ms | 11.9ms 1.1ms | 23.0ms 1.1ms | 34.1ms 1.1ms | 44.3ms exit status: 0O Rust não tem esse comportamento adaptativo na saída, então sempre se comporta como no segundo resultado, independentemente de ser terminal ou não
Tecnicamente, ele sempre envolve a saída padrão em um LineWriter(https://doc.rust-lang.org/std/io/struct.LineWriter.html) e faz flush sempre que encontra uma escrita com quebra de linha. Para maximizar a taxa de transferência, é preciso agrupar várias linhas escrevendo algo como um BufWriter
--release, e C com-O3Fico curioso sobre o quão rápido seria se tudo tivesse sido escrito em assembly
No desenvolvimento de áudio, é muito comum escrever código DSP em assembly
É preciso combinar pesquisa extrema de algoritmos, conhecimento profundo de chamadas de sistema do Linux e otimizações específicas de cada plataforma para que isso seja possível. Nas palavras do próprio autor, Alex Smith:
“@chx: eu já tenho uma dissertação de mestrado. Isso foi mais difícil.”
Está em um nível completamente diferente de algo que se resolve só com “é só fazer em assembly”
“Certo. Eu estudava na University of Washington e fui trabalhar numa empresa chamada Geoworks, onde passei 5 anos programando em assembly. Nós, da Geoworks, escrevemos o sistema operacional inteiro, bibliotecas, drivers, apps, enfim, o desktop OS inteiro em assembly. Era assembly de 8086! E nem era um assembly bom! Só tinha quatro registradores! Se contar o 386, tinha também o registrador si. Era horrível.
Na verdade, nós gostávamos bastante. Era assembly orientado a objetos. É impressionante como a pessoa consegue se convencer a gostar de alguma coisa, e essa é a verdadeira ironia de tudo isso. Para nós, C++ era o ápice da decadência romana. Era como vomitar para conseguir comer mais. Eles tinham IF! Nós tínhamos jump CX zero! Eles tinham ‘objetos’. Nós também tínhamos, mas eles tinham sintaxe para isso. Parecia uma fraqueza inacreditável. E, naquela época, sabíamos que conseguíamos produzir código mais rápido do que qualquer compilador, e de fato conseguíamos!
Então o que aconteceu? A empresa faliu. Por quê? Provavelmente vou discordar de todo mundo da Geoworks nisso. Na verdade, sei que talvez eu seja o único que acredita nisso. Mas foi porque escrevemos 15 milhões de linhas de assembly 8086. Tínhamos ferramentas realmente boas, de classe mundial, pode acreditar. É absolutamente necessário ter isso. Mas chega um momento em que…
O problema é o seguinte: imagine uma formiga tentando atravessar o chão do porta-malas em linha reta. Ela não consegue ir em linha reta. Nós sabemos disso porque temos uma visão mais ampla. Dá para ver a formiga andando por aí, fazendo otimizações locais em torno daquela pedrinha, e agora indo para o outro lado.
Era exatamente assim quando escrevíamos um sistema enorme em assembly. No fim, a Microsoft lançou uma plataforma para dispositivos móveis muito mais rápida que a nossa. Entrei com o depurador e pensei: ‘O que é isso? Por que está acontecendo? A renderização está muito lenta, está engasgando’. Fui ver e, toda vez que a tela era atualizada, alguma barra de título era renderizada 140 vezes. E não era só a barra de título. Tudo estava sendo chamado várias vezes.
Porque já não conseguíamos mais enxergar como o sistema funcionava!
Sistemas pequenos não são apenas mais fáceis de otimizar; eles podem ser otimizados. Quero dizer que a otimização global é possível.”
http://steve-yegge.blogspot.com/2008/05/dynamic-languages-st...
Algo como 99% dos sites e softwares de hoje provavelmente conseguiria pelo menos 50% de ganho de velocidade com mudanças básicas, só prestando um pouco de atenção ao desempenho da aplicação. Coisas como cache correto, otimização de assets, substituir bibliotecas terceiras inchadas por chamadas nativas básicas que façam a mesma coisa, e configuração adequada de servidor e banco de dados
Também parece possível que, em alguns anos, a IA ofereça otimização com um clique no repositório, aplicando boas práticas ou reescrevendo o código original em assembly rápido
Restrições de recursos podem trazer clareza de foco
Embora este experimento seja divertido e instrutivo, ele parece ter uma pequena falha. Em vez de avaliar quão rápido um problema complexo é resolvido, ele testa principalmente uma questão periférica: a eficiência de tirar memória de um processo e passá-la para outro
Então parece que o segundo processo continua escrevendo no console ou em um arquivo, mas tecnicamente não é isso que acontece. Executar
pv >/dev/nullé, em essência, quase o mesmo que não fazer nada, e a chamada de sistemawriteretorna quase imediatamentevmspliceé algo equivalente a memória compartilhada, no sentido de permitir que outro processo acesse o buffer/memória de um processo. Como os requisitos iniciais da competição provavelmente eram ambíguos, não parece claro se isso seria aceitável pelas regras“Escreva um programa FizzBuzz. Execute-o. Direcione a saída com
| pv > /dev/null. Quanto maior o throughput, melhor.”“A saída do programa deve ser um fizzbuzz válido e correto. Não vale trapacear inserindo bytes nulos entre saídas válidas. Tipo bytes nulos que não aparecem no console, mas entram na medição de throughput do pv.”
E
vmsplice(2)de fato produz um fluxo de bytes no pipe da saída padrão, que opv(1)pode fazer splice para/dev/nullou ocat(1)pode copiar para o terminalEsta não foi a única submissão a usar
vmsplice(2). Outros participantes também descobriram que isso está longe de ser uma solução mágica. Mesmo depois de atravessar a barreira de E/S, ainda sobra muito trabalho para gerar as páginas de saída o mais rápido possívelA maior parte do código fica limitada por memória e E/S. Mesmo problemas complexos normalmente emperram na velocidade de mover dados de um lugar para outro; raramente emperram no cálculo em si. Mesmo passando o dia otimizando assembly de GPU, nos raros casos em que o gargalo é computação, depois de otimizar isso o gargalo vira memória
Por exemplo, existe uma representação de bits inteligente que faz com que o carry decimal aconteça naturalmente
Os requisitos iniciais da competição também não eram particularmente ambíguos nesse ponto. Eles dizem explicitamente para medir o throughput com
| pv > /dev/null, e também dizem isto:“Também são permitidas otimizações / assembly específicas de arquitetura. Isto não é uma competição de verdade. Eu só quero ver as pessoas levando o fizz buzz ao limite, mesmo que funcione apenas em um ambiente/plataforma especializado.”
Entender como lidar bem com E/S de memória e E/S de arquivo em termos de desempenho é uma habilidade relevante para todo programa e todo programador
Dizem para salvar como
fizzbuzz.S, mas fiquei curioso sobre a diferença entre as extensões .S e .sSegundo a página de manual:
file.scódigo assembler
file.Sfile.sxcódigo assembler que precisa de pré-processamento
.S) ou não (.s)Não sei se ainda há alguma diferença nas toolchains modernas
.Sé usado para arquivos de assembly escritos por humanos, normalmente rastreados no git, e.spara assembly gerado por máquina que pode ser sobrescrito se necessário.S, mas se você pedir para gerar assembly (por exemplo,gcc -S xyz.c), ele sobrescreve.sPost anterior:
https://news.ycombinator.com/item?id=29031488
https://news.ycombinator.com/item?id=29413656
No começo eu li 55 GiB/s FritzBox. FritzBox é um roteador popular na Europa germanófona
Meu ISP também tuitou na semana passada sobre uma caixa OPNSense[1] com suporte a 60 GiB/s que estará disponível em breve
[1] https://twitter.com/init7/status/1674920410889043973
Também deixo um adaptador USB WiFi conectado, para que certas VLANs entrem em failover se o cabo cair, usando o celular para manter a conectividade crítica do trabalho
Não é barato, mas se você quer hardware de verdade enquanto apoia financeiramente o projeto OPNSense, é difícil contestar os equipamentos da Deciso. Eles são eficientes em consumo, usam componentes focados em durabilidade e simplesmente funcionam bem
É bom ver a linha comercial ficando mais poderosa