Reconstruindo pacotes do Ubuntu para ficarem 90% mais rápidos
(gist.github.com/jwbee)- Ao recompilar o mesmo código-fonte do
jqe trocar o alocador, o tempo de processamento de um GeoJSON de 500 MB caiu de 4,606 s para 2,428 s, ficando 1,90x mais rápido que o binário do Ubuntu - O benchmark foi executado em um Ryzen 9 9950X usando o parcel map do Alameda County Assessor, extraindo
SitusCitycom a condiçãoTotalNetValue < 193000 - Só a recompilação simples já trouxe melhora de 2–4%, e a combinação de
clang-18,-O3,-fltoe-DNDEBUGentregou 1,20x o desempenho do pacote Ubuntu - O perfil mostrou alto custo de alocação de memória, então foram comparados
TCMalloc,jemallocemimalloc; nos testes comLD_PRELOAD, omimallocfoi o mais rápido - A build final com linkagem de
mimalloctambém marcou 0,755 s contra 1,424 s em outro caso com 2,2 GB de JSON, mostrando que a build padrão da distro pode variar bastante conforme a carga de trabalho
Carga de trabalho de referência e metodologia de medição
- O alvo do teste é a ferramenta de processamento de JSON
jq, e os dados de entrada são um arquivo GeoJSON de 500 MB com o parcel map do Alameda County Assessor - A consulta executada imprime
SitusCitydos itens da lista de parcels que atendem à condiçãoTotalNetValue < 193000.features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
- O
/usr/bin/jqpadrão do Ubuntu levou cerca de 5 segundos com o arquivo já em cache, e o benchmark detalhado foi repetido comhyperfine - Para reduzir variação durante a execução, o processo foi fixado no CPU lógico 2 com
taskset -c 2- Isso evita o impacto de interrupções do sistema no CPU 0 e de migração entre CPUs
Recompilação simples do mesmo código-fonte
- Foi baixado o
código-fonte do jqusado pelo Ubuntu e executados configure e build sem flags extras - Só essa recompilação simples já ficou cerca de 2–4% mais rápida que o pacote binário do Ubuntu
- Binário recompilado: média de 4,517 s
- Ubuntu
/usr/bin/jq: média de 4,641 s - Resultado final de cerca de 1,03x em desempenho
Aplicando clang e flags de otimização
- Na etapa seguinte, foram aplicados
clang-18, um nível maior de otimização, LTO e flags relacionadas a depuração e profiling - As flags principais que afetaram o desempenho foram
-O3,-fltoe-DNDEBUG-O3usa um nível de otimização mais agressivo que-O2-fltoativa otimização em tempo de linkedição-DNDEBUGreduz o custo de assertions, que aparecia com destaque no perfil
- Exemplo de configure usado:
CC=clang-18LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld"CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections"
- Essa build foi 1,20x mais rápida que o binário do Ubuntu
- Rebuild otimizada: média de 3,853 s
- Ubuntu
/usr/bin/jq: média de 4,631 s
Experimento trocando o alocador
jqé um programa complexo em C, e o perfil mostrou que alocação de memória era o maior custo- Primeiro, foi feita uma nova build com
TCMalloc, fornecido em pacote pelo Ubuntu- Em
LDFLAGS, foi adicionado-L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimal - Binário recompilado: média de 3,253 s
- Ubuntu
/usr/bin/jq: média de 4,611 s - Isso resultou em 1,42x mais velocidade que o binário do Ubuntu
- Em
- Mesmo trocando só o alocador no binário padrão do Ubuntu via
LD_PRELOAD, já houve alguma melhora- Padrão: média de 4,601 s
- TCMalloc via preload: média de 4,082 s
- 1,13x mais rápido que o padrão
Preload dinâmico e configuração de THP
- Foram comparados com
LD_PRELOADojemalloc, omimalloce oTCMallocfornecidos pelo Ubuntu - Os resultados abaixo foram obtidos com estas variáveis de ambiente definidas:
MIMALLOC_LARGE_OS_PAGES=1MALLOC_CONF="thp:always,metadata_thp:always"GLIBC_TUNABLES=glibc.malloc.hugetlb=1
- No fim, o mimalloc foi o mais rápido
- glibc padrão: média de 4,123 s
- TCMalloc via preload: média de 4,130 s
- jemalloc via preload: média de 3,510 s
- mimalloc via preload: média de 3,154 s
- Ativar THP beneficiou o alocador glibc, o jemalloc e o mimalloc
THP + mimallocficou 31% mais rápido queTHP + glibce 48% mais rápido que o padrão do glibc
Resultado final da build com linkagem de mimalloc
- Como o preload dinâmico foi considerado menos ideal do ponto de vista de desempenho, na etapa final o
jqfoi recompilado com linkagem demimalloc - A build final foi 1,90x mais rápida que o pacote binário do Ubuntu
jqrecompilado commimalloc: média de 2,428 s- Ubuntu
/usr/bin/jq: média de 4,606 s - Cada benchmark foi executado 10 vezes
- A mesma build também foi usada em outra aplicação
- Foram processados 2,2 GB de JSON espalhados por 13.000 arquivos
- O paralelismo foi feito com
rush jqrecompilado commimalloc: 0,755 sjqdo pacote Ubuntu: 1,424 s
- Também nesse caso separado, o ganho de velocidade ficou perto de 2x
1 comentários
Opiniões no Hacker News
Clickbait do tipo “recompilar um pacote do Ubuntu e trocar o alocador de memória para deixá-lo 90% mais rápido” dá vontade de dar um soco através do TCP/IP. Era exatamente um pacote, e parte da melhoria nem vinha da recompilação
Ainda assim, já inseri o jemalloc em um programa com
LD_PRELOADpara trocar a implementação demalloc, e o resultado foi bem bom. Não medi o desempenho, mas o uso de memória da aplicação se estabilizou e um problema que parecia vazamento de memória também foi resolvido. Na prática, é bem provável que fosse fragmentação de memória domallocpadrão, e não um problema da própria aplicaçãofree(), externamente a memória não é realmente liberada, salvo em circunstâncias excepcionaisQuanto mais threads e núcleos de CPU, pior fica esse problema. Uma solução fácil é definir a variável de ambiente “mágica”
MALLOC_ARENA_MAX=2para limitar a quantidade de caches. Outra forma é a aplicação chamarmalloc_trim()periodicamente para esvaziar os caches, mas isso exige alteração no código-fontehttps://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
Por outro lado, ainda bem que não compila com
-O3, que pode ter bugs em potencial. Pode ser bom para algumas partes em que desempenho é importante, mas eu não gostaria de compilar o sistema inteiro com-O3Há muito tempo comecei a compilar o Mozilla e meu kernel Linux do meu jeito, e geralmente conseguia um ganho de desempenho razoável. Todo o propósito da distribuição Gentoo Linux, por exemplo, também é o ganho de desempenho que se pode obter ao compilar tudo a partir do código-fonte com otimizações
jq,grep,ffmpegeocrmypdf, e esses utilitários Unix gerais muitas vezes são feitos como alvos de compilação genéricos, não para uma aplicação específicaEngenharia é compromisso. No texto, a maior parte dos ganhos vem da especialização do alocador de memória. É preciso lembrar que alguns projetos são multithread, alocam em uma thread, escrevem dados em outra e às vezes liberam em uma terceira
O alocador precisa lidar com isso, então uma melhoria de velocidade em um projeto pode virar uma falha em outro. A estratégia de realocação também é um problema. Alguns programas alocam antecipadamente e nunca mais encostam em
malloc, enquanto outros liberam e obtêm memória de novo continuamente. Também importa quão bem ele lida com fragmentação e se o tempo de execução é de 10 segundos ou 10 anos. Às vezes, a escolha do alocador é a diferença entre estabilidade de longo prazo e velocidade de curto prazoExperimentei vários alocadores quando estava criando um editor de vídeo que testava vídeos 4K e fazia cache de frames. Com 32 MB por frame e 60 fps, isso dá quase 2 GB por segundo por trilha. Você esbarra rapidamente nos limites do alocador e percebe que, no mínimo, o alocador padrão da glibc é o melhor em estabilidade de longo prazo. Mas, em benchmarks curtos, é o mais lento
Claro que a magnitude do ganho de velocidade pode variar, mas os benchmarks em geral mostram uma melhora ampla. Se isso é significativo para uma aplicação específica é uma questão completamente diferente. Quanto a falhas, todos eles são alocadores multithread de uso geral, então não se comportam de forma diferente da glibc; bugs também podem existir igualmente na glibc
DEFAULT_MMAP_THRESHOLD_MAXe, em plataformas de 64 bits, isso é 32 MiB, então não dá para convencer a glibc a fazer cache disso, como documentado no manual demalloptA cada vez, ela pede memória diretamente ao kernel com
mmape a devolve communmap. Essas chamadas de sistema são um pouco lentas e, no meu caso, o custo de gerar page faults em cada página de memória no primeiro acesso é lento demais para atingir a meta de desempenho. A solução é realmente simples: usar uma lista livre própria em cima de um alocador de uso geral ou demmap, apenas para frames de vídeo. Como alocações exatamente do mesmo tamanho se repetem de forma muito estável, isso funciona bem[1] No formato UYVY, é um pouco menos de 64 MiB; no formato I420, um pouco menos de 48 MiB
Só que, ao ler este comentário acima, fiquei preocupado de talvez ter entendido o texto completamente errado. Pelo tom, parece dizer que o que o gist propõe nunca deveria ser feito e que é uma sugestão terrível que ignora toda essa complexidade. Você pode me ajudar a entender se o gist original é um bom texto, se tem pontos válidos ou se não tem valor nenhum? Até ver este comentário, eu achava que tinha valor, mas percebi que não sou inteligente o bastante para distinguir
Quando você usa o algoritmo de compressão adequado para os dados, as pessoas explicam por que você é burro e deveria ter usado outro algoritmo. Recentemente, precisei comprimir certas strings JSON longas para colocá-las no Dynamo e testei exaustivamente todos os algoritmos populares; o Brotli ficou muito à frente. Ainda assim, isso não impediu cada pessoa que passava de dizer que zlib seria melhor. Às vezes é bem cansativo
Gentoo Linux é, na prática, uma distribuição feita para esse tipo de pessoa, permitindo otimizar sua máquina Linux para o seu próprio uso
Depois da configuração inicial, ela é bem simples e fácil de usar. Lembro que fiz muitos amigos no canal de Gentoo Linux do Matrix; foi uma época divertida
https://www.gentoo.org/
Um fato curioso: o ChromeOS inicial era basicamente uma instalação customizada do Gentoo Linux. Não sei se ele ainda usa Gentoo Linux internamente
Uso Gentoo há 20 anos, mas nunca usei por causa de desempenho. O Gentoo é excelente quando você sabe como quer que as coisas funcionem e ajuda você a chegar lá
O objetivo do Gentoo é ter um sistema operacional em que todos os programas sejam compilados a partir do código-fonte, em vez de usar pacotes binários pré-compilados. Isso permite melhorias avançadas de velocidade e personalização, mas também significa que até os componentes mais básicos, como o kernel, precisam ser compilados a partir do código-fonte. Na comunidade Linux, ele é conhecido como um sistema operacional muito complexo por causa do processo de instalação trabalhoso. Uma instalação padrão do Gentoo inicializa direto em um prompt de comando, e o usuário precisa particionar o disco manualmente, baixar e extrair um pacote chamado “Stage 3 tarball” e construir o sistema instalando pacotes manualmente. Usuários novos ou inexperientes muitas vezes não sabem o que fazer quando entram no instalador e não há interface gráfica. Integrantes do /g/ frequentemente exageram o valor do Gentoo para enganar novos usuários e fazê-los tentar instalá-lo
Pode haver um ou outro tropeço, mas se você tiver experiência geral suficiente com Linux, é bem provável que consiga entrar nas receitas de build, corrigir o que for preciso, fazer funcionar como deseja e contribuir as correções upstream. Isso é resultado de um foco obsessivo em minimalismo e de evitar qualquer tipo de engenharia excessiva. Também é algo de que senti falta durante todo o tempo em que usei Gentoo. No Gentoo, eu sempre acabava mexendo em flags USE e máscaras de pacotes de um jeito que não ajudava muito outros usuários. O sistema de build era tão complexo que, ao longo dos anos, era difícil demais dominá-lo de fato, corrigir problemas na raiz e contribuir upstream. O Void também pode ser uma base ideal quando você não quer compilar o sistema inteiro a partir do código-fonte, mas quer misturar binários fornecidos pela distribuição com pacotes compilados diretamente do código-fonte por você
Depois migrei para o ArchLinux, e para mim ele foi, em geral, suficiente. Se você usa um processador relativamente padrão, acho que o Gentoo não oferece uma vantagem tão grande
Fazendo isso, você fica de fora não só das atualizações de segurança do
jq, mas também das da onigurama, a dependência de parsing de expressões regulares. Já houve uma atualização de segurança da onigurama no passado e, se algo assim acontecer de novo, você pode ficar vulnerável. Ojqé frequentemente usado para parsear JSON não confiávelA descrição era “Atualização de segurança: corrige várias desreferências de ponteiros inválidos, corrupção de memória por escrita fora dos limites e estouro de buffer na pilha”, e o caso dizia respeito a CVE-2017-9224, CVE-2017-9226, CVE-2017-9227, CVE-2017-9228 e CVE-2017-9229
libonig5, e será atualizada normalmenteNão quero diminuir o valor do sistema de CVEs, mas também é difícil negar que há uma grande diferença de impacto real entre as descobertas
Faz algum tempo que não lido com esse tipo de coisa, mas, pelo que me lembro, no momento em que você passa das flags usadas pelos desenvolvedores upstream, acaba comprando bugs estranhos e uma enorme indiferença quando esses bugs aparecem. Estou falando aqui dos desenvolvedores upstream, não dos empacotadores da distribuição
Nunca usei um
mallocque não fosse o da libc, mas imagino que o mesmo princípio se apliqueMas, se todo mundo fizer isso, vira uma monocultura, e monoculturas são frágeis e ruins. O código só se torna minimamente robusto porque é compilado em contextos diferentes: outras plataformas, compiladores, opções, bibliotecas etc. Um bug que a maioria não encontrou porque uma plataforma ou flags de build, por acaso, passaram bem ao lado da armadilha ainda é um bug, e é melhor para o código que ele seja descoberto e corrigido. Como indivíduos, todos nos beneficiamos quando o código, no geral, fica mais robusto em vez de frágil
Claro, eu também achava que
-march=nativeera a principal melhoria que eu via, mas este texto mostra que não é necessariamente assim. Aplicações que usam ponto flutuante também parecem ter maior chance de ter arestas ásperasEstá mais para recompilar com outro alocador que gera bons benchmarks em um fluxo de trabalho específico
mallocda glibc. O fato de as distribuições continuarem usando omallocda glibc em vez de mimalloc ou jemalloc é praticamente negligência profissionalmallocda glibc é bom?Tenho curiosidade de saber como o desempenho se compara ao deste clone de
jqbaseado em Rustcargo install --locked jaqPara ativar otimizações voltadas a uma família específica de CPU, também dá para adicionar
RUSTFLAGS="-C target-cpu=native". Ocargo installé um recurso subestimado do Rust, perfeito para o tipo de uso descrito no texto. Como a ferramenta é compilada a partir do código-fonte, você pode optar por recursos ou instruções específicos da plataforma que normalmente não seriam incluídos em binários por compatibilidade com CPUs antigas. Também não é preciso clonar o repositório nem descobrir como compilar; isso simplesmente vem juntojaq[1] eyq[2] são minhas escolhas sempre que estou usandojqe preciso de um ganho de desempenho rápido e fácil[1] https://github.com/01mf02/jaq
[2] https://github.com/mikefarah/yq
jqegojq, usando minha solução emjqpara o AoC 2022 day 13 como testehttps://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
Ele ainda fica atrás dos dois
cargo installtambém tem a flag--git, que permite especificar a URL do repositório. Dá para usar quando não há pacote público ou quando você quer o commit mais recente que ainda não foi lançadoJá usei isso várias vezes no passado, especialmente como uma forma fácil de instalar rapidamente uma ferramenta pessoal feita às pressas depois de enviá-la para um repositório, sem precisar criar um processo de release, copiar binários manualmente para minhas máquinas pessoais ou rastrear o commit exato usado na build
Se for realmente fazer isso, basta mandar o Ubuntu baixar o pacote-fonte exato que ele quer. Neste caso, use
apt-get source jqDepois é só entrar no pacote e recompilar à vontade. Também dá para reempacotar para distribuição ou arquivamento. Assim você obtém algo muito mais próximo do Ubuntu upstream, em vez de uma pilha de erros estranhos e inconsistências
O título é enganoso. Quer dizer 90% do tempo que ficou mais rápido; na prática, é cerca de 45% mais rápido
É um pouco interessante, se você se importa com a forma como usamos a linguagem. Também poderíamos dizer que ele faz 90% mais trabalho no mesmo tempo, o que bate com outras unidades de velocidade que usamos com frequência, como milhas por hora, palavras por minuto, bits por segundo. Mas em desempenho de computadores existe a convenção de medir o tempo necessário para uma quantidade fixa de trabalho. Provavelmente porque, em geral, a quantidade de trabalho é fixa e o que muda é o tempo de espera. No caso deste post, é exatamente isso, por isso o tempo acaba ficando no numerador. O texto em si é muito interessante e bem escrito, mas não é 90% mais rápido
Além disso, se fosse usar unidade de tempo, eu não usaria a palavra “faster”. “45% less time” e “45% faster” são afirmações muito diferentes, e ambas fazem sentido dentro e fora da programação
Quando se diz “reduzi algo em N%”, normalmente se presume que esses N% são do próprio objeto reduzido, não de outro valor
Ao ler que uma mudança tão simples pode trazer um grande ganho de velocidade, a primeira coisa que me veio à cabeça foi que seria preciso avisar os autores do jq. Pode haver alguma armadilha a observar, ou eles podem testar e depois torná-lo mais rápido para todos
Seja qual for o resultado, parece útil simplesmente informar. Mas o texto nem parece considerar essa opção, e também não a vi nos comentários daqui. Estou deixando passar alguma coisa?
Não sei se lá o alocador da glibc também é o padrão
https://en.m.wikipedia.org/wiki/Clear_Linux_OS